diff --git a/blocksuite/affine/all/package.json b/blocksuite/affine/all/package.json new file mode 100644 index 0000000000..b3d200a733 --- /dev/null +++ b/blocksuite/affine/all/package.json @@ -0,0 +1,100 @@ +{ + "name": "@blocksuite/affine", + "description": "BlockSuite for Affine", + "type": "module", + "scripts": { + "build": "tsc --build --verbose", + "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": "MIT", + "dependencies": { + "@blocksuite/block-std": "workspace:*", + "@blocksuite/blocks": "workspace:*", + "@blocksuite/global": "workspace:*", + "@blocksuite/inline": "workspace:*", + "@blocksuite/presets": "workspace:*", + "@blocksuite/store": "workspace:*" + }, + "exports": { + ".": "./src/index.ts", + "./effects": "./src/effects.ts", + "./block-std": "./src/block-std/index.ts", + "./block-std/gfx": "./src/block-std/gfx.ts", + "./global": "./src/global/index.ts", + "./global/utils": "./src/global/utils.ts", + "./global/env": "./src/global/env.ts", + "./global/exceptions": "./src/global/exceptions.ts", + "./global/di": "./src/global/di.ts", + "./global/types": "./src/global/types.ts", + "./store": "./src/store/index.ts", + "./inline": "./src/inline/index.ts", + "./inline/consts": "./src/inline/consts.ts", + "./inline/types": "./src/inline/types.ts", + "./presets": "./src/presets/index.ts", + "./blocks": "./src/blocks/index.ts", + "./blocks/schemas": "./src/blocks/schemas.ts" + }, + "typesVersions": { + "*": { + "effects": [ + "dist/effects.d.ts" + ], + "block-std": [ + "dist/block-std/index.d.ts" + ], + "block-std/gfx": [ + "dist/block-std/gfx.d.ts" + ], + "global": [ + "dist/global/index.d.ts" + ], + "global/utils": [ + "dist/global/utils.d.ts" + ], + "global/env": [ + "dist/global/env.d.ts" + ], + "global/exceptions": [ + "dist/global/exceptions.d.ts" + ], + "global/di": [ + "dist/global/di.d.ts" + ], + "global/types": [ + "dist/global/types.d.ts" + ], + "store": [ + "dist/store/index.d.ts" + ], + "inline": [ + "dist/inline/index.d.ts" + ], + "inline/consts": [ + "dist/inline/consts.d.ts" + ], + "inline/types": [ + "dist/inline/types.d.ts" + ], + "presets": [ + "dist/presets/index.d.ts" + ], + "blocks": [ + "dist/blocks/index.d.ts" + ], + "blocks/schemas": [ + "dist/blocks/schemas.d.ts" + ] + } + }, + "files": [ + "src", + "dist", + "!src/__tests__", + "!dist/__tests__" + ] +} diff --git a/blocksuite/affine/all/src/block-std/gfx.ts b/blocksuite/affine/all/src/block-std/gfx.ts new file mode 100644 index 0000000000..71b416e051 --- /dev/null +++ b/blocksuite/affine/all/src/block-std/gfx.ts @@ -0,0 +1 @@ +export * from '@blocksuite/block-std/gfx'; diff --git a/blocksuite/affine/all/src/block-std/index.ts b/blocksuite/affine/all/src/block-std/index.ts new file mode 100644 index 0000000000..82982c0b36 --- /dev/null +++ b/blocksuite/affine/all/src/block-std/index.ts @@ -0,0 +1 @@ +export * from '@blocksuite/block-std'; diff --git a/blocksuite/affine/all/src/blocks/index.ts b/blocksuite/affine/all/src/blocks/index.ts new file mode 100644 index 0000000000..741b265f86 --- /dev/null +++ b/blocksuite/affine/all/src/blocks/index.ts @@ -0,0 +1 @@ +export * from '@blocksuite/blocks'; diff --git a/blocksuite/affine/all/src/blocks/schemas.ts b/blocksuite/affine/all/src/blocks/schemas.ts new file mode 100644 index 0000000000..6412423790 --- /dev/null +++ b/blocksuite/affine/all/src/blocks/schemas.ts @@ -0,0 +1 @@ +export * from '@blocksuite/blocks/schemas'; diff --git a/blocksuite/affine/all/src/effects.ts b/blocksuite/affine/all/src/effects.ts new file mode 100644 index 0000000000..7c9259ed6b --- /dev/null +++ b/blocksuite/affine/all/src/effects.ts @@ -0,0 +1,7 @@ +import { effects as blocksEffects } from '@blocksuite/blocks/effects'; +import { effects as presetsEffects } from '@blocksuite/presets/effects'; + +export function effects() { + blocksEffects(); + presetsEffects(); +} diff --git a/blocksuite/affine/all/src/global/di.ts b/blocksuite/affine/all/src/global/di.ts new file mode 100644 index 0000000000..9f05e86485 --- /dev/null +++ b/blocksuite/affine/all/src/global/di.ts @@ -0,0 +1 @@ +export * from '@blocksuite/global/di'; diff --git a/blocksuite/affine/all/src/global/env.ts b/blocksuite/affine/all/src/global/env.ts new file mode 100644 index 0000000000..065a13bed7 --- /dev/null +++ b/blocksuite/affine/all/src/global/env.ts @@ -0,0 +1 @@ +export * from '@blocksuite/global/env'; diff --git a/blocksuite/affine/all/src/global/exceptions.ts b/blocksuite/affine/all/src/global/exceptions.ts new file mode 100644 index 0000000000..ece97f76dc --- /dev/null +++ b/blocksuite/affine/all/src/global/exceptions.ts @@ -0,0 +1 @@ +export * from '@blocksuite/global/exceptions'; diff --git a/blocksuite/affine/all/src/global/index.ts b/blocksuite/affine/all/src/global/index.ts new file mode 100644 index 0000000000..83a535bd9f --- /dev/null +++ b/blocksuite/affine/all/src/global/index.ts @@ -0,0 +1 @@ +export * from '@blocksuite/global'; diff --git a/blocksuite/affine/all/src/global/types.ts b/blocksuite/affine/all/src/global/types.ts new file mode 100644 index 0000000000..e99605063d --- /dev/null +++ b/blocksuite/affine/all/src/global/types.ts @@ -0,0 +1 @@ +export * from '@blocksuite/global/types'; diff --git a/blocksuite/affine/all/src/global/utils.ts b/blocksuite/affine/all/src/global/utils.ts new file mode 100644 index 0000000000..522b354372 --- /dev/null +++ b/blocksuite/affine/all/src/global/utils.ts @@ -0,0 +1 @@ +export * from '@blocksuite/global/utils'; diff --git a/blocksuite/affine/all/src/index.ts b/blocksuite/affine/all/src/index.ts new file mode 100644 index 0000000000..cb0ff5c3b5 --- /dev/null +++ b/blocksuite/affine/all/src/index.ts @@ -0,0 +1 @@ +export {}; diff --git a/blocksuite/affine/all/src/inline/consts.ts b/blocksuite/affine/all/src/inline/consts.ts new file mode 100644 index 0000000000..d5ff75733a --- /dev/null +++ b/blocksuite/affine/all/src/inline/consts.ts @@ -0,0 +1 @@ +export * from '@blocksuite/inline/consts'; diff --git a/blocksuite/affine/all/src/inline/index.ts b/blocksuite/affine/all/src/inline/index.ts new file mode 100644 index 0000000000..7f498783ba --- /dev/null +++ b/blocksuite/affine/all/src/inline/index.ts @@ -0,0 +1 @@ +export * from '@blocksuite/inline'; diff --git a/blocksuite/affine/all/src/inline/types.ts b/blocksuite/affine/all/src/inline/types.ts new file mode 100644 index 0000000000..da7ad699c8 --- /dev/null +++ b/blocksuite/affine/all/src/inline/types.ts @@ -0,0 +1 @@ +export * from '@blocksuite/inline/types'; diff --git a/blocksuite/affine/all/src/presets/index.ts b/blocksuite/affine/all/src/presets/index.ts new file mode 100644 index 0000000000..d31f2c2c98 --- /dev/null +++ b/blocksuite/affine/all/src/presets/index.ts @@ -0,0 +1 @@ +export * from '@blocksuite/presets'; diff --git a/blocksuite/affine/all/src/store/index.ts b/blocksuite/affine/all/src/store/index.ts new file mode 100644 index 0000000000..edab995ffc --- /dev/null +++ b/blocksuite/affine/all/src/store/index.ts @@ -0,0 +1,5 @@ +/* eslint-disable @typescript-eslint/no-restricted-imports */ + +// oxlint-disable-next-line +// @ts-ignore FIXME: typecheck error +export * from '@blocksuite/store'; diff --git a/blocksuite/affine/all/tsconfig.json b/blocksuite/affine/all/tsconfig.json new file mode 100644 index 0000000000..c5e070d612 --- /dev/null +++ b/blocksuite/affine/all/tsconfig.json @@ -0,0 +1,29 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "rootDir": "./src/", + "outDir": "./dist/", + "noEmit": false + }, + "include": ["./src"], + "references": [ + { + "path": "../../framework/global" + }, + { + "path": "../../framework/store" + }, + { + "path": "../../framework/block-std" + }, + { + "path": "../../framework/inline" + }, + { + "path": "../../blocks" + }, + { + "path": "../../presets" + } + ] +} diff --git a/blocksuite/affine/all/typedoc.json b/blocksuite/affine/all/typedoc.json new file mode 100644 index 0000000000..101e923dba --- /dev/null +++ b/blocksuite/affine/all/typedoc.json @@ -0,0 +1,4 @@ +{ + "extends": ["../../../typedoc.base.json"], + "entryPoints": ["src/index.ts"] +} diff --git a/blocksuite/affine/all/vitest.config.ts b/blocksuite/affine/all/vitest.config.ts new file mode 100644 index 0000000000..0be7ff0fec --- /dev/null +++ b/blocksuite/affine/all/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/affine', + }, + /** + * 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/blocksuite/affine/block-embed/package.json b/blocksuite/affine/block-embed/package.json new file mode 100644 index 0000000000..f68bc94507 --- /dev/null +++ b/blocksuite/affine/block-embed/package.json @@ -0,0 +1,43 @@ +{ + "name": "@blocksuite/affine-block-embed", + "description": "Embed blocks for BlockSuite.", + "type": "module", + "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": "MIT", + "dependencies": { + "@blocksuite/affine-block-surface": "workspace:*", + "@blocksuite/affine-components": "workspace:*", + "@blocksuite/affine-model": "workspace:*", + "@blocksuite/affine-shared": "workspace:*", + "@blocksuite/block-std": "workspace:*", + "@blocksuite/global": "workspace:*", + "@blocksuite/icons": "^2.1.75", + "@blocksuite/inline": "workspace:*", + "@blocksuite/store": "workspace:*", + "@floating-ui/dom": "^1.6.10", + "@lit/context": "^1.1.2", + "@preact/signals-core": "^1.8.0", + "@toeverything/theme": "^1.1.1", + "lit": "^3.2.0", + "minimatch": "^10.0.1", + "zod": "^3.23.8" + }, + "exports": { + ".": "./src/index.ts", + "./effects": "./src/effects.ts" + }, + "files": [ + "src", + "dist", + "!src/__tests__", + "!dist/__tests__" + ] +} diff --git a/blocksuite/affine/block-embed/src/common/adapters/html.ts b/blocksuite/affine/block-embed/src/common/adapters/html.ts new file mode 100644 index 0000000000..c8a9da9e6f --- /dev/null +++ b/blocksuite/affine/block-embed/src/common/adapters/html.ts @@ -0,0 +1,66 @@ +import type { BlockHtmlAdapterMatcher } from '@blocksuite/affine-shared/adapters'; + +export function createEmbedBlockHtmlAdapterMatcher( + flavour: string, + { + toMatch = () => false, + fromMatch = o => o.node.flavour === flavour, + toBlockSnapshot = {}, + fromBlockSnapshot = { + enter: (o, context) => { + const { walkerContext } = context; + // Parse as link + if ( + typeof o.node.props.title !== 'string' || + typeof o.node.props.url !== 'string' + ) { + return; + } + + walkerContext + .openNode( + { + type: 'element', + tagName: 'div', + properties: { + className: ['affine-paragraph-block-container'], + }, + children: [], + }, + 'children' + ) + .openNode( + { + type: 'element', + tagName: 'a', + properties: { + href: o.node.props.url, + }, + children: [ + { + type: 'text', + value: o.node.props.title, + }, + ], + }, + 'children' + ) + .closeNode() + .closeNode(); + }, + }, + }: { + toMatch?: BlockHtmlAdapterMatcher['toMatch']; + fromMatch?: BlockHtmlAdapterMatcher['fromMatch']; + toBlockSnapshot?: BlockHtmlAdapterMatcher['toBlockSnapshot']; + fromBlockSnapshot?: BlockHtmlAdapterMatcher['fromBlockSnapshot']; + } = Object.create(null) +): BlockHtmlAdapterMatcher { + return { + flavour, + toMatch, + fromMatch, + toBlockSnapshot, + fromBlockSnapshot, + }; +} diff --git a/blocksuite/affine/block-embed/src/common/adapters/markdown.ts b/blocksuite/affine/block-embed/src/common/adapters/markdown.ts new file mode 100644 index 0000000000..450c0482ff --- /dev/null +++ b/blocksuite/affine/block-embed/src/common/adapters/markdown.ts @@ -0,0 +1,58 @@ +import type { BlockMarkdownAdapterMatcher } from '@blocksuite/affine-shared/adapters'; + +export function createEmbedBlockMarkdownAdapterMatcher( + flavour: string, + { + toMatch = () => false, + fromMatch = o => o.node.flavour === flavour, + toBlockSnapshot = {}, + fromBlockSnapshot = { + enter: (o, context) => { + const { walkerContext } = context; + // Parse as link + if ( + typeof o.node.props.title !== 'string' || + typeof o.node.props.url !== 'string' + ) { + return; + } + walkerContext + .openNode( + { + type: 'paragraph', + children: [], + }, + 'children' + ) + .openNode( + { + type: 'link', + url: o.node.props.url, + children: [ + { + type: 'text', + value: o.node.props.title, + }, + ], + }, + 'children' + ) + .closeNode() + .closeNode(); + }, + }, + }: { + toMatch?: BlockMarkdownAdapterMatcher['toMatch']; + fromMatch?: BlockMarkdownAdapterMatcher['fromMatch']; + toBlockSnapshot?: BlockMarkdownAdapterMatcher['toBlockSnapshot']; + fromBlockSnapshot?: BlockMarkdownAdapterMatcher['fromBlockSnapshot']; + } = {} +): BlockMarkdownAdapterMatcher { + return { + flavour, + toMatch, + fromMatch, + toBlockSnapshot, + fromBlockSnapshot, + }; +} diff --git a/blocksuite/affine/block-embed/src/common/adapters/plain-text.ts b/blocksuite/affine/block-embed/src/common/adapters/plain-text.ts new file mode 100644 index 0000000000..dabcccd0ab --- /dev/null +++ b/blocksuite/affine/block-embed/src/common/adapters/plain-text.ts @@ -0,0 +1,40 @@ +import type { BlockPlainTextAdapterMatcher } from '@blocksuite/affine-shared/adapters'; + +export function createEmbedBlockPlainTextAdapterMatcher( + flavour: string, + { + toMatch = () => false, + fromMatch = o => o.node.flavour === flavour, + toBlockSnapshot = {}, + fromBlockSnapshot = { + enter: (o, context) => { + const { textBuffer } = context; + // Parse as link + if ( + typeof o.node.props.title !== 'string' || + typeof o.node.props.url !== 'string' + ) { + return; + } + const buffer = `[${o.node.props.title}](${o.node.props.url})`; + if (buffer.length > 0) { + textBuffer.content += buffer; + textBuffer.content += '\n'; + } + }, + }, + }: { + toMatch?: BlockPlainTextAdapterMatcher['toMatch']; + fromMatch?: BlockPlainTextAdapterMatcher['fromMatch']; + toBlockSnapshot?: BlockPlainTextAdapterMatcher['toBlockSnapshot']; + fromBlockSnapshot?: BlockPlainTextAdapterMatcher['fromBlockSnapshot']; + } = {} +): BlockPlainTextAdapterMatcher { + return { + flavour, + toMatch, + fromMatch, + toBlockSnapshot, + fromBlockSnapshot, + }; +} diff --git a/blocksuite/affine/block-embed/src/common/adapters/utils.ts b/blocksuite/affine/block-embed/src/common/adapters/utils.ts new file mode 100644 index 0000000000..8f2607c106 --- /dev/null +++ b/blocksuite/affine/block-embed/src/common/adapters/utils.ts @@ -0,0 +1,13 @@ +import type { ReferenceParams } from '@blocksuite/affine-model'; +import { TextUtils } from '@blocksuite/affine-shared/adapters'; + +export function generateDocUrl( + docBaseUrl: string, + pageId: string, + params: ReferenceParams +) { + const search = TextUtils.toURLSearchParams(params); + const query = search?.size ? `?${search.toString()}` : ''; + const url = docBaseUrl ? `${docBaseUrl}/${pageId}${query}` : ''; + return url; +} diff --git a/blocksuite/affine/block-embed/src/common/embed-block-element.ts b/blocksuite/affine/block-embed/src/common/embed-block-element.ts new file mode 100644 index 0000000000..f064abf86e --- /dev/null +++ b/blocksuite/affine/block-embed/src/common/embed-block-element.ts @@ -0,0 +1,172 @@ +import { + CaptionedBlockComponent, + SelectedStyle, +} from '@blocksuite/affine-components/caption'; +import type { EmbedCardStyle } from '@blocksuite/affine-model'; +import { + EMBED_CARD_HEIGHT, + EMBED_CARD_MIN_WIDTH, + EMBED_CARD_WIDTH, +} from '@blocksuite/affine-shared/consts'; +import { + DocModeProvider, + DragHandleConfigExtension, +} from '@blocksuite/affine-shared/services'; +import { + captureEventTarget, + convertDragPreviewDocToEdgeless, + convertDragPreviewEdgelessToDoc, +} from '@blocksuite/affine-shared/utils'; +import { type BlockService, isGfxBlockComponent } from '@blocksuite/block-std'; +import type { GfxCompatibleProps } from '@blocksuite/block-std/gfx'; +import type { BlockModel } from '@blocksuite/store'; +import type { TemplateResult } from 'lit'; +import { html } from 'lit'; +import { query } from 'lit/decorators.js'; +import { classMap } from 'lit/directives/class-map.js'; +import { type StyleInfo, styleMap } from 'lit/directives/style-map.js'; + +export const EmbedDragHandleOption = DragHandleConfigExtension({ + flavour: /affine:embed-*/, + edgeless: true, + onDragEnd: props => { + const { state, draggingElements } = props; + if ( + draggingElements.length !== 1 || + draggingElements[0].model.flavour.match(/affine:embed-*/) === null + ) + return false; + + const blockComponent = draggingElements[0] as EmbedBlockComponent; + const isInSurface = isGfxBlockComponent(blockComponent); + const target = captureEventTarget(state.raw.target); + const isTargetEdgelessContainer = + target?.classList.contains('edgeless-container'); + + if (isInSurface) { + const style = blockComponent._cardStyle; + const targetStyle = + style === 'vertical' || style === 'cube' ? 'horizontal' : style; + return convertDragPreviewEdgelessToDoc({ + blockComponent, + style: targetStyle, + ...props, + }); + } else if (isTargetEdgelessContainer) { + const style = blockComponent._cardStyle; + + return convertDragPreviewDocToEdgeless({ + blockComponent, + cssSelector: '.embed-block-container', + width: EMBED_CARD_WIDTH[style], + height: EMBED_CARD_HEIGHT[style], + ...props, + }); + } + + return false; + }, +}); + +export class EmbedBlockComponent< + Model extends BlockModel = BlockModel, + Service extends BlockService = BlockService, + WidgetName extends string = string, +> extends CaptionedBlockComponent { + private _fetchAbortController = new AbortController(); + + _cardStyle: EmbedCardStyle = 'horizontal'; + + /** + * The actual rendered scale of the embed card. + * By default, it is set to 1. + */ + protected _scale = 1; + + blockDraggable = true; + + /** + * The style of the embed card. + * You can use this to change the height and width of the card. + * By default, the height and width are set to `_cardHeight` and `_cardWidth` respectively. + */ + protected embedContainerStyle: StyleInfo = {}; + + renderEmbed = (content: () => TemplateResult) => { + if ( + this._cardStyle === 'horizontal' || + this._cardStyle === 'horizontalThin' || + this._cardStyle === 'list' + ) { + this.style.display = 'block'; + + const mode = this.std.get(DocModeProvider).getEditorMode(); + if (mode === 'edgeless') { + this.style.minWidth = `${EMBED_CARD_MIN_WIDTH}px`; + } + } + + const selected = !!this.selected?.is('block'); + return html` +
+ ${content()} +
+ `; + }; + + /** + * The height of the current embed card. Changes based on the card style. + */ + get _cardHeight() { + return EMBED_CARD_HEIGHT[this._cardStyle]; + } + + /** + * The width of the current embed card. Changes based on the card style. + */ + get _cardWidth() { + return EMBED_CARD_WIDTH[this._cardStyle]; + } + + get fetchAbortController() { + return this._fetchAbortController; + } + + override connectedCallback() { + super.connectedCallback(); + + if (this._fetchAbortController.signal.aborted) + this._fetchAbortController = new AbortController(); + + this.contentEditable = 'false'; + } + + override disconnectedCallback(): void { + super.disconnectedCallback(); + this._fetchAbortController.abort(); + } + + protected override accessor blockContainerStyles: StyleInfo | undefined = { + margin: '18px 0', + }; + + @query('.embed-block-container') + protected accessor embedBlock!: HTMLDivElement; + + override accessor selectedStyle = SelectedStyle.Border; + + override accessor useCaptionEditor = true; + + override accessor useZeroWidth = true; +} diff --git a/blocksuite/affine/block-embed/src/common/embed-note-content-styles.ts b/blocksuite/affine/block-embed/src/common/embed-note-content-styles.ts new file mode 100644 index 0000000000..2cc4c5e6da --- /dev/null +++ b/blocksuite/affine/block-embed/src/common/embed-note-content-styles.ts @@ -0,0 +1,80 @@ +import { css } from 'lit'; + +export const embedNoteContentStyles = css` + .affine-embed-doc-content-note-blocks affine-divider, + .affine-embed-doc-content-note-blocks affine-divider > * { + margin-top: 0px !important; + margin-bottom: 0px !important; + padding-top: 8px; + padding-bottom: 8px; + } + .affine-embed-doc-content-note-blocks affine-paragraph, + .affine-embed-doc-content-note-blocks affine-list { + margin-top: 4px !important; + margin-bottom: 4px !important; + padding: 0 2px; + } + .affine-embed-doc-content-note-blocks affine-paragraph *, + .affine-embed-doc-content-note-blocks affine-list * { + margin-top: 0px !important; + margin-bottom: 0px !important; + padding-top: 0; + padding-bottom: 0; + line-height: 20px; + font-size: var(--affine-font-xs); + font-weight: 400; + } + .affine-embed-doc-content-note-blocks affine-list .affine-list-block__prefix { + height: 20px; + } + .affine-embed-doc-content-note-blocks affine-paragraph .quote { + padding-left: 15px; + padding-top: 8px; + padding-bottom: 8px; + } + .affine-embed-doc-content-note-blocks affine-paragraph:has(.h1), + .affine-embed-doc-content-note-blocks affine-paragraph:has(.h2), + .affine-embed-doc-content-note-blocks affine-paragraph:has(.h3), + .affine-embed-doc-content-note-blocks affine-paragraph:has(.h4), + .affine-embed-doc-content-note-blocks affine-paragraph:has(.h5), + .affine-embed-doc-content-note-blocks affine-paragraph:has(.h6) { + margin-top: 6px !important; + margin-bottom: 4px !important; + padding: 0 2px; + } + .affine-embed-doc-content-note-blocks affine-paragraph:has(.h1) *, + .affine-embed-doc-content-note-blocks affine-paragraph:has(.h2) *, + .affine-embed-doc-content-note-blocks affine-paragraph:has(.h3) *, + .affine-embed-doc-content-note-blocks affine-paragraph:has(.h4) *, + .affine-embed-doc-content-note-blocks affine-paragraph:has(.h5) *, + .affine-embed-doc-content-note-blocks affine-paragraph:has(.h6) * { + margin-top: 0px !important; + margin-bottom: 0px !important; + padding-top: 0; + padding-bottom: 0; + line-height: 20px; + font-size: var(--affine-font-xs); + font-weight: 600; + } + + .affine-embed-linked-doc-block.horizontal { + affine-paragraph, + affine-list { + margin-top: 0 !important; + margin-bottom: 0 !important; + max-height: 40px; + overflow: hidden; + display: flex; + } + affine-paragraph .quote { + padding-top: 4px; + padding-bottom: 4px; + height: 28px; + } + affine-paragraph .quote::after { + height: 20px; + margin-top: 4px !important; + margin-bottom: 4px !important; + } + } +`; diff --git a/blocksuite/affine/block-embed/src/common/insert-embed-card.ts b/blocksuite/affine/block-embed/src/common/insert-embed-card.ts new file mode 100644 index 0000000000..ba887f9fc5 --- /dev/null +++ b/blocksuite/affine/block-embed/src/common/insert-embed-card.ts @@ -0,0 +1,81 @@ +import { SurfaceBlockComponent } from '@blocksuite/affine-block-surface'; +import type { EmbedCardStyle } from '@blocksuite/affine-model'; +import { + EMBED_CARD_HEIGHT, + EMBED_CARD_WIDTH, +} from '@blocksuite/affine-shared/consts'; +import type { BlockStdScope } from '@blocksuite/block-std'; +import { Bound, Vec } from '@blocksuite/global/utils'; + +interface EmbedCardProperties { + flavour: string; + targetStyle: EmbedCardStyle; + props: Record; +} + +export function insertEmbedCard( + std: BlockStdScope, + properties: EmbedCardProperties +) { + const { host } = std; + const { flavour, targetStyle, props } = properties; + const selectionManager = host.selection; + + let blockId: string | undefined; + const textSelection = selectionManager.find('text'); + const blockSelection = selectionManager.find('block'); + const surfaceSelection = selectionManager.find('surface'); + if (textSelection) { + blockId = textSelection.blockId; + } else if (blockSelection) { + blockId = blockSelection.blockId; + } else if (surfaceSelection && surfaceSelection.editing) { + blockId = surfaceSelection.blockId; + } + + if (blockId) { + const block = host.view.getBlock(blockId); + if (!block) return; + const parent = host.doc.getParent(block.model); + if (!parent) return; + const index = parent.children.indexOf(block.model); + host.doc.addBlock(flavour as never, props, parent, index + 1); + } else { + const rootId = std.doc.root?.id; + if (!rootId) return; + const edgelessRoot = std.view.getBlock(rootId); + if (!edgelessRoot) return; + + // @ts-expect-error TODO: fix after edgeless refactor + edgelessRoot.service.viewport.smoothZoom(1); + // @ts-expect-error TODO: fix after edgeless refactor + const surfaceBlock = edgelessRoot.surface; + if (!(surfaceBlock instanceof SurfaceBlockComponent)) return; + const center = Vec.toVec(surfaceBlock.renderer.viewport.center); + // @ts-expect-error TODO: fix after edgeless refactor + const cardId = edgelessRoot.service.addBlock( + flavour, + { + ...props, + xywh: Bound.fromCenter( + center, + EMBED_CARD_WIDTH[targetStyle], + EMBED_CARD_HEIGHT[targetStyle] + ).serialize(), + style: targetStyle, + }, + surfaceBlock.model + ); + + // @ts-expect-error TODO: fix after edgeless refactor + edgelessRoot.service.selection.set({ + elements: [cardId], + editing: false, + }); + + // @ts-expect-error TODO: fix after edgeless refactor + edgelessRoot.tools.setEdgelessTool({ + type: 'default', + }); + } +} diff --git a/blocksuite/affine/block-embed/src/common/link-previewer.ts b/blocksuite/affine/block-embed/src/common/link-previewer.ts new file mode 100644 index 0000000000..45a6c26461 --- /dev/null +++ b/blocksuite/affine/block-embed/src/common/link-previewer.ts @@ -0,0 +1,99 @@ +import type { LinkPreviewData } from '@blocksuite/affine-model'; +import { DEFAULT_LINK_PREVIEW_ENDPOINT } from '@blocksuite/affine-shared/consts'; +import { isAbortError } from '@blocksuite/affine-shared/utils'; +import { BlockSuiteError, ErrorCode } from '@blocksuite/global/exceptions'; + +export type LinkPreviewResponseData = { + url: string; + title?: string; + siteName?: string; + description?: string; + images?: string[]; + mediaType?: string; + contentType?: string; + charset?: string; + videos?: string[]; + favicons?: string[]; +}; + +export class LinkPreviewer { + private _endpoint = DEFAULT_LINK_PREVIEW_ENDPOINT; + + query = async ( + url: string, + signal?: AbortSignal + ): Promise> => { + if ( + (url.startsWith('https://x.com/') || + url.startsWith('https://www.x.com/') || + url.startsWith('https://www.twitter.com/') || + url.startsWith('https://twitter.com/')) && + url.includes('/status/') + ) { + // use api.fxtwitter.com + url = + 'https://api.fxtwitter.com/status/' + /\/status\/(.*)/.exec(url)?.[1]; + try { + const { tweet } = await fetch(url, { signal }).then(res => res.json()); + return { + title: tweet.author.name, + icon: tweet.author.avatar_url, + description: tweet.text, + image: tweet.media?.photos?.[0].url || tweet.author.banner_url, + }; + } catch (e) { + console.error(`Failed to fetch tweet: ${url}`); + console.error(e); + return {}; + } + } else { + const response = await fetch(this._endpoint, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + url, + }), + signal, + }) + .then(r => { + if (!r || !r.ok) { + throw new BlockSuiteError( + ErrorCode.DefaultRuntimeError, + `Failed to fetch link preview: ${url}` + ); + } + return r; + }) + .catch(err => { + if (isAbortError(err)) return null; + console.error(`Failed to fetch link preview: ${url}`); + console.error(err); + return null; + }); + + if (!response) return {}; + + const data: LinkPreviewResponseData = await response.json(); + return { + title: data.title ? this._getStringFromHTML(data.title) : null, + description: data.description + ? this._getStringFromHTML(data.description) + : null, + icon: data.favicons?.[0], + image: data.images?.[0], + }; + } + }; + + setEndpoint = (endpoint: string) => { + this._endpoint = endpoint; + }; + + private _getStringFromHTML(html: string) { + const div = document.createElement('div'); + div.innerHTML = html; + return div.textContent; + } +} diff --git a/blocksuite/affine/block-embed/src/common/render-linked-doc.ts b/blocksuite/affine/block-embed/src/common/render-linked-doc.ts new file mode 100644 index 0000000000..ac40477454 --- /dev/null +++ b/blocksuite/affine/block-embed/src/common/render-linked-doc.ts @@ -0,0 +1,297 @@ +import type { SurfaceBlockModel } from '@blocksuite/affine-block-surface'; +import { + type DocMode, + type ImageBlockModel, + NoteDisplayMode, +} from '@blocksuite/affine-model'; +import { EMBED_CARD_HEIGHT } from '@blocksuite/affine-shared/consts'; +import { matchFlavours, SpecProvider } from '@blocksuite/affine-shared/utils'; +import { BlockStdScope } from '@blocksuite/block-std'; +import { assertExists } from '@blocksuite/global/utils'; +import { + type BlockModel, + BlockViewType, + type Doc, + type Query, +} from '@blocksuite/store'; +import { render, type TemplateResult } from 'lit'; + +import type { EmbedLinkedDocBlockComponent } from '../embed-linked-doc-block/index.js'; +import type { EmbedSyncedDocCard } from '../embed-synced-doc-block/components/embed-synced-doc-card.js'; + +export function renderLinkedDocInCard( + card: EmbedLinkedDocBlockComponent | EmbedSyncedDocCard +) { + const linkedDoc = card.linkedDoc; + assertExists( + linkedDoc, + `Trying to load page ${card.model.pageId} in linked page block, but the page is not found.` + ); + + // eslint-disable-next-line sonarjs/no-collapsible-if + if ('bannerContainer' in card) { + if (card.editorMode === 'page') { + renderPageAsBanner(card).catch(e => { + console.error(e); + card.isError = true; + }); + } + } + + renderNoteContent(card).catch(e => { + console.error(e); + card.isError = true; + }); +} + +async function renderPageAsBanner(card: EmbedSyncedDocCard) { + const linkedDoc = card.linkedDoc; + assertExists( + linkedDoc, + `Trying to load page ${card.model.pageId} in linked page block, but the page is not found.` + ); + + const notes = getNotesFromDoc(linkedDoc); + if (!notes) { + card.isBannerEmpty = true; + return; + } + + const target = notes.flatMap(note => + note.children.filter(child => matchFlavours(child, ['affine:image'])) + )[0]; + + if (target) { + await renderImageAsBanner(card, target); + return; + } + + card.isBannerEmpty = true; +} + +async function renderImageAsBanner( + card: EmbedSyncedDocCard, + image: BlockModel +) { + const sourceId = (image as ImageBlockModel).sourceId; + if (!sourceId) return; + + const storage = card.linkedDoc?.blobSync; + if (!storage) return; + + const blob = await storage.get(sourceId); + if (!blob) return; + + const url = URL.createObjectURL(blob); + const $img = document.createElement('img'); + $img.src = url; + await addCover(card, $img); + + card.isBannerEmpty = false; +} + +async function addCover( + card: EmbedSyncedDocCard, + cover: HTMLElement | TemplateResult<1> +) { + const coverContainer = await card.bannerContainer; + if (!coverContainer) return; + while (coverContainer.firstChild) { + coverContainer.firstChild.remove(); + } + + if (cover instanceof HTMLElement) { + coverContainer.append(cover); + } else { + render(cover, coverContainer); + } +} + +async function renderNoteContent( + card: EmbedLinkedDocBlockComponent | EmbedSyncedDocCard +) { + card.isNoteContentEmpty = true; + + const doc = card.linkedDoc; + assertExists( + doc, + `Trying to load page ${card.model.pageId} in linked page block, but the page is not found.` + ); + + const notes = getNotesFromDoc(doc); + if (!notes) { + return; + } + + const cardStyle = card.model.style; + const isHorizontal = cardStyle === 'horizontal'; + const allowFlavours: (keyof BlockSuite.BlockModels)[] = isHorizontal + ? [] + : ['affine:image']; + + const noteChildren = notes.flatMap(note => + note.children.filter(model => { + if (matchFlavours(model, allowFlavours)) { + return true; + } + return filterTextModel(model); + }) + ); + + if (!noteChildren.length) { + return; + } + + card.isNoteContentEmpty = false; + + const noteContainer = await card.noteContainer; + + if (!noteContainer) { + return; + } + + while (noteContainer.firstChild) { + noteContainer.firstChild.remove(); + } + + const noteBlocksContainer = document.createElement('div'); + noteBlocksContainer.classList.add('affine-embed-doc-content-note-blocks'); + noteBlocksContainer.contentEditable = 'false'; + noteContainer.append(noteBlocksContainer); + + if (isHorizontal) { + // When the card is horizontal, we only render the first block + noteChildren.splice(1); + } else { + // Before rendering, we can not know the height of each block + // But we can limit the number of blocks to render simply by the height of the card + const cardHeight = EMBED_CARD_HEIGHT[cardStyle]; + const minSingleBlockHeight = 20; + const maxBlockCount = Math.floor(cardHeight / minSingleBlockHeight); + if (noteChildren.length > maxBlockCount) { + noteChildren.splice(maxBlockCount); + } + } + const childIds = noteChildren.map(child => child.id); + const ids: string[] = []; + childIds.forEach(block => { + let parent: string | null = block; + while (parent && !ids.includes(parent)) { + ids.push(parent); + parent = doc.blockCollection.crud.getParent(parent); + } + }); + const query: Query = { + mode: 'strict', + match: ids.map(id => ({ id, viewType: BlockViewType.Display })), + }; + const previewDoc = doc.blockCollection.getDoc({ query }); + const previewSpec = SpecProvider.getInstance().getSpec('page:preview'); + const previewStd = new BlockStdScope({ + doc: previewDoc, + extensions: previewSpec.value, + }); + const previewTemplate = previewStd.render(); + const fragment = document.createDocumentFragment(); + render(previewTemplate, fragment); + noteBlocksContainer.append(fragment); + const contentEditableElements = noteBlocksContainer.querySelectorAll( + '[contenteditable="true"]' + ); + contentEditableElements.forEach(element => { + (element as HTMLElement).contentEditable = 'false'; + }); +} + +function filterTextModel(model: BlockModel) { + if (matchFlavours(model, ['affine:paragraph', 'affine:list'])) { + return !!model.text?.toString().length; + } + return false; +} + +export function getNotesFromDoc(doc: Doc) { + const notes = doc.root?.children.filter( + child => + matchFlavours(child, ['affine:note']) && + child.displayMode !== NoteDisplayMode.EdgelessOnly + ); + + if (!notes || !notes.length) { + return null; + } + + return notes; +} + +export function isEmptyDoc(doc: Doc | null, mode: DocMode) { + if (!doc) { + return true; + } + + if (mode === 'page') { + const notes = getNotesFromDoc(doc); + if (!notes || !notes.length) { + return true; + } + return notes.every(note => isEmptyNote(note)); + } else { + const surface = getSurfaceBlock(doc); + if (surface?.elementModels.length || doc.blockSize > 2) { + return false; + } + return true; + } +} + +export function isEmptyNote(note: BlockModel) { + return note.children.every(block => { + return ( + block.flavour === 'affine:paragraph' && + (!block.text || block.text.length === 0) + ); + }); +} + +function getSurfaceBlock(doc: Doc) { + const blocks = doc.getBlocksByFlavour('affine:surface'); + return blocks.length !== 0 ? (blocks[0].model as SurfaceBlockModel) : null; +} + +/** + * Gets the document content with a max length. + */ +export function getDocContentWithMaxLength(doc: Doc, maxlength = 500) { + const notes = getNotesFromDoc(doc); + if (!notes) return; + + const noteChildren = notes.flatMap(note => + note.children.filter(model => filterTextModel(model)) + ); + if (!noteChildren.length) return; + + let count = 0; + let reached = false; + const texts = []; + + for (const model of noteChildren) { + let t = model.text?.toString(); + if (t?.length) { + const c: number = count + Math.max(0, texts.length - 1); + + if (t.length + c > maxlength) { + t = t.substring(0, maxlength - c); + reached = true; + } + + texts.push(t); + count += t.length; + + if (reached) { + break; + } + } + } + + return texts.join('\n'); +} diff --git a/blocksuite/affine/block-embed/src/common/to-edgeless-embed-block.ts b/blocksuite/affine/block-embed/src/common/to-edgeless-embed-block.ts new file mode 100644 index 0000000000..2643f6f711 --- /dev/null +++ b/blocksuite/affine/block-embed/src/common/to-edgeless-embed-block.ts @@ -0,0 +1,92 @@ +import { + blockComponentSymbol, + type BlockService, + type GfxBlockComponent, + GfxElementSymbol, + toGfxBlockComponent, +} from '@blocksuite/block-std'; +import type { + GfxBlockElementModel, + GfxCompatibleProps, +} from '@blocksuite/block-std/gfx'; +import { Bound } from '@blocksuite/global/utils'; +import type { StyleInfo } from 'lit/directives/style-map.js'; + +import type { EmbedBlockComponent } from './embed-block-element.js'; + +export function toEdgelessEmbedBlock< + Model extends GfxBlockElementModel, + Service extends BlockService, + WidgetName extends string, + B extends typeof EmbedBlockComponent, +>(block: B) { + return class extends toGfxBlockComponent(block) { + _isDragging = false; + + _isResizing = false; + + _isSelected = false; + + _showOverlay = false; + + override [blockComponentSymbol] = true; + + override blockDraggable = false; + + protected override embedContainerStyle: StyleInfo = {}; + + override [GfxElementSymbol] = true; + + get bound(): Bound { + return Bound.deserialize(this.model.xywh); + } + + get rootService() { + return this.std.getService('affine:page'); + } + + _handleClick(_: MouseEvent): void { + return; + } + + override connectedCallback(): void { + super.connectedCallback(); + const rootService = this.rootService; + + this._disposables.add( + // @ts-expect-error TODO: fix after edgeless slots are migrated to extension + rootService.slots.elementResizeStart.on(() => { + this._isResizing = true; + this._showOverlay = true; + }) + ); + + this._disposables.add( + // @ts-expect-error TODO: fix after edgeless slots are migrated to extension + rootService.slots.elementResizeEnd.on(() => { + this._isResizing = false; + this._showOverlay = + this._isResizing || this._isDragging || !this._isSelected; + }) + ); + } + + override renderGfxBlock() { + const bound = Bound.deserialize(this.model.xywh); + + this.embedContainerStyle.width = `${bound.w}px`; + this.embedContainerStyle.height = `${bound.h}px`; + this.blockContainerStyles = { + width: `${bound.w}px`, + }; + this._scale = bound.w / this._cardWidth; + + return this.renderPageContent(); + } + + protected override accessor blockContainerStyles: StyleInfo | undefined = + undefined; + } as B & { + new (...args: any[]): GfxBlockComponent; + }; +} diff --git a/blocksuite/affine/block-embed/src/common/utils.ts b/blocksuite/affine/block-embed/src/common/utils.ts new file mode 100644 index 0000000000..6e317bf71f --- /dev/null +++ b/blocksuite/affine/block-embed/src/common/utils.ts @@ -0,0 +1,47 @@ +import { + DarkLoadingIcon, + EmbedCardDarkBannerIcon, + EmbedCardDarkCubeIcon, + EmbedCardDarkHorizontalIcon, + EmbedCardDarkListIcon, + EmbedCardDarkVerticalIcon, + EmbedCardLightBannerIcon, + EmbedCardLightCubeIcon, + EmbedCardLightHorizontalIcon, + EmbedCardLightListIcon, + EmbedCardLightVerticalIcon, + LightLoadingIcon, +} from '@blocksuite/affine-components/icons'; +import { ColorScheme } from '@blocksuite/affine-model'; +import type { TemplateResult } from 'lit'; + +type EmbedCardIcons = { + LoadingIcon: TemplateResult<1>; + EmbedCardBannerIcon: TemplateResult<1>; + EmbedCardHorizontalIcon: TemplateResult<1>; + EmbedCardListIcon: TemplateResult<1>; + EmbedCardVerticalIcon: TemplateResult<1>; + EmbedCardCubeIcon: TemplateResult<1>; +}; + +export function getEmbedCardIcons(theme: ColorScheme): EmbedCardIcons { + if (theme === ColorScheme.Light) { + return { + LoadingIcon: LightLoadingIcon, + EmbedCardBannerIcon: EmbedCardLightBannerIcon, + EmbedCardHorizontalIcon: EmbedCardLightHorizontalIcon, + EmbedCardListIcon: EmbedCardLightListIcon, + EmbedCardVerticalIcon: EmbedCardLightVerticalIcon, + EmbedCardCubeIcon: EmbedCardLightCubeIcon, + }; + } else { + return { + LoadingIcon: DarkLoadingIcon, + EmbedCardBannerIcon: EmbedCardDarkBannerIcon, + EmbedCardHorizontalIcon: EmbedCardDarkHorizontalIcon, + EmbedCardListIcon: EmbedCardDarkListIcon, + EmbedCardVerticalIcon: EmbedCardDarkVerticalIcon, + EmbedCardCubeIcon: EmbedCardDarkCubeIcon, + }; + } +} diff --git a/blocksuite/affine/block-embed/src/effects.ts b/blocksuite/affine/block-embed/src/effects.ts new file mode 100644 index 0000000000..1fd00536a5 --- /dev/null +++ b/blocksuite/affine/block-embed/src/effects.ts @@ -0,0 +1,131 @@ +import { EmbedEdgelessBlockComponent } from './embed-figma-block/embed-edgeless-figma-block.js'; +import type { EmbedFigmaBlockService } from './embed-figma-block/embed-figma-service.js'; +import { EmbedFigmaBlockComponent } from './embed-figma-block/index.js'; +import { EmbedEdgelessGithubBlockComponent } from './embed-github-block/embed-edgeless-github-block.js'; +import { + EmbedGithubBlockComponent, + type EmbedGithubBlockService, +} from './embed-github-block/index.js'; +import { EmbedHtmlFullscreenToolbar } from './embed-html-block/components/fullscreen-toolbar.js'; +import { EmbedEdgelessHtmlBlockComponent } from './embed-html-block/embed-edgeless-html-block.js'; +import { EmbedHtmlBlockComponent } from './embed-html-block/index.js'; +import type { insertEmbedLinkedDocCommand } from './embed-linked-doc-block/commands/insert-embed-linked-doc.js'; +import type { + InsertedLinkType, + insertLinkByQuickSearchCommand, +} from './embed-linked-doc-block/commands/insert-link-by-quick-search.js'; +import { EmbedEdgelessLinkedDocBlockComponent } from './embed-linked-doc-block/embed-edgeless-linked-doc-block.js'; +import type { EmbedLinkedDocBlockConfig } from './embed-linked-doc-block/embed-linked-doc-config.js'; +import { EmbedLinkedDocBlockComponent } from './embed-linked-doc-block/index.js'; +import { EmbedEdgelessLoomBlockComponent } from './embed-loom-block/embed-edgeless-loom-bock.js'; +import { + EmbedLoomBlockComponent, + type EmbedLoomBlockService, +} from './embed-loom-block/index.js'; +import { EmbedSyncedDocCard } from './embed-synced-doc-block/components/embed-synced-doc-card.js'; +import { EmbedEdgelessSyncedDocBlockComponent } from './embed-synced-doc-block/embed-edgeless-synced-doc-block.js'; +import { EmbedSyncedDocBlockComponent } from './embed-synced-doc-block/index.js'; +import { EmbedEdgelessYoutubeBlockComponent } from './embed-youtube-block/embed-edgeless-youtube-block.js'; +import { + EmbedYoutubeBlockComponent, + type EmbedYoutubeBlockService, +} from './embed-youtube-block/index.js'; + +export function effects() { + customElements.define( + 'affine-embed-edgeless-figma-block', + EmbedEdgelessBlockComponent + ); + customElements.define('affine-embed-figma-block', EmbedFigmaBlockComponent); + + customElements.define('affine-embed-html-block', EmbedHtmlBlockComponent); + customElements.define( + 'affine-embed-edgeless-html-block', + EmbedEdgelessHtmlBlockComponent + ); + + customElements.define( + 'embed-html-fullscreen-toolbar', + EmbedHtmlFullscreenToolbar + ); + customElements.define( + 'affine-embed-edgeless-github-block', + EmbedEdgelessGithubBlockComponent + ); + customElements.define('affine-embed-github-block', EmbedGithubBlockComponent); + + customElements.define( + 'affine-embed-edgeless-youtube-block', + EmbedEdgelessYoutubeBlockComponent + ); + customElements.define( + 'affine-embed-youtube-block', + EmbedYoutubeBlockComponent + ); + + customElements.define( + 'affine-embed-edgeless-loom-block', + EmbedEdgelessLoomBlockComponent + ); + customElements.define('affine-embed-loom-block', EmbedLoomBlockComponent); + + customElements.define('affine-embed-synced-doc-card', EmbedSyncedDocCard); + + customElements.define( + 'affine-embed-edgeless-linked-doc-block', + EmbedEdgelessLinkedDocBlockComponent + ); + customElements.define( + 'affine-embed-linked-doc-block', + EmbedLinkedDocBlockComponent + ); + + customElements.define( + 'affine-embed-edgeless-synced-doc-block', + EmbedEdgelessSyncedDocBlockComponent + ); + customElements.define( + 'affine-embed-synced-doc-block', + EmbedSyncedDocBlockComponent + ); +} + +declare global { + interface HTMLElementTagNameMap { + 'affine-embed-figma-block': EmbedFigmaBlockComponent; + 'affine-embed-edgeless-figma-block': EmbedEdgelessBlockComponent; + 'affine-embed-github-block': EmbedGithubBlockComponent; + 'affine-embed-edgeless-github-block': EmbedEdgelessGithubBlockComponent; + 'affine-embed-html-block': EmbedHtmlBlockComponent; + 'affine-embed-edgeless-html-block': EmbedEdgelessHtmlBlockComponent; + 'embed-html-fullscreen-toolbar': EmbedHtmlFullscreenToolbar; + 'affine-embed-edgeless-loom-block': EmbedEdgelessLoomBlockComponent; + 'affine-embed-loom-block': EmbedLoomBlockComponent; + 'affine-embed-youtube-block': EmbedYoutubeBlockComponent; + 'affine-embed-edgeless-youtube-block': EmbedEdgelessYoutubeBlockComponent; + 'affine-embed-synced-doc-card': EmbedSyncedDocCard; + 'affine-embed-synced-doc-block': EmbedSyncedDocBlockComponent; + 'affine-embed-edgeless-synced-doc-block': EmbedEdgelessSyncedDocBlockComponent; + 'affine-embed-linked-doc-block': EmbedLinkedDocBlockComponent; + 'affine-embed-edgeless-linked-doc-block': EmbedEdgelessLinkedDocBlockComponent; + } + + namespace BlockSuite { + interface BlockServices { + 'affine:embed-figma': EmbedFigmaBlockService; + 'affine:embed-github': EmbedGithubBlockService; + 'affine:embed-loom': EmbedLoomBlockService; + 'affine:embed-youtube': EmbedYoutubeBlockService; + } + interface BlockConfigs { + 'affine:embed-linked-doc': EmbedLinkedDocBlockConfig; + } + interface CommandContext { + insertedLinkType?: Promise; + } + interface Commands { + insertEmbedLinkedDoc: typeof insertEmbedLinkedDocCommand; + insertLinkByQuickSearch: typeof insertLinkByQuickSearchCommand; + } + } +} diff --git a/blocksuite/affine/block-embed/src/embed-figma-block/adapters/html.ts b/blocksuite/affine/block-embed/src/embed-figma-block/adapters/html.ts new file mode 100644 index 0000000000..b693234708 --- /dev/null +++ b/blocksuite/affine/block-embed/src/embed-figma-block/adapters/html.ts @@ -0,0 +1,11 @@ +import { EmbedFigmaBlockSchema } from '@blocksuite/affine-model'; +import { BlockHtmlAdapterExtension } from '@blocksuite/affine-shared/adapters'; + +import { createEmbedBlockHtmlAdapterMatcher } from '../../common/adapters/html.js'; + +export const embedFigmaBlockHtmlAdapterMatcher = + createEmbedBlockHtmlAdapterMatcher(EmbedFigmaBlockSchema.model.flavour); + +export const EmbedFigmaBlockHtmlAdapterExtension = BlockHtmlAdapterExtension( + embedFigmaBlockHtmlAdapterMatcher +); diff --git a/blocksuite/affine/block-embed/src/embed-figma-block/adapters/index.ts b/blocksuite/affine/block-embed/src/embed-figma-block/adapters/index.ts new file mode 100644 index 0000000000..c1d476903d --- /dev/null +++ b/blocksuite/affine/block-embed/src/embed-figma-block/adapters/index.ts @@ -0,0 +1,3 @@ +export * from './html.js'; +export * from './markdown.js'; +export * from './plain-text.js'; diff --git a/blocksuite/affine/block-embed/src/embed-figma-block/adapters/markdown.ts b/blocksuite/affine/block-embed/src/embed-figma-block/adapters/markdown.ts new file mode 100644 index 0000000000..c6eb0ea922 --- /dev/null +++ b/blocksuite/affine/block-embed/src/embed-figma-block/adapters/markdown.ts @@ -0,0 +1,11 @@ +import { EmbedFigmaBlockSchema } from '@blocksuite/affine-model'; +import { BlockMarkdownAdapterExtension } from '@blocksuite/affine-shared/adapters'; + +import { createEmbedBlockMarkdownAdapterMatcher } from '../../common/adapters/markdown.js'; + +export const embedFigmaBlockMarkdownAdapterMatcher = + createEmbedBlockMarkdownAdapterMatcher(EmbedFigmaBlockSchema.model.flavour); + +export const EmbedFigmaMarkdownAdapterExtension = BlockMarkdownAdapterExtension( + embedFigmaBlockMarkdownAdapterMatcher +); diff --git a/blocksuite/affine/block-embed/src/embed-figma-block/adapters/plain-text.ts b/blocksuite/affine/block-embed/src/embed-figma-block/adapters/plain-text.ts new file mode 100644 index 0000000000..7d67d7ef7d --- /dev/null +++ b/blocksuite/affine/block-embed/src/embed-figma-block/adapters/plain-text.ts @@ -0,0 +1,10 @@ +import { EmbedFigmaBlockSchema } from '@blocksuite/affine-model'; +import { BlockPlainTextAdapterExtension } from '@blocksuite/affine-shared/adapters'; + +import { createEmbedBlockPlainTextAdapterMatcher } from '../../common/adapters/plain-text.js'; + +export const embedFigmaBlockPlainTextAdapterMatcher = + createEmbedBlockPlainTextAdapterMatcher(EmbedFigmaBlockSchema.model.flavour); + +export const EmbedFigmaBlockPlainTextAdapterExtension = + BlockPlainTextAdapterExtension(embedFigmaBlockPlainTextAdapterMatcher); diff --git a/blocksuite/affine/block-embed/src/embed-figma-block/embed-edgeless-figma-block.ts b/blocksuite/affine/block-embed/src/embed-figma-block/embed-edgeless-figma-block.ts new file mode 100644 index 0000000000..d33c23988c --- /dev/null +++ b/blocksuite/affine/block-embed/src/embed-figma-block/embed-edgeless-figma-block.ts @@ -0,0 +1,6 @@ +import { toEdgelessEmbedBlock } from '../common/to-edgeless-embed-block.js'; +import { EmbedFigmaBlockComponent } from './embed-figma-block.js'; + +export class EmbedEdgelessBlockComponent extends toEdgelessEmbedBlock( + EmbedFigmaBlockComponent +) {} diff --git a/blocksuite/affine/block-embed/src/embed-figma-block/embed-figma-block.ts b/blocksuite/affine/block-embed/src/embed-figma-block/embed-figma-block.ts new file mode 100644 index 0000000000..d6741bd965 --- /dev/null +++ b/blocksuite/affine/block-embed/src/embed-figma-block/embed-figma-block.ts @@ -0,0 +1,166 @@ +import { OpenIcon } from '@blocksuite/affine-components/icons'; +import type { + EmbedFigmaModel, + EmbedFigmaStyles, +} from '@blocksuite/affine-model'; +import { html } from 'lit'; +import { state } from 'lit/decorators.js'; +import { classMap } from 'lit/directives/class-map.js'; +import { styleMap } from 'lit/directives/style-map.js'; + +import { EmbedBlockComponent } from '../common/embed-block-element.js'; +import type { EmbedFigmaBlockService } from './embed-figma-service.js'; +import { FigmaIcon, styles } from './styles.js'; + +export class EmbedFigmaBlockComponent extends EmbedBlockComponent< + EmbedFigmaModel, + EmbedFigmaBlockService +> { + static override styles = styles; + + override _cardStyle: (typeof EmbedFigmaStyles)[number] = 'figma'; + + protected _isDragging = false; + + protected _isResizing = false; + + open = () => { + let link = this.model.url; + if (!link.match(/^[a-zA-Z]+:\/\//)) { + link = 'https://' + link; + } + window.open(link, '_blank'); + }; + + refreshData = () => {}; + + private _handleDoubleClick(event: MouseEvent) { + event.stopPropagation(); + this.open(); + } + + private _selectBlock() { + const selectionManager = this.host.selection; + const blockSelection = selectionManager.create('block', { + blockId: this.blockId, + }); + selectionManager.setGroup('note', [blockSelection]); + } + + protected _handleClick(event: MouseEvent) { + event.stopPropagation(); + this._selectBlock(); + } + + override connectedCallback() { + super.connectedCallback(); + this._cardStyle = this.model.style; + + if (!this.model.description && !this.model.title) { + this.doc.withoutTransact(() => { + this.doc.updateBlock(this.model, { + title: 'Figma', + description: this.model.url, + }); + }); + } + + this.disposables.add( + this.model.propsUpdated.on(({ key }) => { + if (key === 'url') { + this.refreshData(); + } + }) + ); + + // this is required to prevent iframe from capturing pointer events + this.disposables.add( + this.std.selection.slots.changed.on(() => { + this._isSelected = + !!this.selected?.is('block') || !!this.selected?.is('surface'); + + this._showOverlay = + this._isResizing || this._isDragging || !this._isSelected; + }) + ); + // this is required to prevent iframe from capturing pointer events + this.handleEvent('dragStart', () => { + this._isDragging = true; + this._showOverlay = + this._isResizing || this._isDragging || !this._isSelected; + }); + + this.handleEvent('dragEnd', () => { + this._isDragging = false; + this._showOverlay = + this._isResizing || this._isDragging || !this._isSelected; + }); + } + + override renderBlock() { + const { title, description, url } = this.model; + const titleText = title ?? 'Figma'; + const descriptionText = description ?? url; + + return this.renderEmbed( + () => html` +
+
+
+ + +
+
+
+
+
+
+ ${FigmaIcon} +
+ +
+ ${titleText} +
+
+ +
+ ${descriptionText} +
+ +
+ www.figma.com + +
${OpenIcon}
+
+
+
+ ` + ); + } + + @state() + protected accessor _isSelected = false; + + @state() + protected accessor _showOverlay = true; +} diff --git a/blocksuite/affine/block-embed/src/embed-figma-block/embed-figma-model.ts b/blocksuite/affine/block-embed/src/embed-figma-block/embed-figma-model.ts new file mode 100644 index 0000000000..aa8be4c8dc --- /dev/null +++ b/blocksuite/affine/block-embed/src/embed-figma-block/embed-figma-model.ts @@ -0,0 +1,2 @@ +export const figmaUrlRegex: RegExp = + /https:\/\/[\w.-]+\.?figma.com\/([\w-]+)\/([0-9a-zA-Z]{22,128})(?:\/.*)?$/; diff --git a/blocksuite/affine/block-embed/src/embed-figma-block/embed-figma-service.ts b/blocksuite/affine/block-embed/src/embed-figma-block/embed-figma-service.ts new file mode 100644 index 0000000000..9847f472b7 --- /dev/null +++ b/blocksuite/affine/block-embed/src/embed-figma-block/embed-figma-service.ts @@ -0,0 +1,23 @@ +import { + EmbedFigmaBlockSchema, + EmbedFigmaStyles, +} from '@blocksuite/affine-model'; +import { EmbedOptionProvider } from '@blocksuite/affine-shared/services'; +import { BlockService } from '@blocksuite/block-std'; + +import { figmaUrlRegex } from './embed-figma-model.js'; + +export class EmbedFigmaBlockService extends BlockService { + static override readonly flavour = EmbedFigmaBlockSchema.model.flavour; + + override mounted() { + super.mounted(); + + this.std.get(EmbedOptionProvider).registerEmbedBlockOptions({ + flavour: this.flavour, + urlRegex: figmaUrlRegex, + styles: EmbedFigmaStyles, + viewType: 'embed', + }); + } +} diff --git a/blocksuite/affine/block-embed/src/embed-figma-block/embed-figma-spec.ts b/blocksuite/affine/block-embed/src/embed-figma-block/embed-figma-spec.ts new file mode 100644 index 0000000000..5712740887 --- /dev/null +++ b/blocksuite/affine/block-embed/src/embed-figma-block/embed-figma-spec.ts @@ -0,0 +1,18 @@ +import { + BlockViewExtension, + type ExtensionType, + FlavourExtension, +} from '@blocksuite/block-std'; +import { literal } from 'lit/static-html.js'; + +import { EmbedFigmaBlockService } from './embed-figma-service.js'; + +export const EmbedFigmaBlockSpec: ExtensionType[] = [ + FlavourExtension('affine:embed-figma'), + EmbedFigmaBlockService, + BlockViewExtension('affine:embed-figma', model => { + return model.parent?.flavour === 'affine:surface' + ? literal`affine-embed-edgeless-figma-block` + : literal`affine-embed-figma-block`; + }), +]; diff --git a/blocksuite/affine/block-embed/src/embed-figma-block/index.ts b/blocksuite/affine/block-embed/src/embed-figma-block/index.ts new file mode 100644 index 0000000000..392b24cd7e --- /dev/null +++ b/blocksuite/affine/block-embed/src/embed-figma-block/index.ts @@ -0,0 +1,5 @@ +export * from './adapters/index.js'; +export * from './embed-figma-block.js'; +export * from './embed-figma-model.js'; +export * from './embed-figma-spec.js'; +export { FigmaIcon } from './styles.js'; diff --git a/blocksuite/affine/block-embed/src/embed-figma-block/styles.ts b/blocksuite/affine/block-embed/src/embed-figma-block/styles.ts new file mode 100644 index 0000000000..2c8023bfa2 --- /dev/null +++ b/blocksuite/affine/block-embed/src/embed-figma-block/styles.ts @@ -0,0 +1,229 @@ +import { + EMBED_CARD_HEIGHT, + EMBED_CARD_WIDTH, +} from '@blocksuite/affine-shared/consts'; +import { css, html } from 'lit'; + +export const styles = css` + .affine-embed-figma-block { + width: ${EMBED_CARD_WIDTH.figma}px; + display: flex; + flex-direction: column; + gap: 20px; + padding: 12px; + + border-radius: 8px; + border: 1px solid var(--affine-background-tertiary-color); + + opacity: var(--add, 1); + background: var(--affine-background-primary-color); + user-select: none; + + aspect-ratio: ${EMBED_CARD_WIDTH.figma} / ${EMBED_CARD_HEIGHT.figma}; + } + + .affine-embed-figma { + flex-grow: 1; + width: 100%; + opacity: var(--add, 1); + } + + .affine-embed-figma img, + .affine-embed-figma object, + .affine-embed-figma svg { + width: 100%; + height: 100%; + object-fit: cover; + border-radius: 4px 4px var(--1, 0px) var(--1, 0px); + } + + .affine-embed-figma-iframe-container { + height: 100%; + position: relative; + } + + .affine-embed-figma-iframe-container > iframe { + width: 100%; + height: 100%; + border-radius: 4px 4px var(--1, 0px) var(--1, 0px); + border: none; + } + + .affine-embed-figma-iframe-overlay { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + } + + .affine-embed-figma-iframe-overlay.hide { + display: none; + } + + .affine-embed-figma-content { + display: block; + flex-direction: column; + width: 100%; + height: fit-content; + border-radius: var(--1, 0px); + opacity: var(--add, 1); + } + + .affine-embed-figma-content-header { + display: flex; + flex-direction: row; + gap: 8px; + align-items: center; + + align-self: stretch; + padding: var(--1, 0px); + border-radius: var(--1, 0px); + opacity: var(--add, 1); + } + + .affine-embed-figma-content-title-icon { + display: flex; + width: 20px; + height: 20px; + justify-content: center; + align-items: center; + } + + .affine-embed-figma-content-title-icon img, + .affine-embed-figma-content-title-icon object, + .affine-embed-figma-content-title-icon svg { + width: 20px; + height: 20px; + fill: var(--affine-background-primary-color); + } + + .affine-embed-figma-content-title-text { + flex: 1 0 0; + + display: -webkit-box; + -webkit-line-clamp: 1; + -webkit-box-orient: vertical; + + word-break: break-word; + overflow: hidden; + text-overflow: ellipsis; + color: var(--affine-text-primary-color); + + font-family: var(--affine-font-family); + font-size: var(--affine-font-sm); + font-style: normal; + font-weight: 600; + line-height: 22px; + } + + .affine-embed-figma-content-description { + height: 40px; + + position: relative; + + word-break: break-word; + white-space: normal; + overflow: hidden; + text-overflow: ellipsis; + color: var(--affine-text-primary-color); + + font-family: var(--affine-font-family); + font-size: var(--affine-font-xs); + font-style: normal; + font-weight: 400; + line-height: 20px; + } + + .affine-embed-figma-content-description::after { + content: '...'; + position: absolute; + right: 0; + bottom: 0; + background-color: var(--affine-background-primary-color); + } + + .affine-embed-figma-content-url { + display: flex; + align-items: center; + justify-content: flex-start; + gap: 4px; + width: max-content; + max-width: 100%; + cursor: pointer; + } + .affine-embed-figma-content-url > span { + display: -webkit-box; + -webkit-line-clamp: 1; + -webkit-box-orient: vertical; + + word-break: break-all; + white-space: normal; + overflow: hidden; + text-overflow: ellipsis; + color: var(--affine-text-secondary-color); + + font-family: var(--affine-font-family); + font-size: var(--affine-font-xs); + font-style: normal; + font-weight: 400; + line-height: 20px; + } + .affine-embed-figma-content-url:hover > span { + color: var(--affine-link-color); + } + .affine-embed-figma-content-url:hover .open-icon { + fill: var(--affine-link-color); + } + + .affine-embed-figma-content-url-icon { + display: flex; + align-items: center; + justify-content: center; + width: 12px; + height: 12px; + } + .affine-embed-figma-content-url-icon .open-icon { + height: 12px; + width: 12px; + fill: var(--affine-text-secondary-color); + } + + .affine-embed-figma-block.selected { + .affine-embed-figma-content-url > span { + color: var(--affine-link-color); + } + .affine-embed-figma-content-url .open-icon { + fill: var(--affine-link-color); + } + } +`; + +export const FigmaIcon = html` + + + + + +`; diff --git a/blocksuite/affine/block-embed/src/embed-github-block/adapters/html.ts b/blocksuite/affine/block-embed/src/embed-github-block/adapters/html.ts new file mode 100644 index 0000000000..f5ffafd1da --- /dev/null +++ b/blocksuite/affine/block-embed/src/embed-github-block/adapters/html.ts @@ -0,0 +1,11 @@ +import { EmbedGithubBlockSchema } from '@blocksuite/affine-model'; +import { BlockHtmlAdapterExtension } from '@blocksuite/affine-shared/adapters'; + +import { createEmbedBlockHtmlAdapterMatcher } from '../../common/adapters/html.js'; + +export const embedGithubBlockHtmlAdapterMatcher = + createEmbedBlockHtmlAdapterMatcher(EmbedGithubBlockSchema.model.flavour); + +export const EmbedGithubBlockHtmlAdapterExtension = BlockHtmlAdapterExtension( + embedGithubBlockHtmlAdapterMatcher +); diff --git a/blocksuite/affine/block-embed/src/embed-github-block/adapters/index.ts b/blocksuite/affine/block-embed/src/embed-github-block/adapters/index.ts new file mode 100644 index 0000000000..c1d476903d --- /dev/null +++ b/blocksuite/affine/block-embed/src/embed-github-block/adapters/index.ts @@ -0,0 +1,3 @@ +export * from './html.js'; +export * from './markdown.js'; +export * from './plain-text.js'; diff --git a/blocksuite/affine/block-embed/src/embed-github-block/adapters/markdown.ts b/blocksuite/affine/block-embed/src/embed-github-block/adapters/markdown.ts new file mode 100644 index 0000000000..249cfbbf0a --- /dev/null +++ b/blocksuite/affine/block-embed/src/embed-github-block/adapters/markdown.ts @@ -0,0 +1,10 @@ +import { EmbedGithubBlockSchema } from '@blocksuite/affine-model'; +import { BlockMarkdownAdapterExtension } from '@blocksuite/affine-shared/adapters'; + +import { createEmbedBlockMarkdownAdapterMatcher } from '../../common/adapters/markdown.js'; + +export const embedGithubBlockMarkdownAdapterMatcher = + createEmbedBlockMarkdownAdapterMatcher(EmbedGithubBlockSchema.model.flavour); + +export const EmbedGithubMarkdownAdapterExtension = + BlockMarkdownAdapterExtension(embedGithubBlockMarkdownAdapterMatcher); diff --git a/blocksuite/affine/block-embed/src/embed-github-block/adapters/plain-text.ts b/blocksuite/affine/block-embed/src/embed-github-block/adapters/plain-text.ts new file mode 100644 index 0000000000..dc70b2101c --- /dev/null +++ b/blocksuite/affine/block-embed/src/embed-github-block/adapters/plain-text.ts @@ -0,0 +1,10 @@ +import { EmbedGithubBlockSchema } from '@blocksuite/affine-model'; +import { BlockPlainTextAdapterExtension } from '@blocksuite/affine-shared/adapters'; + +import { createEmbedBlockPlainTextAdapterMatcher } from '../../common/adapters/plain-text.js'; + +export const embedGithubBlockPlainTextAdapterMatcher = + createEmbedBlockPlainTextAdapterMatcher(EmbedGithubBlockSchema.model.flavour); + +export const EmbedGithubBlockPlainTextAdapterExtension = + BlockPlainTextAdapterExtension(embedGithubBlockPlainTextAdapterMatcher); diff --git a/blocksuite/affine/block-embed/src/embed-github-block/embed-edgeless-github-block.ts b/blocksuite/affine/block-embed/src/embed-github-block/embed-edgeless-github-block.ts new file mode 100644 index 0000000000..3ea5794366 --- /dev/null +++ b/blocksuite/affine/block-embed/src/embed-github-block/embed-edgeless-github-block.ts @@ -0,0 +1,6 @@ +import { toEdgelessEmbedBlock } from '../common/to-edgeless-embed-block.js'; +import { EmbedGithubBlockComponent } from './embed-github-block.js'; + +export class EmbedEdgelessGithubBlockComponent extends toEdgelessEmbedBlock( + EmbedGithubBlockComponent +) {} diff --git a/blocksuite/affine/block-embed/src/embed-github-block/embed-github-block.ts b/blocksuite/affine/block-embed/src/embed-github-block/embed-github-block.ts new file mode 100644 index 0000000000..64ae159051 --- /dev/null +++ b/blocksuite/affine/block-embed/src/embed-github-block/embed-github-block.ts @@ -0,0 +1,275 @@ +import { OpenIcon } from '@blocksuite/affine-components/icons'; +import type { + EmbedGithubModel, + EmbedGithubStyles, +} from '@blocksuite/affine-model'; +import { ThemeProvider } from '@blocksuite/affine-shared/services'; +import { html, nothing } from 'lit'; +import { property, state } from 'lit/decorators.js'; +import { classMap } from 'lit/directives/class-map.js'; +import { repeat } from 'lit/directives/repeat.js'; +import { styleMap } from 'lit/directives/style-map.js'; + +import { EmbedBlockComponent } from '../common/embed-block-element.js'; +import { getEmbedCardIcons } from '../common/utils.js'; +import { githubUrlRegex } from './embed-github-model.js'; +import type { EmbedGithubBlockService } from './embed-github-service.js'; +import { GithubIcon, styles } from './styles.js'; +import { + getGithubStatusIcon, + refreshEmbedGithubStatus, + refreshEmbedGithubUrlData, +} from './utils.js'; + +export class EmbedGithubBlockComponent extends EmbedBlockComponent< + EmbedGithubModel, + EmbedGithubBlockService +> { + static override styles = styles; + + override _cardStyle: (typeof EmbedGithubStyles)[number] = 'horizontal'; + + open = () => { + let link = this.model.url; + if (!link.match(/^[a-zA-Z]+:\/\//)) { + link = 'https://' + link; + } + window.open(link, '_blank'); + }; + + refreshData = () => { + refreshEmbedGithubUrlData(this, this.fetchAbortController.signal).catch( + console.error + ); + }; + + refreshStatus = () => { + refreshEmbedGithubStatus(this, this.fetchAbortController.signal).catch( + console.error + ); + }; + + private _handleAssigneeClick(assignee: string) { + const link = `https://www.github.com/${assignee}`; + window.open(link, '_blank'); + } + + private _handleDoubleClick(event: MouseEvent) { + event.stopPropagation(); + this.open(); + } + + private _selectBlock() { + const selectionManager = this.host.selection; + const blockSelection = selectionManager.create('block', { + blockId: this.blockId, + }); + selectionManager.setGroup('note', [blockSelection]); + } + + protected _handleClick(event: MouseEvent) { + event.stopPropagation(); + this._selectBlock(); + } + + override connectedCallback() { + super.connectedCallback(); + this._cardStyle = this.model.style; + + if (!this.model.owner || !this.model.repo || !this.model.githubId) { + this.doc.withoutTransact(() => { + const url = this.model.url; + const urlMatch = url.match(githubUrlRegex); + if (urlMatch) { + const [, owner, repo, githubType, githubId] = urlMatch; + this.doc.updateBlock(this.model, { + owner, + repo, + githubType: githubType === 'issue' ? 'issue' : 'pr', + githubId, + }); + } + }); + } + + this.doc.withoutTransact(() => { + if (!this.model.description && !this.model.title) { + this.refreshData(); + } else { + this.refreshStatus(); + } + }); + + this.disposables.add( + this.model.propsUpdated.on(({ key }) => { + if (key === 'url') { + this.refreshData(); + } + }) + ); + + this.disposables.add( + this.selection.slots.changed.on(() => { + this._isSelected = + !!this.selected?.is('block') || !!this.selected?.is('surface'); + }) + ); + } + + override renderBlock() { + const { + title = 'GitHub', + githubType, + status, + statusReason, + owner, + repo, + createdAt, + assignees, + description, + image, + style, + } = this.model; + + const loading = this.loading; + const theme = this.std.get(ThemeProvider).theme; + const { LoadingIcon, EmbedCardBannerIcon } = getEmbedCardIcons(theme); + const titleIcon = loading ? LoadingIcon : GithubIcon; + const statusIcon = status + ? getGithubStatusIcon(githubType, status, statusReason) + : nothing; + const statusText = loading ? '' : status; + const titleText = loading ? 'Loading...' : title; + const descriptionText = loading ? '' : description; + const bannerImage = + !loading && image + ? html` + ${EmbedCardBannerIcon} + ` + : EmbedCardBannerIcon; + + let dateText = ''; + if (createdAt) { + const date = new Date(createdAt); + dateText = date.toLocaleDateString('en-US', { + month: 'short', + day: 'numeric', + }); + const day = date.getDate(); + const suffix = + ['th', 'st', 'nd', 'rd'][((day / 10) | 0) !== 1 ? day % 10 : 4] || 'th'; + dateText = dateText.replace(/\d+/, `${day}${suffix}`); + } + + return this.renderEmbed( + () => html` +
+
${bannerImage}
+
+
+
+
+ ${titleIcon} +
+ + ${status && statusText + ? html`
+ ${statusIcon} + + ${statusText} +
` + : nothing} +
+ +
+ ${titleText} +
+
+ +
+ ${descriptionText} +
+ + ${githubType === 'issue' && assignees + ? html` +
+
+ Assignees +
+ +
+ ${assignees.length === 0 + ? html`No one` + : repeat( + assignees, + assignee => assignee, + (assignee, index) => + html` + this._handleAssigneeClick(assignee)} + >${`@${assignee}`} + ${index === assignees.length - 1 ? '' : `, `}` + )} +
+
+ ` + : nothing} + +
+ ${`${owner}/${repo} |`} + + ${createdAt + ? html`` + : nothing} + github.com + +
+ ${OpenIcon} +
+
+
+
+ ` + ); + } + + @state() + private accessor _isSelected = false; + + @property({ attribute: false }) + accessor loading = false; +} diff --git a/blocksuite/affine/block-embed/src/embed-github-block/embed-github-model.ts b/blocksuite/affine/block-embed/src/embed-github-block/embed-github-model.ts new file mode 100644 index 0000000000..82f3097515 --- /dev/null +++ b/blocksuite/affine/block-embed/src/embed-github-block/embed-github-model.ts @@ -0,0 +1,2 @@ +export const githubUrlRegex: RegExp = + /^(?:https?:\/\/)?(?:www\.)?github\.com\/([^/]+)\/([^/]+)\/(issue|pull)s?\/(\d+)$/; diff --git a/blocksuite/affine/block-embed/src/embed-github-block/embed-github-service.ts b/blocksuite/affine/block-embed/src/embed-github-block/embed-github-service.ts new file mode 100644 index 0000000000..7a619062c6 --- /dev/null +++ b/blocksuite/affine/block-embed/src/embed-github-block/embed-github-service.ts @@ -0,0 +1,43 @@ +import { + EmbedGithubBlockSchema, + type EmbedGithubModel, + EmbedGithubStyles, +} from '@blocksuite/affine-model'; +import { EmbedOptionProvider } from '@blocksuite/affine-shared/services'; +import { BlockService } from '@blocksuite/block-std'; + +import { LinkPreviewer } from '../common/link-previewer.js'; +import { githubUrlRegex } from './embed-github-model.js'; +import { queryEmbedGithubApiData, queryEmbedGithubData } from './utils.js'; + +export class EmbedGithubBlockService extends BlockService { + static override readonly flavour = EmbedGithubBlockSchema.model.flavour; + + private static readonly linkPreviewer = new LinkPreviewer(); + + static setLinkPreviewEndpoint = + EmbedGithubBlockService.linkPreviewer.setEndpoint; + + queryApiData = (embedGithubModel: EmbedGithubModel, signal?: AbortSignal) => { + return queryEmbedGithubApiData(embedGithubModel, signal); + }; + + queryUrlData = (embedGithubModel: EmbedGithubModel, signal?: AbortSignal) => { + return queryEmbedGithubData( + embedGithubModel, + EmbedGithubBlockService.linkPreviewer, + signal + ); + }; + + override mounted() { + super.mounted(); + + this.std.get(EmbedOptionProvider).registerEmbedBlockOptions({ + flavour: this.flavour, + urlRegex: githubUrlRegex, + styles: EmbedGithubStyles, + viewType: 'card', + }); + } +} diff --git a/blocksuite/affine/block-embed/src/embed-github-block/embed-github-spec.ts b/blocksuite/affine/block-embed/src/embed-github-block/embed-github-spec.ts new file mode 100644 index 0000000000..e86cd30db4 --- /dev/null +++ b/blocksuite/affine/block-embed/src/embed-github-block/embed-github-spec.ts @@ -0,0 +1,18 @@ +import { + BlockViewExtension, + type ExtensionType, + FlavourExtension, +} from '@blocksuite/block-std'; +import { literal } from 'lit/static-html.js'; + +import { EmbedGithubBlockService } from './embed-github-service.js'; + +export const EmbedGithubBlockSpec: ExtensionType[] = [ + FlavourExtension('affine:embed-github'), + EmbedGithubBlockService, + BlockViewExtension('affine:embed-github', model => { + return model.parent?.flavour === 'affine:surface' + ? literal`affine-embed-edgeless-github-block` + : literal`affine-embed-github-block`; + }), +]; diff --git a/blocksuite/affine/block-embed/src/embed-github-block/index.ts b/blocksuite/affine/block-embed/src/embed-github-block/index.ts new file mode 100644 index 0000000000..aa05ad73f4 --- /dev/null +++ b/blocksuite/affine/block-embed/src/embed-github-block/index.ts @@ -0,0 +1,5 @@ +export * from './adapters/index.js'; +export * from './embed-github-block.js'; +export * from './embed-github-service.js'; +export * from './embed-github-spec.js'; +export { GithubIcon } from './styles.js'; diff --git a/blocksuite/affine/block-embed/src/embed-github-block/styles.ts b/blocksuite/affine/block-embed/src/embed-github-block/styles.ts new file mode 100644 index 0000000000..6fc64b5729 --- /dev/null +++ b/blocksuite/affine/block-embed/src/embed-github-block/styles.ts @@ -0,0 +1,513 @@ +import { + EMBED_CARD_HEIGHT, + EMBED_CARD_WIDTH, +} from '@blocksuite/affine-shared/consts'; +import { css, html } from 'lit'; + +export const styles = css` + .affine-embed-github-block { + box-sizing: border-box; + display: flex; + width: 100%; + height: ${EMBED_CARD_HEIGHT.horizontal}px; + + border-radius: 8px; + border: 1px solid var(--affine-background-tertiary-color); + + opacity: var(--add, 1); + background: var(--affine-background-primary-color); + user-select: none; + } + + .affine-embed-github-content { + display: flex; + flex-grow: 1; + flex-direction: column; + align-self: stretch; + gap: 4px; + padding: 12px; + border-radius: var(--1, 0px); + opacity: var(--add, 1); + } + + .affine-embed-github-content-title { + display: flex; + min-height: 22px; + flex-direction: row; + gap: 8px; + align-items: center; + + align-self: stretch; + padding: var(--1, 0px); + border-radius: var(--1, 0px); + opacity: var(--add, 1); + } + + .affine-embed-github-content-title-icons { + display: flex; + justify-content: center; + align-items: center; + gap: 8px; + } + + .affine-embed-github-content-title-icons img, + .affine-embed-github-content-title-icons object, + .affine-embed-github-content-title-icons svg { + width: 16px; + height: 16px; + color: var(--affine-pure-white); + } + + .affine-embed-github-content-title-site-icon { + display: flex; + width: 16px; + height: 16px; + justify-content: center; + align-items: center; + + .github-icon { + fill: var(--affine-black); + color: var(--affine-black); + } + } + + .affine-embed-github-content-title-status-icon { + display: flex; + align-items: center; + gap: 4px; + padding: 3px 6px; + border-radius: 20px; + + color: var(--affine-pure-white); + leading-trim: both; + + text-edge: cap; + font-feature-settings: + 'clig' off, + 'liga' off; + text-transform: capitalize; + font-family: var(--affine-font-family); + font-size: 12px; + font-style: normal; + font-weight: 500; + line-height: 16px; + } + .affine-embed-github-content-title-status-icon.issue.open { + background: #238636; + } + .affine-embed-github-content-title-status-icon.issue.closed.success { + background: #8957e5; + } + .affine-embed-github-content-title-status-icon.issue.closed.failure { + background: #6e7681; + } + .affine-embed-github-content-title-status-icon.pr.open { + background: #238636; + } + .affine-embed-github-content-title-status-icon.pr.draft { + background: #6e7681; + } + .affine-embed-github-content-title-status-icon.pr.merged { + background: #8957e5; + } + .affine-embed-github-content-title-status-icon.pr.closed { + background: #c03737; + } + + .affine-embed-github-content-title-status-icon > svg { + height: 16px; + width: 16px; + padding: 2px; + } + + .affine-embed-github-content-title-status-icon > span { + padding: 0px 1.5px; + } + + .affine-embed-github-content-title-text { + display: -webkit-box; + -webkit-line-clamp: 1; + -webkit-box-orient: vertical; + + word-break: break-word; + overflow: hidden; + text-overflow: ellipsis; + color: var(--affine-text-primary-color); + + font-family: var(--affine-font-family); + font-size: var(--affine-font-sm); + font-style: normal; + font-weight: 600; + line-height: 22px; + } + + .affine-embed-github-content-description { + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + + flex-grow: 1; + + word-break: break-word; + white-space: normal; + overflow: hidden; + text-overflow: ellipsis; + color: var(--affine-text-primary-color); + + font-family: var(--affine-font-family); + font-size: var(--affine-font-xs); + font-style: normal; + font-weight: 400; + line-height: 20px; + } + + .affine-embed-github-content-assignees { + display: none; + } + + .affine-embed-github-content-url { + display: flex; + align-items: center; + justify-content: flex-start; + gap: 4px; + width: max-content; + max-width: 100%; + cursor: pointer; + } + .affine-embed-github-content-url > span { + display: -webkit-box; + -webkit-line-clamp: 1; + -webkit-box-orient: vertical; + + word-break: break-all; + white-space: normal; + overflow: hidden; + text-overflow: ellipsis; + color: var(--affine-text-secondary-color); + + font-family: var(--affine-font-family); + font-size: var(--affine-font-xs); + font-style: normal; + font-weight: 400; + line-height: 20px; + } + .affine-embed-github-content-url:hover > span { + color: var(--affine-link-color); + } + .affine-embed-github-content-url:hover .open-icon { + fill: var(--affine-link-color); + } + + .affine-embed-github-content-url-icon { + display: flex; + align-items: center; + justify-content: center; + width: 12px; + height: 12px; + } + .affine-embed-github-content-url-icon .open-icon { + height: 12px; + width: 12px; + fill: var(--affine-text-secondary-color); + } + + .affine-embed-github-banner { + margin: 12px 0px 0px 12px; + width: 204px; + height: 102px; + opacity: var(--add, 1); + } + + .affine-embed-github-banner img, + .affine-embed-github-banner object, + .affine-embed-github-banner svg { + width: 204px; + height: 102px; + object-fit: cover; + border-radius: 4px 4px var(--1, 0px) var(--1, 0px); + } + + .affine-embed-github-block.loading { + .affine-embed-github-content-title-text { + color: var(--affine-placeholder-color); + } + } + + .affine-embed-github-block.selected { + .affine-embed-github-content-url > span { + color: var(--affine-link-color); + } + .affine-embed-github-content-url .open-icon { + fill: var(--affine-link-color); + } + } + + .affine-embed-github-block.list { + height: ${EMBED_CARD_HEIGHT.list}px; + width: ${EMBED_CARD_WIDTH.list}px; + + .affine-embed-github-content { + width: 100%; + flex-direction: row; + align-items: center; + justify-content: space-between; + } + + .affine-embed-github-content-title { + width: 660px; + } + + .affine-embed-github-content-repo { + display: none; + } + + .affine-embed-github-content-date { + display: none; + } + + .affine-embed-github-content-url { + width: 90px; + justify-content: flex-end; + } + + .affine-embed-github-content-description { + display: none; + } + + .affine-embed-github-banner { + display: none; + } + } + + .affine-embed-github-block.horizontal { + width: ${EMBED_CARD_WIDTH.horizontal}px; + height: ${EMBED_CARD_HEIGHT.horizontal}px; + } + + .affine-embed-github-block.vertical { + width: ${EMBED_CARD_WIDTH.vertical}px; + height: ${EMBED_CARD_HEIGHT.vertical}px; + flex-direction: column; + + .affine-embed-github-content { + width: 100%; + } + + .affine-embed-github-content-description { + -webkit-line-clamp: 6; + } + + .affine-embed-github-content-assignees { + display: flex; + padding: var(--1, 0px); + align-items: center; + justify-content: flex-start; + gap: 2px; + align-self: stretch; + } + + .affine-embed-github-content-assignees-text { + display: -webkit-box; + -webkit-line-clamp: 1; + -webkit-box-orient: vertical; + + font-family: var(--affine-font-family); + font-size: var(--affine-font-xs); + font-style: normal; + font-weight: 600; + line-height: 20px; + } + + .affine-embed-github-content-assignees-text.label { + width: 72px; + color: var(--affine-text-primary-color); + font-weight: 600; + } + + .affine-embed-github-content-assignees-text.users { + width: calc(100% - 72px); + word-break: break-all; + white-space: normal; + overflow: hidden; + text-overflow: ellipsis; + font-weight: 400; + } + + .affine-embed-github-content-assignees-text-users.user { + color: var(--affine-link-color); + cursor: pointer; + } + + .affine-embed-github-content-assignees-text-users.placeholder { + color: var(--affine-placeholder-color); + } + + .affine-embed-github-banner { + width: 340px; + height: 170px; + margin-left: 12px; + } + + .affine-embed-github-banner img, + .affine-embed-github-banner object, + .affine-embed-github-banner svg { + width: 340px; + height: 170px; + } + } + + .affine-embed-github-block.cube { + width: ${EMBED_CARD_WIDTH.cube}px; + height: ${EMBED_CARD_HEIGHT.cube}px; + + .affine-embed-github-content { + width: 100%; + flex-direction: column; + align-items: flex-start; + justify-content: space-between; + } + + .affine-embed-github-content-title { + flex-direction: column; + gap: 4px; + align-items: flex-start; + } + + .affine-embed-github-content-title-text { + -webkit-line-clamp: 2; + } + + .affine-embed-github-content-description { + display: none; + } + + .affine-embed-github-banner { + display: none; + } + + .affine-embed-github-content-repo { + display: none; + } + + .affine-embed-github-content-date { + display: none; + } + } +`; + +export const GithubIcon = html` + + `; + +export const GithubIssueOpenIcon = html` + + +`; + +export const GithubIssueClosedSuccessIcon = html``; + +export const GithubIssueClosedFailureIcon = html``; + +export const GithubPROpenIcon = html``; + +export const GithubPRDraftIcon = html``; + +export const GithubPRMergedIcon = html``; + +export const GithubPRClosedIcon = html``; diff --git a/blocksuite/affine/block-embed/src/embed-github-block/utils.ts b/blocksuite/affine/block-embed/src/embed-github-block/utils.ts new file mode 100644 index 0000000000..9aca6a945c --- /dev/null +++ b/blocksuite/affine/block-embed/src/embed-github-block/utils.ts @@ -0,0 +1,170 @@ +import type { + EmbedGithubBlockUrlData, + EmbedGithubModel, +} from '@blocksuite/affine-model'; +import { isAbortError } from '@blocksuite/affine-shared/utils'; +import { assertExists } from '@blocksuite/global/utils'; +import { nothing } from 'lit'; + +import type { LinkPreviewer } from '../common/link-previewer.js'; +import type { EmbedGithubBlockComponent } from './embed-github-block.js'; +import { + GithubIssueClosedFailureIcon, + GithubIssueClosedSuccessIcon, + GithubIssueOpenIcon, + GithubPRClosedIcon, + GithubPRDraftIcon, + GithubPRMergedIcon, + GithubPROpenIcon, +} from './styles.js'; + +export async function queryEmbedGithubData( + embedGithubModel: EmbedGithubModel, + linkPreviewer: LinkPreviewer, + signal?: AbortSignal +): Promise> { + const [githubApiData, openGraphData] = await Promise.all([ + queryEmbedGithubApiData(embedGithubModel, signal), + linkPreviewer.query(embedGithubModel.url, signal), + ]); + return { ...githubApiData, ...openGraphData }; +} + +export async function queryEmbedGithubApiData( + embedGithubModel: EmbedGithubModel, + signal?: AbortSignal +): Promise> { + const { owner, repo, githubType, githubId } = embedGithubModel; + let githubApiData: Partial = {}; + + // github's public api has a rate limit of 60 requests per hour + const apiUrl = `https://api.github.com/repos/${owner}/${repo}/${ + githubType === 'issue' ? 'issues' : 'pulls' + }/${githubId}`; + + const githubApiResponse = await fetch(apiUrl, { + cache: 'no-cache', + signal, + }).catch(() => null); + + if (githubApiResponse && githubApiResponse.ok) { + const githubApiJson = await githubApiResponse.json(); + const { state, state_reason, draft, merged, created_at, assignees } = + githubApiJson; + + const assigneeLogins = assignees.map( + (assignee: { login: string }) => assignee.login + ); + + let status = state; + if (merged) { + status = 'merged'; + } else if (state === 'open' && draft) { + status = 'draft'; + } + + githubApiData = { + status, + statusReason: state_reason, + createdAt: created_at, + assignees: assigneeLogins, + }; + } + + return githubApiData; +} + +export async function refreshEmbedGithubUrlData( + embedGithubElement: EmbedGithubBlockComponent, + signal?: AbortSignal +): Promise { + let image = null, + status = null, + statusReason = null, + title = null, + description = null, + createdAt = null, + assignees = null; + + try { + embedGithubElement.loading = true; + + const queryUrlData = embedGithubElement.service?.queryUrlData; + assertExists(queryUrlData); + + const githubUrlData = await queryUrlData(embedGithubElement.model); + ({ + image = null, + status = null, + statusReason = null, + title = null, + description = null, + createdAt = null, + assignees = null, + } = githubUrlData); + + if (signal?.aborted) return; + + embedGithubElement.doc.updateBlock(embedGithubElement.model, { + image, + status, + statusReason, + title, + description, + createdAt, + assignees, + }); + } catch (error) { + if (signal?.aborted || isAbortError(error)) return; + throw Error; + } finally { + embedGithubElement.loading = false; + } +} + +export async function refreshEmbedGithubStatus( + embedGithubElement: EmbedGithubBlockComponent, + signal?: AbortSignal +) { + const queryApiData = embedGithubElement.service?.queryApiData; + assertExists(queryApiData); + const githubApiData = await queryApiData(embedGithubElement.model, signal); + + if (!githubApiData.status || signal?.aborted) return; + + embedGithubElement.doc.updateBlock(embedGithubElement.model, { + status: githubApiData.status, + statusReason: githubApiData.statusReason, + createdAt: githubApiData.createdAt, + assignees: githubApiData.assignees, + }); +} + +export function getGithubStatusIcon( + type: 'issue' | 'pr', + status: string, + statusReason: string | null +) { + if (type === 'issue') { + if (status === 'open') { + return GithubIssueOpenIcon; + } else if (status === 'closed' && statusReason === 'completed') { + return GithubIssueClosedSuccessIcon; + } else if (status === 'closed' && statusReason === 'not_planned') { + return GithubIssueClosedFailureIcon; + } else { + return nothing; + } + } else if (type === 'pr') { + if (status === 'open') { + return GithubPROpenIcon; + } else if (status === 'draft') { + return GithubPRDraftIcon; + } else if (status === 'merged') { + return GithubPRMergedIcon; + } else if (status === 'closed') { + return GithubPRClosedIcon; + } + } + return nothing; +} diff --git a/blocksuite/affine/block-embed/src/embed-html-block/components/fullscreen-toolbar.ts b/blocksuite/affine/block-embed/src/embed-html-block/components/fullscreen-toolbar.ts new file mode 100644 index 0000000000..5a64af26d5 --- /dev/null +++ b/blocksuite/affine/block-embed/src/embed-html-block/components/fullscreen-toolbar.ts @@ -0,0 +1,171 @@ +import { + menu, + popMenu, + popupTargetFromElement, +} from '@blocksuite/affine-components/context-menu'; +import { EditPropsStore } from '@blocksuite/affine-shared/services'; +import { + CopyIcon, + DoneIcon, + ExpandCloseIcon, + SettingsIcon, +} from '@blocksuite/icons/lit'; +import { autoPlacement, flip, offset } from '@floating-ui/dom'; +import { css, html, LitElement } from 'lit'; +import { property, query, state } from 'lit/decorators.js'; + +import type { EmbedEdgelessHtmlBlockComponent } from '../embed-edgeless-html-block.js'; + +export class EmbedHtmlFullscreenToolbar extends LitElement { + static override styles = css` + :host { + box-sizing: border-box; + position: absolute; + z-index: 1; + left: 50%; + transform: translateX(-50%); + bottom: 0; + -webkit-user-select: none; + user-select: none; + } + + .toolbar-toggle-control { + padding-bottom: 20px; + } + + .toolbar-toggle-control[data-auto-hide='true'] { + transition: 0.27s ease; + padding-top: 100px; + transform: translateY(100px); + } + + .toolbar-toggle-control[data-auto-hide='true']:hover { + padding-top: 0; + transform: translateY(0); + } + + .fullscreen-toolbar-container { + background: var(--affine-background-overlay-panel-color); + box-shadow: var(--affine-menu-shadow); + border: 1px solid var(--affine-border-color); + border-radius: 40px; + + display: flex; + justify-content: space-between; + align-items: center; + gap: 12px; + + padding: 0 20px; + + height: 64px; + } + + .short-v-divider { + display: inline-block; + background-color: var(--affine-border-color); + width: 1px; + height: 36px; + } + `; + + private _popSettings = () => { + this._popperVisible = true; + popMenu(popupTargetFromElement(this._fullScreenToolbarContainer), { + options: { + items: [ + () => + html`
+ Settings +
`, + menu.group({ + name: 'thing', + items: [ + menu.toggleSwitch({ + name: 'Hide toolbar', + on: this.autoHideToolbar, + onChange: on => { + this.autoHideToolbar = on; + }, + }), + ], + }), + ], + onClose: () => { + this._popperVisible = false; + }, + }, + middleware: [ + autoPlacement({ allowedPlacements: ['top-end'] }), + flip(), + offset({ mainAxis: 4, crossAxis: -40 }), + ], + container: this.embedHtml.iframeWrapper, + }); + }; + + copyCode = () => { + if (this._copied) return; + + this.embedHtml.std.clipboard + .writeToClipboard(items => { + items['text/plain'] = this.embedHtml.model.html ?? ''; + return items; + }) + .then(() => { + this._copied = true; + setTimeout(() => (this._copied = false), 1500); + }) + .catch(console.error); + }; + + private get autoHideToolbar() { + return ( + this.embedHtml.std + .get(EditPropsStore) + .getStorage('autoHideEmbedHTMLFullScreenToolbar') ?? false + ); + } + + private set autoHideToolbar(val: boolean) { + this.embedHtml.std + .get(EditPropsStore) + .setStorage('autoHideEmbedHTMLFullScreenToolbar', val); + } + + override render() { + const hideToolbar = !this._popperVisible && this.autoHideToolbar; + + return html` +
+
+ ${ExpandCloseIcon()} + + ${SettingsIcon()} + + +
+ + ${this._copied ? DoneIcon() : CopyIcon()} + +
+
+ `; + } + + @state() + private accessor _copied = false; + + @query('.fullscreen-toolbar-container') + private accessor _fullScreenToolbarContainer!: HTMLElement; + + @state() + private accessor _popperVisible = false; + + @property({ attribute: false }) + accessor embedHtml!: EmbedEdgelessHtmlBlockComponent; +} diff --git a/blocksuite/affine/block-embed/src/embed-html-block/embed-edgeless-html-block.ts b/blocksuite/affine/block-embed/src/embed-html-block/embed-edgeless-html-block.ts new file mode 100644 index 0000000000..86dfee16a9 --- /dev/null +++ b/blocksuite/affine/block-embed/src/embed-html-block/embed-edgeless-html-block.ts @@ -0,0 +1,6 @@ +import { toEdgelessEmbedBlock } from '../common/to-edgeless-embed-block.js'; +import { EmbedHtmlBlockComponent } from './embed-html-block.js'; + +export class EmbedEdgelessHtmlBlockComponent extends toEdgelessEmbedBlock( + EmbedHtmlBlockComponent +) {} diff --git a/blocksuite/affine/block-embed/src/embed-html-block/embed-html-block.ts b/blocksuite/affine/block-embed/src/embed-html-block/embed-html-block.ts new file mode 100644 index 0000000000..a9b4cf588f --- /dev/null +++ b/blocksuite/affine/block-embed/src/embed-html-block/embed-html-block.ts @@ -0,0 +1,145 @@ +import type { EmbedHtmlModel, EmbedHtmlStyles } from '@blocksuite/affine-model'; +import { html } from 'lit'; +import { query, state } from 'lit/decorators.js'; +import { classMap } from 'lit/directives/class-map.js'; +import { type StyleInfo, styleMap } from 'lit/directives/style-map.js'; + +import { EmbedBlockComponent } from '../common/embed-block-element.js'; +import { HtmlIcon, styles } from './styles.js'; + +export class EmbedHtmlBlockComponent extends EmbedBlockComponent { + static override styles = styles; + + override _cardStyle: (typeof EmbedHtmlStyles)[number] = 'html'; + + protected _isDragging = false; + + protected _isResizing = false; + + close = () => { + document.exitFullscreen().catch(console.error); + }; + + protected embedHtmlStyle: StyleInfo = {}; + + open = () => { + this.iframeWrapper?.requestFullscreen().catch(console.error); + }; + + refreshData = () => {}; + + private _handleDoubleClick(event: MouseEvent) { + event.stopPropagation(); + this.open(); + } + + private _selectBlock() { + const selectionManager = this.host.selection; + const blockSelection = selectionManager.create('block', { + blockId: this.blockId, + }); + selectionManager.setGroup('note', [blockSelection]); + } + + protected _handleClick(event: MouseEvent) { + event.stopPropagation(); + this._selectBlock(); + } + + override connectedCallback() { + super.connectedCallback(); + this._cardStyle = this.model.style; + + // this is required to prevent iframe from capturing pointer events + this.disposables.add( + this.std.selection.slots.changed.on(() => { + this._isSelected = + !!this.selected?.is('block') || !!this.selected?.is('surface'); + + this._showOverlay = + this._isResizing || this._isDragging || !this._isSelected; + }) + ); + // this is required to prevent iframe from capturing pointer events + this.handleEvent('dragStart', () => { + this._isDragging = true; + this._showOverlay = + this._isResizing || this._isDragging || !this._isSelected; + }); + + this.handleEvent('dragEnd', () => { + this._isDragging = false; + this._showOverlay = + this._isResizing || this._isDragging || !this._isSelected; + }); + } + + override renderBlock(): unknown { + const titleText = 'Basic HTML Page Structure'; + + const htmlSrc = ` + + ${this.model.html} + `; + + return this.renderEmbed(() => { + if (!this.model.html) { + return html`
Empty
`; + } + return html` +
+
+
+
+ + +
+ +
+
+
+ +
+
${HtmlIcon}
+ +
${titleText}
+
+
+ `; + }); + } + + @state() + protected accessor _isSelected = false; + + @state() + protected accessor _showOverlay = true; + + @query('.embed-html-block-iframe-wrapper') + accessor iframeWrapper!: HTMLDivElement; +} diff --git a/blocksuite/affine/block-embed/src/embed-html-block/embed-html-spec.ts b/blocksuite/affine/block-embed/src/embed-html-block/embed-html-spec.ts new file mode 100644 index 0000000000..3ae016592f --- /dev/null +++ b/blocksuite/affine/block-embed/src/embed-html-block/embed-html-spec.ts @@ -0,0 +1,10 @@ +import { BlockViewExtension, type ExtensionType } from '@blocksuite/block-std'; +import { literal } from 'lit/static-html.js'; + +export const EmbedHtmlBlockSpec: ExtensionType[] = [ + BlockViewExtension('affine:embed-html', model => { + return model.parent?.flavour === 'affine:surface' + ? literal`affine-embed-edgeless-html-block` + : literal`affine-embed-html-block`; + }), +]; diff --git a/blocksuite/affine/block-embed/src/embed-html-block/index.ts b/blocksuite/affine/block-embed/src/embed-html-block/index.ts new file mode 100644 index 0000000000..a444319fdb --- /dev/null +++ b/blocksuite/affine/block-embed/src/embed-html-block/index.ts @@ -0,0 +1,7 @@ +export * from './embed-html-block.js'; +export * from './embed-html-spec.js'; +export { + EMBED_HTML_MIN_HEIGHT, + EMBED_HTML_MIN_WIDTH, + HtmlIcon, +} from './styles.js'; diff --git a/blocksuite/affine/block-embed/src/embed-html-block/styles.ts b/blocksuite/affine/block-embed/src/embed-html-block/styles.ts new file mode 100644 index 0000000000..b832a9a4a3 --- /dev/null +++ b/blocksuite/affine/block-embed/src/embed-html-block/styles.ts @@ -0,0 +1,150 @@ +import { css, html } from 'lit'; + +export const EMBED_HTML_MIN_WIDTH = 370; +export const EMBED_HTML_MIN_HEIGHT = 80; + +export const styles = css` + .affine-embed-html-block { + box-sizing: border-box; + width: 100%; + height: 100%; + display: flex; + padding: 12px; + flex-direction: column; + align-items: flex-start; + gap: 20px; + + border-radius: 12px; + border: 1px solid var(--affine-background-tertiary-color); + + opacity: var(--add, 1); + background: var(--affine-background-primary-color); + user-select: none; + } + + .affine-embed-html { + flex-grow: 1; + width: 100%; + opacity: var(--add, 1); + } + + .affine-embed-html img, + .affine-embed-html object, + .affine-embed-html svg { + width: 100%; + height: 100%; + object-fit: cover; + border-radius: 4px 4px var(--1, 0px) var(--1, 0px); + } + + .affine-embed-html-iframe-container { + position: relative; + width: 100%; + height: 100%; + border-radius: 4px 4px 0px 0px; + box-shadow: var(--affine-shadow-1); + overflow: hidden; + } + + .embed-html-block-iframe-wrapper { + position: relative; + width: 100%; + height: 100%; + } + + .embed-html-block-iframe-wrapper > iframe { + width: 100%; + height: 100%; + border: none; + } + + .embed-html-block-iframe-wrapper affine-menu { + min-width: 296px; + } + + .embed-html-block-iframe-wrapper affine-menu .settings-header { + padding: 7px 12px; + font-weight: 500; + font-size: var(--affine-font-xs); + color: var(--affine-text-secondary-color); + } + + .embed-html-block-iframe-wrapper > embed-html-fullscreen-toolbar { + visibility: hidden; + } + + .embed-html-block-iframe-wrapper:fullscreen > embed-html-fullscreen-toolbar { + visibility: visible; + } + + .affine-embed-html-iframe-overlay { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + } + + .affine-embed-html-iframe-overlay.hide { + display: none; + } + + .affine-embed-html-title { + height: fit-content; + display: flex; + align-items: center; + gap: 8px; + + padding: var(--1, 0px); + border-radius: var(--1, 0px); + opacity: var(--add, 1); + } + + .affine-embed-html-title-icon { + display: flex; + width: 20px; + height: 20px; + justify-content: center; + align-items: center; + } + + .affine-embed-html-title-icon img, + .affine-embed-html-title-icon object, + .affine-embed-html-title-icon svg { + width: 20px; + height: 20px; + fill: var(--affine-background-primary-color); + } + + .affine-embed-html-title-text { + display: -webkit-box; + -webkit-line-clamp: 1; + -webkit-box-orient: vertical; + + word-break: break-word; + overflow: hidden; + text-overflow: ellipsis; + color: var(--affine-text-primary-color); + + font-family: var(--affine-font-family); + font-size: var(--affine-font-sm); + font-style: normal; + font-weight: 600; + line-height: 22px; + } +`; + +export const HtmlIcon = html` + + `; diff --git a/blocksuite/affine/block-embed/src/embed-linked-doc-block/adapters/html.ts b/blocksuite/affine/block-embed/src/embed-linked-doc-block/adapters/html.ts new file mode 100644 index 0000000000..aa29fdab01 --- /dev/null +++ b/blocksuite/affine/block-embed/src/embed-linked-doc-block/adapters/html.ts @@ -0,0 +1,64 @@ +import { EmbedLinkedDocBlockSchema } from '@blocksuite/affine-model'; +import { + BlockHtmlAdapterExtension, + type BlockHtmlAdapterMatcher, +} from '@blocksuite/affine-shared/adapters'; + +import { generateDocUrl } from '../../common/adapters/utils.js'; + +export const embedLinkedDocBlockHtmlAdapterMatcher: BlockHtmlAdapterMatcher = { + flavour: EmbedLinkedDocBlockSchema.model.flavour, + toMatch: () => false, + fromMatch: o => o.node.flavour === EmbedLinkedDocBlockSchema.model.flavour, + toBlockSnapshot: {}, + fromBlockSnapshot: { + enter: (o, context) => { + const { configs, walkerContext } = context; + // Parse as link + if (!o.node.props.pageId) { + return; + } + const title = configs.get('title:' + o.node.props.pageId) ?? 'untitled'; + const url = generateDocUrl( + configs.get('docLinkBaseUrl') ?? '', + String(o.node.props.pageId), + o.node.props.params ?? Object.create(null) + ); + + walkerContext + .openNode( + { + type: 'element', + tagName: 'div', + properties: { + className: ['affine-paragraph-block-container'], + }, + children: [], + }, + 'children' + ) + .openNode( + { + type: 'element', + tagName: 'a', + properties: { + href: url, + }, + children: [ + { + type: 'text', + value: title, + }, + ], + }, + 'children' + ) + .closeNode() + .closeNode(); + }, + }, +}; + +export const EmbedLinkedDocHtmlAdapterExtension = BlockHtmlAdapterExtension( + embedLinkedDocBlockHtmlAdapterMatcher +); diff --git a/blocksuite/affine/block-embed/src/embed-linked-doc-block/adapters/index.ts b/blocksuite/affine/block-embed/src/embed-linked-doc-block/adapters/index.ts new file mode 100644 index 0000000000..c1d476903d --- /dev/null +++ b/blocksuite/affine/block-embed/src/embed-linked-doc-block/adapters/index.ts @@ -0,0 +1,3 @@ +export * from './html.js'; +export * from './markdown.js'; +export * from './plain-text.js'; diff --git a/blocksuite/affine/block-embed/src/embed-linked-doc-block/adapters/markdown.ts b/blocksuite/affine/block-embed/src/embed-linked-doc-block/adapters/markdown.ts new file mode 100644 index 0000000000..5dce356718 --- /dev/null +++ b/blocksuite/affine/block-embed/src/embed-linked-doc-block/adapters/markdown.ts @@ -0,0 +1,57 @@ +import { EmbedLinkedDocBlockSchema } from '@blocksuite/affine-model'; +import { + BlockMarkdownAdapterExtension, + type BlockMarkdownAdapterMatcher, +} from '@blocksuite/affine-shared/adapters'; + +import { generateDocUrl } from '../../common/adapters/utils.js'; + +export const embedLinkedDocBlockMarkdownAdapterMatcher: BlockMarkdownAdapterMatcher = + { + flavour: EmbedLinkedDocBlockSchema.model.flavour, + toMatch: () => false, + fromMatch: o => o.node.flavour === EmbedLinkedDocBlockSchema.model.flavour, + toBlockSnapshot: {}, + fromBlockSnapshot: { + enter: (o, context) => { + const { configs, walkerContext } = context; + // Parse as link + if (!o.node.props.pageId) { + return; + } + const title = configs.get('title:' + o.node.props.pageId) ?? 'untitled'; + const url = generateDocUrl( + configs.get('docLinkBaseUrl') ?? '', + String(o.node.props.pageId), + o.node.props.params ?? Object.create(null) + ); + walkerContext + .openNode( + { + type: 'paragraph', + children: [], + }, + 'children' + ) + .openNode( + { + type: 'link', + url, + title: o.node.props.caption as string | null, + children: [ + { + type: 'text', + value: title, + }, + ], + }, + 'children' + ) + .closeNode() + .closeNode(); + }, + }, + }; + +export const EmbedLinkedDocMarkdownAdapterExtension = + BlockMarkdownAdapterExtension(embedLinkedDocBlockMarkdownAdapterMatcher); diff --git a/blocksuite/affine/block-embed/src/embed-linked-doc-block/adapters/plain-text.ts b/blocksuite/affine/block-embed/src/embed-linked-doc-block/adapters/plain-text.ts new file mode 100644 index 0000000000..439ca333c9 --- /dev/null +++ b/blocksuite/affine/block-embed/src/embed-linked-doc-block/adapters/plain-text.ts @@ -0,0 +1,34 @@ +import { EmbedLinkedDocBlockSchema } from '@blocksuite/affine-model'; +import { + BlockPlainTextAdapterExtension, + type BlockPlainTextAdapterMatcher, +} from '@blocksuite/affine-shared/adapters'; + +import { generateDocUrl } from '../../common/adapters/utils.js'; + +export const embedLinkedDocBlockPlainTextAdapterMatcher: BlockPlainTextAdapterMatcher = + { + flavour: EmbedLinkedDocBlockSchema.model.flavour, + toMatch: () => false, + fromMatch: o => o.node.flavour === EmbedLinkedDocBlockSchema.model.flavour, + toBlockSnapshot: {}, + fromBlockSnapshot: { + enter: (o, context) => { + const { configs, textBuffer } = context; + // Parse as link + if (!o.node.props.pageId) { + return; + } + const title = configs.get('title:' + o.node.props.pageId) ?? 'untitled'; + const url = generateDocUrl( + configs.get('docLinkBaseUrl') ?? '', + String(o.node.props.pageId), + o.node.props.params ?? Object.create(null) + ); + textBuffer.content += `${title}: ${url}\n`; + }, + }, + }; + +export const EmbedLinkedDocBlockPlainTextAdapterExtension = + BlockPlainTextAdapterExtension(embedLinkedDocBlockPlainTextAdapterMatcher); diff --git a/blocksuite/affine/block-embed/src/embed-linked-doc-block/commands/index.ts b/blocksuite/affine/block-embed/src/embed-linked-doc-block/commands/index.ts new file mode 100644 index 0000000000..c9e4789658 --- /dev/null +++ b/blocksuite/affine/block-embed/src/embed-linked-doc-block/commands/index.ts @@ -0,0 +1,9 @@ +import type { BlockCommands } from '@blocksuite/block-std'; + +import { insertEmbedLinkedDocCommand } from './insert-embed-linked-doc.js'; +import { insertLinkByQuickSearchCommand } from './insert-link-by-quick-search.js'; + +export const commands: BlockCommands = { + insertEmbedLinkedDoc: insertEmbedLinkedDocCommand, + insertLinkByQuickSearch: insertLinkByQuickSearchCommand, +}; diff --git a/blocksuite/affine/block-embed/src/embed-linked-doc-block/commands/insert-embed-linked-doc.ts b/blocksuite/affine/block-embed/src/embed-linked-doc-block/commands/insert-embed-linked-doc.ts new file mode 100644 index 0000000000..917557f233 --- /dev/null +++ b/blocksuite/affine/block-embed/src/embed-linked-doc-block/commands/insert-embed-linked-doc.ts @@ -0,0 +1,21 @@ +import type { EmbedCardStyle, ReferenceParams } from '@blocksuite/affine-model'; +import type { Command } from '@blocksuite/block-std'; + +import { insertEmbedCard } from '../../common/insert-embed-card.js'; + +export const insertEmbedLinkedDocCommand: Command< + never, + 'insertedLinkType', + { + docId: string; + params?: ReferenceParams; + } +> = (ctx, next) => { + const { docId, params, std } = ctx; + const flavour = 'affine:embed-linked-doc'; + const targetStyle: EmbedCardStyle = 'vertical'; + const props: Record = { pageId: docId }; + if (params) props.params = params; + insertEmbedCard(std, { flavour, targetStyle, props }); + next(); +}; diff --git a/blocksuite/affine/block-embed/src/embed-linked-doc-block/commands/insert-link-by-quick-search.ts b/blocksuite/affine/block-embed/src/embed-linked-doc-block/commands/insert-link-by-quick-search.ts new file mode 100644 index 0000000000..94f75bc8f1 --- /dev/null +++ b/blocksuite/affine/block-embed/src/embed-linked-doc-block/commands/insert-link-by-quick-search.ts @@ -0,0 +1,48 @@ +import { QuickSearchProvider } from '@blocksuite/affine-shared/services'; +import type { Command } from '@blocksuite/block-std'; + +export type InsertedLinkType = { + flavour?: 'affine:bookmark' | 'affine:embed-linked-doc'; +} | null; + +export const insertLinkByQuickSearchCommand: Command< + never, + 'insertedLinkType' +> = (ctx, next) => { + const { std } = ctx; + const quickSearchService = std.getOptional(QuickSearchProvider); + if (!quickSearchService) { + next(); + return; + } + + const insertedLinkType: Promise = quickSearchService + .openQuickSearch() + .then(result => { + if (!result) return null; + + // add linked doc + if ('docId' in result) { + std.command.exec('insertEmbedLinkedDoc', { + docId: result.docId, + params: result.params, + }); + return { + flavour: 'affine:embed-linked-doc', + }; + } + + // add normal link; + if ('externalUrl' in result) { + // @ts-expect-error TODO: fix after bookmark refactor + std.command.exec('insertBookmark', { url: result.externalUrl }); + return { + flavour: 'affine:bookmark', + }; + } + + return null; + }); + + next({ insertedLinkType }); +}; diff --git a/blocksuite/affine/block-embed/src/embed-linked-doc-block/embed-edgeless-linked-doc-block.ts b/blocksuite/affine/block-embed/src/embed-linked-doc-block/embed-edgeless-linked-doc-block.ts new file mode 100644 index 0000000000..7b9d42e21f --- /dev/null +++ b/blocksuite/affine/block-embed/src/embed-linked-doc-block/embed-edgeless-linked-doc-block.ts @@ -0,0 +1,72 @@ +import { + EMBED_CARD_HEIGHT, + EMBED_CARD_WIDTH, +} from '@blocksuite/affine-shared/consts'; +import { cloneReferenceInfoWithoutAliases } from '@blocksuite/affine-shared/utils'; +import { Bound } from '@blocksuite/global/utils'; + +import { toEdgelessEmbedBlock } from '../common/to-edgeless-embed-block.js'; +import { EmbedLinkedDocBlockComponent } from './embed-linked-doc-block.js'; + +export class EmbedEdgelessLinkedDocBlockComponent extends toEdgelessEmbedBlock( + EmbedLinkedDocBlockComponent +) { + override convertToEmbed = () => { + const { id, doc, caption, xywh } = this.model; + + // synced doc entry controlled by awareness flag + const isSyncedDocEnabled = doc.awarenessStore.getFlag( + 'enable_synced_doc_block' + ); + if (!isSyncedDocEnabled) { + return; + } + + const style = 'syncedDoc'; + const bound = Bound.deserialize(xywh); + bound.w = EMBED_CARD_WIDTH[style]; + bound.h = EMBED_CARD_HEIGHT[style]; + + const edgelessService = this.rootService; + + if (!edgelessService) { + return; + } + + // @ts-expect-error TODO: fix after edgeless refactor + const newId = edgelessService.addBlock( + 'affine:embed-synced-doc', + { + xywh: bound.serialize(), + caption, + ...cloneReferenceInfoWithoutAliases(this.referenceInfo$.peek()), + }, + // @ts-expect-error TODO: fix after edgeless refactor + edgelessService.surface + ); + + this.std.command.exec('reassociateConnectors', { + oldId: id, + newId, + }); + + // @ts-expect-error TODO: fix after edgeless refactor + edgelessService.selection.set({ + editing: false, + elements: [newId], + }); + + doc.deleteBlock(this.model); + }; + + get rootService() { + return this.std.getService('affine:page'); + } + + protected override _handleClick(evt: MouseEvent): void { + if (this.config.handleClick) { + this.config.handleClick(evt, this.host, this.referenceInfo$.peek()); + return; + } + } +} diff --git a/blocksuite/affine/block-embed/src/embed-linked-doc-block/embed-linked-doc-block.ts b/blocksuite/affine/block-embed/src/embed-linked-doc-block/embed-linked-doc-block.ts new file mode 100644 index 0000000000..83fcf51cc2 --- /dev/null +++ b/blocksuite/affine/block-embed/src/embed-linked-doc-block/embed-linked-doc-block.ts @@ -0,0 +1,557 @@ +import { isPeekable, Peekable } from '@blocksuite/affine-components/peek'; +import { + REFERENCE_NODE, + RefNodeSlotsProvider, +} from '@blocksuite/affine-components/rich-text'; +import type { + DocMode, + EmbedLinkedDocModel, + EmbedLinkedDocStyles, +} from '@blocksuite/affine-model'; +import { + EMBED_CARD_HEIGHT, + EMBED_CARD_WIDTH, +} from '@blocksuite/affine-shared/consts'; +import { + DocDisplayMetaProvider, + DocModeProvider, + ThemeProvider, +} from '@blocksuite/affine-shared/services'; +import { + cloneReferenceInfo, + cloneReferenceInfoWithoutAliases, + matchFlavours, + referenceToNode, +} from '@blocksuite/affine-shared/utils'; +import { Bound } from '@blocksuite/global/utils'; +import { DocCollection } from '@blocksuite/store'; +import { computed } from '@preact/signals-core'; +import { html, nothing } from 'lit'; +import { property, queryAsync, state } from 'lit/decorators.js'; +import { classMap } from 'lit/directives/class-map.js'; +import { styleMap } from 'lit/directives/style-map.js'; +import { when } from 'lit/directives/when.js'; + +import { EmbedBlockComponent } from '../common/embed-block-element.js'; +import { renderLinkedDocInCard } from '../common/render-linked-doc.js'; +import { SyncedDocErrorIcon } from '../embed-synced-doc-block/styles.js'; +import { + type EmbedLinkedDocBlockConfig, + EmbedLinkedDocBlockConfigIdentifier, +} from './embed-linked-doc-config.js'; +import { styles } from './styles.js'; +import { getEmbedLinkedDocIcons } from './utils.js'; + +@Peekable({ + enableOn: ({ doc }: EmbedLinkedDocBlockComponent) => !doc.readonly, +}) +export class EmbedLinkedDocBlockComponent extends EmbedBlockComponent { + static override styles = styles; + + private _load = async () => { + const { + loading = true, + isError = false, + isBannerEmpty = true, + isNoteContentEmpty = true, + } = this.getInitialState(); + + this._loading = loading; + this.isError = isError; + this.isBannerEmpty = isBannerEmpty; + this.isNoteContentEmpty = isNoteContentEmpty; + + if (!this._loading) { + return; + } + + const linkedDoc = this.linkedDoc; + if (!linkedDoc) { + this._loading = false; + return; + } + + if (!linkedDoc.loaded) { + try { + linkedDoc.load(); + } catch (e) { + console.error(e); + this.isError = true; + } + } + + if (!this.isError && !linkedDoc.root) { + await new Promise(resolve => { + linkedDoc.slots.rootAdded.once(() => { + resolve(); + }); + }); + } + + this._loading = false; + + // If it is a link to a block or element, the content will not be rendered. + if (this._referenceToNode) { + return; + } + + if (!this.isError) { + const cardStyle = this.model.style; + if (cardStyle === 'horizontal' || cardStyle === 'vertical') { + renderLinkedDocInCard(this); + } + } + }; + + private _selectBlock = () => { + const selectionManager = this.host.selection; + const blockSelection = selectionManager.create('block', { + blockId: this.blockId, + }); + selectionManager.setGroup('note', [blockSelection]); + }; + + private _setDocUpdatedAt = () => { + const meta = this.doc.collection.meta.getDocMeta(this.model.pageId); + if (meta) { + const date = meta.updatedDate || meta.createDate; + this._docUpdatedAt = new Date(date); + } + }; + + override _cardStyle: (typeof EmbedLinkedDocStyles)[number] = 'horizontal'; + + convertToEmbed = () => { + if (this._referenceToNode) return; + + const { doc, caption } = this.model; + + // synced doc entry controlled by awareness flag + const isSyncedDocEnabled = doc.awarenessStore.getFlag( + 'enable_synced_doc_block' + ); + if (!isSyncedDocEnabled) { + return; + } + + const parent = doc.getParent(this.model); + if (!parent) { + return; + } + const index = parent.children.indexOf(this.model); + + doc.addBlock( + 'affine:embed-synced-doc', + { + caption, + ...cloneReferenceInfoWithoutAliases(this.referenceInfo$.peek()), + }, + parent, + index + ); + + this.std.selection.setGroup('note', []); + doc.deleteBlock(this.model); + }; + + covertToInline = () => { + const { doc } = this.model; + const parent = doc.getParent(this.model); + if (!parent) { + return; + } + const index = parent.children.indexOf(this.model); + + const yText = new DocCollection.Y.Text(); + yText.insert(0, REFERENCE_NODE); + yText.format(0, REFERENCE_NODE.length, { + reference: { + type: 'LinkedPage', + ...this.referenceInfo$.peek(), + }, + }); + const text = new doc.Text(yText); + + doc.addBlock( + 'affine:paragraph', + { + text, + }, + parent, + index + ); + + doc.deleteBlock(this.model); + }; + + referenceInfo$ = computed(() => { + const { pageId, params, title$, description$ } = this.model; + return cloneReferenceInfo({ + pageId, + params, + title: title$.value, + description: description$.value, + }); + }); + + icon$ = computed(() => { + const { pageId, params, title } = this.referenceInfo$.value; + return this.std + .get(DocDisplayMetaProvider) + .icon(pageId, { params, title, referenced: true }).value; + }); + + open = () => { + this.std + .getOptional(RefNodeSlotsProvider) + ?.docLinkClicked.emit(this.referenceInfo$.peek()); + }; + + refreshData = () => { + this._load().catch(e => { + console.error(e); + this.isError = true; + }); + }; + + title$ = computed(() => { + const { pageId, params, title } = this.referenceInfo$.value; + return ( + title || + this.std + .get(DocDisplayMetaProvider) + .title(pageId, { params, title, referenced: true }) + ); + }); + + get config(): EmbedLinkedDocBlockConfig { + return ( + this.std.provider.getOptional(EmbedLinkedDocBlockConfigIdentifier) || {} + ); + } + + get docTitle() { + return this.model.title || this.linkedDoc?.meta?.title || 'Untitled'; + } + + get editorMode() { + return this._linkedDocMode; + } + + get linkedDoc() { + return this.std.collection.getDoc(this.model.pageId); + } + + private _handleDoubleClick(event: MouseEvent) { + if (this.config.handleDoubleClick) { + this.config.handleDoubleClick( + event, + this.host, + this.referenceInfo$.peek() + ); + if (event.defaultPrevented) { + return; + } + } + + if (isPeekable(this)) { + return; + } + event.stopPropagation(); + this.open(); + } + + private _isDocEmpty() { + const linkedDoc = this.linkedDoc; + if (!linkedDoc) { + return false; + } + return !!linkedDoc && this.isNoteContentEmpty && this.isBannerEmpty; + } + + protected _handleClick(event: MouseEvent) { + if (this.config.handleClick) { + this.config.handleClick(event, this.host, this.referenceInfo$.peek()); + if (event.defaultPrevented) { + return; + } + } + + this._selectBlock(); + } + + override connectedCallback() { + super.connectedCallback(); + + this._cardStyle = this.model.style; + this._referenceToNode = referenceToNode(this.model); + + this._load().catch(e => { + console.error(e); + this.isError = true; + }); + + const linkedDoc = this.linkedDoc; + if (linkedDoc) { + this.disposables.add( + linkedDoc.collection.meta.docMetaUpdated.on(() => { + this._load().catch(e => { + console.error(e); + this.isError = true; + }); + }) + ); + this.disposables.add( + linkedDoc.slots.blockUpdated.on(payload => { + if ( + payload.type === 'update' && + ['', 'caption', 'xywh'].includes(payload.props.key) + ) { + return; + } + + if (payload.type === 'add' && payload.init) { + return; + } + + this._load().catch(e => { + console.error(e); + this.isError = true; + }); + }) + ); + + this._setDocUpdatedAt(); + this.disposables.add( + this.doc.collection.meta.docMetaUpdated.on(() => { + this._setDocUpdatedAt(); + }) + ); + + if (this._referenceToNode) { + this._linkedDocMode = this.model.params?.mode ?? 'page'; + } else { + const docMode = this.std.get(DocModeProvider); + this._linkedDocMode = docMode.getPrimaryMode(this.model.pageId); + this.disposables.add( + docMode.onPrimaryModeChange(mode => { + this._linkedDocMode = mode; + }, this.model.pageId) + ); + } + } + + this.disposables.add( + this.model.propsUpdated.on(({ key }) => { + if (key === 'style') { + this._cardStyle = this.model.style; + } + if (key === 'pageId' || key === 'style') { + this._load().catch(e => { + console.error(e); + this.isError = true; + }); + } + }) + ); + } + + getInitialState(): { + loading?: boolean; + isError?: boolean; + isNoteContentEmpty?: boolean; + isBannerEmpty?: boolean; + } { + return {}; + } + + override renderBlock() { + const linkedDoc = this.linkedDoc; + const isDeleted = !linkedDoc; + const isLoading = this._loading; + const isError = this.isError; + const isEmpty = this._isDocEmpty() && this.isBannerEmpty; + const inCanvas = matchFlavours(this.model.parent, ['affine:surface']); + + const cardClassMap = classMap({ + loading: isLoading, + error: isError, + deleted: isDeleted, + empty: isEmpty, + 'banner-empty': this.isBannerEmpty, + 'note-empty': this.isNoteContentEmpty, + 'in-canvas': inCanvas, + [this._cardStyle]: true, + }); + + const theme = this.std.get(ThemeProvider).theme; + const { + LoadingIcon, + ReloadIcon, + LinkedDocDeletedBanner, + LinkedDocEmptyBanner, + SyncedDocErrorBanner, + } = getEmbedLinkedDocIcons(theme, this._linkedDocMode, this._cardStyle); + + const icon = isError + ? SyncedDocErrorIcon + : isLoading + ? LoadingIcon + : this.icon$.value; + const title = isLoading ? 'Loading...' : this.title$; + const description = this.model.description$; + + const showDefaultNoteContent = isError || isLoading || isDeleted || isEmpty; + const defaultNoteContent = isError + ? 'This linked doc failed to load.' + : isLoading + ? '' + : isDeleted + ? 'This linked doc is deleted.' + : isEmpty + ? 'Preview of the doc will be displayed here.' + : ''; + + const dateText = + this._cardStyle === 'cube' + ? this._docUpdatedAt.toLocaleTimeString() + : this._docUpdatedAt.toLocaleString(); + + const showDefaultBanner = isError || isLoading || isDeleted || isEmpty; + + const defaultBanner = isError + ? SyncedDocErrorBanner + : isLoading + ? LinkedDocEmptyBanner + : isDeleted + ? LinkedDocDeletedBanner + : LinkedDocEmptyBanner; + + const hasDescriptionAlias = Boolean(description.value); + + return this.renderEmbed( + () => html` +
+
+
+
+ ${icon} +
+ +
+ ${title} +
+
+ + ${when( + hasDescriptionAlias, + () => + html`
+ ${description} +
`, + () => + when( + showDefaultNoteContent, + () => html` +
+ ${defaultNoteContent} +
+ `, + () => html` +
+ ` + ) + )} + ${isError + ? html` +
+
+ ${ReloadIcon} Reload +
+
+ ` + : html` + + `} +
+ + ${showDefaultBanner + ? html` +
+ ${defaultBanner} +
+ ` + : nothing} +
+ ` + ); + } + + override updated() { + // update card style when linked doc deleted + const linkedDoc = this.linkedDoc; + const { xywh, style } = this.model; + const bound = Bound.deserialize(xywh); + if (linkedDoc && style === 'horizontalThin') { + bound.w = EMBED_CARD_WIDTH.horizontal; + bound.h = EMBED_CARD_HEIGHT.horizontal; + this.doc.withoutTransact(() => { + this.doc.updateBlock(this.model, { + xywh: bound.serialize(), + style: 'horizontal', + }); + }); + } else if (!linkedDoc && style === 'horizontal') { + bound.w = EMBED_CARD_WIDTH.horizontalThin; + bound.h = EMBED_CARD_HEIGHT.horizontalThin; + this.doc.withoutTransact(() => { + this.doc.updateBlock(this.model, { + xywh: bound.serialize(), + style: 'horizontalThin', + }); + }); + } + } + + @state() + private accessor _docUpdatedAt: Date = new Date(); + + @state() + private accessor _linkedDocMode: DocMode = 'page'; + + @state() + private accessor _loading = false; + + // reference to block/element + @state() + private accessor _referenceToNode = false; + + @property({ attribute: false }) + accessor isBannerEmpty = false; + + @property({ attribute: false }) + accessor isError = false; + + @property({ attribute: false }) + accessor isNoteContentEmpty = false; + + @queryAsync('.affine-embed-linked-doc-content-note.render') + accessor noteContainer!: Promise; +} diff --git a/blocksuite/affine/block-embed/src/embed-linked-doc-block/embed-linked-doc-config.ts b/blocksuite/affine/block-embed/src/embed-linked-doc-block/embed-linked-doc-config.ts new file mode 100644 index 0000000000..bf3202fe3e --- /dev/null +++ b/blocksuite/affine/block-embed/src/embed-linked-doc-block/embed-linked-doc-config.ts @@ -0,0 +1,29 @@ +import type { ReferenceInfo } from '@blocksuite/affine-model'; +import type { EditorHost, ExtensionType } from '@blocksuite/block-std'; +import { createIdentifier } from '@blocksuite/global/di'; + +export interface EmbedLinkedDocBlockConfig { + handleClick?: ( + e: MouseEvent, + host: EditorHost, + referenceInfo: ReferenceInfo + ) => void; + handleDoubleClick?: ( + e: MouseEvent, + host: EditorHost, + referenceInfo: ReferenceInfo + ) => void; +} + +export const EmbedLinkedDocBlockConfigIdentifier = + createIdentifier('EmbedLinkedDocBlockConfig'); + +export function EmbedLinkedDocBlockConfigExtension( + config: EmbedLinkedDocBlockConfig +): ExtensionType { + return { + setup: di => { + di.addImpl(EmbedLinkedDocBlockConfigIdentifier, () => config); + }, + }; +} diff --git a/blocksuite/affine/block-embed/src/embed-linked-doc-block/embed-linked-doc-spec.ts b/blocksuite/affine/block-embed/src/embed-linked-doc-block/embed-linked-doc-spec.ts new file mode 100644 index 0000000000..7b6289c465 --- /dev/null +++ b/blocksuite/affine/block-embed/src/embed-linked-doc-block/embed-linked-doc-spec.ts @@ -0,0 +1,17 @@ +import { + BlockViewExtension, + CommandExtension, + type ExtensionType, +} from '@blocksuite/block-std'; +import { literal } from 'lit/static-html.js'; + +import { commands } from './commands/index.js'; + +export const EmbedLinkedDocBlockSpec: ExtensionType[] = [ + CommandExtension(commands), + BlockViewExtension('affine:embed-linked-doc', model => { + return model.parent?.flavour === 'affine:surface' + ? literal`affine-embed-edgeless-linked-doc-block` + : literal`affine-embed-linked-doc-block`; + }), +]; diff --git a/blocksuite/affine/block-embed/src/embed-linked-doc-block/index.ts b/blocksuite/affine/block-embed/src/embed-linked-doc-block/index.ts new file mode 100644 index 0000000000..3e908a6c00 --- /dev/null +++ b/blocksuite/affine/block-embed/src/embed-linked-doc-block/index.ts @@ -0,0 +1,4 @@ +export * from './adapters/index.js'; +export * from './embed-linked-doc-block.js'; +export * from './embed-linked-doc-config.js'; +export * from './embed-linked-doc-spec.js'; diff --git a/blocksuite/affine/block-embed/src/embed-linked-doc-block/styles.ts b/blocksuite/affine/block-embed/src/embed-linked-doc-block/styles.ts new file mode 100644 index 0000000000..2491fc7272 --- /dev/null +++ b/blocksuite/affine/block-embed/src/embed-linked-doc-block/styles.ts @@ -0,0 +1,2247 @@ +import { + EMBED_CARD_HEIGHT, + EMBED_CARD_MIN_WIDTH, + EMBED_CARD_WIDTH, +} from '@blocksuite/affine-shared/consts'; +import { css, html } from 'lit'; + +import { embedNoteContentStyles } from '../common/embed-note-content-styles.js'; + +export const styles = css` + .affine-embed-linked-doc-block { + box-sizing: border-box; + display: flex; + width: ${EMBED_CARD_WIDTH.horizontal}px; + border-radius: 8px; + border: 1px solid var(--affine-background-tertiary-color); + opacity: var(--add, 1); + background: var(--affine-background-primary-color); + user-select: none; + position: relative; + } + + .affine-embed-linked-doc-block.horizontal { + width: ${EMBED_CARD_WIDTH.horizontal}px; + height: ${EMBED_CARD_HEIGHT.horizontal}px; + } + + .affine-embed-linked-doc-content { + flex-grow: 1; + height: 100%; + display: flex; + flex-direction: column; + align-self: stretch; + gap: 4px; + padding: 12px; + border-radius: var(--1, 0px); + opacity: var(--add, 1); + } + + .affine-embed-linked-doc-content-title { + display: flex; + flex-direction: row; + gap: 8px; + align-items: center; + align-self: stretch; + padding: var(--1, 0px); + border-radius: var(--1, 0px); + opacity: var(--add, 1); + } + + .affine-embed-linked-doc-content-title-icon { + display: flex; + width: 16px; + height: 16px; + justify-content: center; + align-items: center; + } + .affine-embed-linked-doc-content-title-icon img, + .affine-embed-linked-doc-content-title-icon object, + .affine-embed-linked-doc-content-title-icon svg { + width: 16px; + height: 16px; + fill: var(--affine-background-primary-color); + } + + .affine-embed-linked-doc-content-title-text { + flex-grow: 1; + position: relative; + height: 22px; + word-break: break-word; + overflow: hidden; + text-overflow: ellipsis; + color: var(--affine-text-primary-color); + font-family: var(--affine-font-family); + font-size: var(--affine-font-sm); + font-style: normal; + font-weight: 600; + line-height: 22px; + } + + .affine-embed-linked-doc-content-note.render { + display: none; + overflow: hidden; + pointer-events: none; + flex: 1; + } + + ${embedNoteContentStyles} + + .affine-embed-linked-doc-content-note.alias, + .affine-embed-linked-doc-content-note.default { + flex: 1; + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + position: relative; + white-space: normal; + word-break: break-word; + overflow: hidden; + text-overflow: ellipsis; + color: var(--affine-placeholder-color); + font-family: var(--affine-font-family); + font-size: var(--affine-font-xs); + font-style: normal; + font-weight: 400; + line-height: 20px; + } + + .affine-embed-linked-doc-content-note.alias { + color: var(--affine-text-primary-color); + } + + .affine-embed-linked-doc-card-content-reload, + .affine-embed-linked-doc-content-date { + display: flex; + height: 20px; + align-items: flex-end; + justify-content: flex-start; + gap: 8px; + width: max-content; + max-width: 100%; + line-height: 20px; + } + + .affine-embed-linked-doc-card-content-reload-button { + display: flex; + flex-direction: row; + align-items: center; + gap: 4px; + cursor: pointer; + } + .affine-embed-linked-doc-card-content-reload-button svg { + width: 12px; + height: 12px; + fill: var(--affine-background-primary-color); + } + .affine-embed-linked-doc-card-content-reload-button > span { + display: -webkit-box; + -webkit-line-clamp: 1; + -webkit-box-orient: vertical; + word-break: break-all; + white-space: normal; + overflow: hidden; + text-overflow: ellipsis; + color: var(--affine-brand-color); + font-family: var(--affine-font-family); + font-size: var(--affine-font-xs); + font-style: normal; + font-weight: 500; + line-height: 20px; + } + + .affine-embed-linked-doc-content-date > span { + display: -webkit-box; + -webkit-line-clamp: 1; + -webkit-box-orient: vertical; + word-break: break-all; + white-space: normal; + overflow: hidden; + text-overflow: ellipsis; + color: var(--affine-text-secondary-color); + font-family: var(--affine-font-family); + font-size: var(--affine-font-xs); + font-style: normal; + font-weight: 400; + line-height: 20px; + } + + .affine-embed-linked-doc-banner { + margin: 12px 12px 0px 0px; + width: 204px; + max-width: 100%; + height: 102px; + opacity: var(--add, 1); + pointer-events: none; + } + .affine-embed-linked-doc-banner img, + .affine-embed-linked-doc-banner object, + .affine-embed-linked-doc-banner svg { + width: 204px; + max-width: 100%; + height: 102px; + object-fit: cover; + border-radius: 4px 4px var(--1, 0px) var(--1, 0px); + } + + .affine-embed-linked-doc-block:not(.in-canvas) { + width: 100%; + min-width: calc(min(${EMBED_CARD_MIN_WIDTH}px, 100%)); + } + + .affine-embed-linked-doc-block.loading { + .affine-embed-linked-doc-content-date { + display: none; + } + } + + .affine-embed-linked-doc-block:not(.loading):not(.note-empty) { + .affine-embed-linked-doc-content-note.render { + display: block; + } + + .affine-embed-linked-doc-content-note.default { + display: none; + } + } + + .affine-embed-linked-doc-block:not(.loading):not(.banner-empty) { + .affine-embed-linked-doc-banner.default { + display: none; + } + } + + .affine-embed-linked-doc-block:not(.loading):not(.deleted):not(.error):not( + .empty + ).banner-empty { + .affine-embed-linked-doc-content { + width: 100%; + height: 100%; + } + + .affine-embed-linked-doc-banner.default { + display: none; + } + } + .affine-embed-linked-doc-block:not(.loading).error, + .affine-embed-linked-doc-block:not(.loading).deleted { + background: var(--affine-background-secondary-color); + + .affine-embed-linked-doc-content-note.render { + display: none; + } + .affine-embed-linked-doc-content-note.default { + display: block; + } + + .affine-embed-linked-doc-content-date { + display: none; + } + + .affine-embed-linked-doc-banner.default { + display: block; + } + } + .affine-embed-linked-doc-block.horizontalThin { + height: ${EMBED_CARD_HEIGHT.horizontalThin}px; + + .affine-embed-linked-doc-banner { + height: 66px; + } + + .affine-embed-linked-doc-banner img, + .affine-embed-linked-doc-banner object, + .affine-embed-linked-doc-banner svg { + height: 66px; + } + + .affine-embed-linked-doc-content { + gap: 12px; + } + } + .affine-embed-linked-doc-block.list { + height: ${EMBED_CARD_HEIGHT.list}px; + .affine-embed-linked-doc-content { + width: 100%; + flex-direction: row; + align-items: center; + justify-content: space-between; + } + + .affine-embed-linked-doc-content-title { + width: calc(100% - 204px); + } + + .affine-embed-linked-doc-content-note { + display: none !important; + } + + .affine-embed-linked-doc-content-date { + width: 204px; + justify-content: flex-end; + } + + .affine-embed-linked-doc-banner { + display: none !important; + } + } + .affine-embed-linked-doc-block.vertical { + width: ${EMBED_CARD_WIDTH.vertical}px; + height: ${EMBED_CARD_HEIGHT.vertical}px; + flex-direction: column-reverse; + + .affine-embed-linked-doc-content { + width: 100%; + } + + .affine-embed-linked-doc-banner { + width: 340px; + height: 170px; + margin-left: 12px; + } + .affine-embed-linked-doc-banner img, + .affine-embed-linked-doc-banner object, + .affine-embed-linked-doc-banner svg { + width: 340px; + height: 170px; + } + } + .affine-embed-linked-doc-block.vertical:not(.loading):not(.deleted):not( + .error + ):not(.empty).banner-empty { + .affine-embed-linked-doc-content { + width: 100%; + height: 100%; + } + + .affine-embed-linked-doc-banner.default { + display: none; + } + + .affine-embed-linked-doc-content-note { + -webkit-line-clamp: 16; + max-height: 320px; + } + + .affine-embed-linked-doc-content-date { + flex-grow: unset; + align-items: center; + } + } + .affine-embed-linked-doc-block.cube { + width: ${EMBED_CARD_WIDTH.cube}px; + height: ${EMBED_CARD_HEIGHT.cube}px; + + .affine-embed-linked-doc-content { + width: 100%; + flex-direction: column; + align-items: flex-start; + justify-content: space-between; + } + + .affine-embed-linked-doc-content-title { + flex-direction: column; + gap: 4px; + align-items: flex-start; + } + + .affine-embed-linked-doc-content-title-text { + -webkit-line-clamp: 2; + } + + .affine-embed-linked-doc-content-note { + display: none !important; + } + + .affine-embed-linked-doc-banner { + display: none !important; + } + } +`; + +export const LinkedDocDeletedIcon = html` + + `; + +export const LightLinkedPageEmptySmallBanner = html` + + + + + + + + + + + + + + + + + + + + + + + + + + + `; + +export const DarkLinkedPageEmptySmallBanner = html` + + + + + + + + + + + + + + + + + + + + + + + + + + + `; + +export const LightLinkedPageEmptyLargeBanner = html` + + + + + + + + + + + + + + + + + + + + + + + + + + +`; + +export const DarkLinkedPageEmptyLargeBanner = html` + + + + + + + + + + + + + + + + + + + + + + + + + + + `; + +export const LightLinkedPageDeletedSmallBanner = html` + + + + + + + + + + + + + + + + + + + + + + + `; + +export const DarkLinkedPageDeletedSmallBanner = html` + + + + + + + + + + + + + + + + + + + + + + + `; + +export const LightLinkedPageDeletedLargeBanner = html` + + + + + + + + + + + + + + + + + + + + + + + + + + + + + `; + +export const DarkLinkedPageDeletedLargeBanner = html` + + + + + + + + + + + + + + + + + + + + + + + + + + + + + `; + +export const LightLinkedEdgelessEmptySmallBanner = html` + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + `; + +export const DarkLinkedEdgelessEmptySmallBanner = html` + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + `; + +export const LightLinkedEdgelessEmptyLargeBanner = html` + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + `; + +export const DarkLinkedEdgelessEmptyLargeBanner = html` + + + + + + + + + `; + +export const LightLinkedEdgelessDeletedSmallBanner = html` + + + + + + + + + + + + + + + + + + + + + + + + `; + +export const DarkLinkedEdgelessDeletedSmallBanner = html` + + + + + + + + + + + + + + + + + + + + + + + + `; + +export const LightLinkedEdgelessDeletedLargeBanner = html` + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + `; + +export const DarkLinkedEdgelessDeletedLargeBanner = html` + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + `; diff --git a/blocksuite/affine/block-embed/src/embed-linked-doc-block/utils.ts b/blocksuite/affine/block-embed/src/embed-linked-doc-block/utils.ts new file mode 100644 index 0000000000..c489fd5afd --- /dev/null +++ b/blocksuite/affine/block-embed/src/embed-linked-doc-block/utils.ts @@ -0,0 +1,115 @@ +import { + DarkLoadingIcon, + EmbedEdgelessIcon, + EmbedPageIcon, + LightLoadingIcon, + ReloadIcon, +} from '@blocksuite/affine-components/icons'; +import { + ColorScheme, + type EmbedLinkedDocStyles, +} from '@blocksuite/affine-model'; +import type { TemplateResult } from 'lit'; + +import { + DarkSyncedDocErrorBanner, + LightSyncedDocErrorBanner, +} from '../embed-synced-doc-block/styles.js'; +import { + DarkLinkedEdgelessDeletedLargeBanner, + DarkLinkedEdgelessDeletedSmallBanner, + DarkLinkedEdgelessEmptyLargeBanner, + DarkLinkedEdgelessEmptySmallBanner, + DarkLinkedPageDeletedLargeBanner, + DarkLinkedPageDeletedSmallBanner, + DarkLinkedPageEmptyLargeBanner, + DarkLinkedPageEmptySmallBanner, + LightLinkedEdgelessDeletedLargeBanner, + LightLinkedEdgelessDeletedSmallBanner, + LightLinkedEdgelessEmptyLargeBanner, + LightLinkedEdgelessEmptySmallBanner, + LightLinkedPageDeletedLargeBanner, + LightLinkedPageDeletedSmallBanner, + LightLinkedPageEmptyLargeBanner, + LightLinkedPageEmptySmallBanner, + LinkedDocDeletedIcon, +} from './styles.js'; + +type EmbedCardImages = { + LoadingIcon: TemplateResult<1>; + ReloadIcon: TemplateResult<1>; + LinkedDocIcon: TemplateResult<1>; + LinkedDocDeletedIcon: TemplateResult<1>; + LinkedDocEmptyBanner: TemplateResult<1>; + LinkedDocDeletedBanner: TemplateResult<1>; + SyncedDocErrorBanner: TemplateResult<1>; +}; + +export function getEmbedLinkedDocIcons( + theme: ColorScheme, + editorMode: 'page' | 'edgeless', + style: (typeof EmbedLinkedDocStyles)[number] +): EmbedCardImages { + const small = style !== 'vertical'; + if (editorMode === 'page') { + if (theme === ColorScheme.Light) { + return { + LoadingIcon: LightLoadingIcon, + ReloadIcon, + LinkedDocIcon: EmbedPageIcon, + LinkedDocDeletedIcon, + LinkedDocEmptyBanner: small + ? LightLinkedPageEmptySmallBanner + : LightLinkedPageEmptyLargeBanner, + LinkedDocDeletedBanner: small + ? LightLinkedPageDeletedSmallBanner + : LightLinkedPageDeletedLargeBanner, + SyncedDocErrorBanner: LightSyncedDocErrorBanner, + }; + } else { + return { + ReloadIcon, + LoadingIcon: DarkLoadingIcon, + LinkedDocIcon: EmbedPageIcon, + LinkedDocDeletedIcon, + LinkedDocEmptyBanner: small + ? DarkLinkedPageEmptySmallBanner + : DarkLinkedPageEmptyLargeBanner, + LinkedDocDeletedBanner: small + ? DarkLinkedPageDeletedSmallBanner + : DarkLinkedPageDeletedLargeBanner, + SyncedDocErrorBanner: DarkSyncedDocErrorBanner, + }; + } + } else { + if (theme === ColorScheme.Light) { + return { + ReloadIcon, + LoadingIcon: LightLoadingIcon, + LinkedDocIcon: EmbedEdgelessIcon, + LinkedDocDeletedIcon, + LinkedDocEmptyBanner: small + ? LightLinkedEdgelessEmptySmallBanner + : LightLinkedEdgelessEmptyLargeBanner, + LinkedDocDeletedBanner: small + ? LightLinkedEdgelessDeletedSmallBanner + : LightLinkedEdgelessDeletedLargeBanner, + SyncedDocErrorBanner: LightSyncedDocErrorBanner, + }; + } else { + return { + ReloadIcon, + LoadingIcon: DarkLoadingIcon, + LinkedDocIcon: EmbedEdgelessIcon, + LinkedDocDeletedIcon, + LinkedDocEmptyBanner: small + ? DarkLinkedEdgelessEmptySmallBanner + : DarkLinkedEdgelessEmptyLargeBanner, + LinkedDocDeletedBanner: small + ? DarkLinkedEdgelessDeletedSmallBanner + : DarkLinkedEdgelessDeletedLargeBanner, + SyncedDocErrorBanner: DarkSyncedDocErrorBanner, + }; + } + } +} diff --git a/blocksuite/affine/block-embed/src/embed-loom-block/adapters/html.ts b/blocksuite/affine/block-embed/src/embed-loom-block/adapters/html.ts new file mode 100644 index 0000000000..ceb3f97f5b --- /dev/null +++ b/blocksuite/affine/block-embed/src/embed-loom-block/adapters/html.ts @@ -0,0 +1,11 @@ +import { EmbedLoomBlockSchema } from '@blocksuite/affine-model'; +import { BlockHtmlAdapterExtension } from '@blocksuite/affine-shared/adapters'; + +import { createEmbedBlockHtmlAdapterMatcher } from '../../common/adapters/html.js'; + +export const embedLoomBlockHtmlAdapterMatcher = + createEmbedBlockHtmlAdapterMatcher(EmbedLoomBlockSchema.model.flavour); + +export const EmbedLoomBlockHtmlAdapterExtension = BlockHtmlAdapterExtension( + embedLoomBlockHtmlAdapterMatcher +); diff --git a/blocksuite/affine/block-embed/src/embed-loom-block/adapters/index.ts b/blocksuite/affine/block-embed/src/embed-loom-block/adapters/index.ts new file mode 100644 index 0000000000..c1d476903d --- /dev/null +++ b/blocksuite/affine/block-embed/src/embed-loom-block/adapters/index.ts @@ -0,0 +1,3 @@ +export * from './html.js'; +export * from './markdown.js'; +export * from './plain-text.js'; diff --git a/blocksuite/affine/block-embed/src/embed-loom-block/adapters/markdown.ts b/blocksuite/affine/block-embed/src/embed-loom-block/adapters/markdown.ts new file mode 100644 index 0000000000..b85d94d59a --- /dev/null +++ b/blocksuite/affine/block-embed/src/embed-loom-block/adapters/markdown.ts @@ -0,0 +1,11 @@ +import { EmbedLoomBlockSchema } from '@blocksuite/affine-model'; +import { BlockMarkdownAdapterExtension } from '@blocksuite/affine-shared/adapters'; + +import { createEmbedBlockMarkdownAdapterMatcher } from '../../common/adapters/markdown.js'; + +export const embedLoomBlockMarkdownAdapterMatcher = + createEmbedBlockMarkdownAdapterMatcher(EmbedLoomBlockSchema.model.flavour); + +export const EmbedLoomMarkdownAdapterExtension = BlockMarkdownAdapterExtension( + embedLoomBlockMarkdownAdapterMatcher +); diff --git a/blocksuite/affine/block-embed/src/embed-loom-block/adapters/plain-text.ts b/blocksuite/affine/block-embed/src/embed-loom-block/adapters/plain-text.ts new file mode 100644 index 0000000000..62d81b020b --- /dev/null +++ b/blocksuite/affine/block-embed/src/embed-loom-block/adapters/plain-text.ts @@ -0,0 +1,10 @@ +import { EmbedLoomBlockSchema } from '@blocksuite/affine-model'; +import { BlockPlainTextAdapterExtension } from '@blocksuite/affine-shared/adapters'; + +import { createEmbedBlockPlainTextAdapterMatcher } from '../../common/adapters/plain-text.js'; + +export const embedLoomBlockPlainTextAdapterMatcher = + createEmbedBlockPlainTextAdapterMatcher(EmbedLoomBlockSchema.model.flavour); + +export const EmbedLoomBlockPlainTextAdapterExtension = + BlockPlainTextAdapterExtension(embedLoomBlockPlainTextAdapterMatcher); diff --git a/blocksuite/affine/block-embed/src/embed-loom-block/embed-edgeless-loom-bock.ts b/blocksuite/affine/block-embed/src/embed-loom-block/embed-edgeless-loom-bock.ts new file mode 100644 index 0000000000..84b4e9a987 --- /dev/null +++ b/blocksuite/affine/block-embed/src/embed-loom-block/embed-edgeless-loom-bock.ts @@ -0,0 +1,6 @@ +import { toEdgelessEmbedBlock } from '../common/to-edgeless-embed-block.js'; +import { EmbedLoomBlockComponent } from './embed-loom-block.js'; + +export class EmbedEdgelessLoomBlockComponent extends toEdgelessEmbedBlock( + EmbedLoomBlockComponent +) {} diff --git a/blocksuite/affine/block-embed/src/embed-loom-block/embed-loom-block.ts b/blocksuite/affine/block-embed/src/embed-loom-block/embed-loom-block.ts new file mode 100644 index 0000000000..f324347752 --- /dev/null +++ b/blocksuite/affine/block-embed/src/embed-loom-block/embed-loom-block.ts @@ -0,0 +1,202 @@ +import { OpenIcon } from '@blocksuite/affine-components/icons'; +import type { EmbedLoomModel, EmbedLoomStyles } from '@blocksuite/affine-model'; +import { ThemeProvider } from '@blocksuite/affine-shared/services'; +import { html } from 'lit'; +import { property, state } from 'lit/decorators.js'; +import { classMap } from 'lit/directives/class-map.js'; +import { styleMap } from 'lit/directives/style-map.js'; + +import { EmbedBlockComponent } from '../common/embed-block-element.js'; +import { getEmbedCardIcons } from '../common/utils.js'; +import { loomUrlRegex } from './embed-loom-model.js'; +import type { EmbedLoomBlockService } from './embed-loom-service.js'; +import { LoomIcon, styles } from './styles.js'; +import { refreshEmbedLoomUrlData } from './utils.js'; + +export class EmbedLoomBlockComponent extends EmbedBlockComponent< + EmbedLoomModel, + EmbedLoomBlockService +> { + static override styles = styles; + + override _cardStyle: (typeof EmbedLoomStyles)[number] = 'video'; + + protected _isDragging = false; + + protected _isResizing = false; + + open = () => { + let link = this.model.url; + if (!link.match(/^[a-zA-Z]+:\/\//)) { + link = 'https://' + link; + } + window.open(link, '_blank'); + }; + + refreshData = () => { + refreshEmbedLoomUrlData(this, this.fetchAbortController.signal).catch( + console.error + ); + }; + + private _handleDoubleClick(event: MouseEvent) { + event.stopPropagation(); + this.open(); + } + + private _selectBlock() { + const selectionManager = this.host.selection; + const blockSelection = selectionManager.create('block', { + blockId: this.blockId, + }); + selectionManager.setGroup('note', [blockSelection]); + } + + protected _handleClick(event: MouseEvent) { + event.stopPropagation(); + this._selectBlock(); + } + + override connectedCallback() { + super.connectedCallback(); + this._cardStyle = this.model.style; + + if (!this.model.videoId) { + this.doc.withoutTransact(() => { + const url = this.model.url; + const urlMatch = url.match(loomUrlRegex); + if (urlMatch) { + const [, videoId] = urlMatch; + this.doc.updateBlock(this.model, { + videoId, + }); + } + }); + } + + if (!this.model.description && !this.model.title) { + this.doc.withoutTransact(() => { + this.refreshData(); + }); + } + + this.disposables.add( + this.model.propsUpdated.on(({ key }) => { + this.requestUpdate(); + if (key === 'url') { + this.refreshData(); + } + }) + ); + + // this is required to prevent iframe from capturing pointer events + this.disposables.add( + this.std.selection.slots.changed.on(() => { + this._isSelected = + !!this.selected?.is('block') || !!this.selected?.is('surface'); + + this._showOverlay = + this._isResizing || this._isDragging || !this._isSelected; + }) + ); + // this is required to prevent iframe from capturing pointer events + this.handleEvent('dragStart', () => { + this._isDragging = true; + this._showOverlay = + this._isResizing || this._isDragging || !this._isSelected; + }); + + this.handleEvent('dragEnd', () => { + this._isDragging = false; + this._showOverlay = + this._isResizing || this._isDragging || !this._isSelected; + }); + } + + override renderBlock() { + const { image, title = 'Loom', description, videoId } = this.model; + + const loading = this.loading; + const theme = this.std.get(ThemeProvider).theme; + const { LoadingIcon, EmbedCardBannerIcon } = getEmbedCardIcons(theme); + const titleIcon = loading ? LoadingIcon : LoomIcon; + const titleText = loading ? 'Loading...' : title; + const descriptionText = loading ? '' : description; + const bannerImage = + !loading && image + ? html` + ${EmbedCardBannerIcon} + ` + : EmbedCardBannerIcon; + + return this.renderEmbed( + () => html` +
+
+ ${videoId + ? html` +
+ + +
+
+ ` + : bannerImage} +
+
+
+
+ ${titleIcon} +
+ +
+ ${titleText} +
+
+ +
+ ${descriptionText} +
+ +
+ loom.com + +
${OpenIcon}
+
+
+
+ ` + ); + } + + @state() + protected accessor _isSelected = false; + + @state() + protected accessor _showOverlay = true; + + @property({ attribute: false }) + accessor loading = false; +} diff --git a/blocksuite/affine/block-embed/src/embed-loom-block/embed-loom-model.ts b/blocksuite/affine/block-embed/src/embed-loom-block/embed-loom-model.ts new file mode 100644 index 0000000000..30f89cbcca --- /dev/null +++ b/blocksuite/affine/block-embed/src/embed-loom-block/embed-loom-model.ts @@ -0,0 +1,2 @@ +export const loomUrlRegex: RegExp = + /(?:https?:\/\/)??(?:www\.)?loom\.com\/share\/([a-zA-Z0-9]+)/; diff --git a/blocksuite/affine/block-embed/src/embed-loom-block/embed-loom-service.ts b/blocksuite/affine/block-embed/src/embed-loom-block/embed-loom-service.ts new file mode 100644 index 0000000000..1ff401afd8 --- /dev/null +++ b/blocksuite/affine/block-embed/src/embed-loom-block/embed-loom-service.ts @@ -0,0 +1,35 @@ +import { + EmbedLoomBlockSchema, + type EmbedLoomModel, + EmbedLoomStyles, +} from '@blocksuite/affine-model'; +import { EmbedOptionProvider } from '@blocksuite/affine-shared/services'; +import { BlockService } from '@blocksuite/block-std'; + +import { LinkPreviewer } from '../common/link-previewer.js'; +import { loomUrlRegex } from './embed-loom-model.js'; +import { queryEmbedLoomData } from './utils.js'; + +export class EmbedLoomBlockService extends BlockService { + static override readonly flavour = EmbedLoomBlockSchema.model.flavour; + + private static readonly linkPreviewer = new LinkPreviewer(); + + static setLinkPreviewEndpoint = + EmbedLoomBlockService.linkPreviewer.setEndpoint; + + queryUrlData = (embedLoomModel: EmbedLoomModel, signal?: AbortSignal) => { + return queryEmbedLoomData(embedLoomModel, signal); + }; + + override mounted() { + super.mounted(); + + this.std.get(EmbedOptionProvider).registerEmbedBlockOptions({ + flavour: this.flavour, + urlRegex: loomUrlRegex, + styles: EmbedLoomStyles, + viewType: 'embed', + }); + } +} diff --git a/blocksuite/affine/block-embed/src/embed-loom-block/embed-loom-spec.ts b/blocksuite/affine/block-embed/src/embed-loom-block/embed-loom-spec.ts new file mode 100644 index 0000000000..65e4a803da --- /dev/null +++ b/blocksuite/affine/block-embed/src/embed-loom-block/embed-loom-spec.ts @@ -0,0 +1,18 @@ +import { + BlockViewExtension, + type ExtensionType, + FlavourExtension, +} from '@blocksuite/block-std'; +import { literal } from 'lit/static-html.js'; + +import { EmbedLoomBlockService } from './embed-loom-service.js'; + +export const EmbedLoomBlockSpec: ExtensionType[] = [ + FlavourExtension('affine:embed-loom'), + EmbedLoomBlockService, + BlockViewExtension('affine:embed-loom', model => { + return model.parent?.flavour === 'affine:surface' + ? literal`affine-embed-edgeless-loom-block` + : literal`affine-embed-loom-block`; + }), +]; diff --git a/blocksuite/affine/block-embed/src/embed-loom-block/index.ts b/blocksuite/affine/block-embed/src/embed-loom-block/index.ts new file mode 100644 index 0000000000..5c297a7b7b --- /dev/null +++ b/blocksuite/affine/block-embed/src/embed-loom-block/index.ts @@ -0,0 +1,6 @@ +export * from './adapters/index.js'; +export * from './embed-loom-block.js'; +export * from './embed-loom-model.js'; +export * from './embed-loom-service.js'; +export * from './embed-loom-spec.js'; +export { LoomIcon } from './styles.js'; diff --git a/blocksuite/affine/block-embed/src/embed-loom-block/styles.ts b/blocksuite/affine/block-embed/src/embed-loom-block/styles.ts new file mode 100644 index 0000000000..250e5c75ff --- /dev/null +++ b/blocksuite/affine/block-embed/src/embed-loom-block/styles.ts @@ -0,0 +1,221 @@ +import { + EMBED_CARD_HEIGHT, + EMBED_CARD_WIDTH, +} from '@blocksuite/affine-shared/consts'; +import { css, html } from 'lit'; + +export const styles = css` + .affine-embed-loom-block { + box-sizing: border-box; + width: ${EMBED_CARD_WIDTH.video}px; + display: flex; + flex-direction: column; + gap: 20px; + padding: 12px; + + border-radius: 8px; + border: 1px solid var(--affine-background-tertiary-color); + + opacity: var(--add, 1); + background: var(--affine-background-primary-color); + user-select: none; + + aspect-ratio: ${EMBED_CARD_WIDTH.video} / ${EMBED_CARD_HEIGHT.video}; + } + + .affine-embed-loom-video { + flex-grow: 1; + width: 100%; + opacity: var(--add, 1); + } + + .affine-embed-loom-video img, + .affine-embed-loom-video object, + .affine-embed-loom-video svg { + width: 100%; + height: 100%; + object-fit: cover; + border-radius: 4px 4px var(--1, 0px) var(--1, 0px); + } + + .affine-embed-loom-video-iframe-container { + position: relative; + height: 100%; + } + + .affine-embed-loom-video-iframe-container > iframe { + width: 100%; + height: 100%; + border-radius: 4px 4px var(--1, 0px) var(--1, 0px); + } + + .affine-embed-loom-video-iframe-overlay { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + } + + .affine-embed-loom-video-iframe-overlay.hide { + display: none; + } + + .affine-embed-loom-content { + display: flex; + flex-direction: column; + width: 100%; + height: fit-content; + border-radius: var(--1, 0px); + opacity: var(--add, 1); + } + + .affine-embed-loom-content-header { + display: flex; + flex-direction: row; + gap: 8px; + align-items: center; + + align-self: stretch; + padding: var(--1, 0px); + border-radius: var(--1, 0px); + opacity: var(--add, 1); + } + + .affine-embed-loom-content-title-icon { + display: flex; + width: 20px; + height: 20px; + justify-content: center; + align-items: center; + } + + .affine-embed-loom-content-title-icon img, + .affine-embed-loom-content-title-icon object, + .affine-embed-loom-content-title-icon svg { + width: 20px; + height: 20px; + fill: var(--affine-background-primary-color); + } + + .affine-embed-loom-content-title-text { + flex: 1 0 0; + + display: -webkit-box; + -webkit-line-clamp: 1; + -webkit-box-orient: vertical; + + word-break: break-word; + overflow: hidden; + text-overflow: ellipsis; + color: var(--affine-text-primary-color); + + font-family: var(--affine-font-family); + font-size: var(--affine-font-sm); + font-style: normal; + font-weight: 600; + line-height: 22px; + } + + .affine-embed-loom-content-description { + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + + flex: 1 0 0; + align-self: stretch; + + word-break: break-word; + white-space: normal; + overflow: hidden; + text-overflow: ellipsis; + color: var(--affine-text-primary-color); + + font-family: var(--affine-font-family); + font-size: var(--affine-font-xs); + font-style: normal; + font-weight: 400; + line-height: 20px; + } + + .affine-embed-loom-content-url { + display: flex; + align-items: center; + justify-content: flex-start; + gap: 4px; + width: max-content; + max-width: 100%; + cursor: pointer; + } + .affine-embed-loom-content-url > span { + display: -webkit-box; + -webkit-line-clamp: 1; + -webkit-box-orient: vertical; + + word-break: break-all; + white-space: normal; + overflow: hidden; + text-overflow: ellipsis; + color: var(--affine-text-secondary-color); + + font-family: var(--affine-font-family); + font-size: var(--affine-font-xs); + font-style: normal; + font-weight: 400; + line-height: 20px; + } + .affine-embed-loom-content-url:hover > span { + color: var(--affine-link-color); + } + .affine-embed-loom-content-url:hover .open-icon { + fill: var(--affine-link-color); + } + + .affine-embed-loom-content-url-icon { + display: flex; + align-items: center; + justify-content: center; + width: 12px; + height: 12px; + } + .affine-embed-loom-content-url-icon .open-icon { + height: 12px; + width: 12px; + fill: var(--affine-text-secondary-color); + } + + .affine-embed-loom-block.loading { + .affine-embed-loom-content-title-text { + color: var(--affine-placeholder-color); + } + } + + .affine-embed-loom-block.selected { + .affine-embed-loom-content-url > span { + color: var(--affine-link-color); + } + .affine-embed-loom-content-url .open-icon { + fill: var(--affine-link-color); + } + } +`; + +export const LoomIcon = html` + + + + + + + + +`; diff --git a/blocksuite/affine/block-embed/src/embed-loom-block/utils.ts b/blocksuite/affine/block-embed/src/embed-loom-block/utils.ts new file mode 100644 index 0000000000..3fa81360c8 --- /dev/null +++ b/blocksuite/affine/block-embed/src/embed-loom-block/utils.ts @@ -0,0 +1,75 @@ +import type { + EmbedLoomBlockUrlData, + EmbedLoomModel, +} from '@blocksuite/affine-model'; +import { isAbortError } from '@blocksuite/affine-shared/utils'; + +import type { EmbedLoomBlockComponent } from './embed-loom-block.js'; + +const LoomOEmbedEndpoint = 'https://www.loom.com/v1/oembed'; + +export async function queryEmbedLoomData( + embedLoomModel: EmbedLoomModel, + signal?: AbortSignal +): Promise> { + const url = embedLoomModel.url; + + const loomEmbedData: Partial = + await queryLoomOEmbedData(url, signal); + + return loomEmbedData; +} + +export async function queryLoomOEmbedData( + url: string, + signal?: AbortSignal +): Promise> { + let loomOEmbedData: Partial = {}; + + const oEmbedUrl = `${LoomOEmbedEndpoint}?url=${url}`; + + const oEmbedResponse = await fetch(oEmbedUrl, { signal }).catch(() => null); + if (oEmbedResponse && oEmbedResponse.ok) { + const oEmbedJson = await oEmbedResponse.json(); + const { title, description, thumbnail_url: image } = oEmbedJson; + + loomOEmbedData = { + title, + description, + image, + }; + } + + return loomOEmbedData; +} + +export async function refreshEmbedLoomUrlData( + embedLoomElement: EmbedLoomBlockComponent, + signal?: AbortSignal +): Promise { + let title = null, + description = null, + image = null; + + try { + embedLoomElement.loading = true; + + const queryUrlData = embedLoomElement.service?.queryUrlData; + if (!queryUrlData) return; + + const loomUrlData = await queryUrlData(embedLoomElement.model); + ({ title = null, description = null, image = null } = loomUrlData); + + if (signal?.aborted) return; + + embedLoomElement.doc.updateBlock(embedLoomElement.model, { + title, + description, + image, + }); + } catch (error) { + if (signal?.aborted || isAbortError(error)) return; + } finally { + embedLoomElement.loading = false; + } +} diff --git a/blocksuite/affine/block-embed/src/embed-synced-doc-block/adapters/html.ts b/blocksuite/affine/block-embed/src/embed-synced-doc-block/adapters/html.ts new file mode 100644 index 0000000000..be2275268a --- /dev/null +++ b/blocksuite/affine/block-embed/src/embed-synced-doc-block/adapters/html.ts @@ -0,0 +1,88 @@ +import { EmbedSyncedDocBlockSchema } from '@blocksuite/affine-model'; +import { + BlockHtmlAdapterExtension, + type BlockHtmlAdapterMatcher, +} from '@blocksuite/affine-shared/adapters'; + +export const embedSyncedDocBlockHtmlAdapterMatcher: BlockHtmlAdapterMatcher = { + flavour: EmbedSyncedDocBlockSchema.model.flavour, + toMatch: () => false, + fromMatch: o => o.node.flavour === EmbedSyncedDocBlockSchema.model.flavour, + toBlockSnapshot: {}, + fromBlockSnapshot: { + enter: async (o, context) => { + const { configs, walker, walkerContext, job } = context; + const type = configs.get('embedSyncedDocExportType'); + + // this context is used for nested sync block + if ( + walkerContext.getGlobalContext('embed-synced-doc-counter') === undefined + ) { + walkerContext.setGlobalContext('embed-synced-doc-counter', 0); + } + let counter = walkerContext.getGlobalContext( + 'embed-synced-doc-counter' + ) as number; + walkerContext.setGlobalContext('embed-synced-doc-counter', ++counter); + + if (type === 'content') { + const syncedDocId = o.node.props.pageId as string; + const syncedDoc = job.collection.getDoc(syncedDocId); + walkerContext.setGlobalContext('hast:html-root-doc', false); + if (!syncedDoc) return; + + if (counter === 1) { + const syncedSnapshot = job.docToSnapshot(syncedDoc); + if (syncedSnapshot) { + await walker.walkONode(syncedSnapshot.blocks); + } + } else { + walkerContext + .openNode( + { + type: 'element', + tagName: 'div', + properties: { + className: ['affine-paragraph-block-container'], + }, + children: [], + }, + 'children' + ) + .openNode( + { + type: 'element', + tagName: 'p', + properties: {}, + children: [ + { type: 'text', value: syncedDoc.meta?.title ?? '' }, + ], + }, + 'children' + ) + .closeNode() + .closeNode(); + } + } + }, + leave: (_, context) => { + const { walkerContext } = context; + const counter = walkerContext.getGlobalContext( + 'embed-synced-doc-counter' + ) as number; + const currentCounter = counter - 1; + walkerContext.setGlobalContext( + 'embed-synced-doc-counter', + currentCounter + ); + // When leave the last embed synced doc block, we need to set the html root doc context to true + walkerContext.setGlobalContext( + 'hast:html-root-doc', + currentCounter === 0 + ); + }, + }, +}; + +export const EmbedSyncedDocBlockHtmlAdapterExtension = + BlockHtmlAdapterExtension(embedSyncedDocBlockHtmlAdapterMatcher); diff --git a/blocksuite/affine/block-embed/src/embed-synced-doc-block/adapters/index.ts b/blocksuite/affine/block-embed/src/embed-synced-doc-block/adapters/index.ts new file mode 100644 index 0000000000..c1d476903d --- /dev/null +++ b/blocksuite/affine/block-embed/src/embed-synced-doc-block/adapters/index.ts @@ -0,0 +1,3 @@ +export * from './html.js'; +export * from './markdown.js'; +export * from './plain-text.js'; diff --git a/blocksuite/affine/block-embed/src/embed-synced-doc-block/adapters/markdown.ts b/blocksuite/affine/block-embed/src/embed-synced-doc-block/adapters/markdown.ts new file mode 100644 index 0000000000..b0dadd136c --- /dev/null +++ b/blocksuite/affine/block-embed/src/embed-synced-doc-block/adapters/markdown.ts @@ -0,0 +1,64 @@ +import { EmbedSyncedDocBlockSchema } from '@blocksuite/affine-model'; +import { + BlockMarkdownAdapterExtension, + type BlockMarkdownAdapterMatcher, +} from '@blocksuite/affine-shared/adapters'; + +export const embedSyncedDocBlockMarkdownAdapterMatcher: BlockMarkdownAdapterMatcher = + { + flavour: EmbedSyncedDocBlockSchema.model.flavour, + toMatch: () => false, + fromMatch: o => o.node.flavour === EmbedSyncedDocBlockSchema.model.flavour, + toBlockSnapshot: {}, + fromBlockSnapshot: { + enter: async (o, context) => { + const { configs, walker, walkerContext, job } = context; + const type = configs.get('embedSyncedDocExportType'); + + // this context is used for nested sync block + if ( + walkerContext.getGlobalContext('embed-synced-doc-counter') === + undefined + ) { + walkerContext.setGlobalContext('embed-synced-doc-counter', 0); + } + let counter = walkerContext.getGlobalContext( + 'embed-synced-doc-counter' + ) as number; + walkerContext.setGlobalContext('embed-synced-doc-counter', ++counter); + + if (type === 'content') { + const syncedDocId = o.node.props.pageId as string; + const syncedDoc = job.collection.getDoc(syncedDocId); + if (!syncedDoc) return; + + if (counter === 1) { + const syncedSnapshot = job.docToSnapshot(syncedDoc); + if (syncedSnapshot) { + await walker.walkONode(syncedSnapshot.blocks); + } + } else { + // TODO(@L-Sun) may be use the nested content + walkerContext + .openNode({ + type: 'paragraph', + children: [ + { type: 'text', value: syncedDoc.meta?.title ?? '' }, + ], + }) + .closeNode(); + } + } + }, + leave: (_, context) => { + const { walkerContext } = context; + const counter = walkerContext.getGlobalContext( + 'embed-synced-doc-counter' + ) as number; + walkerContext.setGlobalContext('embed-synced-doc-counter', counter - 1); + }, + }, + }; + +export const EmbedSyncedDocBlockMarkdownAdapterExtension = + BlockMarkdownAdapterExtension(embedSyncedDocBlockMarkdownAdapterMatcher); diff --git a/blocksuite/affine/block-embed/src/embed-synced-doc-block/adapters/plain-text.ts b/blocksuite/affine/block-embed/src/embed-synced-doc-block/adapters/plain-text.ts new file mode 100644 index 0000000000..287a14add9 --- /dev/null +++ b/blocksuite/affine/block-embed/src/embed-synced-doc-block/adapters/plain-text.ts @@ -0,0 +1,62 @@ +import { EmbedSyncedDocBlockSchema } from '@blocksuite/affine-model'; +import { + BlockPlainTextAdapterExtension, + type BlockPlainTextAdapterMatcher, +} from '@blocksuite/affine-shared/adapters'; + +export const embedSyncedDocBlockPlainTextAdapterMatcher: BlockPlainTextAdapterMatcher = + { + flavour: EmbedSyncedDocBlockSchema.model.flavour, + toMatch: () => false, + fromMatch: o => o.node.flavour === EmbedSyncedDocBlockSchema.model.flavour, + toBlockSnapshot: {}, + fromBlockSnapshot: { + enter: async (o, context) => { + const { configs, walker, walkerContext, job, textBuffer } = context; + const type = configs.get('embedSyncedDocExportType'); + + // this context is used for nested sync block + if ( + walkerContext.getGlobalContext('embed-synced-doc-counter') === + undefined + ) { + walkerContext.setGlobalContext('embed-synced-doc-counter', 0); + } + let counter = walkerContext.getGlobalContext( + 'embed-synced-doc-counter' + ) as number; + walkerContext.setGlobalContext('embed-synced-doc-counter', ++counter); + + let buffer = ''; + + if (type === 'content') { + const syncedDocId = o.node.props.pageId as string; + const syncedDoc = job.collection.getDoc(syncedDocId); + if (!syncedDoc) return; + + if (counter === 1) { + const syncedSnapshot = job.docToSnapshot(syncedDoc); + if (syncedSnapshot) { + await walker.walkONode(syncedSnapshot.blocks); + } + } else { + buffer = syncedDoc.meta?.title ?? ''; + if (buffer) { + buffer += '\n'; + } + } + } + textBuffer.content += buffer; + }, + leave: (_, context) => { + const { walkerContext } = context; + const counter = walkerContext.getGlobalContext( + 'embed-synced-doc-counter' + ) as number; + walkerContext.setGlobalContext('embed-synced-doc-counter', counter - 1); + }, + }, + }; + +export const EmbedSyncedDocBlockPlainTextAdapterExtension = + BlockPlainTextAdapterExtension(embedSyncedDocBlockPlainTextAdapterMatcher); diff --git a/blocksuite/affine/block-embed/src/embed-synced-doc-block/components/embed-synced-doc-card.ts b/blocksuite/affine/block-embed/src/embed-synced-doc-block/components/embed-synced-doc-card.ts new file mode 100644 index 0000000000..fb96b90485 --- /dev/null +++ b/blocksuite/affine/block-embed/src/embed-synced-doc-block/components/embed-synced-doc-card.ts @@ -0,0 +1,251 @@ +import { ThemeProvider } from '@blocksuite/affine-shared/services'; +import { isGfxBlockComponent, ShadowlessElement } from '@blocksuite/block-std'; +import { WithDisposable } from '@blocksuite/global/utils'; +import { html, nothing } from 'lit'; +import { property, queryAsync } from 'lit/decorators.js'; +import { classMap } from 'lit/directives/class-map.js'; + +import { renderLinkedDocInCard } from '../../common/render-linked-doc.js'; +import type { EmbedSyncedDocBlockComponent } from '../embed-synced-doc-block.js'; +import { cardStyles } from '../styles.js'; +import { getSyncedDocIcons } from '../utils.js'; + +export class EmbedSyncedDocCard extends WithDisposable(ShadowlessElement) { + static override styles = cardStyles; + + private _dragging = false; + + get blockState() { + return this.block.blockState; + } + + get editorMode() { + return this.block.editorMode; + } + + get host() { + return this.block.host; + } + + get linkedDoc() { + return this.block.syncedDoc; + } + + get model() { + return this.block.model; + } + + get std() { + return this.block.std; + } + + private _handleClick(event: MouseEvent) { + event.stopPropagation(); + if (!isGfxBlockComponent(this.block)) { + this._selectBlock(); + } + } + + private _isDocEmpty() { + const syncedDoc = this.block.syncedDoc; + if (!syncedDoc) { + return false; + } + return ( + !!syncedDoc && + !syncedDoc.meta?.title.length && + this.isNoteContentEmpty && + this.isBannerEmpty + ); + } + + private _selectBlock() { + const selectionManager = this.host.selection; + const blockSelection = selectionManager.create('block', { + blockId: this.block.blockId, + }); + selectionManager.setGroup('note', [blockSelection]); + } + + override connectedCallback() { + super.connectedCallback(); + + this.block.handleEvent( + 'dragStart', + () => { + this._dragging = true; + }, + { global: true } + ); + this.block.handleEvent( + 'dragEnd', + () => { + this._dragging = false; + }, + { global: true } + ); + + const { isCycle } = this.block.blockState; + const syncedDoc = this.block.syncedDoc; + if (isCycle && syncedDoc) { + if (syncedDoc.root) { + renderLinkedDocInCard(this); + } else { + syncedDoc.slots.rootAdded.once(() => { + renderLinkedDocInCard(this); + }); + } + + this.disposables.add( + syncedDoc.collection.meta.docMetaUpdated.on(() => { + renderLinkedDocInCard(this); + }) + ); + this.disposables.add( + syncedDoc.slots.blockUpdated.on(payload => { + if (this._dragging) { + return; + } + if ( + payload.type === 'update' && + ['', 'caption', 'xywh'].includes(payload.props.key) + ) { + return; + } + renderLinkedDocInCard(this); + }) + ); + } + } + + override render() { + const { isLoading, isDeleted, isError, isCycle } = this.blockState; + const error = this.isError || isError; + + const isEmpty = this._isDocEmpty() && this.isBannerEmpty; + + const cardClassMap = classMap({ + loading: isLoading, + error, + deleted: isDeleted, + cycle: isCycle, + surface: isGfxBlockComponent(this.block), + empty: isEmpty, + 'banner-empty': this.isBannerEmpty, + 'note-empty': this.isNoteContentEmpty, + }); + + const theme = this.std.get(ThemeProvider).theme; + const { + LoadingIcon, + SyncedDocErrorIcon, + ReloadIcon, + SyncedDocEmptyBanner, + SyncedDocErrorBanner, + SyncedDocDeletedBanner, + } = getSyncedDocIcons(theme, this.editorMode); + + const icon = error + ? SyncedDocErrorIcon + : isLoading + ? LoadingIcon + : this.block.icon$.value; + const title = isLoading ? 'Loading...' : this.block.title$; + + const showDefaultNoteContent = isLoading || error || isDeleted || isEmpty; + const defaultNoteContent = error + ? 'This linked doc failed to load.' + : isLoading + ? '' + : isDeleted + ? 'This linked doc is deleted.' + : isEmpty + ? 'Preview of the page will be displayed here.' + : ''; + + const dateText = this.block.docUpdatedAt.toLocaleString(); + + const showDefaultBanner = isLoading || error || isDeleted || isEmpty; + + const defaultBanner = isLoading + ? SyncedDocEmptyBanner + : error + ? SyncedDocErrorBanner + : isDeleted + ? SyncedDocDeletedBanner + : SyncedDocEmptyBanner; + + return html` +
+
+
+
+ ${icon} +
+ +
+ ${title} +
+
+ + ${showDefaultNoteContent + ? html`
+ ${defaultNoteContent} +
` + : nothing} +
+ + ${error + ? html` +
+
this.block.refreshData()} + > + ${ReloadIcon} Reload +
+
+ ` + : html` + + `} +
+ +
+ + ${showDefaultBanner + ? html` +
+ ${defaultBanner} +
+ ` + : nothing} +
+ `; + } + + @queryAsync('.affine-embed-synced-doc-card-banner.render') + accessor bannerContainer!: Promise; + + @property({ attribute: false }) + accessor block!: EmbedSyncedDocBlockComponent; + + @property({ attribute: false }) + accessor isBannerEmpty = false; + + @property({ attribute: false }) + accessor isError = false; + + @property({ attribute: false }) + accessor isNoteContentEmpty = false; + + @queryAsync('.affine-embed-synced-doc-content-note.render') + accessor noteContainer!: Promise; +} diff --git a/blocksuite/affine/block-embed/src/embed-synced-doc-block/embed-edgeless-synced-doc-block.ts b/blocksuite/affine/block-embed/src/embed-synced-doc-block/embed-edgeless-synced-doc-block.ts new file mode 100644 index 0000000000..5bc9b54234 --- /dev/null +++ b/blocksuite/affine/block-embed/src/embed-synced-doc-block/embed-edgeless-synced-doc-block.ts @@ -0,0 +1,180 @@ +import type { AliasInfo } from '@blocksuite/affine-model'; +import { + EMBED_CARD_HEIGHT, + EMBED_CARD_WIDTH, +} from '@blocksuite/affine-shared/consts'; +import { + ThemeExtensionIdentifier, + ThemeProvider, +} from '@blocksuite/affine-shared/services'; +import { BlockStdScope } from '@blocksuite/block-std'; +import { assertExists, Bound } from '@blocksuite/global/utils'; +import { html } from 'lit'; +import { choose } from 'lit/directives/choose.js'; +import { classMap } from 'lit/directives/class-map.js'; +import { guard } from 'lit/directives/guard.js'; +import { styleMap } from 'lit/directives/style-map.js'; + +import { toEdgelessEmbedBlock } from '../common/to-edgeless-embed-block.js'; +import { EmbedSyncedDocBlockComponent } from './embed-synced-doc-block.js'; + +export class EmbedEdgelessSyncedDocBlockComponent extends toEdgelessEmbedBlock( + EmbedSyncedDocBlockComponent +) { + protected override _renderSyncedView = () => { + const { syncedDoc, editorMode } = this; + + assertExists(syncedDoc, 'Doc should exist'); + + let containerStyleMap = styleMap({ + position: 'relative', + width: '100%', + }); + const modelScale = this.model.scale ?? 1; + const bound = Bound.deserialize(this.model.xywh); + const width = bound.w / modelScale; + const height = bound.h / modelScale; + containerStyleMap = styleMap({ + width: `${width}px`, + height: `${height}px`, + minHeight: `${height}px`, + transform: `scale(${modelScale})`, + transformOrigin: '0 0', + }); + + const themeService = this.std.get(ThemeProvider); + const themeExtension = this.std.getOptional(ThemeExtensionIdentifier); + const appTheme = themeService.app$.value; + let edgelessTheme = themeService.edgeless$.value; + if (themeExtension?.getEdgelessTheme && this.syncedDoc?.id) { + edgelessTheme = themeExtension.getEdgelessTheme(this.syncedDoc.id).value; + } + const theme = this.isPageMode ? appTheme : edgelessTheme; + + const isSelected = !!this.selected?.is('block'); + const scale = this.model.scale ?? 1; + + this.dataset.nestedEditor = ''; + + const renderEditor = () => { + return choose(editorMode, [ + [ + 'page', + () => html` +
+ ${new BlockStdScope({ + doc: syncedDoc, + extensions: this._buildPreviewSpec('page:preview'), + }).render()} +
+ `, + ], + [ + 'edgeless', + () => html` +
+ ${new BlockStdScope({ + doc: syncedDoc, + extensions: this._buildPreviewSpec('edgeless:preview'), + }).render()} +
+ `, + ], + ]); + }; + + return this.renderEmbed( + () => html` +
+
+ ${this.isPageMode && this._isEmptySyncedDoc + ? html` +
+ + This is a linked doc, you can add content here. + +
+ ` + : guard([editorMode, syncedDoc], renderEditor)} +
+
+
+ ` + ); + }; + + override convertToCard = (aliasInfo?: AliasInfo) => { + const { id, doc, caption, xywh } = this.model; + + const edgelessService = this.rootService; + const style = 'vertical'; + const bound = Bound.deserialize(xywh); + bound.w = EMBED_CARD_WIDTH[style]; + bound.h = EMBED_CARD_HEIGHT[style]; + + if (!edgelessService) { + return; + } + + // @ts-expect-error TODO: fix after edgeless refactor + const newId = edgelessService.addBlock( + 'affine:embed-linked-doc', + { + xywh: bound.serialize(), + style, + caption, + ...this.referenceInfo, + ...aliasInfo, + }, + // @ts-expect-error TODO: fix after edgeless refactor + edgelessService.surface + ); + + this.std.command.exec('reassociateConnectors', { + oldId: id, + newId, + }); + + // @ts-expect-error TODO: fix after edgeless refactor + edgelessService.selection.set({ + editing: false, + elements: [newId], + }); + doc.deleteBlock(this.model); + }; + + get rootService() { + return this.std.getService('affine:page'); + } + + override renderGfxBlock() { + const { style, xywh } = this.model; + const bound = Bound.deserialize(xywh); + + this.embedContainerStyle.width = `${bound.w}px`; + this.embedContainerStyle.height = `${bound.h}px`; + + this.cardStyleMap = { + display: 'block', + width: `${EMBED_CARD_WIDTH[style]}px`, + height: `${EMBED_CARD_WIDTH[style]}px`, + transform: `scale(${bound.w / EMBED_CARD_WIDTH[style]}, ${bound.h / EMBED_CARD_HEIGHT[style]})`, + transformOrigin: '0 0', + }; + + return this.renderPageContent(); + } + + override accessor useCaptionEditor = true; +} diff --git a/blocksuite/affine/block-embed/src/embed-synced-doc-block/embed-synced-doc-block.ts b/blocksuite/affine/block-embed/src/embed-synced-doc-block/embed-synced-doc-block.ts new file mode 100644 index 0000000000..cef87d1708 --- /dev/null +++ b/blocksuite/affine/block-embed/src/embed-synced-doc-block/embed-synced-doc-block.ts @@ -0,0 +1,596 @@ +import { Peekable } from '@blocksuite/affine-components/peek'; +import { + REFERENCE_NODE, + RefNodeSlotsProvider, +} from '@blocksuite/affine-components/rich-text'; +import { + type AliasInfo, + type DocMode, + type EmbedSyncedDocModel, + NoteDisplayMode, + type ReferenceInfo, +} from '@blocksuite/affine-model'; +import { + DocDisplayMetaProvider, + DocModeProvider, + ThemeExtensionIdentifier, + ThemeProvider, +} from '@blocksuite/affine-shared/services'; +import { + cloneReferenceInfo, + SpecProvider, +} from '@blocksuite/affine-shared/utils'; +import { + BlockServiceWatcher, + BlockStdScope, + type EditorHost, +} from '@blocksuite/block-std'; +import { GfxControllerIdentifier } from '@blocksuite/block-std/gfx'; +import { assertExists, Bound, getCommonBound } from '@blocksuite/global/utils'; +import { + BlockViewType, + DocCollection, + type GetDocOptions, + type Query, +} from '@blocksuite/store'; +import { computed } from '@preact/signals-core'; +import { html, type PropertyValues } from 'lit'; +import { query, state } from 'lit/decorators.js'; +import { choose } from 'lit/directives/choose.js'; +import { classMap } from 'lit/directives/class-map.js'; +import { guard } from 'lit/directives/guard.js'; +import { type StyleInfo, styleMap } from 'lit/directives/style-map.js'; + +import { EmbedBlockComponent } from '../common/embed-block-element.js'; +import { isEmptyDoc } from '../common/render-linked-doc.js'; +import type { EmbedSyncedDocCard } from './components/embed-synced-doc-card.js'; +import { blockStyles } from './styles.js'; + +@Peekable({ + enableOn: ({ doc }: EmbedSyncedDocBlockComponent) => !doc.readonly, +}) +export class EmbedSyncedDocBlockComponent extends EmbedBlockComponent { + static override styles = blockStyles; + + // Caches total bounds, includes all blocks and elements. + private _cachedBounds: Bound | null = null; + + private _initEdgelessFitEffect = () => { + const fitToContent = () => { + if (this.isPageMode) return; + + const controller = this.syncedDocEditorHost?.std.getOptional( + GfxControllerIdentifier + ); + if (!controller) return; + + const viewport = controller.viewport; + if (!viewport) return; + + if (!this._cachedBounds) { + this._cachedBounds = getCommonBound([ + ...controller.layer.blocks.map(block => + Bound.deserialize(block.xywh) + ), + ...controller.layer.canvasElements, + ]); + } + + viewport.onResize(); + + const { centerX, centerY, zoom } = viewport.getFitToScreenData( + this._cachedBounds + ); + viewport.setCenter(centerX, centerY); + viewport.setZoom(zoom); + }; + + const observer = new ResizeObserver(fitToContent); + const block = this.embedBlock; + + observer.observe(block); + + this._disposables.add(() => { + observer.disconnect(); + }); + + this.syncedDocEditorHost?.updateComplete + .then(() => fitToContent()) + .catch(() => {}); + }; + + private _pageFilter: Query = { + mode: 'loose', + match: [ + { + flavour: 'affine:note', + props: { + displayMode: NoteDisplayMode.EdgelessOnly, + }, + viewType: BlockViewType.Hidden, + }, + ], + }; + + protected _buildPreviewSpec = (name: 'page:preview' | 'edgeless:preview') => { + const nextDepth = this.depth + 1; + const previewSpecBuilder = SpecProvider.getInstance().getSpec(name); + const currentDisposables = this.disposables; + + class EmbedSyncedDocWatcher extends BlockServiceWatcher { + static override readonly flavour = 'affine:embed-synced-doc'; + + override mounted() { + const disposableGroup = this.blockService.disposables; + const slots = this.blockService.specSlots; + disposableGroup.add( + slots.viewConnected.on(({ component }) => { + const nextComponent = component as EmbedSyncedDocBlockComponent; + nextComponent.depth = nextDepth; + currentDisposables.add(() => { + nextComponent.depth = 0; + }); + }) + ); + disposableGroup.add( + slots.viewDisconnected.on(({ component }) => { + const nextComponent = component as EmbedSyncedDocBlockComponent; + nextComponent.depth = 0; + }) + ); + } + } + + previewSpecBuilder.extend([EmbedSyncedDocWatcher]); + + return previewSpecBuilder.value; + }; + + protected _renderSyncedView = () => { + const syncedDoc = this.syncedDoc; + const editorMode = this.editorMode; + const isPageMode = this.isPageMode; + + assertExists(syncedDoc); + + if (isPageMode) { + this.dataset.pageMode = ''; + } + + const containerStyleMap = styleMap({ + position: 'relative', + width: '100%', + }); + + const themeService = this.std.get(ThemeProvider); + const themeExtension = this.std.getOptional(ThemeExtensionIdentifier); + const appTheme = themeService.app$.value; + let edgelessTheme = themeService.edgeless$.value; + if (themeExtension?.getEdgelessTheme && this.syncedDoc?.id) { + edgelessTheme = themeExtension.getEdgelessTheme(this.syncedDoc.id).value; + } + const theme = isPageMode ? appTheme : edgelessTheme; + const isSelected = !!this.selected?.is('block'); + + this.dataset.nestedEditor = ''; + + const renderEditor = () => { + return choose(editorMode, [ + [ + 'page', + () => html` +
+ ${new BlockStdScope({ + doc: syncedDoc, + extensions: this._buildPreviewSpec('page:preview'), + }).render()} +
+ `, + ], + [ + 'edgeless', + () => html` +
+ ${new BlockStdScope({ + doc: syncedDoc, + extensions: this._buildPreviewSpec('edgeless:preview'), + }).render()} +
+ `, + ], + ]); + }; + + return this.renderEmbed( + () => html` +
+
+ ${isPageMode && this._isEmptySyncedDoc + ? html` +
+ + This is a linked doc, you can add content here. + +
+ ` + : guard([editorMode, syncedDoc], renderEditor)} +
+
+
+ ${this.icon$.value} + ${this.title$} +
+
+
+ ` + ); + }; + + protected cardStyleMap = styleMap({ + position: 'relative', + display: 'block', + width: '100%', + }); + + convertToCard = (aliasInfo?: AliasInfo) => { + const { doc, caption } = this.model; + + const parent = doc.getParent(this.model); + assertExists(parent); + const index = parent.children.indexOf(this.model); + + doc.addBlock( + 'affine:embed-linked-doc', + { caption, ...this.referenceInfo, ...aliasInfo }, + parent, + index + ); + + this.std.selection.setGroup('note', []); + doc.deleteBlock(this.model); + }; + + covertToInline = () => { + const { doc } = this.model; + const parent = doc.getParent(this.model); + assertExists(parent); + const index = parent.children.indexOf(this.model); + + const yText = new DocCollection.Y.Text(); + yText.insert(0, REFERENCE_NODE); + yText.format(0, REFERENCE_NODE.length, { + reference: { + type: 'LinkedPage', + ...this.referenceInfo, + }, + }); + const text = new doc.Text(yText); + + doc.addBlock( + 'affine:paragraph', + { + text, + }, + parent, + index + ); + + doc.deleteBlock(this.model); + }; + + protected override embedContainerStyle: StyleInfo = { + height: 'unset', + }; + + icon$ = computed(() => { + const { pageId, params } = this.model; + return this.std + .get(DocDisplayMetaProvider) + .icon(pageId, { params, referenced: true }).value; + }); + + open = () => { + const pageId = this.model.pageId; + if (pageId === this.doc.id) return; + + this.std.getOptional(RefNodeSlotsProvider)?.docLinkClicked.emit({ pageId }); + }; + + refreshData = () => { + this._load().catch(e => { + console.error(e); + this._error = true; + }); + }; + + title$ = computed(() => { + const { pageId, params } = this.model; + return this.std + .get(DocDisplayMetaProvider) + .title(pageId, { params, referenced: true }); + }); + + private get _rootService() { + return this.std.getService('affine:page'); + } + + get blockState() { + return { + isLoading: this._loading, + isError: this._error, + isDeleted: this._deleted, + isCycle: this._cycle, + }; + } + + get docTitle() { + return this.syncedDoc?.meta?.title || 'Untitled'; + } + + get docUpdatedAt() { + return this._docUpdatedAt; + } + + get editorMode() { + return this.linkedMode ?? this.syncedDocMode; + } + + protected get isPageMode() { + return this.editorMode === 'page'; + } + + get linkedMode() { + return this.referenceInfo.params?.mode; + } + + get referenceInfo(): ReferenceInfo { + return cloneReferenceInfo(this.model); + } + + get syncedDoc() { + const options: GetDocOptions = { readonly: true }; + if (this.isPageMode) options.query = this._pageFilter; + return this.std.collection.getDoc(this.model.pageId, options); + } + + private _checkCycle() { + let editorHost: EditorHost | null = this.host; + while (editorHost && !this._cycle) { + this._cycle = !!editorHost && editorHost.doc.id === this.model.pageId; + editorHost = + editorHost.parentElement?.closest('editor-host') ?? null; + } + } + + private _isClickAtBorder( + event: MouseEvent, + element: HTMLElement, + tolerance = 8 + ): boolean { + const { x, y } = event; + const rect = element.getBoundingClientRect(); + if (!rect) { + return false; + } + + return ( + Math.abs(x - rect.left) < tolerance || + Math.abs(x - rect.right) < tolerance || + Math.abs(y - rect.top) < tolerance || + Math.abs(y - rect.bottom) < tolerance + ); + } + + private async _load() { + this._loading = true; + this._error = false; + this._deleted = false; + this._cycle = false; + + const syncedDoc = this.syncedDoc; + if (!syncedDoc) { + this._deleted = true; + this._loading = false; + return; + } + + this._checkCycle(); + + if (!syncedDoc.loaded) { + try { + syncedDoc.load(); + } catch (e) { + console.error(e); + this._error = true; + } + } + + if (!this._error && !syncedDoc.root) { + await new Promise(resolve => { + syncedDoc.slots.rootAdded.once(() => resolve()); + }); + } + + this._loading = false; + } + + private _selectBlock() { + const selectionManager = this.host.selection; + const blockSelection = selectionManager.create('block', { + blockId: this.blockId, + }); + selectionManager.setGroup('note', [blockSelection]); + } + + private _setDocUpdatedAt() { + const meta = this.doc.collection.meta.getDocMeta(this.model.pageId); + if (meta) { + const date = meta.updatedDate || meta.createDate; + this._docUpdatedAt = new Date(date); + } + } + + protected _handleClick(_event: MouseEvent) { + this._selectBlock(); + } + + override connectedCallback() { + super.connectedCallback(); + this._cardStyle = this.model.style; + + this.style.display = 'block'; + this._load().catch(e => { + console.error(e); + this._error = true; + }); + + this.contentEditable = 'false'; + + this.disposables.add( + this.model.propsUpdated.on(({ key }) => { + if (key === 'pageId' || key === 'style') { + this._load().catch(e => { + console.error(e); + this._error = true; + }); + } + }) + ); + + this._setDocUpdatedAt(); + this.disposables.add( + this.doc.collection.meta.docMetaUpdated.on(() => { + this._setDocUpdatedAt(); + }) + ); + + if (this._rootService && !this.linkedMode) { + const docMode = this._rootService.std.get(DocModeProvider); + this.syncedDocMode = docMode.getPrimaryMode(this.model.pageId); + this._isEmptySyncedDoc = isEmptyDoc(this.syncedDoc, this.editorMode); + this.disposables.add( + docMode.onPrimaryModeChange(mode => { + this.syncedDocMode = mode; + this._isEmptySyncedDoc = isEmptyDoc(this.syncedDoc, this.editorMode); + }, this.model.pageId) + ); + } + + this.syncedDoc && + this.disposables.add( + this.syncedDoc.slots.blockUpdated.on(() => { + this._isEmptySyncedDoc = isEmptyDoc(this.syncedDoc, this.editorMode); + }) + ); + } + + override firstUpdated() { + this.disposables.addFromEvent(this, 'click', e => { + e.stopPropagation(); + if (this._isClickAtBorder(e, this)) { + e.preventDefault(); + this._selectBlock(); + } + }); + + // Forward docLinkClicked event from the synced doc + const refNodeProvider = + this.syncedDocEditorHost?.std.getOptional(RefNodeSlotsProvider); + if (refNodeProvider) { + this.disposables.add( + refNodeProvider.docLinkClicked.on(args => { + this.std.getOptional(RefNodeSlotsProvider)?.docLinkClicked.emit(args); + }) + ); + } + + this._initEdgelessFitEffect(); + } + + override renderBlock() { + delete this.dataset.nestedEditor; + + const syncedDoc = this.syncedDoc; + const { isLoading, isError, isDeleted, isCycle } = this.blockState; + const isCardOnly = this.depth >= 1; + + if ( + isLoading || + isError || + isDeleted || + isCardOnly || + isCycle || + !syncedDoc + ) { + return this.renderEmbed( + () => html` + + ` + ); + } + + return this._renderSyncedView(); + } + + override updated(changedProperties: PropertyValues) { + super.updated(changedProperties); + this.syncedDocCard?.requestUpdate(); + } + + @state() + private accessor _cycle = false; + + @state() + private accessor _deleted = false; + + @state() + private accessor _docUpdatedAt: Date = new Date(); + + @state() + private accessor _error = false; + + @state() + protected accessor _isEmptySyncedDoc: boolean = true; + + @state() + private accessor _loading = false; + + @state() + accessor depth = 0; + + @query( + ':scope > .affine-block-component > .embed-block-container > affine-embed-synced-doc-card' + ) + accessor syncedDocCard: EmbedSyncedDocCard | null = null; + + @query( + ':scope > .affine-block-component > .embed-block-container > .affine-embed-synced-doc-container > .affine-embed-synced-doc-editor > div > editor-host' + ) + accessor syncedDocEditorHost: EditorHost | null = null; + + @state() + accessor syncedDocMode: DocMode = 'page'; + + override accessor useCaptionEditor = true; +} diff --git a/blocksuite/affine/block-embed/src/embed-synced-doc-block/embed-synced-doc-service.ts b/blocksuite/affine/block-embed/src/embed-synced-doc-block/embed-synced-doc-service.ts new file mode 100644 index 0000000000..fb734057c8 --- /dev/null +++ b/blocksuite/affine/block-embed/src/embed-synced-doc-block/embed-synced-doc-service.ts @@ -0,0 +1,6 @@ +import { EmbedSyncedDocBlockSchema } from '@blocksuite/affine-model'; +import { BlockService } from '@blocksuite/block-std'; + +export class EmbedSyncedDocBlockService extends BlockService { + static override readonly flavour = EmbedSyncedDocBlockSchema.model.flavour; +} diff --git a/blocksuite/affine/block-embed/src/embed-synced-doc-block/embed-synced-doc-spec.ts b/blocksuite/affine/block-embed/src/embed-synced-doc-block/embed-synced-doc-spec.ts new file mode 100644 index 0000000000..a398623153 --- /dev/null +++ b/blocksuite/affine/block-embed/src/embed-synced-doc-block/embed-synced-doc-spec.ts @@ -0,0 +1,18 @@ +import { + BlockViewExtension, + type ExtensionType, + FlavourExtension, +} from '@blocksuite/block-std'; +import { literal } from 'lit/static-html.js'; + +import { EmbedSyncedDocBlockService } from './embed-synced-doc-service.js'; + +export const EmbedSyncedDocBlockSpec: ExtensionType[] = [ + FlavourExtension('affine:embed-synced-doc'), + EmbedSyncedDocBlockService, + BlockViewExtension('affine:embed-synced-doc', model => { + return model.parent?.flavour === 'affine:surface' + ? literal`affine-embed-edgeless-synced-doc-block` + : literal`affine-embed-synced-doc-block`; + }), +]; diff --git a/blocksuite/affine/block-embed/src/embed-synced-doc-block/index.ts b/blocksuite/affine/block-embed/src/embed-synced-doc-block/index.ts new file mode 100644 index 0000000000..28311f4131 --- /dev/null +++ b/blocksuite/affine/block-embed/src/embed-synced-doc-block/index.ts @@ -0,0 +1,4 @@ +export * from './adapters/index.js'; +export * from './embed-synced-doc-block.js'; +export * from './embed-synced-doc-spec.js'; +export { SYNCED_MIN_HEIGHT, SYNCED_MIN_WIDTH } from './styles.js'; diff --git a/blocksuite/affine/block-embed/src/embed-synced-doc-block/styles.ts b/blocksuite/affine/block-embed/src/embed-synced-doc-block/styles.ts new file mode 100644 index 0000000000..0f8bb12162 --- /dev/null +++ b/blocksuite/affine/block-embed/src/embed-synced-doc-block/styles.ts @@ -0,0 +1,1129 @@ +import { + EMBED_CARD_HEIGHT, + EMBED_CARD_WIDTH, +} from '@blocksuite/affine-shared/consts'; +import { cssVarV2 } from '@toeverything/theme/v2'; +import { css, html, unsafeCSS } from 'lit'; + +import { embedNoteContentStyles } from '../common/embed-note-content-styles.js'; + +export const SYNCED_MIN_WIDTH = 370; +export const SYNCED_MIN_HEIGHT = 64; + +export const blockStyles = css` + affine-embed-synced-doc-block { + --embed-padding: 24px; + } + affine-embed-synced-doc-block[data-page-mode] { + width: calc(100% + var(--embed-padding) * 2); + margin-left: calc(-1 * var(--embed-padding)); + margin-right: calc(-1 * var(--embed-padding)); + } + .edgeless-block-portal-embed + > affine-embed-synced-doc-block[data-nested-editor] { + position: relative; + display: block; + left: 0px; + top: 0px; + width: 100%; + height: 100%; + } + + .edgeless-block-portal-embed + .affine-embed-synced-doc-editor + .affine-page-root-block-container { + width: 100%; + } + + .edgeless-block-portal-embed .affine-embed-synced-doc-container.edgeless { + display: block; + padding: 0; + width: 100%; + height: calc(${EMBED_CARD_HEIGHT.syncedDoc}px + 36px); + } + .edgeless-block-portal-embed .affine-embed-synced-doc-container.surface { + border: 1px solid var(--affine-border-color); + } + + affine-embed-synced-doc-block[data-nested-editor], + affine-embed-edgeless-synced-doc-block[data-nested-editor] { + .affine-embed-synced-doc-container.page { + padding: 0 var(--embed-padding); + } + } + + .affine-embed-synced-doc-editor { + pointer-events: none; + } + + .affine-embed-synced-doc-container { + border-radius: 8px; + overflow: hidden; + } + .affine-embed-synced-doc-container.page { + display: block; + width: 100%; + } + .affine-embed-synced-doc-container.edgeless { + display: block; + width: 100%; + height: calc(${EMBED_CARD_HEIGHT.syncedDoc}px + 36px); + } + .affine-embed-synced-doc-container:hover.light { + box-shadow: 0px 0px 0px 2px rgba(0, 0, 0, 0.08); + } + .affine-embed-synced-doc-container:hover.dark { + box-shadow: 0px 0px 0px 2px rgba(255, 255, 255, 0.14); + } + .affine-embed-synced-doc-header-wrapper { + position: absolute; + top: 0; + left: 0; + height: 34px; + width: 100%; + background-color: var(--affine-white); + opacity: 0; + } + @media print { + .affine-embed-synced-doc-header-wrapper { + display: none; + } + } + .affine-embed-synced-doc-header-wrapper.selected { + opacity: 1; + transition: all 0.23s ease; + } + .affine-embed-synced-doc-header { + display: flex; + align-items: center; + width: 100%; + height: 100%; + padding: 0 var(--embed-padding); + background-color: var(--affine-hover-color); + } + .affine-embed-synced-doc-header svg { + flex-shrink: 0; + } + .affine-embed-synced-doc-icon { + line-height: 0; + color: ${unsafeCSS(cssVarV2.icon.primary)}; + } + .affine-embed-synced-doc-title { + font-size: 14px; + font-weight: 600; + line-height: 22px; + margin-left: 8px; + min-width: 0; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + .affine-embed-synced-doc-editor-overlay { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + z-index: 1; + cursor: pointer; + } + .affine-embed-synced-doc-editor-overlay:hover { + background: var(--affine-hover-color); + } + + .affine-embed-synced-doc-editor-empty { + display: flex; + align-items: center; + width: 100%; + height: 100%; + min-height: 44px; + } + + .affine-embed-synced-doc-container.surface + > .affine-embed-synced-doc-editor + > .affine-embed-synced-doc-editor-empty { + left: 0; + justify-content: center; + } + + .affine-embed-synced-doc-editor-empty > span { + color: var(--affine-placeholder-color); + font-feature-settings: + 'clig' off, + 'liga' off; + font-family: var(--affine-font-family); + font-size: 15px; + font-style: normal; + font-weight: 400; + line-height: 24px; + } + + .affine-embed-synced-doc-container.surface { + background: var(--affine-background-primary-color); + } + + .affine-embed-synced-doc-container + > .affine-embed-synced-doc-editor.affine-page-viewport { + background: transparent; + } + + .affine-embed-synced-doc-container > .affine-embed-synced-doc-editor { + width: 100%; + height: 100%; + } + + .affine-embed-synced-doc-editor .affine-page-root-block-container { + width: 100%; + max-width: 100%; + } + + @container (max-width: 640px) { + affine-embed-synced-doc-block { + --embed-padding: 8px; + } + .affine-embed-synced-doc-title { + font-weight: 400; + } + .affine-embed-synced-doc-header-wrapper { + height: 33px; + } + } +`; + +export const cardStyles = css` + .affine-embed-synced-doc-card { + margin: 0 auto; + box-sizing: border-box; + display: flex; + width: 100%; + height: ${EMBED_CARD_HEIGHT.horizontal}px; + border-radius: 8px; + border: 1px solid var(--affine-background-tertiary-color); + opacity: var(--add, 1); + background: var(--affine-background-primary-color); + user-select: none; + } + + .affine-embed-synced-doc-card-content { + width: calc(100% - 204px); + height: 100%; + display: flex; + flex-direction: column; + align-self: stretch; + gap: 4px; + padding: 12px; + border-radius: var(--1, 0px); + opacity: var(--add, 1); + } + + .affine-embed-synced-doc-card-content-title { + display: flex; + flex-direction: row; + gap: 8px; + align-items: center; + align-self: stretch; + padding: var(--1, 0px); + border-radius: var(--1, 0px); + opacity: var(--add, 1); + } + + .affine-embed-synced-doc-card-content-title-icon { + display: flex; + width: 16px; + height: 16px; + justify-content: center; + align-items: center; + } + .affine-embed-synced-doc-card-content-title-icon svg { + width: 16px; + height: 16px; + fill: var(--affine-background-primary-color); + } + + .affine-embed-synced-doc-card-content-title-text { + display: -webkit-box; + -webkit-line-clamp: 1; + -webkit-box-orient: vertical; + word-break: break-word; + overflow: hidden; + text-overflow: ellipsis; + color: var(--affine-text-primary-color); + font-family: var(--affine-font-family); + font-size: var(--affine-font-sm); + font-style: normal; + font-weight: 600; + line-height: 22px; + } + + .affine-embed-synced-doc-content-note.render { + display: none; + overflow: hidden; + pointer-events: none; + } + + ${embedNoteContentStyles} + + .affine-embed-synced-doc-content-note.default { + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + white-space: normal; + word-break: break-word; + overflow: hidden; + text-overflow: ellipsis; + color: var(--affine-placeholder-color); + font-family: var(--affine-font-family); + font-size: var(--affine-font-xs); + font-style: normal; + font-weight: 400; + line-height: 20px; + } + + .affine-embed-synced-doc-card-content-date, + .affine-embed-synced-doc-card-content-reload { + display: flex; + flex-grow: 1; + align-items: flex-end; + justify-content: flex-start; + gap: 8px; + width: max-content; + max-width: 100%; + line-height: 20px; + } + + .affine-embed-synced-doc-card-content-date > span { + display: -webkit-box; + -webkit-line-clamp: 1; + -webkit-box-orient: vertical; + word-break: break-all; + white-space: normal; + overflow: hidden; + text-overflow: ellipsis; + color: var(--affine-text-secondary-color); + font-family: var(--affine-font-family); + font-size: var(--affine-font-xs); + font-style: normal; + font-weight: 400; + line-height: 20px; + } + + .affine-embed-synced-doc-card-content-reload-button { + display: flex; + flex-direction: row; + align-items: center; + gap: 4px; + cursor: pointer; + } + .affine-embed-synced-doc-card-content-reload-button svg { + width: 12px; + height: 12px; + fill: var(--affine-background-primary-color); + } + .affine-embed-synced-doc-card-content-reload-button > span { + display: -webkit-box; + -webkit-line-clamp: 1; + -webkit-box-orient: vertical; + word-break: break-all; + white-space: normal; + overflow: hidden; + text-overflow: ellipsis; + color: var(--affine-brand-color); + font-family: var(--affine-font-family); + font-size: var(--affine-font-xs); + font-style: normal; + font-weight: 500; + line-height: 20px; + } + + .affine-embed-synced-doc-card-banner { + margin: 12px 12px 0px 0px; + width: 204px; + max-width: 100%; + height: 102px; + opacity: var(--add, 1); + pointer-events: none; + } + .affine-embed-synced-doc-card-banner.render { + display: none; + } + .affine-embed-synced-doc-card-banner img, + .affine-embed-synced-doc-card-banner object, + .affine-embed-synced-doc-card-banner svg { + width: 204px; + max-width: 100%; + height: 102px; + object-fit: cover; + border-radius: 4px 4px var(--1, 0px) var(--1, 0px); + } + + .affine-embed-synced-doc-card.loading, + .affine-embed-synced-doc-card.deleted, + .affine-embed-synced-doc-card.error { + .affine-embed-linked-doc-content-note.render { + display: none; + } + .affine-embed-linked-doc-content-note.default { + display: block; + } + .affine-embed-synced-doc-card-banner.render { + display: none; + } + .affine-embed-synced-doc-card-banner.default { + display: block; + } + .affine-embed-synced-doc-card-content-date { + display: none; + } + } + + .affine-embed-synced-doc-card:not(.loading).deleted, + .affine-embed-synced-doc-card:not(.loading).error { + background: var(--affine-background-secondary-color); + } + .affine-embed-synced-doc-card:not(.loading):not(.error):not( + .surface + ).deleted { + height: ${EMBED_CARD_HEIGHT.horizontalThin}px; + .affine-embed-synced-doc-card-banner { + height: 66px; + } + .affine-embed-synced-doc-card-banner img, + .affine-embed-synced-doc-card-banner object, + .affine-embed-synced-doc-card-banner svg { + height: 66px; + } + .affine-embed-synced-doc-card-content { + gap: 12px; + } + } + + .affine-embed-synced-doc-card:not(.loading):not(.error):not(.deleted):not( + .note-empty + ).cycle { + .affine-embed-synced-doc-content-note.render { + display: block; + } + .affine-embed-synced-doc-content-note.default { + display: none; + } + } + + .affine-embed-synced-doc-card:not(.loading):not(.error):not(.deleted):not( + .banner-empty + ).cycle { + .affine-embed-synced-doc-card-banner.render { + display: block; + } + .affine-embed-synced-doc-card-banner.default { + display: none; + } + } + .affine-embed-synced-doc-card:not(.loading):not(.error):not( + .deleted + ).cycle.banner-empty { + .affine-embed-synced-doc-card-content { + width: 100%; + height: 100%; + } + + .affine-embed-synced-doc-card-banner.render { + display: none; + } + + .affine-embed-synced-doc-card-banner.default { + display: none; + } + } + + .affine-embed-synced-doc-card.surface:not(.cycle) { + width: ${EMBED_CARD_WIDTH.syncedDoc}px; + height: ${EMBED_CARD_HEIGHT.syncedDoc}px; + flex-direction: column-reverse; + + .affine-embed-synced-doc-card-banner.default { + display: flex; + align-items: flex-end; + justify-content: center; + width: 100%; + height: 267.5px; + margin-left: 12px; + flex-shrink: 0; + } + .affine-embed-synced-doc-card-banner img, + .affine-embed-synced-doc-card-banner object, + .affine-embed-synced-doc-card-banner svg { + width: 340px; + height: 170px; + } + + .affine-embed-synced-doc-card-content { + display: flex; + flex-direction: column; + align-items: center; + gap: 4px; + width: 100%; + height: 100%; + } + + .affine-embed-synced-doc-card-content-title { + margin: 0 auto; + } + + .affine-embed-synced-doc-card-content-note { + margin: 0 auto; + flex-grow: 0; + } + + .affine-embed-synced-doc-card-content-reload { + flex-grow: 0; + margin: 0 auto; + } + } + + .affine-embed-synced-doc-card.surface:not(.loading):not(.error):not( + .deleted + ).cycle { + width: ${EMBED_CARD_WIDTH.vertical}px; + height: ${EMBED_CARD_HEIGHT.vertical}px; + flex-direction: column-reverse; + margin-top: calc( + (${EMBED_CARD_HEIGHT.syncedDoc}px - ${EMBED_CARD_HEIGHT.vertical}px) / 2 + ); + + .affine-embed-synced-doc-card-content { + width: 100%; + } + + .affine-embed-synced-doc-card-content-note { + -webkit-line-clamp: 6; + max-height: 130px; + } + + .affine-embed-synced-doc-card-content-date { + flex-grow: 1; + align-items: flex-end; + } + + .affine-embed-synced-doc-card-banner { + width: 340px; + height: 170px; + margin-left: 12px; + } + .affine-embed-synced-doc-card-banner img, + .affine-embed-synced-doc-card-banner object, + .affine-embed-synced-doc-card-banner svg { + width: 340px; + height: 170px; + } + } + + .affine-embed-synced-doc-card.surface:not(.loading):not(.error):not( + .deleted + ).cycle:not(.empty).banner-empty { + .affine-embed-synced-doc-card-content { + width: 100%; + height: 100%; + } + + .affine-embed-synced-doc-card-banner.render { + display: none; + } + + .affine-embed-synced-doc-card-banner.default { + display: none; + } + + .affine-embed-synced-doc-card-content-note { + -webkit-line-clamp: 16; + max-height: 320px; + } + + .affine-embed-synced-doc-card-content-date { + flex-grow: unset; + align-items: center; + } + } +`; + +export const SyncedDocErrorIcon = html` + + + + + + + + +`; + +export const SyncedDocDeletedIcon = html` + + + + + + + + +`; + +export const LightSyncedDocEmptyBanner = html` + + + + + + + + + + + + + + + + + + + + + + + + + + +`; + +export const DarkSyncedDocEmptyBanner = html` + + + + + + + + + + + + + + + + + + + + + + + + + + +`; + +export const LightSyncedDocErrorBanner = html` + + + + + + + + +`; + +export const DarkSyncedDocErrorBanner = html` + + + + + + + + +`; + +export const LightSyncedDocDeletedBanner = html` + + + + + + + + + + + + + + + + + + + + + + + + + + + +`; + +export const DarkSyncedDocDeletedBanner = html` + + + + + + + + + + + + + + + + + + + + + + + + + + + +`; diff --git a/blocksuite/affine/block-embed/src/embed-synced-doc-block/utils.ts b/blocksuite/affine/block-embed/src/embed-synced-doc-block/utils.ts new file mode 100644 index 0000000000..2e05356428 --- /dev/null +++ b/blocksuite/affine/block-embed/src/embed-synced-doc-block/utils.ts @@ -0,0 +1,60 @@ +import { + DarkLoadingIcon, + EmbedEdgelessIcon, + EmbedPageIcon, + LightLoadingIcon, + ReloadIcon, +} from '@blocksuite/affine-components/icons'; +import { ColorScheme } from '@blocksuite/affine-model'; +import type { TemplateResult } from 'lit'; + +import { + DarkSyncedDocDeletedBanner, + DarkSyncedDocEmptyBanner, + DarkSyncedDocErrorBanner, + LightSyncedDocDeletedBanner, + LightSyncedDocEmptyBanner, + LightSyncedDocErrorBanner, + SyncedDocDeletedIcon, + SyncedDocErrorIcon, +} from './styles.js'; + +type SyncedCardImages = { + LoadingIcon: TemplateResult<1>; + SyncedDocIcon: TemplateResult<1>; + SyncedDocErrorIcon: TemplateResult<1>; + SyncedDocDeletedIcon: TemplateResult<1>; + ReloadIcon: TemplateResult<1>; + SyncedDocEmptyBanner: TemplateResult<1>; + SyncedDocErrorBanner: TemplateResult<1>; + SyncedDocDeletedBanner: TemplateResult<1>; +}; + +export function getSyncedDocIcons( + theme: ColorScheme, + editorMode: 'page' | 'edgeless' +): SyncedCardImages { + if (theme === ColorScheme.Light) { + return { + LoadingIcon: LightLoadingIcon, + SyncedDocIcon: editorMode === 'page' ? EmbedPageIcon : EmbedEdgelessIcon, + SyncedDocErrorIcon, + SyncedDocDeletedIcon, + ReloadIcon, + SyncedDocEmptyBanner: LightSyncedDocEmptyBanner, + SyncedDocErrorBanner: LightSyncedDocErrorBanner, + SyncedDocDeletedBanner: LightSyncedDocDeletedBanner, + }; + } else { + return { + LoadingIcon: DarkLoadingIcon, + SyncedDocIcon: editorMode === 'page' ? EmbedPageIcon : EmbedEdgelessIcon, + SyncedDocErrorIcon, + SyncedDocDeletedIcon, + ReloadIcon, + SyncedDocEmptyBanner: DarkSyncedDocEmptyBanner, + SyncedDocErrorBanner: DarkSyncedDocErrorBanner, + SyncedDocDeletedBanner: DarkSyncedDocDeletedBanner, + }; + } +} diff --git a/blocksuite/affine/block-embed/src/embed-youtube-block/adapters/html.ts b/blocksuite/affine/block-embed/src/embed-youtube-block/adapters/html.ts new file mode 100644 index 0000000000..4a35716054 --- /dev/null +++ b/blocksuite/affine/block-embed/src/embed-youtube-block/adapters/html.ts @@ -0,0 +1,51 @@ +import { EmbedYoutubeBlockSchema } from '@blocksuite/affine-model'; +import { + BlockHtmlAdapterExtension, + HastUtils, +} from '@blocksuite/affine-shared/adapters'; +import { nanoid } from '@blocksuite/store'; + +import { createEmbedBlockHtmlAdapterMatcher } from '../../common/adapters/html.js'; + +export const embedYoutubeBlockHtmlAdapterMatcher = + createEmbedBlockHtmlAdapterMatcher(EmbedYoutubeBlockSchema.model.flavour, { + toMatch: o => HastUtils.isElement(o.node) && o.node.tagName === 'iframe', + toBlockSnapshot: { + enter: (o, context) => { + if (!HastUtils.isElement(o.node)) { + return; + } + + const src = o.node.properties?.src; + if (typeof src !== 'string') { + return; + } + + const { walkerContext } = context; + if (src.startsWith('https://www.youtube.com/embed/')) { + const videoId = src.substring( + 'https://www.youtube.com/embed/'.length, + src.indexOf('?') !== -1 ? src.indexOf('?') : undefined + ); + walkerContext + .openNode( + { + type: 'block', + id: nanoid(), + flavour: 'affine:embed-youtube', + props: { + url: `https://www.youtube.com/watch?v=${videoId}`, + }, + children: [], + }, + 'children' + ) + .closeNode(); + } + }, + }, + }); + +export const EmbedYoutubeBlockHtmlAdapterExtension = BlockHtmlAdapterExtension( + embedYoutubeBlockHtmlAdapterMatcher +); diff --git a/blocksuite/affine/block-embed/src/embed-youtube-block/adapters/index.ts b/blocksuite/affine/block-embed/src/embed-youtube-block/adapters/index.ts new file mode 100644 index 0000000000..c1d476903d --- /dev/null +++ b/blocksuite/affine/block-embed/src/embed-youtube-block/adapters/index.ts @@ -0,0 +1,3 @@ +export * from './html.js'; +export * from './markdown.js'; +export * from './plain-text.js'; diff --git a/blocksuite/affine/block-embed/src/embed-youtube-block/adapters/markdown.ts b/blocksuite/affine/block-embed/src/embed-youtube-block/adapters/markdown.ts new file mode 100644 index 0000000000..ddddefa890 --- /dev/null +++ b/blocksuite/affine/block-embed/src/embed-youtube-block/adapters/markdown.ts @@ -0,0 +1,10 @@ +import { EmbedYoutubeBlockSchema } from '@blocksuite/affine-model'; +import { BlockMarkdownAdapterExtension } from '@blocksuite/affine-shared/adapters'; + +import { createEmbedBlockMarkdownAdapterMatcher } from '../../common/adapters/markdown.js'; + +export const embedYoutubeBlockMarkdownAdapterMatcher = + createEmbedBlockMarkdownAdapterMatcher(EmbedYoutubeBlockSchema.model.flavour); + +export const EmbedYoutubeMarkdownAdapterExtension = + BlockMarkdownAdapterExtension(embedYoutubeBlockMarkdownAdapterMatcher); diff --git a/blocksuite/affine/block-embed/src/embed-youtube-block/adapters/plain-text.ts b/blocksuite/affine/block-embed/src/embed-youtube-block/adapters/plain-text.ts new file mode 100644 index 0000000000..00f8299dfb --- /dev/null +++ b/blocksuite/affine/block-embed/src/embed-youtube-block/adapters/plain-text.ts @@ -0,0 +1,12 @@ +import { EmbedYoutubeBlockSchema } from '@blocksuite/affine-model'; +import { BlockPlainTextAdapterExtension } from '@blocksuite/affine-shared/adapters'; + +import { createEmbedBlockPlainTextAdapterMatcher } from '../../common/adapters/plain-text.js'; + +export const embedYoutubeBlockPlainTextAdapterMatcher = + createEmbedBlockPlainTextAdapterMatcher( + EmbedYoutubeBlockSchema.model.flavour + ); + +export const EmbedYoutubeBlockPlainTextAdapterExtension = + BlockPlainTextAdapterExtension(embedYoutubeBlockPlainTextAdapterMatcher); diff --git a/blocksuite/affine/block-embed/src/embed-youtube-block/embed-edgeless-youtube-block.ts b/blocksuite/affine/block-embed/src/embed-youtube-block/embed-edgeless-youtube-block.ts new file mode 100644 index 0000000000..3e4b52ab19 --- /dev/null +++ b/blocksuite/affine/block-embed/src/embed-youtube-block/embed-edgeless-youtube-block.ts @@ -0,0 +1,6 @@ +import { toEdgelessEmbedBlock } from '../common/to-edgeless-embed-block.js'; +import { EmbedYoutubeBlockComponent } from './embed-youtube-block.js'; + +export class EmbedEdgelessYoutubeBlockComponent extends toEdgelessEmbedBlock( + EmbedYoutubeBlockComponent +) {} diff --git a/blocksuite/affine/block-embed/src/embed-youtube-block/embed-youtube-block.ts b/blocksuite/affine/block-embed/src/embed-youtube-block/embed-youtube-block.ts new file mode 100644 index 0000000000..d3fcc882d5 --- /dev/null +++ b/blocksuite/affine/block-embed/src/embed-youtube-block/embed-youtube-block.ts @@ -0,0 +1,250 @@ +import { OpenIcon } from '@blocksuite/affine-components/icons'; +import type { + EmbedYoutubeModel, + EmbedYoutubeStyles, +} from '@blocksuite/affine-model'; +import { ThemeProvider } from '@blocksuite/affine-shared/services'; +import { html, nothing } from 'lit'; +import { property, state } from 'lit/decorators.js'; +import { classMap } from 'lit/directives/class-map.js'; +import { styleMap } from 'lit/directives/style-map.js'; + +import { EmbedBlockComponent } from '../common/embed-block-element.js'; +import { getEmbedCardIcons } from '../common/utils.js'; +import { youtubeUrlRegex } from './embed-youtube-model.js'; +import type { EmbedYoutubeBlockService } from './embed-youtube-service.js'; +import { styles, YoutubeIcon } from './styles.js'; +import { refreshEmbedYoutubeUrlData } from './utils.js'; + +export class EmbedYoutubeBlockComponent extends EmbedBlockComponent< + EmbedYoutubeModel, + EmbedYoutubeBlockService +> { + static override styles = styles; + + override _cardStyle: (typeof EmbedYoutubeStyles)[number] = 'video'; + + protected _isDragging = false; + + protected _isResizing = false; + + open = () => { + let link = this.model.url; + if (!link.match(/^[a-zA-Z]+:\/\//)) { + link = 'https://' + link; + } + window.open(link, '_blank'); + }; + + refreshData = () => { + refreshEmbedYoutubeUrlData(this, this.fetchAbortController.signal).catch( + console.error + ); + }; + + private _handleDoubleClick(event: MouseEvent) { + event.stopPropagation(); + this.open(); + } + + private _selectBlock() { + const selectionManager = this.host.selection; + const blockSelection = selectionManager.create('block', { + blockId: this.blockId, + }); + selectionManager.setGroup('note', [blockSelection]); + } + + protected _handleClick(event: MouseEvent) { + event.stopPropagation(); + this._selectBlock(); + } + + override connectedCallback() { + super.connectedCallback(); + this._cardStyle = this.model.style; + + if (!this.model.videoId) { + this.doc.withoutTransact(() => { + const url = this.model.url; + const urlMatch = url.match(youtubeUrlRegex); + if (urlMatch) { + const [, videoId] = urlMatch; + this.doc.updateBlock(this.model, { + videoId, + }); + } + }); + } + + if (!this.model.description && !this.model.title) { + this.doc.withoutTransact(() => { + this.refreshData(); + }); + } + + this.disposables.add( + this.model.propsUpdated.on(({ key }) => { + this.requestUpdate(); + if (key === 'url') { + this.refreshData(); + } + }) + ); + + // this is required to prevent iframe from capturing pointer events + this.disposables.add( + this.std.selection.slots.changed.on(() => { + this._isSelected = + !!this.selected?.is('block') || !!this.selected?.is('surface'); + + this._showOverlay = + this._isResizing || this._isDragging || !this._isSelected; + }) + ); + // this is required to prevent iframe from capturing pointer events + this.handleEvent('dragStart', () => { + this._isDragging = true; + this._showOverlay = + this._isResizing || this._isDragging || !this._isSelected; + }); + + this.handleEvent('dragEnd', () => { + this._isDragging = false; + this._showOverlay = + this._isResizing || this._isDragging || !this._isSelected; + }); + + matchMedia('print').addEventListener('change', () => { + this._showImage = matchMedia('print').matches; + }); + } + + override renderBlock() { + const { + image, + title = 'YouTube', + description, + creator, + creatorImage, + videoId, + } = this.model; + + const loading = this.loading; + const theme = this.std.get(ThemeProvider).theme; + const { LoadingIcon, EmbedCardBannerIcon } = getEmbedCardIcons(theme); + const titleIcon = loading ? LoadingIcon : YoutubeIcon; + const titleText = loading ? 'Loading...' : title; + const descriptionText = loading ? '' : description; + const bannerImage = + !loading && image + ? html` + ${EmbedCardBannerIcon} + ` + : EmbedCardBannerIcon; + + const creatorImageEl = + !loading && creatorImage + ? html`` + : nothing; + + return this.renderEmbed( + () => html` +
+
+ ${videoId + ? html` +
+ +
+ YouTube Video +
+ ` + : bannerImage} +
+
+
+
+ ${titleIcon} +
+ +
+ ${titleText} +
+ +
+ ${creatorImageEl} +
+ +
+ ${creator} +
+
+ +
+ ${descriptionText} +
+ +
+ www.youtube.com + +
+ ${OpenIcon} +
+
+
+
+ ` + ); + } + + @state() + protected accessor _isSelected = false; + + @state() + private accessor _showImage = false; + + @state() + protected accessor _showOverlay = true; + + @property({ attribute: false }) + accessor loading = false; +} diff --git a/blocksuite/affine/block-embed/src/embed-youtube-block/embed-youtube-model.ts b/blocksuite/affine/block-embed/src/embed-youtube-block/embed-youtube-model.ts new file mode 100644 index 0000000000..5ed0de3746 --- /dev/null +++ b/blocksuite/affine/block-embed/src/embed-youtube-block/embed-youtube-model.ts @@ -0,0 +1,2 @@ +export const youtubeUrlRegex: RegExp = + /(?:https?:\/\/)?(?:(?:www|m)\.)?youtu\.?be(?:\.com)?\/?.*(?:watch|embed)?(?:.*v=|v\/|\/)([\w-_]+)/; diff --git a/blocksuite/affine/block-embed/src/embed-youtube-block/embed-youtube-service.ts b/blocksuite/affine/block-embed/src/embed-youtube-block/embed-youtube-service.ts new file mode 100644 index 0000000000..4500a45998 --- /dev/null +++ b/blocksuite/affine/block-embed/src/embed-youtube-block/embed-youtube-service.ts @@ -0,0 +1,42 @@ +import { + EmbedYoutubeBlockSchema, + type EmbedYoutubeModel, + EmbedYoutubeStyles, +} from '@blocksuite/affine-model'; +import { EmbedOptionProvider } from '@blocksuite/affine-shared/services'; +import { BlockService } from '@blocksuite/block-std'; + +import { LinkPreviewer } from '../common/link-previewer.js'; +import { youtubeUrlRegex } from './embed-youtube-model.js'; +import { queryEmbedYoutubeData } from './utils.js'; + +export class EmbedYoutubeBlockService extends BlockService { + static override readonly flavour = EmbedYoutubeBlockSchema.model.flavour; + + private static readonly linkPreviewer = new LinkPreviewer(); + + static setLinkPreviewEndpoint = + EmbedYoutubeBlockService.linkPreviewer.setEndpoint; + + queryUrlData = ( + embedYoutubeModel: EmbedYoutubeModel, + signal?: AbortSignal + ) => { + return queryEmbedYoutubeData( + embedYoutubeModel, + EmbedYoutubeBlockService.linkPreviewer, + signal + ); + }; + + override mounted() { + super.mounted(); + + this.std.get(EmbedOptionProvider).registerEmbedBlockOptions({ + flavour: this.flavour, + urlRegex: youtubeUrlRegex, + styles: EmbedYoutubeStyles, + viewType: 'embed', + }); + } +} diff --git a/blocksuite/affine/block-embed/src/embed-youtube-block/embed-youtube-spec.ts b/blocksuite/affine/block-embed/src/embed-youtube-block/embed-youtube-spec.ts new file mode 100644 index 0000000000..881699b664 --- /dev/null +++ b/blocksuite/affine/block-embed/src/embed-youtube-block/embed-youtube-spec.ts @@ -0,0 +1,18 @@ +import { + BlockViewExtension, + type ExtensionType, + FlavourExtension, +} from '@blocksuite/block-std'; +import { literal } from 'lit/static-html.js'; + +import { EmbedYoutubeBlockService } from './embed-youtube-service.js'; + +export const EmbedYoutubeBlockSpec: ExtensionType[] = [ + FlavourExtension('affine:embed-youtube'), + EmbedYoutubeBlockService, + BlockViewExtension('affine:embed-youtube', model => { + return model.parent?.flavour === 'affine:surface' + ? literal`affine-embed-edgeless-youtube-block` + : literal`affine-embed-youtube-block`; + }), +]; diff --git a/blocksuite/affine/block-embed/src/embed-youtube-block/index.ts b/blocksuite/affine/block-embed/src/embed-youtube-block/index.ts new file mode 100644 index 0000000000..5d44344003 --- /dev/null +++ b/blocksuite/affine/block-embed/src/embed-youtube-block/index.ts @@ -0,0 +1,6 @@ +export * from './adapters/index.js'; +export * from './embed-youtube-block.js'; +export * from './embed-youtube-model.js'; +export * from './embed-youtube-service.js'; +export * from './embed-youtube-spec.js'; +export { YoutubeIcon } from './styles.js'; diff --git a/blocksuite/affine/block-embed/src/embed-youtube-block/styles.ts b/blocksuite/affine/block-embed/src/embed-youtube-block/styles.ts new file mode 100644 index 0000000000..a4526a1f75 --- /dev/null +++ b/blocksuite/affine/block-embed/src/embed-youtube-block/styles.ts @@ -0,0 +1,253 @@ +import { + EMBED_CARD_HEIGHT, + EMBED_CARD_WIDTH, +} from '@blocksuite/affine-shared/consts'; +import { css, html } from 'lit'; + +export const styles = css` + .affine-embed-youtube-block { + box-sizing: border-box; + width: ${EMBED_CARD_WIDTH.video}px; + max-width: 100%; + + display: flex; + flex-direction: column; + gap: 20px; + padding: 12px; + + border-radius: 8px; + border: 1px solid var(--affine-background-tertiary-color); + + opacity: var(--add, 1); + background: var(--affine-background-primary-color); + user-select: none; + + aspect-ratio: ${EMBED_CARD_WIDTH.video} / ${EMBED_CARD_HEIGHT.video}; + } + + .affine-embed-youtube-video { + flex-grow: 1; + width: 100%; + opacity: var(--add, 1); + } + + .affine-embed-youtube-video img, + .affine-embed-youtube-video object, + .affine-embed-youtube-video svg { + width: 100%; + height: 100%; + object-fit: cover; + border-radius: 4px 4px var(--1, 0px) var(--1, 0px); + } + + .affine-embed-youtube-video-iframe-container { + position: relative; + height: 100%; + } + + .affine-embed-youtube-video-iframe-container > iframe { + width: 100%; + height: 100%; + border-radius: 4px 4px var(--1, 0px) var(--1, 0px); + } + + .affine-embed-youtube-video-iframe-overlay { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + } + + .affine-embed-youtube-video-iframe-overlay.hide { + display: none; + } + + .affine-embed-youtube-content { + display: block; + flex-direction: column; + width: 100%; + height: fit-content; + border-radius: var(--1, 0px); + opacity: var(--add, 1); + } + + .affine-embed-youtube-content-header { + display: flex; + flex-direction: row; + gap: 8px; + align-items: center; + + align-self: stretch; + padding: var(--1, 0px); + border-radius: var(--1, 0px); + opacity: var(--add, 1); + } + + .affine-embed-youtube-content-title-icon { + display: flex; + width: 20px; + height: 20px; + justify-content: center; + align-items: center; + } + + .affine-embed-youtube-content-title-icon img, + .affine-embed-youtube-content-title-icon object, + .affine-embed-youtube-content-title-icon svg { + width: 20px; + height: 20px; + fill: var(--affine-background-primary-color); + } + + .affine-embed-youtube-content-title-text { + flex: 1 0 0; + + display: -webkit-box; + -webkit-line-clamp: 1; + -webkit-box-orient: vertical; + + word-break: break-word; + overflow: hidden; + text-overflow: ellipsis; + color: var(--affine-text-primary-color); + + font-family: var(--affine-font-family); + font-size: var(--affine-font-sm); + font-style: normal; + font-weight: 600; + line-height: 22px; + } + + .affine-embed-youtube-content-creator-image { + display: flex; + width: 16px; + height: 16px; + flex-direction: column; + align-items: flex-start; + } + + .affine-embed-youtube-content-creator-image img, + .affine-embed-youtube-content-creator-image object, + .affine-embed-youtube-content-creator-image svg { + width: 16px; + height: 16px; + border-radius: 50%; + fill: var(--affine-background-primary-color); + } + + .affine-embed-youtube-content-creator-text { + display: -webkit-box; + -webkit-line-clamp: 1; + -webkit-box-orient: vertical; + + color: var(--affine-text-primary-color); + text-align: justify; + font-family: var(--affine-font-family); + font-size: var(--affine-font-xs); + font-style: normal; + font-weight: 400; + line-height: 20px; + } + + .affine-embed-youtube-content-description { + height: 40px; + + position: relative; + + word-break: break-word; + white-space: normal; + overflow: hidden; + text-overflow: ellipsis; + color: var(--affine-text-primary-color); + + font-family: var(--affine-font-family); + font-size: var(--affine-font-xs); + font-style: normal; + font-weight: 400; + line-height: 20px; + } + + .affine-embed-youtube-content-description::after { + content: '...'; + position: absolute; + right: 0; + bottom: 0; + background-color: var(--affine-background-primary-color); + } + + .affine-embed-youtube-content-url { + display: flex; + align-items: center; + justify-content: flex-start; + gap: 4px; + width: max-content; + max-width: 100%; + cursor: pointer; + } + .affine-embed-youtube-content-url > span { + display: -webkit-box; + -webkit-line-clamp: 1; + -webkit-box-orient: vertical; + + word-break: break-all; + white-space: normal; + overflow: hidden; + text-overflow: ellipsis; + color: var(--affine-text-secondary-color); + + font-family: var(--affine-font-family); + font-size: var(--affine-font-xs); + font-style: normal; + font-weight: 400; + line-height: 20px; + } + .affine-embed-youtube-content-url:hover > span { + color: var(--affine-link-color); + } + .affine-embed-youtube-content-url:hover .open-icon { + fill: var(--affine-link-color); + } + + .affine-embed-youtube-content-url-icon { + display: flex; + align-items: center; + justify-content: center; + width: 12px; + height: 12px; + } + .affine-embed-youtube-content-url-icon .open-icon { + height: 12px; + width: 12px; + fill: var(--affine-text-secondary-color); + } + + .affine-embed-youtube-block.loading { + .affine-embed-youtube-content-title-text { + color: var(--affine-placeholder-color); + } + } + + .affine-embed-youtube-block.selected { + .affine-embed-youtube-content-url > span { + color: var(--affine-link-color); + } + .affine-embed-youtube-content-url .open-icon { + fill: var(--affine-link-color); + } + } +`; + +export const YoutubeIcon = html` + + +`; diff --git a/blocksuite/affine/block-embed/src/embed-youtube-block/utils.ts b/blocksuite/affine/block-embed/src/embed-youtube-block/utils.ts new file mode 100644 index 0000000000..5432dbb65b --- /dev/null +++ b/blocksuite/affine/block-embed/src/embed-youtube-block/utils.ts @@ -0,0 +1,109 @@ +import type { + EmbedYoutubeBlockUrlData, + EmbedYoutubeModel, +} from '@blocksuite/affine-model'; +import { isAbortError } from '@blocksuite/affine-shared/utils'; +import { assertExists } from '@blocksuite/global/utils'; + +import type { LinkPreviewer } from '../common/link-previewer.js'; +import type { EmbedYoutubeBlockComponent } from './embed-youtube-block.js'; + +export async function queryEmbedYoutubeData( + embedYoutubeModel: EmbedYoutubeModel, + linkPreviewer: LinkPreviewer, + signal?: AbortSignal +): Promise> { + const url = embedYoutubeModel.url; + + const [videoOpenGraphData, videoOEmbedData] = await Promise.all([ + linkPreviewer.query(url, signal), + queryYoutubeOEmbedData(url, signal), + ]); + + const youtubeEmbedData: Partial = { + ...videoOpenGraphData, + ...videoOEmbedData, + }; + + if (youtubeEmbedData.creatorUrl) { + const creatorOpenGraphData = await linkPreviewer.query( + youtubeEmbedData.creatorUrl, + signal + ); + youtubeEmbedData.creatorImage = creatorOpenGraphData.image; + } + + return youtubeEmbedData; +} + +export async function queryYoutubeOEmbedData( + url: string, + signal?: AbortSignal +): Promise> { + let youtubeOEmbedData: Partial = {}; + + const oEmbedUrl = `https://youtube.com/oembed?url=${url}&format=json`; + + const oEmbedResponse = await fetch(oEmbedUrl, { signal }).catch(() => null); + if (oEmbedResponse && oEmbedResponse.ok) { + const oEmbedJson = await oEmbedResponse.json(); + const { title, author_name, author_url } = oEmbedJson; + + youtubeOEmbedData = { + title, + creator: author_name, + creatorUrl: author_url, + }; + } + + return youtubeOEmbedData; +} + +export async function refreshEmbedYoutubeUrlData( + embedYoutubeElement: EmbedYoutubeBlockComponent, + signal?: AbortSignal +): Promise { + let image = null, + title = null, + description = null, + creator = null, + creatorUrl = null, + creatorImage = null; + + try { + embedYoutubeElement.loading = true; + + const queryUrlData = embedYoutubeElement.service?.queryUrlData; + assertExists(queryUrlData); + + const youtubeUrlData = await queryUrlData( + embedYoutubeElement.model, + signal + ); + + ({ + image = null, + title = null, + description = null, + creator = null, + creatorUrl = null, + creatorImage = null, + } = youtubeUrlData); + + if (signal?.aborted) return; + + embedYoutubeElement.doc.updateBlock(embedYoutubeElement.model, { + image, + title, + description, + creator, + creatorUrl, + creatorImage, + }); + } catch (error) { + if (signal?.aborted || isAbortError(error)) return; + throw error; + } finally { + embedYoutubeElement.loading = false; + } +} diff --git a/blocksuite/affine/block-embed/src/index.ts b/blocksuite/affine/block-embed/src/index.ts new file mode 100644 index 0000000000..39d3b9ab05 --- /dev/null +++ b/blocksuite/affine/block-embed/src/index.ts @@ -0,0 +1,41 @@ +import type { ExtensionType } from '@blocksuite/block-std'; + +import { EmbedDragHandleOption } from './common/embed-block-element.js'; +import { EmbedFigmaBlockSpec } from './embed-figma-block/index.js'; +import { EmbedGithubBlockSpec } from './embed-github-block/index.js'; +import { EmbedHtmlBlockSpec } from './embed-html-block/index.js'; +import { EmbedLinkedDocBlockSpec } from './embed-linked-doc-block/index.js'; +import { EmbedLoomBlockSpec } from './embed-loom-block/index.js'; +import { EmbedSyncedDocBlockSpec } from './embed-synced-doc-block/index.js'; +import { EmbedYoutubeBlockSpec } from './embed-youtube-block/index.js'; + +export const EmbedExtensions: ExtensionType[] = [ + EmbedDragHandleOption, + EmbedFigmaBlockSpec, + EmbedGithubBlockSpec, + EmbedHtmlBlockSpec, + EmbedLoomBlockSpec, + EmbedYoutubeBlockSpec, + EmbedLinkedDocBlockSpec, + EmbedSyncedDocBlockSpec, +].flat(); + +export { createEmbedBlockHtmlAdapterMatcher } from './common/adapters/html.js'; +export { createEmbedBlockMarkdownAdapterMatcher } from './common/adapters/markdown.js'; +export { createEmbedBlockPlainTextAdapterMatcher } from './common/adapters/plain-text.js'; +export { generateDocUrl } from './common/adapters/utils.js'; +export { EmbedBlockComponent } from './common/embed-block-element.js'; +export { insertEmbedCard } from './common/insert-embed-card.js'; +export { + LinkPreviewer, + type LinkPreviewResponseData, +} from './common/link-previewer.js'; +export { getDocContentWithMaxLength } from './common/render-linked-doc.js'; +export { toEdgelessEmbedBlock } from './common/to-edgeless-embed-block.js'; +export * from './embed-figma-block/index.js'; +export * from './embed-github-block/index.js'; +export * from './embed-html-block/index.js'; +export * from './embed-linked-doc-block/index.js'; +export * from './embed-loom-block/index.js'; +export * from './embed-synced-doc-block/index.js'; +export * from './embed-youtube-block/index.js'; diff --git a/blocksuite/affine/block-embed/tsconfig.json b/blocksuite/affine/block-embed/tsconfig.json new file mode 100644 index 0000000000..c1a5453aa5 --- /dev/null +++ b/blocksuite/affine/block-embed/tsconfig.json @@ -0,0 +1,32 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "rootDir": "./src/", + "outDir": "./dist/", + "noEmit": false + }, + "include": ["./src"], + "references": [ + { + "path": "../../framework/global" + }, + { + "path": "../../framework/store" + }, + { + "path": "../../framework/block-std" + }, + { + "path": "../../framework/inline" + }, + { + "path": "../model" + }, + { + "path": "../components" + }, + { + "path": "../shared" + } + ] +} diff --git a/blocksuite/affine/block-embed/typedoc.json b/blocksuite/affine/block-embed/typedoc.json new file mode 100644 index 0000000000..101e923dba --- /dev/null +++ b/blocksuite/affine/block-embed/typedoc.json @@ -0,0 +1,4 @@ +{ + "extends": ["../../../typedoc.base.json"], + "entryPoints": ["src/index.ts"] +} diff --git a/blocksuite/affine/block-embed/vitest.config.ts b/blocksuite/affine/block-embed/vitest.config.ts new file mode 100644 index 0000000000..b86624acc9 --- /dev/null +++ b/blocksuite/affine/block-embed/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/affine-block-list', + }, + /** + * 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/blocksuite/affine/block-list/package.json b/blocksuite/affine/block-list/package.json new file mode 100644 index 0000000000..a74235e60e --- /dev/null +++ b/blocksuite/affine/block-list/package.json @@ -0,0 +1,42 @@ +{ + "name": "@blocksuite/affine-block-list", + "description": "List block for BlockSuite.", + "type": "module", + "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": "MIT", + "dependencies": { + "@blocksuite/affine-components": "workspace:*", + "@blocksuite/affine-model": "workspace:*", + "@blocksuite/affine-shared": "workspace:*", + "@blocksuite/block-std": "workspace:*", + "@blocksuite/global": "workspace:*", + "@blocksuite/inline": "workspace:*", + "@blocksuite/store": "workspace:*", + "@floating-ui/dom": "^1.6.10", + "@lit/context": "^1.1.2", + "@preact/signals-core": "^1.8.0", + "@toeverything/theme": "^1.1.1", + "@types/mdast": "^4.0.4", + "lit": "^3.2.0", + "minimatch": "^10.0.1", + "zod": "^3.23.8" + }, + "exports": { + ".": "./src/index.ts", + "./effects": "./src/effects.ts" + }, + "files": [ + "src", + "dist", + "!src/__tests__", + "!dist/__tests__" + ] +} diff --git a/blocksuite/affine/block-list/src/adapters/html.ts b/blocksuite/affine/block-list/src/adapters/html.ts new file mode 100644 index 0000000000..8b98f3e362 --- /dev/null +++ b/blocksuite/affine/block-list/src/adapters/html.ts @@ -0,0 +1,203 @@ +import { ListBlockSchema } from '@blocksuite/affine-model'; +import { + BlockHtmlAdapterExtension, + type BlockHtmlAdapterMatcher, + HastUtils, + TextUtils, +} from '@blocksuite/affine-shared/adapters'; +import type { DeltaInsert } from '@blocksuite/inline'; +import { nanoid } from '@blocksuite/store'; +import type { Element } from 'hast'; + +export const listBlockHtmlAdapterMatcher: BlockHtmlAdapterMatcher = { + flavour: ListBlockSchema.model.flavour, + toMatch: o => HastUtils.isElement(o.node) && o.node.tagName === 'li', + fromMatch: o => o.node.flavour === ListBlockSchema.model.flavour, + toBlockSnapshot: { + enter: (o, context) => { + if (!HastUtils.isElement(o.node)) { + return; + } + + const parentList = o.parent?.node as Element; + let listType = 'bulleted'; + if (parentList.tagName === 'ol') { + listType = 'numbered'; + } else if (Array.isArray(parentList.properties?.className)) { + if (parentList.properties.className.includes('to-do-list')) { + listType = 'todo'; + } else if (parentList.properties.className.includes('toggle')) { + listType = 'toggle'; + } else if (parentList.properties.className.includes('bulleted-list')) { + listType = 'bulleted'; + } + } + + const listNumber = + typeof parentList.properties.start === 'number' + ? parentList.properties.start + parentList.children.indexOf(o.node) + : null; + const firstElementChild = HastUtils.getElementChildren(o.node)[0]; + o.node = HastUtils.flatNodes( + o.node, + tagName => tagName === 'div' || tagName === 'p' + ) as Element; + + const { walkerContext, deltaConverter } = context; + walkerContext.openNode( + { + type: 'block', + id: nanoid(), + flavour: 'affine:list', + props: { + type: listType, + text: { + '$blocksuite:internal:text$': true, + delta: + listType !== 'toggle' + ? deltaConverter.astToDelta( + HastUtils.getInlineOnlyElementAST(o.node) + ) + : deltaConverter.astToDelta( + HastUtils.querySelector(o.node, 'summary') ?? o.node + ), + }, + checked: + listType === 'todo' + ? firstElementChild && + Array.isArray(firstElementChild.properties?.className) && + firstElementChild.properties.className.includes('checkbox-on') + : false, + collapsed: + listType === 'toggle' + ? firstElementChild && + firstElementChild.tagName === 'details' && + firstElementChild.properties.open === undefined + : false, + order: listNumber, + }, + children: [], + }, + 'children' + ); + }, + leave: (_, context) => { + const { walkerContext } = context; + walkerContext.closeNode(); + }, + }, + fromBlockSnapshot: { + enter: (o, context) => { + const text = (o.node.props.text ?? { delta: [] }) as { + delta: DeltaInsert[]; + }; + const { deltaConverter, walkerContext } = context; + const currentTNode = walkerContext.currentNode(); + const liChildren = deltaConverter.deltaToAST(text.delta); + if (o.node.props.type === 'todo') { + liChildren.unshift({ + type: 'element', + tagName: 'input', + properties: { + type: 'checkbox', + checked: o.node.props.checked as boolean, + }, + children: [ + { + type: 'element', + tagName: 'label', + properties: { + style: 'margin-right: 3px;', + }, + children: [], + }, + ], + }); + } + // check if the list is of the same type + if ( + walkerContext.getNodeContext('affine:list:parent') === o.parent && + currentTNode.type === 'element' && + currentTNode.tagName === + (o.node.props.type === 'numbered' ? 'ol' : 'ul') && + !( + Array.isArray(currentTNode.properties.className) && + currentTNode.properties.className.includes('todo-list') + ) === + TextUtils.isNullish( + o.node.props.type === 'todo' + ? (o.node.props.checked as boolean) + : undefined + ) + ) { + // if true, add the list item to the list + } else { + // if false, create a new list + walkerContext.openNode( + { + type: 'element', + tagName: o.node.props.type === 'numbered' ? 'ol' : 'ul', + properties: { + style: + o.node.props.type === 'todo' + ? 'list-style-type: none; padding-inline-start: 18px;' + : null, + className: [o.node.props.type + '-list'], + }, + children: [], + }, + 'children' + ); + walkerContext.setNodeContext('affine:list:parent', o.parent); + } + + walkerContext.openNode( + { + type: 'element', + tagName: 'li', + properties: { + className: ['affine-list-block-container'], + }, + children: liChildren, + }, + 'children' + ); + }, + leave: (o, context) => { + const { walkerContext } = context; + const currentTNode = walkerContext.currentNode() as Element; + const previousTNode = walkerContext.previousNode() as Element; + if ( + walkerContext.getPreviousNodeContext('affine:list:parent') === + o.parent && + currentTNode.tagName === 'li' && + previousTNode.tagName === + (o.node.props.type === 'numbered' ? 'ol' : 'ul') && + !( + Array.isArray(previousTNode.properties.className) && + previousTNode.properties.className.includes('todo-list') + ) === + TextUtils.isNullish( + o.node.props.type === 'todo' + ? (o.node.props.checked as boolean) + : undefined + ) + ) { + walkerContext.closeNode(); + if ( + o.next?.flavour !== 'affine:list' || + o.next.props.type !== o.node.props.type + ) { + // If the next node is not a list or different type of list, close the list + walkerContext.closeNode(); + } + } else { + walkerContext.closeNode().closeNode(); + } + }, + }, +}; + +export const ListBlockHtmlAdapterExtension = BlockHtmlAdapterExtension( + listBlockHtmlAdapterMatcher +); diff --git a/blocksuite/affine/block-list/src/adapters/index.ts b/blocksuite/affine/block-list/src/adapters/index.ts new file mode 100644 index 0000000000..b4dd5a6d2a --- /dev/null +++ b/blocksuite/affine/block-list/src/adapters/index.ts @@ -0,0 +1,4 @@ +export * from './html.js'; +export * from './markdown.js'; +export * from './notion-html.js'; +export * from './plain-text.js'; diff --git a/blocksuite/affine/block-list/src/adapters/markdown.ts b/blocksuite/affine/block-list/src/adapters/markdown.ts new file mode 100644 index 0000000000..bd9bc4032c --- /dev/null +++ b/blocksuite/affine/block-list/src/adapters/markdown.ts @@ -0,0 +1,156 @@ +import { ListBlockSchema } from '@blocksuite/affine-model'; +import { + BlockMarkdownAdapterExtension, + type BlockMarkdownAdapterMatcher, + type MarkdownAST, + TextUtils, +} from '@blocksuite/affine-shared/adapters'; +import type { DeltaInsert } from '@blocksuite/inline'; +import { nanoid } from '@blocksuite/store'; +import type { List } from 'mdast'; + +const LIST_MDAST_TYPE = new Set(['list', 'listItem']); +const isListMDASTType = (node: MarkdownAST) => LIST_MDAST_TYPE.has(node.type); + +export const listBlockMarkdownAdapterMatcher: BlockMarkdownAdapterMatcher = { + flavour: ListBlockSchema.model.flavour, + toMatch: o => isListMDASTType(o.node), + fromMatch: o => o.node.flavour === ListBlockSchema.model.flavour, + toBlockSnapshot: { + enter: (o, context) => { + const { walkerContext, deltaConverter } = context; + if (o.node.type === 'listItem') { + const parentList = o.parent?.node as List; + const listNumber = parentList.start + ? parentList.start + parentList.children.indexOf(o.node) + : null; + walkerContext.openNode( + { + type: 'block', + id: nanoid(), + flavour: 'affine:list', + props: { + type: + o.node.checked !== null + ? 'todo' + : parentList.ordered + ? 'numbered' + : 'bulleted', + text: { + '$blocksuite:internal:text$': true, + delta: + o.node.children[0] && o.node.children[0].type === 'paragraph' + ? deltaConverter.astToDelta(o.node.children[0]) + : [], + }, + checked: o.node.checked ?? false, + collapsed: false, + order: listNumber, + }, + children: [], + }, + 'children' + ); + if (o.node.children[0] && o.node.children[0].type === 'paragraph') { + walkerContext.skipChildren(1); + } + } + }, + leave: (o, context) => { + const { walkerContext } = context; + if (o.node.type === 'listItem') { + walkerContext.closeNode(); + } + }, + }, + fromBlockSnapshot: { + enter: (o, context) => { + const { walkerContext, deltaConverter } = context; + const text = (o.node.props.text ?? { delta: [] }) as { + delta: DeltaInsert[]; + }; + const currentTNode = walkerContext.currentNode(); + // check if the list is of the same type + if ( + walkerContext.getNodeContext('affine:list:parent') === o.parent && + currentTNode.type === 'list' && + currentTNode.ordered === (o.node.props.type === 'numbered') && + TextUtils.isNullish(currentTNode.children[0].checked) === + TextUtils.isNullish( + o.node.props.type === 'todo' + ? (o.node.props.checked as boolean) + : undefined + ) + ) { + // if true, add the list item to the list + } else { + // if false, create a new list + walkerContext + .openNode( + { + type: 'list', + ordered: o.node.props.type === 'numbered', + spread: false, + children: [], + }, + 'children' + ) + .setNodeContext('affine:list:parent', o.parent); + } + walkerContext + .openNode( + { + type: 'listItem', + checked: + o.node.props.type === 'todo' + ? (o.node.props.checked as boolean) + : undefined, + spread: false, + children: [], + }, + 'children' + ) + .openNode( + { + type: 'paragraph', + children: deltaConverter.deltaToAST(text.delta), + }, + 'children' + ) + .closeNode(); + }, + leave: (o, context) => { + const { walkerContext } = context; + const currentTNode = walkerContext.currentNode(); + const previousTNode = walkerContext.previousNode(); + if ( + walkerContext.getPreviousNodeContext('affine:list:parent') === + o.parent && + currentTNode.type === 'listItem' && + previousTNode?.type === 'list' && + previousTNode.ordered === (o.node.props.type === 'numbered') && + TextUtils.isNullish(currentTNode.checked) === + TextUtils.isNullish( + o.node.props.type === 'todo' + ? (o.node.props.checked as boolean) + : undefined + ) + ) { + walkerContext.closeNode(); + if ( + o.next?.flavour !== 'affine:list' || + o.next.props.type !== o.node.props.type + ) { + // If the next node is not a list or different type of list, close the list + walkerContext.closeNode(); + } + } else { + walkerContext.closeNode().closeNode(); + } + }, + }, +}; + +export const ListBlockMarkdownAdapterExtension = BlockMarkdownAdapterExtension( + listBlockMarkdownAdapterMatcher +); diff --git a/blocksuite/affine/block-list/src/adapters/notion-html.ts b/blocksuite/affine/block-list/src/adapters/notion-html.ts new file mode 100644 index 0000000000..389ea32dea --- /dev/null +++ b/blocksuite/affine/block-list/src/adapters/notion-html.ts @@ -0,0 +1,116 @@ +import { ListBlockSchema } from '@blocksuite/affine-model'; +import { + BlockNotionHtmlAdapterExtension, + type BlockNotionHtmlAdapterMatcher, + HastUtils, +} from '@blocksuite/affine-shared/adapters'; +import type { DeltaInsert } from '@blocksuite/inline'; +import { nanoid } from '@blocksuite/store'; + +const listBlockMatchTags = new Set(['ul', 'ol', 'li']); + +export const listBlockNotionHtmlAdapterMatcher: BlockNotionHtmlAdapterMatcher = + { + flavour: ListBlockSchema.model.flavour, + toMatch: o => + HastUtils.isElement(o.node) && listBlockMatchTags.has(o.node.tagName), + fromMatch: () => false, + toBlockSnapshot: { + enter: (o, context) => { + if (!HastUtils.isElement(o.node)) { + return; + } + + const { walkerContext, pageMap, deltaConverter } = context; + switch (o.node.tagName) { + case 'ul': + case 'ol': { + walkerContext.setNodeContext('hast:list:type', 'bulleted'); + if (o.node.tagName === 'ol') { + walkerContext.setNodeContext('hast:list:type', 'numbered'); + } else if (Array.isArray(o.node.properties?.className)) { + if (o.node.properties.className.includes('to-do-list')) { + walkerContext.setNodeContext('hast:list:type', 'todo'); + } else if (o.node.properties.className.includes('toggle')) { + walkerContext.setNodeContext('hast:list:type', 'toggle'); + } else if ( + o.node.properties.className.includes('bulleted-list') + ) { + walkerContext.setNodeContext('hast:list:type', 'bulleted'); + } + } + break; + } + case 'li': { + const firstElementChild = HastUtils.getElementChildren(o.node)[0]; + const notionListType = + walkerContext.getNodeContext('hast:list:type'); + const listType = + notionListType === 'toggle' ? 'bulleted' : notionListType; + let delta: DeltaInsert[] = []; + if (notionListType === 'toggle') { + delta = deltaConverter.astToDelta( + HastUtils.querySelector(o.node, 'summary') ?? o.node, + { pageMap } + ); + } else if (notionListType === 'todo') { + delta = deltaConverter.astToDelta(o.node, { pageMap }); + } else { + delta = deltaConverter.astToDelta( + HastUtils.getInlineOnlyElementAST(o.node), + { + pageMap, + } + ); + } + walkerContext.openNode( + { + type: 'block', + id: nanoid(), + flavour: 'affine:list', + props: { + type: listType, + text: { + '$blocksuite:internal:text$': true, + delta, + }, + checked: + notionListType === 'todo' + ? firstElementChild && + Array.isArray( + firstElementChild.properties?.className + ) && + firstElementChild.properties.className.includes( + 'checkbox-on' + ) + : false, + collapsed: + notionListType === 'toggle' + ? firstElementChild && + firstElementChild.tagName === 'details' && + firstElementChild.properties.open === undefined + : false, + }, + children: [], + }, + 'children' + ); + break; + } + } + }, + leave: (o, context) => { + const { walkerContext } = context; + if (!HastUtils.isElement(o.node)) { + return; + } + if (o.node.tagName === 'li') { + walkerContext.closeNode(); + } + }, + }, + fromBlockSnapshot: {}, + }; + +export const ListBlockNotionHtmlAdapterExtension = + BlockNotionHtmlAdapterExtension(listBlockNotionHtmlAdapterMatcher); diff --git a/blocksuite/affine/block-list/src/adapters/plain-text.ts b/blocksuite/affine/block-list/src/adapters/plain-text.ts new file mode 100644 index 0000000000..a0352e64d4 --- /dev/null +++ b/blocksuite/affine/block-list/src/adapters/plain-text.ts @@ -0,0 +1,27 @@ +import { ListBlockSchema } from '@blocksuite/affine-model'; +import { + BlockPlainTextAdapterExtension, + type BlockPlainTextAdapterMatcher, +} from '@blocksuite/affine-shared/adapters'; +import type { DeltaInsert } from '@blocksuite/inline'; + +export const listBlockPlainTextAdapterMatcher: BlockPlainTextAdapterMatcher = { + flavour: ListBlockSchema.model.flavour, + toMatch: () => false, + fromMatch: o => o.node.flavour === ListBlockSchema.model.flavour, + toBlockSnapshot: {}, + fromBlockSnapshot: { + enter: (o, context) => { + const text = (o.node.props.text ?? { delta: [] }) as { + delta: DeltaInsert[]; + }; + const { deltaConverter } = context; + const buffer = deltaConverter.deltaToAST(text.delta).join(''); + context.textBuffer.content += buffer; + context.textBuffer.content += '\n'; + }, + }, +}; + +export const ListBlockPlainTextAdapterExtension = + BlockPlainTextAdapterExtension(listBlockPlainTextAdapterMatcher); diff --git a/blocksuite/affine/block-list/src/commands/convert-to-numbered-list.ts b/blocksuite/affine/block-list/src/commands/convert-to-numbered-list.ts new file mode 100644 index 0000000000..b98be520f3 --- /dev/null +++ b/blocksuite/affine/block-list/src/commands/convert-to-numbered-list.ts @@ -0,0 +1,27 @@ +import { toNumberedList } from '@blocksuite/affine-shared/utils'; +import type { Command, EditorHost } from '@blocksuite/block-std'; + +export const convertToNumberedListCommand: Command< + never, + 'listConvertedId', + { + id: string; + order: number; // This parameter may not correspond to the final order. + stopCapturing?: boolean; + } +> = (ctx, next) => { + const { std, id, order, stopCapturing = true } = ctx; + const host = std.host as EditorHost; + const doc = host.doc; + + const model = doc.getBlock(id)?.model; + if (!model || !model.text) return; + + if (stopCapturing) host.doc.captureSync(); + + const listConvertedId = toNumberedList(std, model, order); + + if (!listConvertedId) return; + + return next({ listConvertedId }); +}; diff --git a/blocksuite/affine/block-list/src/commands/dedent-list.ts b/blocksuite/affine/block-list/src/commands/dedent-list.ts new file mode 100644 index 0000000000..6d6c15c509 --- /dev/null +++ b/blocksuite/affine/block-list/src/commands/dedent-list.ts @@ -0,0 +1,161 @@ +import type { IndentContext } from '@blocksuite/affine-shared/types'; +import { matchFlavours } from '@blocksuite/affine-shared/utils'; +import type { Command } from '@blocksuite/block-std'; + +import { correctNumberedListsOrderToPrev } from './utils.js'; + +export const canDedentListCommand: Command< + never, + 'indentContext', + Partial> +> = (ctx, next) => { + let { blockId, inlineIndex } = ctx; + const { std } = ctx; + const { selection, doc } = std; + if (!blockId) { + const text = selection.find('text'); + /** + * Do nothing if the selection: + * - is not a text selection + * - or spans multiple blocks + */ + if (!text || (text.to && text.from.blockId !== text.to.blockId)) { + return; + } + + blockId = text.from.blockId; + inlineIndex = text.from.index; + } + if (blockId == null || inlineIndex == null) { + return; + } + + /** + * initial state: + * - aaa + * - bbb + * - ccc <- unindent + * - ddd + * - eee + * - fff + * + * final state: + * - aaa + * - bbb + * - ccc + * - ddd + * - eee + * - fff + */ + + /** + * ccc + */ + const model = doc.getBlock(blockId)?.model; + if (!model || !matchFlavours(model, ['affine:list'])) { + return; + } + /** + * bbb + */ + const parent = doc.getParent(model); + if (!parent) { + return; + } + if (doc.readonly || parent.role !== 'content') { + // Top most list cannot be unindent + return; + } + /** + * aaa + */ + const grandParent = doc.getParent(parent); + if (!grandParent) { + return; + } + /** + * ccc index + */ + const modelIndex = parent.children.indexOf(model); + if (modelIndex === -1) { + return; + } + + return next({ + indentContext: { + blockId, + inlineIndex, + type: 'dedent', + flavour: 'affine:list', + }, + }); +}; + +export const dedentListCommand: Command<'indentContext'> = (ctx, next) => { + const { indentContext: dedentContext, std } = ctx; + const { doc, selection, range, host } = std; + + if ( + !dedentContext || + dedentContext.type !== 'dedent' || + dedentContext.flavour !== 'affine:list' + ) { + console.warn( + 'you need to use `canDedentList` command before running `dedentList` command' + ); + return; + } + + const { blockId } = dedentContext; + + const model = doc.getBlock(blockId)?.model; + if (!model) return; + + const parent = doc.getParent(model); + if (!parent) return; + + const grandParent = doc.getParent(parent); + if (!grandParent) return; + + doc.captureSync(); + + /** + * step 1: + * - aaa + * - bbb + * - ccc + * - ddd + * - eee <- make eee as ccc's child + * - fff + */ + const nextSiblings = doc.getNexts(model); // [eee] + doc.moveBlocks(nextSiblings, model); + /** + * eee + */ + const nextSibling = nextSiblings.at(0); + if (nextSibling) correctNumberedListsOrderToPrev(doc, nextSibling); + + /** + * step 2: + * - aaa + * - bbb + * - ccc <- make ccc as aaa's child + * - ddd + * - eee + * - fff + */ + doc.moveBlocks([model], grandParent, parent, false); + correctNumberedListsOrderToPrev(doc, model); + + const textSelection = selection.find('text'); + if (textSelection) { + host.updateComplete + .then(() => { + range.syncTextSelectionToRange(textSelection); + }) + .catch(console.error); + } + + return next(); +}; diff --git a/blocksuite/affine/block-list/src/commands/indent-list.ts b/blocksuite/affine/block-list/src/commands/indent-list.ts new file mode 100644 index 0000000000..ec9d721a8c --- /dev/null +++ b/blocksuite/affine/block-list/src/commands/indent-list.ts @@ -0,0 +1,146 @@ +import type { IndentContext } from '@blocksuite/affine-shared/types'; +import { + getNearestHeadingBefore, + matchFlavours, +} from '@blocksuite/affine-shared/utils'; +import type { Command } from '@blocksuite/block-std'; + +import { correctNumberedListsOrderToPrev } from './utils.js'; + +export const canIndentListCommand: Command< + never, + 'indentContext', + Partial> +> = (ctx, next) => { + let { blockId, inlineIndex } = ctx; + const { std } = ctx; + const { selection, doc } = std; + if (!blockId) { + const text = selection.find('text'); + /** + * Do nothing if the selection: + * - is not a text selection + * - or spans multiple blocks + */ + if (!text || (text.to && text.from.blockId !== text.to.blockId)) { + return; + } + + blockId = text.from.blockId; + inlineIndex = text.from.index; + } + if (blockId == null || inlineIndex == null) { + return; + } + + /** + * initial state: + * - aaa + * - bbb + * - ccc <- indent + * - ddd + * - eee + * + * final state: + * - aaa + * - bbb + * - ccc + * - ddd + * - eee + */ + + /** + * ccc + */ + const model = doc.getBlock(blockId)?.model; + if (!model || !matchFlavours(model, ['affine:list'])) { + return; + } + const schema = std.doc.schema; + /** + * aaa + */ + const previousSibling = doc.getPrev(model); + if ( + doc.readonly || + !previousSibling || + !schema.isValid(model.flavour, previousSibling.flavour) + ) { + // cannot indent, do nothing + return; + } + /** + * eee + */ + // const nextSibling = doc.getNext(model); + + return next({ + indentContext: { + blockId, + inlineIndex, + type: 'indent', + flavour: 'affine:list', + }, + }); +}; + +export const indentListCommand: Command<'indentContext', never> = ( + ctx, + next +) => { + const { indentContext, std } = ctx; + if ( + !indentContext || + indentContext.type !== 'indent' || + indentContext.flavour !== 'affine:list' + ) { + console.warn( + 'you need to use `canIndentList` command before running `indentList` command' + ); + return; + } + + const { blockId } = indentContext; + const { doc, selection, host, range } = std; + + const model = doc.getBlock(blockId)?.model; + if (!model) return; + + const previousSibling = doc.getPrev(model); + if (!previousSibling) return; + + const nextSibling = doc.getNext(model); + + doc.captureSync(); + + doc.moveBlocks([model], previousSibling); + correctNumberedListsOrderToPrev(doc, model); + if (nextSibling) correctNumberedListsOrderToPrev(doc, nextSibling); + + // 123 + // > # 456 + // 789 + // + // we need to update 456 collapsed state to false when indent 789 + const nearestHeading = getNearestHeadingBefore(model); + if ( + nearestHeading && + matchFlavours(nearestHeading, ['affine:paragraph']) && + nearestHeading.collapsed + ) { + doc.updateBlock(nearestHeading, { + collapsed: false, + }); + } + + const textSelection = selection.find('text'); + if (textSelection) { + host.updateComplete + .then(() => { + range.syncTextSelectionToRange(textSelection); + }) + .catch(console.error); + } + + return next(); +}; diff --git a/blocksuite/affine/block-list/src/commands/index.ts b/blocksuite/affine/block-list/src/commands/index.ts new file mode 100644 index 0000000000..ce896f9be5 --- /dev/null +++ b/blocksuite/affine/block-list/src/commands/index.ts @@ -0,0 +1,17 @@ +import type { BlockCommands } from '@blocksuite/block-std'; + +import { convertToNumberedListCommand } from './convert-to-numbered-list.js'; +import { canDedentListCommand, dedentListCommand } from './dedent-list.js'; +import { canIndentListCommand, indentListCommand } from './indent-list.js'; +import { listToParagraphCommand } from './list-to-paragraph.js'; +import { splitListCommand } from './split-list.js'; + +export const commands: BlockCommands = { + convertToNumberedList: convertToNumberedListCommand, + listToParagraph: listToParagraphCommand, + splitList: splitListCommand, + canIndentList: canIndentListCommand, + indentList: indentListCommand, + canDedentList: canDedentListCommand, + dedentList: dedentListCommand, +}; diff --git a/blocksuite/affine/block-list/src/commands/list-to-paragraph.ts b/blocksuite/affine/block-list/src/commands/list-to-paragraph.ts new file mode 100644 index 0000000000..bbfb041f22 --- /dev/null +++ b/blocksuite/affine/block-list/src/commands/list-to-paragraph.ts @@ -0,0 +1,42 @@ +import { focusTextModel } from '@blocksuite/affine-components/rich-text'; +import { matchFlavours } from '@blocksuite/affine-shared/utils'; +import type { Command } from '@blocksuite/block-std'; + +export const listToParagraphCommand: Command< + never, + 'listConvertedId', + { + id: string; + stopCapturing?: boolean; + } +> = (ctx, next) => { + const { id, stopCapturing = true } = ctx; + const std = ctx.std; + const doc = std.doc; + const model = doc.getBlock(id)?.model; + + if (!model || !matchFlavours(model, ['affine:list'])) return false; + + const parent = doc.getParent(model); + if (!parent) return false; + + const index = parent.children.indexOf(model); + const blockProps = { + type: 'text' as const, + text: model.text?.clone(), + children: model.children, + }; + if (stopCapturing) std.doc.captureSync(); + doc.deleteBlock(model, { + deleteChildren: false, + }); + + const listConvertedId = doc.addBlock( + 'affine:paragraph', + blockProps, + parent, + index + ); + focusTextModel(std, listConvertedId); + return next({ listConvertedId }); +}; diff --git a/blocksuite/affine/block-list/src/commands/split-list.ts b/blocksuite/affine/block-list/src/commands/split-list.ts new file mode 100644 index 0000000000..2a70b71c85 --- /dev/null +++ b/blocksuite/affine/block-list/src/commands/split-list.ts @@ -0,0 +1,206 @@ +import { focusTextModel } from '@blocksuite/affine-components/rich-text'; +import { + getNextContinuousNumberedLists, + matchFlavours, +} from '@blocksuite/affine-shared/utils'; +import type { Command, EditorHost } from '@blocksuite/block-std'; + +import { correctNumberedListsOrderToPrev } from './utils.js'; + +export const splitListCommand: Command< + never, + never, + { + blockId: string; + inlineIndex: number; + } +> = (ctx, next) => { + const { blockId, inlineIndex, std } = ctx; + const host = std.host as EditorHost; + const doc = host.doc; + + const model = doc.getBlock(blockId)?.model; + if (!model || !matchFlavours(model, ['affine:list'])) { + console.error(`block ${blockId} is not a list block`); + return; + } + const parent = doc.getParent(model); + if (!parent) { + console.error(`block ${blockId} has no parent`); + return; + } + const modelIndex = parent.children.indexOf(model); + if (modelIndex === -1) { + console.error(`block ${blockId} is not a child of its parent`); + return; + } + + doc.captureSync(); + + if (model.text.length === 0) { + /** + * case 1: target is top most, convert the list into a paragraph + * + * before: + * - aaa + * - | <- split here + * - bbb + * + * after: + * - aaa + * | + * - bbb + */ + if (parent.role === 'hub') { + const id = doc.addBlock('affine:paragraph', {}, parent, modelIndex); + const paragraph = doc.getBlock(id); + if (!paragraph) return; + doc.deleteBlock(model, { + bringChildrenTo: paragraph.model, + }); + + // reset next continuous numbered list's order + const nextContinuousNumberedLists = getNextContinuousNumberedLists( + doc, + paragraph.model + ); + let base = 1; + nextContinuousNumberedLists.forEach(list => { + doc.transact(() => { + list.order = base; + }); + base += 1; + }); + + host.updateComplete + .then(() => { + focusTextModel(std, id); + }) + .catch(console.error); + + next(); + return; + } + + /** + * case 2: not top most, unindent the list + * + * before: + * - aaa + * - bbb + * - | <- split here + * - ccc + * + * after: + * - aaa + * - bbb + * - | + * - ccc + */ + if (parent.role === 'content') { + host.command + .chain() + .canDedentList({ + blockId, + inlineIndex: 0, + }) + .dedentList() + .run(); + + next(); + return; + } + + return; + } + + let newListId: string | null = null; + + if (model.children.length > 0 && !model.collapsed) { + /** + * case 3: list has children (list not collapsed) + * + * before: + * - aa|a <- split here + * - bbb + * + * after: + * - aa + * - |a + * - bbb + */ + const afterText = model.text.split(inlineIndex); + newListId = doc.addBlock( + 'affine:list', + { + type: model.type, + text: afterText, + order: model.type === 'numbered' ? 1 : null, + }, + model, + 0 + ); + + if (model.type === 'numbered') { + const nextContinuousNumberedLists = getNextContinuousNumberedLists( + doc, + newListId + ); + let base = 2; + nextContinuousNumberedLists.forEach(list => { + doc.transact(() => { + list.order = base; + }); + base += 1; + }); + } + } else { + /** + * case 4: list has children (list collapsed) + * + * before: + * - aa|a <- split here + * - bbb + * + * after: + * - aa + * - bbb + * - |a + * + * + * case 5: list does not have children + * + * before: + * - aa|a <- split here + * - bbb + * + * after: + * - aa + * - |a + * - bbb + */ + const afterText = model.text.split(inlineIndex); + newListId = doc.addBlock( + 'affine:list', + { + type: model.type, + text: afterText, + order: null, + }, + parent, + modelIndex + 1 + ); + correctNumberedListsOrderToPrev(doc, newListId); + } + + if (newListId) { + host.updateComplete + .then(() => { + focusTextModel(std, newListId); + }) + .catch(console.error); + + next(); + return; + } +}; diff --git a/blocksuite/affine/block-list/src/commands/utils.ts b/blocksuite/affine/block-list/src/commands/utils.ts new file mode 100644 index 0000000000..b9695752d0 --- /dev/null +++ b/blocksuite/affine/block-list/src/commands/utils.ts @@ -0,0 +1,70 @@ +import type { ListBlockModel } from '@blocksuite/affine-model'; +import { + getNextContinuousNumberedLists, + matchFlavours, +} from '@blocksuite/affine-shared/utils'; +import type { BlockModel, Doc } from '@blocksuite/store'; + +/** + * correct target is a numbered list, which is divided into two steps: + * 1. check if there is a numbered list before the target list. If so, adjust the order of the target list + * to the order of the previous list plus 1, otherwise set the order to 1 + * 2. find continuous lists starting from the target list and keep their order continuous + */ +export function correctNumberedListsOrderToPrev( + doc: Doc, + modelOrId: BlockModel | string, + transact = true +) { + const model = + typeof modelOrId === 'string' ? doc.getBlock(modelOrId)?.model : modelOrId; + + if (!model) return; + + if ( + !matchFlavours(model, ['affine:list']) || + model.type$.value !== 'numbered' + ) { + return; + } + + const fn = () => { + // step 1 + const previousSibling = doc.getPrev(model); + if ( + previousSibling && + matchFlavours(previousSibling, ['affine:list']) && + previousSibling.type === 'numbered' + ) { + if (!previousSibling.order) previousSibling.order = 1; + model.order = previousSibling.order + 1; + } else { + model.order = 1; + } + + // step 2 + let base = model.order + 1; + const continuousNumberedLists = getNextContinuousNumberedLists(doc, model); + continuousNumberedLists.forEach(list => { + list.order = base; + base++; + }); + }; + + if (transact) { + doc.transact(fn); + } else { + fn(); + } +} + +export function correctListOrder(doc: Doc, model: ListBlockModel) { + // old numbered list has no order + if (model.type === 'numbered' && !Number.isInteger(model.order)) { + correctNumberedListsOrderToPrev(doc, model, false); + } + // if list is not numbered, order should be null + if (model.type !== 'numbered') { + model.order = null; + } +} diff --git a/blocksuite/affine/block-list/src/effects.ts b/blocksuite/affine/block-list/src/effects.ts new file mode 100644 index 0000000000..bff302c6dc --- /dev/null +++ b/blocksuite/affine/block-list/src/effects.ts @@ -0,0 +1,46 @@ +import type { IndentContext } from '@blocksuite/affine-shared/types'; + +import type { convertToNumberedListCommand } from './commands/convert-to-numbered-list.js'; +import type { + canDedentListCommand, + dedentListCommand, +} from './commands/dedent-list.js'; +import type { + canIndentListCommand, + indentListCommand, +} from './commands/indent-list.js'; +import type { listToParagraphCommand } from './commands/list-to-paragraph.js'; +import type { splitListCommand } from './commands/split-list.js'; +import { ListBlockComponent } from './list-block.js'; +import type { ListBlockService } from './list-service.js'; + +export function effects() { + customElements.define('affine-list', ListBlockComponent); +} + +declare global { + namespace BlockSuite { + interface BlockServices { + 'affine:list': ListBlockService; + } + + interface CommandContext { + listConvertedId?: string; + indentContext?: IndentContext; + } + + interface Commands { + convertToNumberedList: typeof convertToNumberedListCommand; + canDedentList: typeof canDedentListCommand; + canIndentList: typeof canIndentListCommand; + dedentList: typeof dedentListCommand; + indentList: typeof indentListCommand; + listToParagraph: typeof listToParagraphCommand; + splitList: typeof splitListCommand; + } + } + + interface HTMLElementTagNameMap { + 'affine-list': ListBlockComponent; + } +} diff --git a/blocksuite/affine/block-list/src/index.ts b/blocksuite/affine/block-list/src/index.ts new file mode 100644 index 0000000000..4c8404af34 --- /dev/null +++ b/blocksuite/affine/block-list/src/index.ts @@ -0,0 +1,4 @@ +export * from './adapters/index.js'; +export * from './list-block.js'; +export * from './list-service.js'; +export * from './list-spec.js'; diff --git a/blocksuite/affine/block-list/src/list-block.ts b/blocksuite/affine/block-list/src/list-block.ts new file mode 100644 index 0000000000..880d101777 --- /dev/null +++ b/blocksuite/affine/block-list/src/list-block.ts @@ -0,0 +1,217 @@ +import '@blocksuite/affine-shared/commands'; + +import { CaptionedBlockComponent } from '@blocksuite/affine-components/caption'; +import { playCheckAnimation } from '@blocksuite/affine-components/icons'; +import { + DefaultInlineManagerExtension, + type RichText, +} from '@blocksuite/affine-components/rich-text'; +import { TOGGLE_BUTTON_PARENT_CLASS } from '@blocksuite/affine-components/toggle-button'; +import type { ListBlockModel } from '@blocksuite/affine-model'; +import { + BLOCK_CHILDREN_CONTAINER_PADDING_LEFT, + NOTE_SELECTOR, +} from '@blocksuite/affine-shared/consts'; +import { DocModeProvider } from '@blocksuite/affine-shared/services'; +import { getViewportElement } from '@blocksuite/affine-shared/utils'; +import type { BaseSelection, BlockComponent } from '@blocksuite/block-std'; +import { getInlineRangeProvider } from '@blocksuite/block-std'; +import type { InlineRangeProvider } from '@blocksuite/inline'; +import { effect } from '@preact/signals-core'; +import { html, nothing, type TemplateResult } from 'lit'; +import { query, state } from 'lit/decorators.js'; +import { classMap } from 'lit/directives/class-map.js'; +import { styleMap } from 'lit/directives/style-map.js'; + +import { correctNumberedListsOrderToPrev } from './commands/utils.js'; +import type { ListBlockService } from './list-service.js'; +import { listBlockStyles } from './styles.js'; +import { getListIcon } from './utils/get-list-icon.js'; + +export class ListBlockComponent extends CaptionedBlockComponent< + ListBlockModel, + ListBlockService +> { + static override styles = listBlockStyles; + + private _inlineRangeProvider: InlineRangeProvider | null = null; + + private _onClickIcon = (e: MouseEvent) => { + e.stopPropagation(); + + if (this.model.type === 'toggle') { + if (this.doc.readonly) { + this._readonlyCollapsed = !this._readonlyCollapsed; + } else { + this.doc.captureSync(); + this.doc.updateBlock(this.model, { + collapsed: !this.model.collapsed, + }); + } + + return; + } else if (this.model.type === 'todo') { + if (this.doc.readonly) return; + + this.doc.captureSync(); + const checkedPropObj = { checked: !this.model.checked }; + this.doc.updateBlock(this.model, checkedPropObj); + if (this.model.checked) { + const checkEl = this.querySelector('.affine-list-block__todo-prefix'); + if (checkEl) { + playCheckAnimation(checkEl).catch(console.error); + } + } + return; + } + this._select(); + }; + + get attributeRenderer() { + return this.inlineManager.getRenderer(); + } + + get attributesSchema() { + return this.inlineManager.getSchema(); + } + + get embedChecker() { + return this.inlineManager.embedChecker; + } + + get inlineManager() { + return this.std.get(DefaultInlineManagerExtension.identifier); + } + + get markdownShortcutHandler() { + return this.inlineManager.markdownShortcutHandler; + } + + override get topContenteditableElement() { + if (this.std.get(DocModeProvider).getEditorMode() === 'edgeless') { + return this.closest(NOTE_SELECTOR); + } + return this.rootComponent; + } + + private _select() { + const selection = this.host.selection; + selection.update(selList => { + return selList + .filter(sel => !sel.is('text') && !sel.is('block')) + .concat(selection.create('block', { blockId: this.blockId })); + }); + } + + override connectedCallback() { + super.connectedCallback(); + + this._inlineRangeProvider = getInlineRangeProvider(this); + + this.disposables.add( + effect(() => { + const collapsed = this.model.collapsed$.value; + this._readonlyCollapsed = collapsed; + }) + ); + + this.disposables.add( + effect(() => { + const type = this.model.type$.value; + const order = this.model.order$.value; + // old numbered list has no order + if (type === 'numbered' && !Number.isInteger(order)) { + correctNumberedListsOrderToPrev(this.doc, this.model, false); + } + // if list is not numbered, order should be null + if (type !== 'numbered' && order !== null) { + this.model.order = null; + } + }) + ); + } + + override async getUpdateComplete() { + const result = await super.getUpdateComplete(); + await this._richTextElement?.updateComplete; + return result; + } + + override renderBlock(): TemplateResult<1> { + const { model, _onClickIcon } = this; + const collapsed = this.doc.readonly + ? this._readonlyCollapsed + : model.collapsed; + + const listIcon = getListIcon(model, !collapsed, _onClickIcon); + + const children = html`
+ ${this.renderChildren(this.model)} +
`; + + return html` +
+
+ ${this.model.children.length > 0 + ? html` + { + if (this.doc.readonly) { + this._readonlyCollapsed = value; + } else { + this.doc.captureSync(); + this.doc.updateBlock(this.model, { + collapsed: value, + }); + } + }} + > + ` + : nothing} + ${listIcon} + + getViewportElement(this.host)} + > +
+ + ${children} +
+ `; + } + + @state() + private accessor _readonlyCollapsed = false; + + @query('rich-text') + private accessor _richTextElement: RichText | null = null; + + override accessor blockContainerStyles = { + margin: 'var(--affine-list-margin, 10px 0)', + }; +} diff --git a/blocksuite/affine/block-list/src/list-keymap.ts b/blocksuite/affine/block-list/src/list-keymap.ts new file mode 100644 index 0000000000..6913058d01 --- /dev/null +++ b/blocksuite/affine/block-list/src/list-keymap.ts @@ -0,0 +1,125 @@ +import { + markdownInput, + textKeymap, +} from '@blocksuite/affine-components/rich-text'; +import { ListBlockSchema } from '@blocksuite/affine-model'; +import { KeymapExtension } from '@blocksuite/block-std'; +import { IS_MAC } from '@blocksuite/global/env'; + +import { forwardDelete } from './utils/forward-delete.js'; + +export const ListKeymapExtension = KeymapExtension( + std => { + return { + Enter: ctx => { + const text = std.selection.find('text'); + if (!text) return false; + + ctx.get('keyboardState').raw.preventDefault(); + std.command.exec('splitList', { + blockId: text.from.blockId, + inlineIndex: text.from.index, + }); + return true; + }, + 'Mod-Enter': ctx => { + const text = std.selection.find('text'); + if (!text) return false; + + ctx.get('keyboardState').raw.preventDefault(); + std.command.exec('splitList', { + blockId: text.from.blockId, + inlineIndex: text.from.index, + }); + return true; + }, + Tab: ctx => { + const { selectedModels } = std.command.exec('getSelectedModels', { + types: ['text'], + }); + if (selectedModels?.length !== 1) { + return false; + } + const text = std.selection.find('text'); + if (!text) return false; + + ctx.get('keyboardState').raw.preventDefault(); + std.command + .chain() + .canIndentList({ + blockId: text.from.blockId, + inlineIndex: text.from.index, + }) + .indentList() + .run(); + return true; + }, + 'Shift-Tab': ctx => { + const { selectedModels } = std.command.exec('getSelectedModels', { + types: ['text'], + }); + if (selectedModels?.length !== 1) { + return; + } + const text = std.selection.find('text'); + if (!text) return false; + + ctx.get('keyboardState').raw.preventDefault(); + std.command + .chain() + .canDedentList({ + blockId: text.from.blockId, + inlineIndex: text.from.index, + }) + .dedentList() + .run(); + return true; + }, + Backspace: ctx => { + const text = std.selection.find('text'); + if (!text) return false; + const isCollapsed = text.isCollapsed(); + const isStart = isCollapsed && text.from.index === 0; + if (!isStart) return false; + + ctx.get('keyboardState').raw.preventDefault(); + std.command.exec('listToParagraph', { id: text.from.blockId }); + return true; + }, + 'Control-d': ctx => { + if (!IS_MAC) return; + const deleted = forwardDelete(std); + if (!deleted) return; + ctx.get('keyboardState').raw.preventDefault(); + return true; + }, + Delete: ctx => { + const deleted = forwardDelete(std); + if (!deleted) return; + ctx.get('keyboardState').raw.preventDefault(); + return true; + }, + Space: ctx => { + if (!markdownInput(std)) { + return; + } + ctx.get('keyboardState').raw.preventDefault(); + return true; + }, + 'Shift-Space': ctx => { + if (!markdownInput(std)) { + return; + } + ctx.get('keyboardState').raw.preventDefault(); + return true; + }, + }; + }, + { + flavour: ListBlockSchema.model.flavour, + } +); + +export const ListTextKeymapExtension = KeymapExtension(textKeymap, { + flavour: ListBlockSchema.model.flavour, +}); diff --git a/blocksuite/affine/block-list/src/list-service.ts b/blocksuite/affine/block-list/src/list-service.ts new file mode 100644 index 0000000000..6ad1d0740e --- /dev/null +++ b/blocksuite/affine/block-list/src/list-service.ts @@ -0,0 +1,33 @@ +import { ListBlockSchema } from '@blocksuite/affine-model'; +import { DragHandleConfigExtension } from '@blocksuite/affine-shared/services'; +import { matchFlavours } from '@blocksuite/affine-shared/utils'; +import type { BlockComponent } from '@blocksuite/block-std'; +import { BlockService } from '@blocksuite/block-std'; + +import { correctNumberedListsOrderToPrev } from './commands/utils.js'; + +export class ListBlockService extends BlockService { + static override readonly flavour = ListBlockSchema.model.flavour; +} + +export const ListDragHandleOption = DragHandleConfigExtension({ + flavour: ListBlockSchema.model.flavour, + onDragEnd: ({ draggingElements, editorHost }) => { + draggingElements.forEach((el: BlockComponent) => { + const model = el.model; + const doc = el.doc; + if (matchFlavours(model, ['affine:list']) && model.type === 'numbered') { + const next = el.doc.getNext(model); + editorHost.updateComplete + .then(() => { + correctNumberedListsOrderToPrev(doc, model); + if (next) { + correctNumberedListsOrderToPrev(doc, next); + } + }) + .catch(console.error); + } + }); + return false; + }, +}); diff --git a/blocksuite/affine/block-list/src/list-spec.ts b/blocksuite/affine/block-list/src/list-spec.ts new file mode 100644 index 0000000000..763ccc7b62 --- /dev/null +++ b/blocksuite/affine/block-list/src/list-spec.ts @@ -0,0 +1,21 @@ +import { + BlockViewExtension, + CommandExtension, + type ExtensionType, + FlavourExtension, +} from '@blocksuite/block-std'; +import { literal } from 'lit/static-html.js'; + +import { commands } from './commands/index.js'; +import { ListKeymapExtension, ListTextKeymapExtension } from './list-keymap.js'; +import { ListBlockService, ListDragHandleOption } from './list-service.js'; + +export const ListBlockSpec: ExtensionType[] = [ + FlavourExtension('affine:list'), + ListBlockService, + CommandExtension(commands), + BlockViewExtension('affine:list', literal`affine-list`), + ListKeymapExtension, + ListTextKeymapExtension, + ListDragHandleOption, +]; diff --git a/blocksuite/affine/block-list/src/styles.ts b/blocksuite/affine/block-list/src/styles.ts new file mode 100644 index 0000000000..08599f4655 --- /dev/null +++ b/blocksuite/affine/block-list/src/styles.ts @@ -0,0 +1,64 @@ +import { css } from 'lit'; + +export const listPrefix = css` + .affine-list-block__prefix { + display: flex; + color: var(--affine-blue-700); + font-size: var(--affine-font-sm); + user-select: none; + position: relative; + } + + .affine-list-block__numbered { + min-width: 22px; + height: 24px; + margin-left: 2px; + } + + .affine-list-block__todo-prefix { + display: flex; + align-items: center; + cursor: pointer; + width: 24px; + height: 24px; + color: var(--affine-icon-color); + } + + .affine-list-block__todo-prefix.readonly { + cursor: default; + } + + .affine-list-block__todo-prefix > svg { + width: 20px; + height: 20px; + } +`; + +export const listBlockStyles = css` + affine-list { + display: block; + font-size: var(--affine-font-base); + } + + .affine-list-block-container { + box-sizing: border-box; + border-radius: 4px; + position: relative; + } + .affine-list-block-container .affine-list-block-container { + margin-top: 0; + } + .affine-list-rich-text-wrapper { + position: relative; + display: flex; + } + .affine-list-rich-text-wrapper rich-text { + flex: 1; + } + + .affine-list--checked { + color: var(--affine-text-secondary-color); + } + + ${listPrefix} +`; diff --git a/blocksuite/affine/block-list/src/utils/forward-delete.ts b/blocksuite/affine/block-list/src/utils/forward-delete.ts new file mode 100644 index 0000000000..87d4a37d92 --- /dev/null +++ b/blocksuite/affine/block-list/src/utils/forward-delete.ts @@ -0,0 +1,74 @@ +import { + getNextContentBlock, + matchFlavours, +} from '@blocksuite/affine-shared/utils'; +import type { BlockStdScope } from '@blocksuite/block-std'; +import type { Text } from '@blocksuite/store'; + +// When deleting at line end of a list block, +// check current block's children and siblings +/** + * Example: + - Line1 <-(cursor here) + - Line2 + - Line3 + - Line4 + - Line5 + - Line6 + - Line7 + - Line8 + - Line9 + */ +export function forwardDelete(std: BlockStdScope): true | undefined { + const text = std.selection.find('text'); + if (!text) return; + const isCollapsed = text.isCollapsed(); + const doc = std.doc; + const model = doc.getBlock(text.from.blockId)?.model; + if (!model || !matchFlavours(model, ['affine:list'])) return; + const isEnd = isCollapsed && text.from.index === model.text.length; + if (!isEnd) return; + // Has children in list + const firstChild = model.firstChild(); + if (firstChild) { + model.text.join(firstChild.text as Text); + const grandChildren = firstChild.children; + if (grandChildren) { + doc.moveBlocks(grandChildren, model); + doc.deleteBlock(firstChild); + return true; + } + + doc.deleteBlock(firstChild); + return true; + } + + const parent = doc.getParent(model); + // Has text sibling + const nextSibling = doc.getNext(model); + const nextText = nextSibling?.text; + if (nextSibling && nextText) { + model.text.join(nextText); + if (nextSibling.children) { + if (!parent) return; + doc.moveBlocks(nextSibling.children, parent, model, false); + } + + doc.deleteBlock(nextSibling); + return true; + } + + // Has next text block in other note block + const nextBlock = getNextContentBlock(std.host, model); + const nextBlockText = nextBlock?.text; + if (nextBlock && nextBlockText) { + model.text.join(nextBlock.text as Text); + if (nextBlock.children) { + const nextBlockParent = doc.getParent(nextBlock); + if (!nextBlockParent) return; + doc.moveBlocks(nextBlock.children, nextBlockParent, parent, false); + } + doc.deleteBlock(nextBlock); + } + return true; +} diff --git a/blocksuite/affine/block-list/src/utils/get-list-icon.ts b/blocksuite/affine/block-list/src/utils/get-list-icon.ts new file mode 100644 index 0000000000..534282e02b --- /dev/null +++ b/blocksuite/affine/block-list/src/utils/get-list-icon.ts @@ -0,0 +1,66 @@ +import { + BulletIcons, + checkboxChecked, + checkboxUnchecked, + toggleDown, + toggleRight, +} from '@blocksuite/affine-components/icons'; +import type { ListBlockModel } from '@blocksuite/affine-model'; +import { html } from 'lit'; + +import { getNumberPrefix } from './get-number-prefix.js'; + +const getListDeep = (model: ListBlockModel): number => { + let deep = 0; + let parent = model.doc.getParent(model); + while (parent?.flavour === model.flavour) { + deep++; + parent = model.doc.getParent(parent); + } + return deep; +}; + +export function getListIcon( + model: ListBlockModel, + showChildren: boolean, + onClick: (e: MouseEvent) => void +) { + const deep = getListDeep(model); + switch (model.type) { + case 'bulleted': + return html`
+ ${BulletIcons[deep % BulletIcons.length]} +
`; + case 'numbered': + return html`
+ ${model.order ? getNumberPrefix(model.order, deep) : '1.'} +
`; + case 'todo': + return html`
+ ${model.checked ? checkboxChecked() : checkboxUnchecked()} +
`; + case 'toggle': + return html`
+ ${showChildren ? toggleDown : toggleRight} +
`; + default: + console.error('Unknown list type', model.type, model); + return null; + } +} diff --git a/blocksuite/affine/block-list/src/utils/get-number-prefix.ts b/blocksuite/affine/block-list/src/utils/get-number-prefix.ts new file mode 100644 index 0000000000..bb1fa5207b --- /dev/null +++ b/blocksuite/affine/block-list/src/utils/get-number-prefix.ts @@ -0,0 +1,52 @@ +function number2letter(n: number) { + const ordA = 'a'.charCodeAt(0); + const ordZ = 'z'.charCodeAt(0); + const len = ordZ - ordA + 1; + let s = ''; + while (n >= 0) { + s = String.fromCharCode((n % len) + ordA) + s; + n = Math.floor(n / len) - 1; + } + return s; +} + +// Derive from https://gist.github.com/imilu/00f32c61e50b7ca296f91e9d96d8e976 +export function number2roman(num: number) { + const lookup: Record = { + M: 1000, + CM: 900, + D: 500, + CD: 400, + C: 100, + XC: 90, + L: 50, + XL: 40, + X: 10, + IX: 9, + V: 5, + IV: 4, + I: 1, + }; + let romanStr = ''; + for (const i in lookup) { + while (num >= lookup[i]) { + romanStr += i; + num -= lookup[i]; + } + } + return romanStr; +} + +function getPrefix(depth: number, index: number) { + const map = [ + () => index, + () => number2letter(index - 1), + () => number2roman(index), + ]; + return map[depth % map.length](); +} + +export function getNumberPrefix(index: number, depth: number) { + const prefix = getPrefix(depth, index); + return `${prefix}.`; +} diff --git a/blocksuite/affine/block-list/tsconfig.json b/blocksuite/affine/block-list/tsconfig.json new file mode 100644 index 0000000000..c1a5453aa5 --- /dev/null +++ b/blocksuite/affine/block-list/tsconfig.json @@ -0,0 +1,32 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "rootDir": "./src/", + "outDir": "./dist/", + "noEmit": false + }, + "include": ["./src"], + "references": [ + { + "path": "../../framework/global" + }, + { + "path": "../../framework/store" + }, + { + "path": "../../framework/block-std" + }, + { + "path": "../../framework/inline" + }, + { + "path": "../model" + }, + { + "path": "../components" + }, + { + "path": "../shared" + } + ] +} diff --git a/blocksuite/affine/block-list/typedoc.json b/blocksuite/affine/block-list/typedoc.json new file mode 100644 index 0000000000..101e923dba --- /dev/null +++ b/blocksuite/affine/block-list/typedoc.json @@ -0,0 +1,4 @@ +{ + "extends": ["../../../typedoc.base.json"], + "entryPoints": ["src/index.ts"] +} diff --git a/blocksuite/affine/block-list/vitest.config.ts b/blocksuite/affine/block-list/vitest.config.ts new file mode 100644 index 0000000000..b86624acc9 --- /dev/null +++ b/blocksuite/affine/block-list/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/affine-block-list', + }, + /** + * 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/blocksuite/affine/block-paragraph/package.json b/blocksuite/affine/block-paragraph/package.json new file mode 100644 index 0000000000..5f017119ca --- /dev/null +++ b/blocksuite/affine/block-paragraph/package.json @@ -0,0 +1,42 @@ +{ + "name": "@blocksuite/affine-block-paragraph", + "description": "Paragraph block for BlockSuite.", + "type": "module", + "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": "MIT", + "dependencies": { + "@blocksuite/affine-components": "workspace:*", + "@blocksuite/affine-model": "workspace:*", + "@blocksuite/affine-shared": "workspace:*", + "@blocksuite/block-std": "workspace:*", + "@blocksuite/global": "workspace:*", + "@blocksuite/inline": "workspace:*", + "@blocksuite/store": "workspace:*", + "@floating-ui/dom": "^1.6.10", + "@lit/context": "^1.1.2", + "@preact/signals-core": "^1.8.0", + "@toeverything/theme": "^1.1.1", + "@types/mdast": "^4.0.4", + "lit": "^3.2.0", + "minimatch": "^10.0.1", + "zod": "^3.23.8" + }, + "exports": { + ".": "./src/index.ts", + "./effects": "./src/effects.ts" + }, + "files": [ + "src", + "dist", + "!src/__tests__", + "!dist/__tests__" + ] +} diff --git a/blocksuite/affine/block-paragraph/src/adapters/html.ts b/blocksuite/affine/block-paragraph/src/adapters/html.ts new file mode 100644 index 0000000000..d5a0e4aca3 --- /dev/null +++ b/blocksuite/affine/block-paragraph/src/adapters/html.ts @@ -0,0 +1,344 @@ +import { ParagraphBlockSchema } from '@blocksuite/affine-model'; +import { + BlockHtmlAdapterExtension, + type BlockHtmlAdapterMatcher, + HastUtils, +} from '@blocksuite/affine-shared/adapters'; +import type { DeltaInsert } from '@blocksuite/inline'; +import { nanoid } from '@blocksuite/store'; + +const paragraphBlockMatchTags = new Set([ + 'p', + 'h1', + 'h2', + 'h3', + 'h4', + 'h5', + 'h6', + 'blockquote', + 'body', + 'div', + 'span', + 'footer', +]); + +export const paragraphBlockHtmlAdapterMatcher: BlockHtmlAdapterMatcher = { + flavour: ParagraphBlockSchema.model.flavour, + toMatch: o => + HastUtils.isElement(o.node) && paragraphBlockMatchTags.has(o.node.tagName), + fromMatch: o => o.node.flavour === ParagraphBlockSchema.model.flavour, + toBlockSnapshot: { + enter: (o, context) => { + if (!HastUtils.isElement(o.node)) { + return; + } + const { walkerContext, deltaConverter } = context; + switch (o.node.tagName) { + case 'blockquote': { + walkerContext.setGlobalContext('hast:blockquote', true); + // Special case for no paragraph in blockquote + const texts = HastUtils.getTextChildren(o.node); + // check if only blank text + const onlyBlankText = texts.every(text => !text.value.trim()); + if (texts && !onlyBlankText) { + walkerContext + .openNode( + { + type: 'block', + id: nanoid(), + flavour: 'affine:paragraph', + props: { + type: 'quote', + text: { + '$blocksuite:internal:text$': true, + delta: deltaConverter.astToDelta( + HastUtils.getTextChildrenOnlyAst(o.node) + ), + }, + }, + children: [], + }, + 'children' + ) + .closeNode(); + } + break; + } + case 'body': + case 'div': + case 'span': + case 'footer': { + if ( + o.parent?.node.type === 'element' && + !['li', 'p'].includes(o.parent.node.tagName) && + HastUtils.isParagraphLike(o.node) + ) { + walkerContext + .openNode( + { + type: 'block', + id: nanoid(), + flavour: 'affine:paragraph', + props: { + type: 'text', + text: { + '$blocksuite:internal:text$': true, + delta: deltaConverter.astToDelta(o.node), + }, + }, + children: [], + }, + 'children' + ) + .closeNode(); + walkerContext.skipAllChildren(); + } + break; + } + case 'p': { + walkerContext.openNode( + { + type: 'block', + id: nanoid(), + flavour: 'affine:paragraph', + props: { + type: walkerContext.getGlobalContext('hast:blockquote') + ? 'quote' + : 'text', + text: { + '$blocksuite:internal:text$': true, + delta: deltaConverter.astToDelta(o.node), + }, + }, + children: [], + }, + 'children' + ); + break; + } + case 'h1': + case 'h2': + case 'h3': + case 'h4': + case 'h5': + case 'h6': { + walkerContext + .openNode( + { + type: 'block', + id: nanoid(), + flavour: 'affine:paragraph', + props: { + type: o.node.tagName, + text: { + '$blocksuite:internal:text$': true, + delta: deltaConverter.astToDelta(o.node), + }, + }, + children: [], + }, + 'children' + ) + .closeNode(); + walkerContext.skipAllChildren(); + break; + } + } + }, + leave: (o, context) => { + if (!HastUtils.isElement(o.node)) { + return; + } + const { walkerContext } = context; + switch (o.node.tagName) { + case 'div': { + // eslint-disable-next-line sonarjs/no-collapsible-if + if ( + o.parent?.node.type === 'element' && + o.parent.node.tagName !== 'li' && + Array.isArray(o.node.properties?.className) + ) { + if ( + o.node.properties.className.includes( + 'affine-paragraph-block-container' + ) || + o.node.properties.className.includes( + 'affine-block-children-container' + ) || + o.node.properties.className.includes('indented') + ) { + walkerContext.closeNode(); + } + } + break; + } + case 'blockquote': { + walkerContext.setGlobalContext('hast:blockquote', false); + break; + } + case 'p': { + if ( + o.next?.type === 'element' && + o.next.tagName === 'div' && + Array.isArray(o.next.properties?.className) && + (o.next.properties.className.includes( + 'affine-block-children-container' + ) || + o.next.properties.className.includes('indented')) + ) { + // Close the node when leaving div indented + break; + } + walkerContext.closeNode(); + break; + } + } + }, + }, + fromBlockSnapshot: { + enter: (o, context) => { + const text = (o.node.props.text ?? { delta: [] }) as { + delta: DeltaInsert[]; + }; + const { walkerContext, deltaConverter } = context; + switch (o.node.props.type) { + case 'text': { + walkerContext + .openNode( + { + type: 'element', + tagName: 'div', + properties: { + className: ['affine-paragraph-block-container'], + }, + children: [], + }, + 'children' + ) + .openNode( + { + type: 'element', + tagName: 'p', + properties: {}, + children: deltaConverter.deltaToAST(text.delta), + }, + 'children' + ) + .closeNode() + .openNode( + { + type: 'element', + tagName: 'div', + properties: { + className: ['affine-block-children-container'], + style: 'padding-left: 26px;', + }, + children: [], + }, + 'children' + ); + break; + } + case 'h1': + case 'h2': + case 'h3': + case 'h4': + case 'h5': + case 'h6': { + walkerContext + .openNode( + { + type: 'element', + tagName: 'div', + properties: { + className: ['affine-paragraph-block-container'], + }, + children: [], + }, + 'children' + ) + .openNode( + { + type: 'element', + tagName: o.node.props.type, + properties: {}, + children: deltaConverter.deltaToAST(text.delta), + }, + 'children' + ) + .closeNode() + .openNode( + { + type: 'element', + tagName: 'div', + properties: { + className: ['affine-block-children-container'], + style: 'padding-left: 26px;', + }, + children: [], + }, + 'children' + ); + break; + } + case 'quote': { + walkerContext + .openNode( + { + type: 'element', + tagName: 'div', + properties: { + className: ['affine-paragraph-block-container'], + }, + children: [], + }, + 'children' + ) + .openNode( + { + type: 'element', + tagName: 'blockquote', + properties: { + className: ['quote'], + }, + children: [], + }, + 'children' + ) + .openNode( + { + type: 'element', + tagName: 'p', + properties: {}, + children: deltaConverter.deltaToAST(text.delta), + }, + 'children' + ) + .closeNode() + .closeNode() + .openNode( + { + type: 'element', + tagName: 'div', + properties: { + className: ['affine-block-children-container'], + style: 'padding-left: 26px;', + }, + children: [], + }, + 'children' + ); + break; + } + } + }, + leave: (_, context) => { + const { walkerContext } = context; + walkerContext.closeNode().closeNode(); + }, + }, +}; + +export const ParagraphBlockHtmlAdapterExtension = BlockHtmlAdapterExtension( + paragraphBlockHtmlAdapterMatcher +); diff --git a/blocksuite/affine/block-paragraph/src/adapters/index.ts b/blocksuite/affine/block-paragraph/src/adapters/index.ts new file mode 100644 index 0000000000..b4dd5a6d2a --- /dev/null +++ b/blocksuite/affine/block-paragraph/src/adapters/index.ts @@ -0,0 +1,4 @@ +export * from './html.js'; +export * from './markdown.js'; +export * from './notion-html.js'; +export * from './plain-text.js'; diff --git a/blocksuite/affine/block-paragraph/src/adapters/markdown.ts b/blocksuite/affine/block-paragraph/src/adapters/markdown.ts new file mode 100644 index 0000000000..ce043d9ca5 --- /dev/null +++ b/blocksuite/affine/block-paragraph/src/adapters/markdown.ts @@ -0,0 +1,206 @@ +import { ParagraphBlockSchema } from '@blocksuite/affine-model'; +import { + BlockMarkdownAdapterExtension, + type BlockMarkdownAdapterMatcher, + type MarkdownAST, +} from '@blocksuite/affine-shared/adapters'; +import type { DeltaInsert } from '@blocksuite/inline'; +import { nanoid } from '@blocksuite/store'; +import type { Heading } from 'mdast'; + +const PARAGRAPH_MDAST_TYPE = new Set([ + 'paragraph', + 'html', + 'heading', + 'blockquote', +]); + +const isParagraphMDASTType = (node: MarkdownAST) => + PARAGRAPH_MDAST_TYPE.has(node.type); + +export const paragraphBlockMarkdownAdapterMatcher: BlockMarkdownAdapterMatcher = + { + flavour: ParagraphBlockSchema.model.flavour, + toMatch: o => isParagraphMDASTType(o.node), + fromMatch: o => o.node.flavour === ParagraphBlockSchema.model.flavour, + toBlockSnapshot: { + enter: (o, context) => { + const { walkerContext, deltaConverter } = context; + switch (o.node.type) { + case 'html': { + walkerContext + .openNode( + { + type: 'block', + id: nanoid(), + flavour: 'affine:paragraph', + props: { + type: 'text', + text: { + '$blocksuite:internal:text$': true, + delta: [ + { + insert: o.node.value, + }, + ], + }, + }, + children: [], + }, + 'children' + ) + .closeNode(); + break; + } + case 'paragraph': { + walkerContext + .openNode( + { + type: 'block', + id: nanoid(), + flavour: 'affine:paragraph', + props: { + type: 'text', + text: { + '$blocksuite:internal:text$': true, + delta: deltaConverter.astToDelta(o.node), + }, + }, + children: [], + }, + 'children' + ) + .closeNode(); + break; + } + case 'heading': { + walkerContext + .openNode( + { + type: 'block', + id: nanoid(), + flavour: 'affine:paragraph', + props: { + type: `h${o.node.depth}`, + text: { + '$blocksuite:internal:text$': true, + delta: deltaConverter.astToDelta(o.node), + }, + }, + children: [], + }, + 'children' + ) + .closeNode(); + break; + } + case 'blockquote': { + walkerContext + .openNode( + { + type: 'block', + id: nanoid(), + flavour: 'affine:paragraph', + props: { + type: 'quote', + text: { + '$blocksuite:internal:text$': true, + delta: deltaConverter.astToDelta(o.node), + }, + }, + children: [], + }, + 'children' + ) + .closeNode(); + walkerContext.skipAllChildren(); + break; + } + } + }, + }, + fromBlockSnapshot: { + enter: (o, context) => { + const { walkerContext, deltaConverter } = context; + const paragraphDepth = (walkerContext.getGlobalContext( + 'affine:paragraph:depth' + ) ?? 0) as number; + const text = (o.node.props.text ?? { delta: [] }) as { + delta: DeltaInsert[]; + }; + switch (o.node.props.type) { + case 'h1': + case 'h2': + case 'h3': + case 'h4': + case 'h5': + case 'h6': { + walkerContext + .openNode( + { + type: 'heading', + depth: parseInt(o.node.props.type[1]) as Heading['depth'], + children: deltaConverter.deltaToAST( + text.delta, + paragraphDepth + ), + }, + 'children' + ) + .closeNode(); + break; + } + case 'text': { + walkerContext + .openNode( + { + type: 'paragraph', + children: deltaConverter.deltaToAST( + text.delta, + paragraphDepth + ), + }, + 'children' + ) + .closeNode(); + break; + } + case 'quote': { + walkerContext + .openNode( + { + type: 'blockquote', + children: [], + }, + 'children' + ) + .openNode( + { + type: 'paragraph', + children: deltaConverter.deltaToAST(text.delta), + }, + 'children' + ) + .closeNode() + .closeNode(); + break; + } + } + walkerContext.setGlobalContext( + 'affine:paragraph:depth', + paragraphDepth + 1 + ); + }, + leave: (_, context) => { + const { walkerContext } = context; + walkerContext.setGlobalContext( + 'affine:paragraph:depth', + (walkerContext.getGlobalContext('affine:paragraph:depth') as number) - + 1 + ); + }, + }, + }; + +export const ParagraphBlockMarkdownAdapterExtension = + BlockMarkdownAdapterExtension(paragraphBlockMarkdownAdapterMatcher); diff --git a/blocksuite/affine/block-paragraph/src/adapters/notion-html.ts b/blocksuite/affine/block-paragraph/src/adapters/notion-html.ts new file mode 100644 index 0000000000..070ac1e612 --- /dev/null +++ b/blocksuite/affine/block-paragraph/src/adapters/notion-html.ts @@ -0,0 +1,239 @@ +import { ParagraphBlockSchema } from '@blocksuite/affine-model'; +import { + BlockNotionHtmlAdapterExtension, + type BlockNotionHtmlAdapterMatcher, + HastUtils, +} from '@blocksuite/affine-shared/adapters'; +import { nanoid } from '@blocksuite/store'; + +const paragraphBlockMatchTags = new Set([ + 'p', + 'h1', + 'h2', + 'h3', + 'h4', + 'h5', + 'h6', + 'blockquote', + 'div', + 'span', + 'figure', +]); + +const NotionDatabaseTitleToken = '.collection-title'; +const NotionPageLinkToken = '.link-to-page'; +const NotionCalloutToken = '.callout'; +const NotionCheckboxToken = '.checkbox'; + +export const paragraphBlockNotionHtmlAdapterMatcher: BlockNotionHtmlAdapterMatcher = + { + flavour: ParagraphBlockSchema.model.flavour, + toMatch: o => + HastUtils.isElement(o.node) && + paragraphBlockMatchTags.has(o.node.tagName), + fromMatch: () => false, + toBlockSnapshot: { + enter: (o, context) => { + if (!HastUtils.isElement(o.node)) { + return; + } + const { walkerContext, deltaConverter, pageMap } = context; + switch (o.node.tagName) { + case 'blockquote': { + walkerContext.setGlobalContext('hast:blockquote', true); + walkerContext.openNode( + { + type: 'block', + id: nanoid(), + flavour: 'affine:paragraph', + props: { + type: 'quote', + text: { + '$blocksuite:internal:text$': true, + delta: deltaConverter.astToDelta( + HastUtils.getInlineOnlyElementAST(o.node), + { pageMap, removeLastBr: true } + ), + }, + }, + children: [], + }, + 'children' + ); + break; + } + case 'p': { + // Workaround for Notion's bug + // https://html.spec.whatwg.org/multipage/grouping-content.html#the-p-element + if (!o.node.properties.id) { + break; + } + walkerContext.openNode( + { + type: 'block', + id: nanoid(), + flavour: 'affine:paragraph', + props: { + type: walkerContext.getGlobalContext('hast:blockquote') + ? 'quote' + : 'text', + text: { + '$blocksuite:internal:text$': true, + delta: deltaConverter.astToDelta(o.node, { pageMap }), + }, + }, + children: [], + }, + 'children' + ); + break; + } + case 'h1': + case 'h2': + case 'h3': + case 'h4': + case 'h5': + case 'h6': { + if (HastUtils.querySelector(o.node, NotionDatabaseTitleToken)) { + break; + } + walkerContext + .openNode( + { + type: 'block', + id: nanoid(), + flavour: 'affine:paragraph', + props: { + type: o.node.tagName, + text: { + '$blocksuite:internal:text$': true, + delta: deltaConverter.astToDelta(o.node, { pageMap }), + }, + }, + children: [], + }, + 'children' + ) + .closeNode(); + break; + } + case 'figure': + { + // Notion page link + if (HastUtils.querySelector(o.node, NotionPageLinkToken)) { + walkerContext + .openNode( + { + type: 'block', + id: nanoid(), + flavour: 'affine:paragraph', + props: { + type: 'text', + text: { + '$blocksuite:internal:text$': true, + delta: deltaConverter.astToDelta(o.node, { pageMap }), + }, + }, + children: [], + }, + 'children' + ) + .closeNode(); + walkerContext.skipAllChildren(); + break; + } + } + + // Notion callout + if (HastUtils.querySelector(o.node, NotionCalloutToken)) { + const firstElementChild = HastUtils.getElementChildren(o.node)[0]; + const secondElementChild = HastUtils.getElementChildren( + o.node + )[1]; + + const iconSpan = HastUtils.querySelector( + firstElementChild, + '.icon' + ); + const iconText = iconSpan + ? HastUtils.getTextContent(iconSpan) + : ''; + walkerContext + .openNode( + { + type: 'block', + id: nanoid(), + flavour: 'affine:paragraph', + props: { + type: 'quote', + text: { + '$blocksuite:internal:text$': true, + delta: [ + { insert: iconText + '\n' }, + ...deltaConverter.astToDelta(secondElementChild, { + pageMap, + }), + ], + }, + }, + children: [], + }, + 'children' + ) + .closeNode(); + walkerContext.skipAllChildren(); + break; + } + } + }, + leave: (o, context) => { + if (!HastUtils.isElement(o.node)) { + return; + } + const { walkerContext } = context; + switch (o.node.tagName) { + case 'div': { + // eslint-disable-next-line sonarjs/no-collapsible-if + if ( + o.parent?.node.type === 'element' && + !( + o.parent.node.tagName === 'li' && + HastUtils.querySelector(o.parent.node, NotionCheckboxToken) + ) && + Array.isArray(o.node.properties?.className) + ) { + if (o.node.properties.className.includes('indented')) { + walkerContext.closeNode(); + } + } + break; + } + case 'blockquote': { + walkerContext.closeNode(); + walkerContext.setGlobalContext('hast:blockquote', false); + break; + } + case 'p': { + if (!o.node.properties.id) { + break; + } + if ( + o.next?.type === 'element' && + o.next.tagName === 'div' && + Array.isArray(o.next.properties?.className) && + o.next.properties.className.includes('indented') + ) { + // Close the node when leaving div indented + break; + } + walkerContext.closeNode(); + break; + } + } + }, + }, + fromBlockSnapshot: {}, + }; + +export const ParagraphBlockNotionHtmlAdapterExtension = + BlockNotionHtmlAdapterExtension(paragraphBlockNotionHtmlAdapterMatcher); diff --git a/blocksuite/affine/block-paragraph/src/adapters/plain-text.ts b/blocksuite/affine/block-paragraph/src/adapters/plain-text.ts new file mode 100644 index 0000000000..0c05db26bc --- /dev/null +++ b/blocksuite/affine/block-paragraph/src/adapters/plain-text.ts @@ -0,0 +1,28 @@ +import { ParagraphBlockSchema } from '@blocksuite/affine-model'; +import { + BlockPlainTextAdapterExtension, + type BlockPlainTextAdapterMatcher, +} from '@blocksuite/affine-shared/adapters'; +import type { DeltaInsert } from '@blocksuite/inline'; + +export const paragraphBlockPlainTextAdapterMatcher: BlockPlainTextAdapterMatcher = + { + flavour: ParagraphBlockSchema.model.flavour, + toMatch: () => false, + fromMatch: o => o.node.flavour === ParagraphBlockSchema.model.flavour, + toBlockSnapshot: {}, + fromBlockSnapshot: { + enter: (o, context) => { + const text = (o.node.props.text ?? { delta: [] }) as { + delta: DeltaInsert[]; + }; + const { deltaConverter } = context; + const buffer = deltaConverter.deltaToAST(text.delta).join(''); + context.textBuffer.content += buffer; + context.textBuffer.content += '\n'; + }, + }, + }; + +export const ParagraphBlockPlainTextAdapterExtension = + BlockPlainTextAdapterExtension(paragraphBlockPlainTextAdapterMatcher); diff --git a/blocksuite/affine/block-paragraph/src/commands/add-paragraph.ts b/blocksuite/affine/block-paragraph/src/commands/add-paragraph.ts new file mode 100644 index 0000000000..1bbf1c5a4d --- /dev/null +++ b/blocksuite/affine/block-paragraph/src/commands/add-paragraph.ts @@ -0,0 +1,55 @@ +import { focusTextModel } from '@blocksuite/affine-components/rich-text'; +import type { Command } from '@blocksuite/block-std'; + +/** + * Add a paragraph next to the current block. + */ +export const addParagraphCommand: Command< + never, + 'paragraphConvertedId', + { + blockId?: string; + } +> = (ctx, next) => { + const { std } = ctx; + const { doc, selection } = std; + doc.captureSync(); + + let blockId = ctx.blockId; + if (!blockId) { + const text = selection.find('text'); + blockId = text?.blockId; + } + if (!blockId) return; + + const model = doc.getBlock(blockId)?.model; + if (!model) return; + + let id: string; + if (model.children.length > 0) { + // before: + // aaa| + // bbb + // + // after: + // aaa + // | + // bbb + id = doc.addBlock('affine:paragraph', {}, model, 0); + } else { + const parent = doc.getParent(model); + if (!parent) return; + const index = parent.children.indexOf(model); + if (index < 0) return; + // before: + // aaa| + // + // after: + // aaa + // | + id = doc.addBlock('affine:paragraph', {}, parent, index + 1); + } + + focusTextModel(std, id); + return next({ paragraphConvertedId: id }); +}; diff --git a/blocksuite/affine/block-paragraph/src/commands/append-paragraph.ts b/blocksuite/affine/block-paragraph/src/commands/append-paragraph.ts new file mode 100644 index 0000000000..5671ffef67 --- /dev/null +++ b/blocksuite/affine/block-paragraph/src/commands/append-paragraph.ts @@ -0,0 +1,30 @@ +import { focusTextModel } from '@blocksuite/affine-components/rich-text'; +import { getLastNoteBlock } from '@blocksuite/affine-shared/utils'; +import type { Command } from '@blocksuite/block-std'; + +/** + * Append a paragraph block at the end of the whole page. + */ +export const appendParagraphCommand: Command< + never, + never, + { text?: string } +> = (ctx, next) => { + const { std, text = '' } = ctx; + const { doc } = std; + if (!doc.root) return; + + const note = getLastNoteBlock(doc); + let noteId = note?.id; + if (!noteId) { + noteId = doc.addBlock('affine:note', {}, doc.root.id); + } + const id = doc.addBlock( + 'affine:paragraph', + { text: new doc.Text(text) }, + noteId + ); + + focusTextModel(std, id, text.length); + next(); +}; diff --git a/blocksuite/affine/block-paragraph/src/commands/dedent-paragraph.ts b/blocksuite/affine/block-paragraph/src/commands/dedent-paragraph.ts new file mode 100644 index 0000000000..5154073ba6 --- /dev/null +++ b/blocksuite/affine/block-paragraph/src/commands/dedent-paragraph.ts @@ -0,0 +1,110 @@ +import type { IndentContext } from '@blocksuite/affine-shared/types'; +import { + calculateCollapsedSiblings, + matchFlavours, +} from '@blocksuite/affine-shared/utils'; +import type { Command } from '@blocksuite/block-std'; + +export const canDedentParagraphCommand: Command< + never, + 'indentContext', + Partial> +> = (ctx, next) => { + let { blockId, inlineIndex } = ctx; + const { std } = ctx; + const { selection, doc } = std; + const text = selection.find('text'); + + if (!blockId) { + /** + * Do nothing if the selection: + * - is not a text selection + * - or spans multiple blocks + */ + if (!text || (text.to && text.from.blockId !== text.to.blockId)) { + return; + } + + blockId = text.from.blockId; + inlineIndex = text.from.index; + } + if (blockId == null || inlineIndex == null) { + return; + } + + const model = doc.getBlock(blockId)?.model; + if (!model || !matchFlavours(model, ['affine:paragraph'])) { + return; + } + + const parent = doc.getParent(model); + if (doc.readonly || !parent || parent.role !== 'content') { + // Top most, can not unindent, do nothing + return; + } + + const grandParent = doc.getParent(parent); + if (!grandParent) return; + + return next({ + indentContext: { + blockId, + inlineIndex, + type: 'dedent', + flavour: 'affine:paragraph', + }, + }); +}; + +export const dedentParagraphCommand: Command<'indentContext'> = (ctx, next) => { + const { indentContext: dedentContext, std } = ctx; + const { doc, selection, range, host } = std; + + if ( + !dedentContext || + dedentContext.type !== 'dedent' || + dedentContext.flavour !== 'affine:paragraph' + ) { + console.warn( + 'you need to use `canDedentParagraph` command before running `dedentParagraph` command' + ); + return; + } + + const { blockId } = dedentContext; + + const model = doc.getBlock(blockId)?.model; + if (!model) return; + + const parent = doc.getParent(model); + if (!parent) return; + + const grandParent = doc.getParent(parent); + if (!grandParent) return; + + doc.captureSync(); + + if ( + matchFlavours(model, ['affine:paragraph']) && + model.type.startsWith('h') && + model.collapsed + ) { + const collapsedSiblings = calculateCollapsedSiblings(model); + doc.moveBlocks([model, ...collapsedSiblings], grandParent, parent, false); + } else { + const nextSiblings = doc.getNexts(model); + doc.moveBlocks(nextSiblings, model); + doc.moveBlocks([model], grandParent, parent, false); + } + + const textSelection = selection.find('text'); + if (textSelection) { + host.updateComplete + .then(() => { + range.syncTextSelectionToRange(textSelection); + }) + .catch(console.error); + } + + return next(); +}; diff --git a/blocksuite/affine/block-paragraph/src/commands/indent-paragraph.ts b/blocksuite/affine/block-paragraph/src/commands/indent-paragraph.ts new file mode 100644 index 0000000000..b21c18a26a --- /dev/null +++ b/blocksuite/affine/block-paragraph/src/commands/indent-paragraph.ts @@ -0,0 +1,153 @@ +import type { ListBlockModel } from '@blocksuite/affine-model'; +import type { IndentContext } from '@blocksuite/affine-shared/types'; +import { + calculateCollapsedSiblings, + getNearestHeadingBefore, + matchFlavours, +} from '@blocksuite/affine-shared/utils'; +import type { Command } from '@blocksuite/block-std'; + +export const canIndentParagraphCommand: Command< + never, + 'indentContext', + Partial> +> = (cxt, next) => { + let { blockId, inlineIndex } = cxt; + const { std } = cxt; + const { selection, doc } = std; + const { schema } = doc; + + if (!blockId) { + const text = selection.find('text'); + /** + * Do nothing if the selection: + * - is not a text selection + * - or spans multiple blocks + */ + if (!text || (text.to && text.from.blockId !== text.to.blockId)) { + return; + } + + blockId = text.from.blockId; + inlineIndex = text.from.index; + } + if (blockId == null || inlineIndex == null) { + return; + } + + const model = std.doc.getBlock(blockId)?.model; + if (!model || !matchFlavours(model, ['affine:paragraph'])) { + return; + } + + const previousSibling = doc.getPrev(model); + if ( + doc.readonly || + !previousSibling || + !schema.isValid(model.flavour, previousSibling.flavour) + ) { + // Bottom, can not indent, do nothing + return; + } + + return next({ + indentContext: { + blockId, + inlineIndex, + type: 'indent', + flavour: 'affine:paragraph', + }, + }); +}; + +export const indentParagraphCommand: Command<'indentContext'> = (ctx, next) => { + const { indentContext, std } = ctx; + const { doc, selection, host, range } = std; + + if ( + !indentContext || + indentContext.type !== 'indent' || + indentContext.flavour !== 'affine:paragraph' + ) { + console.warn( + 'you need to use `canIndentParagraph` command before running `indentParagraph` command' + ); + return; + } + const { blockId } = indentContext; + + const model = doc.getBlock(blockId)?.model; + if (!model) return; + + const previousSibling = doc.getPrev(model); + if (!previousSibling) return; + + doc.captureSync(); + + { + // > # 123 + // > # 456 + // + // we need to update 123 collapsed state to false when indent 456 + const nearestHeading = getNearestHeadingBefore(model); + if ( + nearestHeading && + matchFlavours(nearestHeading, ['affine:paragraph']) && + nearestHeading.collapsed + ) { + doc.updateBlock(nearestHeading, { + collapsed: false, + }); + } + } + + if ( + matchFlavours(model, ['affine:paragraph']) && + model.type.startsWith('h') && + model.collapsed + ) { + const collapsedSiblings = calculateCollapsedSiblings(model); + doc.moveBlocks([model, ...collapsedSiblings], previousSibling); + } else { + doc.moveBlocks([model], previousSibling); + } + + { + // 123 + // > # 456 + // 789 + // + // we need to update 456 collapsed state to false when indent 789 + const nearestHeading = getNearestHeadingBefore(model); + if ( + nearestHeading && + matchFlavours(nearestHeading, ['affine:paragraph']) && + nearestHeading.collapsed + ) { + doc.updateBlock(nearestHeading, { + collapsed: false, + }); + } + } + + // update collapsed state of affine list + if ( + matchFlavours(previousSibling, ['affine:list']) && + previousSibling.collapsed + ) { + doc.updateBlock(previousSibling, { + collapsed: false, + } as Partial); + } + + const textSelection = selection.find('text'); + if (textSelection) { + host.updateComplete + .then(() => { + range.syncTextSelectionToRange(textSelection); + }) + .catch(console.error); + } + + return next(); +}; diff --git a/blocksuite/affine/block-paragraph/src/commands/index.ts b/blocksuite/affine/block-paragraph/src/commands/index.ts new file mode 100644 index 0000000000..1b1071ec57 --- /dev/null +++ b/blocksuite/affine/block-paragraph/src/commands/index.ts @@ -0,0 +1,23 @@ +import type { BlockCommands } from '@blocksuite/block-std'; + +import { addParagraphCommand } from './add-paragraph.js'; +import { appendParagraphCommand } from './append-paragraph.js'; +import { + canDedentParagraphCommand, + dedentParagraphCommand, +} from './dedent-paragraph.js'; +import { + canIndentParagraphCommand, + indentParagraphCommand, +} from './indent-paragraph.js'; +import { splitParagraphCommand } from './split-paragraph.js'; + +export const commands: BlockCommands = { + appendParagraph: appendParagraphCommand, + splitParagraph: splitParagraphCommand, + addParagraph: addParagraphCommand, + canIndentParagraph: canIndentParagraphCommand, + canDedentParagraph: canDedentParagraphCommand, + indentParagraph: indentParagraphCommand, + dedentParagraph: dedentParagraphCommand, +}; diff --git a/blocksuite/affine/block-paragraph/src/commands/split-paragraph.ts b/blocksuite/affine/block-paragraph/src/commands/split-paragraph.ts new file mode 100644 index 0000000000..68b0a8aa27 --- /dev/null +++ b/blocksuite/affine/block-paragraph/src/commands/split-paragraph.ts @@ -0,0 +1,79 @@ +import { + focusTextModel, + getInlineEditorByModel, +} from '@blocksuite/affine-components/rich-text'; +import { matchFlavours } from '@blocksuite/affine-shared/utils'; +import type { Command } from '@blocksuite/block-std'; + +export const splitParagraphCommand: Command< + never, + 'paragraphConvertedId', + { + blockId?: string; + } +> = (ctx, next) => { + const { std } = ctx; + const { doc, host, selection } = std; + let blockId = ctx.blockId; + if (!blockId) { + const text = selection.find('text'); + blockId = text?.blockId; + } + if (!blockId) return; + + const model = doc.getBlock(blockId)?.model; + if (!model || !matchFlavours(model, ['affine:paragraph'])) return; + + const inlineEditor = getInlineEditorByModel(host, model); + const range = inlineEditor?.getInlineRange(); + if (!range) return; + + const splitIndex = range.index; + const splitLength = range.length; + // On press enter, it may convert symbols from yjs ContentString + // to yjs ContentFormat. Once it happens, the converted symbol will + // be deleted and not counted as model.text.yText.length. + // Example: "`a`[enter]" -> yText[, "a", ] + // In this case, we should not split the block. + if (model.text.yText.length < splitIndex + splitLength) return; + + if (model.children.length > 0 && splitIndex > 0) { + doc.captureSync(); + const right = model.text.split(splitIndex, splitLength); + const id = doc.addBlock( + model.flavour as BlockSuite.Flavour, + { + text: right, + type: model.type, + }, + model, + 0 + ); + focusTextModel(std, id); + return next({ paragraphConvertedId: id }); + } + + const parent = doc.getParent(model); + if (!parent) return; + const index = parent.children.indexOf(model); + if (index < 0) return; + doc.captureSync(); + const right = model.text.split(splitIndex, splitLength); + const id = doc.addBlock( + model.flavour, + { + text: right, + type: model.type, + }, + parent, + index + 1 + ); + const newModel = doc.getBlock(id)?.model; + if (newModel) { + doc.moveBlocks(model.children, newModel); + } else { + console.error('Failed to find the new model split from the paragraph'); + } + focusTextModel(std, id); + return next({ paragraphConvertedId: id }); +}; diff --git a/blocksuite/affine/block-paragraph/src/effects.ts b/blocksuite/affine/block-paragraph/src/effects.ts new file mode 100644 index 0000000000..015b73892d --- /dev/null +++ b/blocksuite/affine/block-paragraph/src/effects.ts @@ -0,0 +1,45 @@ +import type { IndentContext } from '@blocksuite/affine-shared/types'; + +import type { addParagraphCommand } from './commands/add-paragraph.js'; +import type { appendParagraphCommand } from './commands/append-paragraph.js'; +import type { + canDedentParagraphCommand, + dedentParagraphCommand, +} from './commands/dedent-paragraph.js'; +import type { + canIndentParagraphCommand, + indentParagraphCommand, +} from './commands/indent-paragraph.js'; +import type { splitParagraphCommand } from './commands/split-paragraph.js'; +import { effects as ParagraphHeadingIconEffects } from './heading-icon.js'; +import { ParagraphBlockComponent } from './paragraph-block.js'; +import type { ParagraphBlockService } from './paragraph-service.js'; + +export function effects() { + ParagraphHeadingIconEffects(); + customElements.define('affine-paragraph', ParagraphBlockComponent); +} + +declare global { + namespace BlockSuite { + interface BlockServices { + 'affine:paragraph': ParagraphBlockService; + } + interface Commands { + addParagraph: typeof addParagraphCommand; + appendParagraph: typeof appendParagraphCommand; + canIndentParagraph: typeof canIndentParagraphCommand; + canDedentParagraph: typeof canDedentParagraphCommand; + dedentParagraph: typeof dedentParagraphCommand; + indentParagraph: typeof indentParagraphCommand; + splitParagraph: typeof splitParagraphCommand; + } + interface CommandContext { + paragraphConvertedId?: string; + indentContext?: IndentContext; + } + } + interface HTMLElementTagNameMap { + 'affine-paragraph': ParagraphBlockComponent; + } +} diff --git a/blocksuite/affine/block-paragraph/src/heading-icon.ts b/blocksuite/affine/block-paragraph/src/heading-icon.ts new file mode 100644 index 0000000000..bc805bf16b --- /dev/null +++ b/blocksuite/affine/block-paragraph/src/heading-icon.ts @@ -0,0 +1,88 @@ +import { + Heading1Icon, + Heading2Icon, + Heading3Icon, + Heading4Icon, + Heading5Icon, + Heading6Icon, +} from '@blocksuite/affine-components/icons'; +import type { ParagraphBlockModel } from '@blocksuite/affine-model'; +import { ShadowlessElement } from '@blocksuite/block-std'; +import { WithDisposable } from '@blocksuite/global/utils'; +import { cssVarV2 } from '@toeverything/theme/v2'; +import { css, html, nothing, unsafeCSS } from 'lit'; +import { property } from 'lit/decorators.js'; + +function HeadingIcon(i: number) { + switch (i) { + case 1: + return Heading1Icon; + case 2: + return Heading2Icon; + case 3: + return Heading3Icon; + case 4: + return Heading4Icon; + case 5: + return Heading5Icon; + case 6: + return Heading6Icon; + default: + return Heading1Icon; + } +} + +export class ParagraphHeadingIcon extends WithDisposable(ShadowlessElement) { + static override styles = css` + affine-paragraph-heading-icon .heading-icon { + display: flex; + align-items: start; + margin-top: 0.3em; + position: absolute; + left: 0; + transform: translateX(-64px); + border-radius: 4px; + padding: 2px; + cursor: pointer; + opacity: 0; + transition: opacity 0.2s ease-in-out; + pointer-events: none; + + background: ${unsafeCSS(cssVarV2('button/iconButtonSolid', '#FFF'))}; + color: ${unsafeCSS(cssVarV2('icon/primary', '#7A7A7A'))}; + box-shadow: + var(--Shadow-buttonShadow-1-x, 0px) var(--Shadow-buttonShadow-1-y, 0px) + var(--Shadow-buttonShadow-1-blur, 1px) 0px + var(--Shadow-buttonShadow-1-color, rgba(0, 0, 0, 0.12)), + var(--Shadow-buttonShadow-2-x, 0px) var(--Shadow-buttonShadow-2-y, 1px) + var(--Shadow-buttonShadow-2-blur, 5px) 0px + var(--Shadow-buttonShadow-2-color, rgba(0, 0, 0, 0.12)); + } + + .with-drag-handle .heading-icon { + opacity: 1; + } + `; + + override render() { + const type = this.model.type; + if (!type.startsWith('h')) return nothing; + + const i = parseInt(type.slice(1)); + + return html`
${HeadingIcon(i)}
`; + } + + @property({ attribute: false }) + accessor model!: ParagraphBlockModel; +} + +export function effects() { + customElements.define('affine-paragraph-heading-icon', ParagraphHeadingIcon); +} + +declare global { + interface HTMLElementTagNameMap { + 'affine-paragraph-heading-icon': ParagraphHeadingIcon; + } +} diff --git a/blocksuite/affine/block-paragraph/src/index.ts b/blocksuite/affine/block-paragraph/src/index.ts new file mode 100644 index 0000000000..fe6ce306d2 --- /dev/null +++ b/blocksuite/affine/block-paragraph/src/index.ts @@ -0,0 +1,4 @@ +export * from './adapters/index.js'; +export * from './paragraph-block.js'; +export * from './paragraph-service.js'; +export * from './paragraph-spec.js'; diff --git a/blocksuite/affine/block-paragraph/src/paragraph-block.ts b/blocksuite/affine/block-paragraph/src/paragraph-block.ts new file mode 100644 index 0000000000..b6151681de --- /dev/null +++ b/blocksuite/affine/block-paragraph/src/paragraph-block.ts @@ -0,0 +1,323 @@ +import { CaptionedBlockComponent } from '@blocksuite/affine-components/caption'; +import { + DefaultInlineManagerExtension, + type RichText, +} from '@blocksuite/affine-components/rich-text'; +import { TOGGLE_BUTTON_PARENT_CLASS } from '@blocksuite/affine-components/toggle-button'; +import type { ParagraphBlockModel } from '@blocksuite/affine-model'; +import { + BLOCK_CHILDREN_CONTAINER_PADDING_LEFT, + NOTE_SELECTOR, +} from '@blocksuite/affine-shared/consts'; +import { DocModeProvider } from '@blocksuite/affine-shared/services'; +import { + calculateCollapsedSiblings, + getNearestHeadingBefore, + getViewportElement, +} from '@blocksuite/affine-shared/utils'; +import type { BlockComponent } from '@blocksuite/block-std'; +import { getInlineRangeProvider } from '@blocksuite/block-std'; +import type { InlineRangeProvider } from '@blocksuite/inline'; +import { effect, signal } from '@preact/signals-core'; +import { html, nothing, type TemplateResult } from 'lit'; +import { query, state } from 'lit/decorators.js'; +import { classMap } from 'lit/directives/class-map.js'; +import { styleMap } from 'lit/directives/style-map.js'; +import { unsafeHTML } from 'lit/directives/unsafe-html.js'; + +import type { ParagraphBlockService } from './paragraph-service.js'; +import { paragraphBlockStyles } from './styles.js'; + +export class ParagraphBlockComponent extends CaptionedBlockComponent< + ParagraphBlockModel, + ParagraphBlockService +> { + static override styles = paragraphBlockStyles; + + private _composing = signal(false); + + private _displayPlaceholder = signal(false); + + private _inlineRangeProvider: InlineRangeProvider | null = null; + + private _isInDatabase = () => { + let parent = this.parentElement; + while (parent && parent !== document.body) { + if (parent.tagName.toLowerCase() === 'affine-database') { + return true; + } + parent = parent.parentElement; + } + return false; + }; + + get attributeRenderer() { + return this.inlineManager.getRenderer(); + } + + get attributesSchema() { + return this.inlineManager.getSchema(); + } + + get collapsedSiblings() { + return calculateCollapsedSiblings(this.model); + } + + get embedChecker() { + return this.inlineManager.embedChecker; + } + + get inEdgelessText() { + return ( + this.topContenteditableElement?.tagName.toLowerCase() === + 'affine-edgeless-text' + ); + } + + get inlineEditor() { + return this._richTextElement?.inlineEditor; + } + + get inlineManager() { + return this.std.get(DefaultInlineManagerExtension.identifier); + } + + get markdownShortcutHandler() { + return this.inlineManager.markdownShortcutHandler; + } + + override get topContenteditableElement() { + if (this.std.get(DocModeProvider).getEditorMode() === 'edgeless') { + return this.closest(NOTE_SELECTOR); + } + return this.rootComponent; + } + + override connectedCallback() { + super.connectedCallback(); + this.handleEvent( + 'compositionStart', + () => { + this._composing.value = true; + }, + { flavour: true } + ); + this.handleEvent( + 'compositionEnd', + () => { + this._composing.value = false; + }, + { flavour: true } + ); + + this._inlineRangeProvider = getInlineRangeProvider(this); + + this.disposables.add( + effect(() => { + const composing = this._composing.value; + if (composing || this.doc.readonly) { + this._displayPlaceholder.value = false; + return; + } + const textSelection = this.host.selection.find('text'); + const isCollapsed = textSelection?.isCollapsed() ?? false; + if (!this.selected || !isCollapsed) { + this._displayPlaceholder.value = false; + return; + } + + this.updateComplete + .then(() => { + if ( + (this.inlineEditor?.yTextLength ?? 0) > 0 || + this._isInDatabase() + ) { + this._displayPlaceholder.value = false; + return; + } + this._displayPlaceholder.value = true; + return; + }) + .catch(console.error); + }) + ); + + this.disposables.add( + effect(() => { + const type = this.model.type$.value; + if (!type.startsWith('h') && this.model.collapsed) { + this.model.collapsed = false; + } + }) + ); + + this.disposables.add( + effect(() => { + const collapsed = this.model.collapsed$.value; + this._readonlyCollapsed = collapsed; + + // reset text selection when selected block is collapsed + if (this.model.type.startsWith('h') && collapsed) { + const collapsedSiblings = this.collapsedSiblings; + const textSelection = this.host.selection.find('text'); + const blockSelections = this.host.selection.filter('block'); + + if ( + textSelection && + collapsedSiblings.some( + sibling => sibling.id === textSelection.blockId + ) + ) { + this.host.selection.clear(['text']); + } + + if ( + blockSelections.some(selection => + collapsedSiblings.some( + sibling => sibling.id === selection.blockId + ) + ) + ) { + this.host.selection.clear(['block']); + } + } + }) + ); + + // > # 123 + // # 456 + // + // we need to update collapsed state of 123 when 456 converted to text + let beforeType = this.model.type; + this.disposables.add( + effect(() => { + const type = this.model.type$.value; + if (beforeType !== type && !type.startsWith('h')) { + const nearestHeading = getNearestHeadingBefore(this.model); + if ( + nearestHeading && + nearestHeading.type.startsWith('h') && + nearestHeading.collapsed && + !this.doc.readonly + ) { + nearestHeading.collapsed = false; + } + } + beforeType = type; + }) + ); + } + + override async getUpdateComplete() { + const result = await super.getUpdateComplete(); + await this._richTextElement?.updateComplete; + return result; + } + + override renderBlock(): TemplateResult<1> { + const { type$ } = this.model; + const collapsed = this.doc.readonly + ? this._readonlyCollapsed + : this.model.collapsed; + const collapsedSiblings = this.collapsedSiblings; + + let style = html``; + if (this.model.type.startsWith('h') && collapsed) { + style = html` + + `; + } + + const children = html`
+ ${this.renderChildren(this.model)} +
`; + + return html` + ${style} +
+
+ ${this.model.type.startsWith('h') && collapsedSiblings.length > 0 + ? html` + + { + if (this.doc.readonly) { + this._readonlyCollapsed = value; + } else { + this.doc.captureSync(); + this.doc.updateBlock(this.model, { + collapsed: value, + }); + } + }} + > + ` + : nothing} + + getViewportElement(this.host)} + > + ${this.inEdgelessText + ? nothing + : html` +
+ ${this.service.placeholderGenerator(this.model)} +
+ `} +
+ + ${children} +
+ `; + } + + @state() + private accessor _readonlyCollapsed = false; + + @query('rich-text') + private accessor _richTextElement: RichText | null = null; + + override accessor blockContainerStyles = { + margin: 'var(--affine-paragraph-margin, 10px 0)', + }; +} diff --git a/blocksuite/affine/block-paragraph/src/paragraph-drag-extension.ts b/blocksuite/affine/block-paragraph/src/paragraph-drag-extension.ts new file mode 100644 index 0000000000..518c29d1fb --- /dev/null +++ b/blocksuite/affine/block-paragraph/src/paragraph-drag-extension.ts @@ -0,0 +1,51 @@ +import { + ParagraphBlockModel, + ParagraphBlockSchema, +} from '@blocksuite/affine-model'; +import { DragHandleConfigExtension } from '@blocksuite/affine-shared/services'; +import { + calculateCollapsedSiblings, + captureEventTarget, + matchFlavours, +} from '@blocksuite/affine-shared/utils'; + +export const ParagraphDragHandleOption = DragHandleConfigExtension({ + flavour: ParagraphBlockSchema.model.flavour, + onDragStart: ({ state, startDragging, anchorBlockId, editorHost }) => { + if (!anchorBlockId) return false; + + const element = captureEventTarget(state.raw.target); + const dragByHandle = !!element?.closest('affine-drag-handle-widget'); + if (!dragByHandle) return false; + + const block = editorHost.doc.getBlock(anchorBlockId); + if (!block) return false; + const model = block.model; + if ( + matchFlavours(model, ['affine:paragraph']) && + model.type.startsWith('h') && + model.collapsed + ) { + const collapsedSiblings = calculateCollapsedSiblings(model).flatMap( + sibling => editorHost.view.getBlock(sibling.id) ?? [] + ); + const modelElement = editorHost.view.getBlock(anchorBlockId); + if (!modelElement) return false; + startDragging([modelElement, ...collapsedSiblings], state); + return true; + } + + return false; + }, + onDragEnd: ({ draggingElements }) => { + draggingElements + .filter(el => matchFlavours(el.model, ['affine:paragraph'])) + .forEach(el => { + const model = el.model; + if (!(model instanceof ParagraphBlockModel)) return; + model.collapsed = false; + }); + + return false; + }, +}); diff --git a/blocksuite/affine/block-paragraph/src/paragraph-keymap.ts b/blocksuite/affine/block-paragraph/src/paragraph-keymap.ts new file mode 100644 index 0000000000..abe9628a04 --- /dev/null +++ b/blocksuite/affine/block-paragraph/src/paragraph-keymap.ts @@ -0,0 +1,223 @@ +import { + focusTextModel, + getInlineEditorByModel, + markdownInput, + textKeymap, +} from '@blocksuite/affine-components/rich-text'; +import { ParagraphBlockSchema } from '@blocksuite/affine-model'; +import { + calculateCollapsedSiblings, + matchFlavours, +} from '@blocksuite/affine-shared/utils'; +import { KeymapExtension } from '@blocksuite/block-std'; +import { IS_MAC } from '@blocksuite/global/env'; + +import { forwardDelete } from './utils/forward-delete.js'; +import { mergeWithPrev } from './utils/merge-with-prev.js'; + +export const ParagraphKeymapExtension = KeymapExtension( + std => { + return { + Backspace: ctx => { + const text = std.selection.find('text'); + if (!text) return; + const isCollapsed = text.isCollapsed(); + const isStart = isCollapsed && text.from.index === 0; + if (!isStart) return; + + const { doc } = std; + const model = doc.getBlock(text.from.blockId)?.model; + if (!model || !matchFlavours(model, ['affine:paragraph'])) return; + + // const { model, doc } = this; + const event = ctx.get('keyboardState').raw; + event.preventDefault(); + + // When deleting at line start of a paragraph block, + // firstly switch it to normal text, then delete this empty block. + if (model.type !== 'text') { + // Try to switch to normal text + doc.captureSync(); + doc.updateBlock(model, { type: 'text' }); + return true; + } + + const merged = mergeWithPrev(std.host, model); + if (merged) { + return true; + } + + std.command.chain().canDedentParagraph().dedentParagraph().run(); + return true; + }, + 'Mod-Enter': ctx => { + const { doc } = std; + const text = std.selection.find('text'); + if (!text) return; + const model = doc.getBlock(text.from.blockId)?.model; + if (!model || !matchFlavours(model, ['affine:paragraph'])) return; + const inlineEditor = getInlineEditorByModel( + std.host, + text.from.blockId + ); + const inlineRange = inlineEditor?.getInlineRange(); + if (!inlineRange || !inlineEditor) return; + const raw = ctx.get('keyboardState').raw; + raw.preventDefault(); + if (model.type === 'quote') { + doc.captureSync(); + inlineEditor.insertText(inlineRange, '\n'); + inlineEditor.setInlineRange({ + index: inlineRange.index + 1, + length: 0, + }); + return true; + } + + std.command.exec('addParagraph'); + return true; + }, + Enter: ctx => { + const { doc } = std; + const text = std.selection.find('text'); + if (!text) return; + const model = doc.getBlock(text.from.blockId)?.model; + if (!model || !matchFlavours(model, ['affine:paragraph'])) return; + const inlineEditor = getInlineEditorByModel( + std.host, + text.from.blockId + ); + const range = inlineEditor?.getInlineRange(); + if (!range || !inlineEditor) return; + + const raw = ctx.get('keyboardState').raw; + const isEnd = model.text.length === range.index; + + if (model.type === 'quote') { + const textStr = model.text.toString(); + + /** + * If quote block ends with two blank lines, split the block + * --- + * before: + * > \n + * > \n| + * + * after: + * > \n + * | + * --- + */ + const endWithTwoBlankLines = + textStr === '\n' || textStr.endsWith('\n'); + if (isEnd && endWithTwoBlankLines) { + raw.preventDefault(); + doc.captureSync(); + model.text.delete(range.index - 1, 1); + std.command.exec('addParagraph'); + return true; + } + return true; + } + + raw.preventDefault(); + + if (markdownInput(std, model.id)) { + return true; + } + + if (model.type.startsWith('h') && model.collapsed) { + const parent = doc.getParent(model); + if (!parent) return true; + const index = parent.children.indexOf(model); + if (index === -1) return true; + const collapsedSiblings = calculateCollapsedSiblings(model); + + const rightText = model.text.split(range.index); + const newId = doc.addBlock( + model.flavour, + { type: model.type, text: rightText }, + parent, + index + collapsedSiblings.length + 1 + ); + + focusTextModel(std, newId); + + return true; + } + + if (isEnd) { + std.command.exec('addParagraph'); + return true; + } + + std.command.exec('splitParagraph'); + return true; + }, + Delete: ctx => { + const deleted = forwardDelete(std); + if (!deleted) { + return; + } + const event = ctx.get('keyboardState').raw; + event.preventDefault(); + return true; + }, + 'Control-d': ctx => { + if (!IS_MAC) return; + const deleted = forwardDelete(std); + if (!deleted) { + return; + } + const event = ctx.get('keyboardState').raw; + event.preventDefault(); + return true; + }, + Space: ctx => { + if (!markdownInput(std)) { + return; + } + ctx.get('keyboardState').raw.preventDefault(); + return true; + }, + 'Shift-Space': ctx => { + if (!markdownInput(std)) { + return; + } + ctx.get('keyboardState').raw.preventDefault(); + return true; + }, + Tab: ctx => { + const [success] = std.command + .chain() + .canIndentParagraph() + .indentParagraph() + .run(); + if (!success) { + return; + } + ctx.get('keyboardState').raw.preventDefault(); + return true; + }, + 'Shift-Tab': ctx => { + const [success] = std.command + .chain() + .canDedentParagraph() + .dedentParagraph() + .run(); + if (!success) { + return; + } + ctx.get('keyboardState').raw.preventDefault(); + return true; + }, + }; + }, + { + flavour: ParagraphBlockSchema.model.flavour, + } +); + +export const ParagraphTextKeymapExtension = KeymapExtension(textKeymap, { + flavour: ParagraphBlockSchema.model.flavour, +}); diff --git a/blocksuite/affine/block-paragraph/src/paragraph-service.ts b/blocksuite/affine/block-paragraph/src/paragraph-service.ts new file mode 100644 index 0000000000..abbedc5e5e --- /dev/null +++ b/blocksuite/affine/block-paragraph/src/paragraph-service.ts @@ -0,0 +1,26 @@ +import { + type ParagraphBlockModel, + ParagraphBlockSchema, +} from '@blocksuite/affine-model'; +import { BlockService } from '@blocksuite/block-std'; + +export class ParagraphBlockService extends BlockService { + static override readonly flavour = ParagraphBlockSchema.model.flavour; + + placeholderGenerator: (model: ParagraphBlockModel) => string = model => { + if (model.type === 'text') { + return "Type '/' for commands"; + } + + const placeholders = { + h1: 'Heading 1', + h2: 'Heading 2', + h3: 'Heading 3', + h4: 'Heading 4', + h5: 'Heading 5', + h6: 'Heading 6', + quote: '', + }; + return placeholders[model.type]; + }; +} diff --git a/blocksuite/affine/block-paragraph/src/paragraph-spec.ts b/blocksuite/affine/block-paragraph/src/paragraph-spec.ts new file mode 100644 index 0000000000..046e857af3 --- /dev/null +++ b/blocksuite/affine/block-paragraph/src/paragraph-spec.ts @@ -0,0 +1,25 @@ +import { + BlockViewExtension, + CommandExtension, + type ExtensionType, + FlavourExtension, +} from '@blocksuite/block-std'; +import { literal } from 'lit/static-html.js'; + +import { commands } from './commands/index.js'; +import { ParagraphDragHandleOption } from './paragraph-drag-extension.js'; +import { + ParagraphKeymapExtension, + ParagraphTextKeymapExtension, +} from './paragraph-keymap.js'; +import { ParagraphBlockService } from './paragraph-service.js'; + +export const ParagraphBlockSpec: ExtensionType[] = [ + FlavourExtension('affine:paragraph'), + ParagraphBlockService, + CommandExtension(commands), + BlockViewExtension('affine:paragraph', literal`affine-paragraph`), + ParagraphTextKeymapExtension, + ParagraphKeymapExtension, + ParagraphDragHandleOption, +]; diff --git a/blocksuite/affine/block-paragraph/src/styles.ts b/blocksuite/affine/block-paragraph/src/styles.ts new file mode 100644 index 0000000000..57691ae110 --- /dev/null +++ b/blocksuite/affine/block-paragraph/src/styles.ts @@ -0,0 +1,148 @@ +import { css } from 'lit'; + +export const paragraphBlockStyles = css` + affine-paragraph { + box-sizing: border-box; + display: block; + font-size: var(--affine-font-base); + } + + .affine-paragraph-block-container { + position: relative; + border-radius: 4px; + } + .affine-paragraph-rich-text-wrapper { + position: relative; + } + + affine-paragraph code { + font-size: calc(var(--affine-font-base) - 3px); + padding: 0px 4px 2px; + } + + .h1 { + font-size: var(--affine-font-h-1); + font-weight: 700; + letter-spacing: -0.02em; + line-height: calc(1em + 8px); + margin-top: 18px; + margin-bottom: 10px; + } + + .h1 code { + font-size: calc(var(--affine-font-base) + 10px); + padding: 0px 4px; + } + + .h2 { + font-size: var(--affine-font-h-2); + font-weight: 600; + letter-spacing: -0.02em; + line-height: calc(1em + 10px); + margin-top: 14px; + margin-bottom: 10px; + } + + .h2 code { + font-size: calc(var(--affine-font-base) + 8px); + padding: 0px 4px; + } + + .h3 { + font-size: var(--affine-font-h-3); + font-weight: 600; + letter-spacing: -0.02em; + line-height: calc(1em + 8px); + margin-top: 12px; + margin-bottom: 10px; + } + + .h3 code { + font-size: calc(var(--affine-font-base) + 6px); + padding: 0px 4px; + } + + .h4 { + font-size: var(--affine-font-h-4); + font-weight: 600; + letter-spacing: -0.015em; + line-height: calc(1em + 8px); + margin-top: 12px; + margin-bottom: 10px; + } + .h4 code { + font-size: calc(var(--affine-font-base) + 4px); + padding: 0px 4px; + } + + .h5 { + font-size: var(--affine-font-h-5); + font-weight: 600; + letter-spacing: -0.015em; + line-height: calc(1em + 8px); + margin-top: 12px; + margin-bottom: 10px; + } + .h5 code { + font-size: calc(var(--affine-font-base) + 2px); + padding: 0px 4px; + } + + .h6 { + font-size: var(--affine-font-h-6); + font-weight: 600; + letter-spacing: -0.015em; + line-height: calc(1em + 8px); + margin-top: 12px; + margin-bottom: 10px; + } + + .h6 code { + font-size: var(--affine-font-base); + padding: 0px 4px 2px; + } + + .quote { + line-height: 26px; + padding-left: 17px; + margin-top: var(--affine-paragraph-space); + padding-top: 10px; + padding-bottom: 10px; + position: relative; + } + .quote::after { + content: ''; + width: 2px; + height: calc(100% - 20px); + margin-top: 10px; + margin-bottom: 10px; + position: absolute; + left: 0; + top: 0; + background: var(--affine-quote-color); + border-radius: 18px; + } + + .affine-paragraph-placeholder { + position: absolute; + display: none; + left: 0; + bottom: 0; + pointer-events: none; + color: var(--affine-black-30); + fill: var(--affine-black-30); + } + @media print { + .affine-paragraph-placeholder { + display: none !important; + } + } + .affine-paragraph-placeholder.visible { + display: block; + } + @media print { + .affine-paragraph-placeholder.visible { + display: none; + } + } +`; diff --git a/blocksuite/affine/block-paragraph/src/utils/forward-delete.ts b/blocksuite/affine/block-paragraph/src/utils/forward-delete.ts new file mode 100644 index 0000000000..2408b4b5b5 --- /dev/null +++ b/blocksuite/affine/block-paragraph/src/utils/forward-delete.ts @@ -0,0 +1,69 @@ +import { EMBED_BLOCK_FLAVOUR_LIST } from '@blocksuite/affine-shared/consts'; +import { + getNextContentBlock, + matchFlavours, +} from '@blocksuite/affine-shared/utils'; +import type { BlockStdScope } from '@blocksuite/block-std'; + +export function forwardDelete(std: BlockStdScope) { + const { doc, host } = std; + const text = std.selection.find('text'); + if (!text) return; + const isCollapsed = text.isCollapsed(); + const model = doc.getBlock(text.from.blockId)?.model; + if (!model || !matchFlavours(model, ['affine:paragraph'])) return; + const isEnd = isCollapsed && text.from.index === model.text.length; + if (!isEnd) return; + const parent = doc.getParent(model); + if (!parent) return; + + const nextSibling = doc.getNext(model); + const ignoreForwardDeleteFlavourList: BlockSuite.Flavour[] = [ + 'affine:attachment', + 'affine:bookmark', + // @ts-expect-error TODO: should be fixed after database model is migrated to affine-models + 'affine:database', + 'affine:code', + 'affine:image', + 'affine:divider', + ...EMBED_BLOCK_FLAVOUR_LIST, + ]; + + if (matchFlavours(nextSibling, ignoreForwardDeleteFlavourList)) { + std.selection.setGroup('note', [ + std.selection.create('block', { blockId: nextSibling.id }), + ]); + return true; + } + + if (nextSibling?.text) { + model.text.join(nextSibling.text); + if (nextSibling.children) { + const parent = doc.getParent(nextSibling); + if (!parent) return false; + doc.moveBlocks(nextSibling.children, parent, model, false); + } + + doc.deleteBlock(nextSibling); + return true; + } + + const nextBlock = getNextContentBlock(host, model); + if (nextBlock?.text) { + model.text.join(nextBlock.text); + if (nextBlock.children) { + const parent = doc.getParent(nextBlock); + if (!parent) return false; + doc.moveBlocks(nextBlock.children, parent, doc.getParent(model), false); + } + doc.deleteBlock(nextBlock); + return true; + } + + if (nextBlock) { + std.selection.setGroup('note', [ + std.selection.create('block', { blockId: nextBlock.id }), + ]); + } + return true; +} diff --git a/blocksuite/affine/block-paragraph/src/utils/merge-with-prev.ts b/blocksuite/affine/block-paragraph/src/utils/merge-with-prev.ts new file mode 100644 index 0000000000..db6480209e --- /dev/null +++ b/blocksuite/affine/block-paragraph/src/utils/merge-with-prev.ts @@ -0,0 +1,141 @@ +import { + asyncSetInlineRange, + focusTextModel, +} from '@blocksuite/affine-components/rich-text'; +import type { RootBlockModel } from '@blocksuite/affine-model'; +import { EMBED_BLOCK_FLAVOUR_LIST } from '@blocksuite/affine-shared/consts'; +import type { ExtendedModel } from '@blocksuite/affine-shared/types'; +import { + focusTitle, + getDocTitleInlineEditor, + getPrevContentBlock, + matchFlavours, +} from '@blocksuite/affine-shared/utils'; +import type { EditorHost } from '@blocksuite/block-std'; +import type { BlockModel, Text } from '@blocksuite/store'; + +/** + * Merge the paragraph with prev block + * + * Before press backspace + * - line1 + * - line2 + * - |aaa + * - line3 + * + * After press backspace + * - line1 + * - line2|aaa + * - line3 + */ +export function mergeWithPrev(editorHost: EditorHost, model: BlockModel) { + const doc = model.doc; + const parent = doc.getParent(model); + if (!parent) return false; + + if (matchFlavours(parent, ['affine:edgeless-text'])) { + return true; + } + + const prevBlock = getPrevContentBlock(editorHost, model); + if (!prevBlock) { + return handleNoPreviousSibling(editorHost, model); + } + + if (matchFlavours(prevBlock, ['affine:paragraph', 'affine:list'])) { + const modelIndex = parent.children.indexOf(model); + if ( + (modelIndex === -1 || modelIndex === parent.children.length - 1) && + parent.role === 'content' + ) + return false; + + const lengthBeforeJoin = prevBlock.text?.length ?? 0; + prevBlock.text.join(model.text as Text); + doc.deleteBlock(model, { + bringChildrenTo: parent, + }); + asyncSetInlineRange(editorHost, prevBlock, { + index: lengthBeforeJoin, + length: 0, + }).catch(console.error); + return true; + } + + if ( + matchFlavours(prevBlock, [ + 'affine:attachment', + 'affine:bookmark', + 'affine:code', + 'affine:image', + 'affine:divider', + ...EMBED_BLOCK_FLAVOUR_LIST, + ]) + ) { + const selection = editorHost.selection.create('block', { + blockId: prevBlock.id, + }); + editorHost.selection.setGroup('note', [selection]); + + if (model.text?.length === 0) { + doc.deleteBlock(model, { + bringChildrenTo: parent, + }); + } + + return true; + } + + // @ts-expect-error TODO: should be fixed after database model is migrated to affine-models + if (matchFlavours(parent, ['affine:database'])) { + doc.deleteBlock(model); + focusTextModel(editorHost.std, prevBlock.id, prevBlock.text?.yText.length); + return true; + } + + return false; +} + +function handleNoPreviousSibling(editorHost: EditorHost, model: ExtendedModel) { + const doc = model.doc; + const text = model.text; + const parent = doc.getParent(model); + if (!parent) return false; + const titleEditor = getDocTitleInlineEditor(editorHost); + // Probably no title, e.g. in edgeless mode + if (!titleEditor) { + if ( + matchFlavours(parent, ['affine:edgeless-text']) || + model.children.length > 0 + ) { + doc.deleteBlock(model, { + bringChildrenTo: parent, + }); + return true; + } + return false; + } + + const rootModel = model.doc.root as RootBlockModel; + const title = rootModel.title; + + doc.captureSync(); + let textLength = 0; + if (text) { + textLength = text.length; + title.join(text); + } + + // Preserve at least one block to be able to focus on container click + if (doc.getNext(model) || model.children.length > 0) { + const parent = doc.getParent(model); + if (!parent) return false; + doc.deleteBlock(model, { + bringChildrenTo: parent, + }); + } else { + text?.clear(); + } + focusTitle(editorHost, title.length - textLength); + return true; +} diff --git a/blocksuite/affine/block-paragraph/tsconfig.json b/blocksuite/affine/block-paragraph/tsconfig.json new file mode 100644 index 0000000000..c1a5453aa5 --- /dev/null +++ b/blocksuite/affine/block-paragraph/tsconfig.json @@ -0,0 +1,32 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "rootDir": "./src/", + "outDir": "./dist/", + "noEmit": false + }, + "include": ["./src"], + "references": [ + { + "path": "../../framework/global" + }, + { + "path": "../../framework/store" + }, + { + "path": "../../framework/block-std" + }, + { + "path": "../../framework/inline" + }, + { + "path": "../model" + }, + { + "path": "../components" + }, + { + "path": "../shared" + } + ] +} diff --git a/blocksuite/affine/block-paragraph/typedoc.json b/blocksuite/affine/block-paragraph/typedoc.json new file mode 100644 index 0000000000..101e923dba --- /dev/null +++ b/blocksuite/affine/block-paragraph/typedoc.json @@ -0,0 +1,4 @@ +{ + "extends": ["../../../typedoc.base.json"], + "entryPoints": ["src/index.ts"] +} diff --git a/blocksuite/affine/block-paragraph/vitest.config.ts b/blocksuite/affine/block-paragraph/vitest.config.ts new file mode 100644 index 0000000000..fb99961c00 --- /dev/null +++ b/blocksuite/affine/block-paragraph/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/affine-block-paragraph', + }, + /** + * 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/blocksuite/affine/block-surface/package.json b/blocksuite/affine/block-surface/package.json new file mode 100644 index 0000000000..7ef1baabb2 --- /dev/null +++ b/blocksuite/affine/block-surface/package.json @@ -0,0 +1,46 @@ +{ + "name": "@blocksuite/affine-block-surface", + "description": "Surface block for BlockSuite.", + "type": "module", + "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": "MIT", + "dependencies": { + "@blocksuite/affine-components": "workspace:*", + "@blocksuite/affine-model": "workspace:*", + "@blocksuite/affine-shared": "workspace:*", + "@blocksuite/block-std": "workspace:*", + "@blocksuite/global": "workspace:*", + "@blocksuite/inline": "workspace:*", + "@blocksuite/store": "workspace:*", + "@lit/context": "^1.1.2", + "@preact/signals-core": "^1.8.0", + "@toeverything/theme": "^1.1.1", + "fractional-indexing": "^3.2.0", + "lit": "^3.2.0", + "lodash.chunk": "^4.2.0", + "nanoid": "^5.0.7", + "zod": "^3.23.8" + }, + "devDependencies": { + "@types/dompurify": "^3.0.5", + "@types/lodash.chunk": "^4.2.9" + }, + "exports": { + ".": "./src/index.ts", + "./effects": "./src/effects.ts" + }, + "files": [ + "src", + "dist", + "!src/__tests__", + "!dist/__tests__" + ] +} diff --git a/blocksuite/affine/block-surface/src/__tests__/a-star.unit.spec.ts b/blocksuite/affine/block-surface/src/__tests__/a-star.unit.spec.ts new file mode 100644 index 0000000000..276b2f599d --- /dev/null +++ b/blocksuite/affine/block-surface/src/__tests__/a-star.unit.spec.ts @@ -0,0 +1,114 @@ +import type { IVec, IVec3 } from '@blocksuite/global/utils'; +import { almostEqual } from '@blocksuite/global/utils'; +import { describe, expect, it } from 'vitest'; + +import { AStarRunner } from '../utils/a-star.js'; + +function mergePath(points: IVec3[]) { + if (points.length === 0) return points; + const rst: IVec3[] = [points[0]]; + for (let i = 1; i < points.length - 1; i++) { + const cur = points[i]; + const last = points[i - 1]; + const next = points[i + 1]; + if ( + almostEqual(last[0], cur[0], 0.02) && + almostEqual(cur[0], next[0], 0.02) + ) + continue; + if ( + almostEqual(last[1], cur[1], 0.02) && + almostEqual(cur[1], next[1], 0.02) + ) + continue; + rst.push(cur); + } + rst.push(points[points.length - 1]); + return rst; +} + +describe('a* algorithm', () => { + /** + * 0 ---------------- + * | + * | + * ------------------- 0 + */ + it('width is greater than height', () => { + const sp: IVec3 = [0, 0, 0]; + const ep: IVec3 = [200, 100, 0]; + const osp: IVec3 = [-1, 0, 0]; + const oep: IVec3 = [201, 100, 0]; + const points: IVec3[] = [ + sp, + ep, + [100, 0, 0], + [200, 0, 0], + [0, 50, 0], + [100, 50, 3], + [200, 50, 0], + [0, 100, 0], + [100, 100, 0], + ] as IVec3[]; + const aStar = new AStarRunner(points, sp, ep, osp, oep); + aStar.run(); + let path: IVec[] | IVec3[] = aStar.path; + path.pop(); + path.shift(); + path = mergePath(path); + const expected = [ + [0, 0], + [100, 0], + [100, 100], + [200, 100], + ]; + path.forEach((p, i) => { + expect(p[0]).toBe(expected[i][0]); + expect(p[1]).toBe(expected[i][1]); + }); + }); + /** + * 0 + * | + * | + * | + * |----| + * | + * | + * | + * 0 + */ + it('height is greater than width', () => { + const sp: IVec3 = [0, 0, 0]; + const ep: IVec3 = [100, 200, 0]; + const osp: IVec3 = [0, -1, 0]; + const oep: IVec3 = [100, 201, 0]; + const points: IVec3[] = [ + sp, + [50, 0, 0], + [100, 0, 0], + [0, 100, 0], + [50, 100, 3], + [100, 100, 0], + [0, 200, 0], + [50, 200, 0], + ep, + ]; + const aStar = new AStarRunner(points, sp, ep, osp, oep); + aStar.run(); + let path = aStar.path; + path.pop(); + path.shift(); + path = mergePath(path); + const expected = [ + [0, 0], + [0, 100], + [100, 100], + [100, 200], + ]; + path.forEach((p, i) => { + expect(p[0]).toBe(expected[i][0]); + expect(p[1]).toBe(expected[i][1]); + }); + }); +}); diff --git a/blocksuite/affine/block-surface/src/__tests__/bound.unit.spec.ts b/blocksuite/affine/block-surface/src/__tests__/bound.unit.spec.ts new file mode 100644 index 0000000000..5d4fa4fdfc --- /dev/null +++ b/blocksuite/affine/block-surface/src/__tests__/bound.unit.spec.ts @@ -0,0 +1,180 @@ +import { + Bound, + getCommonBound, + inflateBound, + transformPointsToNewBound, +} from '@blocksuite/global/utils'; +import { describe, expect, it } from 'vitest'; + +describe('bound utils', () => { + it('Bound basic', () => { + const bound = new Bound(1, 1, 2, 2); + const serialized = bound.serialize(); + expect(serialized).toBe('[1,1,2,2]'); + expect(Bound.deserialize(serialized)).toMatchObject(bound); + expect(bound.center).toMatchObject([2, 2]); + expect(bound.minX).toBe(1); + expect(bound.minY).toBe(1); + expect(bound.maxX).toBe(3); + expect(bound.maxY).toBe(3); + expect(bound.tl).toMatchObject([1, 1]); + expect(bound.tr).toMatchObject([3, 1]); + expect(bound.bl).toMatchObject([1, 3]); + expect(bound.br).toMatchObject([3, 3]); + }); + + it('from', () => { + const b1 = new Bound(1, 1, 2, 2); + const b2 = Bound.from(b1); + expect(b1).toMatchObject(b2); + }); + + it('getCommonBound basic', () => { + const bounds = Array.from({ length: 10 }) + .fill(0) + .map((_, index) => { + return { + x: index, + y: index, + w: 1, + h: 1, + }; + }); + expect(getCommonBound(bounds)).toMatchObject({ + x: 0, + y: 0, + w: 10, + h: 10, + }); + }); + + it('getCommonBound parameters length equal to 0', () => { + expect(getCommonBound([])).toBeNull(); + }); + + it('getCommonBound parameters length less than 2', () => { + const b1 = { + x: 0, + y: 0, + w: 1, + h: 1, + }; + expect(getCommonBound([b1])).toMatchObject(b1); + }); + + it('inflateBound', () => { + const a = new Bound(0, 0, 10, 10); + const b = inflateBound(a, 4); + expect(b.serialize()).toBe('[-2,-2,14,14]'); + + expect(() => inflateBound(a, -12)).toThrowError( + 'Invalid delta range or bound size.' + ); + }); + + it('transformPointsToNewBound basic', () => { + const a = new Bound(0, 0, 20, 20); + const b = new Bound(4, 4, 18, 18); + const marginA = 4; + const marginB = 6; + const points = [{ x: 6, y: 6, other: 10 }]; + const transformed = transformPointsToNewBound( + points, + a, + marginA, + b, + marginB + ); + + expect(transformed.bound.serialize()).toBe(b.serialize()); + expect(transformed.points[0]).toMatchObject({ + x: 7, + y: 7, + other: 10, + }); + }); + + it('transformPointsToNewBound, new bound too small', () => { + const a = new Bound(0, 0, 20, 20); + const b = new Bound(4, 4, 4, 4); + const marginA = 4; + const marginB = 6; + const points = [{ x: 6, y: 6, other: 10 }]; + const transformed = transformPointsToNewBound( + points, + a, + marginA, + b, + marginB + ); + + expect(transformed.bound.serialize()).toBe('[4,4,13,13]'); + expect(transformed.points[0].x).toBeCloseTo(6.1667); + expect(transformed.points[0].y).toBeCloseTo(6.1667); + expect(transformed.points[0].other).toBe(10); + }); + + it('intersectLine', () => { + const bound = new Bound(0, 0, 10, 10); + expect(bound.intersectLine([0, 0], [10, 10])).toBeTruthy(); + }); + + it('intersectline no intersection', () => { + const bound = new Bound(0, 0, 10, 10); + expect(bound.intersectLine([0, -1], [10, -10])).toBeFalsy(); + }); + + it('isIntersectWithBound', () => { + const a = new Bound(0, 0, 10, 10); + const b = new Bound(5, 5, 10, 10); + expect(a.isIntersectWithBound(b)).toBeTruthy(); + }); + + it('isIntersectWithBound no intersection', () => { + const a = new Bound(0, 0, 10, 10); + const b = new Bound(11, 11, 10, 10); + expect(a.isIntersectWithBound(b)).toBeFalsy(); + }); + + it('unite', () => { + const a = new Bound(0, 0, 10, 10); + const b = new Bound(5, 5, 10, 10); + expect(a.unite(b).serialize()).toBe('[0,0,15,15]'); + }); + + it('isHorizontalCross', () => { + const a = new Bound(0, 0, 10, 10); + const b = new Bound(5, 5, 10, 10); + expect(a.isHorizontalCross(b)).toBeTruthy(); + }); + + it('isHorizontalCross no intersection', () => { + const a = new Bound(0, 0, 10, 10); + const b = new Bound(11, 11, 10, 10); + expect(a.isHorizontalCross(b)).toBeFalsy(); + }); + + it('isVerticalCross', () => { + const a = new Bound(0, 0, 10, 10); + const b = new Bound(5, 5, 10, 10); + expect(a.isVerticalCross(b)).toBeTruthy(); + }); + + it('isVerticalCross no intersection', () => { + const a = new Bound(0, 0, 10, 10); + const b = new Bound(11, 11, 10, 10); + expect(a.isVerticalCross(b)).toBeFalsy(); + }); + + it('horizontalDistance', () => { + const a = new Bound(0, 0, 10, 10); + const b = new Bound(15, 15, 10, 10); + expect(a.horizontalDistance(b)).toBe(5); + }); + + it('verticalDistance', () => { + const a = new Bound(0, 0, 10, 10); + const b = new Bound(15, 15, 10, 10); + expect(a.verticalDistance(b)).toBe(5); + }); +}); diff --git a/blocksuite/affine/block-surface/src/__tests__/graph.unit.spec.ts b/blocksuite/affine/block-surface/src/__tests__/graph.unit.spec.ts new file mode 100644 index 0000000000..fc3c4117ce --- /dev/null +++ b/blocksuite/affine/block-surface/src/__tests__/graph.unit.spec.ts @@ -0,0 +1,23 @@ +import { Bound } from '@blocksuite/global/utils'; +import { describe, expect, it } from 'vitest'; + +import { Graph } from '../utils/graph.js'; + +describe('graph', () => { + it('neighbors', () => { + const bound = new Bound(-5, 5, 10, 10); + const graph = new Graph( + [ + [0, 0], + [100, 0], + [0, 100], + [100, 100], + ], + [bound] + ); + const neighbors = graph.neighbors([0, 0]); + expect(neighbors.length).toBe(1); + expect(neighbors[0][0]).toBe(100); + expect(neighbors[0][1]).toBe(0); + }); +}); diff --git a/blocksuite/affine/block-surface/src/__tests__/math-utils.unit.spec.ts b/blocksuite/affine/block-surface/src/__tests__/math-utils.unit.spec.ts new file mode 100644 index 0000000000..e5f8e0b790 --- /dev/null +++ b/blocksuite/affine/block-surface/src/__tests__/math-utils.unit.spec.ts @@ -0,0 +1,158 @@ +import { + almostEqual, + assertExists, + isPointOnLineSegment, + type IVec, + lineEllipseIntersects, + lineIntersects, + linePolygonIntersects, + linePolylineIntersects, + pointAlmostEqual, + polygonGetPointTangent, + rotatePoints, + toDegree, + toRadian, +} from '@blocksuite/global/utils'; +import { describe, expect, it } from 'vitest'; + +describe('Line', () => { + it('should intersect', () => { + let rst = lineIntersects([0, 0], [1, 1], [0, 1], [1, 0]); + expect(rst).toBeDefined(); + expect(rst).toMatchObject([0.5, 0.5]); + + rst = lineIntersects([5, 5], [15, 5], [10, 0], [10, 10]); + expect(rst).toBeDefined(); + expect(rst).toMatchObject([10, 5]); + }); + + it('should not intersect', () => { + const rst = lineIntersects([0, 0], [1, 0], [0, 1], [1, 1]); + expect(rst).toBeNull(); + }); + + it('should intersect when infinity', () => { + const rst = lineIntersects([0, 0], [0, 10], [1, 1], [10, 1], true); + expect(rst).toBeDefined(); + expect(rst).toMatchObject([0, 1]); + }); + + it('lineEllipseIntersects', () => { + const rst = lineEllipseIntersects([0, -5], [0, 5], [0, 0], 1, 1); + const expected: IVec[] = [ + [0, 1], + [0, -1], + ]; + assertExists(rst); + expect( + rst.every((point, index) => pointAlmostEqual(point, expected[index])) + ).toBeTruthy(); + }); + + it('lineEllipseIntersects with rotate', () => { + const rst = lineEllipseIntersects( + [0, -5], + [0, 5], + [0, 0], + 3, + 2, + Math.PI / 2 + ); + expect(rst).toBeDefined(); + if (rst) { + pointAlmostEqual(rst[0], [0, 3]); + pointAlmostEqual(rst[1], [0, -3]); + } + }); + + it('linePolygonIntersects', () => { + const rst = linePolygonIntersects( + [5, 5], + [15, 5], + [ + [0, 0], + [10, 0], + [10, 10], + [0, 10], + ] + ); + assertExists(rst); + expect(pointAlmostEqual(rst[0], [10, 5])).toBeTruthy(); + }); + + it('linePolylineIntersects', () => { + const rst = linePolylineIntersects( + [5, 5], + [-5, 5], + [ + [0, 0], + [10, 0], + [10, 10], + [0, 10], + ] + ); + + expect(rst).toBeNull(); + }); + + it('isPointOnLineSegment', () => { + const line: IVec[] = [ + [0, 0], + [1, 0], + ]; + const point: IVec = [0.5, 0]; + expect(isPointOnLineSegment(point, line)).toBe(true); + expect(isPointOnLineSegment([0.01, 0], line)).toBe(true); + expect(isPointOnLineSegment([-0.01, 0], line)).toBe(false); + expect(isPointOnLineSegment([0.5, 0.1], line)).toBe(false); + expect(isPointOnLineSegment([0.5, -0.1], line)).toBe(false); + }); + + it('rotatePoints', () => { + const points: IVec[] = [ + [0, 0], + [1, 0], + [1, 1], + [0, 1], + ]; + const rst = rotatePoints(points, [0.5, 0.5], 90); + const expected = [ + [1, 0], + [1, 1], + [0, 1], + [0, 0], + ]; + expect( + rst.every((p, i) => { + return ( + almostEqual(p[0], expected[i][0]) && almostEqual(p[1], expected[i][1]) + ); + }) + ).toBeTruthy(); + }); + + it('polygonGetPointTangent', () => { + const points: IVec[] = [ + [0, 0], + [1, 0], + [1, 1], + [0, 1], + ]; + expect(polygonGetPointTangent(points, [0, 0.5])).toMatchObject([0, -1]); + expect(polygonGetPointTangent(points, [0.5, 0])).toMatchObject([1, 0]); + }); + + it('toRadian', () => { + expect(toRadian(180)).toBe(Math.PI); + expect(toRadian(90)).toBe(Math.PI / 2); + expect(toRadian(0)).toBe(0); + expect(toRadian(360)).toBe(Math.PI * 2); + }); + + it('toDegree', () => { + expect(toDegree(Math.PI)).toBe(180); + expect(toDegree(Math.PI / 2)).toBe(90); + expect(toDegree(0)).toBe(0); + expect(toDegree(Math.PI * 2)).toBe(360); + }); +}); diff --git a/blocksuite/affine/block-surface/src/__tests__/priority-queue.unit.spec.ts b/blocksuite/affine/block-surface/src/__tests__/priority-queue.unit.spec.ts new file mode 100644 index 0000000000..83a3d364c6 --- /dev/null +++ b/blocksuite/affine/block-surface/src/__tests__/priority-queue.unit.spec.ts @@ -0,0 +1,22 @@ +import { describe, expect, it } from 'vitest'; + +import { PriorityQueue } from '../utils/priority-queue.js'; + +describe('priority queue', () => { + it('should dequeue the smallest item', () => { + const pq = new PriorityQueue((a, b) => a - b); + pq.enqueue('d', 4); + pq.enqueue('c', 3); + expect(pq.dequeue()).toBe('c'); + + pq.enqueue('b', 2); + pq.enqueue('a', 1); + expect(pq.dequeue()).toBe('a'); + expect(pq.dequeue()).toBe('b'); + + pq.enqueue('e', 5); + expect(pq.dequeue()).toBe('d'); + expect(pq.dequeue()).toBe('e'); + expect(pq.dequeue()).toBe(null); + }); +}); diff --git a/blocksuite/affine/block-surface/src/__tests__/sort.unit.spec.ts b/blocksuite/affine/block-surface/src/__tests__/sort.unit.spec.ts new file mode 100644 index 0000000000..58a26161f8 --- /dev/null +++ b/blocksuite/affine/block-surface/src/__tests__/sort.unit.spec.ts @@ -0,0 +1,99 @@ +import { describe, expect, it } from 'vitest'; + +import { loadingSort } from '../utils/sort.js'; + +describe('loadingSort', () => { + it('should sort correctly', () => { + const elements = [ + { + id: '1', + deps: ['2', '3'], + }, + { + id: '2', + deps: ['4', '5'], + }, + { + id: '3', + deps: ['a'], + }, + { + id: '4', + deps: ['b', '5'], + }, + { + id: '5', + deps: [], + }, + ]; + + const sorted = loadingSort(elements); + + expect(sorted.map(val => val.id)).toEqual(['3', '5', '4', '2', '1']); + }); + + it('should sort correctly when no deps', () => { + const elements = [ + { + id: '1', + deps: [], + }, + { + id: '2', + deps: [], + }, + { + id: '3', + deps: [], + }, + ]; + + const sorted = loadingSort(elements); + + expect(sorted.map(val => val.id)).toEqual(['1', '2', '3']); + }); + + it('should sort correctly elements deps same element', () => { + const elements = [ + { + id: '1', + deps: ['2', '3'], + }, + { + id: '2', + deps: ['4', '5'], + }, + { + id: '3', + deps: ['6', '7'], + }, + { + id: '4', + deps: ['b', '5'], + }, + { + id: '5', + deps: ['7'], + }, + { + id: '6', + deps: [], + }, + { + id: '7', + deps: [], + }, + ]; + + const sorted = loadingSort(elements); + expect(sorted.map(val => val.id)).toEqual([ + '6', + '7', + '3', + '5', + '4', + '2', + '1', + ]); + }); +}); diff --git a/blocksuite/affine/block-surface/src/adapters/extension.ts b/blocksuite/affine/block-surface/src/adapters/extension.ts new file mode 100644 index 0000000000..daa6faec96 --- /dev/null +++ b/blocksuite/affine/block-surface/src/adapters/extension.ts @@ -0,0 +1,20 @@ +import { SurfaceBlockHtmlAdapterExtension } from './html-adapter/html.js'; +import { + EdgelessSurfaceBlockMarkdownAdapterExtension, + SurfaceBlockMarkdownAdapterExtension, +} from './markdown/markdown.js'; +import { + EdgelessSurfaceBlockPlainTextAdapterExtension, + SurfaceBlockPlainTextAdapterExtension, +} from './plain-text/plain-text.js'; + +export const SurfaceBlockAdapterExtensions = [ + SurfaceBlockPlainTextAdapterExtension, + SurfaceBlockMarkdownAdapterExtension, + SurfaceBlockHtmlAdapterExtension, +]; + +export const EdgelessSurfaceBlockAdapterExtensions = [ + EdgelessSurfaceBlockPlainTextAdapterExtension, + EdgelessSurfaceBlockMarkdownAdapterExtension, +]; diff --git a/blocksuite/affine/block-surface/src/adapters/html-adapter/html.ts b/blocksuite/affine/block-surface/src/adapters/html-adapter/html.ts new file mode 100644 index 0000000000..95f38441ab --- /dev/null +++ b/blocksuite/affine/block-surface/src/adapters/html-adapter/html.ts @@ -0,0 +1,20 @@ +import { + BlockHtmlAdapterExtension, + type BlockHtmlAdapterMatcher, +} from '@blocksuite/affine-shared/adapters'; + +export const surfaceBlockHtmlAdapterMatcher: BlockHtmlAdapterMatcher = { + flavour: 'affine:surface', + toMatch: () => false, + fromMatch: o => o.node.flavour === 'affine:surface', + toBlockSnapshot: {}, + fromBlockSnapshot: { + enter: (_, context) => { + context.walkerContext.skipAllChildren(); + }, + }, +}; + +export const SurfaceBlockHtmlAdapterExtension = BlockHtmlAdapterExtension( + surfaceBlockHtmlAdapterMatcher +); diff --git a/blocksuite/affine/block-surface/src/adapters/markdown/element-adapter/elements/brush.ts b/blocksuite/affine/block-surface/src/adapters/markdown/element-adapter/elements/brush.ts new file mode 100644 index 0000000000..842ab54765 --- /dev/null +++ b/blocksuite/affine/block-surface/src/adapters/markdown/element-adapter/elements/brush.ts @@ -0,0 +1,19 @@ +import type { ElementModelToMarkdownAdapterMatcher } from '../type.js'; + +export const brushToMarkdownAdapterMatcher: ElementModelToMarkdownAdapterMatcher = + { + name: 'brush', + match: elementModel => elementModel.type === 'brush', + toAST: () => { + const content = `Brush Stroke`; + return { + type: 'paragraph', + children: [ + { + type: 'text', + value: content, + }, + ], + }; + }, + }; diff --git a/blocksuite/affine/block-surface/src/adapters/markdown/element-adapter/elements/connector.ts b/blocksuite/affine/block-surface/src/adapters/markdown/element-adapter/elements/connector.ts new file mode 100644 index 0000000000..9094e2e703 --- /dev/null +++ b/blocksuite/affine/block-surface/src/adapters/markdown/element-adapter/elements/connector.ts @@ -0,0 +1,25 @@ +import { getConnectorText } from '../../../utils/text.js'; +import type { ElementModelToMarkdownAdapterMatcher } from '../type.js'; + +export const connectorToMarkdownAdapterMatcher: ElementModelToMarkdownAdapterMatcher = + { + name: 'connector', + match: elementModel => elementModel.type === 'connector', + toAST: elementModel => { + const text = getConnectorText(elementModel); + if (!text) { + return null; + } + + const content = `Connector, with text label "${text}"`; + return { + type: 'paragraph', + children: [ + { + type: 'text', + value: content, + }, + ], + }; + }, + }; diff --git a/blocksuite/affine/block-surface/src/adapters/markdown/element-adapter/elements/group.ts b/blocksuite/affine/block-surface/src/adapters/markdown/element-adapter/elements/group.ts new file mode 100644 index 0000000000..3be7180cfa --- /dev/null +++ b/blocksuite/affine/block-surface/src/adapters/markdown/element-adapter/elements/group.ts @@ -0,0 +1,25 @@ +import { getGroupTitle } from '../../../utils/text.js'; +import type { ElementModelToMarkdownAdapterMatcher } from '../type.js'; + +export const groupToMarkdownAdapterMatcher: ElementModelToMarkdownAdapterMatcher = + { + name: 'group', + match: elementModel => elementModel.type === 'group', + toAST: elementModel => { + const title = getGroupTitle(elementModel); + if (!title) { + return null; + } + + const content = `Group, with title "${title}"`; + return { + type: 'paragraph', + children: [ + { + type: 'text', + value: content, + }, + ], + }; + }, + }; diff --git a/blocksuite/affine/block-surface/src/adapters/markdown/element-adapter/elements/index.ts b/blocksuite/affine/block-surface/src/adapters/markdown/element-adapter/elements/index.ts new file mode 100644 index 0000000000..531f8ba7dd --- /dev/null +++ b/blocksuite/affine/block-surface/src/adapters/markdown/element-adapter/elements/index.ts @@ -0,0 +1,15 @@ +import { brushToMarkdownAdapterMatcher } from './brush.js'; +import { connectorToMarkdownAdapterMatcher } from './connector.js'; +import { groupToMarkdownAdapterMatcher } from './group.js'; +import { mindmapToMarkdownAdapterMatcher } from './mindmap.js'; +import { shapeToMarkdownAdapterMatcher } from './shape.js'; +import { textToMarkdownAdapterMatcher } from './text.js'; + +export const elementToMarkdownAdapterMatchers = [ + groupToMarkdownAdapterMatcher, + shapeToMarkdownAdapterMatcher, + connectorToMarkdownAdapterMatcher, + brushToMarkdownAdapterMatcher, + textToMarkdownAdapterMatcher, + mindmapToMarkdownAdapterMatcher, +]; diff --git a/blocksuite/affine/block-surface/src/adapters/markdown/element-adapter/elements/mindmap.ts b/blocksuite/affine/block-surface/src/adapters/markdown/element-adapter/elements/mindmap.ts new file mode 100644 index 0000000000..1b28eec9b3 --- /dev/null +++ b/blocksuite/affine/block-surface/src/adapters/markdown/element-adapter/elements/mindmap.ts @@ -0,0 +1,67 @@ +import type { MindMapTreeNode } from '../../../types/mindmap.js'; +import { buildMindMapTree } from '../../../utils/mindmap.js'; +import { getShapeText } from '../../../utils/text.js'; +import type { ElementModelToMarkdownAdapterMatcher } from '../type.js'; + +export const mindmapToMarkdownAdapterMatcher: ElementModelToMarkdownAdapterMatcher = + { + name: 'mindmap', + match: elementModel => elementModel.type === 'mindmap', + toAST: (elementModel, context) => { + if (elementModel.type !== 'mindmap') { + return null; + } + + const mindMapTree = buildMindMapTree(elementModel); + if (!mindMapTree) { + return null; + } + + const { walkerContext, elements } = context; + const traverseMindMapTree = (node: MindMapTreeNode) => { + const shapeElement = elements[node.id as string]; + const shapeText = getShapeText(shapeElement); + walkerContext + .openNode( + { + type: 'listItem', + spread: false, + children: [], + }, + 'children' + ) + .openNode( + { + type: 'paragraph', + children: [ + { + type: 'text', + value: shapeText, + }, + ], + }, + 'children' + ) + .closeNode(); + node.children.forEach(child => { + traverseMindMapTree(child); + walkerContext.closeNode(); + }); + }; + + // First create a list node for the mind map tree + walkerContext.openNode( + { + type: 'list', + ordered: false, + spread: false, + children: [], + }, + 'children' + ); + traverseMindMapTree(mindMapTree); + walkerContext.closeNode().closeNode(); + + return null; + }, + }; diff --git a/blocksuite/affine/block-surface/src/adapters/markdown/element-adapter/elements/shape.ts b/blocksuite/affine/block-surface/src/adapters/markdown/element-adapter/elements/shape.ts new file mode 100644 index 0000000000..9f7ae6e35e --- /dev/null +++ b/blocksuite/affine/block-surface/src/adapters/markdown/element-adapter/elements/shape.ts @@ -0,0 +1,46 @@ +import type { MindMapTreeNode } from '../../../types/mindmap.js'; +import { getShapeText, getShapeType } from '../../../utils/text.js'; +import type { ElementModelToMarkdownAdapterMatcher } from '../type.js'; + +export const shapeToMarkdownAdapterMatcher: ElementModelToMarkdownAdapterMatcher = + { + name: 'shape', + match: elementModel => elementModel.type === 'shape', + toAST: (elementModel, context) => { + let content = ''; + const { walkerContext } = context; + const mindMapNodeMaps = walkerContext.getGlobalContext( + 'surface:mindMap:nodeMapArray' + ) as Array>; + if (mindMapNodeMaps && mindMapNodeMaps.length > 0) { + // Check if the elementModel is a mindMap node + // If it is, we should return { content: '' } directly + // And get the content when we handle the whole mindMap + const isMindMapNode = mindMapNodeMaps.some(nodeMap => + nodeMap.has(elementModel.id as string) + ); + if (isMindMapNode) { + return null; + } + } + + // If it is not, we should return the text and shapeType + const text = getShapeText(elementModel); + const type = getShapeType(elementModel); + if (!text && !type) { + return null; + } + + const shapeType = type.charAt(0).toUpperCase() + type.slice(1); + content = `${shapeType}, with text label "${text}"`; + return { + type: 'paragraph', + children: [ + { + type: 'text', + value: content, + }, + ], + }; + }, + }; diff --git a/blocksuite/affine/block-surface/src/adapters/markdown/element-adapter/elements/text.ts b/blocksuite/affine/block-surface/src/adapters/markdown/element-adapter/elements/text.ts new file mode 100644 index 0000000000..4528cc8cf4 --- /dev/null +++ b/blocksuite/affine/block-surface/src/adapters/markdown/element-adapter/elements/text.ts @@ -0,0 +1,24 @@ +import { getTextElementText } from '../../../utils/text.js'; +import type { ElementModelToMarkdownAdapterMatcher } from '../type.js'; + +export const textToMarkdownAdapterMatcher: ElementModelToMarkdownAdapterMatcher = + { + name: 'text', + match: elementModel => elementModel.type === 'text', + toAST: elementModel => { + const content = getTextElementText(elementModel); + if (!content) { + return null; + } + + return { + type: 'paragraph', + children: [ + { + type: 'text', + value: content, + }, + ], + }; + }, + }; diff --git a/blocksuite/affine/block-surface/src/adapters/markdown/element-adapter/index.ts b/blocksuite/affine/block-surface/src/adapters/markdown/element-adapter/index.ts new file mode 100644 index 0000000000..7c7211bdf7 --- /dev/null +++ b/blocksuite/affine/block-surface/src/adapters/markdown/element-adapter/index.ts @@ -0,0 +1,32 @@ +import type { MarkdownAST } from '@blocksuite/affine-shared/adapters'; + +import { + ElementModelAdapter, + type ElementModelAdapterContext, +} from '../../type.js'; +import { elementToMarkdownAdapterMatchers } from './elements/index.js'; +import type { ElementModelToMarkdownAdapterMatcher } from './type.js'; + +export class MarkdownElementModelAdapter extends ElementModelAdapter< + MarkdownAST, + MarkdownAST +> { + constructor( + readonly elementModelMatchers: ElementModelToMarkdownAdapterMatcher[] = elementToMarkdownAdapterMatchers + ) { + super(); + } + + fromElementModel( + element: Record, + context: ElementModelAdapterContext + ) { + const markdownAST: MarkdownAST | null = null; + for (const matcher of this.elementModelMatchers) { + if (matcher.match(element)) { + return matcher.toAST(element, context); + } + } + return markdownAST; + } +} diff --git a/blocksuite/affine/block-surface/src/adapters/markdown/element-adapter/type.ts b/blocksuite/affine/block-surface/src/adapters/markdown/element-adapter/type.ts new file mode 100644 index 0000000000..ee51e534ad --- /dev/null +++ b/blocksuite/affine/block-surface/src/adapters/markdown/element-adapter/type.ts @@ -0,0 +1,6 @@ +import type { MarkdownAST } from '@blocksuite/affine-shared/adapters'; + +import type { ElementModelMatcher } from '../../type.js'; + +export type ElementModelToMarkdownAdapterMatcher = + ElementModelMatcher; diff --git a/blocksuite/affine/block-surface/src/adapters/markdown/markdown.ts b/blocksuite/affine/block-surface/src/adapters/markdown/markdown.ts new file mode 100644 index 0000000000..d0d3675341 --- /dev/null +++ b/blocksuite/affine/block-surface/src/adapters/markdown/markdown.ts @@ -0,0 +1,65 @@ +import { + BlockMarkdownAdapterExtension, + type BlockMarkdownAdapterMatcher, +} from '@blocksuite/affine-shared/adapters'; + +import { getMindMapNodeMap } from '../utils/mindmap.js'; +import { MarkdownElementModelAdapter } from './element-adapter/index.js'; + +export const surfaceBlockMarkdownAdapterMatcher: BlockMarkdownAdapterMatcher = { + flavour: 'affine:surface', + toMatch: () => false, + fromMatch: o => o.node.flavour === 'affine:surface', + toBlockSnapshot: {}, + fromBlockSnapshot: { + enter: (_, context) => { + context.walkerContext.skipAllChildren(); + }, + }, +}; + +export const SurfaceBlockMarkdownAdapterExtension = + BlockMarkdownAdapterExtension(surfaceBlockMarkdownAdapterMatcher); + +export const edgelessSurfaceBlockMarkdownAdapterMatcher: BlockMarkdownAdapterMatcher = + { + flavour: 'affine:surface', + toMatch: () => false, + fromMatch: o => o.node.flavour === 'affine:surface', + toBlockSnapshot: {}, + fromBlockSnapshot: { + enter: (o, context) => { + const { walkerContext } = context; + const markdownElementModelAdapter = new MarkdownElementModelAdapter(); + if ('elements' in o.node.props) { + const elements = o.node.props.elements as Record< + string, + Record + >; + // Get all the node maps of mindMap elements + const mindMapArray = Object.entries(elements) + .filter(([_, element]) => element.type === 'mindmap') + .map(([_, element]) => getMindMapNodeMap(element)); + walkerContext.setGlobalContext( + 'surface:mindMap:nodeMapArray', + mindMapArray + ); + + Object.entries( + o.node.props.elements as Record> + ).forEach(([_, element]) => { + const markdownAST = markdownElementModelAdapter.fromElementModel( + element, + { walkerContext, elements } + ); + if (markdownAST) { + walkerContext.openNode(markdownAST, 'children').closeNode(); + } + }); + } + }, + }, + }; + +export const EdgelessSurfaceBlockMarkdownAdapterExtension = + BlockMarkdownAdapterExtension(edgelessSurfaceBlockMarkdownAdapterMatcher); diff --git a/blocksuite/affine/block-surface/src/adapters/plain-text/element-adapter/elements/brush.ts b/blocksuite/affine/block-surface/src/adapters/plain-text/element-adapter/elements/brush.ts new file mode 100644 index 0000000000..68fb545d80 --- /dev/null +++ b/blocksuite/affine/block-surface/src/adapters/plain-text/element-adapter/elements/brush.ts @@ -0,0 +1,11 @@ +import type { ElementModelToPlainTextAdapterMatcher } from '../type.js'; + +export const brushToPlainTextAdapterMatcher: ElementModelToPlainTextAdapterMatcher = + { + name: 'brush', + match: elementModel => elementModel.type === 'brush', + toAST: () => { + const content = `Brush Stroke`; + return { content }; + }, + }; diff --git a/blocksuite/affine/block-surface/src/adapters/plain-text/element-adapter/elements/connector.ts b/blocksuite/affine/block-surface/src/adapters/plain-text/element-adapter/elements/connector.ts new file mode 100644 index 0000000000..2ef58fc4cf --- /dev/null +++ b/blocksuite/affine/block-surface/src/adapters/plain-text/element-adapter/elements/connector.ts @@ -0,0 +1,13 @@ +import { getConnectorText } from '../../../utils/text.js'; +import type { ElementModelToPlainTextAdapterMatcher } from '../type.js'; + +export const connectorToPlainTextAdapterMatcher: ElementModelToPlainTextAdapterMatcher = + { + name: 'connector', + match: elementModel => elementModel.type === 'connector', + toAST: elementModel => { + const text = getConnectorText(elementModel); + const content = `Connector, with text label "${text}"`; + return { content }; + }, + }; diff --git a/blocksuite/affine/block-surface/src/adapters/plain-text/element-adapter/elements/group.ts b/blocksuite/affine/block-surface/src/adapters/plain-text/element-adapter/elements/group.ts new file mode 100644 index 0000000000..40b8263b83 --- /dev/null +++ b/blocksuite/affine/block-surface/src/adapters/plain-text/element-adapter/elements/group.ts @@ -0,0 +1,13 @@ +import { getGroupTitle } from '../../../utils/text.js'; +import type { ElementModelToPlainTextAdapterMatcher } from '../type.js'; + +export const groupToPlainTextAdapterMatcher: ElementModelToPlainTextAdapterMatcher = + { + name: 'group', + match: elementModel => elementModel.type === 'group', + toAST: elementModel => { + const title = getGroupTitle(elementModel); + const content = `Group, with title "${title}"`; + return { content }; + }, + }; diff --git a/blocksuite/affine/block-surface/src/adapters/plain-text/element-adapter/elements/index.ts b/blocksuite/affine/block-surface/src/adapters/plain-text/element-adapter/elements/index.ts new file mode 100644 index 0000000000..10c454c924 --- /dev/null +++ b/blocksuite/affine/block-surface/src/adapters/plain-text/element-adapter/elements/index.ts @@ -0,0 +1,15 @@ +import { brushToPlainTextAdapterMatcher } from './brush.js'; +import { connectorToPlainTextAdapterMatcher } from './connector.js'; +import { groupToPlainTextAdapterMatcher } from './group.js'; +import { mindmapToPlainTextAdapterMatcher } from './mindmap.js'; +import { shapeToPlainTextAdapterMatcher } from './shape.js'; +import { textToPlainTextAdapterMatcher } from './text.js'; + +export const elementModelToPlainTextAdapterMatchers = [ + groupToPlainTextAdapterMatcher, + shapeToPlainTextAdapterMatcher, + connectorToPlainTextAdapterMatcher, + brushToPlainTextAdapterMatcher, + textToPlainTextAdapterMatcher, + mindmapToPlainTextAdapterMatcher, +]; diff --git a/blocksuite/affine/block-surface/src/adapters/plain-text/element-adapter/elements/mindmap.ts b/blocksuite/affine/block-surface/src/adapters/plain-text/element-adapter/elements/mindmap.ts new file mode 100644 index 0000000000..7f1479d96b --- /dev/null +++ b/blocksuite/affine/block-surface/src/adapters/plain-text/element-adapter/elements/mindmap.ts @@ -0,0 +1,12 @@ +import { getMindMapTreeText } from '../../../utils/text.js'; +import type { ElementModelToPlainTextAdapterMatcher } from '../type.js'; + +export const mindmapToPlainTextAdapterMatcher: ElementModelToPlainTextAdapterMatcher = + { + name: 'mindmap', + match: elementModel => elementModel.type === 'mindmap', + toAST: (elementModel, context) => { + const mindMapContent = getMindMapTreeText(elementModel, context.elements); + return { content: mindMapContent }; + }, + }; diff --git a/blocksuite/affine/block-surface/src/adapters/plain-text/element-adapter/elements/shape.ts b/blocksuite/affine/block-surface/src/adapters/plain-text/element-adapter/elements/shape.ts new file mode 100644 index 0000000000..806cff8f05 --- /dev/null +++ b/blocksuite/affine/block-surface/src/adapters/plain-text/element-adapter/elements/shape.ts @@ -0,0 +1,34 @@ +import type { MindMapTreeNode } from '../../../types/mindmap.js'; +import { getShapeText, getShapeType } from '../../../utils/text.js'; +import type { ElementModelToPlainTextAdapterMatcher } from '../type.js'; + +export const shapeToPlainTextAdapterMatcher: ElementModelToPlainTextAdapterMatcher = + { + name: 'shape', + match: elementModel => elementModel.type === 'shape', + toAST: (elementModel, context) => { + let content = ''; + const { walkerContext } = context; + const mindMapNodeMaps = walkerContext.getGlobalContext( + 'surface:mindMap:nodeMapArray' + ) as Array>; + if (mindMapNodeMaps && mindMapNodeMaps.length > 0) { + // Check if the elementModel is a mindMap node + // If it is, we should return { content: '' } directly + // And get the content when we handle the whole mindMap + const isMindMapNode = mindMapNodeMaps.some(nodeMap => + nodeMap.has(elementModel.id as string) + ); + if (isMindMapNode) { + return { content }; + } + } + + // If it is not, we should return the text and shapeType + const text = getShapeText(elementModel); + const type = getShapeType(elementModel); + const shapeType = type.charAt(0).toUpperCase() + type.slice(1); + content = `${shapeType}, with text label "${text}"`; + return { content }; + }, + }; diff --git a/blocksuite/affine/block-surface/src/adapters/plain-text/element-adapter/elements/text.ts b/blocksuite/affine/block-surface/src/adapters/plain-text/element-adapter/elements/text.ts new file mode 100644 index 0000000000..81bb2156f4 --- /dev/null +++ b/blocksuite/affine/block-surface/src/adapters/plain-text/element-adapter/elements/text.ts @@ -0,0 +1,12 @@ +import { getTextElementText } from '../../../utils/text.js'; +import type { ElementModelToPlainTextAdapterMatcher } from '../type.js'; + +export const textToPlainTextAdapterMatcher: ElementModelToPlainTextAdapterMatcher = + { + name: 'text', + match: elementModel => elementModel.type === 'text', + toAST: elementModel => { + const content = getTextElementText(elementModel); + return { content }; + }, + }; diff --git a/blocksuite/affine/block-surface/src/adapters/plain-text/element-adapter/index.ts b/blocksuite/affine/block-surface/src/adapters/plain-text/element-adapter/index.ts new file mode 100644 index 0000000000..c2a3fba964 --- /dev/null +++ b/blocksuite/affine/block-surface/src/adapters/plain-text/element-adapter/index.ts @@ -0,0 +1,31 @@ +import type { TextBuffer } from '@blocksuite/affine-shared/adapters'; + +import { + ElementModelAdapter, + type ElementModelAdapterContext, +} from '../../type.js'; +import { elementModelToPlainTextAdapterMatchers } from './elements/index.js'; +import type { ElementModelToPlainTextAdapterMatcher } from './type.js'; + +export class PlainTextElementModelAdapter extends ElementModelAdapter< + string, + TextBuffer +> { + constructor( + readonly elementModelMatchers: ElementModelToPlainTextAdapterMatcher[] = elementModelToPlainTextAdapterMatchers + ) { + super(); + } + + fromElementModel( + element: Record, + context: ElementModelAdapterContext + ) { + for (const matcher of this.elementModelMatchers) { + if (matcher.match(element)) { + return matcher.toAST(element, context)?.content ?? ''; + } + } + return ''; + } +} diff --git a/blocksuite/affine/block-surface/src/adapters/plain-text/element-adapter/type.ts b/blocksuite/affine/block-surface/src/adapters/plain-text/element-adapter/type.ts new file mode 100644 index 0000000000..274de33ef2 --- /dev/null +++ b/blocksuite/affine/block-surface/src/adapters/plain-text/element-adapter/type.ts @@ -0,0 +1,6 @@ +import type { TextBuffer } from '@blocksuite/affine-shared/adapters'; + +import type { ElementModelMatcher } from '../../type.js'; + +export type ElementModelToPlainTextAdapterMatcher = + ElementModelMatcher; diff --git a/blocksuite/affine/block-surface/src/adapters/plain-text/plain-text.ts b/blocksuite/affine/block-surface/src/adapters/plain-text/plain-text.ts new file mode 100644 index 0000000000..16f32ca80d --- /dev/null +++ b/blocksuite/affine/block-surface/src/adapters/plain-text/plain-text.ts @@ -0,0 +1,66 @@ +import { + BlockPlainTextAdapterExtension, + type BlockPlainTextAdapterMatcher, +} from '@blocksuite/affine-shared/adapters'; + +import { getMindMapNodeMap } from '../utils/mindmap.js'; +import { PlainTextElementModelAdapter } from './element-adapter/index.js'; + +export const surfaceBlockPlainTextAdapterMatcher: BlockPlainTextAdapterMatcher = + { + flavour: 'affine:surface', + toMatch: () => false, + fromMatch: o => o.node.flavour === 'affine:surface', + toBlockSnapshot: {}, + fromBlockSnapshot: { + enter: (_, context) => { + context.walkerContext.skipAllChildren(); + }, + }, + }; + +export const SurfaceBlockPlainTextAdapterExtension = + BlockPlainTextAdapterExtension(surfaceBlockPlainTextAdapterMatcher); + +export const edgelessSurfaceBlockPlainTextAdapterMatcher: BlockPlainTextAdapterMatcher = + { + flavour: 'affine:surface', + toMatch: () => false, + fromMatch: o => o.node.flavour === 'affine:surface', + toBlockSnapshot: {}, + fromBlockSnapshot: { + enter: (o, context) => { + const { walkerContext } = context; + const plainTextElementModelAdapter = new PlainTextElementModelAdapter(); + if ('elements' in o.node.props) { + const elements = o.node.props.elements as Record< + string, + Record + >; + // Get all the node maps of mindMap elements + const mindMapArray = Object.entries(elements) + .filter(([_, element]) => element.type === 'mindmap') + .map(([_, element]) => getMindMapNodeMap(element)); + walkerContext.setGlobalContext( + 'surface:mindMap:nodeMapArray', + mindMapArray + ); + + Object.entries( + o.node.props.elements as Record> + ).forEach(([_, element]) => { + const plainText = plainTextElementModelAdapter.fromElementModel( + element, + { walkerContext, elements } + ); + if (plainText) { + context.textBuffer.content += plainText + '\n'; + } + }); + } + }, + }, + }; + +export const EdgelessSurfaceBlockPlainTextAdapterExtension = + BlockPlainTextAdapterExtension(edgelessSurfaceBlockPlainTextAdapterMatcher); diff --git a/blocksuite/affine/block-surface/src/adapters/type.ts b/blocksuite/affine/block-surface/src/adapters/type.ts new file mode 100644 index 0000000000..1648bac44f --- /dev/null +++ b/blocksuite/affine/block-surface/src/adapters/type.ts @@ -0,0 +1,30 @@ +import type { ASTWalkerContext } from '@blocksuite/store'; + +import type { ElementModelMap } from '../element-model/index.js'; + +export type ElementModelAdapterContext = { + walkerContext: ASTWalkerContext; + elements: Record>; +}; + +export type ElementModelMatcher = { + name: keyof ElementModelMap; + match: (element: Record) => boolean; + toAST: ( + element: Record, + context: ElementModelAdapterContext + ) => TNode | null; +}; + +export abstract class ElementModelAdapter< + AST = unknown, + TNode extends object = never, +> { + /** + * Convert element model to AST format + */ + abstract fromElementModel( + element: Record, + context: ElementModelAdapterContext + ): AST | null; +} diff --git a/blocksuite/affine/block-surface/src/adapters/types/mindmap.ts b/blocksuite/affine/block-surface/src/adapters/types/mindmap.ts new file mode 100644 index 0000000000..7fa7897598 --- /dev/null +++ b/blocksuite/affine/block-surface/src/adapters/types/mindmap.ts @@ -0,0 +1,25 @@ +export interface MindMapTreeNode { + id: string; + index: string; + children: MindMapTreeNode[]; +} + +export interface MindMapNode { + index: string; + parent?: string; +} + +export type MindMapJson = Record; + +export interface MindMapElement { + index: string; + seed: number; + children: { + 'affine:surface:ymap': boolean; + json: MindMapJson; + }; + layoutType: number; + style: number; + type: 'mindmap'; + id: string; +} diff --git a/blocksuite/affine/block-surface/src/adapters/utils/mindmap.ts b/blocksuite/affine/block-surface/src/adapters/utils/mindmap.ts new file mode 100644 index 0000000000..6f37936350 --- /dev/null +++ b/blocksuite/affine/block-surface/src/adapters/utils/mindmap.ts @@ -0,0 +1,74 @@ +import type { + MindMapElement, + MindMapJson, + MindMapTreeNode, +} from '../types/mindmap.js'; + +function isMindMapElement(element: unknown): element is MindMapElement { + return ( + typeof element === 'object' && + element !== null && + 'type' in element && + (element as MindMapElement).type === 'mindmap' && + 'children' in element && + typeof (element as MindMapElement).children === 'object' && + 'json' in (element as MindMapElement).children + ); +} + +export function getMindMapChildrenJson( + element: Record +): MindMapJson | null { + if (!isMindMapElement(element)) { + return null; + } + + return element.children.json; +} + +export function getMindMapNodeMap( + element: Record +): Map { + const nodeMap = new Map(); + const childrenJson = getMindMapChildrenJson(element); + if (!childrenJson) { + return nodeMap; + } + + for (const [id, info] of Object.entries(childrenJson)) { + nodeMap.set(id, { + id, + index: info.index, + children: [], + }); + } + + return nodeMap; +} + +export function buildMindMapTree(element: Record) { + let root: MindMapTreeNode | null = null; + + // First traverse to get node map + const nodeMap = getMindMapNodeMap(element); + const childrenJson = getMindMapChildrenJson(element); + if (!childrenJson) { + return root; + } + + // Second traverse to build tree + for (const [id, info] of Object.entries(childrenJson)) { + const node = nodeMap.get(id)!; + + if (info.parent) { + const parentNode = nodeMap.get(info.parent); + if (parentNode) { + parentNode.children.push(node); + } + } else { + root = node; + } + } + + return root; +} diff --git a/blocksuite/affine/block-surface/src/adapters/utils/text.ts b/blocksuite/affine/block-surface/src/adapters/utils/text.ts new file mode 100644 index 0000000000..220ad4f428 --- /dev/null +++ b/blocksuite/affine/block-surface/src/adapters/utils/text.ts @@ -0,0 +1,162 @@ +import type { DeltaInsert } from '@blocksuite/inline/types'; + +import type { MindMapTreeNode } from '../types/mindmap.js'; +import { buildMindMapTree } from './mindmap.js'; + +export function getShapeType(elementModel: Record): string { + let shapeType = ''; + if (elementModel.type !== 'shape') { + return shapeType; + } + + if ( + 'shapeType' in elementModel && + typeof elementModel.shapeType === 'string' + ) { + shapeType = elementModel.shapeType; + } + return shapeType; +} + +export function getShapeText(elementModel: Record): string { + let text = ''; + if (elementModel.type !== 'shape') { + return text; + } + + if ( + 'text' in elementModel && + typeof elementModel.text === 'object' && + elementModel.text + ) { + let delta: DeltaInsert[] = []; + if ('delta' in elementModel.text) { + delta = elementModel.text.delta as DeltaInsert[]; + } + text = delta.map(d => d.insert).join(''); + } + return text; +} + +export function getConnectorText( + elementModel: Record +): string { + let text = ''; + if (elementModel.type !== 'connector') { + return text; + } + + if ( + 'text' in elementModel && + typeof elementModel.text === 'object' && + elementModel.text + ) { + let delta: DeltaInsert[] = []; + if ('delta' in elementModel.text) { + delta = elementModel.text.delta as DeltaInsert[]; + } + text = delta.map(d => d.insert).join(''); + } + return text; +} + +export function getGroupTitle(elementModel: Record): string { + let title = ''; + if (elementModel.type !== 'group') { + return title; + } + + if ( + 'title' in elementModel && + typeof elementModel.title === 'object' && + elementModel.title + ) { + let delta: DeltaInsert[] = []; + if ('delta' in elementModel.title) { + delta = elementModel.title.delta as DeltaInsert[]; + } + title = delta.map(d => d.insert).join(''); + } + return title; +} + +export function getTextElementText( + elementModel: Record +): string { + let text = ''; + if (elementModel.type !== 'text') { + return text; + } + if ( + 'text' in elementModel && + typeof elementModel.text === 'object' && + elementModel.text + ) { + let delta: DeltaInsert[] = []; + if ('delta' in elementModel.text) { + delta = elementModel.text.delta as DeltaInsert[]; + } + text = delta.map(d => d.insert).join(''); + } + return text; +} + +/** + * traverse the mindMapTree and construct the content string + * like: + * - Root + * - Child 1 + * - Child 1.1 + * - Child 1.2 + * - Child 2 + * - Child 2.1 + * - Child 2.2 + * - Child 3 + * - Child 3.1 + * - Child 3.2 + * @param elementModel - the mindmap element model + * @param elements - the elements map + * @returns the mindmap tree text + */ +export function getMindMapTreeText( + elementModel: Record, + elements: Record>, + options: { + prefix: string; + repeat: number; + } = { + prefix: ' ', + repeat: 2, + } +): string { + let mindMapContent = ''; + if (elementModel.type !== 'mindmap') { + return mindMapContent; + } + + const mindMapTree = buildMindMapTree(elementModel); + if (!mindMapTree) { + return mindMapContent; + } + + let layer = 0; + const traverseMindMapTree = ( + node: MindMapTreeNode, + prefix: string, + repeat: number + ) => { + const shapeElement = elements[node.id as string]; + const shapeText = getShapeText(shapeElement); + if (shapeElement) { + mindMapContent += `${prefix.repeat(layer * repeat)}- ${shapeText}\n`; + } + node.children.forEach(child => { + layer++; + traverseMindMapTree(child, prefix, repeat); + layer--; + }); + }; + traverseMindMapTree(mindMapTree, options.prefix, options.repeat); + + return mindMapContent; +} diff --git a/blocksuite/affine/block-surface/src/commands/auto-align.ts b/blocksuite/affine/block-surface/src/commands/auto-align.ts new file mode 100644 index 0000000000..477a3b5790 --- /dev/null +++ b/blocksuite/affine/block-surface/src/commands/auto-align.ts @@ -0,0 +1,164 @@ +import { + ConnectorElementModel, + EdgelessTextBlockModel, + EmbedSyncedDocModel, + MindmapElementModel, + NoteBlockModel, +} from '@blocksuite/affine-model'; +import type { GfxModel } from '@blocksuite/block-std/gfx'; +import { Bound } from '@blocksuite/global/utils'; +import chunk from 'lodash.chunk'; + +const ALIGN_HEIGHT = 200; +const ALIGN_PADDING = 20; + +import type { Command } from '@blocksuite/block-std'; +import type { BlockModel, BlockProps } from '@blocksuite/store'; + +import { updateXYWH } from '../utils/update-xywh.js'; + +/** + * Automatically arrange elements according to fixed row and column rules + */ +export const autoArrangeElementsCommand: Command = ( + ctx, + next +) => { + const { updateBlock } = ctx.std.doc; + const rootService = ctx.std.getService('affine:page'); + // @ts-expect-error TODO: fix after edgeless refactor + const elements = rootService?.selection.selectedElements; + // @ts-expect-error TODO: fix after edgeless refactor + const updateElement = rootService?.updateElement; + if (elements && updateElement) { + autoArrangeElements(elements, updateElement, updateBlock); + } + next(); +}; + +/** + * Adjust the height of the selected element to a fixed value and arrange the elements + */ +export const autoResizeElementsCommand: Command = ( + ctx, + next +) => { + const { updateBlock } = ctx.std.doc; + const rootService = ctx.std.getService('affine:page'); + // @ts-expect-error TODO: fix after edgeless refactor + const elements = rootService?.selection.selectedElements; + // @ts-expect-error TODO: fix after edgeless refactor + const updateElement = rootService?.updateElement; + if (elements && updateElement) { + autoResizeElements(elements, updateElement, updateBlock); + } + next(); +}; + +function splitElementsToChunks(models: GfxModel[]) { + const sortByCenterX = (a: GfxModel, b: GfxModel) => + a.elementBound.center[0] - b.elementBound.center[0]; + const sortByCenterY = (a: GfxModel, b: GfxModel) => + a.elementBound.center[1] - b.elementBound.center[1]; + const elements = models.filter(ele => { + if ( + ele instanceof ConnectorElementModel && + (ele.target.id || ele.source.id) + ) { + return false; + } + return true; + }); + elements.sort(sortByCenterY); + const chunks = chunk(elements, 4); + chunks.forEach(items => items.sort(sortByCenterX)); + return chunks; +} + +function autoArrangeElements( + elements: GfxModel[], + updateElement: (id: string, props: Record) => void, + updateBlock: ( + model: BlockModel, + callBackOrProps: (() => void) | Partial + ) => void +) { + const chunks = splitElementsToChunks(elements); + // update element XY + const startX: number = chunks[0][0].elementBound.x; + let startY: number = chunks[0][0].elementBound.y; + chunks.forEach(items => { + let posX = startX; + let maxHeight = 0; + items.forEach(ele => { + const { x: eleX, y: eleY } = ele.elementBound; + const bound = Bound.deserialize(ele.xywh); + const xOffset = bound.x - eleX; + const yOffset = bound.y - eleY; + bound.x = posX + xOffset; + bound.y = startY + yOffset; + updateXYWH(ele, bound, updateElement, updateBlock); + if (ele.elementBound.h > maxHeight) { + maxHeight = ele.elementBound.h; + } + posX += ele.elementBound.w + ALIGN_PADDING; + }); + startY += maxHeight + ALIGN_PADDING; + }); +} + +function autoResizeElements( + elements: GfxModel[], + updateElement: (id: string, props: Record) => void, + updateBlock: ( + model: BlockModel, + callBackOrProps: (() => void) | Partial + ) => void +) { + // resize or scale to fixed height + elements.forEach(ele => { + if ( + ele instanceof ConnectorElementModel || + ele instanceof MindmapElementModel + ) { + return; + } + + if (ele instanceof NoteBlockModel) { + const curScale = ele.edgeless.scale ?? 1; + const nextScale = curScale * (ALIGN_HEIGHT / ele.elementBound.h); + const bound = Bound.deserialize(ele.xywh); + bound.h = bound.h * (nextScale / curScale); + bound.w = bound.w * (nextScale / curScale); + updateElement(ele.id, { + edgeless: { + ...ele.edgeless, + scale: nextScale, + }, + xywh: bound.serialize(), + }); + } else if ( + ele instanceof EdgelessTextBlockModel || + ele instanceof EmbedSyncedDocModel + ) { + const curScale = ele.scale ?? 1; + const nextScale = curScale * (ALIGN_HEIGHT / ele.elementBound.h); + const bound = Bound.deserialize(ele.xywh); + bound.h = bound.h * (nextScale / curScale); + bound.w = bound.w * (nextScale / curScale); + updateElement(ele.id, { + scale: nextScale, + xywh: bound.serialize(), + }); + } else { + const bound = Bound.deserialize(ele.xywh); + const scale = ALIGN_HEIGHT / ele.elementBound.h; + bound.h = scale * bound.h; + bound.w = scale * bound.w; + updateXYWH(ele, bound, updateElement, updateBlock); + } + }); + + // arrange + autoArrangeElements(elements, updateElement, updateBlock); +} diff --git a/blocksuite/affine/block-surface/src/commands/index.ts b/blocksuite/affine/block-surface/src/commands/index.ts new file mode 100644 index 0000000000..c1439dd56e --- /dev/null +++ b/blocksuite/affine/block-surface/src/commands/index.ts @@ -0,0 +1,13 @@ +import type { BlockCommands } from '@blocksuite/block-std'; + +import { + autoArrangeElementsCommand, + autoResizeElementsCommand, +} from './auto-align.js'; +import { reassociateConnectorsCommand } from './reassociate-connectors.js'; + +export const commands: BlockCommands = { + reassociateConnectors: reassociateConnectorsCommand, + autoArrangeElements: autoArrangeElementsCommand, + autoResizeElements: autoResizeElementsCommand, +}; diff --git a/blocksuite/affine/block-surface/src/commands/reassociate-connectors.ts b/blocksuite/affine/block-surface/src/commands/reassociate-connectors.ts new file mode 100644 index 0000000000..c244c970bb --- /dev/null +++ b/blocksuite/affine/block-surface/src/commands/reassociate-connectors.ts @@ -0,0 +1,44 @@ +import type { Command } from '@blocksuite/block-std'; + +/** + * Re-associate bindings for block that have been converted. + * + * @param oldId - the old block id + * @param newId - the new block id + */ +export const reassociateConnectorsCommand: Command< + never, + never, + { oldId: string; newId: string } +> = (ctx, next) => { + const { oldId, newId } = ctx; + const service = ctx.std.getService('affine:surface'); + if (!oldId || !newId || !service) { + next(); + return; + } + + const surface = service.surface; + const connectors = surface.getConnectors(oldId); + for (const { id, source, target } of connectors) { + if (source.id === oldId) { + surface.updateElement(id, { + source: { + ...source, + id: newId, + }, + }); + continue; + } + if (target.id === oldId) { + surface.updateElement(id, { + target: { + ...target, + id: newId, + }, + }); + } + } + + next(); +}; diff --git a/blocksuite/affine/block-surface/src/consts.ts b/blocksuite/affine/block-surface/src/consts.ts new file mode 100644 index 0000000000..eee97f7cea --- /dev/null +++ b/blocksuite/affine/block-surface/src/consts.ts @@ -0,0 +1,16 @@ +export const ZOOM_MAX = 6.0; +export const ZOOM_MIN = 0.1; +export const ZOOM_STEP = 0.25; +export const ZOOM_INITIAL = 1.0; +export const ZOOM_WHEEL_STEP = 0.1; +export const GRID_SIZE = 3000; +export const GRID_GAP_MIN = 10; +export const GRID_GAP_MAX = 50; + +// TODO: need to check the default central area ratio +export const DEFAULT_CENTRAL_AREA_RATIO = 0.3; + +export interface IModelCoord { + x: number; + y: number; +} diff --git a/blocksuite/affine/block-surface/src/effects.ts b/blocksuite/affine/block-surface/src/effects.ts new file mode 100644 index 0000000000..796c0dcacd --- /dev/null +++ b/blocksuite/affine/block-surface/src/effects.ts @@ -0,0 +1,30 @@ +import type { + autoArrangeElementsCommand, + autoResizeElementsCommand, +} from './commands/auto-align.js'; +import type { reassociateConnectorsCommand } from './commands/reassociate-connectors.js'; +import { SurfaceBlockComponent } from './surface-block.js'; +import { SurfaceBlockVoidComponent } from './surface-block-void.js'; +import type { SurfaceBlockModel } from './surface-model.js'; +import type { SurfaceBlockService } from './surface-service.js'; + +export function effects() { + customElements.define('affine-surface-void', SurfaceBlockVoidComponent); + customElements.define('affine-surface', SurfaceBlockComponent); +} + +declare global { + namespace BlockSuite { + interface BlockServices { + 'affine:surface': SurfaceBlockService; + } + interface BlockModels { + 'affine:surface': SurfaceBlockModel; + } + interface Commands { + reassociateConnectors: typeof reassociateConnectorsCommand; + autoArrangeElements: typeof autoArrangeElementsCommand; + autoResizeElements: typeof autoResizeElementsCommand; + } + } +} diff --git a/blocksuite/affine/block-surface/src/element-model/base.ts b/blocksuite/affine/block-surface/src/element-model/base.ts new file mode 100644 index 0000000000..4745916ec3 --- /dev/null +++ b/blocksuite/affine/block-surface/src/element-model/base.ts @@ -0,0 +1,4 @@ +export { + GfxPrimitiveElementModel as SurfaceElementModel, + GfxGroupLikeElementModel as SurfaceGroupLikeModel, +} from '@blocksuite/block-std/gfx'; diff --git a/blocksuite/affine/block-surface/src/element-model/index.ts b/blocksuite/affine/block-surface/src/element-model/index.ts new file mode 100644 index 0000000000..b5e98fbdee --- /dev/null +++ b/blocksuite/affine/block-surface/src/element-model/index.ts @@ -0,0 +1,51 @@ +import { + BrushElementModel, + ConnectorElementModel, + GroupElementModel, + MindmapElementModel, + ShapeElementModel, + TextElementModel, +} from '@blocksuite/affine-model'; + +import { SurfaceElementModel } from './base.js'; + +export const elementsCtorMap = { + group: GroupElementModel, + connector: ConnectorElementModel, + shape: ShapeElementModel, + brush: BrushElementModel, + text: TextElementModel, + mindmap: MindmapElementModel, +}; + +export { + BrushElementModel, + ConnectorElementModel, + GroupElementModel, + MindmapElementModel, + ShapeElementModel, + SurfaceElementModel, + TextElementModel, +}; + +export enum CanvasElementType { + BRUSH = 'brush', + CONNECTOR = 'connector', + GROUP = 'group', + MINDMAP = 'mindmap', + SHAPE = 'shape', + TEXT = 'text', +} + +export type ElementModelMap = { + ['shape']: ShapeElementModel; + ['brush']: BrushElementModel; + ['connector']: ConnectorElementModel; + ['text']: TextElementModel; + ['group']: GroupElementModel; + ['mindmap']: MindmapElementModel; +}; + +export function isCanvasElementType(type: string): type is CanvasElementType { + return type.toLocaleUpperCase() in CanvasElementType; +} diff --git a/blocksuite/affine/block-surface/src/index.ts b/blocksuite/affine/block-surface/src/index.ts new file mode 100644 index 0000000000..cf62cb7fee --- /dev/null +++ b/blocksuite/affine/block-surface/src/index.ts @@ -0,0 +1,153 @@ +// eslint-disable-next-line @typescript-eslint/triple-slash-reference +/// +export { type IModelCoord, ZOOM_MAX, ZOOM_MIN, ZOOM_STEP } from './consts.js'; +export { GRID_GAP_MAX, GRID_GAP_MIN } from './consts.js'; +export { + SurfaceElementModel, + SurfaceGroupLikeModel, +} from './element-model/base.js'; +export { CanvasElementType } from './element-model/index.js'; +import { + isConnectorAndBindingsAllSelected, + isConnectorWithLabel, +} from './managers/connector-manager.js'; +export { + calculateNearestLocation, + ConnectionOverlay, + ConnectorEndpointLocations, + ConnectorEndpointLocationsOnTriangle, + ConnectorPathGenerator, + PathGenerator, +} from './managers/connector-manager.js'; +export { CanvasRenderer } from './renderer/canvas-renderer.js'; +export * from './renderer/elements/group/consts.js'; +export type { ElementRenderer } from './renderer/elements/index.js'; +export { + elementRenderers, + normalizeShapeBound, +} from './renderer/elements/index.js'; +export { fitContent } from './renderer/elements/shape/utils.js'; +export * from './renderer/elements/type.js'; +export { Overlay, OverlayIdentifier } from './renderer/overlay.js'; +import { + getCursorByCoord, + getLineHeight, + isFontStyleSupported, + isFontWeightSupported, + normalizeTextBound, + splitIntoLines, +} from './renderer/elements/text/utils.js'; +import { + getFontFaces, + getFontFacesByFontFamily, + isSameFontFamily, + wrapFontFamily, +} from './utils/font.js'; +export type { SurfaceContext } from './surface-block.js'; +export { SurfaceBlockComponent } from './surface-block.js'; +export { SurfaceBlockModel, SurfaceBlockSchema } from './surface-model.js'; +export type { SurfaceBlockService } from './surface-service.js'; +export { + EdgelessSurfaceBlockSpec, + PageSurfaceBlockSpec, +} from './surface-spec.js'; +export { SurfaceBlockTransformer } from './surface-transformer.js'; +export { AStarRunner } from './utils/a-star.js'; +export { + NODE_FIRST_LEVEL_HORIZONTAL_SPACING, + NODE_HORIZONTAL_SPACING, + NODE_VERTICAL_SPACING, +} from './utils/mindmap/layout.js'; +export { RoughCanvas } from './utils/rough/canvas.js'; + +import { + almostEqual, + clamp, + getPointFromBoundsWithRotation, + getStroke, + getSvgPathFromStroke, + intersects, + isOverlap, + isPointIn, + lineIntersects, + linePolygonIntersects, + normalizeDegAngle, + polygonGetPointTangent, + polygonNearestPoint, + polygonPointDistance, + polyLineNearestPoint, + rotatePoints, + sign, + toDegree, + toRadian, +} from '@blocksuite/global/utils'; +import { generateKeyBetween } from 'fractional-indexing'; + +import { generateElementId, normalizeWheelDeltaY } from './utils/index.js'; +import { + addTree, + containsNode, + createFromTree, + detachMindmap, + findTargetNode, + hideNodeConnector, + moveNode, + tryMoveNode, +} from './utils/mindmap/utils.js'; +export type { Options } from './utils/rough/core.js'; +export { sortIndex } from './utils/sort.js'; +export { updateXYWH } from './utils/update-xywh.js'; + +export const ConnectorUtils = { + isConnectorAndBindingsAllSelected, + isConnectorWithLabel, +}; + +export const TextUtils = { + splitIntoLines, + normalizeTextBound, + getLineHeight, + getCursorByCoord, + isFontWeightSupported, + isFontStyleSupported, + wrapFontFamily, + getFontFaces, + getFontFacesByFontFamily, + isSameFontFamily, +}; + +export const CommonUtils = { + almostEqual, + clamp, + generateElementId, + generateKeyBetween, + getPointFromBoundsWithRotation, + getStroke, + getSvgPathFromStroke, + intersects, + isOverlap, + isPointIn, + lineIntersects, + linePolygonIntersects, + normalizeDegAngle, + normalizeWheelDeltaY, + polygonGetPointTangent, + polygonNearestPoint, + polygonPointDistance, + polyLineNearestPoint, + rotatePoints, + sign, + toDegree, + toRadian, +}; + +export const MindmapUtils = { + addTree, + createFromTree, + detachMindmap, + moveNode, + findTargetNode, + tryMoveNode, + hideNodeConnector, + containsNode, +}; diff --git a/blocksuite/affine/block-surface/src/managers/connector-manager.ts b/blocksuite/affine/block-surface/src/managers/connector-manager.ts new file mode 100644 index 0000000000..7cbfc3c017 --- /dev/null +++ b/blocksuite/affine/block-surface/src/managers/connector-manager.ts @@ -0,0 +1,1419 @@ +import { + type BrushElementModel, + type Connection, + ConnectorElementModel, + ConnectorMode, + GroupElementModel, + type LocalConnectorElementModel, +} from '@blocksuite/affine-model'; +import { ThemeProvider } from '@blocksuite/affine-shared/services'; +import type { GfxController, GfxModel } from '@blocksuite/block-std/gfx'; +import type { IBound, IVec, IVec3 } from '@blocksuite/global/utils'; +import { + almostEqual, + assertEquals, + assertExists, + assertType, + Bound, + clamp, + getBezierCurveBoundingBox, + getBezierParameters, + getBoundFromPoints, + getBoundWithRotation, + getPointFromBoundsWithRotation, + isOverlap, + isVecZero, + last, + lineIntersects, + PI2, + PointLocation, + sign, + toRadian, + Vec, +} from '@blocksuite/global/utils'; +import { effect } from '@preact/signals-core'; + +import { Overlay } from '../renderer/overlay.js'; +import { AStarRunner } from '../utils/a-star.js'; + +export type Connectable = Exclude< + BlockSuite.EdgelessModel, + ConnectorElementModel | BrushElementModel | GroupElementModel +>; + +export type OrthogonalConnectorInput = { + startBound: Bound | null; + endBound: Bound | null; + startPoint: PointLocation; + endPoint: PointLocation; +}; + +export const ConnectorEndpointLocations: IVec[] = [ + // At top + [0.5, 0], + // At right + [1, 0.5], + // At bottom + [0.5, 1], + // At left + [0, 0.5], +]; + +export const ConnectorEndpointLocationsOnTriangle: IVec[] = [ + // At top + [0.5, 0], + // At right + [0.75, 0.5], + // At bottom + [0.5, 1], + // At left + [0.25, 0.5], +]; + +export function isConnectorWithLabel( + model: GfxModel | BlockSuite.SurfaceLocalModel +) { + return model instanceof ConnectorElementModel && model.hasLabel(); +} + +export function calculateNearestLocation( + point: IVec, + bounds: IBound, + locations = ConnectorEndpointLocations, + shortestDistance = Number.POSITIVE_INFINITY +) { + const { x, y, w, h } = bounds; + return locations + .map(offset => [x + offset[0] * w, y + offset[1] * h] as IVec) + .map(point => getPointFromBoundsWithRotation(bounds, point)) + .reduce( + (prev, curr, index) => { + const d = Vec.dist(point, curr); + if (d < shortestDistance) { + const location = locations[index]; + shortestDistance = d; + prev[0] = location[0]; + prev[1] = location[1]; + } + return prev; + }, + [...locations[0]] + ) as IVec; +} + +function rBound(ele: GfxModel, anti = false): IBound { + const bound = Bound.deserialize(ele.xywh); + return { ...bound, rotate: anti ? -ele.rotate : ele.rotate }; +} + +export function isConnectorAndBindingsAllSelected( + connector: ConnectorElementModel | LocalConnectorElementModel, + selected: GfxModel[] +) { + const connectorSelected = selected.find(s => s.id === connector.id); + if (!connectorSelected) { + return false; + } + const { source, target } = connector; + const startSelected = selected.find(s => s.id === source?.id); + const endSelected = selected.find(s => s.id === target?.id); + if (!source.id && !target.id) { + return true; + } + if (!source.id && endSelected) { + return true; + } + if (!target.id && startSelected) { + return true; + } + if (startSelected && endSelected) { + return true; + } + return false; +} + +export function getAnchors(ele: GfxModel) { + const bound = Bound.deserialize(ele.xywh); + const offset = 10; + const anchors: { point: PointLocation; coord: IVec }[] = []; + const rotate = ele.rotate; + + [ + [bound.center[0], bound.y - offset], + [bound.center[0], bound.maxY + offset], + [bound.x - offset, bound.center[1]], + [bound.maxX + offset, bound.center[1]], + ] + .map(vec => + getPointFromBoundsWithRotation({ ...bound, rotate }, vec as IVec) + ) + .forEach(vec => { + const rst = ele.getLineIntersections(bound.center as IVec, vec as IVec); + assertExists(rst); + const originPoint = getPointFromBoundsWithRotation( + { ...bound, rotate: -rotate }, + rst[0] + ); + anchors.push({ point: rst[0], coord: bound.toRelative(originPoint) }); + }); + return anchors; +} + +function getConnectableRelativePosition(connectable: GfxModel, position: IVec) { + const location = connectable.getRelativePointLocation(position as IVec); + if (isVecZero(Vec.sub(position, [0, 0.5]))) + location.tangent = Vec.rot([0, -1], toRadian(connectable.rotate)); + else if (isVecZero(Vec.sub(position, [1, 0.5]))) + location.tangent = Vec.rot([0, 1], toRadian(connectable.rotate)); + else if (isVecZero(Vec.sub(position, [0.5, 0]))) + location.tangent = Vec.rot([1, 0], toRadian(connectable.rotate)); + else if (isVecZero(Vec.sub(position, [0.5, 1]))) + location.tangent = Vec.rot([-1, 0], toRadian(connectable.rotate)); + return location; +} + +export function getNearestConnectableAnchor(ele: Connectable, point: IVec) { + const anchors = getAnchors(ele); + return closestPoint( + anchors.map(a => a.point), + point + ); +} + +function closestPoint(points: PointLocation[], point: IVec) { + const rst = points.map(p => ({ p, d: Vec.dist(p, point) })); + rst.sort((a, b) => a.d - b.d); + return rst[0].p; +} + +function computePoints( + startPoint: IVec, + endPoint: IVec, + nextStartPoint: IVec, + lastEndPoint: IVec, + startBound: Bound | null, + endBound: Bound | null, + expandStartBound: Bound | null, + expandEndBound: Bound | null +): [IVec3[], IVec3, IVec3, IVec3, IVec3] { + const startPointVec3 = downscalePrecision(startPoint); + const endPointVec3 = downscalePrecision(endPoint); + let nextStartPointVec3 = downscalePrecision(nextStartPoint); + let lastEndPointVec3 = downscalePrecision(lastEndPoint); + + const result = getConnectablePoints( + startPointVec3, + endPointVec3, + nextStartPointVec3, + lastEndPointVec3, + startBound, + endBound, + expandStartBound, + expandEndBound + ); + const points = result.points; + nextStartPointVec3 = result.nextStartPoint; + lastEndPointVec3 = result.lastEndPoint; + const finalPoints = filterConnectablePoints( + filterConnectablePoints(points, expandStartBound?.expand(-1) ?? null), + expandEndBound?.expand(-1) ?? null + ); + return [ + finalPoints, + startPointVec3, + endPointVec3, + nextStartPointVec3, + lastEndPointVec3, + ]; +} + +function downscalePrecision(point: IVec | IVec3): IVec3 { + return [ + Number(point[0].toFixed(2)), + Number(point[1].toFixed(2)), + point[2] ?? 0, + ]; +} + +function filterConnectablePoints( + points: T[], + bound: Bound | null +): T[] { + return points.filter(point => { + if (!bound) return true; + return !bound.isPointInBound(point as IVec); + }); +} + +function pushWithPriority(points: number[][], vecs: IVec[], priority = 0) { + points.push(...vecs.map(vec => [...vec, priority])); +} + +function pushLineIntersectsToPoints( + points: IVec3[], + aLine: IVec[], + bLine: IVec[], + priority = 0 +) { + const rst = lineIntersects(aLine[0], aLine[1], bLine[0], bLine[1], true); + if (rst) { + pushWithPriority(points, [rst], priority); + } +} + +function pushOuterPoints( + points: IVec3[], + expandStartBound: Bound, + expandEndBound: Bound, + outerBound: Bound +) { + if (expandStartBound && expandEndBound && outerBound) { + pushWithPriority(points, outerBound.getVerticesAndMidpoints()); + pushWithPriority(points, [outerBound.center], 2); + [ + expandStartBound.upperLine, + expandStartBound.horizontalLine, + expandStartBound.lowerLine, + expandEndBound.upperLine, + expandEndBound.horizontalLine, + expandEndBound.lowerLine, + ].forEach(line => { + pushLineIntersectsToPoints(points, line, outerBound.leftLine, 0); + pushLineIntersectsToPoints(points, line, outerBound.rightLine, 0); + }); + [ + expandStartBound.leftLine, + expandStartBound.verticalLine, + expandStartBound.rightLine, + expandEndBound.leftLine, + expandEndBound.verticalLine, + expandEndBound.rightLine, + ].forEach(line => { + pushLineIntersectsToPoints(points, line, outerBound.upperLine, 0); + pushLineIntersectsToPoints(points, line, outerBound.lowerLine, 0); + }); + } +} + +function pushBoundMidPoint( + points: IVec3[], + bound1: Bound, + bound2: Bound, + expandBound1: Bound, + expandBound2: Bound +) { + if (bound1.maxX < bound2.x) { + const midX = (bound1.maxX + bound2.x) / 2; + [ + expandBound1.horizontalLine, + expandBound2.horizontalLine, + expandBound1.upperLine, + expandBound1.lowerLine, + expandBound2.upperLine, + expandBound2.lowerLine, + ].forEach((line, index) => { + pushLineIntersectsToPoints( + points, + line, + [ + [midX, 0], + [midX, 1], + ], + index === 0 || index === 1 ? 6 : 3 + ); + }); + } + if (bound1.maxY < bound2.y) { + const midY = (bound1.maxY + bound2.y) / 2; + [ + expandBound1.verticalLine, + expandBound2.verticalLine, + expandBound1.leftLine, + expandBound1.rightLine, + expandBound2.leftLine, + expandBound2.rightLine, + ].forEach((line, index) => { + pushLineIntersectsToPoints( + points, + line, + [ + [0, midY], + [1, midY], + ], + index === 0 || index === 1 ? 6 : 3 + ); + }); + } +} + +function pushGapMidPoint( + points: IVec3[], + point: IVec3, + bound: Bound, + bound2: Bound, + expandBound: Bound, + expandBound2: Bound +) { + /** on top or on bottom */ + if ( + almostEqual(point[1], bound.y, 0.02) || + almostEqual(point[1], bound.maxY, 0.02) + ) { + const rst = [ + bound.upperLine, + bound.lowerLine, + bound2.upperLine, + bound2.lowerLine, + ].map(line => { + return lineIntersects( + point as unknown as IVec, + [point[0], point[1] + 1], + line[0], + line[1], + true + ) as IVec; + }); + rst.sort((a, b) => a[1] - b[1]); + const midPoint = Vec.lrp(rst[1], rst[2], 0.5); + pushWithPriority(points, [midPoint], 6); + [ + expandBound.leftLine, + expandBound.rightLine, + expandBound2.leftLine, + expandBound2.rightLine, + ].forEach(line => { + pushLineIntersectsToPoints( + points, + [midPoint, [midPoint[0] + 1, midPoint[1]]], + line, + 0 + ); + }); + } else { + const rst = [ + bound.leftLine, + bound.rightLine, + bound2.leftLine, + bound2.rightLine, + ].map(line => { + return lineIntersects( + point as unknown as IVec, + [point[0] + 1, point[1]], + line[0], + line[1], + true + ) as IVec; + }); + rst.sort((a, b) => a[0] - b[0]); + const midPoint = Vec.lrp(rst[1], rst[2], 0.5); + pushWithPriority(points, [midPoint], 6); + [ + expandBound.upperLine, + expandBound.lowerLine, + expandBound2.upperLine, + expandBound2.lowerLine, + ].forEach(line => { + pushLineIntersectsToPoints( + points, + [midPoint, [midPoint[0], midPoint[1] + 1]], + line, + 0 + ); + }); + } +} + +function removeDulicatePoints(points: IVec[] | IVec3[]) { + points = points.map(downscalePrecision); + points.sort((a, b) => a[0] - b[0]); + assertType(points); + for (let i = 1; i < points.length - 1; i++) { + const cur = points[i]; + const last = points[i - 1]; + if (almostEqual(cur[0], last[0], 0.02)) { + cur[0] = last[0]; + } + } + points.sort((a, b) => a[1] - b[1]); + for (let i = 1; i < points.length - 1; i++) { + const cur = points[i]; + const last = points[i - 1]; + if (almostEqual(cur[1], last[1], 0.02)) { + cur[1] = last[1]; + } + } + points.sort((a, b) => { + if (a[0] < b[0]) return -1; + if (a[0] > b[0]) return 1; + if (a[1] < b[1]) return -1; + if (a[1] > b[1]) return 1; + return 0; + }); + for (let i = 1; i < points.length; i++) { + const cur = points[i]; + const last = points[i - 1]; + if ( + almostEqual(cur[0], last[0], 0.02) && + almostEqual(cur[1], last[1], 0.02) + ) { + if (cur[2] <= last[2]) points.splice(i, 1); + else points.splice(i - 1, 1); + i--; + continue; + } + } + return points; +} + +function getConnectablePoints( + startPoint: IVec3, + endPoint: IVec3, + nextStartPoint: IVec3, + lastEndPoint: IVec3, + startBound: Bound | null, + endBound: Bound | null, + expandStartBound: Bound | null, + expandEndBound: Bound | null +) { + const lineBound = Bound.fromPoints([ + startPoint, + endPoint, + ] as unknown[] as IVec[]); + const outerBound = + expandStartBound && + expandEndBound && + expandStartBound.unite(expandEndBound); + let points = [nextStartPoint, lastEndPoint] as IVec3[]; + pushWithPriority(points, lineBound.getVerticesAndMidpoints()); + + if (!startBound || !endBound) { + pushWithPriority(points, [lineBound.center], 3); + } + if (outerBound) { + pushOuterPoints(points, expandStartBound, expandEndBound, outerBound); + } + + if (startBound && endBound) { + assertExists(expandStartBound); + assertExists(expandEndBound); + pushGapMidPoint( + points, + startPoint, + startBound, + endBound, + expandStartBound, + expandEndBound + ); + pushGapMidPoint( + points, + endPoint, + endBound, + startBound, + expandEndBound, + expandStartBound + ); + pushBoundMidPoint( + points, + startBound, + endBound, + expandStartBound, + expandEndBound + ); + pushBoundMidPoint( + points, + endBound, + startBound, + expandEndBound, + expandStartBound + ); + } + + if (expandStartBound) { + pushWithPriority(points, expandStartBound.getVerticesAndMidpoints()); + pushWithPriority( + points, + expandStartBound.include(lastEndPoint as unknown as IVec).points + ); + } + + if (expandEndBound) { + pushWithPriority(points, expandEndBound.getVerticesAndMidpoints()); + pushWithPriority( + points, + expandEndBound.include(nextStartPoint as unknown as IVec).points + ); + } + + points = removeDulicatePoints(points); + + const sorted = points.map(point => point[0] + ',' + point[1]).sort(); + sorted.forEach((cur, index) => { + if (index === 0) return; + if (cur === sorted[index - 1]) { + throw new Error('duplicate point'); + } + }); + const startEnds = [nextStartPoint, lastEndPoint].map(point => { + return points.find( + item => + almostEqual(item[0], point[0], 0.02) && + almostEqual(item[1], point[1], 0.02) + ); + }) as IVec3[]; + assertExists(startEnds[0]); + assertExists(startEnds[1]); + return { points, nextStartPoint: startEnds[0], lastEndPoint: startEnds[1] }; +} + +function getDirectPath(startPoint: IVec, endPoint: IVec): IVec[] { + if ( + almostEqual(startPoint[0], endPoint[0], 0.02) || + almostEqual(startPoint[1], endPoint[1], 0.02) + ) { + return [startPoint, endPoint]; + } else { + const vec = Vec.sub(endPoint, startPoint); + const mid: IVec = [startPoint[0], startPoint[1] + vec[1]]; + return [startPoint, mid, endPoint]; + } +} + +function mergePath(points: IVec[] | IVec3[]) { + if (points.length === 0) return []; + const result: IVec[] = [[points[0][0], points[0][1]]]; + for (let i = 1; i < points.length - 1; i++) { + const cur = points[i]; + const last = points[i - 1]; + const next = points[i + 1]; + if ( + almostEqual(last[0], cur[0], 0.02) && + almostEqual(cur[0], next[0], 0.02) + ) + continue; + if ( + almostEqual(last[1], cur[1], 0.02) && + almostEqual(cur[1], next[1], 0.02) + ) + continue; + result.push([cur[0], cur[1]]); + } + result.push(last(points) as IVec); + for (let i = 0; i < result.length - 1; i++) { + const cur = result[i]; + const next = result[i + 1]; + try { + assertEquals( + almostEqual(cur[0], next[0], 0.02) || + almostEqual(cur[1], next[1], 0.02), + true + ); + } catch { + console.warn(points); + console.warn(result); + } + } + return result; +} + +function computeOffset(startBound: Bound | null, endBound: Bound | null) { + const startOffset = [20, 20, 20, 20]; + const endOffset = [20, 20, 20, 20]; + if (!(startBound && endBound)) { + return [startOffset, endOffset]; + } + // left, top, right, bottom + let overlap = isOverlap(startBound.upperLine, endBound.lowerLine, 0, false); + let dist: number; + if (overlap && startBound.upperLine[0][1] > endBound.lowerLine[0][1]) { + dist = Vec.distanceToLineSegment( + startBound.upperLine[0], + startBound.upperLine[1], + endBound.lowerLine[0], + false + ); + startOffset[1] = Math.max(Math.min(dist / 2, startOffset[1]), 0); + } + + overlap = isOverlap(startBound.rightLine, endBound.leftLine, 1, false); + if (overlap && startBound.rightLine[0][0] < endBound.leftLine[0][0]) { + dist = Vec.distanceToLineSegment( + startBound.rightLine[0], + startBound.rightLine[1], + endBound.leftLine[0], + false + ); + startOffset[2] = Math.max(Math.min(dist / 2, startOffset[2]), 0); + } + + overlap = isOverlap(startBound.lowerLine, endBound.upperLine, 0, false); + if (overlap && startBound.lowerLine[0][1] < endBound.upperLine[0][1]) { + dist = Vec.distanceToLineSegment( + startBound.lowerLine[0], + startBound.lowerLine[1], + endBound.upperLine[0], + false + ); + startOffset[3] = Math.max(Math.min(dist / 2, startOffset[3]), 0); + } + + startOffset[0] = endOffset[2] = + Math.min(startOffset[0], endOffset[2]) === 0 + ? 20 + : Math.min(startOffset[0], endOffset[2]); + startOffset[1] = endOffset[3] = + Math.min(startOffset[1], endOffset[3]) === 0 + ? 20 + : Math.min(startOffset[1], endOffset[3]); + startOffset[2] = endOffset[0] = + Math.min(startOffset[2], endOffset[0]) === 0 + ? 20 + : Math.min(startOffset[2], endOffset[0]); + startOffset[3] = endOffset[1] = + Math.min(startOffset[3], endOffset[1]) === 0 + ? 20 + : Math.min(startOffset[3], endOffset[1]); + + return [startOffset, endOffset]; +} + +function getNextPoint( + bound: Bound, + point: PointLocation, + offsetX = 10, + offsetY = 10, + offsetW = 10, + offsetH = 10 +) { + const result: IVec = Array.from(point) as IVec; + if (almostEqual(bound.x, result[0])) result[0] -= offsetX; + else if (almostEqual(bound.y, result[1])) result[1] -= offsetY; + else if (almostEqual(bound.maxX, result[0])) result[0] += offsetW; + else if (almostEqual(bound.maxY, result[1])) result[1] += offsetH; + else { + const direction = Vec.normalize(Vec.sub(result, bound.center)); + const xDirection = direction[0] > 0 ? 1 : -1; + const yDirection = direction[1] > 0 ? 1 : -1; + + const slope = + Math.abs(point.tangent[0]) < Math.abs(point.tangent[1]) ? 0 : 1; + // if the slope is big, use the x direction + if (slope === 0) { + if (xDirection > 0) { + const intersects = lineIntersects( + bound.rightLine[0], + bound.rightLine[1], + result, + [bound.maxX + 10, result[1]] + ); + assertExists(intersects); + result[0] = intersects[0] + offsetX; + } else { + const intersects = lineIntersects( + bound.leftLine[0], + bound.leftLine[1], + result, + [bound.x - 10, result[1]] + ); + assertExists(intersects); + result[0] = intersects[0] - offsetX; + } + } else { + if (yDirection > 0) { + const intersects = lineIntersects( + bound.lowerLine[0], + bound.lowerLine[1], + result, + [result[0], bound.maxY + 10] + ); + assertExists(intersects); + result[1] = intersects[1] + offsetY; + } else { + const intersects = lineIntersects( + bound.upperLine[0], + bound.upperLine[1], + result, + [result[0], bound.y - 10] + ); + assertExists(intersects); + result[1] = intersects[1] - offsetY; + } + } + } + return result; +} + +function computeNextStartEndpoint( + startPoint: PointLocation, + endPoint: PointLocation, + startBound: Bound | null, + endBound: Bound | null, + startOffset: number[] | null, + endOffset: number[] | null +) { + const nextStartPoint = + startBound && startOffset + ? getNextPoint( + startBound, + startPoint, + startOffset[0], + startOffset[1], + startOffset[2], + startOffset[3] + ) + : startPoint; + const lastEndPoint = + endBound && endOffset + ? getNextPoint( + endBound, + endPoint, + endOffset[0], + endOffset[1], + endOffset[2], + endOffset[3] + ) + : endPoint; + return [nextStartPoint, lastEndPoint]; +} + +function adjustStartEndPoint( + startPoint: IVec3, + endPoint: IVec3, + startBound: Bound | null = null, + endBound: Bound | null = null +) { + if (!endBound) { + if ( + Math.abs(endPoint[0] - startPoint[0]) > + Math.abs(endPoint[1] - startPoint[1]) + ) { + endPoint[0] += sign(endPoint[0] - startPoint[0]) * 20; + } else { + endPoint[1] += sign(endPoint[1] - startPoint[1]) * 20; + } + } + if (!startBound) { + if ( + Math.abs(endPoint[0] - startPoint[0]) > + Math.abs(endPoint[1] - startPoint[1]) + ) { + startPoint[0] -= sign(endPoint[0] - startPoint[0]) * 20; + } else { + startPoint[1] -= sign(endPoint[1] - startPoint[1]) * 20; + } + } +} + +function renderRect( + ctx: CanvasRenderingContext2D, + bounds: IBound, + color: string, + lineWidth: number +) { + const { x, y, w, h } = bounds; + ctx.save(); + ctx.beginPath(); + ctx.strokeStyle = color; + ctx.lineWidth = lineWidth; + ctx.setLineDash([lineWidth * 2, lineWidth * 2]); + ctx.strokeRect(x, y, w, h); + ctx.closePath(); + ctx.restore(); +} + +export class ConnectionOverlay extends Overlay { + static override overlayName = 'connection'; + + private _emphasisColor: string; + + private _themeDisposer: (() => void) | null = null; + + highlightPoint: IVec | null = null; + + points: IVec[] = []; + + sourceBounds: IBound | null = null; + + targetBounds: IBound | null = null; + + constructor(gfx: GfxController) { + super(gfx); + this._emphasisColor = this._getEmphasisColor(); + this._setupThemeListener(); + } + + private _findConnectablesInViews() { + const gfx = this.gfx; + const bound = gfx.viewport.viewportBounds; + return gfx.getElementsByBound(bound).filter(ele => ele.connectable); + } + + private _getEmphasisColor(): string { + return getComputedStyle(this.gfx.std.host).getPropertyValue( + '--affine-text-emphasis-color' + ); + } + + private _setupThemeListener(): void { + const themeService = this.gfx.std.get(ThemeProvider); + this._themeDisposer = effect(() => { + themeService.theme$; + this._emphasisColor = this._getEmphasisColor(); + }); + } + + _clearRect() { + this.points = []; + this.highlightPoint = null; + this._renderer?.refresh(); + } + + override clear() { + this.sourceBounds = null; + this.targetBounds = null; + this._clearRect(); + } + + override dispose() { + this._themeDisposer?.(); + if (!this._renderer) return; + this._renderer.removeOverlay(this); + this._renderer = null; + } + + override render(ctx: CanvasRenderingContext2D): void { + const zoom = this.gfx.viewport.zoom; + const radius = 5 / zoom; + const color = this._emphasisColor; + ctx.globalAlpha = 0.6; + let lineWidth = 1 / zoom; + if (this.sourceBounds) { + renderRect(ctx, this.sourceBounds, color, lineWidth); + } + if (this.targetBounds) { + renderRect(ctx, this.targetBounds, color, lineWidth); + } + + lineWidth = 2 / zoom; + this.points.forEach(p => { + ctx.beginPath(); + ctx.arc(p[0], p[1], radius, 0, PI2); + ctx.fillStyle = 'white'; + ctx.strokeStyle = color; + ctx.lineWidth = lineWidth; + ctx.fill(); + ctx.stroke(); + ctx.closePath(); + }); + + ctx.globalAlpha = 1; + if (this.highlightPoint) { + ctx.beginPath(); + ctx.arc(this.highlightPoint[0], this.highlightPoint[1], radius, 0, PI2); + ctx.fillStyle = color; + ctx.strokeStyle = color; + ctx.lineWidth = lineWidth; + ctx.fill(); + ctx.stroke(); + ctx.closePath(); + } + } + + /** + * Render the connector at the given point. It will try to find + * the closest connectable element and render the connector. If the + * point is not close to any connectable element, it will just render + * the connector at the given point. + * @param point the point to render the connector + * @param excludedIds the ids of the elements that should be excluded + * @returns the connection result + */ + renderConnector(point: IVec, excludedIds: string[] = []) { + const connectables = this._findConnectablesInViews(); + const context = this.gfx; + const target = []; + + this._clearRect(); + + let result: Connection | null = null; + // eslint-disable-next-line @typescript-eslint/prefer-for-of + for (let i = 0; i < connectables.length; i++) { + const connectable = connectables[i]; + // first check if in excluedIds + if (excludedIds.includes(connectable.id)) continue; + + // then check if in expanded bound + const bound = Bound.deserialize(connectable.xywh); + const rotateBound = Bound.from(getBoundWithRotation(rBound(connectable))); + // FIXME: the real path needs to be expanded: diamod, ellipse, trangle. + if (!rotateBound.expand(10).isPointInBound(point)) continue; + + // then check if closes to anchors + const anchors = getAnchors(connectable); + const len = anchors.length; + const pointerViewCoord = context.viewport.toViewCoord(point[0], point[1]); + + let shortestDistance = Number.POSITIVE_INFINITY; + let j = 0; + + this.points = anchors.map(a => a.point); + + for (; j < len; j++) { + const anchor = anchors[j]; + const anchorViewCoord = context.viewport.toViewCoord( + anchor.point[0], + anchor.point[1] + ); + const d = Vec.dist(anchorViewCoord, pointerViewCoord); + if (d < shortestDistance) { + shortestDistance = d; + target.push(connectable); + this.highlightPoint = anchor.point; + result = { + id: connectable.id, + position: anchor.coord as IVec, + }; + } + } + + if (shortestDistance < 8 && result) break; + + // if not, check if closes to bound + const nearestPoint = connectable.getNearestPoint(point as IVec) as IVec; + + if (Vec.dist(nearestPoint, point) < 8) { + this.highlightPoint = nearestPoint; + const originPoint = getPointFromBoundsWithRotation( + rBound(connectable, true), + nearestPoint + ); + this._renderer?.refresh(); + target.push(connectable); + result = { + id: connectable.id, + position: bound + .toRelative(originPoint) + .map(n => clamp(n, 0, 1)) as IVec, + }; + } + + if (result) continue; + + // if not, check if in inside of the element + if ( + connectable.includesPoint( + point[0], + point[1], + { + ignoreTransparent: false, + }, + this.gfx.std.host + ) + ) { + target.push(connectable); + result = { + id: connectable.id, + }; + } + } + + if (last(target) instanceof GroupElementModel) { + this.targetBounds = Bound.deserialize(last(target)!.xywh); + } else { + this.targetBounds = null; + } + + // at last, if not, just return the point + if (!result) { + result = { + position: point as IVec, + }; + } + + this._renderer?.refresh(); + + return result; + } +} + +export class PathGenerator { + protected _aStarRunner: AStarRunner | null = null; + + protected _prepareOrthogonalConnectorInfo( + connectorInfo: OrthogonalConnectorInput + ): [ + IVec, + IVec, + IVec, + IVec, + Bound | null, + Bound | null, + Bound | null, + Bound | null, + ] { + const { startBound, endBound, startPoint, endPoint } = connectorInfo; + + const [startOffset, endOffset] = computeOffset(startBound, endBound); + const [nextStartPoint, lastEndPoint] = computeNextStartEndpoint( + startPoint, + endPoint, + startBound, + endBound, + startOffset, + endOffset + ); + const expandStartBound = startBound + ? startBound.expand( + startOffset[0], + startOffset[1], + startOffset[2], + startOffset[3] + ) + : null; + const expandEndBound = endBound + ? endBound.expand(endOffset[0], endOffset[1], endOffset[2], endOffset[3]) + : null; + + return [ + startPoint, + endPoint, + nextStartPoint, + lastEndPoint, + startBound, + endBound, + expandStartBound, + expandEndBound, + ]; + } + + generateOrthogonalConnectorPath(input: OrthogonalConnectorInput): IVec[] { + const info = this._prepareOrthogonalConnectorInfo(input); + const [startPoint, endPoint, nextStartPoint, lastEndPoint] = info; + const [, , , , startBound, endBound, expandStartBound, expandEndBound] = + info; + const blocks = []; + const expandBlocks = []; + startBound && blocks.push(startBound.clone()); + endBound && blocks.push(endBound.clone()); + expandStartBound && expandBlocks.push(expandStartBound.clone()); + expandEndBound && expandBlocks.push(expandEndBound.clone()); + + if ( + startBound && + endBound && + startBound.isPointInBound(endPoint) && + endBound.isPointInBound(startPoint) + ) { + return getDirectPath(startPoint, endPoint); + } + + if (startBound && expandStartBound?.isPointInBound(endPoint, 0)) { + return getDirectPath(startPoint, endPoint); + } + + if (endBound && expandEndBound?.isPointInBound(startPoint, 0)) { + return getDirectPath(startPoint, endPoint); + } + + const points = computePoints( + startPoint, + endPoint, + nextStartPoint, + lastEndPoint, + startBound, + endBound, + expandStartBound, + expandEndBound + ); + const finalPoints = points[0]; + const [, startPointV3, endPointV3, nextStartPointV3, lastEndPointV3] = + points; + + adjustStartEndPoint(startPointV3, endPointV3, startBound, endBound); + this._aStarRunner = new AStarRunner( + finalPoints, + nextStartPointV3, + lastEndPointV3, + startPointV3, + endPointV3, + blocks, + expandBlocks + ); + this._aStarRunner.run(); + const path = this._aStarRunner.path; + if (!endBound) path.pop(); + if (!startBound) path.shift(); + + return mergePath(path); + } +} + +export class ConnectorPathGenerator extends PathGenerator { + constructor( + private options: { + getElementById: (id: string) => GfxModel | null; + } + ) { + super(); + } + + static updatePath( + connector: ConnectorElementModel | LocalConnectorElementModel, + path: PointLocation[] | null, + elementGetter?: (id: string) => GfxModel | null + ) { + const instance = new ConnectorPathGenerator({ + getElementById: elementGetter ?? (() => null), + }); + const points = path ?? instance._generateConnectorPath(connector) ?? []; + const bound = + connector.mode === ConnectorMode.Curve + ? getBezierCurveBoundingBox(getBezierParameters(points)) + : getBoundFromPoints(points); + const relativePoints = points.map((p: PointLocation) => { + return p.setVec(Vec.sub(p, [bound.x, bound.y])); + }); + + connector.updatingPath = true; + // the property assignment order matters + connector.xywh = bound.serialize(); + connector.path = relativePoints; + + // Updates Connector's Label position. + if (isConnectorWithLabel(connector)) { + const model = connector as ConnectorElementModel; + const [cx, cy] = model.getPointByOffsetDistance( + model.labelOffset.distance + ); + const [, , w, h] = model.labelXYWH!; + model.labelXYWH = [cx - w / 2, cy - h / 2, w, h]; + } + + connector.updatingPath = false; + } + + private _computeStartEndPoint( + connector: ConnectorElementModel | LocalConnectorElementModel + ) { + const { source, target } = connector; + const start = this._getConnectorEndElement(connector, 'source'); + const end = this._getConnectorEndElement(connector, 'target'); + + let startPoint: PointLocation | null = null; + let endPoint: PointLocation | null = null; + if (source.id && !source.position && target.id && !target.position) { + assertExists(start); + assertExists(end); + const startAnchors = getAnchors(start); + const endAnchors = getAnchors(end); + let minDist = Infinity; + let minStartAnchor = new PointLocation(); + let minEndAnchor = new PointLocation(); + for (const sa of startAnchors) { + for (const ea of endAnchors) { + const dist = Vec.dist(sa.point, ea.point); + if (dist + 0.1 < minDist) { + minDist = dist; + minStartAnchor = sa.point; + minEndAnchor = ea.point; + } + } + } + startPoint = minStartAnchor; + endPoint = minEndAnchor; + } else { + startPoint = this._getConnectionPoint(connector, 'source'); + endPoint = this._getConnectionPoint(connector, 'target'); + } + + if (!startPoint || !endPoint) return []; + + return [startPoint, endPoint]; + } + + private _generateConnectorPath( + connector: ConnectorElementModel | LocalConnectorElementModel + ) { + const { mode } = connector; + if (mode === ConnectorMode.Straight) { + return this._generateStraightConnectorPath(connector); + } else if (mode === ConnectorMode.Orthogonal) { + const start = this._getConnectorEndElement(connector, 'source'); + const end = this._getConnectorEndElement(connector, 'target'); + + const [startPoint, endPoint] = this._computeStartEndPoint(connector); + + const startBound = start + ? Bound.from(getBoundWithRotation(rBound(start))) + : null; + const endBound = end + ? Bound.from(getBoundWithRotation(rBound(end))) + : null; + const path = this.generateOrthogonalConnectorPath({ + startPoint, + endPoint, + startBound, + endBound, + }); + return path.map(p => new PointLocation(p)); + } else if (mode === ConnectorMode.Curve) { + return this._generateCurveConnectorPath(connector); + } + throw new Error('unknown connector mode'); + } + + private _generateCurveConnectorPath( + connector: ConnectorElementModel | LocalConnectorElementModel + ) { + const { source, target } = connector; + let startPoint: PointLocation | null = null; + let endPoint: PointLocation | null = null; + + if (source.id || target.id) { + if (!source.position && !target.position) { + const start = this._getConnectorEndElement( + connector, + 'source' + ) as Connectable; + const end = this._getConnectorEndElement( + connector, + 'target' + ) as Connectable; + const sb = Bound.deserialize(start.xywh); + const eb = Bound.deserialize(end.xywh); + startPoint = getNearestConnectableAnchor(start, eb.center); + endPoint = getNearestConnectableAnchor(end, sb.center); + } else { + startPoint = this._getConnectionPoint(connector, 'source'); + endPoint = this._getConnectionPoint(connector, 'target'); + } + + if (!startPoint || !endPoint) return []; + + if (source.id) { + const startTangentVertical = Vec.rot(startPoint.tangent, -Math.PI / 2); + startPoint.out = Vec.mul( + startTangentVertical, + Math.max( + 100, + Math.abs( + Vec.pry(Vec.sub(endPoint, startPoint), startTangentVertical) + ) / 3 + ) + ); + } + if (target.id) { + const endTangentVertical = Vec.rot(endPoint.tangent, -Math.PI / 2); + endPoint.in = Vec.mul( + endTangentVertical, + Math.max( + 100, + Math.abs( + Vec.pry(Vec.sub(startPoint, endPoint), endTangentVertical) + ) / 3 + ) + ); + } + return [startPoint, endPoint]; + } else { + startPoint = this._getConnectionPoint(connector, 'source'); + endPoint = this._getConnectionPoint(connector, 'target'); + + if (!startPoint || !endPoint) return []; + + if ( + Math.abs(endPoint[0] - startPoint[0]) > + Math.abs(endPoint[1] - startPoint[1]) + ) { + startPoint.out = [Vec.mul(Vec.sub(endPoint, startPoint), 2 / 3)[0], 0]; + endPoint.in = [Vec.mul(Vec.sub(startPoint, endPoint), 2 / 3)[0], 0]; + } else { + startPoint.out = [0, Vec.mul(Vec.sub(endPoint, startPoint), 2 / 3)[1]]; + endPoint.in = [0, Vec.mul(Vec.sub(startPoint, endPoint), 2 / 3)[1]]; + } + return [startPoint, endPoint]; + } + } + + private _generateStraightConnectorPath( + connector: ConnectorElementModel | LocalConnectorElementModel + ) { + const { source, target } = connector; + if (source.id && !source.position && target.id && !target.position) { + const start = this._getConnectorEndElement( + connector, + 'source' + ) as Connectable; + const end = this._getConnectorEndElement( + connector, + 'target' + ) as Connectable; + const sb = Bound.deserialize(start.xywh); + const eb = Bound.deserialize(end.xywh); + const startPoint = getNearestConnectableAnchor(start, eb.center); + const endPoint = getNearestConnectableAnchor(end, sb.center); + return [startPoint, endPoint]; + } else { + const endPoint = this._getConnectionPoint(connector, 'target'); + const startPoint = this._getConnectionPoint(connector, 'source'); + return (startPoint && endPoint && [startPoint, endPoint]) ?? []; + } + } + + private _getConnectionPoint( + connector: ConnectorElementModel | LocalConnectorElementModel, + type: 'source' | 'target' + ): PointLocation | null { + const connection = connector[type]; + + if (!connection) return null; + + const connectable = this._getConnectorEndElement(connector, type); + + if (!connectable && connection.position) { + return PointLocation.fromVec(connection.position); + } + + if (!connectable) return null; + + let point: PointLocation | null = null; + + if (connection.position) { + point = getConnectableRelativePosition(connectable, connection.position); + } else { + const anotherType = type === 'source' ? 'target' : 'source'; + const otherPoint = this._getConnectionPoint(connector, anotherType); + if (otherPoint) { + point = getNearestConnectableAnchor(connectable, otherPoint); + } + } + + return point; + } + + private _getConnectorEndElement( + connector: ConnectorElementModel | LocalConnectorElementModel, + type: 'source' | 'target' + ): Connectable | null { + const id = connector[type].id; + + if (id) { + return this.options.getElementById(id) as Connectable; + } + + return null; + } + + hasRelatedElement( + connecter: ConnectorElementModel | LocalConnectorElementModel + ) { + const { source, target } = connecter; + if ( + (source.id && !this.options.getElementById(source.id)) || + (target.id && !this.options.getElementById(target.id)) + ) { + return false; + } + + return true; + } +} diff --git a/blocksuite/affine/block-surface/src/renderer/canvas-renderer.ts b/blocksuite/affine/block-surface/src/renderer/canvas-renderer.ts new file mode 100644 index 0000000000..3c1e242e07 --- /dev/null +++ b/blocksuite/affine/block-surface/src/renderer/canvas-renderer.ts @@ -0,0 +1,440 @@ +import { type Color, ColorScheme } from '@blocksuite/affine-model'; +import { requestConnectedFrame } from '@blocksuite/affine-shared/utils'; +import type { + GridManager, + LayerManager, + SurfaceBlockModel, + Viewport, +} from '@blocksuite/block-std/gfx'; +import type { IBound } from '@blocksuite/global/utils'; +import { + DisposableGroup, + getBoundWithRotation, + intersects, + last, + Slot, +} from '@blocksuite/global/utils'; + +import type { SurfaceElementModel } from '../element-model/base.js'; +import { RoughCanvas } from '../utils/rough/canvas.js'; +import type { ElementRenderer } from './elements/index.js'; +import type { Overlay } from './overlay.js'; + +type EnvProvider = { + generateColorProperty: (color: Color, fallback: string) => string; + getColorScheme: () => ColorScheme; + getColorValue: (color: Color, fallback?: string, real?: boolean) => string; + getPropertyValue: (property: string) => string; + selectedElements?: () => string[]; +}; + +type RendererOptions = { + viewport: Viewport; + layerManager: LayerManager; + provider?: Partial; + enableStackingCanvas?: boolean; + onStackingCanvasCreated?: (canvas: HTMLCanvasElement) => void; + elementRenderers: Record; + gridManager: GridManager; + surfaceModel: SurfaceBlockModel; +}; + +export class CanvasRenderer { + private _container!: HTMLElement; + + private _disposables = new DisposableGroup(); + + private _overlays = new Set(); + + private _refreshRafId: number | null = null; + + private _stackingCanvas: HTMLCanvasElement[] = []; + + canvas: HTMLCanvasElement; + + ctx: CanvasRenderingContext2D; + + elementRenderers: Record; + + grid: GridManager; + + layerManager: LayerManager; + + provider: Partial; + + stackingCanvasUpdated = new Slot<{ + canvases: HTMLCanvasElement[]; + added: HTMLCanvasElement[]; + removed: HTMLCanvasElement[]; + }>(); + + viewport: Viewport; + + get stackingCanvas() { + return this._stackingCanvas; + } + + constructor(options: RendererOptions) { + const canvas = document.createElement('canvas'); + + this.canvas = canvas; + this.ctx = this.canvas.getContext('2d') as CanvasRenderingContext2D; + this.viewport = options.viewport; + this.layerManager = options.layerManager; + this.grid = options.gridManager; + this.provider = options.provider ?? {}; + this.elementRenderers = options.elementRenderers; + this._initViewport(); + + options.enableStackingCanvas = options.enableStackingCanvas ?? false; + if (options.enableStackingCanvas) { + this._initStackingCanvas(options.onStackingCanvasCreated); + } + + this._watchSurface(options.surfaceModel); + } + + /** + * Specifying the actual size gives better results and more consistent behavior across browsers. + * + * Make sure the main canvas and the offscreen canvas or layer canvas are the same size. + * + * It is not recommended to set width and height to 100%. + */ + private _canvasSizeUpdater(dpr = window.devicePixelRatio) { + const { width, height } = this.viewport; + const actualWidth = Math.ceil(width * dpr); + const actualHeight = Math.ceil(height * dpr); + + return { + filter({ width, height }: HTMLCanvasElement) { + return width !== actualWidth || height !== actualHeight; + }, + update(canvas: HTMLCanvasElement) { + canvas.style.width = `${width}px`; + canvas.style.height = `${height}px`; + canvas.width = actualWidth; + canvas.height = actualHeight; + }, + }; + } + + private _initStackingCanvas(onCreated?: (canvas: HTMLCanvasElement) => void) { + const layer = this.layerManager; + const updateStackingCanvasSize = (canvases: HTMLCanvasElement[]) => { + this._stackingCanvas = canvases; + + const sizeUpdater = this._canvasSizeUpdater(); + + canvases.filter(sizeUpdater.filter).forEach(sizeUpdater.update); + }; + const updateStackingCanvas = () => { + /** + * we already have a main canvas, so the last layer should be skipped + */ + const canvasLayers = layer.getCanvasLayers().slice(0, -1); + const canvases = []; + const currentCanvases = this._stackingCanvas; + const lastLayer = last(this.layerManager.layers); + const maximumZIndex = lastLayer + ? lastLayer.zIndex + lastLayer.elements.length + 1 + : 1; + + this.canvas.style.zIndex = maximumZIndex.toString(); + + for (let i = 0; i < canvasLayers.length; ++i) { + const layer = canvasLayers[i]; + const created = i < currentCanvases.length; + const canvas = created + ? currentCanvases[i] + : document.createElement('canvas'); + + if (!created) { + onCreated?.(canvas); + } + + canvas.dataset.layerId = `[${layer.indexes[0]}--${layer.indexes[1]}]`; + canvas.style.zIndex = layer.zIndex.toString(); + canvases.push(canvas); + } + + this._stackingCanvas = canvases; + updateStackingCanvasSize(canvases); + + if (currentCanvases.length !== canvases.length) { + const diff = canvases.length - currentCanvases.length; + const payload: { + canvases: HTMLCanvasElement[]; + removed: HTMLCanvasElement[]; + added: HTMLCanvasElement[]; + } = { + canvases, + removed: [], + added: [], + }; + + if (diff > 0) { + payload.added = canvases.slice(-diff); + } else { + payload.removed = currentCanvases.slice(diff); + } + + this.stackingCanvasUpdated.emit(payload); + } + + this.refresh(); + }; + + this._disposables.add( + this.layerManager.slots.layerUpdated.on(() => { + updateStackingCanvas(); + }) + ); + + updateStackingCanvas(); + } + + private _initViewport() { + let sizeUpdatedRafId: number | null = null; + + this._disposables.add( + this.viewport.viewportUpdated.on(() => { + this.refresh(); + }) + ); + + this._disposables.add( + this.viewport.sizeUpdated.on(() => { + if (sizeUpdatedRafId) return; + sizeUpdatedRafId = requestConnectedFrame(() => { + sizeUpdatedRafId = null; + this._resetSize(); + this._render(); + this.refresh(); + }, this._container); + }) + ); + } + + private _render() { + const { viewportBounds, zoom } = this.viewport; + const { ctx } = this; + const dpr = window.devicePixelRatio; + const scale = zoom * dpr; + const matrix = new DOMMatrix().scaleSelf(scale); + /** + * if a layer does not have a corresponding canvas + * its element will be add to this array and drawing on the + * main canvas + */ + let fallbackElement: SurfaceElementModel[] = []; + + this.layerManager.getCanvasLayers().forEach((layer, idx) => { + if (!this._stackingCanvas[idx]) { + fallbackElement = fallbackElement.concat(layer.elements); + return; + } + + const canvas = this._stackingCanvas[idx]; + const ctx = canvas.getContext('2d') as CanvasRenderingContext2D; + const rc = new RoughCanvas(ctx.canvas); + + ctx.clearRect(0, 0, canvas.width, canvas.height); + ctx.save(); + ctx.setTransform(matrix); + + this._renderByBound(ctx, matrix, rc, viewportBounds, layer.elements); + }); + + ctx.clearRect(0, 0, this.canvas.width, this.canvas.height); + ctx.save(); + + ctx.setTransform(matrix); + + this._renderByBound( + ctx, + matrix, + new RoughCanvas(ctx.canvas), + viewportBounds, + fallbackElement, + true + ); + } + + private _renderByBound( + ctx: CanvasRenderingContext2D | null, + matrix: DOMMatrix, + rc: RoughCanvas, + bound: IBound, + surfaceElements?: SurfaceElementModel[], + overLay: boolean = false + ) { + if (!ctx) return; + + const elements = + surfaceElements ?? + (this.grid.search(bound, { + filter: ['canvas', 'local'], + }) as SurfaceElementModel[]); + + for (const element of elements) { + const display = (element.display ?? true) && !element.hidden; + if (display && intersects(getBoundWithRotation(element), bound)) { + const renderFn = + this.elementRenderers[ + element.type as keyof typeof this.elementRenderers + ]; + + if (!renderFn) { + console.warn(`Cannot find renderer for ${element.type}`); + continue; + } + + ctx.save(); + + ctx.globalAlpha = element.opacity ?? 1; + const dx = element.x - bound.x; + const dy = element.y - bound.y; + + renderFn(element, ctx, matrix.translate(dx, dy), this, rc, bound); + ctx.restore(); + } + } + + if (overLay) { + for (const overlay of this._overlays) { + ctx.save(); + ctx.translate(-bound.x, -bound.y); + overlay.render(ctx, rc); + ctx.restore(); + } + } + + ctx.restore(); + } + + private _resetSize() { + const sizeUpdater = this._canvasSizeUpdater(); + + sizeUpdater.update(this.canvas); + + this._stackingCanvas.forEach(sizeUpdater.update); + this.refresh(); + } + + private _watchSurface(surfaceModel: SurfaceBlockModel) { + const slots = [ + 'elementAdded', + 'elementRemoved', + 'localElementAdded', + 'localElementDeleted', + 'localElementUpdated', + ] as const; + + slots.forEach(slotName => { + this._disposables.add(surfaceModel[slotName].on(() => this.refresh())); + }); + + this._disposables.add( + surfaceModel.elementUpdated.on(payload => { + // ignore externalXYWH update cause it's updated by the renderer + if (payload.props['externalXYWH']) return; + this.refresh(); + }) + ); + } + + addOverlay(overlay: Overlay) { + overlay.setRenderer(this); + this._overlays.add(overlay); + this.refresh(); + } + + /** + * Used to attach main canvas, main canvas will always exist + * @param container + */ + attach(container: HTMLElement) { + this._container = container; + container.append(this.canvas); + + this._resetSize(); + this.refresh(); + } + + dispose(): void { + this._overlays.forEach(overlay => overlay.dispose()); + this._overlays.clear(); + this._disposables.dispose(); + } + + generateColorProperty(color: Color, fallback: string) { + return ( + this.provider.generateColorProperty?.(color, fallback) ?? + (fallback.startsWith('--') ? `var(${fallback})` : fallback) + ); + } + + getCanvasByBound( + bound: IBound = this.viewport.viewportBounds, + surfaceElements?: SurfaceElementModel[], + canvas?: HTMLCanvasElement, + clearBeforeDrawing?: boolean, + withZoom?: boolean + ): HTMLCanvasElement { + canvas = canvas || document.createElement('canvas'); + + const dpr = window.devicePixelRatio || 1; + if (canvas.width !== bound.w * dpr) canvas.width = bound.w * dpr; + if (canvas.height !== bound.h * dpr) canvas.height = bound.h * dpr; + + canvas.style.width = `${bound.w}px`; + canvas.style.height = `${bound.h}px`; + + const ctx = canvas.getContext('2d') as CanvasRenderingContext2D; + const matrix = new DOMMatrix().scaleSelf( + withZoom ? dpr * this.viewport.zoom : dpr + ); + const rc = new RoughCanvas(canvas); + + if (clearBeforeDrawing) ctx.clearRect(0, 0, canvas.width, canvas.height); + ctx.setTransform(matrix); + + this._renderByBound(ctx, matrix, rc, bound, surfaceElements); + + return canvas; + } + + getColorScheme() { + return this.provider.getColorScheme?.() ?? ColorScheme.Light; + } + + getColorValue(color: Color, fallback?: string, real?: boolean) { + return ( + this.provider.getColorValue?.(color, fallback, real) ?? 'transparent' + ); + } + + getPropertyValue(property: string) { + return this.provider.getPropertyValue?.(property) ?? ''; + } + + refresh() { + if (this._refreshRafId !== null) return; + + this._refreshRafId = requestConnectedFrame(() => { + this._refreshRafId = null; + this._render(); + }, this._container); + } + + removeOverlay(overlay: Overlay) { + if (!this._overlays.has(overlay)) { + return; + } + + overlay.setRenderer(null); + this._overlays.delete(overlay); + this.refresh(); + } +} diff --git a/blocksuite/affine/block-surface/src/renderer/elements/brush/index.ts b/blocksuite/affine/block-surface/src/renderer/elements/brush/index.ts new file mode 100644 index 0000000000..6dc720828d --- /dev/null +++ b/blocksuite/affine/block-surface/src/renderer/elements/brush/index.ts @@ -0,0 +1,25 @@ +import type { BrushElementModel } from '@blocksuite/affine-model'; + +import type { CanvasRenderer } from '../../canvas-renderer.js'; + +export function brush( + model: BrushElementModel, + ctx: CanvasRenderingContext2D, + matrix: DOMMatrix, + renderer: CanvasRenderer +) { + const { rotate } = model; + const [, , w, h] = model.deserializedXYWH; + const cx = w / 2; + const cy = h / 2; + + ctx.setTransform( + matrix.translateSelf(cx, cy).rotateSelf(rotate).translateSelf(-cx, -cy) + ); + + const color = renderer.getColorValue(model.color, '#000000', true); + + ctx.fillStyle = color; + + ctx.fill(new Path2D(model.commands)); +} diff --git a/blocksuite/affine/block-surface/src/renderer/elements/connector/index.ts b/blocksuite/affine/block-surface/src/renderer/elements/connector/index.ts new file mode 100644 index 0000000000..b4df3468f6 --- /dev/null +++ b/blocksuite/affine/block-surface/src/renderer/elements/connector/index.ts @@ -0,0 +1,298 @@ +import { + type ConnectorElementModel, + ConnectorMode, + type LocalConnectorElementModel, + type PointStyle, +} from '@blocksuite/affine-model'; +import { + getBezierParameters, + type PointLocation, +} from '@blocksuite/global/utils'; +import { deltaInsertsToChunks } from '@blocksuite/inline'; + +import { isConnectorWithLabel } from '../../../managers/connector-manager.js'; +import type { RoughCanvas } from '../../../utils/rough/canvas.js'; +import type { CanvasRenderer } from '../../canvas-renderer.js'; +import { + getFontString, + getLineHeight, + getTextWidth, + isRTL, + type TextDelta, + wrapTextDeltas, +} from '../text/utils.js'; +import { + DEFAULT_ARROW_SIZE, + getArrowOptions, + renderArrow, + renderCircle, + renderDiamond, + renderTriangle, +} from './utils.js'; + +export function connector( + model: ConnectorElementModel | LocalConnectorElementModel, + ctx: CanvasRenderingContext2D, + matrix: DOMMatrix, + renderer: CanvasRenderer, + rc: RoughCanvas +) { + const { + mode, + path: points, + strokeStyle, + frontEndpointStyle, + rearEndpointStyle, + strokeWidth, + } = model; + + // points might not be build yet in some senarios + // eg. undo/redo, copy/paste + if (!points.length || points.length < 2) { + return; + } + + ctx.setTransform(matrix); + + const hasLabel = isConnectorWithLabel(model); + let dx = 0; + let dy = 0; + + if (hasLabel) { + ctx.save(); + + const { deserializedXYWH, labelXYWH } = model as ConnectorElementModel; + const [x, y, w, h] = deserializedXYWH; + const [lx, ly, lw, lh] = labelXYWH!; + const offset = DEFAULT_ARROW_SIZE * strokeWidth; + + dx = lx - x; + dy = ly - y; + + const path = new Path2D(); + path.rect(-offset / 2, -offset / 2, w + offset, h + offset); + path.rect(dx - 3 - 0.5, dy - 3 - 0.5, lw + 6 + 1, lh + 6 + 1); + ctx.clip(path, 'evenodd'); + } + + const strokeColor = renderer.getColorValue(model.stroke, '#000000', true); + + renderPoints( + model, + ctx, + rc, + points, + strokeStyle === 'dash', + mode === ConnectorMode.Curve, + strokeColor + ); + renderEndpoint( + model, + points, + ctx, + rc, + 'Front', + frontEndpointStyle, + strokeColor + ); + renderEndpoint( + model, + points, + ctx, + rc, + 'Rear', + rearEndpointStyle, + strokeColor + ); + + if (hasLabel) { + ctx.restore(); + + renderLabel( + model as ConnectorElementModel, + ctx, + matrix.translate(dx, dy), + renderer + ); + } +} + +function renderPoints( + model: ConnectorElementModel | LocalConnectorElementModel, + ctx: CanvasRenderingContext2D, + rc: RoughCanvas, + points: PointLocation[], + dash: boolean, + curve: boolean, + stroke: string +) { + const { seed, strokeWidth, roughness, rough } = model; + + if (rough) { + const options = { + seed, + roughness, + stroke, + strokeLineDash: dash ? [12, 12] : undefined, + strokeWidth, + }; + if (curve) { + const b = getBezierParameters(points); + rc.path( + `M${b[0][0]},${b[0][1]} C${b[1][0]},${b[1][1]} ${b[2][0]},${b[2][1]} ${b[3][0]},${b[3][1]}`, + options + ); + } else { + rc.linearPath(points as unknown as [number, number][], options); + } + } else { + ctx.save(); + ctx.strokeStyle = stroke; + ctx.lineWidth = strokeWidth; + ctx.lineJoin = 'round'; + ctx.lineCap = 'round'; + dash && ctx.setLineDash([12, 12]); + ctx.beginPath(); + if (curve) { + points.forEach((point, index) => { + if (index === 0) { + ctx.moveTo(point[0], point[1]); + } else { + const last = points[index - 1]; + ctx.bezierCurveTo( + last.absOut[0], + last.absOut[1], + point.absIn[0], + point.absIn[1], + point[0], + point[1] + ); + } + }); + } else { + points.forEach((point, index) => { + if (index === 0) { + ctx.moveTo(point[0], point[1]); + } else { + ctx.lineTo(point[0], point[1]); + } + }); + } + ctx.stroke(); + ctx.closePath(); + ctx.restore(); + } +} + +function renderEndpoint( + model: ConnectorElementModel | LocalConnectorElementModel, + location: PointLocation[], + ctx: CanvasRenderingContext2D, + rc: RoughCanvas, + end: 'Front' | 'Rear', + style: PointStyle, + stroke: string +) { + const arrowOptions = getArrowOptions(end, model, stroke); + + switch (style) { + case 'Arrow': + renderArrow(location, ctx, rc, arrowOptions); + break; + case 'Triangle': + renderTriangle(location, ctx, rc, arrowOptions); + break; + case 'Circle': + renderCircle(location, ctx, rc, arrowOptions); + break; + case 'Diamond': + renderDiamond(location, ctx, rc, arrowOptions); + break; + } +} + +function renderLabel( + model: ConnectorElementModel, + ctx: CanvasRenderingContext2D, + matrix: DOMMatrix, + renderer: CanvasRenderer +) { + const { + text, + labelXYWH, + labelStyle: { + color, + fontSize, + fontWeight, + fontStyle, + fontFamily, + textAlign, + }, + labelConstraints: { hasMaxWidth, maxWidth }, + } = model; + const font = getFontString({ + fontStyle, + fontWeight, + fontSize, + fontFamily, + }); + const [, , w, h] = labelXYWH!; + const cx = w / 2; + const cy = h / 2; + const deltas = wrapTextDeltas(text!, font, w); + const lines = deltaInsertsToChunks(deltas); + const lineHeight = getLineHeight(fontFamily, fontSize, fontWeight); + const textHeight = (lines.length - 1) * lineHeight * 0.5; + + ctx.setTransform(matrix); + + ctx.font = font; + ctx.textAlign = textAlign; + ctx.textBaseline = 'middle'; + ctx.fillStyle = renderer.getColorValue(color, '#000000', true); + + let textMaxWidth = textAlign === 'center' ? 0 : getMaxTextWidth(lines, font); + if (hasMaxWidth && maxWidth > 0) { + textMaxWidth = Math.min(textMaxWidth, textMaxWidth); + } + + for (const [index, line] of lines.entries()) { + for (const delta of line) { + const str = delta.insert; + const rtl = isRTL(str); + const shouldTemporarilyAttach = rtl && !ctx.canvas.isConnected; + if (shouldTemporarilyAttach) { + // to correctly render RTL text mixed with LTR, we have to append it + // to the DOM + document.body.append(ctx.canvas); + } + + ctx.canvas.setAttribute('dir', rtl ? 'rtl' : 'ltr'); + + const x = + textMaxWidth * + (textAlign === 'center' + ? 1 + : textAlign === 'right' + ? rtl + ? -0.5 + : 0.5 + : rtl + ? 0.5 + : -0.5); + ctx.fillText(str, x + cx, index * lineHeight - textHeight + cy); + + if (shouldTemporarilyAttach) { + ctx.canvas.remove(); + } + } + } +} + +function getMaxTextWidth(lines: TextDelta[][], font: string) { + return Math.max( + ...lines.flatMap(line => + line.map(delta => getTextWidth(delta.insert, font)) + ) + ); +} diff --git a/blocksuite/affine/block-surface/src/renderer/elements/connector/utils.ts b/blocksuite/affine/block-surface/src/renderer/elements/connector/utils.ts new file mode 100644 index 0000000000..7ecdf23a60 --- /dev/null +++ b/blocksuite/affine/block-surface/src/renderer/elements/connector/utils.ts @@ -0,0 +1,313 @@ +import { + type ConnectorElementModel, + ConnectorMode, + type LocalConnectorElementModel, +} from '@blocksuite/affine-model'; +import type { + BezierCurveParameters, + IVec, + PointLocation, +} from '@blocksuite/global/utils'; +import { + getBezierParameters, + getBezierTangent, + Vec, +} from '@blocksuite/global/utils'; + +import type { RoughCanvas } from '../../../utils/rough/canvas.js'; + +type ConnectorEnd = 'Front' | 'Rear'; + +export const DEFAULT_ARROW_SIZE = 15; + +export function getArrowPoints( + points: PointLocation[], + size = 10, + mode: ConnectorMode, + bezierParameters: BezierCurveParameters, + endPoint: ConnectorEnd = 'Rear', + radians: number = Math.PI / 4 +) { + const anchorPoint = getPointWithTangent( + points, + mode, + endPoint, + bezierParameters + ); + const unit = Vec.mul(anchorPoint.tangent, -1); + const angle = endPoint === 'Front' ? Math.PI : 0; + + return { + points: [ + Vec.add(Vec.mul(Vec.rot(unit, angle + radians), size), anchorPoint), + anchorPoint, + Vec.add(Vec.mul(Vec.rot(unit, angle - radians), size), anchorPoint), + ], + }; +} + +export function getCircleCenterPoint( + points: PointLocation[], + radius = 5, + mode: ConnectorMode, + bezierParameters: BezierCurveParameters, + endPoint: ConnectorEnd = 'Rear' +) { + const anchorPoint = getPointWithTangent( + points, + mode, + endPoint, + bezierParameters + ); + + const unit = Vec.mul(anchorPoint.tangent, -1); + const angle = endPoint === 'Front' ? Math.PI : 0; + + return Vec.add(Vec.mul(Vec.rot(unit, angle), radius), anchorPoint); +} + +export function getPointWithTangent( + points: PointLocation[], + mode: ConnectorMode, + endPoint: ConnectorEnd, + bezierParameters: BezierCurveParameters +) { + const anchorIndex = endPoint === 'Rear' ? points.length - 1 : 0; + const pointToAnchorIndex = + endPoint === 'Rear' ? anchorIndex - 1 : anchorIndex + 1; + const anchorPoint = points[anchorIndex]; + const pointToAnchor = points[pointToAnchorIndex]; + + const clone = anchorPoint.clone(); + let tangent; + if (mode !== ConnectorMode.Curve) { + tangent = + endPoint === 'Rear' + ? Vec.tangent(anchorPoint, pointToAnchor) + : Vec.tangent(pointToAnchor, anchorPoint); + } else { + tangent = + endPoint === 'Rear' + ? getBezierTangent(bezierParameters, 1) + : getBezierTangent(bezierParameters, 0); + } + clone.tangent = tangent ?? [0, 0]; + + return clone; +} + +export function getDiamondPoints( + point: PointLocation, + size = 10, + endPoint: ConnectorEnd = 'Rear' +) { + const unit = Vec.mul(point.tangent, -1); + const angle = endPoint === 'Front' ? Math.PI : 0; + + const diamondPoints = [ + Vec.add(Vec.mul(Vec.rot(unit, angle + Math.PI * 0.25), size), point), + point, + Vec.add(Vec.mul(Vec.rot(unit, angle - Math.PI * 0.25), size), point), + Vec.add(Vec.mul(Vec.rot(unit, angle), size * Math.sqrt(2)), point), + ]; + + return { + points: diamondPoints, + }; +} + +export type ArrowOptions = ReturnType; + +export function getArrowOptions( + end: ConnectorEnd, + model: ConnectorElementModel | LocalConnectorElementModel, + strokeColor: string +) { + const { seed, mode, rough, roughness, strokeWidth, path } = model; + + return { + end, + seed, + mode, + rough, + roughness, + strokeWidth, + strokeColor, + fillColor: strokeColor, + fillStyle: 'solid', + bezierParameters: getBezierParameters(path), + }; +} + +export function getRcOptions(options: ArrowOptions) { + const { seed, roughness, strokeWidth, strokeColor, fillColor } = options; + return { + seed, + roughness, + stroke: strokeColor, + strokeWidth, + fill: fillColor, + fillStyle: 'solid', + }; +} + +export function renderRoundedPolygon( + ctx: CanvasRenderingContext2D, + points: IVec[], + color: string, + strokeWidth: number, + fill: boolean = true +) { + ctx.fillStyle = color; + ctx.strokeStyle = color; + ctx.lineWidth = strokeWidth; + ctx.lineJoin = 'round'; + ctx.lineCap = 'round'; + ctx.save(); + ctx.beginPath(); + + for (let i = 0; i < points.length; i++) { + if (i === 0) { + ctx.moveTo(points[i][0], points[i][1]); + } else { + ctx.lineTo(points[i][0], points[i][1]); + } + } + + if (fill) { + ctx.closePath(); + ctx.fill(); + } + + ctx.stroke(); + ctx.restore(); +} + +export function renderArrow( + points: PointLocation[], + ctx: CanvasRenderingContext2D, + rc: RoughCanvas, + options: ArrowOptions +) { + const { mode, end, bezierParameters, rough, strokeColor, strokeWidth } = + options; + const radians = Math.PI / 4; + const size = DEFAULT_ARROW_SIZE * (strokeWidth / 2); + const { points: arrowPoints } = getArrowPoints( + points, + size, + mode, + bezierParameters, + end, + radians + ); + + if (rough) { + rc.linearPath(arrowPoints as [number, number][], getRcOptions(options)); + } else { + renderRoundedPolygon(ctx, arrowPoints, strokeColor, strokeWidth, false); + } +} + +export function renderTriangle( + points: PointLocation[], + ctx: CanvasRenderingContext2D, + rc: RoughCanvas, + options: ArrowOptions +) { + const { mode, end, bezierParameters, rough, strokeColor, strokeWidth } = + options; + const radians = Math.PI / 6; + const size = DEFAULT_ARROW_SIZE * (strokeWidth / 2); + const { points: trianglePoints } = getArrowPoints( + points, + size, + mode, + bezierParameters, + end, + radians + ); + + if (rough) { + rc.polygon( + [ + [trianglePoints[0][0], trianglePoints[0][1]], + [trianglePoints[1][0], trianglePoints[1][1]], + [trianglePoints[2][0], trianglePoints[2][1]], + ], + getRcOptions(options) + ); + } else { + renderRoundedPolygon(ctx, trianglePoints, strokeColor, strokeWidth); + } +} + +export function renderDiamond( + points: PointLocation[], + ctx: CanvasRenderingContext2D, + rc: RoughCanvas, + options: ArrowOptions +) { + const { mode, end, rough, bezierParameters, strokeColor, strokeWidth } = + options; + const anchorPoint = getPointWithTangent(points, mode, end, bezierParameters); + const size = 10 * (strokeWidth / 2); + const { points: diamondPoints } = getDiamondPoints(anchorPoint, size, end); + + if (rough) { + rc.polygon( + [ + [diamondPoints[0][0], diamondPoints[0][1]], + [diamondPoints[1][0], diamondPoints[1][1]], + [diamondPoints[2][0], diamondPoints[2][1]], + [diamondPoints[3][0], diamondPoints[3][1]], + ], + getRcOptions(options) + ); + } else { + renderRoundedPolygon(ctx, diamondPoints, strokeColor, strokeWidth); + } +} + +export function renderCircle( + points: PointLocation[], + ctx: CanvasRenderingContext2D, + rc: RoughCanvas, + options: ArrowOptions +) { + const { + bezierParameters, + mode, + end, + fillColor, + strokeColor, + strokeWidth, + rough, + } = options; + const radius = 5 * (strokeWidth / 2); + const centerPoint = getCircleCenterPoint( + points, + radius, + mode, + bezierParameters, + end + ); + const cx = centerPoint[0]; + const cy = centerPoint[1]; + + if (rough) { + // radius + 2 when render rough circle to avoid connector line cross the circle and make it looks bad + rc.circle(cx, cy, radius + 2, getRcOptions(options)); + } else { + ctx.fillStyle = fillColor; + ctx.strokeStyle = strokeColor; + ctx.lineWidth = strokeWidth; + ctx.save(); + ctx.beginPath(); + ctx.ellipse(cx, cy, radius, radius, 0, 0, 2 * Math.PI); + ctx.closePath(); + ctx.fill(); + ctx.stroke(); + ctx.restore(); + } +} diff --git a/blocksuite/affine/block-surface/src/renderer/elements/group/consts.ts b/blocksuite/affine/block-surface/src/renderer/elements/group/consts.ts new file mode 100644 index 0000000000..064247b8a9 --- /dev/null +++ b/blocksuite/affine/block-surface/src/renderer/elements/group/consts.ts @@ -0,0 +1,6 @@ +import { FontFamily } from '@blocksuite/affine-model'; + +export const GROUP_TITLE_FONT = FontFamily.Inter; +export const GROUP_TITLE_FONT_SIZE = 12; +export const GROUP_TITLE_PADDING = [2, 0]; +export const GROUP_TITLE_OFFSET = 4; diff --git a/blocksuite/affine/block-surface/src/renderer/elements/group/index.ts b/blocksuite/affine/block-surface/src/renderer/elements/group/index.ts new file mode 100644 index 0000000000..a2c4ce71fa --- /dev/null +++ b/blocksuite/affine/block-surface/src/renderer/elements/group/index.ts @@ -0,0 +1,58 @@ +import type { GroupElementModel } from '@blocksuite/affine-model'; +import { Bound } from '@blocksuite/global/utils'; + +import type { CanvasRenderer } from '../../canvas-renderer.js'; +import { titleRenderParams } from './utils.js'; + +export function group( + model: GroupElementModel, + ctx: CanvasRenderingContext2D, + matrix: DOMMatrix, + renderer: CanvasRenderer +) { + const { xywh } = model; + const bound = Bound.deserialize(xywh); + const elements = renderer.provider.selectedElements?.() || []; + + const renderParams = titleRenderParams(model, renderer.viewport.zoom); + model.externalXYWH = renderParams.titleBound.serialize(); + + ctx.setTransform(matrix); + + if (elements.includes(model.id)) { + if (model.showTitle) { + renderTitle(model, ctx, renderer, renderParams); + } else { + ctx.lineWidth = 2 / renderer.viewport.zoom; + ctx.strokeStyle = renderer.getPropertyValue('--affine-blue'); + ctx.strokeRect(0, 0, bound.w, bound.h); + } + } else if (model.childElements.some(child => elements.includes(child.id))) { + ctx.lineWidth = 2 / renderer.viewport.zoom; + ctx.strokeStyle = '#8FD1FF'; + ctx.strokeRect(0, 0, bound.w, bound.h); + + if (model.showTitle) renderTitle(model, ctx, renderer, renderParams); + } +} + +function renderTitle( + model: GroupElementModel, + ctx: CanvasRenderingContext2D, + renderer: CanvasRenderer, + renderParams: ReturnType +) { + const { text, lineHeight, font, padding, offset, titleBound } = renderParams; + + model.externalXYWH = titleBound.serialize(); + + ctx.translate(0, -offset); + + ctx.beginPath(); + + ctx.font = font; + ctx.fillStyle = renderer.getPropertyValue('--affine-blue'); + ctx.textAlign = 'left'; + ctx.textBaseline = 'middle'; + ctx.fillText(text, padding[0], -lineHeight / 2 - padding[1]); +} diff --git a/blocksuite/affine/block-surface/src/renderer/elements/group/utils.ts b/blocksuite/affine/block-surface/src/renderer/elements/group/utils.ts new file mode 100644 index 0000000000..eea058d098 --- /dev/null +++ b/blocksuite/affine/block-surface/src/renderer/elements/group/utils.ts @@ -0,0 +1,77 @@ +import type { GroupElementModel } from '@blocksuite/affine-model'; +import { FontWeight } from '@blocksuite/affine-model'; +import { Bound } from '@blocksuite/global/utils'; + +import { + getFontString, + getLineHeight, + getLineWidth, + truncateTextByWidth, +} from '../text/utils.js'; +import { + GROUP_TITLE_FONT, + GROUP_TITLE_FONT_SIZE, + GROUP_TITLE_OFFSET, + GROUP_TITLE_PADDING, +} from './consts.js'; + +export function titleRenderParams(group: GroupElementModel, zoom: number) { + let text = group.title.toString().trim(); + const font = getGroupTitleFont(zoom); + const lineWidth = getLineWidth(text, font); + const lineHeight = getLineHeight( + GROUP_TITLE_FONT, + GROUP_TITLE_FONT_SIZE / zoom, + 'normal' + ); + const bound = group.elementBound; + const padding = [ + GROUP_TITLE_PADDING[0] / zoom, + GROUP_TITLE_PADDING[1] / zoom, + ]; + const offset = GROUP_TITLE_OFFSET / zoom; + + let titleWidth = lineWidth + padding[0] * 2; + const titleHeight = lineHeight + padding[1] * 2; + + if (titleWidth > bound.w) { + text = truncateTextByWidth(text, font, bound.w - 10); + text = text.slice(0, text.length - 1) + '..'; + titleWidth = bound.w; + } + + return { + font, + bound, + text, + titleWidth, + titleHeight, + offset, + lineHeight, + padding, + titleBound: new Bound( + bound.x, + bound.y - titleHeight - offset, + titleWidth, + titleHeight + ), + }; +} + +export function titleBound(group: GroupElementModel, zoom: number) { + const { titleWidth, titleHeight, bound } = titleRenderParams(group, zoom); + + return new Bound(bound.x, bound.y - titleHeight, titleWidth, titleHeight); +} + +function getGroupTitleFont(zoom: number) { + const fontSize = GROUP_TITLE_FONT_SIZE / zoom; + const font = getFontString({ + fontSize, + fontFamily: GROUP_TITLE_FONT, + fontWeight: FontWeight.Regular, + fontStyle: 'normal', + }); + + return font; +} diff --git a/blocksuite/affine/block-surface/src/renderer/elements/index.ts b/blocksuite/affine/block-surface/src/renderer/elements/index.ts new file mode 100644 index 0000000000..5113afe5a0 --- /dev/null +++ b/blocksuite/affine/block-surface/src/renderer/elements/index.ts @@ -0,0 +1,31 @@ +import type { IBound } from '@blocksuite/global/utils'; + +import type { RoughCanvas, SurfaceElementModel } from '../../index.js'; +import type { CanvasRenderer } from '../canvas-renderer.js'; +import { brush } from './brush/index.js'; +import { connector } from './connector/index.js'; +import { group } from './group/index.js'; +import { mindmap } from './mindmap.js'; +import { shape } from './shape/index.js'; +import { text } from './text/index.js'; +export { normalizeShapeBound } from './shape/utils.js'; + +export type ElementRenderer< + T extends BlockSuite.SurfaceElementModel = SurfaceElementModel, +> = ( + model: T, + ctx: CanvasRenderingContext2D, + matrix: DOMMatrix, + renderer: CanvasRenderer, + rc: RoughCanvas, + viewportBound: IBound +) => void; + +export const elementRenderers = { + brush, + connector, + group, + shape, + text, + mindmap, +} as Record>; diff --git a/blocksuite/affine/block-surface/src/renderer/elements/mindmap.ts b/blocksuite/affine/block-surface/src/renderer/elements/mindmap.ts new file mode 100644 index 0000000000..c505b98bd9 --- /dev/null +++ b/blocksuite/affine/block-surface/src/renderer/elements/mindmap.ts @@ -0,0 +1,66 @@ +import type { + MindmapElementModel, + MindmapNode, +} from '@blocksuite/affine-model'; +import type { GfxModel } from '@blocksuite/block-std/gfx'; +import type { IBound } from '@blocksuite/global/utils'; + +import { ConnectorPathGenerator } from '../../managers/connector-manager.js'; +import type { RoughCanvas } from '../../utils/rough/canvas.js'; +import type { CanvasRenderer } from '../canvas-renderer.js'; +import { connector as renderConnector } from './connector/index.js'; + +export function mindmap( + model: MindmapElementModel, + ctx: CanvasRenderingContext2D, + matrix: DOMMatrix, + renderer: CanvasRenderer, + rc: RoughCanvas, + bound: IBound +) { + const dx = model.x - bound.x; + const dy = model.y - bound.y; + + matrix = matrix.translate(-dx, -dy); + + const mindmapOpacity = model.opacity; + + const traverse = (node: MindmapNode) => { + const connectors = model.getConnectors(node); + if (!connectors) return; + connectors.reverse().forEach(result => { + const { connector, outdated } = result; + const elementGetter = (id: string) => + model.surface.getElementById(id) ?? + (model.surface.doc.getBlockById(id) as GfxModel); + + if (outdated) { + ConnectorPathGenerator.updatePath(connector, null, elementGetter); + } + + const dx = connector.x - bound.x; + const dy = connector.y - bound.y; + const origin = ctx.globalAlpha; + const shouldSetGlobalAlpha = + origin !== connector.opacity * mindmapOpacity; + + if (shouldSetGlobalAlpha) { + ctx.globalAlpha = connector.opacity * mindmapOpacity; + } + + renderConnector(connector, ctx, matrix.translate(dx, dy), renderer, rc); + + if (shouldSetGlobalAlpha) { + ctx.globalAlpha = origin; + } + }); + + if (node.detail.collapsed) { + return; + } else { + node.children.forEach(traverse); + } + }; + + model.tree && traverse(model.tree); +} diff --git a/blocksuite/affine/block-surface/src/renderer/elements/shape/diamond.ts b/blocksuite/affine/block-surface/src/renderer/elements/shape/diamond.ts new file mode 100644 index 0000000000..a6b0540ac2 --- /dev/null +++ b/blocksuite/affine/block-surface/src/renderer/elements/shape/diamond.ts @@ -0,0 +1,64 @@ +import type { + LocalShapeElementModel, + ShapeElementModel, +} from '@blocksuite/affine-model'; + +import type { RoughCanvas } from '../../../utils/rough/canvas.js'; +import type { CanvasRenderer } from '../../canvas-renderer.js'; +import { type Colors, drawGeneralShape } from './utils.js'; + +export function diamond( + model: ShapeElementModel | LocalShapeElementModel, + ctx: CanvasRenderingContext2D, + matrix: DOMMatrix, + renderer: CanvasRenderer, + rc: RoughCanvas, + colors: Colors +) { + const { + seed, + strokeWidth, + filled, + strokeStyle, + roughness, + rotate, + shapeStyle, + } = model; + const [, , w, h] = model.deserializedXYWH; + const renderOffset = Math.max(strokeWidth, 0) / 2; + const renderWidth = w - renderOffset * 2; + const renderHeight = h - renderOffset * 2; + const cx = renderWidth / 2; + const cy = renderHeight / 2; + + const { fillColor, strokeColor } = colors; + + ctx.setTransform( + matrix + .translateSelf(renderOffset, renderOffset) + .translateSelf(cx, cy) + .rotateSelf(rotate) + .translateSelf(-cx, -cy) + ); + + if (shapeStyle === 'General') { + drawGeneralShape(ctx, model, renderer, filled, fillColor, strokeColor); + } else { + rc.polygon( + [ + [renderWidth / 2, 0], + [renderWidth, renderHeight / 2], + [renderWidth / 2, renderHeight], + [0, renderHeight / 2], + ], + { + seed, + roughness: shapeStyle === 'Scribbled' ? roughness : 0, + strokeLineDash: strokeStyle === 'dash' ? [12, 12] : undefined, + stroke: strokeStyle === 'none' ? 'none' : strokeColor, + strokeWidth, + fill: filled ? fillColor : undefined, + } + ); + } +} diff --git a/blocksuite/affine/block-surface/src/renderer/elements/shape/ellipse.ts b/blocksuite/affine/block-surface/src/renderer/elements/shape/ellipse.ts new file mode 100644 index 0000000000..800a480ff3 --- /dev/null +++ b/blocksuite/affine/block-surface/src/renderer/elements/shape/ellipse.ts @@ -0,0 +1,57 @@ +import type { + LocalShapeElementModel, + ShapeElementModel, +} from '@blocksuite/affine-model'; + +import type { RoughCanvas } from '../../../utils/rough/canvas.js'; +import type { CanvasRenderer } from '../../canvas-renderer.js'; +import { type Colors, drawGeneralShape } from './utils.js'; + +export function ellipse( + model: ShapeElementModel | LocalShapeElementModel, + ctx: CanvasRenderingContext2D, + matrix: DOMMatrix, + renderer: CanvasRenderer, + rc: RoughCanvas, + colors: Colors +) { + const { + seed, + strokeWidth, + filled, + strokeStyle, + roughness, + rotate, + shapeStyle, + } = model; + const [, , w, h] = model.deserializedXYWH; + const renderOffset = Math.max(strokeWidth, 0) / 2; + const renderWidth = Math.max(1, w - renderOffset * 2); + const renderHeight = Math.max(1, h - renderOffset * 2); + const cx = renderWidth / 2; + const cy = renderHeight / 2; + + const { fillColor, strokeColor } = colors; + + ctx.setTransform( + matrix + .translateSelf(renderOffset, renderOffset) + .translateSelf(cx, cy) + .rotateSelf(rotate) + .translateSelf(-cx, -cy) + ); + + if (shapeStyle === 'General') { + drawGeneralShape(ctx, model, renderer, filled, fillColor, strokeColor); + } else { + rc.ellipse(cx, cy, renderWidth, renderHeight, { + seed, + roughness: shapeStyle === 'Scribbled' ? roughness : 0, + strokeLineDash: strokeStyle === 'dash' ? [12, 12] : undefined, + stroke: strokeStyle === 'none' ? 'none' : strokeColor, + strokeWidth, + fill: filled ? fillColor : undefined, + curveFitting: 1, + }); + } +} diff --git a/blocksuite/affine/block-surface/src/renderer/elements/shape/index.ts b/blocksuite/affine/block-surface/src/renderer/elements/shape/index.ts new file mode 100644 index 0000000000..202f44a397 --- /dev/null +++ b/blocksuite/affine/block-surface/src/renderer/elements/shape/index.ts @@ -0,0 +1,177 @@ +import type { + LocalShapeElementModel, + ShapeElementModel, + ShapeType, +} from '@blocksuite/affine-model'; +import { + DEFAULT_SHAPE_FILL_COLOR, + DEFAULT_SHAPE_STROKE_COLOR, + DEFAULT_SHAPE_TEXT_COLOR, + TextAlign, +} from '@blocksuite/affine-model'; +import type { IBound } from '@blocksuite/global/utils'; +import { Bound } from '@blocksuite/global/utils'; +import { deltaInsertsToChunks } from '@blocksuite/inline'; + +import type { RoughCanvas } from '../../../utils/rough/canvas.js'; +import type { CanvasRenderer } from '../../canvas-renderer.js'; +import { + getFontMetrics, + getFontString, + getLineWidth, + isRTL, + measureTextInDOM, + wrapTextDeltas, +} from '../text/utils.js'; +import { diamond } from './diamond.js'; +import { ellipse } from './ellipse.js'; +import { rect } from './rect.js'; +import { triangle } from './triangle.js'; +import { type Colors, horizontalOffset, verticalOffset } from './utils.js'; + +const shapeRenderers: Record< + ShapeType, + ( + model: ShapeElementModel | LocalShapeElementModel, + ctx: CanvasRenderingContext2D, + matrix: DOMMatrix, + renderer: CanvasRenderer, + rc: RoughCanvas, + colors: Colors + ) => void +> = { + diamond, + rect, + triangle, + ellipse, +}; + +export function shape( + model: ShapeElementModel | LocalShapeElementModel, + ctx: CanvasRenderingContext2D, + matrix: DOMMatrix, + renderer: CanvasRenderer, + rc: RoughCanvas +) { + const color = renderer.getColorValue( + model.color, + DEFAULT_SHAPE_TEXT_COLOR, + true + ); + const fillColor = renderer.getColorValue( + model.fillColor, + DEFAULT_SHAPE_FILL_COLOR, + true + ); + const strokeColor = renderer.getColorValue( + model.strokeColor, + DEFAULT_SHAPE_STROKE_COLOR, + true + ); + const colors = { color, fillColor, strokeColor }; + + shapeRenderers[model.shapeType](model, ctx, matrix, renderer, rc, colors); + + if (model.textDisplay) { + renderText(model, ctx, colors); + } +} + +function renderText( + model: ShapeElementModel | LocalShapeElementModel, + ctx: CanvasRenderingContext2D, + { color }: Colors +) { + const { + x, + y, + text, + fontSize, + fontFamily, + fontWeight, + textAlign, + w, + h, + textVerticalAlign, + padding, + } = model; + if (!text) return; + + const [verticalPadding, horPadding] = padding; + const font = getFontString(model); + const { lineGap, lineHeight } = measureTextInDOM( + fontFamily, + fontSize, + fontWeight + ); + const metrics = getFontMetrics(fontFamily, fontSize, fontWeight); + const lines = + typeof text === 'string' + ? [text.split('\n').map(line => ({ insert: line }))] + : deltaInsertsToChunks(wrapTextDeltas(text, font, w - horPadding * 2)); + const horOffset = horizontalOffset(model.w, model.textAlign, horPadding); + const vertOffset = + verticalOffset( + lines, + lineHeight + lineGap, + h, + textVerticalAlign, + verticalPadding + ) + + metrics.fontBoundingBoxAscent + + lineGap / 2; + let maxLineWidth = 0; + + ctx.font = font; + ctx.fillStyle = color; + ctx.textAlign = textAlign; + ctx.textBaseline = 'alphabetic'; + + for (const [lineIndex, line] of lines.entries()) { + for (const delta of line) { + const str = delta.insert; + const rtl = isRTL(str); + const shouldTemporarilyAttach = rtl && !ctx.canvas.isConnected; + if (shouldTemporarilyAttach) { + // to correctly render RTL text mixed with LTR, we have to append it + // to the DOM + document.body.append(ctx.canvas); + } + + if (ctx.canvas.dir !== (rtl ? 'rtl' : 'ltr')) { + ctx.canvas.setAttribute('dir', rtl ? 'rtl' : 'ltr'); + } + + ctx.fillText( + str, + // 0.5 is the dom editor padding to make the text align with the DOM text + horOffset + 0.5, + lineIndex * lineHeight + vertOffset + ); + + maxLineWidth = Math.max(maxLineWidth, getLineWidth(str, font)); + + if (shouldTemporarilyAttach) { + ctx.canvas.remove(); + } + } + } + + const offsetX = + model.textAlign === TextAlign.Center + ? (w - maxLineWidth) / 2 + : model.textAlign === TextAlign.Left + ? horOffset + : horOffset - maxLineWidth; + const offsetY = vertOffset - lineHeight + verticalPadding / 2; + + const bound = new Bound( + x + offsetX, + y + offsetY, + maxLineWidth, + lineHeight * lines.length + ) as IBound; + + bound.rotate = model.rotate ?? 0; + model.textBound = bound; +} diff --git a/blocksuite/affine/block-surface/src/renderer/elements/shape/rect.ts b/blocksuite/affine/block-surface/src/renderer/elements/shape/rect.ts new file mode 100644 index 0000000000..7afb5ea30b --- /dev/null +++ b/blocksuite/affine/block-surface/src/renderer/elements/shape/rect.ts @@ -0,0 +1,96 @@ +import type { + LocalShapeElementModel, + ShapeElementModel, +} from '@blocksuite/affine-model'; + +import type { RoughCanvas } from '../../../utils/rough/canvas.js'; +import type { CanvasRenderer } from '../../canvas-renderer.js'; +import { type Colors, drawGeneralShape } from './utils.js'; + +/** + * "magic number" for bezier approximations of arcs (http://itc.ktu.lt/itc354/Riskus354.pdf) + */ +const K_RECT = 1 - 0.5522847498; + +export function rect( + model: ShapeElementModel | LocalShapeElementModel, + ctx: CanvasRenderingContext2D, + matrix: DOMMatrix, + renderer: CanvasRenderer, + rc: RoughCanvas, + colors: Colors +) { + const { + filled, + radius, + rotate, + roughness, + seed, + shapeStyle, + strokeStyle, + strokeWidth, + } = model; + const [, , w, h] = model.deserializedXYWH; + const renderOffset = Math.max(strokeWidth, 0) / 2; + const renderWidth = w - renderOffset * 2; + const renderHeight = h - renderOffset * 2; + const r = + radius < 1 ? Math.min(renderWidth * radius, renderHeight * radius) : radius; + const cx = renderWidth / 2; + const cy = renderHeight / 2; + + const { fillColor, strokeColor } = colors; + + ctx.setTransform( + matrix + .translateSelf(renderOffset, renderOffset) + .translateSelf(cx, cy) + .rotateSelf(rotate) + .translateSelf(-cx, -cy) + ); + + if (shapeStyle === 'General') { + drawGeneralShape(ctx, model, renderer, filled, fillColor, strokeColor); + } else { + rc.path( + ` + M ${r} 0 + L ${renderWidth - r} 0 + C ${renderWidth - K_RECT * r} 0 ${renderWidth} ${ + K_RECT * r + } ${renderWidth} ${r} + L ${renderWidth} ${renderHeight - r} + C ${renderWidth} ${renderHeight - K_RECT * r} ${ + renderWidth - K_RECT * r + } ${renderHeight} ${renderWidth - r} ${renderHeight} + L ${r} ${renderHeight} + C ${K_RECT * r} ${renderHeight} 0 ${renderHeight - K_RECT * r} 0 ${ + renderHeight - r + } + L 0 ${r} + C 0 ${K_RECT * r} ${K_RECT * r} 0 ${r} 0 + Z + `, + { + seed, + roughness, + strokeLineDash: strokeStyle === 'dash' ? [12, 12] : undefined, + stroke: strokeStyle === 'none' ? 'none' : strokeColor, + strokeWidth, + fill: filled ? fillColor : undefined, + } + ); + } + + ctx.setTransform( + ctx + .getTransform() + .translateSelf(cx, cy) + .rotateSelf(-rotate) + .translateSelf(-cx, -cy) + .translateSelf(-renderOffset, -renderOffset) + .translateSelf(cx, cy) + .rotateSelf(rotate) + .translateSelf(-cx, -cy) + ); +} diff --git a/blocksuite/affine/block-surface/src/renderer/elements/shape/triangle.ts b/blocksuite/affine/block-surface/src/renderer/elements/shape/triangle.ts new file mode 100644 index 0000000000..2ab8ece125 --- /dev/null +++ b/blocksuite/affine/block-surface/src/renderer/elements/shape/triangle.ts @@ -0,0 +1,63 @@ +import type { + LocalShapeElementModel, + ShapeElementModel, +} from '@blocksuite/affine-model'; + +import type { RoughCanvas } from '../../../utils/rough/canvas.js'; +import type { CanvasRenderer } from '../../canvas-renderer.js'; +import { type Colors, drawGeneralShape } from './utils.js'; + +export function triangle( + model: ShapeElementModel | LocalShapeElementModel, + ctx: CanvasRenderingContext2D, + matrix: DOMMatrix, + renderer: CanvasRenderer, + rc: RoughCanvas, + colors: Colors +) { + const { + seed, + strokeWidth, + filled, + strokeStyle, + roughness, + rotate, + shapeStyle, + } = model; + const [, , w, h] = model.deserializedXYWH; + const renderOffset = Math.max(strokeWidth, 0) / 2; + const renderWidth = w - renderOffset * 2; + const renderHeight = h - renderOffset * 2; + const cx = renderWidth / 2; + const cy = renderHeight / 2; + + const { fillColor, strokeColor } = colors; + + ctx.setTransform( + matrix + .translateSelf(renderOffset, renderOffset) + .translateSelf(cx, cy) + .rotateSelf(rotate) + .translateSelf(-cx, -cy) + ); + + if (shapeStyle === 'General') { + drawGeneralShape(ctx, model, renderer, filled, fillColor, strokeColor); + } else { + rc.polygon( + [ + [renderWidth / 2, 0], + [renderWidth, renderHeight], + [0, renderHeight], + ], + { + seed, + roughness: shapeStyle === 'Scribbled' ? roughness : 0, + strokeLineDash: strokeStyle === 'dash' ? [12, 12] : undefined, + stroke: strokeStyle === 'none' ? 'none' : strokeColor, + strokeWidth, + fill: filled ? fillColor : undefined, + } + ); + } +} diff --git a/blocksuite/affine/block-surface/src/renderer/elements/shape/utils.ts b/blocksuite/affine/block-surface/src/renderer/elements/shape/utils.ts new file mode 100644 index 0000000000..2bafcdf2f8 --- /dev/null +++ b/blocksuite/affine/block-surface/src/renderer/elements/shape/utils.ts @@ -0,0 +1,270 @@ +import type { + LocalShapeElementModel, + ShapeElementModel, + TextAlign, + TextVerticalAlign, +} from '@blocksuite/affine-model'; +import type { Bound, SerializedXYWH } from '@blocksuite/global/utils'; +import { deltaInsertsToChunks } from '@blocksuite/inline'; + +import type { CanvasRenderer } from '../../canvas-renderer.js'; +import { + getFontString, + getLineHeight, + getLineWidth, + getTextWidth, + measureTextInDOM, + type TextDelta, + wrapText, + wrapTextDeltas, +} from '../text/utils.js'; + +export type Colors = { + color: string; + fillColor: string; + strokeColor: string; +}; + +export function drawGeneralShape( + ctx: CanvasRenderingContext2D, + shapeModel: ShapeElementModel | LocalShapeElementModel, + renderer: CanvasRenderer, + filled: boolean, + fillColor: string, + strokeColor: string +) { + const sizeOffset = Math.max(shapeModel.strokeWidth, 0); + const w = Math.max(shapeModel.w - sizeOffset, 0); + const h = Math.max(shapeModel.h - sizeOffset, 0); + + switch (shapeModel.shapeType) { + case 'rect': + drawRect(ctx, 0, 0, w, h, shapeModel.radius ?? 0); + break; + case 'diamond': + drawDiamond(ctx, 0, 0, w, h); + break; + case 'ellipse': + drawEllipse(ctx, 0, 0, w, h); + break; + case 'triangle': + drawTriangle(ctx, 0, 0, w, h); + } + + ctx.lineWidth = shapeModel.strokeWidth; + ctx.strokeStyle = strokeColor; + ctx.fillStyle = filled ? fillColor : 'transparent'; + + switch (shapeModel.strokeStyle) { + case 'none': + ctx.strokeStyle = 'transparent'; + break; + case 'dash': + ctx.setLineDash([12, 12]); + break; + } + + if (shapeModel.shadow) { + const { blur, offsetX, offsetY, color } = shapeModel.shadow; + const scale = ctx.getTransform().a; + + const enableShadowBlur = shapeModel.surface.doc.awarenessStore.getFlag( + 'enable_shape_shadow_blur' + ); + + // hard shadow, or soft shadow if `enable_shape_shadow_blur` is true + // see comment of `shape.shadow` in `ShapeElementModel` + if (blur === 0 || enableShadowBlur) { + ctx.shadowBlur = blur * scale; + ctx.shadowOffsetX = offsetX * scale; + ctx.shadowOffsetY = offsetY * scale; + } + + ctx.shadowColor = renderer.getPropertyValue(color); + } + + ctx.stroke(); + ctx.fill(); + + if (shapeModel.shadow) { + ctx.shadowBlur = 0; + ctx.shadowOffsetX = 0; + ctx.shadowOffsetY = 0; + } + + ctx.fill(); + ctx.stroke(); +} + +function drawRect( + ctx: CanvasRenderingContext2D, + x: number, + y: number, + width: number, + height: number, + radius: number +) { + const r = + radius < 1 + ? Math.max(Math.min(width * radius, height * radius), 0) + : radius; + ctx.beginPath(); + ctx.moveTo(x + r, y); + ctx.lineTo(x + width - r, y); + ctx.arcTo(x + width, y, x + width, y + r, r); + ctx.lineTo(x + width, y + height - r); + ctx.arcTo(x + width, y + height, x + width - r, y + height, r); + ctx.lineTo(x + r, y + height); + ctx.arcTo(x, y + height, x, y + height - r, r); + ctx.lineTo(x, y + r); + ctx.arcTo(x, y, x + r, y, r); + ctx.closePath(); +} + +function drawDiamond( + ctx: CanvasRenderingContext2D, + x: number, + y: number, + width: number, + height: number +) { + ctx.beginPath(); + ctx.moveTo(width / 2, y); + ctx.lineTo(width, height / 2); + ctx.lineTo(width / 2, height); + ctx.lineTo(x, height / 2); + ctx.closePath(); +} + +function drawEllipse( + ctx: CanvasRenderingContext2D, + _x: number, + _y: number, + width: number, + height: number +) { + const cx = width / 2; + const cy = height / 2; + ctx.beginPath(); + ctx.ellipse(cx, cy, width / 2, height / 2, 0, 0, 2 * Math.PI); + ctx.closePath(); +} + +function drawTriangle( + ctx: CanvasRenderingContext2D, + x: number, + y: number, + width: number, + height: number +) { + ctx.beginPath(); + ctx.moveTo(width / 2, y); + ctx.lineTo(width, height); + ctx.lineTo(x, height); + ctx.closePath(); +} + +export function horizontalOffset( + width: number, + textAlign: TextAlign, + horiPadding: number +) { + return textAlign === 'center' + ? width / 2 + : textAlign === 'right' + ? width - horiPadding + : horiPadding; +} + +export function verticalOffset( + lines: TextDelta[][], + lineHeight: number, + height: number, + textVerticalAlign: TextVerticalAlign, + verticalPadding: number +) { + return textVerticalAlign === 'center' + ? Math.max((height - lineHeight * lines.length) / 2, verticalPadding) + : textVerticalAlign === 'top' + ? verticalPadding + : height - lineHeight * lines.length - verticalPadding; +} +export function normalizeShapeBound( + shape: ShapeElementModel, + bound: Bound +): Bound { + if (!shape.text) return bound; + + const [verticalPadding, horiPadding] = shape.padding; + const yText = shape.text; + const { fontFamily, fontSize, fontStyle, fontWeight } = shape; + const lineHeight = getLineHeight(fontFamily, fontSize, fontWeight); + const font = getFontString({ + fontStyle, + fontWeight, + fontSize, + fontFamily, + }); + const widestCharWidth = + [...yText.toString()] + .map(char => getTextWidth(char, font)) + .sort((a, b) => a - b) + .pop() ?? getTextWidth('W', font); + + if (bound.w < widestCharWidth + horiPadding * 2) { + bound.w = widestCharWidth + horiPadding * 2; + } + const deltas: TextDelta[] = (yText.toDelta() as TextDelta[]).flatMap( + delta => ({ + insert: wrapText(delta.insert, font, bound.w - horiPadding * 2), + attributes: delta.attributes, + }) + ) as TextDelta[]; + const lines = deltaInsertsToChunks(deltas); + + if (bound.h < lineHeight * lines.length + verticalPadding * 2) { + bound.h = lineHeight * lines.length + verticalPadding * 2; + } + + return bound; +} + +export function fitContent(shape: ShapeElementModel) { + const font = getFontString(shape); + + if (!shape.text) { + return; + } + + const [verticalPadding, horiPadding] = shape.padding; + const lines = deltaInsertsToChunks( + wrapTextDeltas(shape.text, font, shape.maxWidth || Number.MAX_SAFE_INTEGER) + ); + const { lineHeight, lineGap } = measureTextInDOM( + shape.fontFamily, + shape.fontSize, + shape.fontWeight + ); + let maxWidth = 0; + let height = 0; + + lines.forEach(line => { + for (const delta of line) { + const str = delta.insert; + + maxWidth = Math.max(maxWidth, getLineWidth(str, font)); + } + height += lineHeight + lineGap; + }); + + height = Math.max(lineHeight + lineGap, height); + + maxWidth += horiPadding * 2; + height += verticalPadding * 2; + + const newXYWH = `[${shape.x},${shape.y},${maxWidth},${height}]`; + + if (shape.xywh !== newXYWH) { + shape.xywh = newXYWH as SerializedXYWH; + } +} diff --git a/blocksuite/affine/block-surface/src/renderer/elements/text/index.ts b/blocksuite/affine/block-surface/src/renderer/elements/text/index.ts new file mode 100644 index 0000000000..73904c43db --- /dev/null +++ b/blocksuite/affine/block-surface/src/renderer/elements/text/index.ts @@ -0,0 +1,80 @@ +import type { TextElementModel } from '@blocksuite/affine-model'; +import { deltaInsertsToChunks } from '@blocksuite/inline'; + +import type { CanvasRenderer } from '../../canvas-renderer.js'; +import { + getFontString, + getLineHeight, + getTextWidth, + isRTL, + wrapTextDeltas, +} from './utils.js'; + +export function text( + model: TextElementModel, + ctx: CanvasRenderingContext2D, + matrix: DOMMatrix, + renderer: CanvasRenderer +) { + const { fontSize, fontWeight, fontStyle, fontFamily, textAlign, rotate } = + model; + const [, , w, h] = model.deserializedXYWH; + const cx = w / 2; + const cy = h / 2; + + ctx.setTransform( + matrix.translateSelf(cx, cy).rotateSelf(rotate).translateSelf(-cx, -cy) + ); + + // const deltas: ITextDelta[] = yText.toDelta() as ITextDelta[]; + const font = getFontString({ + fontStyle, + fontWeight, + fontSize, + fontFamily, + }); + const deltas = wrapTextDeltas(model.text, font, w); + const lines = deltaInsertsToChunks(deltas); + const lineHeightPx = getLineHeight(fontFamily, fontSize, fontWeight); + const horizontalOffset = + textAlign === 'center' ? w / 2 : textAlign === 'right' ? w : 0; + + const color = renderer.getColorValue(model.color, '#000000', true); + + ctx.font = font; + ctx.fillStyle = color; + ctx.textAlign = textAlign; + ctx.textBaseline = 'ideographic'; + + for (const [lineIndex, line] of lines.entries()) { + let beforeTextWidth = 0; + + for (const delta of line) { + const str = delta.insert; + const rtl = isRTL(str); + const shouldTemporarilyAttach = rtl && !ctx.canvas.isConnected; + if (shouldTemporarilyAttach) { + // to correctly render RTL text mixed with LTR, we have to append it + // to the DOM + document.body.append(ctx.canvas); + } + + ctx.canvas.setAttribute('dir', rtl ? 'rtl' : 'ltr'); + + // 0.5 comes from v-line padding + const offset = + textAlign === 'center' ? 0 : textAlign === 'right' ? -0.5 : 0.5; + ctx.fillText( + str, + horizontalOffset + beforeTextWidth + offset, + (lineIndex + 1) * lineHeightPx + ); + + beforeTextWidth += getTextWidth(str, font); + + if (shouldTemporarilyAttach) { + ctx.canvas.remove(); + } + } + } +} diff --git a/blocksuite/affine/block-surface/src/renderer/elements/text/utils.ts b/blocksuite/affine/block-surface/src/renderer/elements/text/utils.ts new file mode 100644 index 0000000000..99157dd7a5 --- /dev/null +++ b/blocksuite/affine/block-surface/src/renderer/elements/text/utils.ts @@ -0,0 +1,555 @@ +import type { + FontFamily, + FontStyle, + FontWeight, + TextElementModel, +} from '@blocksuite/affine-model'; +import type { Bound } from '@blocksuite/global/utils'; +import { + getPointsFromBoundWithRotation, + rotatePoints, +} from '@blocksuite/global/utils'; +import { deltaInsertsToChunks } from '@blocksuite/inline'; +import type { Y } from '@blocksuite/store'; + +import { + getFontFacesByFontFamily, + wrapFontFamily, +} from '../../../utils/font.js'; + +export type TextDelta = { + insert: string; + attributes?: Record; +}; + +const getMeasureCtx = (function initMeasureContext() { + let ctx: CanvasRenderingContext2D | null = null; + let canvas: HTMLCanvasElement | null = null; + + return () => { + if (!canvas) { + canvas = document.createElement('canvas'); + ctx = canvas.getContext('2d')!; + } + + return ctx!; + }; +})(); + +const textMeasureCache = new Map< + string, + { + lineHeight: number; + lineGap: number; + fontSize: number; + } +>(); + +export function measureTextInDOM( + fontFamily: string, + fontSize: number, + fontWeight: string +) { + const cacheKey = `${wrapFontFamily(fontFamily)}-${fontWeight}`; + + if (textMeasureCache.has(cacheKey)) { + const { + fontSize: cacheFontSize, + lineGap, + lineHeight, + } = textMeasureCache.get(cacheKey)!; + + return { + lineHeight: lineHeight * (fontSize / cacheFontSize), + lineGap: lineGap * (fontSize / cacheFontSize), + }; + } + + const div = document.createElement('div'); + const span = document.createElement('span'); + + div.append(span); + + span.innerText = 'x'; + + div.style.position = 'absolute'; + div.style.top = '0px'; + div.style.left = '0px'; + div.style.visibility = 'hidden'; + div.style.fontFamily = wrapFontFamily(fontFamily); + div.style.fontWeight = fontWeight; + div.style.fontSize = `${fontSize}px`; + + div.style.pointerEvents = 'none'; + + document.body.append(div); + + const lineHeight = span.getBoundingClientRect().height; + const height = div.getBoundingClientRect().height; + const result = { + lineHeight, + lineGap: height - lineHeight, + }; + + div.remove(); + + textMeasureCache.set(cacheKey, { + ...result, + fontSize, + }); + + return result; +} + +export function getFontString({ + fontStyle, + fontWeight, + fontSize, + fontFamily, +}: { + fontStyle: string; + fontWeight: string; + fontSize: number; + fontFamily: string; +}): string { + const lineHeight = getLineHeight(fontFamily, fontSize, fontWeight); + return `${fontStyle} ${fontWeight} ${fontSize}px/${lineHeight}px ${wrapFontFamily( + fontFamily + )}, sans-serif`.trim(); +} + +export function getLineHeight( + fontFamily: string, + fontSize: number, + fontWeight: string +): number { + const { lineHeight } = measureTextInDOM(fontFamily, fontSize, fontWeight); + return lineHeight; +} + +type Writeable = { -readonly [P in keyof T]: T[P] }; + +type TextMetricsLike = Writeable; + +const metricsCache = new Map< + string, + { + fontSize: number; + metrics: TextMetrics; + } +>(); +export function getFontMetrics( + fontFamily: string, + fontSize: number, + fontWeight: string +) { + const ctx = getMeasureCtx(); + const cacheKey = `${wrapFontFamily(fontFamily)}-${fontWeight}`; + + if (metricsCache.has(cacheKey)) { + const { fontSize: cacheFontSize, metrics } = metricsCache.get(cacheKey)!; + + return Object.keys(Object.getPrototypeOf(metrics)).reduce((acc, key) => { + acc[key as keyof TextMetrics] = + metrics[key as keyof TextMetrics] * (fontSize / cacheFontSize); + return acc; + }, {} as TextMetricsLike); + } + + const font = `${fontWeight} ${fontSize}px ${wrapFontFamily(fontFamily)}`; + ctx.font = font; + const metrics = ctx.measureText('x'); + + // check if font does not fallback + if (ctx.font === font) { + metricsCache.set(cacheKey, { + fontSize, + metrics, + }); + } + + return metrics; +} + +const RS_LTR_CHARS = + 'A-Za-z\u00C0-\u00D6\u00D8-\u00F6\u00F8-\u02B8\u0300-\u0590\u0800-\u1FFF' + + '\u2C00-\uFB1C\uFDFE-\uFE6F\uFEFD-\uFFFF'; +const RS_RTL_CHARS = '\u0591-\u07FF\uFB1D-\uFDFD\uFE70-\uFEFC'; +// eslint-disable-next-line no-misleading-character-class +const RE_RTL_CHECK = new RegExp(`^[^${RS_LTR_CHARS}]*[${RS_RTL_CHARS}]`); +export function isRTL(text: string) { + return RE_RTL_CHECK.test(text); +} + +export function splitIntoLines(text: string): string[] { + return normalizeText(text).split('\n'); +} + +export function getLineWidth(text: string, font: string): number { + const ctx = getMeasureCtx(); + if (font !== ctx.font) ctx.font = font; + const width = ctx.measureText(text).width; + + return width; +} + +export function getTextWidth(text: string, font: string): number { + const lines = splitIntoLines(text); + let width = 0; + lines.forEach(line => { + width = Math.max(width, getLineWidth(line, font)); + }); + return width; +} + +export function wrapTextDeltas(text: Y.Text, font: string, w: number) { + const deltas: TextDelta[] = (text.toDelta() as TextDelta[]).flatMap( + delta => ({ + insert: wrapText(delta.insert, font, w), + attributes: delta.attributes, + }) + ) as TextDelta[]; + + return deltas; +} + +export const truncateTextByWidth = ( + text: string, + font: string, + width: number +) => { + let totalWidth = 0; + let i = 0; + for (; i < text.length; i++) { + const char = text[i]; + totalWidth += charWidth.calculate(char, font); + if (totalWidth > width) { + break; + } + } + return text.slice(0, i); +}; + +export function getTextCursorPosition( + model: TextElementModel, + coord: { x: number; y: number } +) { + const leftTop = getPointsFromBoundWithRotation(model)[0]; + const mousePos = rotatePoints( + [[coord.x, coord.y]], + leftTop, + -model.rotate + )[0]; + + return [ + Math.floor( + (mousePos[1] - leftTop[1]) / + getLineHeight(model.fontFamily, model.fontSize, model.fontWeight) + ), + mousePos[0] - leftTop[0], + ]; +} + +export function getCursorByCoord( + model: TextElementModel, + coord: { x: number; y: number } +) { + const [lineIndex, offsetX] = getTextCursorPosition(model, coord); + + const font = getFontString(model); + const deltas = wrapTextDeltas(model.text, font, model.w); + const lines = deltaInsertsToChunks(deltas).map(line => + line.map(iTextDelta => iTextDelta.insert).join('') + ); + + if (lineIndex < 0 || lineIndex >= lines.length) { + return model.text.length; + } + + const string = lines[lineIndex]; + + let index = lines.slice(0, lineIndex).join('').length - 1; + let currentStringWidth = 0; + let charIndex = 0; + while (currentStringWidth < offsetX) { + index += 1; + if (charIndex === string.length) { + break; + } + currentStringWidth += charWidth.calculate(string[charIndex], font); + charIndex += 1; + } + return index; +} + +export function normalizeTextBound( + { + yText, + fontStyle, + fontWeight, + fontSize, + fontFamily, + hasMaxWidth, + maxWidth, + }: { + yText: Y.Text; + fontStyle: FontStyle; + fontWeight: FontWeight; + fontSize: number; + fontFamily: FontFamily; + hasMaxWidth?: boolean; + maxWidth?: number; + }, + bound: Bound, + dragging: boolean = false +): Bound { + if (!yText) return bound; + + const lineHeightPx = getLineHeight(fontFamily, fontSize, fontWeight); + const font = getFontString({ + fontStyle, + fontWeight, + fontSize, + fontFamily, + }); + + let lines: TextDelta[][] = []; + const deltas: TextDelta[] = yText.toDelta() as TextDelta[]; + const text = yText.toString(); + const widestCharWidth = + [...text] + .map(char => getTextWidth(char, font)) + .sort((a, b) => a - b) + .pop() ?? getTextWidth('W', font); + + if (bound.w < widestCharWidth) { + bound.w = widestCharWidth; + } + + const width = bound.w; + const insertDeltas = deltas.flatMap(delta => ({ + insert: wrapText(delta.insert, font, width), + attributes: delta.attributes, + })) as TextDelta[]; + lines = deltaInsertsToChunks(insertDeltas); + if (!dragging) { + lines = deltaInsertsToChunks(deltas); + const widestLineWidth = Math.max( + ...text.split('\n').map(line => getTextWidth(line, font)) + ); + bound.w = widestLineWidth; + if (hasMaxWidth && maxWidth && maxWidth > 0) { + bound.w = Math.min(bound.w, maxWidth); + } + } + bound.h = lineHeightPx * lines.length; + + return bound; +} + +export function isFontWeightSupported( + fontFamily: FontFamily | string, + weight: FontWeight +) { + const fontFaces = getFontFacesByFontFamily(fontFamily); + const fontFace = fontFaces.find(fontFace => fontFace.weight === weight); + return !!fontFace; +} + +export function isFontStyleSupported( + fontFamily: FontFamily | string, + style: FontStyle +) { + const fontFaces = getFontFacesByFontFamily(fontFamily); + const fontFace = fontFaces.find(fontFace => fontFace.style === style); + return !!fontFace; +} + +export function normalizeText(text: string): string { + return ( + text + // replace tabs with spaces so they render and measure correctly + .replace(/\t/g, ' ') + // normalize newlines + .replace(/\r?\n|\r/g, '\n') + ); +} + +export const getTextHeight = (text: string, lineHeight: number) => { + const lineCount = splitIntoLines(text).length; + return lineHeight * lineCount; +}; + +export function parseTokens(text: string): string[] { + // Splitting words containing "-" as those are treated as separate words + // by css wrapping algorithm eg non-profit => non-, profit + const words = text.split('-'); + if (words.length > 1) { + // non-proft org => ['non-', 'profit org'] + words.forEach((word, index) => { + if (index !== words.length - 1) { + words[index] = word += '-'; + } + }); + } + // Joining the words with space and splitting them again with space to get the + // final list of tokens + // ['non-', 'profit org'] =>,'non- profit org' => ['non-','profit','org'] + return words.join(' ').split(' '); +} + +export const charWidth = (() => { + const cachedCharWidth: Record> = {}; + + const calculate = (char: string, font: string) => { + const ascii = char.charCodeAt(0); + if (!cachedCharWidth[font]) { + cachedCharWidth[font] = []; + } + if (!cachedCharWidth[font][ascii]) { + const width = getLineWidth(char, font); + cachedCharWidth[font][ascii] = width; + } + + return cachedCharWidth[font][ascii]; + }; + + const getCache = (font: string) => { + return cachedCharWidth[font]; + }; + return { + calculate, + getCache, + }; +})(); + +export function wrapText(text: string, font: string, maxWidth: number): string { + // if maxWidth is not finite or NaN which can happen in case of bugs in + // computation, we need to make sure we don't continue as we'll end up + // in an infinite loop + if (!Number.isFinite(maxWidth) || maxWidth < 0) { + return text; + } + + const lines: Array = []; + const originalLines = text.split('\n'); + const spaceWidth = getLineWidth(' ', font); + + let currentLine = ''; + let currentLineWidthTillNow = 0; + + const push = (str: string) => { + if (str.trim()) { + lines.push(str); + } + }; + + const resetParams = () => { + currentLine = ''; + currentLineWidthTillNow = 0; + }; + originalLines.forEach(originalLine => { + const currentLineWidth = getTextWidth(originalLine, font); + + // Push the line if its <= maxWidth + if (currentLineWidth <= maxWidth) { + lines.push(originalLine); + return; // continue + } + + const words = parseTokens(originalLine); + resetParams(); + + let index = 0; + + while (index < words.length) { + const currentWordWidth = getLineWidth(words[index], font); + + // This will only happen when single word takes entire width + if (currentWordWidth === maxWidth) { + push(words[index]); + index++; + } + + // Start breaking longer words exceeding max width + else if (currentWordWidth > maxWidth) { + // push current line since the current word exceeds the max width + // so will be appended in next line + + push(currentLine); + + resetParams(); + + while (words[index].length > 0) { + const currentChar = String.fromCodePoint( + words[index].codePointAt(0)! + ); + const width = charWidth.calculate(currentChar, font); + currentLineWidthTillNow += width; + words[index] = words[index].slice(currentChar.length); + + if (currentLineWidthTillNow >= maxWidth) { + push(currentLine); + currentLine = currentChar; + currentLineWidthTillNow = width; + } else { + currentLine += currentChar; + } + } + // push current line if appending space exceeds max width + if (currentLineWidthTillNow + spaceWidth >= maxWidth) { + push(currentLine); + resetParams(); + // space needs to be appended before next word + // as currentLine contains chars which couldn't be appended + // to previous line unless the line ends with hyphen to sync + // with css word-wrap + } else if (!currentLine.endsWith('-')) { + currentLine += ' '; + currentLineWidthTillNow += spaceWidth; + } + index++; + } else { + // Start appending words in a line till max width reached + while (currentLineWidthTillNow < maxWidth && index < words.length) { + const word = words[index]; + currentLineWidthTillNow = getLineWidth(currentLine + word, font); + + if (currentLineWidthTillNow > maxWidth) { + push(currentLine); + resetParams(); + + break; + } + index++; + + // if word ends with "-" then we don't need to add space + // to sync with css word-wrap + const shouldAppendSpace = !word.endsWith('-'); + currentLine += word; + + if (shouldAppendSpace) { + currentLine += ' '; + } + + // Push the word if appending space exceeds max width + if (currentLineWidthTillNow + spaceWidth >= maxWidth) { + if (shouldAppendSpace) { + lines.push(currentLine.slice(0, -1)); + } else { + lines.push(currentLine); + } + resetParams(); + break; + } + } + } + } + if (currentLine.slice(-1) === ' ') { + // only remove last trailing space which we have added when joining words + currentLine = currentLine.slice(0, -1); + push(currentLine); + } + }); + return lines.join('\n'); +} diff --git a/blocksuite/affine/block-surface/src/renderer/elements/type.ts b/blocksuite/affine/block-surface/src/renderer/elements/type.ts new file mode 100644 index 0000000000..9c5834a739 --- /dev/null +++ b/blocksuite/affine/block-surface/src/renderer/elements/type.ts @@ -0,0 +1,6 @@ +import type { + ShapeElementModel, + TextElementModel, +} from '@blocksuite/affine-model'; + +export type CanvasElementWithText = ShapeElementModel | TextElementModel; diff --git a/blocksuite/affine/block-surface/src/renderer/overlay.ts b/blocksuite/affine/block-surface/src/renderer/overlay.ts new file mode 100644 index 0000000000..63c5f3a30b --- /dev/null +++ b/blocksuite/affine/block-surface/src/renderer/overlay.ts @@ -0,0 +1,55 @@ +import { Extension } from '@blocksuite/block-std'; +import { + type GfxController, + GfxControllerIdentifier, +} from '@blocksuite/block-std/gfx'; +import { type Container, createIdentifier } from '@blocksuite/global/di'; +import { BlockSuiteError, ErrorCode } from '@blocksuite/global/exceptions'; + +import type { RoughCanvas } from '../utils/rough/canvas.js'; +import type { CanvasRenderer } from './canvas-renderer.js'; + +/** + * An overlay is a layer covered on top of elements, + * can be used for rendering non-CRDT state indicators. + */ +export abstract class Overlay extends Extension { + static overlayName: string = ''; + + protected _renderer: CanvasRenderer | null = null; + + constructor(protected gfx: GfxController) { + super(); + } + + static override setup(di: Container): void { + if (!this.overlayName) { + throw new BlockSuiteError( + ErrorCode.ValueNotExists, + `The overlay constructor '${this.name}' should have a static 'overlayName' property.` + ); + } + + di.addImpl(OverlayIdentifier(this.overlayName), this, [ + GfxControllerIdentifier, + ]); + } + + clear() {} + + dispose() {} + + refresh() { + if (this._renderer) { + this._renderer.refresh(); + } + } + + abstract render(ctx: CanvasRenderingContext2D, rc: RoughCanvas): void; + + setRenderer(renderer: CanvasRenderer | null) { + this._renderer = renderer; + } +} + +export const OverlayIdentifier = createIdentifier('Overlay'); diff --git a/blocksuite/affine/block-surface/src/surface-block-void.ts b/blocksuite/affine/block-surface/src/surface-block-void.ts new file mode 100644 index 0000000000..baddd6b760 --- /dev/null +++ b/blocksuite/affine/block-surface/src/surface-block-void.ts @@ -0,0 +1,20 @@ +import { BlockComponent } from '@blocksuite/block-std'; +import { nothing } from 'lit'; + +import type { SurfaceBlockModel } from './surface-model.js'; +import type { SurfaceBlockService } from './surface-service.js'; + +export class SurfaceBlockVoidComponent extends BlockComponent< + SurfaceBlockModel, + SurfaceBlockService +> { + override render() { + return nothing; + } +} + +declare global { + interface HTMLElementTagNameMap { + 'affine-surface-void': SurfaceBlockVoidComponent; + } +} diff --git a/blocksuite/affine/block-surface/src/surface-block.ts b/blocksuite/affine/block-surface/src/surface-block.ts new file mode 100644 index 0000000000..f52975088a --- /dev/null +++ b/blocksuite/affine/block-surface/src/surface-block.ts @@ -0,0 +1,245 @@ +import type { Color } from '@blocksuite/affine-model'; +import { ThemeProvider } from '@blocksuite/affine-shared/services'; +import type { EditorHost, SurfaceSelection } from '@blocksuite/block-std'; +import { BlockComponent, RANGE_SYNC_EXCLUDE_ATTR } from '@blocksuite/block-std'; +import { + GfxControllerIdentifier, + type Viewport, +} from '@blocksuite/block-std/gfx'; +import type { Slot } from '@blocksuite/global/utils'; +import { Bound } from '@blocksuite/global/utils'; +import { css, html } from 'lit'; +import { query } from 'lit/decorators.js'; + +import { ConnectorElementModel } from './element-model/index.js'; +import { CanvasRenderer } from './renderer/canvas-renderer.js'; +import type { ElementRenderer } from './renderer/elements/index.js'; +import { OverlayIdentifier } from './renderer/overlay.js'; +import type { SurfaceBlockModel } from './surface-model.js'; +import type { SurfaceBlockService } from './surface-service.js'; + +export interface SurfaceContext { + viewport: Viewport; + host: EditorHost; + elementRenderers: Record; + selection: { + selectedIds: string[]; + slots: { + updated: Slot; + }; + }; +} + +export class SurfaceBlockComponent extends BlockComponent< + SurfaceBlockModel, + SurfaceBlockService +> { + static isConnector = (element: unknown): element is ConnectorElementModel => { + return element instanceof ConnectorElementModel; + }; + + static override styles = css` + .affine-edgeless-surface-block-container { + width: 100%; + height: 100%; + } + + .affine-edgeless-surface-block-container canvas { + left: 0; + top: 0; + width: 100%; + height: 100%; + position: absolute; + z-index: 1; + pointer-events: none; + } + + edgeless-block-portal-container { + position: relative; + box-sizing: border-box; + overflow: hidden; + display: block; + height: 100%; + font-family: var(--affine-font-family); + font-size: var(--affine-font-base); + line-height: var(--affine-line-height); + color: var(--affine-text-primary-color); + font-weight: 400; + } + + .affine-block-children-container.edgeless { + padding-left: 0; + position: relative; + overflow: hidden; + height: 100%; + /** + * Fix: pointerEvent stops firing after a short time. + * When a gesture is started, the browser intersects the touch-action values of the touched element and its ancestors, + * up to the one that implements the gesture (in other words, the first containing scrolling element) + * https://developer.mozilla.org/en-US/docs/Web/CSS/touch-action + */ + touch-action: none; + background-color: var(--affine-background-primary-color); + background-image: radial-gradient( + var(--affine-edgeless-grid-color) 1px, + var(--affine-background-primary-color) 1px + ); + z-index: 0; + } + + .affine-edgeless-block-child { + position: absolute; + transform-origin: center; + box-sizing: border-box; + border: 2px solid var(--affine-white-10); + border-radius: 8px; + box-shadow: var(--affine-shadow-3); + pointer-events: all; + } + `; + + private _cachedViewport = new Bound(); + + private _initThemeObserver = () => { + const theme = this.std.get(ThemeProvider); + this.disposables.add(theme.theme$.subscribe(() => this.requestUpdate())); + }; + + private _lastTime = 0; + + private _renderer!: CanvasRenderer; + + fitToViewport = (bound: Bound) => { + const { viewport } = this._gfx; + bound = bound.expand(30); + if (Date.now() - this._lastTime > 200) + this._cachedViewport = viewport.viewportBounds; + this._lastTime = Date.now(); + + if (this._cachedViewport.contains(bound)) return; + + this._cachedViewport = this._cachedViewport.unite(bound); + viewport.setViewportByBound(this._cachedViewport, [0, 0, 0, 0], true); + }; + + refresh = () => { + this._renderer?.refresh(); + }; + + private get _edgelessService() { + return this.std.getService('affine:page') as unknown as SurfaceContext; + } + + private get _gfx() { + return this.std.get(GfxControllerIdentifier); + } + + get renderer() { + return this._renderer; + } + + private _initOverlays() { + this.std.provider.getAll(OverlayIdentifier).forEach(overlay => { + this._renderer.addOverlay(overlay); + }); + + this._disposables.add(() => { + this.std.provider.getAll(OverlayIdentifier).forEach(overlay => { + this._renderer.removeOverlay(overlay); + }); + }); + } + + private _initRenderer() { + const gfx = this._gfx; + const themeService = this.std.get(ThemeProvider); + + this._renderer = new CanvasRenderer({ + viewport: gfx.viewport, + layerManager: gfx.layer, + gridManager: gfx.grid, + enableStackingCanvas: true, + provider: { + generateColorProperty: (color: Color, fallback: string) => + themeService.generateColorProperty( + color, + fallback, + themeService.edgelessTheme + ), + getColorValue: (color: Color, fallback?: string, real?: boolean) => + themeService.getColorValue( + color, + fallback, + real, + themeService.edgelessTheme + ), + getColorScheme: () => themeService.edgelessTheme, + getPropertyValue: (property: string) => + themeService.getCssVariableColor( + property, + themeService.edgelessTheme + ), + selectedElements: () => this._edgelessService.selection.selectedIds, + }, + onStackingCanvasCreated(canvas) { + canvas.className = 'indexable-canvas'; + }, + elementRenderers: this._edgelessService.elementRenderers, + surfaceModel: this.model, + }); + + this._disposables.add(() => { + this._renderer.dispose(); + }); + this._disposables.add( + this._renderer.stackingCanvasUpdated.on(payload => { + if (payload.added.length) { + this._surfaceContainer.append(...payload.added); + } + + if (payload.removed.length) { + payload.removed.forEach(canvas => { + canvas.remove(); + }); + } + }) + ); + this._disposables.add( + this._edgelessService.selection.slots.updated.on(() => { + this._renderer.refresh(); + }) + ); + } + + override connectedCallback() { + super.connectedCallback(); + + this.setAttribute(RANGE_SYNC_EXCLUDE_ATTR, 'true'); + + this._initThemeObserver(); + this._initRenderer(); + this._initOverlays(); + } + + override firstUpdated() { + this._renderer.attach(this._surfaceContainer); + this._surfaceContainer.append(...this._renderer.stackingCanvas); + } + + override render() { + return html` +
+ +
+ `; + } + + @query('.affine-edgeless-surface-block-container') + private accessor _surfaceContainer!: HTMLElement; +} + +declare global { + interface HTMLElementTagNameMap { + 'affine-surface': SurfaceBlockComponent; + } +} diff --git a/blocksuite/affine/block-surface/src/surface-model.ts b/blocksuite/affine/block-surface/src/surface-model.ts new file mode 100644 index 0000000000..30e99abff6 --- /dev/null +++ b/blocksuite/affine/block-surface/src/surface-model.ts @@ -0,0 +1,64 @@ +import type { ConnectorElementModel } from '@blocksuite/affine-model'; +import type { SurfaceBlockProps } from '@blocksuite/block-std/gfx'; +import { SurfaceBlockModel as BaseSurfaceModel } from '@blocksuite/block-std/gfx'; +import { DisposableGroup } from '@blocksuite/global/utils'; +import { defineBlockSchema, DocCollection } from '@blocksuite/store'; + +import { elementsCtorMap } from './element-model/index.js'; +import { SurfaceBlockTransformer } from './surface-transformer.js'; +import { connectorWatcher } from './watchers/connector.js'; +import { groupRelationWatcher } from './watchers/group.js'; + +export const SurfaceBlockSchema = defineBlockSchema({ + flavour: 'affine:surface', + props: (internalPrimitives): SurfaceBlockProps => ({ + elements: internalPrimitives.Boxed(new DocCollection.Y.Map()), + }), + metadata: { + version: 5, + role: 'hub', + parent: ['affine:page'], + children: [ + 'affine:frame', + 'affine:image', + 'affine:bookmark', + 'affine:attachment', + 'affine:embed-*', + 'affine:edgeless-text', + ], + }, + transformer: () => new SurfaceBlockTransformer(), + toModel: () => new SurfaceBlockModel(), +}); + +export type SurfaceMiddleware = (surface: SurfaceBlockModel) => () => void; + +export class SurfaceBlockModel extends BaseSurfaceModel { + private _disposables: DisposableGroup = new DisposableGroup(); + + override _init() { + this._extendElement(elementsCtorMap); + super._init(); + [connectorWatcher(this), groupRelationWatcher(this)].forEach(disposable => + this._disposables.add(disposable) + ); + } + + getConnectors(id: string) { + const connectors = this.getElementsByType( + 'connector' + ) as unknown[] as ConnectorElementModel[]; + + return connectors.filter( + connector => connector.source?.id === id || connector.target?.id === id + ); + } + + override getElementsByType( + type: K + ): BlockSuite.SurfaceElementModelMap[K][] { + return super.getElementsByType( + type + ) as BlockSuite.SurfaceElementModelMap[K][]; + } +} diff --git a/blocksuite/affine/block-surface/src/surface-service.ts b/blocksuite/affine/block-surface/src/surface-service.ts new file mode 100644 index 0000000000..030a34c902 --- /dev/null +++ b/blocksuite/affine/block-surface/src/surface-service.ts @@ -0,0 +1,35 @@ +import { BlockService } from '@blocksuite/block-std'; +import { GfxControllerIdentifier } from '@blocksuite/block-std/gfx'; + +import { type SurfaceBlockModel, SurfaceBlockSchema } from './surface-model.js'; + +export class SurfaceBlockService extends BlockService { + static override readonly flavour = SurfaceBlockSchema.model.flavour; + + surface!: SurfaceBlockModel; + + get layer() { + return this.std.get(GfxControllerIdentifier).layer; + } + + override mounted(): void { + super.mounted(); + + this.surface = this.doc.getBlockByFlavour( + 'affine:surface' + )[0] as SurfaceBlockModel; + + if (!this.surface) { + const disposable = this.doc.slots.blockUpdated.on(payload => { + if (payload.flavour === 'affine:surface') { + disposable.dispose(); + const surface = this.doc.getBlockById( + payload.id + ) as SurfaceBlockModel | null; + if (!surface) return; + this.surface = surface; + } + }); + } + } +} diff --git a/blocksuite/affine/block-surface/src/surface-spec.ts b/blocksuite/affine/block-surface/src/surface-spec.ts new file mode 100644 index 0000000000..fc4f141ded --- /dev/null +++ b/blocksuite/affine/block-surface/src/surface-spec.ts @@ -0,0 +1,36 @@ +import { HighlightSelectionExtension } from '@blocksuite/affine-shared/selection'; +import { + BlockViewExtension, + CommandExtension, + type ExtensionType, + FlavourExtension, +} from '@blocksuite/block-std'; +import { literal } from 'lit/static-html.js'; + +import { + EdgelessSurfaceBlockAdapterExtensions, + SurfaceBlockAdapterExtensions, +} from './adapters/extension.js'; +import { commands } from './commands/index.js'; +import { SurfaceBlockService } from './surface-service.js'; +import { MindMapView } from './view/mindmap.js'; + +const CommonSurfaceBlockSpec: ExtensionType[] = [ + FlavourExtension('affine:surface'), + SurfaceBlockService, + CommandExtension(commands), + HighlightSelectionExtension, + MindMapView, +]; + +export const PageSurfaceBlockSpec: ExtensionType[] = [ + ...CommonSurfaceBlockSpec, + ...SurfaceBlockAdapterExtensions, + BlockViewExtension('affine:surface', literal`affine-surface-void`), +]; + +export const EdgelessSurfaceBlockSpec: ExtensionType[] = [ + ...CommonSurfaceBlockSpec, + ...EdgelessSurfaceBlockAdapterExtensions, + BlockViewExtension('affine:surface', literal`affine-surface`), +]; diff --git a/blocksuite/affine/block-surface/src/surface-transformer.ts b/blocksuite/affine/block-surface/src/surface-transformer.ts new file mode 100644 index 0000000000..d63f1d5f1a --- /dev/null +++ b/blocksuite/affine/block-surface/src/surface-transformer.ts @@ -0,0 +1,106 @@ +import type { SurfaceBlockProps } from '@blocksuite/block-std/gfx'; +import type { + FromSnapshotPayload, + SnapshotNode, + ToSnapshotPayload, + Y, +} from '@blocksuite/store'; +import { BaseBlockTransformer, DocCollection } from '@blocksuite/store'; + +const SURFACE_TEXT_UNIQ_IDENTIFIER = 'affine:surface:text'; +// Used for group children field +const SURFACE_YMAP_UNIQ_IDENTIFIER = 'affine:surface:ymap'; + +export class SurfaceBlockTransformer extends BaseBlockTransformer { + private _elementToJSON(element: Y.Map) { + const value: Record = {}; + element.forEach((_value, _key) => { + value[_key] = this._toJSON(_value); + }); + + return value; + } + + private _fromJSON(value: unknown): unknown { + if (value instanceof Object) { + if (Reflect.has(value, SURFACE_TEXT_UNIQ_IDENTIFIER)) { + const yText = new DocCollection.Y.Text(); + yText.applyDelta(Reflect.get(value, 'delta')); + return yText; + } else if (Reflect.has(value, SURFACE_YMAP_UNIQ_IDENTIFIER)) { + const yMap = new DocCollection.Y.Map(); + const json = Reflect.get(value, 'json') as Record; + Object.entries(json).forEach(([key, value]) => { + yMap.set(key, value); + }); + return yMap; + } + } + return value; + } + + private _toJSON(value: unknown): unknown { + if (value instanceof DocCollection.Y.Text) { + return { + [SURFACE_TEXT_UNIQ_IDENTIFIER]: true, + delta: value.toDelta(), + }; + } else if (value instanceof DocCollection.Y.Map) { + return { + [SURFACE_YMAP_UNIQ_IDENTIFIER]: true, + json: value.toJSON(), + }; + } + return value; + } + + elementFromJSON(element: Record) { + const yMap = new DocCollection.Y.Map(); + Object.entries(element).forEach(([key, value]) => { + yMap.set(key, this._fromJSON(value)); + }); + + return yMap; + } + + override async fromSnapshot( + payload: FromSnapshotPayload + ): Promise> { + const snapshotRet = await super.fromSnapshot(payload); + const elementsJSON = snapshotRet.props.elements as unknown as Record< + string, + unknown + >; + const yMap = new DocCollection.Y.Map>(); + + Object.entries(elementsJSON).forEach(([key, value]) => { + const element = this.elementFromJSON(value as Record); + + yMap.set(key, element); + }); + + const elements = this._internal.Boxed(yMap); + + snapshotRet.props = { + elements, + }; + + return snapshotRet; + } + + override toSnapshot(payload: ToSnapshotPayload) { + const snapshot = super.toSnapshot(payload); + const elementsValue = payload.model.elements.getValue(); + const value: Record = {}; + if (elementsValue) { + elementsValue.forEach((element, key) => { + value[key] = this._elementToJSON(element as Y.Map); + }); + } + snapshot.props = { + elements: value, + }; + + return snapshot; + } +} diff --git a/blocksuite/affine/block-surface/src/utils/a-star.ts b/blocksuite/affine/block-surface/src/utils/a-star.ts new file mode 100644 index 0000000000..bee00280d0 --- /dev/null +++ b/blocksuite/affine/block-surface/src/utils/a-star.ts @@ -0,0 +1,288 @@ +import type { Bound, IVec3 } from '@blocksuite/global/utils'; +import { almostEqual, assertExists } from '@blocksuite/global/utils'; + +import { Graph } from './graph.js'; +import { PriorityQueue } from './priority-queue.js'; + +function cost(point: IVec3, point2: IVec3) { + return Math.abs(point[0] - point2[0]) + Math.abs(point[1] - point2[1]); +} + +function compare(a: [number, number, number], b: [number, number, number]) { + if (a[2] + 0.01 < b[2]) return -1; + else if (a[2] - 0.01 > b[2]) return 1; + else if (a[0] < b[0]) return -1; + else if (a[0] > b[0]) return 1; + else if (a[1] > b[1]) return -1; + else if (a[1] < b[1]) return 1; + else return 0; +} + +function heuristic(a: IVec3, b: IVec3): number { + return Math.abs(a[0] - b[0]) + Math.abs(a[1] - b[1]); +} + +function getDiagonalCount(a: IVec3, last: IVec3, last2: IVec3): number { + if (almostEqual(a[0], last[0]) && almostEqual(a[0], last2[0])) return 0; + if (almostEqual(a[1], last[1]) && almostEqual(a[1], last2[1])) return 0; + return 1; +} + +function pointAlmostEqual(a: IVec3, b: IVec3): boolean { + return almostEqual(a[0], b[0], 0.02) && almostEqual(a[1], b[1], 0.02); +} + +export class AStarRunner { + private _cameFrom = new Map(); + + private _complete = false; + + private _costSoFar = new Map(); + + private _current: IVec3 | null = null; + + private _diagonalCount = new Map(); + + private _frontier!: PriorityQueue< + IVec3, + [diagonalCount: number, pointPriority: number, distCost: number] + >; + + private _graph: Graph; + + private _pointPriority = new Map(); + + get path() { + const result: IVec3[] = []; + let current: null | IVec3 = this._complete + ? this._originalEp + : this._current; + const nextIndexs = [0]; + while (current) { + result.unshift(current); + const froms = this._cameFrom.get(current); + if (!froms) return result; + const index = nextIndexs.shift(); + assertExists(index); + nextIndexs.push(froms.indexs[index]); + current = froms.from[index]; + } + return result; + } + + constructor( + points: IVec3[], + private _sp: IVec3, + private _ep: IVec3, + private _originalSp: IVec3, + private _originalEp: IVec3, + blocks: Bound[] = [], + expandBlocks: Bound[] = [] + ) { + this._sp[2] = 0; + this._ep[2] = 0; + this._originalEp[2] = 0; + this._graph = new Graph([...points], blocks, expandBlocks); + this._init(); + } + + private _init() { + this._cameFrom.set(this._sp, { from: [this._originalSp], indexs: [-1] }); + this._cameFrom.set(this._originalSp, { from: [], indexs: [] }); + + this._costSoFar.set(this._sp, [0]); + this._diagonalCount.set(this._sp, [0]); + this._pointPriority.set(this._sp, [0]); + this._frontier = new PriorityQueue< + IVec3, + [diagonalCount: number, pointPriority: number, distCost: number] + >(compare); + this._frontier.enqueue(this._sp, [0, 0, 0]); + } + + private _neighbors(cur: IVec3) { + const neighbors = this._graph.neighbors(cur); + const cameFroms = this._cameFrom.get(cur); + assertExists(cameFroms); + + cameFroms.from.forEach(from => { + const index = neighbors.findIndex(n => pointAlmostEqual(n, from)); + if (index >= 0) { + neighbors.splice(index, 1); + } + }); + if (cur === this._ep) neighbors.push(this._originalEp); + return neighbors; + } + + reset() { + this._cameFrom.clear(); + this._costSoFar.clear(); + this._diagonalCount.clear(); + this._pointPriority.clear(); + this._complete = false; + this._init(); + } + + run() { + while (!this._complete) { + this.step(); + } + } + + step() { + if (this._complete) return; + this._current = this._frontier.dequeue(); + const current = this._current; + if (!current) { + this._complete = true; + return; + } + if (current === this._ep && pointAlmostEqual(this._ep, this._originalEp)) { + this._originalEp = this._ep; + } + const neighbors = this._neighbors(current); + + // eslint-disable-next-line @typescript-eslint/prefer-for-of + for (let i = 0; i < neighbors.length; i++) { + const next = neighbors[i]; + const curCosts = this._costSoFar.get(current); + const curDiagoalCounts = this._diagonalCount.get(current); + const curPointPrioritys = this._pointPriority.get(current); + const cameFroms = this._cameFrom.get(current); + assertExists(curCosts); + assertExists(curDiagoalCounts); + assertExists(curPointPrioritys); + assertExists(cameFroms); + const newCosts = curCosts.map(co => co + cost(current, next)); + + const newDiagonalCounts = curDiagoalCounts.map( + (count, index) => + count + getDiagonalCount(next, current, cameFroms.from[index]) + ); + assertExists(next[2]); + const newPointPrioritys = curPointPrioritys.map( + pointPriority => pointPriority + next[2] + ); + let index = -1; + if (newCosts.length === 1) { + index = 0; + } else { + const costsIndexs = findAllMinimalIndexs( + newCosts, + (a, b) => a + 0.01 < b, + (a, b) => almostEqual(a, b, 0.02) + ); + if (costsIndexs.length === 1) { + index = costsIndexs[0]; + } else { + const diagonalCounts = costsIndexs.map(i => newDiagonalCounts[i]); + const diagonalCountsIndexs = findAllMinimalIndexs( + diagonalCounts, + (a, b) => a < b, + (a, b) => a === b + ); + if (diagonalCountsIndexs.length === 1) { + index = costsIndexs[diagonalCountsIndexs[0]]; + } else { + const pointPriorities = diagonalCountsIndexs.map( + i => newPointPrioritys[costsIndexs[i]] + ); + const pointPrioritiesIndexs = findAllMaximalIndexs( + pointPriorities, + (a, b) => a > b, + (a, b) => a === b + ); + index = pointPrioritiesIndexs[0]; + } + } + } + const shouldEnqueue = !this._costSoFar.has(next); + const nextCosts = this._costSoFar.get(next) ?? []; + const nextDiagonalCounts = this._diagonalCount.get(next) ?? []; + const nextPointPriorities = this._pointPriority.get(next) ?? []; + const nextCameFrom = this._cameFrom.get(next) ?? { from: [], indexs: [] }; + nextCosts.push(newCosts[index]); + nextDiagonalCounts.push(newDiagonalCounts[index]); + nextPointPriorities.push(newPointPrioritys[index]); + nextCameFrom.from.push(current); + nextCameFrom.indexs.push(index); + + const newDiagonalCount = newDiagonalCounts[index]; + const newPointPriority = newPointPrioritys[index]; + const newCost = newCosts[index]; + + this._costSoFar.set(next, nextCosts); + this._diagonalCount.set(next, nextDiagonalCounts); + this._pointPriority.set(next, nextPointPriorities); + this._cameFrom.set(next, nextCameFrom); + const newPriority: [number, number, number] = [ + newDiagonalCount, + newPointPriority, + newCost + heuristic(next, this._ep), + ]; + if (shouldEnqueue) { + this._frontier.enqueue(next, newPriority); + } else { + const index = this._frontier.heap.findIndex( + item => item.value === next + ); + const old = this._frontier.heap[index]; + if (old) { + if (compare(newPriority, old.priority) < 0) { + old.priority = newPriority; + this._frontier.bubbleUp(index); + } + } else { + this._frontier.enqueue(next, newPriority); + } + } + if ( + pointAlmostEqual(current, this._ep) && + pointAlmostEqual(next, this._originalEp) + ) { + this._originalEp = next; + this._complete = true; + return; + } + } + } +} + +function findAllMinimalIndexs( + data: number[], + isLess: (a: number, b: number) => boolean, + isEqual: (a: number, b: number) => boolean +) { + let min = Infinity; + let indexs: number[] = []; + for (let i = 0; i < data.length; i++) { + const cur = data[i]; + if (isLess(cur, min)) { + min = cur; + indexs = [i]; + } else if (isEqual(cur, min)) { + indexs.push(i); + } + } + return indexs; +} + +function findAllMaximalIndexs( + data: number[], + isGreat: (a: number, b: number) => boolean, + isEqual: (a: number, b: number) => boolean +) { + let max = -Infinity; + let indexs: number[] = []; + for (let i = 0; i < data.length; i++) { + const cur = data[i]; + if (isGreat(cur, max)) { + max = cur; + indexs = [i]; + } else if (isEqual(cur, max)) { + indexs.push(i); + } + } + return indexs; +} diff --git a/blocksuite/affine/block-surface/src/utils/font.ts b/blocksuite/affine/block-surface/src/utils/font.ts new file mode 100644 index 0000000000..dd961670ca --- /dev/null +++ b/blocksuite/affine/block-surface/src/utils/font.ts @@ -0,0 +1,47 @@ +import type { FontFamily } from '@blocksuite/affine-model'; +import { IS_FIREFOX } from '@blocksuite/global/env'; + +export function wrapFontFamily(fontFamily: FontFamily | string): string { + return `"${fontFamily}"`; +} + +export const getFontFaces = IS_FIREFOX + ? () => { + const keys = document.fonts.keys(); + const fonts = []; + let done = false; + while (!done) { + const item = keys.next(); + done = !!item.done; + if (item.value) { + fonts.push(item.value); + } + } + return fonts; + } + : () => [...document.fonts.keys()]; + +export const isSameFontFamily = IS_FIREFOX + ? (fontFamily: FontFamily | string) => (fontFace: FontFace) => + fontFace.family === `"${fontFamily}"` + : (fontFamily: FontFamily | string) => (fontFace: FontFace) => + fontFace.family === fontFamily; + +export function getFontFacesByFontFamily( + fontFamily: FontFamily | string +): FontFace[] { + return ( + getFontFaces() + .filter(isSameFontFamily(fontFamily)) + // remove duplicate font faces + .filter( + (item, index, arr) => + arr.findIndex( + fontFace => + fontFace.family === item.family && + fontFace.weight === item.weight && + fontFace.style === item.style + ) === index + ) + ); +} diff --git a/blocksuite/affine/block-surface/src/utils/graph.ts b/blocksuite/affine/block-surface/src/utils/graph.ts new file mode 100644 index 0000000000..1440f7879d --- /dev/null +++ b/blocksuite/affine/block-surface/src/utils/graph.ts @@ -0,0 +1,151 @@ +import type { Bound, IVec, IVec3 } from '@blocksuite/global/utils'; +import { + almostEqual, + isOverlap as _isOverlap, + linePolygonIntersects, +} from '@blocksuite/global/utils'; + +function isOverlap(line: IVec[], line2: IVec[]) { + if ( + [line[0][1], line[1][1], line2[0][1], line2[1][1]].every(y => + almostEqual(y, line[0][1], 0.02) + ) + ) { + return _isOverlap(line, line2, 0); + } else if ( + [line[0][0], line[1][0], line2[0][0], line2[1][0]].every(x => + almostEqual(x, line[0][0], 0.02) + ) + ) { + return _isOverlap(line, line2, 1); + } + return false; +} + +function arrayAlmostEqual(point: IVec | IVec3, point2: IVec | IVec3) { + return almostEqual(point[0], point2[0]) && almostEqual(point[1], point2[1]); +} + +export class Graph { + private _xMap = new Map(); + + private _yMap = new Map(); + + constructor( + private points: V[], + private blocks: Bound[] = [], + private expandedBlocks: Bound[] = [], + private excludedPoints: V[] = [] + ) { + const xMap = this._xMap; + const yMap = this._yMap; + this.points.forEach(point => { + const [x, y] = point; + if (!xMap.has(x)) xMap.set(x, []); + if (!yMap.has(y)) yMap.set(y, []); + xMap.get(x)?.push(point); + yMap.get(y)?.push(point); + }); + } + + private _canSkipBlock(point: IVec | IVec3) { + return this.excludedPoints.some(excludedPoint => { + return arrayAlmostEqual(point, excludedPoint); + }); + } + + private _isBlock(sp: IVec | IVec3, ep: IVec | IVec3) { + return ( + this.blocks.some(block => { + const rst = linePolygonIntersects(sp as IVec, ep as IVec, block.points); + return ( + rst?.length === 2 || + block.isPointInBound(sp as IVec, 0) || + block.isPointInBound(ep as IVec, 0) || + [ + block.leftLine, + block.upperLine, + block.rightLine, + block.lowerLine, + ].some(line => { + return isOverlap(line, [sp, ep] as IVec[]); + }) + ); + }) || + this.expandedBlocks.some(block => { + const result = linePolygonIntersects( + sp as IVec, + ep as IVec, + block.expand(-0.5).points + ); + return result?.length === 2; + }) + ); + } + + neighbors(curPoint: V): V[] { + const [x, y] = curPoint; + const neighbors = new Set(); + const xPoints = this._xMap.get(x); + const yPoints = this._yMap.get(y); + if (xPoints) { + let plusMin = Infinity; + let minusMin = Infinity; + let plusPoint: V | undefined; + let minusPoint: V | undefined; + xPoints.forEach(point => { + if (arrayAlmostEqual(point, curPoint as unknown as IVec)) return; + const dif = point[1] - curPoint[1]; + if (dif > 0 && dif < plusMin) { + plusMin = dif; + plusPoint = point; + } + if (dif < 0 && Math.abs(dif) < minusMin) { + minusMin = Math.abs(dif); + minusPoint = point; + } + }); + if ( + plusPoint && + (this._canSkipBlock(plusPoint) || !this._isBlock(curPoint, plusPoint)) + ) { + neighbors.add(plusPoint); + } + if ( + minusPoint && + (this._canSkipBlock(minusPoint) || !this._isBlock(curPoint, minusPoint)) + ) + neighbors.add(minusPoint); + } + if (yPoints) { + let plusMin = Infinity; + let minusMin = Infinity; + let plusPoint: V | undefined; + let minusPoint: V | undefined; + yPoints.forEach(point => { + if (arrayAlmostEqual(point, curPoint)) return; + const dif = point[0] - curPoint[0]; + if (dif > 0 && dif < plusMin) { + plusMin = dif; + plusPoint = point; + } + if (dif < 0 && Math.abs(dif) < minusMin) { + minusMin = Math.abs(dif); + minusPoint = point; + } + }); + if ( + plusPoint && + (this._canSkipBlock(plusPoint) || !this._isBlock(curPoint, plusPoint)) + ) + neighbors.add(plusPoint); + if ( + minusPoint && + (this._canSkipBlock(minusPoint) || !this._isBlock(curPoint, minusPoint)) + ) + neighbors.add(minusPoint); + } + + return Array.from(neighbors); + } +} diff --git a/blocksuite/affine/block-surface/src/utils/index.ts b/blocksuite/affine/block-surface/src/utils/index.ts new file mode 100644 index 0000000000..c3df20e455 --- /dev/null +++ b/blocksuite/affine/block-surface/src/utils/index.ts @@ -0,0 +1,34 @@ +import { nanoid } from 'nanoid'; + +import { ZOOM_WHEEL_STEP } from '../consts.js'; + +export { generateKeyBetween, generateNKeysBetween } from 'fractional-indexing'; + +export function generateElementId() { + return nanoid(10); +} + +/** + * Normalizes wheel delta. + * + * See https://stackoverflow.com/a/13650579 + * + * From https://github.com/excalidraw/excalidraw/blob/master/src/components/App.tsx + * MIT License + */ +export function normalizeWheelDeltaY(delta: number, zoom = 1) { + const sign = Math.sign(delta); + const abs = Math.abs(delta); + const maxStep = ZOOM_WHEEL_STEP * 100; + if (abs > maxStep) { + delta = maxStep * sign; + } + let newZoom = zoom - delta / 100; + // increase zoom steps the more zoomed-in we are (applies to >100% only) + newZoom += + Math.log10(Math.max(1, zoom)) * + -sign * + // reduced amplification for small deltas (small movements on a trackpad) + Math.min(1, abs / 20); + return newZoom; +} diff --git a/blocksuite/affine/block-surface/src/utils/mindmap/layout.ts b/blocksuite/affine/block-surface/src/utils/mindmap/layout.ts new file mode 100644 index 0000000000..2328084706 --- /dev/null +++ b/blocksuite/affine/block-surface/src/utils/mindmap/layout.ts @@ -0,0 +1,201 @@ +import { + LayoutType, + type MindmapElementModel, + type MindmapNode, + type MindmapRoot, +} from '@blocksuite/affine-model'; +import type { SerializedXYWH } from '@blocksuite/global/utils'; +import { Bound } from '@blocksuite/global/utils'; + +export const NODE_VERTICAL_SPACING = 45; +export const NODE_HORIZONTAL_SPACING = 110; +export const NODE_FIRST_LEVEL_HORIZONTAL_SPACING = 200; + +type TreeSize = { + /** + * The parent of the tree + */ + parent: TreeSize | null; + + /** + * The root node of the tree + */ + root: MindmapNode; + + /** + * The size of the tree, including its descendants + */ + bound: Bound; + + /** + * The size of the children of the root + */ + children: TreeSize[]; +}; + +const calculateNodeSize = ( + root: MindmapNode, + parent: TreeSize | null = null, + rootChildren?: MindmapNode[] +): TreeSize => { + const bound = root.element.elementBound; + const children: TreeSize[] = []; + const firstLevel = parent === null; + + rootChildren = rootChildren ?? root.children; + + const treeSize: TreeSize = { + parent, + root, + bound, + children, + }; + + if (rootChildren?.length && !root.detail.collapsed) { + const childrenBound = rootChildren.reduce( + (pre, node) => { + const childSize = calculateNodeSize(node, treeSize); + + children.push(childSize); + + pre.w = Math.max(pre.w, childSize.bound.w); + pre.h += + pre.h > 0 + ? NODE_VERTICAL_SPACING + childSize.bound.h + : childSize.bound.h; + + return pre; + }, + new Bound(0, 0, 0, 0) + ); + + bound.w += + childrenBound.w + + (firstLevel + ? NODE_FIRST_LEVEL_HORIZONTAL_SPACING + : NODE_HORIZONTAL_SPACING); + bound.h = Math.max(bound.h, childrenBound.h); + } + + return treeSize; +}; + +const layoutTree = ( + tree: TreeSize, + layoutType: LayoutType.LEFT | LayoutType.RIGHT, + mindmap: MindmapElementModel, + path: number[] = [0] +) => { + const firstLevel = path.length === 1; + const treeHeight = tree.bound.h; + const currentX = + layoutType === LayoutType.RIGHT + ? tree.root.element.x + + tree.root.element.w + + (firstLevel + ? NODE_FIRST_LEVEL_HORIZONTAL_SPACING + : NODE_HORIZONTAL_SPACING) + : tree.root.element.x - + (firstLevel + ? NODE_FIRST_LEVEL_HORIZONTAL_SPACING + : NODE_HORIZONTAL_SPACING); + let currentY = tree.root.element.y + (tree.root.element.h - treeHeight) / 2; + + if (tree.root.element.h >= treeHeight && tree.children.length) { + const onlyChild = tree.children[0]; + + currentY += (tree.root.element.h - onlyChild.root.element.h) / 2; + } + + if (!tree.root.detail.collapsed) { + tree.children.forEach((subtree, idx) => { + const subtreeRootEl = subtree.root.element; + const subtreeHeight = subtree.bound.h; + const xywh = `[${ + layoutType === LayoutType.RIGHT ? currentX : currentX - subtreeRootEl.w + },${currentY + (subtreeHeight - subtreeRootEl.h) / 2},${subtreeRootEl.w},${subtreeRootEl.h}]` as SerializedXYWH; + + const currentNodePath = [...path, idx]; + + if (subtreeRootEl.xywh !== xywh) { + subtreeRootEl.xywh = xywh; + } + + layoutTree(subtree, layoutType, mindmap, currentNodePath); + + currentY += subtreeHeight + NODE_VERTICAL_SPACING; + }); + } +}; + +const layoutRight = ( + root: MindmapNode, + mindmap: MindmapElementModel, + path = [0] +) => { + const rootTree = calculateNodeSize(root, null); + + layoutTree(rootTree, LayoutType.RIGHT, mindmap, path); +}; + +const layoutLeft = ( + root: MindmapNode, + mindmap: MindmapElementModel, + path = [0] +) => { + const rootTree = calculateNodeSize(root, null); + + layoutTree(rootTree, LayoutType.LEFT, mindmap, path); +}; + +const layoutBalance = ( + root: MindmapNode, + mindmap: MindmapElementModel, + path = [0] +) => { + const rootTree = calculateNodeSize(root, null); + const leftTree: MindmapNode[] = (root as MindmapRoot).left; + const rightTree: MindmapNode[] = (root as MindmapRoot).right; + + { + const leftTreeSize = calculateNodeSize(root, null, leftTree); + const mockRoot = { + parent: null, + root: rootTree.root, + bound: leftTreeSize.bound, + children: leftTreeSize.children, + }; + + layoutTree(mockRoot, LayoutType.LEFT, mindmap, path); + } + + { + const rightTreeSize = calculateNodeSize(root, null, rightTree); + const mockRoot = { + parent: null, + root: rootTree.root, + bound: rightTreeSize.bound, + children: rightTreeSize.children, + }; + + layoutTree(mockRoot, LayoutType.RIGHT, mindmap, [0]); + } +}; + +export const layout = ( + root: MindmapNode, + mindmap: MindmapElementModel, + layoutDir: LayoutType | null, + path: number[] +) => { + layoutDir = layoutDir ?? mindmap.layoutType; + + switch (layoutDir) { + case LayoutType.RIGHT: + return layoutRight(root, mindmap, path); + case LayoutType.LEFT: + return layoutLeft(root, mindmap, path); + case LayoutType.BALANCE: + return layoutBalance(root, mindmap, path); + } +}; diff --git a/blocksuite/affine/block-surface/src/utils/mindmap/utils.ts b/blocksuite/affine/block-surface/src/utils/mindmap/utils.ts new file mode 100644 index 0000000000..451752daca --- /dev/null +++ b/blocksuite/affine/block-surface/src/utils/mindmap/utils.ts @@ -0,0 +1,689 @@ +import { + applyNodeStyle, + LayoutType, + type MindmapElementModel, + type MindmapNode, + type MindmapRoot, + type MindmapStyle, + type NodeDetail, + type NodeType, + type ShapeElementModel, +} from '@blocksuite/affine-model'; +import { + generateKeyBetween, + type SurfaceBlockModel, +} from '@blocksuite/block-std/gfx'; +import { assertType, isEqual, type IVec, last } from '@blocksuite/global/utils'; +import { DocCollection } from '@blocksuite/store'; + +import { fitContent } from '../../renderer/elements/shape/utils.js'; +import { layout } from './layout.js'; + +export function getHoveredArea( + target: ShapeElementModel, + position: [number, number], + layoutDir: LayoutType +): 'top-left' | 'top-right' | 'bottom-left' | 'bottom-right' { + const { x, y, w, h } = target; + const center = + layoutDir === LayoutType.BALANCE + ? [x + w / 2, y + h / 2] + : layoutDir === LayoutType.LEFT + ? [x + (w / 3) * 1, y + h / 2] + : [x + (w / 3) * 2, y + h / 2]; + + return `${position[1] - center[1] > 0 ? 'bottom' : 'top'}-${position[0] - center[0] > 0 ? 'right' : 'left'}`; +} + +/** + * Hide the connector between the target node and its parent + */ +export function hideNodeConnector( + mindmap: MindmapElementModel, + /** + * The mind map node which's connector will be hide + */ + target: MindmapNode +) { + const parent = mindmap.getParentNode(target.id); + + if (!parent) { + return; + } + + const connectorId = `#${parent.id}-${target.id}`; + const connector = mindmap.connectors.get(connectorId); + + if (!connector) { + return; + } + + connector.opacity = 0; + + return () => { + connector.opacity = 1; + }; +} + +/** + * Move the node to the new parent within the same mind map + * @param mindmap + * @param node the node should already exist in the mind map + * @param parent + * @param targetIndex + * @param layout + * @returns + */ +function moveNodePosition( + mindmap: MindmapElementModel, + node: MindmapNode, + parent: string | MindmapNode, + targetIndex: number, + layout?: LayoutType +) { + parent = mindmap.nodeMap.get( + typeof parent === 'string' ? parent : parent.id + )!; + + if (!parent || !mindmap.nodeMap.has(node.id)) { + return; + } + + assertType(parent); + + if (layout === LayoutType.BALANCE || parent !== mindmap.tree) { + layout = undefined; + } + + const siblings = parent.children.filter(child => child !== node); + + targetIndex = Math.min(targetIndex, parent.children.length); + + siblings.splice(targetIndex, 0, node); + + // calculate the index + // the sibling node may be the same node, so we need to filter it out + const preSibling = siblings[targetIndex - 1]; + const afterSibling = siblings[targetIndex + 1]; + const index = + preSibling || afterSibling + ? generateKeyBetween( + preSibling?.detail.index ?? null, + afterSibling?.detail.index ?? null + ) + : (node.detail.index ?? undefined); + + mindmap.surface.doc.transact(() => { + const val: NodeDetail = { + ...node.detail, + index, + parent: parent.id, + }; + + mindmap.children.set(node.id, val); + }); + + if (parent.detail.collapsed) { + mindmap.toggleCollapse(parent); + } + + mindmap.layout(); + + return mindmap.nodeMap.get(node.id); +} + +export function applyStyle( + mindmap: MindmapElementModel, + shouldFitContent: boolean = false +) { + mindmap.surface.doc.transact(() => { + const style = mindmap.styleGetter; + + if (!style) return; + + applyNodeStyle(mindmap.tree, style.root); + if (shouldFitContent) { + fitContent(mindmap.tree.element as ShapeElementModel); + } + + const walk = (node: MindmapNode, path: number[]) => { + node.children.forEach((child, idx) => { + const currentPath = [...path, idx]; + const nodeStyle = style.getNodeStyle(child, currentPath); + + applyNodeStyle(child, nodeStyle.node); + if (shouldFitContent) { + fitContent(child.element as ShapeElementModel); + } + + walk(child, currentPath); + }); + }; + + walk(mindmap.tree, [0]); + }); +} + +/** + * + * @param mindmap the mind map to add the node to + * @param parent the parent node or the parent node id + * @param node the node must be an detached node + * @param targetIndex the index to insert the node at + * @returns + */ +export function addNode( + mindmap: MindmapElementModel, + parent: string | MindmapNode, + node: MindmapNode, + targetIndex?: number +) { + const parentNode = mindmap.getNode( + typeof parent === 'string' ? parent : parent.id + ); + + if (!parentNode) { + return; + } + + const children = parentNode.children.slice(); + + targetIndex = + targetIndex !== undefined + ? Math.min(targetIndex, children.length) + : children.length; + + children.splice(targetIndex, 0, node); + + const before = children[targetIndex - 1] ?? null; + const after = children[targetIndex + 1] ?? null; + const index = + before || after + ? generateKeyBetween( + before?.detail.index ?? null, + after?.detail.index ?? null + ) + : node.detail.index; + + mindmap.surface.doc.transact(() => { + mindmap.children.set(node.id, { + ...node.detail, + index, + parent: parentNode.id, + }); + + const recursiveAddChild = (node: MindmapNode) => { + node.children?.forEach(child => { + mindmap.children.set(child.id, { + ...child.detail, + parent: node.id, + }); + + recursiveAddChild(child); + }); + }; + + recursiveAddChild(node); + }); + + if (parentNode.detail.collapsed) { + mindmap.toggleCollapse(parentNode); + } + + mindmap.layout(); +} + +export function addTree( + mindmap: MindmapElementModel, + parent: string | MindmapNode, + tree: NodeType | MindmapNode, + /** + * `sibling` indicates where to insert a subtree among peer elements. + * If it's a string, it represents a peer element's ID; + * if it's a number, it represents its index. + * The subtree will be inserted before the sibling element. + */ + sibling?: string | number +) { + parent = typeof parent === 'string' ? parent : parent.id; + + if (!mindmap.nodeMap.has(parent) || !parent) { + return null; + } + + assertType(parent); + + const traverse = ( + node: NodeType | MindmapNode, + parent: string, + sibling?: string | number + ) => { + let nodeId: string; + if ('text' in node) { + nodeId = mindmap.addNode(parent, sibling, 'before', { + text: node.text, + }); + } else { + mindmap.children.set(node.id, { + ...node.detail, + parent, + }); + nodeId = node.id; + } + + node.children?.forEach(child => traverse(child, nodeId)); + + return nodeId; + }; + + if (!('text' in tree)) { + // Modify the children ymap directly hence need transaction + mindmap.surface.doc.transact(() => { + traverse(tree, parent, sibling); + }); + + applyStyle(mindmap); + mindmap.layout(); + + return mindmap.nodeMap.get(tree.id); + } else { + const nodeId = traverse(tree, parent, sibling); + + mindmap.layout(); + + return mindmap.nodeMap.get(nodeId); + } +} + +/** + * Detach a mindmap node or subtree. It is similar to `removeChild` but + * it does not delete the node. + * + * So the node can be used to create a new mind map or merge into other mind map + * @param mindmap the mind map that the subtree belongs to + * @param subtree the subtree to detach + */ +export function detachMindmap( + mindmap: MindmapElementModel, + subtree: string | MindmapNode +) { + subtree = + typeof subtree === 'string' ? mindmap.nodeMap.get(subtree)! : subtree; + + assertType(subtree); + + if (!subtree) return; + + const traverse = (subtree: MindmapNode) => { + mindmap.children.delete(subtree.id); + + // cut the reference inside the ymap + subtree.detail = { + ...subtree.detail, + }; + + subtree.children.forEach(child => traverse(child)); + }; + + mindmap.surface.doc.transact(() => { + traverse(subtree); + }); + + mindmap.layout(); + + delete subtree.detail.parent; + + return subtree; +} + +export function handleLayout( + mindmap: MindmapElementModel, + tree?: MindmapNode | MindmapRoot, + shouldApplyStyle = true, + layoutType?: LayoutType +) { + if (!tree || !tree.element) return; + + if (shouldApplyStyle) { + applyStyle(mindmap, true); + } + + mindmap.surface.doc.transact(() => { + const path = mindmap.getPath(tree.id); + layout(tree, mindmap, layoutType ?? mindmap.getLayoutDir(tree.id), path); + }); +} + +export function createFromTree( + tree: MindmapNode, + style: MindmapStyle, + layoutType: LayoutType, + surface: SurfaceBlockModel +) { + const children = new DocCollection.Y.Map(); + const traverse = (subtree: MindmapNode, parent?: string) => { + const value: NodeDetail = { + ...subtree.detail, + parent, + }; + + if (!parent) { + delete value.parent; + } + + children.set(subtree.id, value); + + subtree.children.forEach(child => traverse(child, subtree.id)); + }; + + traverse(tree); + + const mindmapId = surface.addElement({ + type: 'mindmap', + children, + layoutType, + style, + }); + const mindmap = surface.getElementById(mindmapId) as MindmapElementModel; + handleLayout(mindmap, mindmap.tree, true, mindmap.layoutType); + + return mindmap; +} + +/** + * Move a subtree from one mind map to another + * @param from the mind map that the `subtree` belongs to + * @param subtree the subtree to move + * @param to the mind map to move the `subtree` to + * @param parent the new parent node to attach the `subtree` to + * @param index the index to insert the `subtree` at + */ +export function moveNode( + from: MindmapElementModel, + subtree: MindmapNode, + to: MindmapElementModel, + parent: MindmapNode | string, + index: number +) { + if (from === to) { + return moveNodePosition(from, subtree, parent, index); + } + + if (!detachMindmap(from, subtree)) return; + + return addNode(to, parent, subtree, index); +} + +export function findTargetNode( + mindmap: MindmapElementModel, + position: IVec +): MindmapNode | null { + const find = (node: MindmapNode): MindmapNode | null => { + if (!node.responseArea) { + return null; + } + + const layoutDir = mindmap.getLayoutDir(node); + + if ( + layoutDir === LayoutType.BALANCE || + (layoutDir === LayoutType.RIGHT && + position[0] > node.element.x + node.element.w) || + (layoutDir === LayoutType.LEFT && position[0] < node.element.x) + ) { + for (const child of node.children) { + const result = find(child); + if (result) { + return result; + } + } + } + + return node.responseArea.containsPoint(position) ? node : null; + }; + + return find(mindmap.tree); +} + +function determineInsertPosition( + mindmap: MindmapElementModel, + mindmapNode: MindmapNode, + position: IVec +): + | { + type: 'child'; + layoutDir: LayoutType.LEFT | LayoutType.RIGHT; + } + | { + type: 'sibling'; + layoutDir: LayoutType.LEFT | LayoutType.RIGHT; + position: 'prev' | 'next'; + } + | null { + if ( + !mindmapNode.responseArea || + !mindmapNode.responseArea.containsPoint(position) + ) { + return null; + } + + const layoutDir = mindmap.getLayoutDir(mindmapNode); + const elementBound = mindmapNode.element.elementBound; + const targetLayout: LayoutType.LEFT | LayoutType.RIGHT = + layoutDir === LayoutType.BALANCE + ? position[0] > elementBound.x + elementBound.w / 2 + ? LayoutType.RIGHT + : LayoutType.LEFT + : layoutDir; + + if ( + elementBound.containsPoint(position) || + (layoutDir === LayoutType.RIGHT + ? position[0] > elementBound.x + elementBound.w + : position[0] < elementBound.x) + ) { + return { + type: 'child', + layoutDir: targetLayout, + }; + } + + if ( + mindmap.layoutType === LayoutType.BALANCE && + mindmap.getPath(mindmapNode.id).length === 2 + ) { + return { + type: 'sibling', + layoutDir: targetLayout, + position: + targetLayout === LayoutType.LEFT + ? position[1] > elementBound.y + elementBound.h / 2 + ? 'prev' + : 'next' + : position[1] > elementBound.y + elementBound.h / 2 + ? 'next' + : 'prev', + }; + } + + return { + type: 'sibling', + layoutDir: targetLayout, + position: + position[1] > elementBound.y + elementBound.h / 2 ? 'next' : 'prev', + }; +} + +function showMergeIndicator( + targetMindMap: MindmapElementModel, + target: MindmapNode, + sourceMindMap: MindmapElementModel, + source: MindmapNode, + insertPosition: + | { + type: 'sibling'; + layoutDir: Exclude; + position: 'prev' | 'next'; + } + | { type: 'child'; layoutDir: Exclude }, + callback: (option: { + targetMindMap: MindmapElementModel; + target: MindmapNode; + sourceMindMap: MindmapElementModel; + source: MindmapNode; + newParent: MindmapNode; + insertPosition: + | { + type: 'sibling'; + layoutDir: Exclude; + position: 'prev' | 'next'; + } + | { type: 'child'; layoutDir: Exclude }; + path: number[]; + }) => () => void +) { + const newParent = + insertPosition.type === 'child' + ? target + : targetMindMap.getParentNode(target.id)!; + + if (!newParent) { + return null; + } + + const path = targetMindMap.getPath(newParent); + const curPath = sourceMindMap.getPath(source.id); + + if (insertPosition.type === 'sibling') { + const curPath = targetMindMap.getPath(target.id); + const parent = targetMindMap.getParentNode(target.id); + + if (!parent) { + return null; + } + + const idx = parent.children + .filter(child => child.id !== source.id) + .indexOf(target); + + path.push( + idx === -1 + ? Math.max( + 0, + last(curPath)! + (insertPosition.position === 'next' ? 1 : 0) + ) + : Math.max(0, idx + (insertPosition.position === 'next' ? 1 : 0)) + ); + } else { + path.push(target.children.length); + } + + // hide original connector + const abortPreview = callback({ + targetMindMap, + target: target, + sourceMindMap, + source, + newParent, + insertPosition, + path, + }); + + const abort = () => { + abortPreview?.(); + }; + + const merge = () => { + abort(); + + if (targetMindMap === sourceMindMap && isEqual(path, curPath)) { + return; + } + + moveNode(sourceMindMap, source, targetMindMap, newParent, last(path)!); + }; + + return { + abort, + merge, + }; +} + +/** + * Try to move a node to another mind map. + * It will show a merge indicator if the node can be merged to the target mind map. + * @param targetMindMap + * @param target + * @param sourceMindMap + * @param source + * @param position + * @return return two functions, `abort` and `merge`. `abort` will cancel the operation and `merge` will merge the node to the target mind map. + */ +export function tryMoveNode( + targetMindMap: MindmapElementModel, + target: MindmapNode, + sourceMindMap: MindmapElementModel, + source: MindmapNode, + position: IVec, + callback: (option: { + targetMindMap: MindmapElementModel; + target: MindmapNode; + sourceMindMap: MindmapElementModel; + source: MindmapNode; + newParent: MindmapNode; + insertPosition: + | { + type: 'sibling'; + layoutDir: Exclude; + position: 'prev' | 'next'; + } + | { type: 'child'; layoutDir: Exclude }; + path: number[]; + }) => () => void +) { + const insertInfo = determineInsertPosition(targetMindMap, target, position); + + if (!insertInfo) { + return null; + } + + return showMergeIndicator( + targetMindMap, + target, + sourceMindMap, + source, + insertInfo, + callback + ); +} + +/** + * Check if the mind map contains the target node. + * @param mindmap Mind map to check + * @param targetNode Node to check + * @param searchParent If provided, check if the node is a descendant of the parent node. Otherwise, check the whole mind map. + * @returns + */ +export function containsNode( + mindmap: MindmapElementModel, + targetNode: MindmapNode, + searchParent?: MindmapNode +) { + searchParent = searchParent ?? mindmap.tree; + + const find = (checkAgainstNode: MindmapNode) => { + if (checkAgainstNode.id === targetNode.id) { + return true; + } + + for (const child of checkAgainstNode.children) { + if (find(child)) { + return true; + } + } + + return false; + }; + + return find(searchParent); +} diff --git a/blocksuite/affine/block-surface/src/utils/path-data-parser/LICENSE b/blocksuite/affine/block-surface/src/utils/path-data-parser/LICENSE new file mode 100644 index 0000000000..ea684ea1b1 --- /dev/null +++ b/blocksuite/affine/block-surface/src/utils/path-data-parser/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2020 Preet Shihn + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. \ No newline at end of file diff --git a/blocksuite/affine/block-surface/src/utils/path-data-parser/absolutize.ts b/blocksuite/affine/block-surface/src/utils/path-data-parser/absolutize.ts new file mode 100644 index 0000000000..c8dafe2910 --- /dev/null +++ b/blocksuite/affine/block-surface/src/utils/path-data-parser/absolutize.ts @@ -0,0 +1,117 @@ +import type { Segment } from './parser.js'; + +// Translate relative commands to absolute commands +export function absolutize(segments: Segment[]): Segment[] { + let cx = 0, + cy = 0; + let subx = 0, + suby = 0; + const out: Segment[] = []; + for (const { key, data } of segments) { + switch (key) { + case 'M': + out.push({ key: 'M', data: [...data] }); + [cx, cy] = data; + [subx, suby] = data; + break; + case 'm': + cx += data[0]; + cy += data[1]; + out.push({ key: 'M', data: [cx, cy] }); + subx = cx; + suby = cy; + break; + case 'L': + out.push({ key: 'L', data: [...data] }); + [cx, cy] = data; + break; + case 'l': + cx += data[0]; + cy += data[1]; + out.push({ key: 'L', data: [cx, cy] }); + break; + case 'C': + out.push({ key: 'C', data: [...data] }); + cx = data[4]; + cy = data[5]; + break; + case 'c': { + const newdata = data.map((d, i) => (i % 2 ? d + cy : d + cx)); + out.push({ key: 'C', data: newdata }); + cx = newdata[4]; + cy = newdata[5]; + break; + } + case 'Q': + out.push({ key: 'Q', data: [...data] }); + cx = data[2]; + cy = data[3]; + break; + case 'q': { + const newdata = data.map((d, i) => (i % 2 ? d + cy : d + cx)); + out.push({ key: 'Q', data: newdata }); + cx = newdata[2]; + cy = newdata[3]; + break; + } + case 'A': + out.push({ key: 'A', data: [...data] }); + cx = data[5]; + cy = data[6]; + break; + case 'a': + cx += data[5]; + cy += data[6]; + out.push({ + key: 'A', + data: [data[0], data[1], data[2], data[3], data[4], cx, cy], + }); + break; + case 'H': + out.push({ key: 'H', data: [...data] }); + cx = data[0]; + break; + case 'h': + cx += data[0]; + out.push({ key: 'H', data: [cx] }); + break; + case 'V': + out.push({ key: 'V', data: [...data] }); + cy = data[0]; + break; + case 'v': + cy += data[0]; + out.push({ key: 'V', data: [cy] }); + break; + case 'S': + out.push({ key: 'S', data: [...data] }); + cx = data[2]; + cy = data[3]; + break; + case 's': { + const newdata = data.map((d, i) => (i % 2 ? d + cy : d + cx)); + out.push({ key: 'S', data: newdata }); + cx = newdata[2]; + cy = newdata[3]; + break; + } + case 'T': + out.push({ key: 'T', data: [...data] }); + cx = data[0]; + cy = data[1]; + break; + case 't': + cx += data[0]; + cy += data[1]; + out.push({ key: 'T', data: [cx, cy] }); + break; + case 'Z': + case 'z': + out.push({ key: 'Z', data: [] }); + cx = subx; + cy = suby; + break; + } + } + return out; +} diff --git a/blocksuite/affine/block-surface/src/utils/path-data-parser/normalize.ts b/blocksuite/affine/block-surface/src/utils/path-data-parser/normalize.ts new file mode 100644 index 0000000000..e64756b8d3 --- /dev/null +++ b/blocksuite/affine/block-surface/src/utils/path-data-parser/normalize.ts @@ -0,0 +1,280 @@ +import type { Segment } from './parser.js'; + +// Normalize path to include only M, L, C, and Z commands +export function normalize(segments: Segment[]): Segment[] { + const out: Segment[] = []; + + let lastType = ''; + let cx = 0, + cy = 0; + let subx = 0, + suby = 0; + let lcx = 0, + lcy = 0; + + for (const { key, data } of segments) { + switch (key) { + case 'M': + out.push({ key: 'M', data: [...data] }); + [cx, cy] = data; + [subx, suby] = data; + break; + case 'C': + out.push({ key: 'C', data: [...data] }); + cx = data[4]; + cy = data[5]; + lcx = data[2]; + lcy = data[3]; + break; + case 'L': + out.push({ key: 'L', data: [...data] }); + [cx, cy] = data; + break; + case 'H': + cx = data[0]; + out.push({ key: 'L', data: [cx, cy] }); + break; + case 'V': + cy = data[0]; + out.push({ key: 'L', data: [cx, cy] }); + break; + case 'S': { + let cx1 = 0, + cy1 = 0; + if (lastType === 'C' || lastType === 'S') { + cx1 = cx + (cx - lcx); + cy1 = cy + (cy - lcy); + } else { + cx1 = cx; + cy1 = cy; + } + out.push({ key: 'C', data: [cx1, cy1, ...data] }); + lcx = data[0]; + lcy = data[1]; + cx = data[2]; + cy = data[3]; + break; + } + case 'T': { + const [x, y] = data; + let x1 = 0, + y1 = 0; + if (lastType === 'Q' || lastType === 'T') { + x1 = cx + (cx - lcx); + y1 = cy + (cy - lcy); + } else { + x1 = cx; + y1 = cy; + } + const cx1 = cx + (2 * (x1 - cx)) / 3; + const cy1 = cy + (2 * (y1 - cy)) / 3; + const cx2 = x + (2 * (x1 - x)) / 3; + const cy2 = y + (2 * (y1 - y)) / 3; + out.push({ key: 'C', data: [cx1, cy1, cx2, cy2, x, y] }); + lcx = x1; + lcy = y1; + cx = x; + cy = y; + break; + } + case 'Q': { + const [x1, y1, x, y] = data; + const cx1 = cx + (2 * (x1 - cx)) / 3; + const cy1 = cy + (2 * (y1 - cy)) / 3; + const cx2 = x + (2 * (x1 - x)) / 3; + const cy2 = y + (2 * (y1 - y)) / 3; + out.push({ key: 'C', data: [cx1, cy1, cx2, cy2, x, y] }); + lcx = x1; + lcy = y1; + cx = x; + cy = y; + break; + } + case 'A': { + const r1 = Math.abs(data[0]); + const r2 = Math.abs(data[1]); + const angle = data[2]; + const largeArcFlag = data[3]; + const sweepFlag = data[4]; + const x = data[5]; + const y = data[6]; + if (r1 === 0 || r2 === 0) { + out.push({ key: 'C', data: [cx, cy, x, y, x, y] }); + cx = x; + cy = y; + } else { + if (cx !== x || cy !== y) { + const curves: number[][] = arcToCubicCurves( + cx, + cy, + x, + y, + r1, + r2, + angle, + largeArcFlag, + sweepFlag + ); + curves.forEach(function (curve) { + out.push({ key: 'C', data: curve }); + }); + cx = x; + cy = y; + } + } + break; + } + case 'Z': + out.push({ key: 'Z', data: [] }); + cx = subx; + cy = suby; + break; + } + lastType = key; + } + return out; +} + +function degToRad(degrees: number): number { + return (Math.PI * degrees) / 180; +} + +function rotate(x: number, y: number, angleRad: number): [number, number] { + const X = x * Math.cos(angleRad) - y * Math.sin(angleRad); + const Y = x * Math.sin(angleRad) + y * Math.cos(angleRad); + return [X, Y]; +} + +function arcToCubicCurves( + x1: number, + y1: number, + x2: number, + y2: number, + r1: number, + r2: number, + angle: number, + largeArcFlag: number, + sweepFlag: number, + recursive?: number[] +): number[][] { + const angleRad = degToRad(angle); + let params: number[][] = []; + + let f1 = 0, + f2 = 0, + cx = 0, + cy = 0; + if (recursive) { + [f1, f2, cx, cy] = recursive; + } else { + [x1, y1] = rotate(x1, y1, -angleRad); + [x2, y2] = rotate(x2, y2, -angleRad); + + const x = (x1 - x2) / 2; + const y = (y1 - y2) / 2; + let h = (x * x) / (r1 * r1) + (y * y) / (r2 * r2); + if (h > 1) { + h = Math.sqrt(h); + r1 = h * r1; + r2 = h * r2; + } + + const sign = largeArcFlag === sweepFlag ? -1 : 1; + + const r1Pow = r1 * r1; + const r2Pow = r2 * r2; + + const left = r1Pow * r2Pow - r1Pow * y * y - r2Pow * x * x; + const right = r1Pow * y * y + r2Pow * x * x; + + const k = sign * Math.sqrt(Math.abs(left / right)); + + cx = (k * r1 * y) / r2 + (x1 + x2) / 2; + cy = (k * -r2 * x) / r1 + (y1 + y2) / 2; + + f1 = Math.asin(parseFloat(((y1 - cy) / r2).toFixed(9))); + f2 = Math.asin(parseFloat(((y2 - cy) / r2).toFixed(9))); + + if (x1 < cx) { + f1 = Math.PI - f1; + } + if (x2 < cx) { + f2 = Math.PI - f2; + } + + if (f1 < 0) { + f1 = Math.PI * 2 + f1; + } + if (f2 < 0) { + f2 = Math.PI * 2 + f2; + } + + if (sweepFlag && f1 > f2) { + f1 = f1 - Math.PI * 2; + } + if (!sweepFlag && f2 > f1) { + f2 = f2 - Math.PI * 2; + } + } + + let df = f2 - f1; + + if (Math.abs(df) > (Math.PI * 120) / 180) { + const f2old = f2; + const x2old = x2; + const y2old = y2; + + if (sweepFlag && f2 > f1) { + f2 = f1 + ((Math.PI * 120) / 180) * 1; + } else { + f2 = f1 + ((Math.PI * 120) / 180) * -1; + } + + x2 = cx + r1 * Math.cos(f2); + y2 = cy + r2 * Math.sin(f2); + params = arcToCubicCurves( + x2, + y2, + x2old, + y2old, + r1, + r2, + angle, + 0, + sweepFlag, + [f2, f2old, cx, cy] + ); + } + + df = f2 - f1; + + const c1 = Math.cos(f1); + const s1 = Math.sin(f1); + const c2 = Math.cos(f2); + const s2 = Math.sin(f2); + const t = Math.tan(df / 4); + const hx = (4 / 3) * r1 * t; + const hy = (4 / 3) * r2 * t; + + const m1 = [x1, y1]; + const m2 = [x1 + hx * s1, y1 - hy * c1]; + const m3 = [x2 + hx * s2, y2 - hy * c2]; + const m4 = [x2, y2]; + + m2[0] = 2 * m1[0] - m2[0]; + m2[1] = 2 * m1[1] - m2[1]; + + if (recursive) { + return [m2, m3, m4].concat(params); + } else { + params = [m2, m3, m4].concat(params); + const curves = []; + for (let i = 0; i < params.length; i += 3) { + const r1 = rotate(params[i][0], params[i][1], angleRad); + const r2 = rotate(params[i + 1][0], params[i + 1][1], angleRad); + const r3 = rotate(params[i + 2][0], params[i + 2][1], angleRad); + curves.push([r1[0], r1[1], r2[0], r2[1], r3[0], r3[1]]); + } + return curves; + } +} diff --git a/blocksuite/affine/block-surface/src/utils/path-data-parser/parser.ts b/blocksuite/affine/block-surface/src/utils/path-data-parser/parser.ts new file mode 100644 index 0000000000..607bfd8edd --- /dev/null +++ b/blocksuite/affine/block-surface/src/utils/path-data-parser/parser.ts @@ -0,0 +1,153 @@ +const COMMAND = 0; +const NUMBER = 1; +const EOD = 2; +type TokenType = 0 | 1 | 2; + +interface PathToken { + type: TokenType; + text: string; +} + +export interface Segment { + key: string; + data: number[]; +} + +const PARAMS: Record = { + A: 7, + a: 7, + C: 6, + c: 6, + H: 1, + h: 1, + L: 2, + l: 2, + M: 2, + m: 2, + Q: 4, + q: 4, + S: 4, + s: 4, + T: 2, + t: 2, + V: 1, + v: 1, + Z: 0, + z: 0, +}; + +function tokenize(d: string): PathToken[] { + const tokens: PathToken[] = []; + let match: RegExpMatchArray | null; + + while (d !== '') { + match = d.match(/^([ \t\r\n,]+)/); + if (match) { + d = d.slice(match[0].length); + continue; + } + match = d.match(/^([aAcChHlLmMqQsStTvVzZ])/); + if (match) { + tokens.push({ type: COMMAND, text: match[0] }); + d = d.slice(match[0].length); + continue; + } + match = d.match( + /^(([-+]?[0-9]+(\.[0-9]*)?|[-+]?\.[0-9]+)([eE][-+]?[0-9]+)?)/ + ); + if (match) { + tokens.push({ type: NUMBER, text: `${parseFloat(match[0])}` }); + d = d.slice(match[0].length); + } else { + return []; + } + } + tokens.push({ type: EOD, text: '' }); + return tokens; +} + +function isType(token: PathToken, type: number) { + return token.type === type; +} + +export function parsePath(d: string): Segment[] { + const segments: Segment[] = []; + const tokens = tokenize(d); + let mode = 'BOD'; + let index = 0; + let token = tokens[index]; + while (!isType(token, EOD)) { + let paramsCount = 0; + const params: number[] = []; + if (mode === 'BOD') { + if (token.text === 'M' || token.text === 'm') { + index++; + paramsCount = PARAMS[token.text]; + mode = token.text; + } else { + return parsePath('M0,0' + d); + } + } else if (isType(token, NUMBER)) { + paramsCount = PARAMS[mode]; + } else { + index++; + paramsCount = PARAMS[token.text]; + mode = token.text; + } + if (index + paramsCount < tokens.length) { + for (let i = index; i < index + paramsCount; i++) { + const numbeToken = tokens[i]; + if (isType(numbeToken, NUMBER)) { + params[params.length] = +numbeToken.text; + } else { + throw new Error( + 'Param not a number: ' + mode + ',' + numbeToken.text + ); + } + } + if (typeof PARAMS[mode] === 'number') { + const segment: Segment = { key: mode, data: params }; + segments.push(segment); + index += paramsCount; + token = tokens[index]; + if (mode === 'M') mode = 'L'; + if (mode === 'm') mode = 'l'; + } else { + throw new Error('Bad segment: ' + mode); + } + } else { + throw new Error('Path data ended short'); + } + } + return segments; +} + +export function serialize(segments: Segment[]): string { + const tokens: (string | number)[] = []; + for (const { key, data } of segments) { + tokens.push(key); + switch (key) { + case 'C': + case 'c': + tokens.push( + data[0], + `${data[1]},`, + data[2], + `${data[3]},`, + data[4], + data[5] + ); + break; + case 'S': + case 's': + case 'Q': + case 'q': + tokens.push(data[0], `${data[1]},`, data[2], data[3]); + break; + default: + tokens.push(...data); + break; + } + } + return tokens.join(' '); +} diff --git a/blocksuite/affine/block-surface/src/utils/points-on-curve/LICENSE b/blocksuite/affine/block-surface/src/utils/points-on-curve/LICENSE new file mode 100644 index 0000000000..ea684ea1b1 --- /dev/null +++ b/blocksuite/affine/block-surface/src/utils/points-on-curve/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2020 Preet Shihn + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. \ No newline at end of file diff --git a/blocksuite/affine/block-surface/src/utils/points-on-curve/curve-to-bezier.ts b/blocksuite/affine/block-surface/src/utils/points-on-curve/curve-to-bezier.ts new file mode 100644 index 0000000000..a6eca1e0a6 --- /dev/null +++ b/blocksuite/affine/block-surface/src/utils/points-on-curve/curve-to-bezier.ts @@ -0,0 +1,51 @@ +import type { Point } from '../rough/geometry.js'; + +function clone(p: Point): Point { + return [...p] as Point; +} + +export function curveToBezier( + pointsIn: readonly Point[], + curveTightness = 0 +): Point[] { + const len = pointsIn.length; + if (len < 3) { + throw new Error('A curve must have at least three points.'); + } + const out: Point[] = []; + if (len === 3) { + out.push( + clone(pointsIn[0]), + clone(pointsIn[1]), + clone(pointsIn[2]), + clone(pointsIn[2]) + ); + } else { + const points: Point[] = []; + points.push(pointsIn[0], pointsIn[0]); + for (let i = 1; i < pointsIn.length; i++) { + points.push(pointsIn[i]); + if (i === pointsIn.length - 1) { + points.push(pointsIn[i]); + } + } + const b: Point[] = []; + const s = 1 - curveTightness; + out.push(clone(points[0])); + for (let i = 1; i + 2 < points.length; i++) { + const cachedVertArray = points[i]; + b[0] = [cachedVertArray[0], cachedVertArray[1]]; + b[1] = [ + cachedVertArray[0] + (s * points[i + 1][0] - s * points[i - 1][0]) / 6, + cachedVertArray[1] + (s * points[i + 1][1] - s * points[i - 1][1]) / 6, + ]; + b[2] = [ + points[i + 1][0] + (s * points[i][0] - s * points[i + 2][0]) / 6, + points[i + 1][1] + (s * points[i][1] - s * points[i + 2][1]) / 6, + ]; + b[3] = [points[i + 1][0], points[i + 1][1]]; + out.push(b[1], b[2], b[3]); + } + } + return out; +} diff --git a/blocksuite/affine/block-surface/src/utils/points-on-curve/index.ts b/blocksuite/affine/block-surface/src/utils/points-on-curve/index.ts new file mode 100644 index 0000000000..1d8e6df110 --- /dev/null +++ b/blocksuite/affine/block-surface/src/utils/points-on-curve/index.ts @@ -0,0 +1,163 @@ +import type { Point } from '../rough/geometry.js'; + +// distance between 2 points +function distance(p1: Point, p2: Point): number { + return Math.sqrt(distanceSq(p1, p2)); +} + +// distance between 2 points squared +function distanceSq(p1: Point, p2: Point): number { + return Math.pow(p1[0] - p2[0], 2) + Math.pow(p1[1] - p2[1], 2); +} + +// Sistance squared from a point p to the line segment vw +function distanceToSegmentSq(p: Point, v: Point, w: Point): number { + const l2 = distanceSq(v, w); + if (l2 === 0) { + return distanceSq(p, v); + } + let t = ((p[0] - v[0]) * (w[0] - v[0]) + (p[1] - v[1]) * (w[1] - v[1])) / l2; + t = Math.max(0, Math.min(1, t)); + return distanceSq(p, lerp(v, w, t)); +} + +function lerp(a: Point, b: Point, t: number): Point { + return [a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t]; +} + +// Adapted from https://seant23.wordpress.com/2010/11/12/offset-bezier-curves/ +function flatness(points: readonly Point[], offset: number): number { + const p1 = points[offset + 0]; + const p2 = points[offset + 1]; + const p3 = points[offset + 2]; + const p4 = points[offset + 3]; + + let ux = 3 * p2[0] - 2 * p1[0] - p4[0]; + ux *= ux; + let uy = 3 * p2[1] - 2 * p1[1] - p4[1]; + uy *= uy; + let vx = 3 * p3[0] - 2 * p4[0] - p1[0]; + vx *= vx; + let vy = 3 * p3[1] - 2 * p4[1] - p1[1]; + vy *= vy; + + if (ux < vx) { + ux = vx; + } + + if (uy < vy) { + uy = vy; + } + + return ux + uy; +} + +function getPointsOnBezierCurveWithSplitting( + points: readonly Point[], + offset: number, + tolerance: number, + newPoints?: Point[] +): Point[] { + const outPoints = newPoints || []; + if (flatness(points, offset) < tolerance) { + const p0 = points[offset + 0]; + if (outPoints.length) { + const d = distance(outPoints[outPoints.length - 1], p0); + if (d > 1) { + outPoints.push(p0); + } + } else { + outPoints.push(p0); + } + outPoints.push(points[offset + 3]); + } else { + // subdivide + const t = 0.5; + const p1 = points[offset + 0]; + const p2 = points[offset + 1]; + const p3 = points[offset + 2]; + const p4 = points[offset + 3]; + + const q1 = lerp(p1, p2, t); + const q2 = lerp(p2, p3, t); + const q3 = lerp(p3, p4, t); + + const r1 = lerp(q1, q2, t); + const r2 = lerp(q2, q3, t); + + const red = lerp(r1, r2, t); + + getPointsOnBezierCurveWithSplitting( + [p1, q1, r1, red], + 0, + tolerance, + outPoints + ); + getPointsOnBezierCurveWithSplitting( + [red, r2, q3, p4], + 0, + tolerance, + outPoints + ); + } + return outPoints; +} + +export function simplify(points: readonly Point[], distance: number): Point[] { + return simplifyPoints(points, 0, points.length, distance); +} + +// Ramer–Douglas–Peucker algorithm +// https://en.wikipedia.org/wiki/Ramer%E2%80%93Douglas%E2%80%93Peucker_algorithm +function simplifyPoints( + points: readonly Point[], + start: number, + end: number, + epsilon: number, + newPoints?: Point[] +): Point[] { + const outPoints = newPoints || []; + + // find the most distance point from the endpoints + const s = points[start]; + const e = points[end - 1]; + let maxDistSq = 0; + let maxNdx = 1; + for (let i = start + 1; i < end - 1; ++i) { + const distSq = distanceToSegmentSq(points[i], s, e); + if (distSq > maxDistSq) { + maxDistSq = distSq; + maxNdx = i; + } + } + + // if that point is too far, split + if (Math.sqrt(maxDistSq) > epsilon) { + simplifyPoints(points, start, maxNdx + 1, epsilon, outPoints); + simplifyPoints(points, maxNdx, end, epsilon, outPoints); + } else { + if (!outPoints.length) { + outPoints.push(s); + } + outPoints.push(e); + } + + return outPoints; +} + +export function pointsOnBezierCurves( + points: Point[], + tolerance = 0.15, + distance?: number +): Point[] { + const newPoints: Point[] = []; + const numSegments = (points.length - 1) / 3; + for (let i = 0; i < numSegments; i++) { + const offset = i * 3; + getPointsOnBezierCurveWithSplitting(points, offset, tolerance, newPoints); + } + if (distance && distance > 0) { + return simplifyPoints(newPoints, 0, newPoints.length, distance); + } + return newPoints; +} diff --git a/blocksuite/affine/block-surface/src/utils/points-on-path/LICENSE b/blocksuite/affine/block-surface/src/utils/points-on-path/LICENSE new file mode 100644 index 0000000000..edf22f8bd2 --- /dev/null +++ b/blocksuite/affine/block-surface/src/utils/points-on-path/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2020 Preet + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. \ No newline at end of file diff --git a/blocksuite/affine/block-surface/src/utils/points-on-path/index.ts b/blocksuite/affine/block-surface/src/utils/points-on-path/index.ts new file mode 100644 index 0000000000..385b175e55 --- /dev/null +++ b/blocksuite/affine/block-surface/src/utils/points-on-path/index.ts @@ -0,0 +1,77 @@ +import { absolutize } from '../path-data-parser/absolutize.js'; +import { normalize } from '../path-data-parser/normalize.js'; +import { parsePath } from '../path-data-parser/parser.js'; +import { pointsOnBezierCurves, simplify } from '../points-on-curve/index.js'; +import type { Point } from '../rough/geometry.js'; + +export function pointsOnPath( + path: string, + tolerance?: number, + distance?: number +): Point[][] { + const segments = parsePath(path); + const normalized = normalize(absolutize(segments)); + + const sets: Point[][] = []; + let currentPoints: Point[] = []; + let start: Point = [0, 0]; + let pendingCurve: Point[] = []; + + const appendPendingCurve = () => { + if (pendingCurve.length >= 4) { + currentPoints.push(...pointsOnBezierCurves(pendingCurve, tolerance)); + } + pendingCurve = []; + }; + + const appendPendingPoints = () => { + appendPendingCurve(); + if (currentPoints.length) { + sets.push(currentPoints); + currentPoints = []; + } + }; + + for (const { key, data } of normalized) { + switch (key) { + case 'M': + appendPendingPoints(); + start = [data[0], data[1]]; + currentPoints.push(start); + break; + case 'L': + appendPendingCurve(); + currentPoints.push([data[0], data[1]]); + break; + case 'C': + if (!pendingCurve.length) { + const lastPoint = currentPoints.length + ? currentPoints[currentPoints.length - 1] + : start; + pendingCurve.push([lastPoint[0], lastPoint[1]]); + } + pendingCurve.push([data[0], data[1]]); + pendingCurve.push([data[2], data[3]]); + pendingCurve.push([data[4], data[5]]); + break; + case 'Z': + appendPendingCurve(); + currentPoints.push([start[0], start[1]]); + break; + } + } + appendPendingPoints(); + + if (!distance) { + return sets; + } + + const out: Point[][] = []; + for (const set of sets) { + const simplifiedSet = simplify(set, distance); + if (simplifiedSet.length) { + out.push(simplifiedSet); + } + } + return out; +} diff --git a/blocksuite/affine/block-surface/src/utils/priority-queue.ts b/blocksuite/affine/block-surface/src/utils/priority-queue.ts new file mode 100644 index 0000000000..842e709fdb --- /dev/null +++ b/blocksuite/affine/block-surface/src/utils/priority-queue.ts @@ -0,0 +1,85 @@ +type PriorityQueueNode = { + value: T; + priority: K; +}; + +export class PriorityQueue { + heap: PriorityQueueNode[] = []; + + constructor(private _compare: (a: K, b: K) => number) {} + + bubbleDown(): void { + let index = 0; + const length = this.heap.length; + const element = this.heap[0]; + + for (;;) { + const leftChildIndex = 2 * index + 1; + const rightChildIndex = 2 * index + 2; + let swap = -1; + let leftChild: PriorityQueueNode, + rightChild: PriorityQueueNode; + + if (leftChildIndex < length) { + leftChild = this.heap[leftChildIndex]; + if (this._compare(leftChild.priority, element.priority) < 0) { + swap = leftChildIndex; + } + } + + if (rightChildIndex < length) { + leftChild = this.heap[leftChildIndex]; + rightChild = this.heap[rightChildIndex]; + if ( + (swap === null && + this._compare(rightChild.priority, element.priority) < 0) || + (swap !== null && + this._compare(rightChild.priority, leftChild.priority) < 0) + ) { + swap = rightChildIndex; + } + } + + if (swap === -1) break; + + this.heap[index] = this.heap[swap]; + this.heap[swap] = element; + index = swap; + } + } + + bubbleUp(index: number = this.heap.length - 1): void { + const element = this.heap[index]; + + while (index > 0) { + const parentIndex = Math.floor((index - 1) / 2); + const parent = this.heap[parentIndex]; + + if (this._compare(parent.priority, element.priority) <= 0) break; + + this.heap[parentIndex] = element; + this.heap[index] = parent; + index = parentIndex; + } + } + + dequeue(): T | null { + const min = this.heap[0]; + const end = this.heap.pop(); + if (this.heap.length > 0 && end) { + this.heap[0] = end; + this.bubbleDown(); + } + return min?.value ?? null; + } + + empty(): boolean { + return this.heap.length === 0; + } + + enqueue(value: T, priority: K): void { + const node = { value, priority }; + this.heap.push(node); + this.bubbleUp(); + } +} diff --git a/blocksuite/affine/block-surface/src/utils/rough/LICENSE b/blocksuite/affine/block-surface/src/utils/rough/LICENSE new file mode 100644 index 0000000000..ea684ea1b1 --- /dev/null +++ b/blocksuite/affine/block-surface/src/utils/rough/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2020 Preet Shihn + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. \ No newline at end of file diff --git a/blocksuite/affine/block-surface/src/utils/rough/canvas.ts b/blocksuite/affine/block-surface/src/utils/rough/canvas.ts new file mode 100644 index 0000000000..0a23cb33a5 --- /dev/null +++ b/blocksuite/affine/block-surface/src/utils/rough/canvas.ts @@ -0,0 +1,214 @@ +import type { + Config, + Drawable, + OpSet, + Options, + ResolvedOptions, +} from './core.js'; +import { RoughGenerator } from './generator.js'; +import type { Point } from './geometry.js'; + +export class RoughCanvas { + private canvas: HTMLCanvasElement; + + private ctx: CanvasRenderingContext2D; + + private gen: RoughGenerator; + + get generator(): RoughGenerator { + return this.gen; + } + + constructor(canvas: HTMLCanvasElement, config?: Config) { + this.canvas = canvas; + + this.ctx = this.canvas.getContext('2d')!; + this.gen = new RoughGenerator(config); + } + + private _drawToContext( + ctx: CanvasRenderingContext2D, + drawing: OpSet, + fixedDecimals?: number, + rule: CanvasFillRule = 'nonzero' + ) { + ctx.beginPath(); + for (const item of drawing.ops) { + const data = + typeof fixedDecimals === 'number' && fixedDecimals >= 0 + ? item.data.map(d => +d.toFixed(fixedDecimals)) + : item.data; + switch (item.op) { + case 'move': + ctx.moveTo(data[0], data[1]); + break; + case 'bcurveTo': + ctx.bezierCurveTo( + data[0], + data[1], + data[2], + data[3], + data[4], + data[5] + ); + break; + case 'lineTo': + ctx.lineTo(data[0], data[1]); + break; + } + } + if (drawing.type === 'fillPath') { + ctx.fill(rule); + } else { + ctx.stroke(); + } + } + + private fillSketch( + ctx: CanvasRenderingContext2D, + drawing: OpSet, + o: ResolvedOptions + ) { + let fweight = o.fillWeight; + if (fweight < 0) { + fweight = o.strokeWidth / 2; + } + ctx.save(); + if (o.fillLineDash) { + ctx.setLineDash(o.fillLineDash); + } + if (o.fillLineDashOffset) { + ctx.lineDashOffset = o.fillLineDashOffset; + } + ctx.strokeStyle = o.fill || ''; + ctx.lineWidth = fweight; + this._drawToContext(ctx, drawing, o.fixedDecimalPlaceDigits); + ctx.restore(); + } + + arc( + x: number, + y: number, + width: number, + height: number, + start: number, + stop: number, + closed = false, + options?: Options + ): Drawable { + const d = this.gen.arc(x, y, width, height, start, stop, closed, options); + this.draw(d); + return d; + } + + circle(x: number, y: number, diameter: number, options?: Options): Drawable { + const d = this.gen.circle(x, y, diameter, options); + this.draw(d); + return d; + } + + curve(points: Point[], options?: Options): Drawable { + const d = this.gen.curve(points, options); + this.draw(d); + return d; + } + + draw(drawable: Drawable): void { + const sets = drawable.sets || []; + const o = drawable.options || this.getDefaultOptions(); + const ctx = this.ctx; + const precision = drawable.options.fixedDecimalPlaceDigits; + + for (const drawing of sets) { + switch (drawing.type) { + case 'path': + ctx.save(); + ctx.strokeStyle = o.stroke === 'none' ? 'transparent' : o.stroke; + ctx.lineWidth = o.strokeWidth; + if (o.strokeLineDash) { + ctx.setLineDash(o.strokeLineDash); + } + if (o.strokeLineDashOffset) { + ctx.lineDashOffset = o.strokeLineDashOffset; + } + this._drawToContext(ctx, drawing, precision); + ctx.restore(); + break; + case 'fillPath': { + ctx.save(); + ctx.fillStyle = o.fill || ''; + const fillRule: CanvasFillRule = + drawable.shape === 'curve' || + drawable.shape === 'polygon' || + drawable.shape === 'path' + ? 'evenodd' + : 'nonzero'; + this._drawToContext(ctx, drawing, precision, fillRule); + ctx.restore(); + break; + } + case 'fillSketch': + this.fillSketch(ctx, drawing, o); + break; + } + } + } + + ellipse( + x: number, + y: number, + width: number, + height: number, + options?: Options + ): Drawable { + const d = this.gen.ellipse(x, y, width, height, options); + this.draw(d); + return d; + } + + getDefaultOptions(): ResolvedOptions { + return this.gen.defaultOptions; + } + + line( + x1: number, + y1: number, + x2: number, + y2: number, + options?: Options + ): Drawable { + const d = this.gen.line(x1, y1, x2, y2, options); + this.draw(d); + return d; + } + + linearPath(points: Point[], options?: Options): Drawable { + const d = this.gen.linearPath(points, options); + this.draw(d); + return d; + } + + path(d: string, options?: Options): Drawable { + const drawing = this.gen.path(d, options); + this.draw(drawing); + return drawing; + } + + polygon(points: Point[], options?: Options): Drawable { + const d = this.gen.polygon(points, options); + this.draw(d); + return d; + } + + rectangle( + x: number, + y: number, + width: number, + height: number, + options?: Options + ): Drawable { + const d = this.gen.rectangle(x, y, width, height, options); + this.draw(d); + return d; + } +} diff --git a/blocksuite/affine/block-surface/src/utils/rough/core.ts b/blocksuite/affine/block-surface/src/utils/rough/core.ts new file mode 100644 index 0000000000..0486d3d4db --- /dev/null +++ b/blocksuite/affine/block-surface/src/utils/rough/core.ts @@ -0,0 +1,93 @@ +import type { Point } from './geometry.js'; +import type { Random } from './math.js'; + +export const SVGNS = 'http://www.w3.org/2000/svg'; + +export interface Config { + options?: Options; +} + +export interface DrawingSurface { + width: number | SVGAnimatedLength; + height: number | SVGAnimatedLength; +} + +export interface Options { + maxRandomnessOffset?: number; + roughness?: number; + bowing?: number; + stroke?: string; + strokeWidth?: number; + curveFitting?: number; + curveTightness?: number; + curveStepCount?: number; + fill?: string; + fillStyle?: string; + fillWeight?: number; + hachureAngle?: number; + hachureGap?: number; + simplification?: number; + dashOffset?: number; + dashGap?: number; + zigzagOffset?: number; + seed?: number; + strokeLineDash?: number[]; + strokeLineDashOffset?: number; + fillLineDash?: number[]; + fillLineDashOffset?: number; + disableMultiStroke?: boolean; + disableMultiStrokeFill?: boolean; + preserveVertices?: boolean; + fixedDecimalPlaceDigits?: number; +} + +export interface ResolvedOptions extends Options { + maxRandomnessOffset: number; + roughness: number; + bowing: number; + stroke: string; + strokeWidth: number; + curveFitting: number; + curveTightness: number; + curveStepCount: number; + fillStyle: string; + fillWeight: number; + hachureAngle: number; + hachureGap: number; + dashOffset: number; + dashGap: number; + zigzagOffset: number; + seed: number; + randomizer?: Random; + disableMultiStroke: boolean; + disableMultiStrokeFill: boolean; + preserveVertices: boolean; +} + +export declare type OpType = 'move' | 'bcurveTo' | 'lineTo'; +export declare type OpSetType = 'path' | 'fillPath' | 'fillSketch'; + +export interface Op { + op: OpType; + data: number[]; +} + +export interface OpSet { + type: OpSetType; + ops: Op[]; + size?: Point; + path?: string; +} + +export interface Drawable { + shape: string; + options: ResolvedOptions; + sets: OpSet[]; +} + +export interface PathInfo { + d: string; + stroke: string; + strokeWidth: number; + fill?: string; +} diff --git a/blocksuite/affine/block-surface/src/utils/rough/fillers/dashed-filler.ts b/blocksuite/affine/block-surface/src/utils/rough/fillers/dashed-filler.ts new file mode 100644 index 0000000000..3b698a7cac --- /dev/null +++ b/blocksuite/affine/block-surface/src/utils/rough/fillers/dashed-filler.ts @@ -0,0 +1,62 @@ +import type { Op, OpSet, ResolvedOptions } from '../core.js'; +import type { Line, Point } from '../geometry.js'; +import { lineLength } from '../geometry.js'; +import type { PatternFiller, RenderHelper } from './filler-interface.js'; +import { polygonHachureLines } from './scan-line-hachure.js'; + +export class DashedFiller implements PatternFiller { + private helper: RenderHelper; + + constructor(helper: RenderHelper) { + this.helper = helper; + } + + private dashedLine(lines: Line[], o: ResolvedOptions): Op[] { + const offset = + o.dashOffset < 0 + ? o.hachureGap < 0 + ? o.strokeWidth * 4 + : o.hachureGap + : o.dashOffset; + const gap = + o.dashGap < 0 + ? o.hachureGap < 0 + ? o.strokeWidth * 4 + : o.hachureGap + : o.dashGap; + const ops: Op[] = []; + lines.forEach(line => { + const length = lineLength(line); + const count = Math.floor(length / (offset + gap)); + const startOffset = (length + gap - count * (offset + gap)) / 2; + let p1 = line[0]; + let p2 = line[1]; + if (p1[0] > p2[0]) { + p1 = line[1]; + p2 = line[0]; + } + const alpha = Math.atan((p2[1] - p1[1]) / (p2[0] - p1[0])); + for (let i = 0; i < count; i++) { + const lstart = i * (offset + gap); + const lend = lstart + offset; + const start: Point = [ + p1[0] + lstart * Math.cos(alpha) + startOffset * Math.cos(alpha), + p1[1] + lstart * Math.sin(alpha) + startOffset * Math.sin(alpha), + ]; + const end: Point = [ + p1[0] + lend * Math.cos(alpha) + startOffset * Math.cos(alpha), + p1[1] + lend * Math.sin(alpha) + startOffset * Math.sin(alpha), + ]; + ops.push( + ...this.helper.doubleLineOps(start[0], start[1], end[0], end[1], o) + ); + } + }); + return ops; + } + + fillPolygons(polygonList: Point[][], o: ResolvedOptions): OpSet { + const lines = polygonHachureLines(polygonList, o); + return { type: 'fillSketch', ops: this.dashedLine(lines, o) }; + } +} diff --git a/blocksuite/affine/block-surface/src/utils/rough/fillers/dot-filler.ts b/blocksuite/affine/block-surface/src/utils/rough/fillers/dot-filler.ts new file mode 100644 index 0000000000..b208f350f6 --- /dev/null +++ b/blocksuite/affine/block-surface/src/utils/rough/fillers/dot-filler.ts @@ -0,0 +1,50 @@ +import type { Op, OpSet, ResolvedOptions } from '../core.js'; +import type { Line, Point } from '../geometry.js'; +import { lineLength } from '../geometry.js'; +import type { PatternFiller, RenderHelper } from './filler-interface.js'; +import { polygonHachureLines } from './scan-line-hachure.js'; + +export class DotFiller implements PatternFiller { + private helper: RenderHelper; + + constructor(helper: RenderHelper) { + this.helper = helper; + } + + private dotsOnLines(lines: Line[], o: ResolvedOptions): OpSet { + const ops: Op[] = []; + let gap = o.hachureGap; + if (gap < 0) { + gap = o.strokeWidth * 4; + } + gap = Math.max(gap, 0.1); + let fweight = o.fillWeight; + if (fweight < 0) { + fweight = o.strokeWidth / 2; + } + const ro = gap / 4; + for (const line of lines) { + const length = lineLength(line); + const dl = length / gap; + const count = Math.ceil(dl) - 1; + const offset = length - count * gap; + const x = (line[0][0] + line[1][0]) / 2 - gap / 4; + const minY = Math.min(line[0][1], line[1][1]); + + for (let i = 0; i < count; i++) { + const y = minY + offset + i * gap; + const cx = x - ro + Math.random() * 2 * ro; + const cy = y - ro + Math.random() * 2 * ro; + const el = this.helper.ellipse(cx, cy, fweight, fweight, o); + ops.push(...el.ops); + } + } + return { type: 'fillSketch', ops }; + } + + fillPolygons(polygonList: Point[][], o: ResolvedOptions): OpSet { + o = Object.assign({}, o, { hachureAngle: 0 }); + const lines = polygonHachureLines(polygonList, o); + return this.dotsOnLines(lines, o); + } +} diff --git a/blocksuite/affine/block-surface/src/utils/rough/fillers/filler-interface.ts b/blocksuite/affine/block-surface/src/utils/rough/fillers/filler-interface.ts new file mode 100644 index 0000000000..9601b4eca4 --- /dev/null +++ b/blocksuite/affine/block-surface/src/utils/rough/fillers/filler-interface.ts @@ -0,0 +1,25 @@ +import type { Op, OpSet, ResolvedOptions } from '../core.js'; +import type { Point } from '../geometry.js'; + +export interface PatternFiller { + fillPolygons(polygonList: Point[][], o: ResolvedOptions): OpSet; +} + +export interface RenderHelper { + randOffset(x: number, o: ResolvedOptions): number; + randOffsetWithRange(min: number, max: number, o: ResolvedOptions): number; + ellipse( + x: number, + y: number, + width: number, + height: number, + o: ResolvedOptions + ): OpSet; + doubleLineOps( + x1: number, + y1: number, + x2: number, + y2: number, + o: ResolvedOptions + ): Op[]; +} diff --git a/blocksuite/affine/block-surface/src/utils/rough/fillers/filler.ts b/blocksuite/affine/block-surface/src/utils/rough/fillers/filler.ts new file mode 100644 index 0000000000..6a2272f729 --- /dev/null +++ b/blocksuite/affine/block-surface/src/utils/rough/fillers/filler.ts @@ -0,0 +1,54 @@ +import type { ResolvedOptions } from '../core.js'; +import { DashedFiller } from './dashed-filler.js'; +import { DotFiller } from './dot-filler.js'; +import type { PatternFiller, RenderHelper } from './filler-interface.js'; +import { HachureFiller } from './hachure-filler.js'; +import { HatchFiller } from './hatch-filler.js'; +import { ZigZagFiller } from './zigzag-filler.js'; +import { ZigZagLineFiller } from './zigzag-line-filler.js'; + +const fillers: Record = {}; + +export function getFiller( + o: ResolvedOptions, + helper: RenderHelper +): PatternFiller { + let fillerName = o.fillStyle || 'hachure'; + if (!fillers[fillerName]) { + switch (fillerName) { + case 'zigzag': + if (!fillers[fillerName]) { + fillers[fillerName] = new ZigZagFiller(helper); + } + break; + case 'cross-hatch': + if (!fillers[fillerName]) { + fillers[fillerName] = new HatchFiller(helper); + } + break; + case 'dots': + if (!fillers[fillerName]) { + fillers[fillerName] = new DotFiller(helper); + } + break; + case 'dashed': + if (!fillers[fillerName]) { + fillers[fillerName] = new DashedFiller(helper); + } + break; + case 'zigzag-line': + if (!fillers[fillerName]) { + fillers[fillerName] = new ZigZagLineFiller(helper); + } + break; + case 'hachure': + default: + fillerName = 'hachure'; + if (!fillers[fillerName]) { + fillers[fillerName] = new HachureFiller(helper); + } + break; + } + } + return fillers[fillerName]; +} diff --git a/blocksuite/affine/block-surface/src/utils/rough/fillers/hachure-filler.ts b/blocksuite/affine/block-surface/src/utils/rough/fillers/hachure-filler.ts new file mode 100644 index 0000000000..a9cacdd336 --- /dev/null +++ b/blocksuite/affine/block-surface/src/utils/rough/fillers/hachure-filler.ts @@ -0,0 +1,38 @@ +import type { Op, OpSet, ResolvedOptions } from '../core.js'; +import type { Line, Point } from '../geometry.js'; +import type { PatternFiller, RenderHelper } from './filler-interface.js'; +import { polygonHachureLines } from './scan-line-hachure.js'; + +export class HachureFiller implements PatternFiller { + private helper: RenderHelper; + + constructor(helper: RenderHelper) { + this.helper = helper; + } + + protected _fillPolygons(polygonList: Point[][], o: ResolvedOptions): OpSet { + const lines = polygonHachureLines(polygonList, o); + const ops = this.renderLines(lines, o); + return { type: 'fillSketch', ops }; + } + + fillPolygons(polygonList: Point[][], o: ResolvedOptions): OpSet { + return this._fillPolygons(polygonList, o); + } + + protected renderLines(lines: Line[], o: ResolvedOptions): Op[] { + const ops: Op[] = []; + for (const line of lines) { + ops.push( + ...this.helper.doubleLineOps( + line[0][0], + line[0][1], + line[1][0], + line[1][1], + o + ) + ); + } + return ops; + } +} diff --git a/blocksuite/affine/block-surface/src/utils/rough/fillers/hatch-filler.ts b/blocksuite/affine/block-surface/src/utils/rough/fillers/hatch-filler.ts new file mode 100644 index 0000000000..fb220876d6 --- /dev/null +++ b/blocksuite/affine/block-surface/src/utils/rough/fillers/hatch-filler.ts @@ -0,0 +1,13 @@ +import type { OpSet, ResolvedOptions } from '../core.js'; +import type { Point } from '../geometry.js'; +import { HachureFiller } from './hachure-filler.js'; + +export class HatchFiller extends HachureFiller { + override fillPolygons(polygonList: Point[][], o: ResolvedOptions): OpSet { + const set = this._fillPolygons(polygonList, o); + const o2 = Object.assign({}, o, { hachureAngle: o.hachureAngle + 90 }); + const set2 = this._fillPolygons(polygonList, o2); + set.ops = set.ops.concat(set2.ops); + return set; + } +} diff --git a/blocksuite/affine/block-surface/src/utils/rough/fillers/scan-line-hachure.ts b/blocksuite/affine/block-surface/src/utils/rough/fillers/scan-line-hachure.ts new file mode 100644 index 0000000000..d8e7ec95bd --- /dev/null +++ b/blocksuite/affine/block-surface/src/utils/rough/fillers/scan-line-hachure.ts @@ -0,0 +1,152 @@ +import type { ResolvedOptions } from '../core.js'; +import type { Line, Point } from '../geometry.js'; +import { rotateLines, rotatePoints } from '../geometry.js'; + +interface EdgeEntry { + ymin: number; + ymax: number; + x: number; + islope: number; +} + +interface ActiveEdgeEntry { + s: number; + edge: EdgeEntry; +} + +export function polygonHachureLines( + polygonList: Point[][], + o: ResolvedOptions +): Line[] { + const angle = o.hachureAngle + 90; + let gap = o.hachureGap; + if (gap < 0) { + gap = o.strokeWidth * 4; + } + gap = Math.max(gap, 0.1); + + const rotationCenter: Point = [0, 0]; + if (angle) { + for (const polygon of polygonList) { + rotatePoints(polygon, rotationCenter, angle); + } + } + const lines = straightHachureLines(polygonList, gap); + if (angle) { + for (const polygon of polygonList) { + rotatePoints(polygon, rotationCenter, -angle); + } + rotateLines(lines, rotationCenter, -angle); + } + return lines; +} + +function straightHachureLines(polygonList: Point[][], gap: number): Line[] { + const vertexArray: Point[][] = []; + for (const polygon of polygonList) { + const vertices = [...polygon]; + if (vertices[0].join(',') !== vertices[vertices.length - 1].join(',')) { + vertices.push([vertices[0][0], vertices[0][1]]); + } + if (vertices.length > 2) { + vertexArray.push(vertices); + } + } + + const lines: Line[] = []; + gap = Math.max(gap, 0.1); + + // Create sorted edges table + const edges: EdgeEntry[] = []; + + for (const vertices of vertexArray) { + for (let i = 0; i < vertices.length - 1; i++) { + const p1 = vertices[i]; + const p2 = vertices[i + 1]; + if (p1[1] !== p2[1]) { + const ymin = Math.min(p1[1], p2[1]); + edges.push({ + ymin, + ymax: Math.max(p1[1], p2[1]), + x: ymin === p1[1] ? p1[0] : p2[0], + islope: (p2[0] - p1[0]) / (p2[1] - p1[1]), + }); + } + } + } + + edges.sort((e1, e2) => { + if (e1.ymin < e2.ymin) { + return -1; + } + if (e1.ymin > e2.ymin) { + return 1; + } + if (e1.x < e2.x) { + return -1; + } + if (e1.x > e2.x) { + return 1; + } + if (e1.ymax === e2.ymax) { + return 0; + } + return (e1.ymax - e2.ymax) / Math.abs(e1.ymax - e2.ymax); + }); + if (!edges.length) { + return lines; + } + + // Start scanning + let activeEdges: ActiveEdgeEntry[] = []; + let y = edges[0].ymin; + while (activeEdges.length || edges.length) { + if (edges.length) { + let ix = -1; + for (let i = 0; i < edges.length; i++) { + if (edges[i].ymin > y) { + break; + } + ix = i; + } + const removed = edges.splice(0, ix + 1); + removed.forEach(edge => { + activeEdges.push({ s: y, edge }); + }); + } + activeEdges = activeEdges.filter(ae => { + if (ae.edge.ymax <= y) { + return false; + } + return true; + }); + activeEdges.sort((ae1, ae2) => { + if (ae1.edge.x === ae2.edge.x) { + return 0; + } + return (ae1.edge.x - ae2.edge.x) / Math.abs(ae1.edge.x - ae2.edge.x); + }); + + // fill between the edges + if (activeEdges.length > 1) { + for (let i = 0; i < activeEdges.length; i = i + 2) { + const nexti = i + 1; + if (nexti >= activeEdges.length) { + break; + } + const ce = activeEdges[i].edge; + const ne = activeEdges[nexti].edge; + lines.push([ + [Math.round(ce.x), y], + [Math.round(ne.x), y], + ]); + } + } + + y += gap; + activeEdges.forEach(ae => { + ae.edge.x = ae.edge.x + gap * ae.edge.islope; + }); + } + return lines; +} diff --git a/blocksuite/affine/block-surface/src/utils/rough/fillers/zigzag-filler.ts b/blocksuite/affine/block-surface/src/utils/rough/fillers/zigzag-filler.ts new file mode 100644 index 0000000000..3425a71980 --- /dev/null +++ b/blocksuite/affine/block-surface/src/utils/rough/fillers/zigzag-filler.ts @@ -0,0 +1,31 @@ +import type { OpSet, ResolvedOptions } from '../core.js'; +import type { Line, Point } from '../geometry.js'; +import { lineLength } from '../geometry.js'; +import { HachureFiller } from './hachure-filler.js'; +import { polygonHachureLines } from './scan-line-hachure.js'; + +export class ZigZagFiller extends HachureFiller { + override fillPolygons(polygonList: Point[][], o: ResolvedOptions): OpSet { + let gap = o.hachureGap; + if (gap < 0) { + gap = o.strokeWidth * 4; + } + gap = Math.max(gap, 0.1); + const o2 = Object.assign({}, o, { hachureGap: gap }); + const lines = polygonHachureLines(polygonList, o2); + const zigZagAngle = (Math.PI / 180) * o.hachureAngle; + const zigzagLines: Line[] = []; + const dgx = gap * 0.5 * Math.cos(zigZagAngle); + const dgy = gap * 0.5 * Math.sin(zigZagAngle); + for (const [p1, p2] of lines) { + if (lineLength([p1, p2])) { + zigzagLines.push( + [[p1[0] - dgx, p1[1] + dgy], [...p2]], + [[p1[0] + dgx, p1[1] - dgy], [...p2]] + ); + } + } + const ops = this.renderLines(zigzagLines, o); + return { type: 'fillSketch', ops }; + } +} diff --git a/blocksuite/affine/block-surface/src/utils/rough/fillers/zigzag-line-filler.ts b/blocksuite/affine/block-surface/src/utils/rough/fillers/zigzag-line-filler.ts new file mode 100644 index 0000000000..3b9c847d8b --- /dev/null +++ b/blocksuite/affine/block-surface/src/utils/rough/fillers/zigzag-line-filler.ts @@ -0,0 +1,64 @@ +import type { Op, OpSet, ResolvedOptions } from '../core.js'; +import type { Line, Point } from '../geometry.js'; +import { lineLength } from '../geometry.js'; +import type { PatternFiller, RenderHelper } from './filler-interface.js'; +import { polygonHachureLines } from './scan-line-hachure.js'; + +export class ZigZagLineFiller implements PatternFiller { + private helper: RenderHelper; + + constructor(helper: RenderHelper) { + this.helper = helper; + } + + private zigzagLines(lines: Line[], zo: number, o: ResolvedOptions): Op[] { + const ops: Op[] = []; + lines.forEach(line => { + const length = lineLength(line); + const count = Math.round(length / (2 * zo)); + let p1 = line[0]; + let p2 = line[1]; + if (p1[0] > p2[0]) { + p1 = line[1]; + p2 = line[0]; + } + const alpha = Math.atan((p2[1] - p1[1]) / (p2[0] - p1[0])); + for (let i = 0; i < count; i++) { + const lstart = i * 2 * zo; + const lend = (i + 1) * 2 * zo; + const dz = Math.sqrt(2 * Math.pow(zo, 2)); + const start: Point = [ + p1[0] + lstart * Math.cos(alpha), + p1[1] + lstart * Math.sin(alpha), + ]; + const end: Point = [ + p1[0] + lend * Math.cos(alpha), + p1[1] + lend * Math.sin(alpha), + ]; + const middle: Point = [ + start[0] + dz * Math.cos(alpha + Math.PI / 4), + start[1] + dz * Math.sin(alpha + Math.PI / 4), + ]; + ops.push( + ...this.helper.doubleLineOps( + start[0], + start[1], + middle[0], + middle[1], + o + ), + ...this.helper.doubleLineOps(middle[0], middle[1], end[0], end[1], o) + ); + } + }); + return ops; + } + + fillPolygons(polygonList: Point[][], o: ResolvedOptions): OpSet { + const gap = o.hachureGap < 0 ? o.strokeWidth * 4 : o.hachureGap; + const zo = o.zigzagOffset < 0 ? gap : o.zigzagOffset; + o = Object.assign({}, o, { hachureGap: gap + zo }); + const lines = polygonHachureLines(polygonList, o); + return { type: 'fillSketch', ops: this.zigzagLines(lines, zo, o) }; + } +} diff --git a/blocksuite/affine/block-surface/src/utils/rough/generator.ts b/blocksuite/affine/block-surface/src/utils/rough/generator.ts new file mode 100644 index 0000000000..d123b701b0 --- /dev/null +++ b/blocksuite/affine/block-surface/src/utils/rough/generator.ts @@ -0,0 +1,340 @@ +import { curveToBezier } from '../points-on-curve/curve-to-bezier.js'; +import { pointsOnBezierCurves } from '../points-on-curve/index.js'; +import { pointsOnPath } from '../points-on-path/index.js'; +import type { + Config, + Drawable, + OpSet, + Options, + PathInfo, + ResolvedOptions, +} from './core.js'; +import type { Point } from './geometry.js'; +import { randomSeed } from './math.js'; +import { + arc, + curve, + ellipseWithParams, + generateEllipseParams, + line, + linearPath, + patternFillArc, + patternFillPolygons, + rectangle, + solidFillPolygon, + svgPath, +} from './renderer.js'; + +const NOS = 'none'; + +export class RoughGenerator { + private config: Config; + + defaultOptions: ResolvedOptions = { + maxRandomnessOffset: 2, + roughness: 1, + bowing: 1, + stroke: '#000', + strokeWidth: 1, + curveTightness: 0, + curveFitting: 0.95, + curveStepCount: 9, + fillStyle: 'hachure', + fillWeight: -1, + hachureAngle: -41, + hachureGap: -1, + dashOffset: -1, + dashGap: -1, + zigzagOffset: -1, + seed: 0, + disableMultiStroke: false, + disableMultiStrokeFill: false, + preserveVertices: false, + }; + + constructor(config?: Config) { + this.config = config || {}; + if (this.config.options) { + this.defaultOptions = this._o(this.config.options); + } + } + + static newSeed(): number { + return randomSeed(); + } + + private _d(shape: string, sets: OpSet[], options: ResolvedOptions): Drawable { + return { shape, sets: sets || [], options: options || this.defaultOptions }; + } + + private _o(options?: Options): ResolvedOptions { + return options + ? Object.assign({}, this.defaultOptions, options) + : this.defaultOptions; + } + + private fillSketch(drawing: OpSet, o: ResolvedOptions): PathInfo { + let fweight = o.fillWeight; + if (fweight < 0) { + fweight = o.strokeWidth / 2; + } + return { + d: this.opsToPath(drawing), + stroke: o.fill || NOS, + strokeWidth: fweight, + fill: NOS, + }; + } + + arc( + x: number, + y: number, + width: number, + height: number, + start: number, + stop: number, + closed = false, + options?: Options + ): Drawable { + const o = this._o(options); + const paths = []; + const outline = arc(x, y, width, height, start, stop, closed, true, o); + if (closed && o.fill) { + if (o.fillStyle === 'solid') { + const fillOptions: ResolvedOptions = { ...o }; + fillOptions.disableMultiStroke = true; + const shape = arc( + x, + y, + width, + height, + start, + stop, + true, + false, + fillOptions + ); + shape.type = 'fillPath'; + paths.push(shape); + } else { + paths.push(patternFillArc(x, y, width, height, start, stop, o)); + } + } + if (o.stroke !== NOS) { + paths.push(outline); + } + return this._d('arc', paths, o); + } + + circle(x: number, y: number, diameter: number, options?: Options): Drawable { + const ret = this.ellipse(x, y, diameter, diameter, options); + ret.shape = 'circle'; + return ret; + } + + curve(points: Point[], options?: Options): Drawable { + const o = this._o(options); + const paths: OpSet[] = []; + const outline = curve(points, o); + if (o.fill && o.fill !== NOS && points.length >= 3) { + const bcurve = curveToBezier(points); + const polyPoints = pointsOnBezierCurves( + bcurve, + 10, + (1 + o.roughness) / 2 + ); + if (o.fillStyle === 'solid') { + paths.push(solidFillPolygon([polyPoints], o)); + } else { + paths.push(patternFillPolygons([polyPoints], o)); + } + } + if (o.stroke !== NOS) { + paths.push(outline); + } + return this._d('curve', paths, o); + } + + ellipse( + x: number, + y: number, + width: number, + height: number, + options?: Options + ): Drawable { + const o = this._o(options); + const paths: OpSet[] = []; + const ellipseParams = generateEllipseParams(width, height, o); + const ellipseResponse = ellipseWithParams(x, y, o, ellipseParams); + if (o.fill) { + if (o.fillStyle === 'solid') { + const shape = ellipseWithParams(x, y, o, ellipseParams).opset; + shape.type = 'fillPath'; + paths.push(shape); + } else { + paths.push(patternFillPolygons([ellipseResponse.estimatedPoints], o)); + } + } + if (o.stroke !== NOS) { + paths.push(ellipseResponse.opset); + } + return this._d('ellipse', paths, o); + } + + line( + x1: number, + y1: number, + x2: number, + y2: number, + options?: Options + ): Drawable { + const o = this._o(options); + return this._d('line', [line(x1, y1, x2, y2, o)], o); + } + + linearPath(points: Point[], options?: Options): Drawable { + const o = this._o(options); + return this._d('linearPath', [linearPath(points, false, o)], o); + } + + opsToPath(drawing: OpSet, fixedDecimals?: number): string { + let path = ''; + for (const item of drawing.ops) { + const data = + typeof fixedDecimals === 'number' && fixedDecimals >= 0 + ? item.data.map(d => +d.toFixed(fixedDecimals)) + : item.data; + switch (item.op) { + case 'move': + path += `M${data[0]} ${data[1]} `; + break; + case 'bcurveTo': + path += `C${data[0]} ${data[1]}, ${data[2]} ${data[3]}, ${data[4]} ${data[5]} `; + break; + case 'lineTo': + path += `L${data[0]} ${data[1]} `; + break; + } + } + return path.trim(); + } + + path(d: string, options?: Options): Drawable { + const o = this._o(options); + const paths: OpSet[] = []; + if (!d) { + return this._d('path', paths, o); + } + d = (d || '') + .replace(/\n/g, ' ') + .replace(/(-\s)/g, '-') + .replace('/(ss)/g', ' '); + + const hasFill = o.fill && o.fill !== 'transparent' && o.fill !== NOS; + const hasStroke = o.stroke !== NOS; + const simplified = !!(o.simplification && o.simplification < 1); + const distance = simplified + ? 4 - 4 * o.simplification! + : (1 + o.roughness) / 2; + const sets = pointsOnPath(d, 1, distance); + + if (hasFill) { + if (o.fillStyle === 'solid') { + paths.push(solidFillPolygon(sets, o)); + } else { + paths.push(patternFillPolygons(sets, o)); + } + } + if (hasStroke) { + if (simplified) { + sets.forEach(set => { + paths.push(linearPath(set, false, o)); + }); + } else { + paths.push(svgPath(d, o)); + } + } + + return this._d('path', paths, o); + } + + polygon(points: Point[], options?: Options): Drawable { + const o = this._o(options); + const paths: OpSet[] = []; + const outline = linearPath(points, true, o); + if (o.fill) { + if (o.fillStyle === 'solid') { + paths.push(solidFillPolygon([points], o)); + } else { + paths.push(patternFillPolygons([points], o)); + } + } + if (o.stroke !== NOS) { + paths.push(outline); + } + return this._d('polygon', paths, o); + } + + rectangle( + x: number, + y: number, + width: number, + height: number, + options?: Options + ): Drawable { + const o = this._o(options); + const paths = []; + const outline = rectangle(x, y, width, height, o); + if (o.fill) { + const points: Point[] = [ + [x, y], + [x + width, y], + [x + width, y + height], + [x, y + height], + ]; + if (o.fillStyle === 'solid') { + paths.push(solidFillPolygon([points], o)); + } else { + paths.push(patternFillPolygons([points], o)); + } + } + if (o.stroke !== NOS) { + paths.push(outline); + } + return this._d('rectangle', paths, o); + } + + toPaths(drawable: Drawable): PathInfo[] { + const sets = drawable.sets || []; + const o = drawable.options || this.defaultOptions; + const paths: PathInfo[] = []; + for (const drawing of sets) { + let path: PathInfo | null = null; + switch (drawing.type) { + case 'path': + path = { + d: this.opsToPath(drawing), + stroke: o.stroke, + strokeWidth: o.strokeWidth, + fill: NOS, + }; + break; + case 'fillPath': + path = { + d: this.opsToPath(drawing), + stroke: NOS, + strokeWidth: 0, + fill: o.fill || NOS, + }; + break; + case 'fillSketch': + path = this.fillSketch(drawing, o); + break; + } + if (path) { + paths.push(path); + } + } + return paths; + } +} diff --git a/blocksuite/affine/block-surface/src/utils/rough/geometry.ts b/blocksuite/affine/block-surface/src/utils/rough/geometry.ts new file mode 100644 index 0000000000..ec07bc1bb8 --- /dev/null +++ b/blocksuite/affine/block-surface/src/utils/rough/geometry.ts @@ -0,0 +1,43 @@ +export type Point = [number, number]; +export type Line = [Point, Point]; + +export interface Rectangle { + x: number; + y: number; + width: number; + height: number; +} + +export function rotatePoints( + points: Point[], + center: Point, + degrees: number +): void { + if (points && points.length) { + const [cx, cy] = center; + const angle = (Math.PI / 180) * degrees; + const cos = Math.cos(angle); + const sin = Math.sin(angle); + points.forEach(p => { + const [x, y] = p; + p[0] = (x - cx) * cos - (y - cy) * sin + cx; + p[1] = (x - cx) * sin + (y - cy) * cos + cy; + }); + } +} + +export function rotateLines( + lines: Line[], + center: Point, + degrees: number +): void { + const points: Point[] = []; + lines.forEach(line => points.push(...line)); + rotatePoints(points, center, degrees); +} + +export function lineLength(line: Line): number { + const p1 = line[0]; + const p2 = line[1]; + return Math.sqrt(Math.pow(p1[0] - p2[0], 2) + Math.pow(p1[1] - p2[1], 2)); +} diff --git a/blocksuite/affine/block-surface/src/utils/rough/math.ts b/blocksuite/affine/block-surface/src/utils/rough/math.ts new file mode 100644 index 0000000000..e881c496b8 --- /dev/null +++ b/blocksuite/affine/block-surface/src/utils/rough/math.ts @@ -0,0 +1,21 @@ +export function randomSeed(): number { + return Math.floor(Math.random() * 2 ** 31); +} + +export class Random { + private seed: number; + + constructor(seed: number) { + this.seed = seed; + } + + next(): number { + if (this.seed) { + return ( + ((2 ** 31 - 1) & (this.seed = Math.imul(48271, this.seed))) / 2 ** 31 + ); + } else { + return Math.random(); + } + } +} diff --git a/blocksuite/affine/block-surface/src/utils/rough/renderer.ts b/blocksuite/affine/block-surface/src/utils/rough/renderer.ts new file mode 100644 index 0000000000..0722c2531e --- /dev/null +++ b/blocksuite/affine/block-surface/src/utils/rough/renderer.ts @@ -0,0 +1,742 @@ +import { absolutize } from '../path-data-parser/absolutize.js'; +import { normalize } from '../path-data-parser/normalize.js'; +import { parsePath } from '../path-data-parser/parser.js'; +import type { Op, OpSet, ResolvedOptions } from './core.js'; +import { getFiller } from './fillers/filler.js'; +import type { RenderHelper } from './fillers/filler-interface.js'; +import type { Point } from './geometry.js'; +import { Random } from './math.js'; + +interface EllipseParams { + rx: number; + ry: number; + increment: number; +} + +const helper: RenderHelper = { + randOffset, + randOffsetWithRange, + ellipse, + doubleLineOps: doubleLineFillOps, +}; + +export function line( + x1: number, + y1: number, + x2: number, + y2: number, + o: ResolvedOptions +): OpSet { + return { type: 'path', ops: _doubleLine(x1, y1, x2, y2, o) }; +} + +export function linearPath( + points: Point[], + close: boolean, + o: ResolvedOptions +): OpSet { + const len = (points || []).length; + if (len > 2) { + const ops: Op[] = []; + for (let i = 0; i < len - 1; i++) { + ops.push( + ..._doubleLine( + points[i][0], + points[i][1], + points[i + 1][0], + points[i + 1][1], + o + ) + ); + } + if (close) { + ops.push( + ..._doubleLine( + points[len - 1][0], + points[len - 1][1], + points[0][0], + points[0][1], + o + ) + ); + } + return { type: 'path', ops }; + } else if (len === 2) { + return line(points[0][0], points[0][1], points[1][0], points[1][1], o); + } + return { type: 'path', ops: [] }; +} + +export function polygon(points: Point[], o: ResolvedOptions): OpSet { + return linearPath(points, true, o); +} + +export function rectangle( + x: number, + y: number, + width: number, + height: number, + o: ResolvedOptions +): OpSet { + const points: Point[] = [ + [x, y], + [x + width, y], + [x + width, y + height], + [x, y + height], + ]; + return polygon(points, o); +} + +export function curve(points: Point[], o: ResolvedOptions): OpSet { + let o1 = _curveWithOffset(points, 1 * (1 + o.roughness * 0.2), o); + if (!o.disableMultiStroke) { + const o2 = _curveWithOffset( + points, + 1.5 * (1 + o.roughness * 0.22), + cloneOptionsAlterSeed(o) + ); + o1 = o1.concat(o2); + } + return { type: 'path', ops: o1 }; +} + +export interface EllipseResult { + opset: OpSet; + estimatedPoints: Point[]; +} + +export function ellipse( + x: number, + y: number, + width: number, + height: number, + o: ResolvedOptions +): OpSet { + const params = generateEllipseParams(width, height, o); + return ellipseWithParams(x, y, o, params).opset; +} + +export function generateEllipseParams( + width: number, + height: number, + o: ResolvedOptions +): EllipseParams { + const psq = Math.sqrt( + Math.PI * + 2 * + Math.sqrt((Math.pow(width / 2, 2) + Math.pow(height / 2, 2)) / 2) + ); + const stepCount = Math.ceil( + Math.max(o.curveStepCount, (o.curveStepCount / Math.sqrt(200)) * psq) + ); + const increment = (Math.PI * 2) / stepCount; + let rx = Math.abs(width / 2); + let ry = Math.abs(height / 2); + const curveFitRandomness = 1 - o.curveFitting; + rx += _offsetOpt(rx * curveFitRandomness, o); + ry += _offsetOpt(ry * curveFitRandomness, o); + return { increment, rx, ry }; +} + +export function ellipseWithParams( + x: number, + y: number, + o: ResolvedOptions, + ellipseParams: EllipseParams +): EllipseResult { + const [ap1, cp1] = _computeEllipsePoints( + ellipseParams.increment, + x, + y, + ellipseParams.rx, + ellipseParams.ry, + 1, + ellipseParams.increment * _offset(0.1, _offset(0.4, 1, o), o), + o + ); + let o1 = _curve(ap1, null, o); + if (!o.disableMultiStroke && o.roughness !== 0) { + const [ap2] = _computeEllipsePoints( + ellipseParams.increment, + x, + y, + ellipseParams.rx, + ellipseParams.ry, + 1.5, + 0, + o + ); + const o2 = _curve(ap2, null, o); + o1 = o1.concat(o2); + } + return { + estimatedPoints: cp1, + opset: { type: 'path', ops: o1 }, + }; +} + +export function arc( + x: number, + y: number, + width: number, + height: number, + start: number, + stop: number, + closed: boolean, + roughClosure: boolean, + o: ResolvedOptions +): OpSet { + const cx = x; + const cy = y; + let rx = Math.abs(width / 2); + let ry = Math.abs(height / 2); + rx += _offsetOpt(rx * 0.01, o); + ry += _offsetOpt(ry * 0.01, o); + let strt = start; + let stp = stop; + while (strt < 0) { + strt += Math.PI * 2; + stp += Math.PI * 2; + } + if (stp - strt > Math.PI * 2) { + strt = 0; + stp = Math.PI * 2; + } + const ellipseInc = (Math.PI * 2) / o.curveStepCount; + const arcInc = Math.min(ellipseInc / 2, (stp - strt) / 2); + const ops = _arc(arcInc, cx, cy, rx, ry, strt, stp, 1, o); + if (!o.disableMultiStroke) { + const o2 = _arc(arcInc, cx, cy, rx, ry, strt, stp, 1.5, o); + ops.push(...o2); + } + if (closed) { + if (roughClosure) { + ops.push( + ..._doubleLine( + cx, + cy, + cx + rx * Math.cos(strt), + cy + ry * Math.sin(strt), + o + ), + ..._doubleLine( + cx, + cy, + cx + rx * Math.cos(stp), + cy + ry * Math.sin(stp), + o + ) + ); + } else { + ops.push( + { op: 'lineTo', data: [cx, cy] }, + { + op: 'lineTo', + data: [cx + rx * Math.cos(strt), cy + ry * Math.sin(strt)], + } + ); + } + } + return { type: 'path', ops }; +} + +export function svgPath(path: string, o: ResolvedOptions): OpSet { + const segments = normalize(absolutize(parsePath(path))); + const ops: Op[] = []; + let first: Point = [0, 0]; + let current: Point = [0, 0]; + for (const { key, data } of segments) { + switch (key) { + case 'M': { + const ro = 1 * (o.maxRandomnessOffset || 0); + const pv = o.preserveVertices; + ops.push({ + op: 'move', + data: data.map(d => d + (pv ? 0 : _offsetOpt(ro, o))), + }); + current = [data[0], data[1]]; + first = [data[0], data[1]]; + break; + } + case 'L': + ops.push(..._doubleLine(current[0], current[1], data[0], data[1], o)); + current = [data[0], data[1]]; + break; + case 'C': { + const [x1, y1, x2, y2, x, y] = data; + ops.push(..._bezierTo(x1, y1, x2, y2, x, y, current, o)); + current = [x, y]; + break; + } + case 'Z': + ops.push(..._doubleLine(current[0], current[1], first[0], first[1], o)); + current = [first[0], first[1]]; + break; + } + } + return { type: 'path', ops }; +} + +// Fills + +export function solidFillPolygon( + polygonList: Point[][], + o: ResolvedOptions +): OpSet { + const ops: Op[] = []; + for (const points of polygonList) { + if (points.length) { + const offset = o.maxRandomnessOffset || 0; + const len = points.length; + if (len > 2) { + ops.push({ + op: 'move', + data: [ + points[0][0] + _offsetOpt(offset, o), + points[0][1] + _offsetOpt(offset, o), + ], + }); + for (let i = 1; i < len; i++) { + ops.push({ + op: 'lineTo', + data: [ + points[i][0] + _offsetOpt(offset, o), + points[i][1] + _offsetOpt(offset, o), + ], + }); + } + } + } + } + return { type: 'fillPath', ops }; +} + +export function patternFillPolygons( + polygonList: Point[][], + o: ResolvedOptions +): OpSet { + return getFiller(o, helper).fillPolygons(polygonList, o); +} + +export function patternFillArc( + x: number, + y: number, + width: number, + height: number, + start: number, + stop: number, + o: ResolvedOptions +): OpSet { + const cx = x; + const cy = y; + let rx = Math.abs(width / 2); + let ry = Math.abs(height / 2); + rx += _offsetOpt(rx * 0.01, o); + ry += _offsetOpt(ry * 0.01, o); + let strt = start; + let stp = stop; + while (strt < 0) { + strt += Math.PI * 2; + stp += Math.PI * 2; + } + if (stp - strt > Math.PI * 2) { + strt = 0; + stp = Math.PI * 2; + } + const increment = (stp - strt) / o.curveStepCount; + const points: Point[] = []; + for (let angle = strt; angle <= stp; angle = angle + increment) { + points.push([cx + rx * Math.cos(angle), cy + ry * Math.sin(angle)]); + } + points.push([cx + rx * Math.cos(stp), cy + ry * Math.sin(stp)]); + points.push([cx, cy]); + return patternFillPolygons([points], o); +} + +export function randOffset(x: number, o: ResolvedOptions): number { + return _offsetOpt(x, o); +} + +export function randOffsetWithRange( + min: number, + max: number, + o: ResolvedOptions +): number { + return _offset(min, max, o); +} + +export function doubleLineFillOps( + x1: number, + y1: number, + x2: number, + y2: number, + o: ResolvedOptions +): Op[] { + return _doubleLine(x1, y1, x2, y2, o, true); +} + +// Private helpers + +function cloneOptionsAlterSeed(ops: ResolvedOptions): ResolvedOptions { + const result: ResolvedOptions = { ...ops }; + result.randomizer = undefined; + if (ops.seed) { + result.seed = ops.seed + 1; + } + return result; +} + +function random(ops: ResolvedOptions): number { + if (!ops.randomizer) { + ops.randomizer = new Random(ops.seed || 0); + } + return ops.randomizer.next(); +} + +function _offset( + min: number, + max: number, + ops: ResolvedOptions, + roughnessGain = 1 +): number { + return ops.roughness * roughnessGain * (random(ops) * (max - min) + min); +} + +function _offsetOpt( + x: number, + ops: ResolvedOptions, + roughnessGain = 1 +): number { + return _offset(-x, x, ops, roughnessGain); +} + +function _doubleLine( + x1: number, + y1: number, + x2: number, + y2: number, + o: ResolvedOptions, + filling = false +): Op[] { + const singleStroke = filling + ? o.disableMultiStrokeFill + : o.disableMultiStroke; + const o1 = _line(x1, y1, x2, y2, o, true, false); + if (singleStroke) { + return o1; + } + const o2 = _line(x1, y1, x2, y2, o, true, true); + return o1.concat(o2); +} + +function _line( + x1: number, + y1: number, + x2: number, + y2: number, + o: ResolvedOptions, + move: boolean, + overlay: boolean +): Op[] { + const lengthSq = Math.pow(x1 - x2, 2) + Math.pow(y1 - y2, 2); + const length = Math.sqrt(lengthSq); + let roughnessGain = 1; + if (length < 200) { + roughnessGain = 1; + } else if (length > 500) { + roughnessGain = 0.4; + } else { + roughnessGain = -0.0016668 * length + 1.233334; + } + + let offset = o.maxRandomnessOffset || 0; + if (offset * offset * 100 > lengthSq) { + offset = length / 10; + } + const halfOffset = offset / 2; + const divergePoint = 0.2 + random(o) * 0.2; + let midDispX = (o.bowing * o.maxRandomnessOffset * (y2 - y1)) / 200; + let midDispY = (o.bowing * o.maxRandomnessOffset * (x1 - x2)) / 200; + midDispX = _offsetOpt(midDispX, o, roughnessGain); + midDispY = _offsetOpt(midDispY, o, roughnessGain); + const ops: Op[] = []; + const randomHalf = () => _offsetOpt(halfOffset, o, roughnessGain); + const randomFull = () => _offsetOpt(offset, o, roughnessGain); + const preserveVertices = o.preserveVertices; + if (move) { + if (overlay) { + ops.push({ + op: 'move', + data: [ + x1 + (preserveVertices ? 0 : randomHalf()), + y1 + (preserveVertices ? 0 : randomHalf()), + ], + }); + } else { + ops.push({ + op: 'move', + data: [ + x1 + (preserveVertices ? 0 : _offsetOpt(offset, o, roughnessGain)), + y1 + (preserveVertices ? 0 : _offsetOpt(offset, o, roughnessGain)), + ], + }); + } + } + if (overlay) { + ops.push({ + op: 'bcurveTo', + data: [ + midDispX + x1 + (x2 - x1) * divergePoint + randomHalf(), + midDispY + y1 + (y2 - y1) * divergePoint + randomHalf(), + midDispX + x1 + 2 * (x2 - x1) * divergePoint + randomHalf(), + midDispY + y1 + 2 * (y2 - y1) * divergePoint + randomHalf(), + x2 + (preserveVertices ? 0 : randomHalf()), + y2 + (preserveVertices ? 0 : randomHalf()), + ], + }); + } else { + ops.push({ + op: 'bcurveTo', + data: [ + midDispX + x1 + (x2 - x1) * divergePoint + randomFull(), + midDispY + y1 + (y2 - y1) * divergePoint + randomFull(), + midDispX + x1 + 2 * (x2 - x1) * divergePoint + randomFull(), + midDispY + y1 + 2 * (y2 - y1) * divergePoint + randomFull(), + x2 + (preserveVertices ? 0 : randomFull()), + y2 + (preserveVertices ? 0 : randomFull()), + ], + }); + } + return ops; +} + +function _curveWithOffset( + points: Point[], + offset: number, + o: ResolvedOptions +): Op[] { + const ps: Point[] = []; + ps.push([ + points[0][0] + _offsetOpt(offset, o), + points[0][1] + _offsetOpt(offset, o), + ]); + ps.push([ + points[0][0] + _offsetOpt(offset, o), + points[0][1] + _offsetOpt(offset, o), + ]); + for (let i = 1; i < points.length; i++) { + ps.push([ + points[i][0] + _offsetOpt(offset, o), + points[i][1] + _offsetOpt(offset, o), + ]); + if (i === points.length - 1) { + ps.push([ + points[i][0] + _offsetOpt(offset, o), + points[i][1] + _offsetOpt(offset, o), + ]); + } + } + return _curve(ps, null, o); +} + +function _curve( + points: Point[], + closePoint: Point | null, + o: ResolvedOptions +): Op[] { + const len = points.length; + const ops: Op[] = []; + if (len > 3) { + const b = []; + const s = 1 - o.curveTightness; + ops.push({ op: 'move', data: [points[1][0], points[1][1]] }); + for (let i = 1; i + 2 < len; i++) { + const cachedVertArray = points[i]; + b[0] = [cachedVertArray[0], cachedVertArray[1]]; + b[1] = [ + cachedVertArray[0] + (s * points[i + 1][0] - s * points[i - 1][0]) / 6, + cachedVertArray[1] + (s * points[i + 1][1] - s * points[i - 1][1]) / 6, + ]; + b[2] = [ + points[i + 1][0] + (s * points[i][0] - s * points[i + 2][0]) / 6, + points[i + 1][1] + (s * points[i][1] - s * points[i + 2][1]) / 6, + ]; + b[3] = [points[i + 1][0], points[i + 1][1]]; + ops.push({ + op: 'bcurveTo', + data: [b[1][0], b[1][1], b[2][0], b[2][1], b[3][0], b[3][1]], + }); + } + if (closePoint && closePoint.length === 2) { + const ro = o.maxRandomnessOffset; + ops.push({ + op: 'lineTo', + data: [ + closePoint[0] + _offsetOpt(ro, o), + closePoint[1] + _offsetOpt(ro, o), + ], + }); + } + } else if (len === 3) { + ops.push({ op: 'move', data: [points[1][0], points[1][1]] }); + ops.push({ + op: 'bcurveTo', + data: [ + points[1][0], + points[1][1], + points[2][0], + points[2][1], + points[2][0], + points[2][1], + ], + }); + } else if (len === 2) { + ops.push( + ..._doubleLine(points[0][0], points[0][1], points[1][0], points[1][1], o) + ); + } + return ops; +} + +function _computeEllipsePoints( + increment: number, + cx: number, + cy: number, + rx: number, + ry: number, + offset: number, + overlap: number, + o: ResolvedOptions +): Point[][] { + const coreOnly = o.roughness === 0; + const corePoints: Point[] = []; + const allPoints: Point[] = []; + + if (coreOnly) { + increment = increment / 4; + allPoints.push([ + cx + rx * Math.cos(-increment), + cy + ry * Math.sin(-increment), + ]); + for (let angle = 0; angle <= Math.PI * 2; angle = angle + increment) { + const p: Point = [cx + rx * Math.cos(angle), cy + ry * Math.sin(angle)]; + corePoints.push(p); + allPoints.push(p); + } + allPoints.push([cx + rx * Math.cos(0), cy + ry * Math.sin(0)]); + allPoints.push([ + cx + rx * Math.cos(increment), + cy + ry * Math.sin(increment), + ]); + } else { + const radOffset = _offsetOpt(0.5, o) - Math.PI / 2; + allPoints.push([ + _offsetOpt(offset, o) + cx + 0.9 * rx * Math.cos(radOffset - increment), + _offsetOpt(offset, o) + cy + 0.9 * ry * Math.sin(radOffset - increment), + ]); + const endAngle = Math.PI * 2 + radOffset - 0.01; + for (let angle = radOffset; angle < endAngle; angle = angle + increment) { + const p: Point = [ + _offsetOpt(offset, o) + cx + rx * Math.cos(angle), + _offsetOpt(offset, o) + cy + ry * Math.sin(angle), + ]; + corePoints.push(p); + allPoints.push(p); + } + allPoints.push([ + _offsetOpt(offset, o) + + cx + + rx * Math.cos(radOffset + Math.PI * 2 + overlap * 0.5), + _offsetOpt(offset, o) + + cy + + ry * Math.sin(radOffset + Math.PI * 2 + overlap * 0.5), + ]); + allPoints.push([ + _offsetOpt(offset, o) + cx + 0.98 * rx * Math.cos(radOffset + overlap), + _offsetOpt(offset, o) + cy + 0.98 * ry * Math.sin(radOffset + overlap), + ]); + allPoints.push([ + _offsetOpt(offset, o) + + cx + + 0.9 * rx * Math.cos(radOffset + overlap * 0.5), + _offsetOpt(offset, o) + + cy + + 0.9 * ry * Math.sin(radOffset + overlap * 0.5), + ]); + } + + return [allPoints, corePoints]; +} + +function _arc( + increment: number, + cx: number, + cy: number, + rx: number, + ry: number, + strt: number, + stp: number, + offset: number, + o: ResolvedOptions +) { + const radOffset = strt + _offsetOpt(0.1, o); + const points: Point[] = []; + points.push([ + _offsetOpt(offset, o) + cx + 0.9 * rx * Math.cos(radOffset - increment), + _offsetOpt(offset, o) + cy + 0.9 * ry * Math.sin(radOffset - increment), + ]); + for (let angle = radOffset; angle <= stp; angle = angle + increment) { + points.push([ + _offsetOpt(offset, o) + cx + rx * Math.cos(angle), + _offsetOpt(offset, o) + cy + ry * Math.sin(angle), + ]); + } + points.push([cx + rx * Math.cos(stp), cy + ry * Math.sin(stp)]); + points.push([cx + rx * Math.cos(stp), cy + ry * Math.sin(stp)]); + return _curve(points, null, o); +} + +function _bezierTo( + x1: number, + y1: number, + x2: number, + y2: number, + x: number, + y: number, + current: Point, + o: ResolvedOptions +): Op[] { + const ops: Op[] = []; + const ros = [o.maxRandomnessOffset || 1, (o.maxRandomnessOffset || 1) + 0.3]; + let f: Point = [0, 0]; + const iterations = o.disableMultiStroke ? 1 : 2; + const preserveVertices = o.preserveVertices; + for (let i = 0; i < iterations; i++) { + if (i === 0) { + ops.push({ op: 'move', data: [current[0], current[1]] }); + } else { + ops.push({ + op: 'move', + data: [ + current[0] + (preserveVertices ? 0 : _offsetOpt(ros[0], o)), + current[1] + (preserveVertices ? 0 : _offsetOpt(ros[0], o)), + ], + }); + } + f = preserveVertices + ? [x, y] + : [x + _offsetOpt(ros[i], o), y + _offsetOpt(ros[i], o)]; + ops.push({ + op: 'bcurveTo', + data: [ + x1 + _offsetOpt(ros[i], o), + y1 + _offsetOpt(ros[i], o), + x2 + _offsetOpt(ros[i], o), + y2 + _offsetOpt(ros[i], o), + f[0], + f[1], + ], + }); + } + return ops; +} diff --git a/blocksuite/affine/block-surface/src/utils/rough/rough.ts b/blocksuite/affine/block-surface/src/utils/rough/rough.ts new file mode 100644 index 0000000000..327dbf9eab --- /dev/null +++ b/blocksuite/affine/block-surface/src/utils/rough/rough.ts @@ -0,0 +1,22 @@ +import { RoughCanvas } from './canvas.js'; +import type { Config } from './core.js'; +import { RoughGenerator } from './generator.js'; +import { RoughSVG } from './svg.js'; + +export default { + canvas(canvas: HTMLCanvasElement, config?: Config): RoughCanvas { + return new RoughCanvas(canvas, config); + }, + + svg(svg: SVGSVGElement, config?: Config): RoughSVG { + return new RoughSVG(svg, config); + }, + + generator(config?: Config): RoughGenerator { + return new RoughGenerator(config); + }, + + newSeed(): number { + return RoughGenerator.newSeed(); + }, +}; diff --git a/blocksuite/affine/block-surface/src/utils/rough/svg.ts b/blocksuite/affine/block-surface/src/utils/rough/svg.ts new file mode 100644 index 0000000000..3c754fca47 --- /dev/null +++ b/blocksuite/affine/block-surface/src/utils/rough/svg.ts @@ -0,0 +1,182 @@ +import type { + Config, + Drawable, + OpSet, + Options, + ResolvedOptions, +} from './core.js'; +import { SVGNS } from './core.js'; +import { RoughGenerator } from './generator.js'; +import type { Point } from './geometry.js'; + +export class RoughSVG { + private gen: RoughGenerator; + + private svg: SVGSVGElement; + + get generator(): RoughGenerator { + return this.gen; + } + + constructor(svg: SVGSVGElement, config?: Config) { + this.svg = svg; + this.gen = new RoughGenerator(config); + } + + private fillSketch( + doc: Document, + drawing: OpSet, + o: ResolvedOptions + ): SVGPathElement { + let fweight = o.fillWeight; + if (fweight < 0) { + fweight = o.strokeWidth / 2; + } + const path = doc.createElementNS(SVGNS, 'path'); + path.setAttribute('d', this.opsToPath(drawing, o.fixedDecimalPlaceDigits)); + path.setAttribute('stroke', o.fill || ''); + path.setAttribute('stroke-width', fweight + ''); + path.setAttribute('fill', 'none'); + if (o.fillLineDash) { + path.setAttribute('stroke-dasharray', o.fillLineDash.join(' ').trim()); + } + if (o.fillLineDashOffset) { + path.setAttribute('stroke-dashoffset', `${o.fillLineDashOffset}`); + } + return path; + } + + arc( + x: number, + y: number, + width: number, + height: number, + start: number, + stop: number, + closed = false, + options?: Options + ): SVGGElement { + const d = this.gen.arc(x, y, width, height, start, stop, closed, options); + return this.draw(d); + } + + circle( + x: number, + y: number, + diameter: number, + options?: Options + ): SVGGElement { + const d = this.gen.circle(x, y, diameter, options); + return this.draw(d); + } + + curve(points: Point[], options?: Options): SVGGElement { + const d = this.gen.curve(points, options); + return this.draw(d); + } + + draw(drawable: Drawable): SVGGElement { + const sets = drawable.sets || []; + const o = drawable.options || this.getDefaultOptions(); + const doc = this.svg.ownerDocument || window.document; + const g = doc.createElementNS(SVGNS, 'g'); + const precision = drawable.options.fixedDecimalPlaceDigits; + for (const drawing of sets) { + let path = null; + switch (drawing.type) { + case 'path': { + path = doc.createElementNS(SVGNS, 'path'); + path.setAttribute('d', this.opsToPath(drawing, precision)); + path.setAttribute('stroke', o.stroke); + path.setAttribute('stroke-width', o.strokeWidth + ''); + path.setAttribute('fill', 'none'); + if (o.strokeLineDash) { + path.setAttribute( + 'stroke-dasharray', + o.strokeLineDash.join(' ').trim() + ); + } + if (o.strokeLineDashOffset) { + path.setAttribute('stroke-dashoffset', `${o.strokeLineDashOffset}`); + } + break; + } + case 'fillPath': { + path = doc.createElementNS(SVGNS, 'path'); + path.setAttribute('d', this.opsToPath(drawing, precision)); + path.setAttribute('stroke', 'none'); + path.setAttribute('stroke-width', '0'); + path.setAttribute('fill', o.fill || ''); + if (drawable.shape === 'curve' || drawable.shape === 'polygon') { + path.setAttribute('fill-rule', 'evenodd'); + } + break; + } + case 'fillSketch': { + path = this.fillSketch(doc, drawing, o); + break; + } + } + if (path) { + g.append(path); + } + } + return g; + } + + ellipse( + x: number, + y: number, + width: number, + height: number, + options?: Options + ): SVGGElement { + const d = this.gen.ellipse(x, y, width, height, options); + return this.draw(d); + } + + getDefaultOptions(): ResolvedOptions { + return this.gen.defaultOptions; + } + + line( + x1: number, + y1: number, + x2: number, + y2: number, + options?: Options + ): SVGGElement { + const d = this.gen.line(x1, y1, x2, y2, options); + return this.draw(d); + } + + linearPath(points: Point[], options?: Options): SVGGElement { + const d = this.gen.linearPath(points, options); + return this.draw(d); + } + + opsToPath(drawing: OpSet, fixedDecimalPlaceDigits?: number): string { + return this.gen.opsToPath(drawing, fixedDecimalPlaceDigits); + } + + path(d: string, options?: Options): SVGGElement { + const drawing = this.gen.path(d, options); + return this.draw(drawing); + } + + polygon(points: Point[], options?: Options): SVGGElement { + const d = this.gen.polygon(points, options); + return this.draw(d); + } + + rectangle( + x: number, + y: number, + width: number, + height: number, + options?: Options + ): SVGGElement { + const d = this.gen.rectangle(x, y, width, height, options); + return this.draw(d); + } +} diff --git a/blocksuite/affine/block-surface/src/utils/sort.ts b/blocksuite/affine/block-surface/src/utils/sort.ts new file mode 100644 index 0000000000..ba82d7396b --- /dev/null +++ b/blocksuite/affine/block-surface/src/utils/sort.ts @@ -0,0 +1,93 @@ +export function loadingSort( + elements: T[] +) { + const graph = new Map(); + const outDegree = new Map(); + const sortedOrder = []; + const map = new Map(); + + elements.forEach(element => { + outDegree.set(element.id, 0); + map.set(element.id, element); + }); + + elements.forEach(element => { + element.deps.forEach(depId => { + if (outDegree.has(depId)) { + graph.has(depId) + ? graph.get(depId)!.push(element.id) + : graph.set(depId, [element.id]); + outDegree.set(element.id, outDegree.get(element.id)! + 1); + } + }); + }); + + const queue: string[] = []; + + for (const [id, degree] of outDegree) { + if (degree === 0) { + queue.push(id); + } + } + + while (queue.length > 0) { + const node = queue.shift()!; + sortedOrder.push(node); + + const deps = graph.get(node) || []; + deps.forEach(depId => { + if (outDegree.has(depId)) { + outDegree.set(depId, outDegree.get(depId)! - 1); + + if (outDegree.get(depId) === 0) { + queue.push(depId); + } + } + }); + } + + return sortedOrder.map(id => map.get(id)!); +} + +export function sortIndex( + a: { id: string; index: string }, + b: { id: string; index: string }, + groupIndexMap: Map +) { + const aGroupIndex = groupIndexMap.get(a.id); + const bGroupIndex = groupIndexMap.get(b.id); + + if (aGroupIndex && bGroupIndex) { + return aGroupIndex.id === bGroupIndex.id + ? a.index === b.index + ? 0 + : a.index > b.index + ? 1 + : -1 + : aGroupIndex.index > bGroupIndex.index + ? 1 + : -1; + } + + if (aGroupIndex) { + return aGroupIndex.id === b.id + ? 1 + : aGroupIndex.index === b.index + ? 0 + : aGroupIndex.index > b.index + ? 1 + : -1; + } + + if (bGroupIndex) { + return a.id === bGroupIndex.id + ? -1 + : a.index === bGroupIndex.index + ? 0 + : a.index > bGroupIndex.index + ? 1 + : -1; + } + + return a.index === b.index ? 0 : a.index > b.index ? 1 : -1; +} diff --git a/blocksuite/affine/block-surface/src/utils/update-xywh.ts b/blocksuite/affine/block-surface/src/utils/update-xywh.ts new file mode 100644 index 0000000000..ba8eb333f6 --- /dev/null +++ b/blocksuite/affine/block-surface/src/utils/update-xywh.ts @@ -0,0 +1,82 @@ +import { + ConnectorElementModel, + MindmapElementModel, + NOTE_MIN_HEIGHT, + NOTE_MIN_WIDTH, + NoteBlockModel, +} from '@blocksuite/affine-model'; +import { + type GfxGroupCompatibleInterface, + type GfxModel, + isGfxGroupCompatibleModel, +} from '@blocksuite/block-std/gfx'; +import { Bound, clamp } from '@blocksuite/global/utils'; +import type { BlockModel, BlockProps } from '@blocksuite/store'; + +function updatChildElementsXYWH( + container: GfxGroupCompatibleInterface, + targetBound: Bound, + updateElement: (id: string, props: Record) => void, + updateBlock: ( + model: BlockModel, + callBackOrProps: (() => void) | Partial + ) => void +) { + const containerBound = Bound.deserialize(container.xywh); + const scaleX = targetBound.w / containerBound.w; + const scaleY = targetBound.h / containerBound.h; + container.childElements.forEach(child => { + const childBound = Bound.deserialize(child.xywh); + childBound.x = targetBound.x + scaleX * (childBound.x - containerBound.x); + childBound.y = targetBound.y + scaleY * (childBound.y - containerBound.y); + childBound.w = scaleX * childBound.w; + childBound.h = scaleY * childBound.h; + updateXYWH(child, childBound, updateElement, updateBlock); + }); +} + +export function updateXYWH( + ele: GfxModel, + bound: Bound, + updateElement: (id: string, props: Record) => void, + updateBlock: ( + model: BlockModel, + callBackOrProps: (() => void) | Partial + ) => void +) { + if (ele instanceof ConnectorElementModel) { + ele.moveTo(bound); + } else if (ele instanceof NoteBlockModel) { + const scale = ele.edgeless.scale ?? 1; + bound.w = clamp(bound.w, NOTE_MIN_WIDTH * scale, Infinity); + bound.h = clamp(bound.h, NOTE_MIN_HEIGHT * scale, Infinity); + if (bound.h >= NOTE_MIN_HEIGHT * scale) { + updateBlock(ele, () => { + ele.edgeless.collapse = true; + ele.edgeless.collapsedHeight = bound.h / scale; + }); + } + updateElement(ele.id, { + xywh: bound.serialize(), + }); + } else if (ele instanceof MindmapElementModel) { + const rootId = ele.tree.id; + const rootEle = ele.childElements.find(child => child.id === rootId); + if (rootEle) { + const rootBound = Bound.deserialize(rootEle.xywh); + rootBound.x += bound.x - ele.x; + rootBound.y += bound.y - ele.y; + updateXYWH(rootEle, rootBound, updateElement, updateBlock); + } + ele.layout(); + } else if (isGfxGroupCompatibleModel(ele)) { + updatChildElementsXYWH(ele, bound, updateElement, updateBlock); + updateElement(ele.id, { + xywh: bound.serialize(), + }); + } else { + updateElement(ele.id, { + xywh: bound.serialize(), + }); + } +} diff --git a/blocksuite/affine/block-surface/src/view/mindmap.ts b/blocksuite/affine/block-surface/src/view/mindmap.ts new file mode 100644 index 0000000000..f38194f8f8 --- /dev/null +++ b/blocksuite/affine/block-surface/src/view/mindmap.ts @@ -0,0 +1,335 @@ +import { + LayoutType, + LocalShapeElementModel, + type MindmapElementModel, + type MindmapNode, + type MindmapRoot, +} from '@blocksuite/affine-model'; +import { TelemetryProvider } from '@blocksuite/affine-shared/services'; +import { requestThrottledConnectedFrame } from '@blocksuite/affine-shared/utils'; +import type { PointerEventState } from '@blocksuite/block-std'; +import { GfxElementModelView } from '@blocksuite/block-std/gfx'; + +import { handleLayout } from '../utils/mindmap/utils.js'; + +export class MindMapView extends GfxElementModelView { + static override type = 'mindmap'; + + private _collapseButtons = new Map(); + + private _hoveredState = new Map< + string, + { + button: boolean; + node: boolean; + } + >(); + + private _getCollapseButton(node: MindmapNode | string) { + const id = typeof node === 'string' ? node : node.id; + return this._collapseButtons.get(`collapse-btn-${id}`); + } + + private _initCollapseButtons() { + const updateButtons = requestThrottledConnectedFrame(() => { + if (!this.isConnected) { + return; + } + + const visited = new Set(); + + this.model.traverse(node => { + const btn = this._updateCollapseButton(node); + + btn && visited.add(btn); + }); + + this._collapseButtons.forEach(btn => { + if (!visited.has(btn)) { + this.surface.deleteLocalElement(btn); + this._collapseButtons.delete(btn.id); + const hoveredId = btn.id.replace('collapse-btn-', ''); + + this._hoveredState.delete(hoveredId); + } + }); + }); + + this.disposable.add( + this.model.propsUpdated.on(({ key }) => { + if (key === 'layoutType' || key === 'style') { + updateButtons(); + } + }) + ); + + this.disposable.add( + this.surface.elementUpdated.on(payload => { + if (this.model.children.has(payload.id)) { + if (payload.props['xywh']) { + updateButtons(); + } + if (payload.props['hidden'] !== undefined) { + this._updateButtonVisibility(payload.id); + } + } + }) + ); + + this.model.children.observe(updateButtons); + + this.disposable.add(() => { + this.model.children.unobserve(updateButtons); + }); + + updateButtons(); + } + + private _needToUpdateButtonStyle(options: { + button: LocalShapeElementModel; + node: MindmapNode; + updateKey?: boolean; + }) { + const { button, node } = options; + const layout = this.model.getLayoutDir(node); + const cacheKey = `${node.detail.collapsed ?? false}-${layout}-${node.element.xywh}-${this.model.style}`; + + if (button.cache.get('MINDMAP_COLLAPSE_BUTTON') === cacheKey) { + return false; + } else if (options.updateKey) { + button.cache.set('MINDMAP_COLLAPSE_BUTTON', cacheKey); + } + + return true; + } + + private _setLayoutMethod() { + this.model.setLayoutMethod(function ( + this: MindmapElementModel, + tree: MindmapNode | MindmapRoot = this.tree, + options: { + applyStyle?: boolean; + layoutType?: LayoutType; + stashed?: boolean; + } = { + applyStyle: true, + stashed: true, + } + ) { + const { stashed, applyStyle, layoutType } = Object.assign( + { + applyStyle: true, + calculateTreeBound: true, + stashed: true, + }, + options + ); + + const pop = stashed ? this.stashTree(tree) : null; + handleLayout(this, tree, applyStyle, layoutType); + pop?.(); + }); + } + + private _setVisibleOnSelection() { + let lastNode: null | string = null; + this.disposable.add( + this.gfx.selection.slots.updated.on(() => { + const elm = this.gfx.selection.firstElement; + + if (lastNode) { + this._updateButtonVisibility(lastNode); + } + + if ( + this.gfx.selection.selectedElements.length === 1 && + elm?.id && + this.model.children.has(elm.id) + ) { + const button = this._getCollapseButton(elm.id); + + if (!button) { + return; + } + + this._updateButtonVisibility(elm.id); + lastNode = elm.id; + } + }) + ); + } + + private _updateButtonVisibility(node: string) { + const latestNode = this.model.getNode(node); + const buttonModel = this._getCollapseButton(node); + + if (!buttonModel) { + return; + } + + if (!latestNode) { + buttonModel.opacity = 0; + return; + } + + const hoveredState = this._hoveredState.get(node) ?? { + button: false, + node: false, + }; + + const hovered = hoveredState.button || hoveredState.node; + const hasChildren = (latestNode.children.length ?? 0) > 0; + const notHidden = !latestNode.element.hidden; + const isNodeSelected = + this.gfx.selection.firstElement === latestNode.element; + const collapsed = latestNode.detail.collapsed ?? false; + + buttonModel.hidden = latestNode.element.hidden; + buttonModel.opacity = + hasChildren && notHidden && (collapsed || isNodeSelected || hovered) + ? 1 + : 0; + } + + private _updateCollapseButton(node: MindmapNode) { + if (!node?.element || node.children.length === 0) return null; + + const id = `collapse-btn-${node.id}`; + const alreadyCreated = this._collapseButtons.has(id); + const collapseButton = + this._collapseButtons.get(id) || + new LocalShapeElementModel(this.model.surface); + const collapsed = node.detail.collapsed ?? false; + + if ( + this._needToUpdateButtonStyle({ + button: collapseButton, + node, + updateKey: true, + }) + ) { + const style = this.model.styleGetter.getNodeStyle( + node, + this.model.getPath(node) + ); + const layout = this.model.getLayoutDir(node); + const buttonStyle = collapsed ? style.expandButton : style.collapseButton; + + Object.entries(buttonStyle).forEach(([key, value]) => { + // @ts-expect-error FIXME: ts error + collapseButton[key as unknown] = value; + }); + + const nodeElementBound = node.element.elementBound; + const buttonBound = nodeElementBound.moveDelta( + layout === LayoutType.LEFT + ? -6 - buttonStyle.width + : 6 + nodeElementBound.w, + (nodeElementBound.h - buttonStyle.height) / 2 + ); + + buttonBound.w = buttonStyle.width; + buttonBound.h = buttonStyle.height; + + collapseButton.responseExtension = [16, 16]; + collapseButton.xywh = buttonBound.serialize(); + collapseButton.groupId = this.model.id; + collapseButton.text = collapsed ? node.children.length.toString() : ''; + } + + if (!alreadyCreated) { + collapseButton.id = id; + collapseButton.opacity = !node.element.hidden && collapsed ? 1 : 0; + + this._collapseButtons.set(id, collapseButton); + this.surface.addLocalElement(collapseButton); + + const hoveredState = { + button: false, + node: false, + }; + const buttonView = this.gfx.view.get(id) as GfxElementModelView; + const isOnElementBound = (evt: PointerEventState) => { + const [x, y] = this.gfx.viewport.toModelCoord(evt.x, evt.y); + + return buttonView.model.includesPoint( + x, + y, + { useElementBound: true }, + this.gfx.std.host + ); + }; + + this._hoveredState = this._hoveredState.set(node.id, hoveredState); + + buttonView.on('pointerenter', () => { + hoveredState.button = true; + this._updateButtonVisibility(node.id); + }); + buttonView.on('pointermove', evt => { + const latestNode = this.model.getNode(node.id); + if ( + latestNode && + !latestNode.element.hidden && + latestNode.children.length > 0 + ) { + if (isOnElementBound(evt)) { + this.gfx.cursor$.value = 'pointer'; + } else { + this.gfx.cursor$.value = 'default'; + } + } + }); + buttonView.on('pointerleave', () => { + this.gfx.cursor$.value = 'default'; + + hoveredState.button = false; + this._updateButtonVisibility(node.id); + }); + buttonView.on('click', evt => { + const latestNode = this.model.getNode(node.id); + const telemetry = this.gfx.std.getOptional(TelemetryProvider); + + if (latestNode && isOnElementBound(evt)) { + if (telemetry) { + telemetry.track('ExpandedAndCollapsed', { + page: 'whiteboard editor', + segment: 'mind map', + type: latestNode.detail.collapsed ? 'expand' : 'collapse', + }); + } + + this.model.toggleCollapse(latestNode!, { layout: true }); + } + }); + + const nodeView = this.gfx.view.get(node.id) as GfxElementModelView; + + nodeView.on('pointerenter', () => { + hoveredState.node = true; + this._updateButtonVisibility(node.id); + }); + nodeView.on('pointerleave', () => { + hoveredState.node = false; + this._updateButtonVisibility(node.id); + }); + } else { + this._updateButtonVisibility(node.id); + } + + return collapseButton; + } + + override onCreated(): void { + this._setLayoutMethod(); + this._initCollapseButtons(); + this._setVisibleOnSelection(); + } + + override onDestroyed() { + super.onDestroyed(); + this._collapseButtons.forEach(btn => { + this.surface.deleteLocalElement(btn); + }); + } +} diff --git a/blocksuite/affine/block-surface/src/watchers/connector.ts b/blocksuite/affine/block-surface/src/watchers/connector.ts new file mode 100644 index 0000000000..e4672503f0 --- /dev/null +++ b/blocksuite/affine/block-surface/src/watchers/connector.ts @@ -0,0 +1,88 @@ +import type { ConnectorElementModel } from '@blocksuite/affine-model'; +import type { GfxModel } from '@blocksuite/block-std/gfx'; + +import { ConnectorPathGenerator } from '../managers/connector-manager.js'; +import type { SurfaceBlockModel, SurfaceMiddleware } from '../surface-model.js'; + +export const connectorWatcher: SurfaceMiddleware = ( + surface: SurfaceBlockModel +) => { + const hasElementById = (id: string) => + surface.hasElementById(id) || surface.doc.hasBlockById(id); + const elementGetter = (id: string) => + surface.getElementById(id) ?? (surface.doc.getBlockById(id) as GfxModel); + const updateConnectorPath = (connector: ConnectorElementModel) => { + if ( + ((connector.source?.id && hasElementById(connector.source.id)) || + (!connector.source?.id && connector.source?.position)) && + ((connector.target?.id && hasElementById(connector.target.id)) || + (!connector.target?.id && connector.target?.position)) + ) { + ConnectorPathGenerator.updatePath(connector, null, elementGetter); + } + }; + const pendingList = new Set(); + let pendingFlag = false; + const addToUpdateList = (connector: ConnectorElementModel) => { + pendingList.add(connector); + + if (!pendingFlag) { + pendingFlag = true; + queueMicrotask(() => { + pendingList.forEach(updateConnectorPath); + pendingList.clear(); + pendingFlag = false; + }); + } + }; + + const disposables = [ + surface.elementAdded.on(({ id }) => { + const element = elementGetter(id); + + if (!element) return; + + if ('type' in element && element.type === 'connector') { + addToUpdateList(element as ConnectorElementModel); + } else { + surface.getConnectors(id).forEach(addToUpdateList); + } + }), + surface.elementUpdated.on(({ id, props }) => { + const element = elementGetter(id); + + if (props['xywh'] || props['rotate']) { + surface.getConnectors(id).forEach(addToUpdateList); + } + + if ( + 'type' in element && + element.type === 'connector' && + (props['mode'] !== undefined || + props['target'] || + props['source'] || + (props['xywh'] && !(element as ConnectorElementModel).updatingPath)) + ) { + addToUpdateList(element as ConnectorElementModel); + } + }), + surface.doc.slots.blockUpdated.on(payload => { + if ( + payload.type === 'add' || + (payload.type === 'update' && payload.props.key === 'xywh') + ) { + surface.getConnectors(payload.id).forEach(addToUpdateList); + } + }), + ]; + + surface + .getElementsByType('connector') + .forEach(connector => + updateConnectorPath(connector as ConnectorElementModel) + ); + + return () => { + disposables.forEach(d => d.dispose()); + }; +}; diff --git a/blocksuite/affine/block-surface/src/watchers/group.ts b/blocksuite/affine/block-surface/src/watchers/group.ts new file mode 100644 index 0000000000..3b58696fc8 --- /dev/null +++ b/blocksuite/affine/block-surface/src/watchers/group.ts @@ -0,0 +1,27 @@ +import { SurfaceGroupLikeModel } from '../element-model/base.js'; +import type { SurfaceBlockModel, SurfaceMiddleware } from '../surface-model.js'; + +export const groupRelationWatcher: SurfaceMiddleware = ( + surface: SurfaceBlockModel +) => { + const disposables = [ + surface.elementUpdated + .filter(payload => payload.local) + .on(({ id, props }) => { + const element = surface.getElementById(id)!; + + // remove the group if it has no children + if ( + element instanceof SurfaceGroupLikeModel && + props['childIds'] && + element.childIds.length === 0 + ) { + surface.deleteElement(id); + } + }), + ]; + + return () => { + disposables.forEach(d => d.dispose()); + }; +}; diff --git a/blocksuite/affine/block-surface/tsconfig.json b/blocksuite/affine/block-surface/tsconfig.json new file mode 100644 index 0000000000..c1a5453aa5 --- /dev/null +++ b/blocksuite/affine/block-surface/tsconfig.json @@ -0,0 +1,32 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "rootDir": "./src/", + "outDir": "./dist/", + "noEmit": false + }, + "include": ["./src"], + "references": [ + { + "path": "../../framework/global" + }, + { + "path": "../../framework/store" + }, + { + "path": "../../framework/block-std" + }, + { + "path": "../../framework/inline" + }, + { + "path": "../model" + }, + { + "path": "../components" + }, + { + "path": "../shared" + } + ] +} diff --git a/blocksuite/affine/block-surface/typedoc.json b/blocksuite/affine/block-surface/typedoc.json new file mode 100644 index 0000000000..101e923dba --- /dev/null +++ b/blocksuite/affine/block-surface/typedoc.json @@ -0,0 +1,4 @@ +{ + "extends": ["../../../typedoc.base.json"], + "entryPoints": ["src/index.ts"] +} diff --git a/blocksuite/affine/block-surface/vitest.config.ts b/blocksuite/affine/block-surface/vitest.config.ts new file mode 100644 index 0000000000..3bb7c2cc2d --- /dev/null +++ b/blocksuite/affine/block-surface/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/affine-block-surface', + }, + /** + * 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/blocksuite/affine/components/package.json b/blocksuite/affine/components/package.json new file mode 100644 index 0000000000..ec1486746b --- /dev/null +++ b/blocksuite/affine/components/package.json @@ -0,0 +1,63 @@ +{ + "name": "@blocksuite/affine-components", + "description": "Default BlockSuite editable blocks.", + "type": "module", + "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": "MIT", + "dependencies": { + "@blocksuite/affine-model": "workspace:*", + "@blocksuite/affine-shared": "workspace:*", + "@blocksuite/block-std": "workspace:*", + "@blocksuite/global": "workspace:*", + "@blocksuite/icons": "^2.1.75", + "@blocksuite/inline": "workspace:*", + "@blocksuite/store": "workspace:*", + "@floating-ui/dom": "^1.6.10", + "@lit/context": "^1.1.2", + "@lottiefiles/dotlottie-wc": "^0.4.0", + "@preact/signals-core": "^1.8.0", + "@toeverything/theme": "^1.1.1", + "date-fns": "^4.0.0", + "katex": "^0.16.11", + "lit": "^3.2.0", + "lit-html": "^3.2.1", + "lodash.clonedeep": "^4.5.0", + "shiki": "^1.12.0", + "zod": "^3.23.8" + }, + "exports": { + ".": "./src/index.ts", + "./icons": "./src/icons/index.ts", + "./peek": "./src/peek/index.ts", + "./portal": "./src/portal/index.ts", + "./hover": "./src/hover/index.ts", + "./toolbar": "./src/toolbar/index.ts", + "./toast": "./src/toast/index.ts", + "./rich-text": "./src/rich-text/index.ts", + "./caption": "./src/caption/index.ts", + "./context-menu": "./src/context-menu/index.ts", + "./date-picker": "./src/date-picker/index.ts", + "./drag-indicator": "./src/drag-indicator/index.ts", + "./virtual-keyboard": "./src/virtual-keyboard/index.ts", + "./toggle-button": "./src/toggle-button/index.ts", + "./notification": "./src/notification/index.ts" + }, + "files": [ + "src", + "dist", + "!src/__tests__", + "!dist/__tests__" + ], + "devDependencies": { + "@types/katex": "^0.16.7", + "@types/lodash.clonedeep": "^4.5.9" + } +} diff --git a/blocksuite/affine/components/src/caption/block-caption.ts b/blocksuite/affine/components/src/caption/block-caption.ts new file mode 100644 index 0000000000..ac11529deb --- /dev/null +++ b/blocksuite/affine/components/src/caption/block-caption.ts @@ -0,0 +1,180 @@ +import type { DocMode } from '@blocksuite/affine-model'; +import { stopPropagation } from '@blocksuite/affine-shared/utils'; +import type { BlockStdScope } from '@blocksuite/block-std'; +import { + docContext, + modelContext, + ShadowlessElement, + stdContext, +} from '@blocksuite/block-std'; +import { WithDisposable } from '@blocksuite/global/utils'; +import type { BlockModel, Doc } from '@blocksuite/store'; +import { Text } from '@blocksuite/store'; +import { consume } from '@lit/context'; +import { css, html, nothing } from 'lit'; +import { query, state } from 'lit/decorators.js'; + +import { focusTextModel } from '../rich-text/index.js'; + +export interface BlockCaptionProps { + caption: string | null | undefined; +} + +export class BlockCaptionEditor< + Model extends BlockModel = BlockModel, +> extends WithDisposable(ShadowlessElement) { + static override styles = css` + .block-caption-editor { + display: inline-table; + resize: none; + width: 100%; + outline: none; + border: 0; + background: transparent; + color: var(--affine-icon-color); + font-size: var(--affine-font-sm); + font-family: inherit; + text-align: center; + field-sizing: content; + padding: 0; + margin-top: 4px; + } + .block-caption-editor::placeholder { + color: var(--affine-placeholder-color); + } + `; + + private _focus = false; + + show = () => { + this.display = true; + this.updateComplete.then(() => this.input.focus()).catch(console.error); + }; + + get mode(): DocMode { + return this.doc.getParent(this.model)?.flavour === 'affine:surface' + ? 'edgeless' + : 'page'; + } + + private _onCaptionKeydown(event: KeyboardEvent) { + event.stopPropagation(); + + if (this.mode === 'edgeless' || event.isComposing) { + return; + } + + if (event.key === 'Enter') { + event.preventDefault(); + const doc = this.doc; + const target = event.target as HTMLInputElement; + const start = target.selectionStart; + if (start === null) { + return; + } + + const model = this.model; + const parent = doc.getParent(model); + if (!parent) { + return; + } + + const value = target.value; + const caption = value.slice(0, start); + doc.updateBlock(model, { caption }); + + const nextBlockText = value.slice(start); + const index = parent.children.indexOf(model); + const id = doc.addBlock( + 'affine:paragraph', + { text: new Text(nextBlockText) }, + parent, + index + 1 + ); + + focusTextModel(this.std, id); + } + } + + private _onInputBlur() { + this._focus = false; + this.display = !!this.caption?.length; + } + + private _onInputChange(e: InputEvent) { + const target = e.target as HTMLInputElement; + this.caption = target.value; + this.doc.updateBlock(this.model, { + caption: this.caption, + }); + } + + private _onInputFocus() { + this._focus = true; + } + + override connectedCallback(): void { + super.connectedCallback(); + + this.caption = this.model.caption; + + this.disposables.add( + this.model.propsUpdated.on(({ key }) => { + if (key === 'caption') { + this.caption = this.model.caption; + if (!this._focus) { + this.display = !!this.caption?.length; + } + } + }) + ); + } + + override render() { + if (!this.display && !this.caption) { + return nothing; + } + + return html``; + } + + @state() + accessor caption: string | null | undefined = undefined; + + @state() + accessor display = false; + + @consume({ context: docContext }) + accessor doc!: Doc; + + @query('.block-caption-editor') + accessor input!: HTMLInputElement; + + @consume({ context: modelContext }) + accessor model!: Model; + + @consume({ context: stdContext }) + accessor std!: BlockStdScope; +} + +declare global { + interface HTMLElementTagNameMap { + 'block-caption-editor': BlockCaptionEditor; + } +} diff --git a/blocksuite/affine/components/src/caption/captioned-block-component.ts b/blocksuite/affine/components/src/caption/captioned-block-component.ts new file mode 100644 index 0000000000..72f1f9efa1 --- /dev/null +++ b/blocksuite/affine/components/src/caption/captioned-block-component.ts @@ -0,0 +1,80 @@ +import { ThemeProvider } from '@blocksuite/affine-shared/services'; +import { BlockComponent, type BlockService } from '@blocksuite/block-std'; +import type { BlockModel } from '@blocksuite/store'; +import { html, nothing } from 'lit'; +import { classMap } from 'lit/directives/class-map.js'; +import { createRef, type Ref, ref } from 'lit/directives/ref.js'; +import { type StyleInfo, styleMap } from 'lit/directives/style-map.js'; + +import type { BlockCaptionEditor } from './block-caption.js'; +import { styles } from './styles.js'; + +export enum SelectedStyle { + Background = 'Background', + Border = 'Border', +} + +export class CaptionedBlockComponent< + Model extends BlockModel = BlockModel, + Service extends BlockService = BlockService, + WidgetName extends string = string, +> extends BlockComponent { + static override styles = styles; + + get captionEditor() { + if (!this.useCaptionEditor || !this._captionEditorRef.value) { + console.error( + 'Oops! Please enable useCaptionEditor before accessing captionEditor' + ); + } + return this._captionEditorRef.value; + } + + constructor() { + super(); + this.addRenderer(this._renderWithWidget); + } + + private _renderWithWidget(content: unknown) { + const style = styleMap({ + position: 'relative', + ...this.blockContainerStyles, + }); + const theme = this.std.get(ThemeProvider).theme; + const isBorder = this.selectedStyle === SelectedStyle.Border; + + return html`
+ ${content} + ${this.useCaptionEditor + ? html`` + : nothing} + ${this.selectedStyle === SelectedStyle.Background + ? html`` + : null} + ${this.useZeroWidth && !this.doc.readonly + ? html`` + : nothing} +
`; + } + + // There may be multiple block-caption-editors in a nested structure. + private accessor _captionEditorRef: Ref = + createRef(); + + protected accessor blockContainerStyles: StyleInfo | undefined = undefined; + + protected accessor selectedStyle = SelectedStyle.Background; + + protected accessor useCaptionEditor = false; + + protected accessor useZeroWidth = false; +} diff --git a/blocksuite/affine/components/src/caption/index.ts b/blocksuite/affine/components/src/caption/index.ts new file mode 100644 index 0000000000..14c425455b --- /dev/null +++ b/blocksuite/affine/components/src/caption/index.ts @@ -0,0 +1,10 @@ +import { BlockCaptionEditor } from './block-caption.js'; +export { BlockCaptionEditor, type BlockCaptionProps } from './block-caption.js'; +export { + CaptionedBlockComponent, + SelectedStyle, +} from './captioned-block-component.js'; + +export function effects() { + customElements.define('block-caption-editor', BlockCaptionEditor); +} diff --git a/blocksuite/affine/components/src/caption/styles.ts b/blocksuite/affine/components/src/caption/styles.ts new file mode 100644 index 0000000000..5a4ce3ad76 --- /dev/null +++ b/blocksuite/affine/components/src/caption/styles.ts @@ -0,0 +1,18 @@ +import { css } from 'lit'; + +export const styles = css` + .affine-block-component.border.light .selected-style { + border-radius: 8px; + box-shadow: 0px 0px 0px 1px var(--affine-brand-color); + } + .affine-block-component.border.dark .selected-style { + border-radius: 8px; + box-shadow: 0px 0px 0px 1px var(--affine-brand-color); + } + @media print { + .affine-block-component.border.light .selected-style, + .affine-block-component.border.dark .selected-style { + box-shadow: none; + } + } +`; diff --git a/blocksuite/affine/components/src/context-menu/button.ts b/blocksuite/affine/components/src/context-menu/button.ts new file mode 100644 index 0000000000..bfda8c4afd --- /dev/null +++ b/blocksuite/affine/components/src/context-menu/button.ts @@ -0,0 +1,282 @@ +import { IS_MOBILE } from '@blocksuite/global/env'; +import { + CheckBoxCkeckSolidIcon, + CheckBoxUnIcon, + DoneIcon, +} from '@blocksuite/icons/lit'; +import type { ReadonlySignal } from '@preact/signals-core'; +import { cssVarV2 } from '@toeverything/theme/v2'; +import { css, html, type TemplateResult, unsafeCSS } from 'lit'; +import { property } from 'lit/decorators.js'; +import { classMap } from 'lit/directives/class-map.js'; +import { keyed } from 'lit/directives/keyed.js'; +import type { ClassInfo } from 'lit-html/directives/class-map.js'; + +import { MenuFocusable } from './focusable.js'; +import type { Menu } from './menu.js'; +import type { MenuClass, MenuItemRender } from './types.js'; + +export type MenuButtonData = { + content: () => TemplateResult; + class: ClassInfo; + select: (ele: HTMLElement) => void | false; + onHover?: (hover: boolean) => void; +}; + +export class MenuButton extends MenuFocusable { + static override styles = css` + .affine-menu-button { + display: flex; + width: 100%; + font-size: 20px; + cursor: pointer; + align-items: center; + padding: 4px; + gap: 8px; + border-radius: 4px; + color: var(--affine-icon-color); + } + + .affine-menu-button:hover, + affine-menu-button.active .affine-menu-button { + background-color: var(--affine-hover-color); + } + + .affine-menu-button .affine-menu-action-text { + flex: 1; + font-size: 14px; + line-height: 22px; + color: var(--affine-text-primary-color); + } + + .affine-menu-button.focused { + outline: 1px solid ${unsafeCSS(cssVarV2.layer.insideBorder.primaryBorder)}; + } + + .affine-menu-button.delete-item:hover { + background-color: var(--affine-background-error-color); + color: var(--affine-error-color); + } + + .affine-menu-button.delete-item:hover .affine-menu-action-text { + color: var(--affine-error-color); + } + `; + + override connectedCallback() { + super.connectedCallback(); + this.disposables.addFromEvent(this, 'mouseenter', () => { + this.data.onHover?.(true); + this.menu.closeSubMenu(); + }); + this.disposables.addFromEvent(this, 'mouseleave', () => { + this.data.onHover?.(false); + }); + this.disposables.addFromEvent(this, 'click', this.onClick); + } + + override disconnectedCallback() { + super.disconnectedCallback(); + this.data.onHover?.(false); + } + + onClick() { + if (this.data.select(this) !== false) { + this.menu.options.onComplete?.(); + this.menu.close(); + } + } + + override onPressEnter() { + this.onClick(); + } + + protected override render(): unknown { + const classString = classMap({ + 'affine-menu-button': true, + focused: this.isFocused$.value, + ...this.data.class, + }); + return html`
${this.data.content()}
`; + } + + @property({ attribute: false }) + accessor data!: MenuButtonData; +} + +export class MobileMenuButton extends MenuFocusable { + static override styles = css` + .mobile-menu-button { + display: flex; + width: 100%; + cursor: pointer; + align-items: center; + font-size: 20px; + padding: 11px 8px; + gap: 8px; + border-radius: 4px; + color: var(--affine-icon-color); + } + + .mobile-menu-button .affine-menu-action-text { + flex: 1; + color: var(--affine-text-primary-color); + font-size: 17px; + line-height: 22px; + } + + .mobile-menu-button.delete-item { + color: var(--affine-error-color); + } + + .mobile-menu-button.delete-item .mobile-menu-action-text { + color: var(--affine-error-color); + } + `; + + override connectedCallback() { + super.connectedCallback(); + this.disposables.addFromEvent(this, 'click', this.onClick); + } + + // eslint-disable-next-line sonarjs/no-identical-functions + onClick() { + if (this.data.select(this) !== false) { + this.menu.options.onComplete?.(); + this.menu.close(); + } + } + + override onPressEnter() { + this.onClick(); + } + + protected override render(): unknown { + const classString = classMap({ + 'mobile-menu-button': true, + focused: this.isFocused$.value, + ...this.data.class, + }); + return html`
${this.data.content()}
`; + } + + @property({ attribute: false }) + accessor data!: MenuButtonData; +} + +const renderButton = (data: MenuButtonData, menu: Menu) => { + if (IS_MOBILE) { + return html``; + } + return html``; +}; +export const menuButtonItems = { + action: + (config: { + name: string; + label?: () => TemplateResult; + prefix?: TemplateResult; + postfix?: TemplateResult; + isSelected?: boolean; + select: (ele: HTMLElement) => void | false; + onHover?: (hover: boolean) => void; + class?: MenuClass; + hide?: () => boolean; + }) => + menu => { + if (config.hide?.() || !menu.search(config.name)) { + return; + } + const data: MenuButtonData = { + content: () => { + return html` + ${config.prefix} +
+ ${config.label?.() ?? config.name} +
+ ${config.postfix ?? (config.isSelected ? DoneIcon() : undefined)} + `; + }, + onHover: config.onHover, + select: config.select, + class: { + 'selected-item': config.isSelected ?? false, + ...config.class, + }, + }; + return renderButton(data, menu); + }, + checkbox: + (config: { + name: string; + checked: ReadonlySignal; + postfix?: TemplateResult; + label?: () => TemplateResult; + select: (checked: boolean) => boolean; + class?: ClassInfo; + }) => + menu => { + if (!menu.search(config.name)) { + return; + } + const data: MenuButtonData = { + content: () => html` + ${config.checked.value + ? CheckBoxCkeckSolidIcon({ style: `color:#1E96EB` }) + : CheckBoxUnIcon()} +
+ ${config.label?.() ?? config.name} +
+ ${config.postfix} + `, + select: () => { + config.select(config.checked.value); + return false; + }, + class: config.class ?? {}, + }; + return html`${keyed(config.name, renderButton(data, menu))}`; + }, + toggleSwitch: + (config: { + name: string; + on: boolean; + postfix?: TemplateResult; + label?: () => TemplateResult; + onChange: (on: boolean) => void; + class?: ClassInfo; + }) => + menu => { + if (!menu.search(config.name)) { + return; + } + const onChange = (on: boolean) => { + config.onChange(on); + }; + + const data: MenuButtonData = { + content: () => html` +
+ ${config.label?.() ?? config.name} +
+ + ${config.postfix} + `, + select: () => { + config.onChange(config.on); + return false; + }, + class: config.class ?? {}, + }; + return html`${keyed(config.name, renderButton(data, menu))}`; + }, +} satisfies Record>; diff --git a/blocksuite/affine/components/src/context-menu/dynamic.ts b/blocksuite/affine/components/src/context-menu/dynamic.ts new file mode 100644 index 0000000000..b203387643 --- /dev/null +++ b/blocksuite/affine/components/src/context-menu/dynamic.ts @@ -0,0 +1,15 @@ +import { html, type TemplateResult } from 'lit'; + +import type { MenuConfig } from './menu.js'; +import type { MenuItemRender } from './types.js'; + +export const menuDynamicItems = { + dynamic: (config: () => MenuConfig[]) => menu => { + const items = menu.renderItems(config()); + if (!items.length) { + return; + } + const result: TemplateResult = html`${items}`; + return result; + }, +} satisfies Record>; diff --git a/blocksuite/affine/components/src/context-menu/focusable.ts b/blocksuite/affine/components/src/context-menu/focusable.ts new file mode 100644 index 0000000000..447f0715c0 --- /dev/null +++ b/blocksuite/affine/components/src/context-menu/focusable.ts @@ -0,0 +1,18 @@ +import { computed } from '@preact/signals-core'; + +import { MenuItem } from './item.js'; + +export abstract class MenuFocusable extends MenuItem { + isFocused$ = computed(() => this.menu.currentFocused$.value === this); + + override connectedCallback() { + super.connectedCallback(); + this.dataset.focusable = 'true'; + } + + override focus() { + this.menu.focusTo(this); + } + + abstract onPressEnter(): void; +} diff --git a/blocksuite/affine/components/src/context-menu/group.ts b/blocksuite/affine/components/src/context-menu/group.ts new file mode 100644 index 0000000000..0fe472c01d --- /dev/null +++ b/blocksuite/affine/components/src/context-menu/group.ts @@ -0,0 +1,35 @@ +import { unsafeCSSVarV2 } from '@blocksuite/affine-shared/theme'; +import { IS_MOBILE } from '@blocksuite/global/env'; +import { html, type TemplateResult } from 'lit'; + +import type { MenuConfig } from './menu.js'; +import type { MenuItemRender } from './types.js'; + +export const menuGroupItems = { + group: (config: { name?: string; items: MenuConfig[] }) => (menu, index) => { + const items = menu.renderItems(config.items); + if (!items.length) { + return; + } + if (IS_MOBILE) { + return html`
+ ${items} +
`; + } + const result: TemplateResult = html` ${index === 0 + ? '' + : html`
`} +
${items}
`; + return result; + }, +} satisfies Record>; diff --git a/blocksuite/affine/components/src/context-menu/index.ts b/blocksuite/affine/components/src/context-menu/index.ts new file mode 100644 index 0000000000..8f56c37066 --- /dev/null +++ b/blocksuite/affine/components/src/context-menu/index.ts @@ -0,0 +1,26 @@ +import { MenuButton, MobileMenuButton } from './button.js'; +import { MenuInput, MobileMenuInput } from './input.js'; +import { MenuComponent, MobileMenuComponent } from './menu-renderer.js'; +import { MenuSubMenu, MobileSubMenu } from './sub-menu.js'; + +export * from './button.js'; +export * from './focusable.js'; +export * from './group.js'; +export * from './input.js'; +export * from './item.js'; +export * from './menu.js'; +export * from './menu-renderer.js'; +export * from './sub-menu.js'; + +export function effects() { + customElements.define('affine-menu', MenuComponent); + customElements.define('mobile-menu', MobileMenuComponent); + customElements.define('affine-menu-button', MenuButton); + customElements.define('mobile-menu-button', MobileMenuButton); + customElements.define('affine-menu-input', MenuInput); + customElements.define('mobile-menu-input', MobileMenuInput); + customElements.define('affine-menu-sub-menu', MenuSubMenu); + customElements.define('mobile-sub-menu', MobileSubMenu); +} + +export * from './types.js'; diff --git a/blocksuite/affine/components/src/context-menu/input.ts b/blocksuite/affine/components/src/context-menu/input.ts new file mode 100644 index 0000000000..dfe47cb56f --- /dev/null +++ b/blocksuite/affine/components/src/context-menu/input.ts @@ -0,0 +1,252 @@ +import { unsafeCSSVarV2 } from '@blocksuite/affine-shared/theme'; +import { IS_MOBILE } from '@blocksuite/global/env'; +import { cssVarV2 } from '@toeverything/theme/v2'; +import { css, html, type TemplateResult } from 'lit'; +import { property, query } from 'lit/decorators.js'; +import { classMap } from 'lit/directives/class-map.js'; +import { styleMap } from 'lit/directives/style-map.js'; +import type { StyleInfo } from 'lit-html/directives/style-map.js'; + +import { MenuFocusable } from './focusable.js'; +import type { Menu } from './menu.js'; +import type { MenuItemRender } from './types.js'; + +export type MenuInputData = { + placeholder?: string; + initialValue?: string; + class?: string; + onComplete?: (value: string) => void; + onChange?: (value: string) => void; + disableAutoFocus?: boolean; +}; + +export class MenuInput extends MenuFocusable { + static override styles = css` + .affine-menu-input { + flex: 1; + outline: none; + border-radius: 4px; + font-size: 14px; + line-height: 22px; + padding: 4px 6px; + border: 1px solid var(--affine-border-color); + width: 100%; + color: ${unsafeCSSVarV2('text/primary')}; + background-color: transparent; + } + + .affine-menu-input.focused { + border: 1px solid ${unsafeCSSVarV2('layer/insideBorder/primaryBorder')}; + } + + .affine-menu-input:focus { + border: 1px solid ${unsafeCSSVarV2('layer/insideBorder/primaryBorder')}; + box-shadow: 0px 0px 0px 2px rgba(28, 158, 228, 0.3); + } + `; + + private onCompositionEnd = () => { + this.data.onChange?.(this.inputRef.value); + }; + + private onInput = (e: InputEvent) => { + e.stopPropagation(); + if (e.isComposing) return; + this.data.onChange?.(this.inputRef.value); + }; + + private onKeydown = (e: KeyboardEvent) => { + e.stopPropagation(); + if (e.isComposing) return; + if (e.key === 'Escape') { + this.complete(); + this.inputRef.blur(); + this.menu.focusTo(this); + return; + } + if (e.key === 'Enter') { + this.complete(); + this.menu.close(); + return; + } + }; + + private stopPropagation = (e: Event) => { + e.stopPropagation(); + }; + + complete() { + this.data.onComplete?.(this.inputRef.value); + } + + override connectedCallback() { + super.connectedCallback(); + this.disposables.addFromEvent(this, 'click', e => { + e.stopPropagation(); + }); + this.disposables.addFromEvent(this, 'mouseenter', () => { + this.menu.closeSubMenu(); + }); + requestAnimationFrame(() => { + requestAnimationFrame(() => { + this.inputRef.select(); + }); + }); + } + + override onPressEnter() { + this.inputRef.focus(); + } + + protected override render(): unknown { + const classString = classMap({ + [this.data.class ?? '']: true, + 'affine-menu-input': true, + focused: this.isFocused$.value, + }); + + return html``; + } + + @property({ attribute: false }) + accessor data!: MenuInputData; + + @query('input') + accessor inputRef!: HTMLInputElement; +} + +export class MobileMenuInput extends MenuFocusable { + static override styles = css` + .mobile-menu-input { + flex: 1; + outline: none; + font-size: 17px; + line-height: 22px; + border: none; + width: 100%; + color: ${unsafeCSSVarV2('text/primary')}; + } + `; + + private onCompositionEnd = () => { + this.data.onChange?.(this.inputRef.value); + }; + + private onInput = (e: InputEvent) => { + e.stopPropagation(); + if (e.isComposing) return; + this.data.onChange?.(this.inputRef.value); + }; + + private stopPropagation = (e: Event) => { + e.stopPropagation(); + }; + + complete() { + this.data.onComplete?.(this.inputRef.value); + } + + override onPressEnter() { + this.inputRef.focus(); + } + + protected override render(): unknown { + const classString = classMap({ + [this.data.class ?? '']: true, + 'mobile-menu-input': true, + focused: this.isFocused$.value, + }); + + return html``; + } + + @property({ attribute: false }) + accessor data!: MenuInputData; + + @query('input') + accessor inputRef!: HTMLInputElement; +} + +const renderInput = (data: MenuInputData, menu: Menu) => { + if (IS_MOBILE) { + return html` `; + } + return html` `; +}; +export const menuInputItems = { + input: + (config: { + placeholder?: string; + initialValue?: string; + postfix?: TemplateResult; + prefix?: TemplateResult; + onComplete?: (value: string) => void; + onChange?: (value: string) => void; + class?: string; + style?: Readonly; + }) => + menu => { + if (menu.showSearch$.value) { + return; + } + const data: MenuInputData = { + placeholder: config.placeholder, + initialValue: config.initialValue, + class: config.class, + onComplete: config.onComplete, + onChange: config.onChange, + }; + const style = styleMap({ + display: 'flex', + alignItems: 'center', + ...(IS_MOBILE + ? { + borderRadius: '12px', + backgroundColor: cssVarV2('layer/background/primary'), + padding: '12px', + gap: '8px', + } + : { + marginBottom: '8px', + gap: '4px', + }), + ...config.style, + }); + return html` +
+ ${config.prefix} ${renderInput(data, menu)} ${config.postfix} +
+ `; + }, +} satisfies Record>; diff --git a/blocksuite/affine/components/src/context-menu/item.ts b/blocksuite/affine/components/src/context-menu/item.ts new file mode 100644 index 0000000000..cb7e62cc08 --- /dev/null +++ b/blocksuite/affine/components/src/context-menu/item.ts @@ -0,0 +1,12 @@ +import { ShadowlessElement } from '@blocksuite/block-std'; +import { SignalWatcher, WithDisposable } from '@blocksuite/global/utils'; +import { property } from 'lit/decorators.js'; + +import type { Menu } from './menu.js'; + +export abstract class MenuItem extends SignalWatcher( + WithDisposable(ShadowlessElement) +) { + @property({ attribute: false }) + accessor menu!: Menu; +} diff --git a/blocksuite/affine/components/src/context-menu/menu-renderer.ts b/blocksuite/affine/components/src/context-menu/menu-renderer.ts new file mode 100644 index 0000000000..1677e258a3 --- /dev/null +++ b/blocksuite/affine/components/src/context-menu/menu-renderer.ts @@ -0,0 +1,571 @@ +import { unsafeCSSVar, unsafeCSSVarV2 } from '@blocksuite/affine-shared/theme'; +import { ShadowlessElement } from '@blocksuite/block-std'; +import { IS_MOBILE } from '@blocksuite/global/env'; +import { SignalWatcher, WithDisposable } from '@blocksuite/global/utils'; +import { + ArrowLeftBigIcon, + ArrowLeftSmallIcon, + CloseIcon, + SearchIcon, +} from '@blocksuite/icons/lit'; +import { + autoPlacement, + autoUpdate, + computePosition, + type Middleware, + offset, + type ReferenceElement, + shift, +} from '@floating-ui/dom'; +import { css, html, nothing } from 'lit'; +import { property } from 'lit/decorators.js'; +import { createRef, ref } from 'lit/directives/ref.js'; +import { styleMap } from 'lit/directives/style-map.js'; + +import type { MenuFocusable } from './focusable.js'; +import { Menu, type MenuConfig, type MenuOptions } from './menu.js'; +import type { MenuComponentInterface } from './types.js'; + +export class MenuComponent + extends SignalWatcher(WithDisposable(ShadowlessElement)) + implements MenuComponentInterface +{ + static override styles = css` + affine-menu { + font-family: var(--affine-font-family); + display: flex; + flex-direction: column; + user-select: none; + min-width: 276px; + box-shadow: ${unsafeCSSVar('overlayPanelShadow')}; + border-radius: 4px; + background-color: ${unsafeCSSVarV2('layer/background/overlayPanel')}; + padding: 8px; + position: absolute; + z-index: 999; + gap: 8px; + border: 0.5px solid ${unsafeCSSVarV2('layer/insideBorder/border')}; + color: ${unsafeCSSVarV2('text/primary')}; + } + + .affine-menu-search-container { + border-radius: 4px; + display: flex; + align-items: center; + padding: 4px 10px; + gap: 8px; + border: 1px solid ${unsafeCSSVarV2('input/border/default')}; + } + + .affine-menu-search { + flex: 1; + outline: none; + font-size: 14px; + line-height: 22px; + border: none; + background-color: transparent; + } + + .affine-menu-body { + display: flex; + flex-direction: column; + gap: 4px; + } + + .no-results { + font-size: 12px; + line-height: 20px; + color: var(--affine-text-secondary-color); + display: flex; + align-items: center; + justify-content: center; + margin-top: 8px; + } + `; + + private _clickContainer = (e: MouseEvent) => { + e.stopPropagation(); + this.focusInput(); + this.menu.closeSubMenu(); + }; + + private searchRef = createRef(); + + override firstUpdated() { + const input = this.searchRef.value; + if (input) { + requestAnimationFrame(() => { + this.focusInput(); + }); + const length = input.value.length; + input.setSelectionRange(length, length); + this.disposables.addFromEvent(input, 'keydown', e => { + e.stopPropagation(); + if (e.key === 'Escape') { + this.menu.close(); + return; + } + const onBack = this.menu.options.title?.onBack; + if (e.key === 'Backspace' && onBack && !this.menu.showSearch$.value) { + this.menu.close(); + onBack(this.menu); + return; + } + if (e.key === 'Enter' && !e.isComposing) { + this.menu.pressEnter(); + return; + } + if (e.key === 'ArrowUp') { + e.preventDefault(); + this.menu.focusPrev(); + return; + } + if (e.key === 'ArrowDown') { + e.preventDefault(); + this.menu.focusNext(); + return; + } + }); + + this.disposables.addFromEvent(input, 'copy', e => { + e.stopPropagation(); + }); + this.disposables.addFromEvent(input, 'cut', e => { + e.stopPropagation(); + }); + this.disposables.addFromEvent(this, 'click', this._clickContainer); + } + } + + focusInput() { + this.searchRef.value?.focus(); + } + + focusTo(ele?: MenuFocusable) { + this.menu.setFocusOnly(ele); + this.focusInput(); + } + + getFirstFocusableElement(): HTMLElement | null { + return this.querySelector('[data-focusable="true"]'); + } + + getFocusableElements(): HTMLElement[] { + return Array.from(this.querySelectorAll('[data-focusable="true"]')); + } + + override render() { + const result = this.menu.renderItems(this.menu.options.items); + return html` + ${this.renderTitle()} ${this.renderSearch()} +
+ ${result.length === 0 && this.menu.enableSearch + ? html`
No Results
` + : ''} + ${result} +
+ `; + } + + renderSearch() { + const config = this.menu.options.search; + const showSearch = this.menu.showSearch$.value || config?.placeholder; + const searchStyle = styleMap({ + opacity: showSearch ? '1' : '0', + height: showSearch ? undefined : '0', + overflow: showSearch ? undefined : 'hidden', + position: showSearch ? undefined : 'absolute', + pointerEvents: showSearch ? undefined : 'none', + }); + return html`
+
+ ${SearchIcon()} +
+ +
`; + } + + renderTitle() { + const title = this.menu.options.title; + if (!title) { + return; + } + return html` +
+ ${title.onBack + ? html`
+ ${ArrowLeftBigIcon()} +
` + : nothing} +
+ ${title.text} +
+ ${title.postfix?.()} + ${title.onClose + ? html`
+ ${CloseIcon()} +
` + : nothing} +
+ `; + } + + @property({ attribute: false }) + accessor menu!: Menu; +} + +declare global { + interface HTMLElementTagNameMap { + 'affine-menu': MenuComponent; + } +} + +export class MobileMenuComponent + extends SignalWatcher(WithDisposable(ShadowlessElement)) + implements MenuComponentInterface +{ + static override styles = css` + mobile-menu { + height: 100%; + font-family: var(--affine-font-family); + display: flex; + flex-direction: column; + user-select: none; + width: 100%; + background-color: ${unsafeCSSVarV2('layer/background/secondary')}; + padding: calc(8px + env(safe-area-inset-top, 0px)) 8px + calc(8px + env(safe-area-inset-bottom, 0px)) 8px; + position: absolute; + z-index: 999; + color: ${unsafeCSSVarV2('text/primary')}; + } + + .mobile-menu-body { + display: flex; + flex-direction: column; + padding: 24px 16px; + gap: 16px; + flex: 1; + overflow-y: auto; + } + `; + + onClose = () => { + const close = this.menu.options.title?.onClose; + if (close) { + close(); + } else { + this.menu.close(); + } + }; + + focusTo(ele?: MenuFocusable) { + this.menu.setFocusOnly(ele); + } + + getFirstFocusableElement(): HTMLElement | null { + return this.querySelector('[data-focusable="true"]'); + } + + getFocusableElements(): HTMLElement[] { + return Array.from(this.querySelectorAll('[data-focusable="true"]')); + } + + override render() { + const result = this.menu.renderItems(this.menu.options.items); + return html` + ${this.renderTitle()} +
${result}
+ `; + } + + renderTitle() { + const title = this.menu.options.title; + return html` +
+
+ ${title?.onBack + ? html`
+ ${ArrowLeftSmallIcon()} +
` + : nothing} +
+
+ ${title?.text} +
+
+ Done +
+
+ `; + } + + @property({ attribute: false }) + accessor menu!: Menu; +} + +declare global { + interface HTMLElementTagNameMap { + 'affine-menu-mobile': MobileMenuComponent; + } +} + +export const getDefaultModalRoot = (ele: HTMLElement) => { + const host: HTMLElement | null = + ele.closest('editor-host') ?? ele.closest('.data-view-popup-container'); + if (host) { + return host; + } + return document.body; +}; +export const createModal = (container: HTMLElement = document.body) => { + const div = document.createElement('div'); + div.style.pointerEvents = 'auto'; + div.style.position = 'absolute'; + div.style.left = '0'; + div.style.top = '0'; + div.style.width = '100%'; + div.style.height = '100%'; + div.style.zIndex = '1001'; + div.style.fontFamily = 'var(--affine-font-family)'; + container.append(div); + return div; +}; +export type PopupTarget = { + targetRect: ReferenceElement; + root: HTMLElement; + popupStart: () => () => void; +}; +export const popupTargetFromElement = (element: HTMLElement): PopupTarget => { + let rect = element.getBoundingClientRect(); + let count = 0; + let isActive = false; + return { + targetRect: { + getBoundingClientRect: () => { + if (element.isConnected) { + return (rect = element.getBoundingClientRect()); + } + return rect; + }, + }, + root: getDefaultModalRoot(element), + popupStart: () => { + if (!count) { + isActive = element.classList.contains('active'); + if (!isActive) { + element.classList.add('active'); + } + } + count++; + return () => { + count--; + if (!count && !isActive) { + element.classList.remove('active'); + } + }; + }, + }; +}; +export const createPopup = ( + target: PopupTarget, + content: HTMLElement, + options?: { + onClose?: () => void; + middleware?: Array; + container?: HTMLElement; + } +) => { + const close = () => { + modal.remove(); + options?.onClose?.(); + }; + const modal = createModal(target.root); + autoUpdate(target.targetRect, content, () => { + computePosition(target.targetRect, content, { + middleware: options?.middleware ?? [shift({ crossAxis: true })], + }) + .then(({ x, y }) => { + Object.assign(content.style, { + left: `${x}px`, + top: `${y}px`, + }); + }) + .catch(console.error); + }); + modal.append(content); + + modal.onpointerdown = ev => { + if (ev.target === modal) { + close(); + } + }; + + modal.onmousedown = ev => { + if (ev.target === modal) { + close(); + } + }; + + modal.oncontextmenu = ev => { + ev.preventDefault(); + if (ev.target === modal) { + close(); + } + }; + + return close; +}; + +export type MenuHandler = { + close: () => void; + menu: Menu; + reopen: () => void; +}; + +const popMobileMenu = (options: MenuOptions): MenuHandler => { + const model = createModal(document.body); + const menu = new Menu({ + ...options, + onClose: () => { + closePopup(); + }, + }); + model.append(menu.menuElement); + const closePopup = () => { + model.remove(); + options.onClose?.(); + }; + return { + close: () => { + menu.close(); + }, + menu, + reopen: () => { + menu.close(); + popMobileMenu(options); + }, + }; +}; + +export const popMenu = ( + target: PopupTarget, + props: { + options: MenuOptions; + middleware?: Array; + container?: HTMLElement; + } +): MenuHandler => { + if (IS_MOBILE) { + return popMobileMenu(props.options); + } + const popupEnd = target.popupStart(); + const onClose = () => { + props.options.onClose?.(); + popupEnd(); + closePopup(); + }; + const menu = new Menu({ + ...props.options, + onClose: onClose, + }); + const closePopup = createPopup(target, menu.menuElement, { + onClose: () => { + menu.close(); + }, + middleware: props.middleware ?? [ + autoPlacement({ + allowedPlacements: [ + 'bottom-start', + 'bottom-end', + 'top-start', + 'top-end', + ], + }), + offset(4), + ], + container: props.container, + }); + return { + close: closePopup, + menu, + reopen: () => { + popMenu(target, props); + }, + }; +}; +export const popFilterableSimpleMenu = ( + target: PopupTarget, + options: MenuConfig[], + onClose?: () => void +) => { + popMenu(target, { + options: { + items: options, + onClose, + }, + }); +}; diff --git a/blocksuite/affine/components/src/context-menu/menu.ts b/blocksuite/affine/components/src/context-menu/menu.ts new file mode 100644 index 0000000000..e968ce1601 --- /dev/null +++ b/blocksuite/affine/components/src/context-menu/menu.ts @@ -0,0 +1,180 @@ +import { IS_MOBILE } from '@blocksuite/global/env'; +import { computed, signal } from '@preact/signals-core'; +import type { TemplateResult } from 'lit'; + +import { menuButtonItems } from './button.js'; +import { menuDynamicItems } from './dynamic.js'; +import { MenuFocusable } from './focusable.js'; +import { menuGroupItems } from './group.js'; +import { menuInputItems } from './input.js'; +// eslint-disable-next-line +import { MenuComponent, MobileMenuComponent } from './menu-renderer.js'; +import { subMenuItems } from './sub-menu.js'; +import type { MenuComponentInterface, MenuItemRender } from './types.js'; + +export const menu = { + ...menuButtonItems, + ...subMenuItems, + ...menuInputItems, + ...menuGroupItems, + ...menuDynamicItems, +} satisfies Record>; +export type MenuConfig = ( + menu: Menu, + index: number +) => TemplateResult | undefined; + +export type MenuOptions = { + onComplete?: () => void; + onClose?: () => void; + title?: { + text: string; + onBack?: (menu: Menu) => void; + onClose?: () => void; + postfix?: () => TemplateResult; + }; + search?: { + placeholder?: string; + }; + items: MenuConfig[]; +}; + +// Global menu open listener type +type MenuOpenListener = (menu: Menu) => (() => void) | void; + +// Global menu open listeners +const menuOpenListeners = new Set(); + +// Add global menu open listener +export function onMenuOpen(listener: MenuOpenListener) { + menuOpenListeners.add(listener); + // Return cleanup function + return () => { + menuOpenListeners.delete(listener); + }; +} + +export class Menu { + private _cleanupFns: Array<() => void> = []; + + private _currentFocused$ = signal(); + + private _subMenu$ = signal(); + + closed = false; + + readonly currentFocused$ = computed(() => this._currentFocused$.value); + + menuElement: MenuComponentInterface; + + searchName$ = signal(''); + + searchResult$ = computed(() => { + return this.renderItems(this.options.items); + }); + + showSearch$ = computed(() => { + return this.enableSearch && this.searchName$.value.length > 0; + }); + + get enableSearch() { + return true; + } + + constructor(public options: MenuOptions) { + this.menuElement = IS_MOBILE + ? new MobileMenuComponent() + : new MenuComponent(); + this.menuElement.menu = this; + + // Call global menu open listeners + menuOpenListeners.forEach(listener => { + const cleanup = listener(this); + if (cleanup) { + this._cleanupFns.push(cleanup); + } + }); + } + + close() { + if (this.closed) { + return; + } + this.closed = true; + // Execute cleanup functions + this._cleanupFns.forEach(cleanup => cleanup()); + this._cleanupFns = []; + + this.menuElement.remove(); + this.options.onClose?.(); + } + + closeSubMenu() { + this._subMenu$.value?.close(); + this._subMenu$.value = undefined; + } + + focusNext() { + if (!this._currentFocused$.value) { + const ele = this.menuElement.getFirstFocusableElement(); + if (ele instanceof MenuFocusable) { + ele.focus(); + } + return; + } + const list = this.menuElement + .getFocusableElements() + .filter(ele => ele instanceof MenuFocusable); + const index = list.indexOf(this._currentFocused$.value); + list[index + 1]?.focus(); + } + + focusPrev() { + if (!this._currentFocused$.value) { + return; + } + const list = this.menuElement + .getFocusableElements() + .filter(ele => ele instanceof MenuFocusable); + const index = list.indexOf(this._currentFocused$.value); + if (index === 0) { + this._currentFocused$.value = undefined; + return; + } + list[index - 1]?.focus(); + } + + focusTo(ele?: MenuFocusable) { + this.menuElement.focusTo(ele); + } + + openSubMenu(menu: Menu) { + this.closeSubMenu(); + this._subMenu$.value = menu; + } + + pressEnter() { + this._currentFocused$.value?.onPressEnter(); + } + + renderItems(items: MenuConfig[]) { + const result = []; + // eslint-disable-next-line @typescript-eslint/prefer-for-of + for (let i = 0; i < items.length; i++) { + const item = items[i]; + const template = item(this, result.length); + if (template != null) { + result.push(template); + } + } + return result; + } + + search(name: string) { + return name.toLowerCase().includes(this.searchName$.value.toLowerCase()); + } + + setFocusOnly(ele?: MenuFocusable) { + this._currentFocused$.value = ele; + } +} diff --git a/blocksuite/affine/components/src/context-menu/sub-menu.ts b/blocksuite/affine/components/src/context-menu/sub-menu.ts new file mode 100644 index 0000000000..d74ce7d52f --- /dev/null +++ b/blocksuite/affine/components/src/context-menu/sub-menu.ts @@ -0,0 +1,194 @@ +import { IS_MOBILE } from '@blocksuite/global/env'; +import { ArrowRightSmallIcon } from '@blocksuite/icons/lit'; +import { + autoPlacement, + autoUpdate, + computePosition, + offset, +} from '@floating-ui/dom'; +import { html, nothing, type TemplateResult } from 'lit'; +import { property } from 'lit/decorators.js'; +import { classMap } from 'lit/directives/class-map.js'; + +import { MenuFocusable } from './focusable.js'; +import { Menu, type MenuOptions } from './menu.js'; +import { popMenu, popupTargetFromElement } from './menu-renderer.js'; +import type { MenuItemRender } from './types.js'; + +export type MenuSubMenuData = { + content: () => TemplateResult; + options: MenuOptions; + select?: () => void; + class?: string; +}; +export const subMenuOffset = offset({ + mainAxis: 16, + crossAxis: -8.5, +}); +export const subMenuPlacements = autoPlacement({ + allowedPlacements: ['right-start', 'left-start', 'right-end', 'left-end'], +}); +export const subMenuMiddleware = [subMenuOffset, subMenuPlacements]; + +export class MenuSubMenu extends MenuFocusable { + createTime = 0; + + override connectedCallback() { + super.connectedCallback(); + this.createTime = Date.now(); + this.disposables.addFromEvent(this, 'mouseenter', this.onMouseEnter); + this.disposables.addFromEvent(this, 'click', e => { + e.preventDefault(); + e.stopPropagation(); + if (this.data.select) { + this.data.select(); + this.menu.close(); + } else { + this.openSubMenu(); + } + }); + } + + onMouseEnter() { + if (Date.now() - this.createTime > 100) { + this.openSubMenu(); + } + } + + override onPressEnter() { + this.onMouseEnter(); + } + + openSubMenu() { + const focus = this.menu.currentFocused$.value; + const menu = new Menu({ + ...this.data.options, + onComplete: () => { + this.menu.close(); + }, + onClose: () => { + menu.menuElement.remove(); + this.menu.focusTo(focus); + this.data.options.onClose?.(); + unsub(); + }, + }); + this.menu.menuElement.parentElement?.append(menu.menuElement); + const unsub = autoUpdate(this, menu.menuElement, () => { + computePosition(this, menu.menuElement, { + middleware: subMenuMiddleware, + }) + .then(({ x, y }) => { + menu.menuElement.style.left = `${x}px`; + menu.menuElement.style.top = `${y}px`; + }) + .catch(err => console.error(err)); + }); + this.menu.openSubMenu(menu); + } + + protected override render(): unknown { + const classString = classMap({ + [this.data.class ?? '']: true, + 'affine-menu-button': true, + focused: this.isFocused$.value, + }); + return html`
${this.data.content()}
`; + } + + @property({ attribute: false }) + accessor data!: MenuSubMenuData; +} + +export class MobileSubMenu extends MenuFocusable { + override connectedCallback() { + super.connectedCallback(); + this.disposables.addFromEvent(this, 'click', e => { + e.preventDefault(); + e.stopPropagation(); + this.openSubMenu(); + }); + } + + onMouseEnter() { + this.openSubMenu(); + } + + override onPressEnter() { + this.onMouseEnter(); + } + + openSubMenu() { + const { menu } = popMenu(popupTargetFromElement(this), { + options: { + ...this.data.options, + onComplete: () => { + this.menu.close(); + }, + onClose: () => { + menu.menuElement.remove(); + this.data.options.onClose?.(); + }, + }, + }); + this.menu.openSubMenu(menu); + } + + protected override render(): unknown { + const classString = classMap({ + [this.data.class ?? '']: true, + 'mobile-menu-button': true, + focused: this.isFocused$.value, + }); + return html`
${this.data.content()}
`; + } + + @property({ attribute: false }) + accessor data!: MenuSubMenuData; +} + +export const renderSubMenu = (data: MenuSubMenuData, menu: Menu) => { + if (IS_MOBILE) { + return html` `; + } + return html` `; +}; + +export const subMenuItems = { + subMenu: + (config: { + name: string; + label?: () => TemplateResult; + select?: () => void; + isSelected?: boolean; + postfix?: TemplateResult; + prefix?: TemplateResult; + class?: string; + options: MenuOptions; + disableArrow?: boolean; + hide?: () => boolean; + }) => + menu => { + if (config.hide?.() || !menu.search(config.name)) { + return; + } + const data: MenuSubMenuData = { + content: () => + html`${config.prefix} +
+ ${config.label?.() ?? config.name} +
+ ${config.postfix} + ${config.disableArrow ? nothing : ArrowRightSmallIcon()} `, + class: config.class, + options: config.options, + }; + return renderSubMenu(data, menu); + }, +} satisfies Record>; diff --git a/blocksuite/affine/components/src/context-menu/types.ts b/blocksuite/affine/components/src/context-menu/types.ts new file mode 100644 index 0000000000..1d473746ac --- /dev/null +++ b/blocksuite/affine/components/src/context-menu/types.ts @@ -0,0 +1,21 @@ +import type { ClassInfo } from 'lit-html/directives/class-map.js'; + +import type { MenuFocusable } from './focusable.js'; +import type { Menu, MenuConfig } from './menu.js'; + +export type MenuClass = ClassInfo & { + 'delete-item'?: boolean; +}; +export type MenuItemRender = (props: Props) => MenuConfig; + +export interface MenuComponentInterface extends HTMLElement { + menu: Menu; + + remove(): void; + + getFocusableElements(): HTMLElement[]; + + getFirstFocusableElement(): HTMLElement | null; + + focusTo(ele?: MenuFocusable): void; +} diff --git a/blocksuite/affine/components/src/date-picker/date-picker.ts b/blocksuite/affine/components/src/date-picker/date-picker.ts new file mode 100644 index 0000000000..ef10c8c5d8 --- /dev/null +++ b/blocksuite/affine/components/src/date-picker/date-picker.ts @@ -0,0 +1,625 @@ +import { WithDisposable } from '@blocksuite/global/utils'; +import { isSameDay, isSameMonth, isToday } from 'date-fns'; +import { + html, + LitElement, + nothing, + type PropertyValues, + type TemplateResult, +} from 'lit'; +import { property } from 'lit/decorators.js'; +import { classMap } from 'lit/directives/class-map.js'; +import { styleMap } from 'lit/directives/style-map.js'; + +import { arrowLeftIcon } from './icons.js'; +import { datePickerStyle } from './style.js'; +import { clamp, getMonthMatrix, toDate } from './utils.js'; + +const days = ['Su', 'Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa']; +const months = [ + 'Jan', + 'Feb', + 'Mar', + 'Apr', + 'May', + 'Jun', + 'Jul', + 'Aug', + 'Sep', + 'Oct', + 'Nov', + 'Dec', +]; + +export interface DateCell { + date: Date; + label: string; + isToday: boolean; + notCurrentMonth: boolean; + selected?: boolean; + tabIndex?: number; +} + +type NavActionArg = { + action: () => void; + disable?: boolean; +}; + +/** + * Date picker + */ +export class DatePicker extends WithDisposable(LitElement) { + static override styles = datePickerStyle; + + /** current active month */ + private _cursor = new Date(); + + private _maxYear = 2099; + + private _minYear = 1970; + + get _cardStyle() { + return { + '--cell-size': `${this.size}px`, + '--gap-h': `${this.gapH}px`, + '--gap-v': `${this.gapV}px`, + 'min-width': `${this.cardWidth}px`, + 'min-height': `${this.cardHeight}px`, + padding: `${this.padding}px`, + }; + } + + get cardHeight() { + const rowNum = 7; + return this.size * rowNum + this.padding * 2 + this.gapV * (rowNum - 1) - 2; + } + + get cardWidth() { + const colNum = 7; + return this.size * colNum + this.padding * 2 + this.gapH * (colNum - 1); + } + + get date() { + return this._cursor.getDate(); + } + + get day() { + return this._cursor.getDay(); + } + + get dayLabel() { + return days[this.day]; + } + + get minHeight() { + const rowNum = this._matrix.length; + return this.size * rowNum + this.padding * 2 + this.gapV * (rowNum - 1) - 2; + } + + get month() { + return this._cursor.getMonth(); + } + + get monthLabel() { + return months[this.month]; + } + + get year() { + return this._cursor.getFullYear(); + } + + get yearLabel() { + return this.year; + } + + /** Cell */ + private _cellRenderer(cell: DateCell) { + const classes = classMap({ + interactive: true, + 'date-cell': true, + 'date-cell--today': cell.isToday, + 'date-cell--not-curr-month': cell.notCurrentMonth, + 'date-cell--selected': !!cell.selected, + }); + const dateRaw = `${cell.date.getFullYear()}-${cell.date.getMonth()}-${cell.date.getDate()}(${cell.date.getDay()})`; + return html``; + } + + private _dateContent() { + return html`
+
+ + + +
+ + ${this._navAction( + () => this._moveMonth(-1), + () => this._moveMonth(1), + html`` + )} +
+ ${this._dayHeaderRenderer()} +
+ ${this._matrix.map( + week => + html`
+ ${week.map(cell => this._cellRenderer(cell))} +
` + )} +
+ ${this.onClear + ? html`` + : nothing}`; + } + + /** Week header */ + private _dayHeaderRenderer() { + return html`
+ ${days.map(day => html`
${day}
`)} +
`; + } + + private _getMatrix() { + this._matrix = getMonthMatrix(this._cursor).map(row => { + return row.map(date => { + const tabIndex = isSameDay(date, this._cursor) ? 0 : -1; + return { + date, + label: date.getDate().toString(), + isToday: isToday(date), + notCurrentMonth: !isSameMonth(date, this._cursor), + selected: this.value ? isSameDay(date, toDate(this.value)) : false, + tabIndex, + } satisfies DateCell; + }); + }); + } + + private _getYearMatrix() { + // every decade has 12 years + const no = Math.floor((this._yearCursor - this._minYear) / 12); + const decade = no * 12; + const start = this._minYear + decade; + const end = start + 12; + this._yearMatrix = Array.from( + { length: end - start }, + (_, i) => start + i + ).filter(v => v >= this._minYear && v <= this._maxYear); + } + + private _modeDecade(offset: number) { + this._yearCursor = clamp( + this._minYear, + this._maxYear, + this._yearCursor + offset + ); + this._getYearMatrix(); + } + + private _monthContent() { + return html`
+ + + ${this._navAction( + { + action: () => this._monthPickYearCursor--, + disable: this._monthPickYearCursor <= this._minYear, + }, + { + action: () => this._monthPickYearCursor++, + disable: this._monthPickYearCursor >= this._maxYear, + } + )} +
+
+ ${months.map((month, index) => { + const isActive = this.value + ? isSameMonth( + this.value, + new Date(this._monthPickYearCursor, index, 1) + ) + : false; + const classes = classMap({ + 'month-cell': true, + interactive: true, + active: isActive, + }); + return html``; + })} +
`; + } + + private _moveMonth(offset: number) { + this._cursor.setMonth(this._cursor.getMonth() + offset); + this._getMatrix(); + } + + /** Actions */ + private _navAction( + prev: NavActionArg | NavActionArg['action'], + curr: NavActionArg | NavActionArg['action'], + slot?: TemplateResult + ) { + const onPrev = typeof prev === 'function' ? prev : prev.action; + const onNext = typeof curr === 'function' ? curr : curr.action; + const prevDisable = typeof prev === 'function' ? false : prev.disable; + const nextDisable = typeof curr === 'function' ? false : curr.disable; + const classes = classMap({ + 'date-picker-header__action': true, + 'with-slot': !!slot, + }); + return html`
+ + ${slot ?? nothing} + +
`; + } + + private _onChange(date: Date, emit = true) { + this._cursor = date; + this.value = date.getTime(); + this._getMatrix(); + emit && this.onChange?.(date); + } + + private _switchMode(map: Record) { + return (map[this._mode] as T) ?? nothing; + } + + private _yearContent() { + const startYear = this._yearMatrix[0]; + const endYear = this._yearMatrix[this._yearMatrix.length - 1]; + return html`
+ + ${this._navAction( + { + action: () => this._modeDecade(-12), + disable: startYear <= this._minYear, + }, + { + action: () => this._modeDecade(12), + disable: endYear >= this._maxYear, + } + )} +
+
+ ${this._yearMatrix.map(year => { + const isActive = year === this._cursor.getFullYear(); + const classes = classMap({ + 'year-cell': true, + interactive: true, + active: isActive, + }); + return html``; + })} +
`; + } + + closeMonthSelector() { + this._mode = 'date'; + } + + closeYearSelector() { + this._mode = 'date'; + } + + override connectedCallback(): void { + super.connectedCallback(); + if (this.value) this._cursor = toDate(this.value); + this._getMatrix(); + } + + override firstUpdated(): void { + this._disposables.addFromEvent( + this, + 'keydown', + e => { + e.stopPropagation(); + const directions = new Set([ + 'ArrowLeft', + 'ArrowRight', + 'ArrowUp', + 'ArrowDown', + ]); + if (directions.has(e.key) && this.isDateCellFocused()) { + e.preventDefault(); + + if (e.key === 'ArrowLeft') { + this._cursor.setDate(this._cursor.getDate() - 1); + } else if (e.key === 'ArrowRight') { + this._cursor.setDate(this._cursor.getDate() + 1); + } else if (e.key === 'ArrowUp') { + this._cursor.setDate(this._cursor.getDate() - 7); + } else if (e.key === 'ArrowDown') { + this._cursor.setDate(this._cursor.getDate() + 7); + } + this._getMatrix(); + setTimeout(this.focusDateCell.bind(this)); + } + + if (directions.has(e.key) && this.isMonthCellFocused()) { + e.preventDefault(); + if (e.key === 'ArrowLeft') { + this._monthCursor = (this._monthCursor - 1 + 12) % 12; + } else if (e.key === 'ArrowRight') { + this._monthCursor = (this._monthCursor + 1) % 12; + } else if (e.key === 'ArrowUp') { + this._monthCursor = (this._monthCursor - 3 + 12) % 12; + } else if (e.key === 'ArrowDown') { + this._monthCursor = (this._monthCursor + 3) % 12; + } + setTimeout(this.focusMonthCell.bind(this)); + } + + if (directions.has(e.key) && this.isYearCellFocused()) { + e.preventDefault(); + if (e.key === 'ArrowLeft') { + this._modeDecade(-1); + } else if (e.key === 'ArrowRight') { + this._modeDecade(1); + } else if (e.key === 'ArrowUp') { + this._modeDecade(-3); + } else if (e.key === 'ArrowDown') { + this._modeDecade(3); + } + setTimeout(this.focusYearCell.bind(this)); + } + + if (e.key === 'Tab') { + setTimeout(() => { + const focused = this.shadowRoot?.activeElement as HTMLElement; + const firstEl = this.shadowRoot?.querySelector('button'); + + // check if focus the last element, then focus the first element + if (!e.shiftKey && !focused) firstEl?.focus(); + // check if focused element is inside current date-picker + if (e.shiftKey && !this.shadowRoot?.contains(focused)) + this.focusDateCell(); + }); + } + + if (e.key === 'Escape') { + this.onEscape?.(toDate(this.value)); + } + }, + true + ); + } + + /** + * Focus on date-cell + */ + focusDateCell() { + const lastEl = this.shadowRoot?.querySelector( + 'button.date-cell[tabindex="0"]' + ) as HTMLElement; + lastEl?.focus(); + } + + focusMonthCell() { + const lastEl = this.shadowRoot?.querySelector( + 'button.month-cell[tabindex="0"]' + ) as HTMLElement; + lastEl?.focus(); + } + + focusYearCell() { + const lastEl = this.shadowRoot?.querySelector( + 'button.year-cell[tabindex="0"]' + ) as HTMLElement; + lastEl?.focus(); + } + + /** + * check if date-cell is focused + * @returns + */ + isDateCellFocused() { + const focused = this.shadowRoot?.activeElement as HTMLElement; + return focused?.classList.contains('date-cell'); + } + + isMonthCellFocused() { + const focused = this.shadowRoot?.activeElement as HTMLElement; + return focused?.classList.contains('month-cell'); + } + + isYearCellFocused() { + const focused = this.shadowRoot?.activeElement as HTMLElement; + return focused?.classList.contains('year-cell'); + } + + openMonthSelector() { + this._monthCursor = this.month; + this._monthPickYearCursor = this.year; + this._mode = 'month'; + } + + openYearSelector() { + this._yearCursor = clamp(this._minYear, this._maxYear, this.year); + this._mode = 'year'; + this._getYearMatrix(); + } + + override render() { + const classes = classMap({ + 'date-picker': true, + [`date-picker--mode-${this._mode}`]: true, + popup: this.popup, + }); + const wrapperStyle = styleMap({ + 'min-height': `${this.minHeight}px`, + }); + return html`
+
+ ${this._switchMode({ + date: this._dateContent(), + month: this._monthContent(), + year: this._yearContent(), + })} +
+
`; + } + + toggleMonthSelector() { + if (this._mode === 'month') this.closeMonthSelector(); + else this.openMonthSelector(); + } + + toggleYearSelector() { + if (this._mode === 'year') this.closeYearSelector(); + else this.openYearSelector(); + } + + override updated(_changedProperties: PropertyValues): void { + if (_changedProperties.has('value')) { + // this._getMatrix(); + if (this.value) this._onChange(toDate(this.value), false); + else this._getMatrix(); + } + } + + /** date matrix */ + @property({ attribute: false }) + private accessor _matrix: DateCell[][] = []; + + @property({ attribute: false }) + private accessor _mode: 'date' | 'month' | 'year' = 'date'; + + /** web-accessibility for month select */ + @property({ attribute: false }) + private accessor _monthCursor = 0; + + @property({ attribute: false }) + private accessor _monthPickYearCursor = 0; + + @property({ attribute: false }) + private accessor _yearCursor = 0; + + @property({ attribute: false }) + private accessor _yearMatrix: number[] = []; + + /** horizontal gap between cells in px */ + @property({ type: Number }) + accessor gapH = 10; + + /** vertical gap between cells in px */ + @property({ type: Number }) + accessor gapV = 8; + + @property({ attribute: false }) + accessor onChange: ((value: Date) => void) | undefined = undefined; + + @property({ attribute: false }) + accessor onClear: (() => void) | undefined = undefined; + + @property({ attribute: false }) + accessor onEscape: ((value: Date) => void) | undefined = undefined; + + /** card padding in px */ + @property({ type: Number }) + accessor padding = 20; + + @property({ type: Boolean }) + accessor popup: boolean = false; + + /** cell size in px */ + @property({ type: Number }) + accessor size = 28; + + /** Checked date timestamp */ + @property({ type: Number }) + accessor value: number | undefined = undefined; +} + +declare global { + interface HTMLElementTagNameMap { + 'date-picker': DatePicker; + } +} diff --git a/blocksuite/affine/components/src/date-picker/icons.ts b/blocksuite/affine/components/src/date-picker/icons.ts new file mode 100644 index 0000000000..2d38606815 --- /dev/null +++ b/blocksuite/affine/components/src/date-picker/icons.ts @@ -0,0 +1,11 @@ +import { svg } from 'lit'; + +export const arrowLeftIcon = svg` + + +`; diff --git a/blocksuite/affine/components/src/date-picker/index.ts b/blocksuite/affine/components/src/date-picker/index.ts new file mode 100644 index 0000000000..2819654836 --- /dev/null +++ b/blocksuite/affine/components/src/date-picker/index.ts @@ -0,0 +1,7 @@ +import { DatePicker } from './date-picker.js'; + +export * from './date-picker.js'; + +export function effects() { + customElements.define('date-picker', DatePicker); +} diff --git a/blocksuite/affine/components/src/date-picker/style.ts b/blocksuite/affine/components/src/date-picker/style.ts new file mode 100644 index 0000000000..ab88979c79 --- /dev/null +++ b/blocksuite/affine/components/src/date-picker/style.ts @@ -0,0 +1,309 @@ +import { css } from 'lit'; + +export const datePickerStyle = css` + :host { + display: block; + } + + .date-picker { + display: flex; + flex-direction: column; + box-sizing: border-box; + gap: var(--gap-v); + font-family: var(--affine-font-family); + } + + .popup.date-picker { + background: var(--affine-background-overlay-panel-color); + border-radius: 12px; + box-shadow: var(--affine-menu-shadow); + } + + /* small action */ + + .date-picker-small-action { + display: flex; + align-items: center; + justify-content: center; + width: 20px; + height: 20px; + border-radius: 4px; + } + + .interactive.date-picker-small-action, + .interactive.action-label.today { + color: var(--affine-icon-color); + } + + .date-picker-small-action:hover { + color: var(--affine-icon-hover-color); + background: var(--affine-icon-hover-background); + } + + .date-picker-small-action.left > svg { + transform: rotate(0deg); + } + + .date-picker-small-action.right > svg { + transform: rotate(180deg); + } + + .date-picker-small-action.down > svg { + transform: rotate(-90deg); + } + + /* action-header */ + + .date-picker-header { + display: flex; + align-items: center; + justify-content: space-between; + } + + .date-picker-header__buttons { + display: flex; + } + + .date-picker-header__date { + display: flex; + align-items: center; + gap: 4px; + color: var(--affine-text-primary-color); + font-weight: 600; + padding: 2px; + border-radius: 4px; + font-size: 14px; + line-height: 22px; + } + + .date-picker-header__date > div { + padding: 0px 4px; + } + + .date-picker-header__action { + display: flex; + align-items: center; + gap: 16px; + color: var(--affine-icon-color); + } + + .date-picker-header__action.with-slot { + gap: 4px; + } + + .date-picker-header__action .action-label { + font-size: 12px; + padding: 0px 4px; + height: 20px; + border-radius: 4px; + transition: all 0.23s ease; + max-width: 100px; + } + + .date-picker-header__action .action-label > span { + display: inline-flex; + justify-content: center; + align-items: center; + width: 100%; + text-align: center; + white-space: nowrap; + overflow: hidden; + } + + /** days header */ + + .days-header { + display: flex; + gap: var(--gap-h); + } + + .days-header > div { + color: var(--affine-text-secondary-color); + font-weight: 500; + font-size: 12px; + cursor: default; + } + + /** week */ + + .date-picker-weeks { + display: flex; + flex-direction: column; + gap: var(--gap-v); + } + + .date-picker-week { + display: flex; + gap: var(--gap-h); + } + + /** cell */ + + .date-cell { + width: var(--cell-size); + height: var(--cell-size); + display: flex; + align-items: center; + justify-content: center; + user-select: none; + border-radius: 8px; + } + + .date-cell[data-date] { + font-weight: 400; + font-size: 14px; + } + + .date-cell.date-cell--not-curr-month { + opacity: 0.1; + } + + .date-cell.date-cell--today { + color: var(--affine-primary-color); + font-weight: 600; + } + + .date-cell.date-cell--selected { + background: var(--affine-primary-color); + color: var(--affine-pure-white); + font-weight: 500; + } + + /** interactive */ + + .interactive { + cursor: pointer; + /* transition: + background 0.23s ease, + color 0.23s ease; */ + user-select: none; + position: relative; + border: none; + background-color: unset; + font-family: var(--affine-font-family); + color: var(--affine-text-primary-color); + } + + /* --hover */ + + .interactive::after, + .interactive::before { + content: ''; + position: absolute; + inset: 0; + border-radius: inherit; + pointer-events: none; + transition: background 0.23s ease; + } + + .interactive::after { + opacity: 1; + background: transparent; + } + + .interactive:hover::after { + background: var(--affine-hover-color); + } + + /* --focus */ + + .interactive::before { + opacity: 0; + transition: none; + box-shadow: 0 0 0 3px var(--affine-primary-color); + } + + /* .interactive:active, */ + + .interactive:focus-visible { + outline: none; + outline: 1px solid var(--affine-primary-color); + } + + /* .interactive:active::before, */ + + .interactive:focus-visible::before { + opacity: 0.5; + } + + /** disabled */ + + .interactive[disabled] { + cursor: not-allowed; + opacity: 0.5; + } + + /** Month Select */ + + .date-picker-month { + --btn-width: 36px; + } + + .date-picker-year { + --btn-width: 46px; + } + + .date-picker-month, + .date-picker-year { + display: grid; + grid-template-columns: repeat(3, var(--btn-width)); + gap: 18px 32px; + justify-content: space-between; + } + + .date-picker-month button, + .date-picker-year button { + height: 34px; + width: fit-content; + padding: 4px; + border-radius: 8px; + font-size: 15px; + display: flex; + align-items: center; + justify-content: center; + width: var(--btn-width); + } + + .date-picker-month button.active, + .date-picker-year button.active { + color: var(--affine-primary-color); + /* background: var(--affine-primary-color); */ + font-weight: 600; + } + + .date-picker .date-picker-header { + padding: 0px; + transition: padding 0.23s ease; + } + + .date-picker--mode-month, + .date-picker--mode-year { + gap: 26px; + } + + .date-picker--mode-month .date-picker-header, + .date-picker--mode-year .date-picker-header { + /* padding: 0 10px; */ + } + + .date-picker-footer { + margin-top: 8px; + padding-top: 8px; + border-top: 1px solid var(--affine-border-color); + } + + .footer-button { + height: 28px; + border: none; + border-radius: 4px; + background: none; + color: var(--affine-text-secondary-color); + cursor: pointer; + font-size: var(--affine-font-sm); + padding: 0 12px; + } + + .footer-button:hover { + background: var(--affine-hover-color); + } +`; diff --git a/blocksuite/affine/components/src/date-picker/utils.ts b/blocksuite/affine/components/src/date-picker/utils.ts new file mode 100644 index 0000000000..e8a7e0182c --- /dev/null +++ b/blocksuite/affine/components/src/date-picker/utils.ts @@ -0,0 +1,73 @@ +export type MaybeDate = Date | string | number | undefined; + +/** + * @deprecated should not use raw string to represent timestamp + * @param str + * @returns + */ +function _isTimestampString(str: string) { + return /^\d+$/.test(str); +} + +/** + * Parse the given date to Date object + * @param date + * @returns + */ +export function toDate(date?: MaybeDate) { + // TODO: handle invalid date + if (date instanceof Date) return date; + if (typeof date === 'string' && _isTimestampString(date)) date = +date; + return date ? new Date(date) : new Date(); +} + +/** + * get the first day of the month of the given date + * @param maybeDate + */ +export function getFirstDayOfMonth(maybeDate: MaybeDate) { + const date = toDate(maybeDate); + return new Date(date.getFullYear(), date.getMonth(), 1); +} + +/** + * get the last day of the month of the given date + * @param maybeDate + * @example + * getLastDayOfMonth('2021-01-01') // 2021-01-31 + */ +export function getLastDayOfMonth(maybeDate: MaybeDate) { + const date = toDate(maybeDate); + return new Date(date.getFullYear(), date.getMonth() + 1, 0); +} + +export function getMonthMatrix(maybeDate: MaybeDate) { + const date = toDate(maybeDate); + const firstDayOfMonth = getFirstDayOfMonth(date); + const lastDayOfMonth = getLastDayOfMonth(date); + const firstDayOfFirstWeek = new Date(firstDayOfMonth); + firstDayOfFirstWeek.setDate( + firstDayOfMonth.getDate() - firstDayOfMonth.getDay() + ); + const lastDayOfLastWeek = new Date(lastDayOfMonth); + lastDayOfLastWeek.setDate( + lastDayOfMonth.getDate() + (6 - lastDayOfMonth.getDay()) + ); + const matrix = []; + let week = []; + const day = new Date(firstDayOfFirstWeek); + while (day <= lastDayOfLastWeek) { + week.push(new Date(day)); + if (week.length === 7) { + matrix.push(week); + week = []; + } + day.setDate(day.getDate() + 1); + } + return matrix; +} + +export function clamp(num1: number, num2: number, value: number) { + const [min, max] = [num1, num2].sort(); + return Math.min(Math.max(value, min), max); +} diff --git a/blocksuite/affine/components/src/drag-indicator/index.ts b/blocksuite/affine/components/src/drag-indicator/index.ts new file mode 100644 index 0000000000..e71ec34685 --- /dev/null +++ b/blocksuite/affine/components/src/drag-indicator/index.ts @@ -0,0 +1,48 @@ +import type { Rect } from '@blocksuite/global/utils'; +import { css, html, LitElement } from 'lit'; +import { property } from 'lit/decorators.js'; +import { styleMap } from 'lit/directives/style-map.js'; + +export class DragIndicator extends LitElement { + static override styles = css` + .affine-drag-indicator { + position: absolute; + top: 0; + left: 0; + background: var(--affine-primary-color); + transition-property: width, height, transform; + transition-duration: 100ms; + transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1); + transition-delay: 0s; + transform-origin: 0 0; + pointer-events: none; + z-index: 2; + } + `; + + override render() { + if (!this.rect) { + return null; + } + const { left, top, width, height } = this.rect; + const style = styleMap({ + width: `${width}px`, + height: `${height}px`, + transform: `translate(${left}px, ${top}px)`, + }); + return html`
`; + } + + @property({ attribute: false }) + accessor rect: Rect | null = null; +} + +declare global { + interface HTMLElementTagNameMap { + 'affine-drag-indicator': DragIndicator; + } +} + +export function effects() { + customElements.define('affine-drag-indicator', DragIndicator); +} diff --git a/blocksuite/affine/components/src/hover/controller.ts b/blocksuite/affine/components/src/hover/controller.ts new file mode 100644 index 0000000000..12214ce407 --- /dev/null +++ b/blocksuite/affine/components/src/hover/controller.ts @@ -0,0 +1,201 @@ +import { DisposableGroup } from '@blocksuite/global/utils'; +import type { ReactiveController, ReactiveElement } from 'lit'; + +import { + type AdvancedPortalOptions, + createLitPortal, +} from '../portal/index.js'; +import type { HoverOptions } from './types.js'; +import { whenHover } from './when-hover.js'; + +type OptionsParams = Omit< + ReturnType, + 'setFloating' | 'dispose' +> & { + abortController: AbortController; +}; +type HoverPortalOptions = Omit; + +const DEFAULT_HOVER_OPTIONS: HoverOptions = { + transition: { + duration: 100, + in: { + opacity: '1', + transition: 'opacity 0.1s ease-in-out', + }, + out: { + opacity: '0', + transition: 'opacity 0.1s ease-in-out', + }, + }, + setPortalAsFloating: true, + allowMultiple: false, +}; + +const abortHoverPortal = ({ + portal, + hoverOptions, + abortController, +}: { + portal: HTMLDivElement | undefined; + hoverOptions: HoverOptions; + abortController: AbortController; +}) => { + if (!portal || !hoverOptions.transition) { + abortController.abort(); + return; + } + // Transition out + Object.assign(portal.style, hoverOptions.transition.out); + + portal.addEventListener( + 'transitionend', + () => { + abortController.abort(); + }, + { signal: abortController.signal } + ); + portal.addEventListener( + 'transitioncancel', + () => { + abortController.abort(); + }, + { signal: abortController.signal } + ); + + // Make sure the portal is aborted after the transition ends + setTimeout(() => abortController.abort(), hoverOptions.transition.duration); +}; + +export class HoverController implements ReactiveController { + static globalAbortController?: AbortController; + + private _abortController?: AbortController; + + private readonly _hoverOptions: HoverOptions; + + private _isHovering = false; + + private readonly _onHover: ( + options: OptionsParams + ) => HoverPortalOptions | null; + + private _portal?: HTMLDivElement; + + private _setReference: (element?: Element | undefined) => void = () => { + console.error('setReference is not ready'); + }; + + protected _disposables = new DisposableGroup(); + + host: ReactiveElement; + + /** + * Callback when the portal needs to be aborted. + */ + onAbort = () => { + this.abort(); + }; + + /** + * Whether the host is currently hovering. + * + * This property is unreliable when the floating element disconnect from the DOM suddenly. + */ + get isHovering() { + return this._isHovering; + } + + get portal() { + return this._portal; + } + + get setReference() { + return this._setReference; + } + + constructor( + host: ReactiveElement, + onHover: (options: OptionsParams) => HoverPortalOptions | null, + hoverOptions?: Partial + ) { + this._hoverOptions = { ...DEFAULT_HOVER_OPTIONS, ...hoverOptions }; + (this.host = host).addController(this); + this._onHover = onHover; + } + + abort(force = false) { + if (!this._abortController) return; + if (force) { + this._abortController.abort(); + return; + } + abortHoverPortal({ + portal: this._portal, + hoverOptions: this._hoverOptions, + abortController: this._abortController, + }); + } + + hostConnected() { + if (this._disposables.disposed) { + this._disposables = new DisposableGroup(); + } + // Start a timer when the host is connected + const { setReference, setFloating, dispose } = whenHover(isHover => { + if (!this.host.isConnected) { + return; + } + + this._isHovering = isHover; + if (!isHover) { + this.onAbort(); + return; + } + + if (this._abortController) { + return; + } + + this._abortController = new AbortController(); + this._abortController.signal.addEventListener('abort', () => { + this._abortController = undefined; + }); + + if (!this._hoverOptions.allowMultiple) { + HoverController.globalAbortController?.abort(); + HoverController.globalAbortController = this._abortController; + } + + const portalOptions = this._onHover({ + setReference, + abortController: this._abortController, + }); + if (!portalOptions) { + // Sometimes the portal is not ready to show + this._abortController.abort(); + return; + } + this._portal = createLitPortal({ + ...portalOptions, + abortController: this._abortController, + }); + + const transition = this._hoverOptions.transition; + if (transition) { + Object.assign(this._portal.style, transition.in); + } + + if (this._hoverOptions.setPortalAsFloating) { + setFloating(this._portal); + } + }, this._hoverOptions); + this._setReference = setReference; + this._disposables.add(dispose); + } + + hostDisconnected() { + this._abortController?.abort(); + this._disposables.dispose(); + } +} diff --git a/blocksuite/affine/components/src/hover/index.ts b/blocksuite/affine/components/src/hover/index.ts new file mode 100644 index 0000000000..3cd8cdecfd --- /dev/null +++ b/blocksuite/affine/components/src/hover/index.ts @@ -0,0 +1,3 @@ +export { HoverController } from './controller.js'; +export type * from './types.js'; +export { whenHover } from './when-hover.js'; diff --git a/blocksuite/affine/components/src/hover/middlewares/basic.ts b/blocksuite/affine/components/src/hover/middlewares/basic.ts new file mode 100644 index 0000000000..66a15748fd --- /dev/null +++ b/blocksuite/affine/components/src/hover/middlewares/basic.ts @@ -0,0 +1,79 @@ +import { sleep } from '@blocksuite/global/utils'; + +import type { HoverMiddleware } from '../types.js'; + +/** + * When the mouse is hovering in, the `mouseover` event will be fired multiple times. + * This middleware will filter out the duplicated events. + */ +export const dedupe = (keepWhenFloatingNotReady = true): HoverMiddleware => { + const SKIP = false; + const KEEP = true; + let hoverState = false; + return ({ event, floatingElement }) => { + const curState = hoverState; + if (event.type === 'mouseover') { + // hover in + hoverState = true; + if (curState !== hoverState) + // state changed, so we should keep the event + return KEEP; + if ( + keepWhenFloatingNotReady && + (!floatingElement || !floatingElement.isConnected) + ) { + // Already hovered + // But the floating element is not ready + // so we should not skip the event + return KEEP; + } + return SKIP; + } + if (event.type === 'mouseleave') { + // hover out + hoverState = false; + if (curState !== hoverState) return KEEP; + if (keepWhenFloatingNotReady && floatingElement?.isConnected) { + // Already hover out + // But the floating element is still showing + // so we should not skip the event + return KEEP; + } + return SKIP; + } + console.warn('Unknown event type in hover middleware', event); + return KEEP; + }; +}; + +/** + * Wait some time before emitting the `mouseover` event. + */ +export const delayShow = (delay: number): HoverMiddleware => { + let abortController = new AbortController(); + return async ({ event }) => { + abortController.abort(); + const newAbortController = new AbortController(); + abortController = newAbortController; + if (event.type !== 'mouseover') return true; + if (delay <= 0) return true; + await sleep(delay, newAbortController.signal); + return !newAbortController.signal.aborted; + }; +}; + +/** + * Wait some time before emitting the `mouseleave` event. + */ +export const delayHide = (delay: number): HoverMiddleware => { + let abortController = new AbortController(); + return async ({ event }) => { + abortController.abort(); + const newAbortController = new AbortController(); + abortController = newAbortController; + if (event.type !== 'mouseleave') return true; + if (delay <= 0) return true; + await sleep(delay, newAbortController.signal); + return !newAbortController.signal.aborted; + }; +}; diff --git a/blocksuite/affine/components/src/hover/middlewares/safe-area.ts b/blocksuite/affine/components/src/hover/middlewares/safe-area.ts new file mode 100644 index 0000000000..99bfa35423 --- /dev/null +++ b/blocksuite/affine/components/src/hover/middlewares/safe-area.ts @@ -0,0 +1,361 @@ +import type { HoverMiddleware } from '../types.js'; + +export type SafeTriangleOptions = { + zIndex: number; + buffer: number; + /** + * abort triangle guard if the mouse not move for some time + */ + idleTimeout: number; + debug?: boolean; +}; + +/** + * Returns true if the line from (a,b)->(c,d) intersects with (p,q)->(r,s) + * + * See https://stackoverflow.com/questions/9043805/test-if-two-lines-intersect-javascript-function + */ +function hasIntersection( + { x: a, y: b }: { x: number; y: number }, + { x: c, y: d }: { x: number; y: number }, + { x: p, y: q }: { x: number; y: number }, + { x: r, y: s }: { x: number; y: number } +) { + const det = (c - a) * (s - q) - (r - p) * (d - b); + if (det === 0) { + return false; + } else { + const lambda = ((s - q) * (r - a) + (p - r) * (s - b)) / det; + const gamma = ((b - d) * (r - a) + (c - a) * (s - b)) / det; + return 0 < lambda && lambda < 1 && 0 < gamma && gamma < 1; + } +} + +function isInside( + { x, y }: { x: number; y: number }, + rect: DOMRect, + buffer = 0 +) { + return ( + x >= rect.left - buffer && + x <= rect.right + buffer && + y >= rect.top - buffer && + y <= rect.bottom - buffer + ); +} + +const getNearestSide = ( + point: { x: number; y: number }, + rect: DOMRect +): + | [ + 'top' | 'bottom' | 'left' | 'right', + { x: number; y: number }, + { x: number; y: number }, + ] + | null => { + const centerPoint = { + x: rect.x + rect.width / 2, + y: rect.y + rect.height / 2, + }; + const topLeft = { x: rect.x, y: rect.y }; + const topRight = { x: rect.right, y: rect.y }; + const bottomLeft = { x: rect.x, y: rect.bottom }; + const bottomRight = { x: rect.right, y: rect.bottom }; + if (hasIntersection(point, centerPoint, bottomLeft, bottomRight)) { + return ['top', bottomLeft, bottomRight]; + } + if (hasIntersection(point, centerPoint, topLeft, topRight)) { + return ['bottom', topLeft, topRight]; + } + if (hasIntersection(point, centerPoint, topLeft, bottomLeft)) { + return ['right', topLeft, bottomLeft]; + } + if (hasIntersection(point, centerPoint, topRight, bottomRight)) { + return ['left', topRight, bottomRight]; + } + return null; +}; + +/** + * Part of the code is ported from https://github.com/floating-ui/floating-ui/blob/master/packages/react/src/safePolygon.ts + * Licensed under MIT. + */ +export const safeTriangle = ({ + zIndex = 10000, + buffer = 2, + idleTimeout = 40, + debug = false, +}: Partial = {}): HoverMiddleware => { + let abortController = new AbortController(); + return async ({ event, referenceElement, floatingElement }) => { + abortController.abort(); + const newAbortController = new AbortController(); + abortController = newAbortController; + + const isLeave = event.type === 'mouseleave'; + if (!isLeave || event.target !== referenceElement) return true; + if (!(event instanceof MouseEvent)) { + console.warn('Unknown event type in hover middleware', event); + return true; + } + if (!floatingElement) return true; + + const mouseX = event.x; + const mouseY = event.y; + const refRect = referenceElement.getBoundingClientRect(); + const rect = floatingElement.getBoundingClientRect(); + + // If the mouse leaves from inside the referenceElement element, + // we should ignore the event. + const leaveFromInside = isInside({ x: mouseX, y: mouseY }, refRect); + if (leaveFromInside) return true; + + // what side is the floating element on + const floatingData = getNearestSide({ x: mouseX, y: mouseY }, rect); + if (!floatingData) return true; + const floatingSide = floatingData[0]; + // If the pointer is leaving from the opposite side, no need to show the triangle. + // A constant of 1 handles floating point rounding errors. + if ( + (floatingSide === 'top' && mouseY >= refRect.bottom - 1) || + (floatingSide === 'bottom' && mouseY <= refRect.top + 1) || + (floatingSide === 'left' && mouseX >= refRect.right - 1) || + (floatingSide === 'right' && mouseX <= refRect.left + 1) + ) { + return true; + } + + const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg'); + const path = document.createElementNS('http://www.w3.org/2000/svg', 'path'); + const updateSafeTriangle = (mouseX: number, mouseY: number) => { + if (newAbortController.signal.aborted) return; + // If the mouse is inside the floating element, we should ignore the event. + if ( + mouseX >= rect.left && + mouseX <= rect.right && + mouseY >= rect.top && + mouseY <= rect.bottom + ) + newAbortController.abort(); + const p1 = { x: mouseX, y: mouseY }; + // Assume the floating element is still in the same position. + const p2 = floatingData[1]; + const p3 = floatingData[2]; + // The base point is the top left corner of the three points. + const basePoint = { + x: Math.min(p1.x, p2.x, p3.x) - buffer, + y: Math.min(p1.y, p2.y, p3.y) - buffer, + }; + const areaHeight = Math.max( + Math.abs(p1.y - p2.y), + Math.abs(p1.y - p3.y), + Math.abs(p2.y - p3.y) + ); + const areaWidth = Math.max( + Math.abs(p1.x - p2.x), + Math.abs(p1.x - p3.x), + Math.abs(p2.x - p3.x) + ); + Object.assign(svg.style, { + position: 'fixed', + pointerEvents: 'none', + width: areaWidth + buffer * 2, + height: areaHeight + buffer * 2, + zIndex, + top: 0, + left: 0, + transform: `translate(${basePoint.x}px, ${basePoint.y}px)`, + }); + path.setAttributeNS( + null, + 'd', + `M${p1.x - basePoint.x} ${p1.y - basePoint.y} ${p2.x - basePoint.x} ${ + p2.y - basePoint.y + } ${p3.x - basePoint.x} ${p3.y - basePoint.y} z` + ); + }; + path.setAttributeNS(null, 'pointer-events', 'auto'); + path.setAttributeNS(null, 'fill', 'transparent'); + path.setAttributeNS(null, 'stroke-width', buffer.toString()); + path.setAttributeNS(null, 'stroke', 'transparent'); + if (debug) { + path.setAttributeNS(null, 'stroke', 'red'); + } + updateSafeTriangle(mouseX, mouseY); + svg.append(path); + document.body.append(svg); + abortController.signal.addEventListener('abort', () => svg.remove()); + let frameId = 0; + let idleId = window.setTimeout( + () => newAbortController.abort(), + idleTimeout + ); + svg.addEventListener( + 'mousemove', + e => { + clearTimeout(idleId); + idleId = window.setTimeout( + () => newAbortController.abort(), + idleTimeout + ); + cancelAnimationFrame(frameId); + // prevent unexpected mouseleave + frameId = requestAnimationFrame(() => + updateSafeTriangle(e.clientX, e.clientY) + ); + }, + { signal: newAbortController.signal } + ); + + await new Promise(res => { + if (newAbortController.signal.aborted) res(); + newAbortController.signal.addEventListener('abort', () => res()); + svg.addEventListener('mouseleave', () => newAbortController.abort(), { + signal: newAbortController.signal, + }); + }); + + return true; + }; +}; + +export type SafeBridgeOptions = { debug: boolean; idleTimeout: number }; + +/** + * Create a virtual rectangular bridge between the reference element and the floating element. + * + * Part of the code is ported from https://github.com/floating-ui/floating-ui/blob/master/packages/react/src/safePolygon.ts + * Licensed under MIT. + */ +export const safeBridge = ({ + debug = false, + idleTimeout = 500, +}: Partial = {}): HoverMiddleware => { + let abortController = new AbortController(); + return async ({ event, referenceElement, floatingElement }) => { + abortController.abort(); + const newAbortController = new AbortController(); + abortController = newAbortController; + + const isLeave = event.type === 'mouseleave'; + if (!isLeave || event.target !== referenceElement) return true; + if (!(event instanceof MouseEvent)) { + console.warn('Unknown event type in hover middleware', event); + return true; + } + if (!floatingElement) return true; + const checkInside = (mouseX: number, mouseY: number) => { + if (newAbortController.signal.aborted) return false; + const point = { x: mouseX, y: mouseY }; + const refRect = referenceElement.getBoundingClientRect(); + const rect = floatingElement.getBoundingClientRect(); + // what side is the floating element on + const floatingData = getNearestSide(point, rect); + if (!floatingData) return false; + const floatingSide = floatingData[0]; + // If the pointer is leaving from the other side, no need to show the bridge. + // A constant of 1 handles floating point rounding errors. + if ( + (floatingSide === 'top' && mouseY > refRect.top + 1) || + (floatingSide === 'bottom' && mouseY < refRect.bottom - 1) || + (floatingSide === 'left' && mouseX > refRect.left + 1) || + (floatingSide === 'right' && mouseX < refRect.right - 1) + ) + return false; + let rectRect: DOMRect; + switch (floatingSide) { + case 'top': { + rectRect = new DOMRect( + Math.max(rect.left, refRect.left), + rect.bottom, + Math.min(rect.right, refRect.right) - + Math.max(rect.left, refRect.left), + refRect.top - rect.bottom + ); + break; + } + case 'bottom': { + rectRect = new DOMRect( + Math.max(rect.left, refRect.left), + refRect.bottom, + Math.min(rect.right, refRect.right) - + Math.max(rect.left, refRect.left), + rect.top - refRect.bottom + ); + break; + } + case 'left': { + rectRect = new DOMRect( + rect.right, + Math.max(rect.top, refRect.top), + refRect.left - rect.right, + Math.min(rect.bottom, refRect.bottom) - + Math.max(rect.top, refRect.top) + ); + break; + } + case 'right': { + rectRect = new DOMRect( + refRect.right, + Math.max(rect.top, refRect.top), + rect.left - refRect.right, + Math.min(rect.bottom, refRect.bottom) - + Math.max(rect.top, refRect.top) + ); + break; + } + default: + return false; + } + + const inside = isInside(point, rectRect, 1); + if (inside && debug) { + const debugId = 'debug-rectangle-bridge-rect'; + const rectDom = + document.querySelector(`#${debugId}`) ?? + document.createElement('div'); + rectDom.id = debugId; + Object.assign(rectDom.style, { + position: 'fixed', + pointerEvents: 'none', + background: 'aqua', + opacity: '0.3', + top: rectRect.top + 'px', + left: rectRect.left + 'px', + width: rectRect.width + 'px', + height: rectRect.height + 'px', + }); + document.body.append(rectDom); + newAbortController.signal.addEventListener('abort', () => + rectDom.remove() + ); + } + return inside; + }; + if (!checkInside(event.x, event.y)) return true; + await new Promise(res => { + if (newAbortController.signal.aborted) res(); + newAbortController.signal.addEventListener('abort', () => res()); + let idleId = window.setTimeout( + () => newAbortController.abort(), + idleTimeout + ); + document.addEventListener( + 'mousemove', + e => { + clearTimeout(idleId); + idleId = window.setTimeout( + () => newAbortController.abort(), + idleTimeout + ); + if (!checkInside(e.clientX, e.clientY)) newAbortController.abort(); + }, + { + signal: newAbortController.signal, + } + ); + }); + return true; + }; +}; diff --git a/blocksuite/affine/components/src/hover/types.ts b/blocksuite/affine/components/src/hover/types.ts new file mode 100644 index 0000000000..afde4c651b --- /dev/null +++ b/blocksuite/affine/components/src/hover/types.ts @@ -0,0 +1,65 @@ +import type { StyleInfo } from 'lit/directives/style-map.js'; + +import type { + SafeBridgeOptions, + SafeTriangleOptions, +} from './middlewares/safe-area.js'; + +export type WhenHoverOptions = { + enterDelay?: number; + leaveDelay?: number; + /** + * When already hovered to the reference element, + * but the floating element is not ready, + * the callback will still be executed if the `alwayRunWhenNoFloating` is true. + * + * It is useful when the floating element is removed just before by a user's action, + * and the user's mouse is still hovering over the reference element. + * + * @default true + */ + alwayRunWhenNoFloating?: boolean; + safeTriangle?: boolean | SafeTriangleOptions; + /** + * Create a virtual rectangular bridge between the reference element and the floating element. + */ + safeBridge?: boolean | SafeBridgeOptions; +}; + +export type HoverMiddleware = (ctx: { + event: Event; + referenceElement?: Element; + floatingElement?: Element; +}) => boolean | Promise; + +export type HoverOptions = { + /** + * Transition style when the portal is shown or hidden. + */ + transition: { + /** + * Specifies the length of the transition in ms. + * + * You only need to specify the transition end duration actually. + * + * --- + * + * Why is the duration required? + * + * The transition event is not reliable, and it may not be triggered in some cases. + * + * See also https://github.com/w3c/csswg-drafts/issues/3043 https://github.com/toeverything/blocksuite/pull/7248/files#r1631375330 + * + * Take a look at solutions from other projects: https://floating-ui.com/docs/useTransition#duration + */ + duration: number; + in: StyleInfo; + out: StyleInfo; + } | null; + /** + * Set the portal as hover element automatically. + * @default true + */ + setPortalAsFloating: boolean; + allowMultiple?: boolean; +} & WhenHoverOptions; diff --git a/blocksuite/affine/components/src/hover/when-hover.ts b/blocksuite/affine/components/src/hover/when-hover.ts new file mode 100644 index 0000000000..4c8959a28c --- /dev/null +++ b/blocksuite/affine/components/src/hover/when-hover.ts @@ -0,0 +1,142 @@ +import { dedupe, delayHide, delayShow } from './middlewares/basic.js'; +import { safeBridge, safeTriangle } from './middlewares/safe-area.js'; +import type { HoverMiddleware, WhenHoverOptions } from './types.js'; + +/** + * Call the `whenHoverChange` callback when the element is hovered. + * + * After the mouse leaves the element, there is a 300ms delay by default. + * + * Note: The callback may be called multiple times when the mouse is hovering or hovering out. + * + * See also https://floating-ui.com/docs/useHover + * + * @example + * ```ts + * private _setReference: RefOrCallback; + * + * connectedCallback() { + * let hoverTip: HTMLElement | null = null; + * const { setReference, setFloating } = whenHover(isHover => { + * if (!isHover) { + * hoverTips?.remove(); + * return; + * } + * hoverTip = document.createElement('div'); + * document.body.append(hoverTip); + * setFloating(hoverTip); + * }, { hoverDelay: 500 }); + * this._setReference = setReference; + * } + * + * render() { + * return html` + *
+ * `; + * } + * ``` + */ +export const whenHover = ( + whenHoverChange: (isHover: boolean, event?: Event) => void, + { + enterDelay = 0, + leaveDelay = 250, + alwayRunWhenNoFloating = true, + safeTriangle: triangleOptions = false, + safeBridge: bridgeOptions = true, + }: WhenHoverOptions = {} +) => { + /** + * The event listener will be removed when the signal is aborted. + */ + const abortController = new AbortController(); + let referenceElement: Element | undefined; + let floatingElement: Element | undefined; + + const middlewares: HoverMiddleware[] = [ + dedupe(alwayRunWhenNoFloating), + triangleOptions && + safeTriangle( + typeof triangleOptions === 'boolean' ? undefined : triangleOptions + ), + bridgeOptions && + safeBridge( + typeof bridgeOptions === 'boolean' ? undefined : bridgeOptions + ), + delayShow(enterDelay), + delayHide(leaveDelay), + ].filter(v => typeof v !== 'boolean') as HoverMiddleware[]; + + let currentEvent: Event | null = null; + const onHoverChange = (async (e: Event) => { + currentEvent = e; + for (const middleware of middlewares) { + const go = await middleware({ + event: e, + floatingElement, + referenceElement, + }); + if (!go) return; + } + // ignore expired event + if (e !== currentEvent) return; + const isHover = e.type === 'mouseover' ? true : false; + whenHoverChange(isHover, e); + }) as (e: Event) => void; + + const addHoverListener = (element?: Element) => { + if (!element) return; + // see https://stackoverflow.com/questions/14795099/pure-javascript-to-check-if-something-has-hover-without-setting-on-mouseover-ou + const alreadyHover = element.matches(':hover'); + if (alreadyHover && !abortController.signal.aborted) { + // When the element is already hovered, we need to trigger the callback manually + onHoverChange(new MouseEvent('mouseover')); + } + element.addEventListener('mouseover', onHoverChange, { + capture: true, + signal: abortController.signal, + }); + element.addEventListener('mouseleave', onHoverChange, { + // Please refrain use `capture: true` here. + // It will cause the `mouseleave` trigger incorrectly when the pointer is still within the element. + // The issue is detailed in https://github.com/toeverything/blocksuite/issues/6241 + // + // The `mouseleave` does not **bubble**. + // This means that `mouseleave` is fired when the pointer has exited the element and all of its descendants, + // If `capture` is used, all `mouseleave` events will be received when the pointer leaves the element or leaves one of the element's descendants (even if the pointer is still within the element). + // + // capture: true, + signal: abortController.signal, + }); + }; + + const removeHoverListener = (element?: Element) => { + if (!element) return; + element.removeEventListener('mouseover', onHoverChange); + element.removeEventListener('mouseleave', onHoverChange); + }; + + const setReference = (element?: Element) => { + // Clean previous listeners + removeHoverListener(referenceElement); + addHoverListener(element); + referenceElement = element; + }; + + const setFloating = (element?: Element) => { + // Clean previous listeners + removeHoverListener(floatingElement); + addHoverListener(element); + floatingElement = element; + }; + + return { + setReference, + setFloating, + dispose: () => { + abortController.abort(); + }, + }; +}; + +export type { WhenHoverOptions }; diff --git a/blocksuite/affine/components/src/icons/ai.ts b/blocksuite/affine/components/src/icons/ai.ts new file mode 100644 index 0000000000..edeaa6f2b9 --- /dev/null +++ b/blocksuite/affine/components/src/icons/ai.ts @@ -0,0 +1,103 @@ +import { html } from 'lit'; + +export const AIStarIcon = html` + + + + + + + + + +`; + +// dotlottie-wc can only be used in the browser +if (typeof window !== 'undefined') { + import('@lottiefiles/dotlottie-wc').catch(console.error); +} + +export const AIStarIconWithAnimation = html``; + +export const AIStopIcon = html` + + + + + + + + +`; + +export const AIDoneIcon = html` + +`; + +export const EnterIcon = html` + + `; + +export const ArrowRightIcon = html` + + `; diff --git a/blocksuite/affine/components/src/icons/edgeless.ts b/blocksuite/affine/components/src/icons/edgeless.ts new file mode 100644 index 0000000000..d881a2ac6a --- /dev/null +++ b/blocksuite/affine/components/src/icons/edgeless.ts @@ -0,0 +1,2424 @@ +// Edgeless toolbar +import * as icons from '@blocksuite/icons/lit'; +import { html } from 'lit'; + +export const SelectIcon = icons.SelectIcon({ + width: '24', + height: '24', +}); + +export const LassoFreeHandIcon = html` + + + + + + +`; + +export const LassoPolygonalIcon = html` + + + + + + +`; +export const ImageUploadIcon = icons.ImageIcon({ + width: '20', + height: '20', +}); + +export const RenameIcon = icons.EditIcon({ width: '24', height: '24' }); + +export const GroupIcon = icons.GroupIcon({ width: '20', height: '20' }); + +export const ReleaseFromGroupButtonIcon = icons.ReleaseFromGroupIcon({ + width: '24', + height: '24', +}); + +export const UngroupButtonIcon = icons.UngroupIcon({ + width: '24', + height: '24', +}); + +export const FrameNavigatorIcon = icons.PresentationIcon({ + width: '24', + height: '24', +}); + +export const FrameNavigatorPrevIcon = icons.StartPointArrowIcon({ + width: '24', + height: '24', +}); + +export const FrameNavigatorNextIcon = icons.EndPointArrowIcon({ + width: '24', + height: '24', +}); + +export const NavigatorFullScreenIcon = icons.ExpandFullIcon({ + width: '24', + height: '24', +}); +export const NavigatorExitFullScreenIcon = icons.ExpandCloseIcon({ + width: '24', + height: '24', +}); + +export const ExpandIcon = icons.AutoHeightIcon({ + width: '20', + height: '20', +}); + +export const NavigatorSettingsIcon = icons.SettingsIcon({ + width: '24', + height: '24', +}); + +export const ShrinkIcon = icons.CustomizedHeightIcon({ + width: '20', + height: '20', +}); + +export const FrameOrderAdjustmentIcon = icons.LayerIcon({ + width: '24', + height: '24', +}); + +// auto-complete +export const AutoCompleteArrowIcon = icons.ArrowUpBigIcon({ + width: '16', + height: '16', +}); + +export const NoteAutoCompleteIcon = icons.PlusIcon({ + width: '24', + height: '24', +}); + +export const ImageIcon = icons.ImageIcon({ + width: '24', + height: '24', +}); + +export const ImageIcon20 = icons.ImageIcon({ + width: '20', + height: '20', +}); + +export const BookmarkIcon = icons.BookmarkIcon({ + width: '20', + height: '20', +}); + +export const PenIcon = icons.PenIcon({ + width: '24', + height: '24', +}); + +export const HandIcon = icons.HandIcon({ + width: '24', + height: '24', +}); + +export const SquareIcon = icons.SquareIcon({ + width: '20', + height: '20', +}); + +export const EllipseIcon = icons.EllipseIcon({ + width: '20', + height: '20', +}); + +export const DiamondIcon = icons.DiamondIcon({ + width: '20', + height: '20', +}); + +export const TriangleIcon = icons.TriangleIcon({ + width: '20', + height: '20', +}); + +export const RoundedRectangleIcon = icons.RoundedRectangleIcon({ + width: '20', + height: '20', +}); + +export const ScribbledSquareIcon = html` + + + + + + + + +`; + +export const ScribbledEllipseIcon = html` + +`; + +export const ScribbledDiamondIcon = html` + + + + + + + + +`; + +export const ScribbledTriangleIcon = html` + + + + + + +`; + +export const ScribbledRoundedRectangleIcon = html` + + + + + + + + + + +`; + +export const MinusIcon = icons.MinusIcon({ + width: '24', + height: '24', +}); + +export const PlusIcon = icons.PlusIcon({ + width: '24', + height: '24', +}); + +export const ViewBarIcon = icons.ViewBarIcon({ + width: '24', + height: '24', +}); + +export const TransparentIcon = html` + +`; + +export const MoreHorizontalIcon = icons.MoreHorizontalIcon({ + width: '24', + height: '24', +}); + +export const MoreVerticalIcon = icons.MoreVerticalIcon({ + width: '20', + height: '20', +}); + +export const LineStyleIcon = icons.LineStyleIcon({ + width: '20', + height: '20', +}); + +export const ConnectorEndpointNoneIcon = icons.StartPointIcon({ + width: '20', + height: '20', +}); + +export const FrontEndpointArrowIcon = icons.StartPointArrowIcon({ + width: '20', + height: '20', +}); + +export const FrontEndpointTriangleIcon = icons.StartPointTriangleIcon({ + width: '20', + height: '20', +}); + +export const FrontEndpointCircleIcon = icons.StartPointCircleIcon({ + width: '20', + height: '20', +}); + +export const FrontEndpointDiamondIcon = icons.StartPointDiamondIcon({ + width: '20', + height: '20', +}); + +export const RearEndpointArrowIcon = icons.EndPointArrowIcon({ + width: '20', + height: '20', +}); + +export const RearEndpointTriangleIcon = icons.EndPointTriangleIcon({ + width: '20', + height: '20', +}); + +export const RearEndpointCircleIcon = icons.EndPointCircleIcon({ + width: '20', + height: '20', +}); + +export const RearEndpointDiamondIcon = icons.EndPointDiamondIcon({ + width: '20', + height: '20', +}); + +export const FlipDirectionIcon = icons.FlipDirectionIcon({ + width: '20', + height: '20', +}); + +export const ElbowedLineIcon = icons.ElbowedLineIcon({ + width: '20', + height: '20', +}); + +export const CurveLineIcon = icons.CurveLineIcon({ + width: '20', + height: '20', +}); + +export const StraightLineIcon = icons.StraightLineIcon({ + width: '20', + height: '20', +}); + +export const ConnectorCWithArrowIcon = icons.ConnectorCIcon({ + width: '20', + height: '20', +}); + +export const ConnectorXWithArrowIcon = icons.ConnectorEIcon({ + width: '20', + height: '20', +}); + +export const ConnectorLWithArrowIcon = icons.ConnectorLIcon({ + width: '20', + height: '20', +}); + +export const DashLineIcon = icons.DashLineIcon({ + width: '20', + height: '20', +}); + +export const BanIcon = icons.BanIcon({ + width: '20', + height: '20', +}); + +export const NoteSmallIcon = icons.PageIcon({ + width: '16', + height: '16', +}); + +export const NoteIcon = icons.PageIcon({ + width: '24', + height: '24', +}); + +export const NoteCornerIcon = icons.CornerIcon({ + width: '20', + height: '20', +}); + +export const NoteShadowIcon = icons.NoteShadowDuotoneIcon({ + width: '20', + height: '20', +}); + +export const NoteNoShadowIcon = html` + + + + + +`; + +export const NoteShadowSampleIcon = html` + + + + + + + + +`; + +export const SmallNoteIcon = icons.PageIcon({ + width: '20', + height: '20', +}); + +export const FrameIcon = icons.FrameIcon({ + width: '20', + height: '20', +}); + +export const LargeFrameIcon = icons.FrameIcon({ + width: '24', + height: '24', +}); + +export const TextAlignLeftIcon = icons.TextAlignLeftIcon({ + width: '20', + height: '20', +}); + +export const TextAlignCenterIcon = icons.TextAlignCenterIcon({ + width: '20', + height: '20', +}); + +export const TextAlignRightIcon = icons.TextAlignRightIcon({ + width: '20', + height: '20', +}); + +export const HiddenIcon = icons.InvisibleIcon({ + width: '20', + height: '20', +}); + +export const ArrowIcon = icons.ArrowUpSmallIcon({ + width: '16', + height: '16', +}); + +export const SmallScissorsIcon = icons.ScissorsIcon({ + width: '16', + height: '16', +}); + +export const ScissorsIcon = icons.ScissorsIcon({ + width: '20', + height: '20', +}); + +export const ArrowUpIcon = html` + +`; + +export const EdgelessModeIcon = icons.EdgelessIcon({ + width: '20', + height: '20', +}); + +export const EdgelessPenLightIcon = html` + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +`; +export const EdgelessPenDarkIcon = html` + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +`; + +export const EdgelessEraserLightIcon = html` + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +`; +export const EdgelessEraserDarkIcon = html` + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +`; + +export const EdgelessGeneralShapeIcon = html` + + + + + + + + + + + + + + +`; + +export const ellipseSvg = html` + + `; + +export const triangleSvg = html` + + `; + +export const diamondSvg = html` + +`; + +export const roundedSvg = html` + + `; + +export const MoreIcon = icons.MoreHorizontalIcon({ + width: '24', + height: '24', +}); + +export const BringToFrontIcon = icons.ArrowUpBigTopIcon({ + width: '20', + height: '20', +}); + +export const BringForwardIcon = icons.ArrowUpBigIcon({ + width: '20', + height: '20', +}); + +export const SendBackwardIcon = icons.ArrowDownBigIcon({ + width: '20', + height: '20', +}); + +export const SendToBackIcon = icons.ArrowDownBigBottomIcon({ + width: '20', + height: '20', +}); + +export const MoreCopyIcon = icons.CopyIcon({ + width: '20', + height: '20', +}); + +export const CopyAsPngIcon = icons.ExportToPngIcon({ + width: '20', + height: '20', +}); + +export const MoreDuplicateIcon = icons.DuplicateIcon({ + width: '20', + height: '20', +}); + +export const MoreDeleteIcon = icons.DeleteIcon({ + width: '20', + height: '20', +}); + +export const SmallArrowDownIcon = icons.ArrowDownSmallIcon({ + width: '16', + height: '16', +}); + +export const GeneralStyleIcon = icons.StyleGeneralIcon({ + width: '20', + height: '20', +}); + +export const ScribbledStyleIcon = icons.StyleScribbleIcon({ + width: '20', + height: '20', +}); + +export const ChangeShapeIcon = icons.ShapeIcon({ + width: '20', + height: '20', +}); + +export const ArrowRightSmallIcon = icons.ArrowRightSmallIcon({ + width: '32', + height: '32', +}); + +export const AlignLeftIcon = icons.AlignLeftIcon({ + width: '20', + height: '20', +}); + +export const AlignRightIcon = icons.AlignRightIcon({ + width: '20', + height: '20', +}); + +export const AlignHorizontallyIcon = icons.AlignHorizontalCenterIcon({ + width: '20', + height: '20', +}); + +export const AlignDistributeHorizontallyIcon = icons.DistributeHorizontalIcon({ + width: '20', + height: '20', +}); + +export const AlignTopIcon = icons.AlignTopIcon({ + width: '20', + height: '20', +}); + +export const AlignBottomIcon = icons.AlignBottomIcon({ + width: '20', + height: '20', +}); +export const AlignVerticallyIcon = icons.AlignVerticalCenterIcon({ + width: '20', + height: '20', +}); + +export const AlignDistributeVerticallyIcon = icons.DistributeVerticalIcon({ + width: '20', + height: '20', +}); + +export const RemoteCursor = icons.MultiCursorDuotoneIcon({ + width: '24', + height: '24', +}); + +export const ConnectorIcon = icons.ConnectorCIcon({ + width: '24', + height: '24', +}); + +export const AutoConnectLeftIcon = icons.ArrowLeftSmallIcon({ + width: '16', + height: '16', + style: 'color:#77757D;', +}); + +export const AutoConnectRightIcon = icons.ArrowRightSmallIcon({ + width: '16', + height: '16', + style: 'color:#77757D;', +}); + +export const SettingsIcon = icons.SettingsIcon({ + width: '20', + height: '20', +}); + +export const MoreIndicatorIcon = html` + +`; + +export const EdgelessIcon = icons.EdgelessIcon({ + width: '20', + height: '20', +}); + +export const PageIcon = icons.PageIcon({ + width: '20', + height: '20', +}); + +export const ToolsIcon = html` + + + +`; + +export const MindmapStyleIcon = icons.StyleIcon({ + width: '20', + height: '20', +}); + +export const MindmapBalanceLayoutIcon = icons.RadiantIcon({ + width: '20', + height: '20', +}); + +export const MindmapLeftLayoutIcon = icons.RightLayoutIcon({ + width: '20', + height: '20', + style: 'transform: rotate(0.5turn); transform-origin: center;', +}); + +export const MindmapRightLayoutIcon = icons.RightLayoutIcon({ + width: '20', + height: '20', +}); + +export const MindmapStyleOne = html` + + + + + + + + + + + + + + + + + `; + +export const MindmapStyleTwo = html` + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + `; + +export const MindmapStyleThree = html` + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + `; + +export const MindmapStyleFour = html` + + + + + + + + `; + +export const MindMapSiblingIcon = icons.SiblingNodeIcon({ + width: '16', + height: '16', +}); + +export const MindMapChildIcon = icons.SubNodeIcon({ + width: '16', + height: '16', +}); + +export const AddTextIcon = icons.AddTextIcon({ + width: '20', + height: '20', +}); + +export const CheckIcon = icons.DoneIcon({ + width: '20', + height: '20', +}); + +export const ArrowLeftSmallIcon = icons.ArrowLeftSmallIcon({ + width: '20', + height: '20', +}); + +export const StopAIIcon = icons.StopAiIcon({ + width: '24', + height: '24', + style: 'color: white;', +}); diff --git a/blocksuite/affine/components/src/icons/file-icons.ts b/blocksuite/affine/components/src/icons/file-icons.ts new file mode 100644 index 0000000000..c9d76b6243 --- /dev/null +++ b/blocksuite/affine/components/src/icons/file-icons.ts @@ -0,0 +1,1150 @@ +import { html } from 'lit'; + +export function getAttachmentFileIcons(filetype: string) { + switch (filetype) { + case 'img': + return IMGFileIcon; + case 'jpg': + return JPGFileIcon; + case 'jpeg': + return JPEGFileIcon; + case 'png': + return PNGFileIcon; + case 'webp': + return WEBPFileIcon; + case 'tiff': + return TIFFFileIcon; + case 'gif': + return GIFFileIcon; + case 'svg': + return SVGFileIcon; + case 'eps': + return EPSFileIcon; + case 'pdf': + return PDFFileIcon; + case 'doc': + return DOCFileIcon; + case 'docx': + return DOCXFileIcon; + case 'txt': + return TXTFileIcon; + case 'csv': + return CSVFileIcon; + case 'xls': + return XLSFileIcon; + case 'xlsx': + return XLSXFileIcon; + case 'ppt': + return PPTFileIcon; + case 'pptx': + return PPTXFileIcon; + case 'fig': + return FIGFileIcon; + case 'ai': + return AIFileIcon; + case 'psd': + return PSDFileIcon; + case 'indd': + return INDDFileIcon; + case 'aep': + return AEPFileIcon; + case 'mp3': + return MP3FileIcon; + case 'wav': + return WAVFileIcon; + case 'mp4': + return MP4FileIcon; + case 'mpeg': + return MPEGFileIcon; + case 'avi': + return AVIFileIcon; + case 'mkv': + return MKVFileIcon; + case 'html': + return HTMLFileIcon; + case 'css': + return CSSFileIcon; + case 'rss': + return RSSFileIcon; + case 'sql': + return SQLFileIcon; + case 'js': + return JSFileIcon; + case 'json': + return JSONFileIcon; + case 'java': + return JAVAFileIcon; + case 'xml': + return XMLFileIcon; + case 'exe': + return EXEFileIcon; + case 'dmg': + return DMGFileIcon; + case 'zip': + return ZIPFileIcon; + case 'rar': + return RARFileIcon; + default: + return UnknownFileIcon; + } +} + +export const FileIcon = html` + +`; + +const UnknownFileIcon = html` + + +`; + +const IMGFileIcon = html` + + + + +`; + +const JPGFileIcon = html` + + + + +`; + +const JPEGFileIcon = html` + + + + +`; + +const PNGFileIcon = html` + + + + +`; + +const WEBPFileIcon = html` + + + + +`; + +const TIFFFileIcon = html` + + + + +`; + +const GIFFileIcon = html` + + + + +`; + +const SVGFileIcon = html` + + + + +`; + +const EPSFileIcon = html` + + + + +`; + +const PDFFileIcon = html` + + + + +`; + +const DOCFileIcon = html` + + + + +`; + +const DOCXFileIcon = html` + + + + +`; + +const TXTFileIcon = html` + + + + +`; + +const CSVFileIcon = html` + + + + +`; + +const XLSFileIcon = html` + + + + +`; + +const XLSXFileIcon = html` + + + + +`; + +const PPTFileIcon = html` + + + + +`; + +const PPTXFileIcon = html` + + + + +`; + +const FIGFileIcon = html` + + + + +`; + +const AIFileIcon = html` + + + + +`; + +const PSDFileIcon = html` + + + + +`; + +const INDDFileIcon = html` + + + + +`; + +const AEPFileIcon = html` + + + + +`; + +const MP3FileIcon = html` + + + + +`; + +const WAVFileIcon = html` + + + + +`; + +const MP4FileIcon = html` + + + + +`; + +const MPEGFileIcon = html` + + + + +`; + +const AVIFileIcon = html` + + + + +`; + +const MKVFileIcon = html` + + + + +`; + +const HTMLFileIcon = html` + + + + +`; + +const CSSFileIcon = html` + + + + +`; + +const RSSFileIcon = html` + + + + +`; + +const SQLFileIcon = html` + + + + +`; + +const JSFileIcon = html` + + + + +`; + +const JSONFileIcon = html` + + + + +`; + +const JAVAFileIcon = html` + + + + +`; + +const XMLFileIcon = html` + + + + +`; + +const EXEFileIcon = html` + + + + +`; + +const DMGFileIcon = html` + + + + +`; + +const ZIPFileIcon = html` + + + + +`; + +const RARFileIcon = html` + + + + +`; diff --git a/blocksuite/affine/components/src/icons/import-export.ts b/blocksuite/affine/components/src/icons/import-export.ts new file mode 100644 index 0000000000..52392d6088 --- /dev/null +++ b/blocksuite/affine/components/src/icons/import-export.ts @@ -0,0 +1,110 @@ +import { html } from 'lit'; + +export const CloseIcon = html` + + + +`; + +export const ExportToMarkdownIcon = html` + + `; + +export const ExportToHTMLIcon = html` + + `; + +export const NotionIcon = html` + + + +`; + +export const NewIcon = html` + +`; + +export const HelpIcon = html` + + `; + +export const ImportIcon = html` + + + +`; diff --git a/blocksuite/affine/components/src/icons/index.ts b/blocksuite/affine/components/src/icons/index.ts new file mode 100644 index 0000000000..bf8b422d3a --- /dev/null +++ b/blocksuite/affine/components/src/icons/index.ts @@ -0,0 +1,9 @@ +export * from './ai.js'; +export * from './edgeless.js'; +export * from './file-icons.js'; +export * from './import-export.js'; +export * from './list.js'; +export * from './misc.js'; +export * from './tags.js'; +export * from './text.js'; +export * from './utils.js'; diff --git a/blocksuite/affine/components/src/icons/list.ts b/blocksuite/affine/components/src/icons/list.ts new file mode 100644 index 0000000000..3961937588 --- /dev/null +++ b/blocksuite/affine/components/src/icons/list.ts @@ -0,0 +1,172 @@ +import { html, svg } from 'lit'; + +const Level1Icon = html` + + + +`; + +const Level2Icon = html` + + + +`; + +const Level3Icon = html` + + + +`; + +const Level4Icon = html` + + + +`; + +const toggleSVG = svg` + +`; + +export const toggleRight = html` + + ${toggleSVG} + +`; + +export const toggleDown = html` + + ${toggleSVG} + +`; + +export const checkboxChecked = () => { + return html` + + + + `; +}; + +export const checkboxUnchecked = () => { + return html` + + + + `; +}; + +export const playCheckAnimation = async ( + refElement: Element, + { left = 0, size = 20 }: { left?: number; size?: number } = {} +) => { + const sparkingEl = document.createElement('div'); + sparkingEl.classList.add('affine-check-animation'); + if (size < 20) { + console.warn('If the size is less than 20, the animation may be abnormal.'); + } + sparkingEl.style.cssText = ` + position: absolute; + width: ${size}px; + height: ${size}px; + border-radius: 50%; + `; + sparkingEl.style.left = `${left}px`; + refElement.append(sparkingEl); + + await sparkingEl.animate( + [ + { + boxShadow: + '0 -18px 0 -8px #1e96eb, 16px -8px 0 -8px #1e96eb, 16px 8px 0 -8px #1e96eb, 0 18px 0 -8px #1e96eb, -16px 8px 0 -8px #1e96eb, -16px -8px 0 -8px #1e96eb', + }, + ], + { duration: 240, easing: 'ease', fill: 'forwards' } + ).finished; + await sparkingEl.animate( + [ + { + boxShadow: + '0 -36px 0 -10px transparent, 32px -16px 0 -10px transparent, 32px 16px 0 -10px transparent, 0 36px 0 -10px transparent, -32px 16px 0 -10px transparent, -32px -16px 0 -10px transparent', + }, + ], + { duration: 360, easing: 'ease', fill: 'forwards' } + ).finished; + + sparkingEl.remove(); +}; + +export const BulletIcons = [Level1Icon, Level2Icon, Level3Icon, Level4Icon]; diff --git a/blocksuite/affine/components/src/icons/misc.ts b/blocksuite/affine/components/src/icons/misc.ts new file mode 100644 index 0000000000..11331d9fcd --- /dev/null +++ b/blocksuite/affine/components/src/icons/misc.ts @@ -0,0 +1,59 @@ +import { html } from 'lit'; + +export const WarningIcon = html` + +`; + +export const InsertBelowIcon = html` + +`; + +export const ResetIcon = html` + +`; + +export const ReplaceIcon = html` + +`; diff --git a/blocksuite/affine/components/src/icons/tags.ts b/blocksuite/affine/components/src/icons/tags.ts new file mode 100644 index 0000000000..b1982b70f6 --- /dev/null +++ b/blocksuite/affine/components/src/icons/tags.ts @@ -0,0 +1,6 @@ +import { svg } from 'lit'; + +import { icon } from './utils.js'; + +const TagsSVG = svg``; +export const TagsIcon = icon(TagsSVG, 16); diff --git a/blocksuite/affine/components/src/icons/text.ts b/blocksuite/affine/components/src/icons/text.ts new file mode 100644 index 0000000000..713b122d55 --- /dev/null +++ b/blocksuite/affine/components/src/icons/text.ts @@ -0,0 +1,922 @@ +import * as icons from '@blocksuite/icons/lit'; +import { html, svg } from 'lit'; + +import { icon } from './utils.js'; + +// Paragraph icons + +export const TextIcon = icons.TextIcon({ + width: '20', + height: '20', +}); + +export const HeadingIcon = icons.HeadingsIcon({ + width: '20', + height: '20', +}); + +export const Heading1Icon = icons.Heading1Icon({ + width: '20', + height: '20', +}); + +export const Heading2Icon = icons.Heading2Icon({ + width: '20', + height: '20', +}); + +export const Heading3Icon = icons.Heading3Icon({ + width: '20', + height: '20', +}); + +export const Heading4Icon = icons.Heading4Icon({ + width: '20', + height: '20', +}); + +export const Heading5Icon = icons.Heading5Icon({ + width: '20', + height: '20', +}); + +export const Heading6Icon = icons.Heading6Icon({ + width: '20', + height: '20', +}); + +export const BulletedListIcon = icons.BulletedListIcon({ + width: '20', + height: '20', +}); + +export const NumberedListIcon = icons.NumberedListIcon({ + width: '20', + height: '20', +}); + +/** + * Size 24 + * + * See also {@link DatabaseTableViewIcon20} + */ +export const DatabaseTableViewIcon = icons.DatabaseTableViewIcon({ + width: '24', + height: '24', +}); +export const DatabaseTableViewIcon20 = icons.DatabaseTableViewIcon({ + width: '20', + height: '20', +}); + +/** + * Size 24 + * + * See also {@link DatabaseKanbanViewIcon20} + */ +export const DatabaseKanbanViewIcon = icons.DatabaseKanbanViewIcon({ + width: '24', + height: '24', +}); +export const DatabaseKanbanViewIcon20 = icons.DatabaseKanbanViewIcon({ + width: '20', + height: '20', +}); + +export const CheckBoxIcon = icons.CheckBoxCheckLinearIcon({ + width: '20', + height: '20', +}); + +export const CodeBlockIcon = icons.CodeBlockIcon({ + width: '20', + height: '20', +}); + +export const QuoteIcon = icons.QuoteIcon({ + width: '20', + height: '20', +}); + +export const DividerIcon = icons.DividerIcon({ + width: '20', + height: '20', +}); + +// Format icons + +export const BoldIcon = icons.BoldIcon({ + width: '20', + height: '20', +}); + +export const ItalicIcon = icons.ItalicIcon({ + width: '20', + height: '20', +}); + +export const UnderlineIcon = icons.UnderLineIcon({ + width: '20', + height: '20', +}); + +export const StrikethroughIcon = icons.StrikeThroughIcon({ + width: '20', + height: '20', +}); + +export const CodeIcon = icons.CodeIcon({ + width: '20', + height: '20', +}); + +export const LinkIcon = icons.LinkIcon({ + width: '20', + height: '20', +}); + +// Slash menu action icons +export const CopyIcon = icons.CopyIcon({ + width: '20', + height: '20', +}); + +export const PasteIcon = icons.PasteIcon({ + width: '20', + height: '20', +}); + +export const DuplicateIcon = icons.DuplicateIcon({ + width: '20', + height: '20', +}); + +export const DeleteIcon = icons.DeleteIcon({ + width: '20', + height: '20', +}); + +// Date & Time icons +export const TodayIcon = icons.TodayIcon({ + width: '20', + height: '20', +}); + +export const TomorrowIcon = icons.TomorrowIcon({ + width: '20', + height: '20', +}); + +export const YesterdayIcon = icons.YesterdayIcon({ + width: '20', + height: '20', +}); + +export const NowIcon = icons.NowIcon({ + width: '20', + height: '20', +}); + +// Misc icons + +export const CrossIcon = icons.CloseIcon({ + width: '24', + height: '24', +}); + +export const SearchIcon = icons.SearchIcon({ + width: '20', + height: '20', +}); + +export const RefreshIcon = icons.ResetIcon({ + width: '20', + height: '20', +}); + +export const WebIcon16 = icons.PublishIcon({ + width: '16', + height: '16', +}); + +// Link Icon + +/** + * ✅ + */ +export const ConfirmIcon = icons.DoneIcon({ + width: '20', + height: '20', +}); + +/** + * 🖊️ + */ + +export const OpenIcon = icons.OpenInNewIcon({ + width: '20', + height: '20', +}); + +export const CenterPeekIcon = icons.CenterPeekIcon({ + width: '20', + height: '20', +}); + +export const ExpandFullSmallIcon = icons.ExpandFullIcon({ + width: '20', + height: '20', +}); + +export const SplitViewIcon = icons.SplitViewIcon({ + width: '20', + height: '20', +}); + +export const EditIcon = icons.EditIcon({ + width: '20', + height: '20', +}); + +export const UnlinkIcon = icons.UnlinkIcon({ + width: '20', + height: '20', +}); + +// Image Icon + +export const CaptionIcon = icons.CaptionIcon({ + width: '20', + height: '20', +}); + +export const DownloadIcon = icons.DownloadIcon({ + width: '20', + height: '20', +}); + +export const WrapIcon = icons.WrapIcon({ + width: '20', + height: '20', +}); + +export const CancelWrapIcon = icons.CancelWrapIcon({ + width: '20', + height: '20', +}); + +// Attachment + +export const ViewIcon = icons.ViewIcon({ + width: '20', + height: '20', +}); + +export const EmbedWebIcon = icons.EmbedWebIcon({ + width: '20', + height: '20', +}); + +export const ArrowDownIcon = icons.ArrowDownSmallIcon({ + width: '20', + height: '20', +}); + +// Linked Doc + +export const FontDocIcon = icons.PageIcon({ + width: '1.25em', + height: '1.25em', + style: 'vertical-align: middle; font-size: inherit; margin-bottom: 0.1em;', +}); +export const DocIcon = icons.PageIcon({ + width: '20', + height: '20', +}); +export const SmallDocIcon = icons.PageIcon({ + width: '16', + height: '16', +}); + +export const FontLinkedDocIcon = icons.LinkedPageIcon({ + width: '1.25em', + height: '1.25em', + style: 'vertical-align: middle; font-size: inherit; margin-bottom: 0.1em;', +}); + +export const BlockLinkIcon = icons.BlockLinkIcon({ + width: '1.25em', + height: '1.25em', + style: 'vertical-align: middle; font-size: inherit; margin-bottom: 0.1em;', +}); + +export const LinkedEdgelessIcon = icons.LinkedEdgelessIcon({ + width: '20', + height: '20', +}); + +export const NewDocIcon = icons.PlusIcon({ + width: '20', + height: '20', +}); + +export const DualLinkIcon16 = icons.DualLinkIcon({ + width: '16', + height: '16', +}); + +export const ArrowDownSmallIcon = icons.ArrowDownSmallIcon({ + width: '24', + height: '24', +}); + +export const AddCursorIcon = icons.PlusIcon({ + width: '24', + height: '24', +}); + +export const FontFamilyIcon = icons.FontIcon({ + width: '20', + height: '20', +}); + +export const AttachmentIcon = icons.AttachmentIcon({ + width: '20', + height: '20', +}); +export const AttachmentIcon16 = icons.AttachmentIcon({ + width: '16', + height: '16', +}); + +export const TextBackgroundDuotoneIcon = html` + + + +`; + +export const TextForegroundDuotoneIcon = html` + + +`; + +const HighLightDuotoneSVG = svg` + + +`; +export const HighLightDuotoneIcon = icon(HighLightDuotoneSVG, 20); + +export const ArrowRightBigIcon = icons.ArrowRightBigIcon({ + width: '24', + height: '24', +}); + +export const ArrowLeftBigIcon = icons.ArrowLeftBigIcon({ + width: '24', + height: '24', +}); + +export const ExpandWideIcon = icons.ExpandWideIcon({ + width: '24', + height: '24', +}); + +export const ExpandFullIcon = icons.ExpandFullIcon({ + width: '24', + height: '24', +}); + +export const ExpandCloseIcon = icons.ExpandCloseIcon({ + width: '24', + height: '24', +}); + +export const MoveLeftIcon = icons.MoveLeftIcon({ + width: '24', + height: '24', +}); +export const MoveRightIcon = icons.MoveRightIcon({ + width: '24', + height: '24', +}); +export const ArrowUpBigIcon = icons.ArrowUpBigIcon({ + width: '20', + height: '20', +}); + +export const ArrowDownBigIcon = icons.ArrowDownBigIcon({ + width: '20', + height: '20', +}); +export const PaletteIcon = icons.PaletteIcon({ + width: '20', + height: '20', +}); + +const LoadingIcon = (color: string) => { + return html` + + + + `; +}; + +export const LightLoadingIcon = LoadingIcon('white'); + +export const DarkLoadingIcon = LoadingIcon('black'); + +export const EmbedCardLightBannerIcon = html` + + + + +`; + +export const EmbedCardDarkBannerIcon = html` + + + + +`; + +export const EmbedCardLightHorizontalIcon = html` + + + + + + + + + + + + + + + + + + +`; + +export const EmbedCardDarkHorizontalIcon = html` + + + + + + + + + + + + + + + + + + +`; + +export const EmbedCardLightListIcon = html` + + + + + + +`; + +export const EmbedCardDarkListIcon = html` + + + + + + +`; + +export const EmbedCardLightVerticalIcon = html` + + + + + + + + + + + + + + + + + +`; + +export const EmbedCardDarkVerticalIcon = html` + + + + + + + + + + + + + + + + + +`; + +export const EmbedCardLightCubeIcon = html` + + + + + + + +`; + +export const EmbedCardDarkCubeIcon = html` + + + + + + + +`; + +export const ReloadIcon = html` + + + + + + + + +`; + +export const EmbedPageIcon = icons.LinkedPageIcon({ + width: '16', + height: '16', +}); + +export const EmbedEdgelessIcon = icons.LinkedEdgelessIcon({ + width: '16', + height: '16', +}); + +export const LinkedDocIcon = icons.LinkedPageIcon({ + width: '20', + height: '20', +}); diff --git a/blocksuite/affine/components/src/icons/utils.ts b/blocksuite/affine/components/src/icons/utils.ts new file mode 100644 index 0000000000..80496ceb84 --- /dev/null +++ b/blocksuite/affine/components/src/icons/utils.ts @@ -0,0 +1,12 @@ +import { html, type TemplateResult } from 'lit'; + +export function icon(svg: TemplateResult<2>, size = 24) { + return html` + ${svg} + `; +} diff --git a/blocksuite/affine/components/src/index.ts b/blocksuite/affine/components/src/index.ts new file mode 100644 index 0000000000..cb0ff5c3b5 --- /dev/null +++ b/blocksuite/affine/components/src/index.ts @@ -0,0 +1 @@ +export {}; diff --git a/blocksuite/affine/components/src/notification/index.ts b/blocksuite/affine/components/src/notification/index.ts new file mode 100644 index 0000000000..f5d586fc7a --- /dev/null +++ b/blocksuite/affine/components/src/notification/index.ts @@ -0,0 +1 @@ +export * from './linked-doc.js'; diff --git a/blocksuite/affine/components/src/notification/linked-doc.ts b/blocksuite/affine/components/src/notification/linked-doc.ts new file mode 100644 index 0000000000..3d39d56873 --- /dev/null +++ b/blocksuite/affine/components/src/notification/linked-doc.ts @@ -0,0 +1,70 @@ +import { NotificationProvider } from '@blocksuite/affine-shared/services'; +import type { BlockStdScope } from '@blocksuite/block-std'; + +import { toast } from '../toast/toast.js'; + +function notify(std: BlockStdScope, title: string, message: string) { + const notification = std.getOptional(NotificationProvider); + const { doc, host } = std; + + if (!notification) { + toast(host, title); + return; + } + + const abortController = new AbortController(); + const clear = () => { + doc.history.off('stack-item-added', addHandler); + doc.history.off('stack-item-popped', popHandler); + disposable.dispose(); + }; + const closeNotify = () => { + abortController.abort(); + clear(); + }; + + // edit or undo or switch doc, close notify toast + const addHandler = doc.history.on('stack-item-added', closeNotify); + const popHandler = doc.history.on('stack-item-popped', closeNotify); + const disposable = host.slots.unmounted.on(closeNotify); + + notification.notify({ + title, + message, + accent: 'info', + duration: 10 * 1000, + action: { + label: 'Undo', + onClick: () => { + doc.undo(); + clear(); + }, + }, + abort: abortController.signal, + onClose: clear, + }); +} + +export function notifyLinkedDocSwitchedToCard(std: BlockStdScope) { + notify( + std, + 'View Updated', + 'The alias modification has disabled sync. The embed has been updated to a card view.' + ); +} + +export function notifyLinkedDocSwitchedToEmbed(std: BlockStdScope) { + notify( + std, + 'Embed View Restored', + 'Custom alias removed. The linked doc now displays the original title and description.' + ); +} + +export function notifyLinkedDocClearedAliases(std: BlockStdScope) { + notify( + std, + 'Reset successful', + `Card view has been restored to original doc title and description. All custom aliases have been removed.` + ); +} diff --git a/blocksuite/affine/components/src/peek/commands.ts b/blocksuite/affine/components/src/peek/commands.ts new file mode 100644 index 0000000000..e175adbbbc --- /dev/null +++ b/blocksuite/affine/components/src/peek/commands.ts @@ -0,0 +1,55 @@ +/// +import type { + BlockComponent, + Command, + InitCommandCtx, +} from '@blocksuite/block-std'; + +import { isPeekable, peek } from './peekable.js'; + +const getSelectedPeekableBlocks = (cmd: InitCommandCtx) => { + const [result, ctx] = cmd.std.command + .chain() + .tryAll(chain => [chain.getTextSelection(), chain.getBlockSelections()]) + .getSelectedBlocks({ types: ['text', 'block'] }) + .run(); + return ((result ? ctx.selectedBlocks : []) || []).filter(isPeekable); +}; + +export const getSelectedPeekableBlocksCommand: Command< + 'selectedBlocks', + 'selectedPeekableBlocks' +> = (ctx, next) => { + const selectedPeekableBlocks = getSelectedPeekableBlocks(ctx); + if (selectedPeekableBlocks.length > 0) { + next({ selectedPeekableBlocks }); + } +}; + +export const peekSelectedBlockCommand: Command<'selectedBlocks'> = ( + ctx, + next +) => { + const peekableBlocks = getSelectedPeekableBlocks(ctx); + // if there are multiple blocks, peek the first one + const block = peekableBlocks.at(0); + + if (block) { + peek(block); + next(); + } +}; + +declare global { + namespace BlockSuite { + interface CommandContext { + selectedPeekableBlocks?: BlockComponent[]; + } + + interface Commands { + peekSelectedBlock: typeof peekSelectedBlockCommand; + getSelectedPeekableBlocks: typeof getSelectedPeekableBlocksCommand; + // todo: add command for peek an inline element? + } + } +} diff --git a/blocksuite/affine/components/src/peek/controller.ts b/blocksuite/affine/components/src/peek/controller.ts new file mode 100644 index 0000000000..b47f6bda76 --- /dev/null +++ b/blocksuite/affine/components/src/peek/controller.ts @@ -0,0 +1,31 @@ +import type { TemplateResult } from 'lit'; + +import { PeekViewProvider } from './service.js'; +import type { PeekableClass, PeekViewService } from './type.js'; + +export class PeekableController { + private _getPeekViewService = (): PeekViewService | null => { + return this.target.std.getOptional(PeekViewProvider); + }; + + peek = (template?: TemplateResult) => { + return Promise.resolve( + this._getPeekViewService()?.peek({ + target: this.target, + template, + }) + ); + }; + + get peekable() { + return ( + !!this._getPeekViewService() && + (this.enable ? this.enable(this.target) : true) + ); + } + + constructor( + private target: T, + private enable?: (e: T) => boolean + ) {} +} diff --git a/blocksuite/affine/components/src/peek/index.ts b/blocksuite/affine/components/src/peek/index.ts new file mode 100644 index 0000000000..f0e02d3afb --- /dev/null +++ b/blocksuite/affine/components/src/peek/index.ts @@ -0,0 +1,8 @@ +export { + getSelectedPeekableBlocksCommand, + peekSelectedBlockCommand, +} from './commands.js'; +export { PeekableController } from './controller.js'; +export { isPeekable, peek, Peekable } from './peekable.js'; +export * from './service.js'; +export type { PeekableOptions, PeekOptions, PeekViewService } from './type.js'; diff --git a/blocksuite/affine/components/src/peek/peekable.ts b/blocksuite/affine/components/src/peek/peekable.ts new file mode 100644 index 0000000000..d9a9a0c5b4 --- /dev/null +++ b/blocksuite/affine/components/src/peek/peekable.ts @@ -0,0 +1,81 @@ +import { isInsideEdgelessEditor } from '@blocksuite/affine-shared/utils'; +import type { Constructor } from '@blocksuite/global/utils'; +import type { LitElement, TemplateResult } from 'lit'; + +import { PeekableController } from './controller.js'; +import type { PeekableClass, PeekableOptions } from './type.js'; + +const symbol = Symbol('peekable'); + +export const isPeekable = (e: Element): boolean => { + return Reflect.has(e, symbol) && (e as any)[symbol]?.peekable; +}; + +export const peek = ( + e: Element, + template?: TemplateResult +): void => { + isPeekable(e) && (e as any)[symbol]?.peek(template); +}; + +/** + * Mark a class as peekable, which means the class can be peeked by the peek view service. + * + * Note: This class must be syntactically below the `@customElement` decorator (it will be applied before customElement). + */ +export const Peekable = + >( + options: PeekableOptions = { + action: ['double-click', 'shift-click'], + } + ) => + (Class: C, context: ClassDecoratorContext) => { + if (context.kind !== 'class') { + console.error('@Peekable() can only be applied to a class'); + return; + } + + if (options.action === undefined) + options.action = ['double-click', 'shift-click']; + + const actions = Array.isArray(options.action) + ? options.action + : options.action + ? [options.action] + : []; + + const derivedClass = class extends Class { + [symbol] = new PeekableController(this as unknown as T, options.enableOn); + + override connectedCallback() { + super.connectedCallback(); + + const target: HTMLElement = + (options.selector ? this.querySelector(options.selector) : this) || + this; + + if (actions.includes('double-click')) { + this.disposables.addFromEvent(target, 'dblclick', e => { + if (this[symbol].peekable) { + e.stopPropagation(); + this[symbol].peek().catch(console.error); + } + }); + } + if ( + actions.includes('shift-click') && + // shift click in edgeless should be selection + !isInsideEdgelessEditor(this.std.host) + ) { + this.disposables.addFromEvent(target, 'click', e => { + if (e.shiftKey && this[symbol].peekable) { + e.stopPropagation(); + e.stopImmediatePropagation(); + this[symbol].peek().catch(console.error); + } + }); + } + } + }; + return derivedClass as unknown as C; + }; diff --git a/blocksuite/affine/components/src/peek/service.ts b/blocksuite/affine/components/src/peek/service.ts new file mode 100644 index 0000000000..51e476c07d --- /dev/null +++ b/blocksuite/affine/components/src/peek/service.ts @@ -0,0 +1,16 @@ +import type { ExtensionType } from '@blocksuite/block-std'; +import { createIdentifier } from '@blocksuite/global/di'; + +import type { PeekViewService } from './type.js'; + +export const PeekViewProvider = createIdentifier( + 'AffinePeekViewProvider' +); + +export function PeekViewExtension(service: PeekViewService): ExtensionType { + return { + setup: di => { + di.addImpl(PeekViewProvider, () => service); + }, + }; +} diff --git a/blocksuite/affine/components/src/peek/type.ts b/blocksuite/affine/components/src/peek/type.ts new file mode 100644 index 0000000000..3a8d43bbd8 --- /dev/null +++ b/blocksuite/affine/components/src/peek/type.ts @@ -0,0 +1,70 @@ +import type { BlockComponent, BlockStdScope } from '@blocksuite/block-std'; +import type { DisposableClass } from '@blocksuite/global/utils'; +import type { LitElement, TemplateResult } from 'lit'; + +export type PeekableClass = { std: BlockStdScope } & DisposableClass & + LitElement; + +export interface PeekOptions { + /** + * Abort signal to abort the peek view + */ + abortSignal?: AbortSignal; +} + +export interface PeekViewService { + /** + * Peek a target element page ref info + * @param pageRef The page ref info to peek. + * @returns A promise that resolves when the peek view is closed. + */ + peek( + pageRef: { + docId: string; + blockIds?: string[]; + databaseId?: string; + databaseDocId?: string; + databaseRowId?: string; + elementIds?: string[]; + target?: HTMLElement; + }, + options?: PeekOptions + ): Promise; + + /** + * Peek a target element with a optional template + * @param target The target element to peek. There are two use cases: + * 1. If the template is not given, peek view content rendering will be delegated to the implementation of peek view service. + * 2. To determine the origin of the peek view modal animation + * @param template Optional template to render in the peek view modal. If not given, the peek view service will render the content. + * @returns A promise that resolves when the peek view is closed. + */ + peek( + // eslint-disable-next-line @typescript-eslint/unified-signatures + element: { target: HTMLElement; template?: TemplateResult }, + options?: PeekOptions + ): Promise; + + peek( + element: { target: Element; template?: TemplateResult }, + options?: PeekOptions + ): Promise; +} + +type PeekableAction = 'double-click' | 'shift-click'; + +export type PeekableOptions = { + /** + * Action to bind to the peekable element. default to ['double-click', 'shift-click'] + * false means do not bind any action. + */ + action?: PeekableAction | PeekableAction[] | false; + /** + * It will check the block is enable to peek or not + */ + enableOn?: (block: T) => boolean; + /** + * Selector inside of the peekable element to bind the action + */ + selector?: string; +}; diff --git a/blocksuite/affine/components/src/portal/helper.ts b/blocksuite/affine/components/src/portal/helper.ts new file mode 100644 index 0000000000..ee3f856c61 --- /dev/null +++ b/blocksuite/affine/components/src/portal/helper.ts @@ -0,0 +1,221 @@ +import { assertExists, Slot } from '@blocksuite/global/utils'; +import { + autoUpdate, + computePosition, + type ComputePositionReturn, +} from '@floating-ui/dom'; +import { cssVar } from '@toeverything/theme'; +import { render } from 'lit'; + +import type { AdvancedPortalOptions, PortalOptions } from './types.js'; + +/** + * Similar to ``, but only renders once when called. + * + * The template should be a **static** template since it will not be re-rendered unless `updatePortal` is called. + * + * See {@link Portal} for more details. + */ +export function createSimplePortal({ + template, + container = document.body, + signal = new AbortController().signal, + renderOptions, + shadowDom = true, + identifyWrapper = true, +}: PortalOptions) { + const portalRoot = document.createElement('div'); + if (identifyWrapper) { + portalRoot.classList.add('blocksuite-portal'); + } + if (shadowDom) { + portalRoot.attachShadow({ + mode: 'open', + ...(typeof shadowDom !== 'boolean' ? shadowDom : {}), + }); + } + signal.addEventListener('abort', () => { + portalRoot.remove(); + }); + + const root = shadowDom ? portalRoot.shadowRoot : portalRoot; + assertExists(root); + + let updateId = 0; + const updatePortal: (id: number) => void = id => { + if (id !== updateId) { + console.warn( + 'Potentially infinite recursion! Please clean up the old event listeners before `updatePortal`' + ); + return; + } + updateId++; + const curId = updateId; + const templateResult = + template instanceof Function + ? template({ updatePortal: () => updatePortal(curId) }) + : template; + assertExists(templateResult); + render(templateResult, root, renderOptions); + }; + + updatePortal(updateId); + container.append(portalRoot); + + // affine's modal will set pointer-events: none to body + // in order to avoid the issue that the floating element in blocksuite cannot be clicked + // we add pointer-events: auto here + portalRoot.style.pointerEvents = 'auto'; + + return portalRoot; +} + +/** + * Where el is the DOM element you'd like to test for visibility + */ +function isElementVisible(el: Element) { + // The API is not stable, so we need to check the existence of the function first + // See also https://caniuse.com/?search=checkVisibility + if (el.checkVisibility) { + // See https://drafts.csswg.org/cssom-view-1/#dom-element-checkvisibility + return el.checkVisibility(); + } + // Fallback to the old way + // Remove this when the `checkVisibility` API is stable + if (!el.isConnected) return false; + + if (el instanceof HTMLElement) { + // See https://stackoverflow.com/questions/19669786/check-if-element-is-visible-in-dom + return !(el.offsetParent === null); + } + return true; +} + +/** + * Similar to `createSimplePortal`, but supports auto update position. + * + * The template should be a **static** template since it will not be re-rendered. + * + * See {@link createSimplePortal} for more details. + * + * @example + * ```ts + * createLitPortal({ + * template: RenameModal({ + * model, + * abortController: renameAbortController, + * }), + * computePosition: { + * referenceElement: anchor, + * placement: 'top-end', + * middleware: [flip(), offset(4)], + * autoUpdate: true, + * }, + * abortController: renameAbortController, + * }); + * ``` + */ +export function createLitPortal({ + computePosition: positionConfigOrFn, + abortController, + closeOnClickAway = false, + positionStrategy = 'absolute', + ...portalOptions +}: AdvancedPortalOptions) { + let positionSlot = new Slot(); + const template = portalOptions.template; + const templateWithPosition = + template instanceof Function + ? ({ updatePortal }: { updatePortal: () => void }) => { + // We need to create a new slot for each template, otherwise the slot may be used in the old template + positionSlot = new Slot(); + return template({ updatePortal, positionSlot }); + } + : template; + + const portalRoot = createSimplePortal({ + ...portalOptions, + signal: abortController.signal, + template: templateWithPosition, + }); + + if (closeOnClickAway) { + // Avoid triggering click away listener on initial render + setTimeout(() => + document.addEventListener( + 'click', + e => { + if (portalRoot.contains(e.target as Node)) return; + abortController.abort(); + }, + { + signal: abortController.signal, + } + ) + ); + } + + if (!positionConfigOrFn) { + return portalRoot; + } + + const visibility = portalRoot.style.visibility; + portalRoot.style.visibility = 'hidden'; + portalRoot.style.position = positionStrategy; + portalRoot.style.left = '0'; + portalRoot.style.top = '0'; + portalRoot.style.zIndex = cssVar('zIndexPopover'); + + Object.assign(portalRoot.style, portalOptions.portalStyles); + + const computePositionOptions = + positionConfigOrFn instanceof Function + ? positionConfigOrFn(portalRoot) + : positionConfigOrFn; + const { referenceElement, ...options } = computePositionOptions; + assertExists(referenceElement, 'referenceElement is required'); + const update = () => { + if ( + computePositionOptions.abortWhenRefRemoved !== false && + referenceElement instanceof Element && + !isElementVisible(referenceElement) + ) { + abortController.abort(); + } + computePosition(referenceElement, portalRoot, { + strategy: positionStrategy, + ...options, + }) + .then(positionReturn => { + const { x, y } = positionReturn; + // Use transform maybe cause overlay-mask offset issue + // portalRoot.style.transform = `translate(${x}px, ${y}px)`; + portalRoot.style.left = `${x}px`; + portalRoot.style.top = `${y}px`; + if (portalRoot.style.visibility === 'hidden') { + portalRoot.style.visibility = visibility; + } + positionSlot.emit(positionReturn); + }) + .catch(console.error); + }; + if (!computePositionOptions.autoUpdate) { + update(); + } else { + const autoUpdateOptions = + computePositionOptions.autoUpdate === true + ? {} + : computePositionOptions.autoUpdate; + const cleanup = autoUpdate( + referenceElement, + portalRoot, + update, + autoUpdateOptions + ); + abortController.signal.addEventListener('abort', () => { + cleanup(); + }); + } + + return portalRoot; +} diff --git a/blocksuite/affine/components/src/portal/index.ts b/blocksuite/affine/components/src/portal/index.ts new file mode 100644 index 0000000000..1c894d4de6 --- /dev/null +++ b/blocksuite/affine/components/src/portal/index.ts @@ -0,0 +1,9 @@ +export { createLitPortal, createSimplePortal } from './helper.js'; +export { Portal } from './portal.js'; +export type * from './types.js'; + +import { Portal } from './portal.js'; + +export function effects() { + customElements.define('blocksuite-portal', Portal); +} diff --git a/blocksuite/affine/components/src/portal/portal.ts b/blocksuite/affine/components/src/portal/portal.ts new file mode 100644 index 0000000000..231febcb84 --- /dev/null +++ b/blocksuite/affine/components/src/portal/portal.ts @@ -0,0 +1,60 @@ +import { html, LitElement, type TemplateResult } from 'lit'; +import { property } from 'lit/decorators.js'; + +/** + * Renders a template into a portal. Defaults to `document.body`. + * + * Note that every time the parent component re-renders, the portal will be re-called. + * + * See https://lit.dev/docs/components/rendering/#writing-a-good-render()-method + * + * @example + * ```ts + * render() { + * return html`${showPortal + * ? html`` + * : null}`; + * }; + * ``` + */ +export class Portal extends LitElement { + private _portalRoot: HTMLElement | null = null; + + override createRenderRoot() { + const portalRoot = document.createElement('div'); + const renderRoot = this.shadowDom + ? portalRoot.attachShadow({ + mode: 'open', + ...(typeof this.shadowDom !== 'boolean' ? this.shadowDom : {}), + }) + : portalRoot; + portalRoot.classList.add('blocksuite-portal'); + this.container.append(portalRoot); + this._portalRoot = portalRoot; + return renderRoot; + } + + override disconnectedCallback(): void { + super.disconnectedCallback(); + this._portalRoot?.remove(); + } + + override render() { + return this.template; + } + + @property({ attribute: false }) + accessor container = document.body; + + @property({ attribute: false }) + accessor shadowDom: boolean | ShadowRootInit = true; + + @property({ attribute: false }) + accessor template: TemplateResult | undefined = html``; +} + +declare global { + interface HTMLElementTagNameMap { + 'blocksuite-portal': Portal; + } +} diff --git a/blocksuite/affine/components/src/portal/types.ts b/blocksuite/affine/components/src/portal/types.ts new file mode 100644 index 0000000000..6ed049e4fa --- /dev/null +++ b/blocksuite/affine/components/src/portal/types.ts @@ -0,0 +1,82 @@ +import type { Slot } from '@blocksuite/global/utils'; +import type { + AutoUpdateOptions, + ComputePositionConfig, + ComputePositionReturn, + ReferenceElement, +} from '@floating-ui/dom'; +import type { RenderOptions, TemplateResult } from 'lit'; + +/** + * See https://lit.dev/docs/templates/expressions/#child-expressions + */ +type Renderable = + | TemplateResult<1> + // Any DOM node can be passed to a child expression. + | HTMLElement + // Numbers values like 5 will render the string '5'. Bigints are treated similarly. + | number + // A boolean value true will render 'true', and false will render 'false', but rendering a boolean like this is uncommon. + | boolean + // The empty string '', null, and undefined are specially treated and render nothing. + | string + | null + | undefined; + +export type PortalOptions = { + template: Renderable | ((ctx: { updatePortal: () => void }) => Renderable); + container?: Element; + /** + * The portal is removed when the AbortSignal is aborted. + */ + signal?: AbortSignal; + /** + * Defaults to `true`. + */ + shadowDom?: boolean | ShadowRootInit; + renderOptions?: RenderOptions; + /** + * Defaults to `true`. + * If true, the portalRoot will be added a class `blocksuite-portal`. It's useful for finding the portalRoot. + */ + identifyWrapper?: boolean; + + portalStyles?: Record; +}; + +type ComputePositionOptions = { + referenceElement: ReferenceElement; + /** + * Default `false`. + */ + autoUpdate?: true | AutoUpdateOptions; + /** + * Default `true`. Only work when `referenceElement` is an `Element`. Check when position update (`autoUpdate` is `true` or first tick) + */ + abortWhenRefRemoved?: boolean; +} & Partial; + +export type AdvancedPortalOptions = Omit< + PortalOptions, + 'template' | 'signal' +> & { + abortController: AbortController; + template: + | Renderable + | ((context: { + positionSlot: Slot; + updatePortal: () => void; + }) => Renderable); + positionStrategy?: 'absolute' | 'fixed'; + /** + * See https://floating-ui.com/docs/computePosition + */ + computePosition?: + | ComputePositionOptions + | ((portalRoot: Element) => ComputePositionOptions); + /** + * Whether to close the portal when click away(click outside). + * @default false + */ + closeOnClickAway?: boolean; +}; diff --git a/blocksuite/affine/components/src/rich-text/all-extensions.ts b/blocksuite/affine/components/src/rich-text/all-extensions.ts new file mode 100644 index 0000000000..8121a9d077 --- /dev/null +++ b/blocksuite/affine/components/src/rich-text/all-extensions.ts @@ -0,0 +1,41 @@ +import type { ExtensionType } from '@blocksuite/block-std'; + +import { InlineManagerExtension } from './extension/index.js'; +import { + BackgroundInlineSpecExtension, + BoldInlineSpecExtension, + CodeInlineSpecExtension, + ColorInlineSpecExtension, + InlineSpecExtensions, + ItalicInlineSpecExtension, + LatexInlineSpecExtension, + LinkInlineSpecExtension, + MarkdownExtensions, + ReferenceInlineSpecExtension, + StrikeInlineSpecExtension, + UnderlineInlineSpecExtension, +} from './inline/index.js'; +import { LatexEditorInlineManagerExtension } from './inline/presets/nodes/latex-node/latex-editor-menu.js'; + +export const DefaultInlineManagerExtension = InlineManagerExtension({ + id: 'DefaultInlineManager', + specs: [ + BoldInlineSpecExtension.identifier, + ItalicInlineSpecExtension.identifier, + UnderlineInlineSpecExtension.identifier, + StrikeInlineSpecExtension.identifier, + CodeInlineSpecExtension.identifier, + BackgroundInlineSpecExtension.identifier, + ColorInlineSpecExtension.identifier, + LatexInlineSpecExtension.identifier, + ReferenceInlineSpecExtension.identifier, + LinkInlineSpecExtension.identifier, + ], +}); + +export const RichTextExtensions: ExtensionType[] = [ + InlineSpecExtensions, + MarkdownExtensions, + LatexEditorInlineManagerExtension, + DefaultInlineManagerExtension, +].flat(); diff --git a/blocksuite/affine/components/src/rich-text/dom.ts b/blocksuite/affine/components/src/rich-text/dom.ts new file mode 100644 index 0000000000..acba6c914a --- /dev/null +++ b/blocksuite/affine/components/src/rich-text/dom.ts @@ -0,0 +1,88 @@ +import { + asyncGetBlockComponent, + matchFlavours, +} from '@blocksuite/affine-shared/utils'; +import type { BlockStdScope, EditorHost } from '@blocksuite/block-std'; +import type { InlineRange } from '@blocksuite/inline'; +import type { BlockModel } from '@blocksuite/store'; + +import type { RichText } from './rich-text.js'; + +/** + * In most cases, you not need RichText, you can use {@link getInlineEditorByModel} instead. + */ +export function getRichTextByModel(editorHost: EditorHost, id: string) { + const blockComponent = editorHost.view.getBlock(id); + const richText = blockComponent?.querySelector('rich-text'); + if (!richText) return null; + return richText; +} + +export async function asyncGetRichText(editorHost: EditorHost, id: string) { + const blockComponent = await asyncGetBlockComponent(editorHost, id); + if (!blockComponent) return null; + await blockComponent.updateComplete; + const richText = blockComponent?.querySelector('rich-text'); + if (!richText) return null; + return richText; +} + +export function getInlineEditorByModel( + editorHost: EditorHost, + model: BlockModel | string +) { + const blockModel = + typeof model === 'string' + ? editorHost.std.doc.getBlock(model)?.model + : model; + // @ts-expect-error TODO: migrate database model to `@blocksuite/affine-model` + if (!blockModel || matchFlavours(blockModel, ['affine:database'])) { + // Not support database model since it's may be have multiple inline editor instances. + // Support to enter the editing state through the Enter key in the database. + return null; + } + const richText = getRichTextByModel(editorHost, blockModel.id); + if (!richText) return null; + return richText.inlineEditor; +} + +export async function asyncSetInlineRange( + editorHost: EditorHost, + model: BlockModel, + inlineRange: InlineRange +) { + const richText = await asyncGetRichText(editorHost, model.id); + if (!richText) { + return; + } + + await richText.updateComplete; + const inlineEditor = richText.inlineEditor; + if (!inlineEditor) { + return; + } + inlineEditor.setInlineRange(inlineRange); +} + +export function focusTextModel( + std: BlockStdScope, + id: string, + offset: number = 0 +) { + selectTextModel(std, id, offset); +} + +export function selectTextModel( + std: BlockStdScope, + id: string, + index: number = 0, + length: number = 0 +) { + const { selection } = std; + selection.setGroup('note', [ + selection.create('text', { + from: { blockId: id, index, length }, + to: null, + }), + ]); +} diff --git a/blocksuite/affine/components/src/rich-text/effects.ts b/blocksuite/affine/components/src/rich-text/effects.ts new file mode 100644 index 0000000000..64a44e761d --- /dev/null +++ b/blocksuite/affine/components/src/rich-text/effects.ts @@ -0,0 +1,76 @@ +import type { AffineTextAttributes } from '@blocksuite/affine-shared/types'; + +import type { deleteTextCommand } from './format/delete-text.js'; +import type { formatBlockCommand } from './format/format-block.js'; +import type { formatNativeCommand } from './format/format-native.js'; +import type { formatTextCommand } from './format/format-text.js'; +import type { insertInlineLatex } from './format/insert-inline-latex.js'; +import type { + getTextStyle, + isTextStyleActive, + toggleBold, + toggleCode, + toggleItalic, + toggleLink, + toggleStrike, + toggleTextStyleCommand, + toggleUnderline, +} from './format/text-style.js'; +import { AffineLink, AffineReference } from './inline/index.js'; +import { AffineText } from './inline/presets/nodes/affine-text.js'; +import { LatexEditorMenu } from './inline/presets/nodes/latex-node/latex-editor-menu.js'; +import { LatexEditorUnit } from './inline/presets/nodes/latex-node/latex-editor-unit.js'; +import { AffineLatexNode } from './inline/presets/nodes/latex-node/latex-node.js'; +import { LinkPopup } from './inline/presets/nodes/link-node/link-popup/link-popup.js'; +import { ReferenceAliasPopup } from './inline/presets/nodes/reference-node/reference-alias-popup.js'; +import { ReferencePopup } from './inline/presets/nodes/reference-node/reference-popup.js'; +import { RichText } from './rich-text.js'; + +export function effects() { + customElements.define('affine-text', AffineText); + customElements.define('latex-editor-menu', LatexEditorMenu); + customElements.define('latex-editor-unit', LatexEditorUnit); + customElements.define('rich-text', RichText); + customElements.define('affine-latex-node', AffineLatexNode); + customElements.define('link-popup', LinkPopup); + customElements.define('affine-link', AffineLink); + customElements.define('reference-popup', ReferencePopup); + customElements.define('reference-alias-popup', ReferenceAliasPopup); + customElements.define('affine-reference', AffineReference); +} + +declare global { + interface HTMLElementTagNameMap { + 'affine-latex-node': AffineLatexNode; + 'affine-reference': AffineReference; + 'affine-link': AffineLink; + 'affine-text': AffineText; + 'rich-text': RichText; + 'reference-popup': ReferencePopup; + 'reference-alias-popup': ReferenceAliasPopup; + 'latex-editor-unit': LatexEditorUnit; + 'latex-editor-menu': LatexEditorMenu; + 'link-popup': LinkPopup; + } + namespace BlockSuite { + interface CommandContext { + textStyle?: AffineTextAttributes; + } + interface Commands { + deleteText: typeof deleteTextCommand; + formatBlock: typeof formatBlockCommand; + formatNative: typeof formatNativeCommand; + formatText: typeof formatTextCommand; + toggleBold: typeof toggleBold; + toggleItalic: typeof toggleItalic; + toggleUnderline: typeof toggleUnderline; + toggleStrike: typeof toggleStrike; + toggleCode: typeof toggleCode; + toggleLink: typeof toggleLink; + toggleTextStyle: typeof toggleTextStyleCommand; + getTextStyle: typeof getTextStyle; + isTextStyleActive: typeof isTextStyleActive; + insertInlineLatex: typeof insertInlineLatex; + } + } +} diff --git a/blocksuite/affine/components/src/rich-text/extension/index.ts b/blocksuite/affine/components/src/rich-text/extension/index.ts new file mode 100644 index 0000000000..5d8771e273 --- /dev/null +++ b/blocksuite/affine/components/src/rich-text/extension/index.ts @@ -0,0 +1,5 @@ +export * from './inline-manager.js'; +export * from './inline-spec.js'; +export * from './markdown-matcher.js'; +export * from './ref-node-slots.js'; +export * from './type.js'; diff --git a/blocksuite/affine/components/src/rich-text/extension/inline-manager.ts b/blocksuite/affine/components/src/rich-text/extension/inline-manager.ts new file mode 100644 index 0000000000..5351c10cb0 --- /dev/null +++ b/blocksuite/affine/components/src/rich-text/extension/inline-manager.ts @@ -0,0 +1,131 @@ +import type { AffineTextAttributes } from '@blocksuite/affine-shared/types'; +import { + type BlockStdScope, + type ExtensionType, + StdIdentifier, +} from '@blocksuite/block-std'; +import { + createIdentifier, + type ServiceIdentifier, +} from '@blocksuite/global/di'; +import { + type AttributeRenderer, + baseTextAttributes, + type DeltaInsert, + getDefaultAttributeRenderer, + KEYBOARD_ALLOW_DEFAULT, + type KeyboardBindingContext, +} from '@blocksuite/inline'; +import type { Y } from '@blocksuite/store'; +import { z, type ZodObject, type ZodTypeAny } from 'zod'; + +import { MarkdownMatcherIdentifier } from './markdown-matcher.js'; +import type { InlineMarkdownMatch, InlineSpecs } from './type.js'; + +export class InlineManager { + embedChecker = (delta: DeltaInsert) => { + for (const spec of this.specs) { + if (spec.embed && spec.match(delta)) { + return true; + } + } + return false; + }; + + getRenderer = (): AttributeRenderer => { + const defaultRenderer = getDefaultAttributeRenderer(); + + const renderer: AttributeRenderer = props => { + // Priority increases from front to back + for (const spec of this.specs.toReversed()) { + if (spec.match(props.delta)) { + return spec.renderer(props); + } + } + return defaultRenderer(props); + }; + return renderer; + }; + + getSchema = (): ZodObject> => { + const defaultSchema = baseTextAttributes as unknown as ZodObject< + Record + >; + + const schema: ZodObject> = + this.specs.reduce((acc, cur) => { + const currentSchema = z.object({ + [cur.name]: cur.schema, + }) as ZodObject>; + return acc.merge(currentSchema) as ZodObject< + Record + >; + }, defaultSchema); + return schema; + }; + + markdownShortcutHandler = ( + context: KeyboardBindingContext, + undoManager: Y.UndoManager + ) => { + const { inlineEditor, prefixText, inlineRange } = context; + for (const match of this.markdownMatches) { + const matchedText = prefixText.match(match.pattern); + if (matchedText) { + return match.action({ + inlineEditor, + prefixText, + inlineRange, + pattern: match.pattern, + undoManager, + }); + } + } + + return KEYBOARD_ALLOW_DEFAULT; + }; + + readonly specs: Array>; + + constructor( + readonly std: BlockStdScope, + readonly markdownMatches: InlineMarkdownMatch[], + ...specs: Array> + ) { + this.specs = specs; + } +} + +export const InlineManagerIdentifier = createIdentifier( + 'AffineInlineManager' +); + +export type InlineManagerExtensionConfig = { + id: string; + enableMarkdown?: boolean; + specs: ServiceIdentifier>[]; +}; + +export function InlineManagerExtension({ + id, + enableMarkdown = true, + specs, +}: InlineManagerExtensionConfig): ExtensionType & { + identifier: ServiceIdentifier; +} { + const identifier = InlineManagerIdentifier(id); + return { + setup: di => { + di.addImpl(identifier, provider => { + return new InlineManager( + provider.get(StdIdentifier), + enableMarkdown + ? Array.from(provider.getAll(MarkdownMatcherIdentifier).values()) + : [], + ...specs.map(spec => provider.get(spec)) + ); + }); + }, + identifier, + }; +} diff --git a/blocksuite/affine/components/src/rich-text/extension/inline-spec.ts b/blocksuite/affine/components/src/rich-text/extension/inline-spec.ts new file mode 100644 index 0000000000..7b5aa824b9 --- /dev/null +++ b/blocksuite/affine/components/src/rich-text/extension/inline-spec.ts @@ -0,0 +1,47 @@ +import type { AffineTextAttributes } from '@blocksuite/affine-shared/types'; +import type { ExtensionType } from '@blocksuite/block-std'; +import { + createIdentifier, + type ServiceIdentifier, + type ServiceProvider, +} from '@blocksuite/global/di'; + +import type { InlineSpecs } from './type.js'; + +export const InlineSpecIdentifier = + createIdentifier>('AffineInlineSpec'); + +export function InlineSpecExtension( + name: string, + getSpec: (provider: ServiceProvider) => InlineSpecs +): ExtensionType & { + identifier: ServiceIdentifier>; +}; +export function InlineSpecExtension( + spec: InlineSpecs +): ExtensionType & { + identifier: ServiceIdentifier>; +}; +export function InlineSpecExtension( + nameOrSpec: string | InlineSpecs, + getSpec?: (provider: ServiceProvider) => InlineSpecs +): ExtensionType & { + identifier: ServiceIdentifier>; +} { + if (typeof nameOrSpec === 'string') { + const identifier = InlineSpecIdentifier(nameOrSpec); + return { + identifier, + setup: di => { + di.addImpl(identifier, provider => getSpec!(provider)); + }, + }; + } + const identifier = InlineSpecIdentifier(nameOrSpec.name); + return { + identifier, + setup: di => { + di.addImpl(identifier, nameOrSpec); + }, + }; +} diff --git a/blocksuite/affine/components/src/rich-text/extension/markdown-matcher.ts b/blocksuite/affine/components/src/rich-text/extension/markdown-matcher.ts new file mode 100644 index 0000000000..9d88b90590 --- /dev/null +++ b/blocksuite/affine/components/src/rich-text/extension/markdown-matcher.ts @@ -0,0 +1,27 @@ +import type { AffineTextAttributes } from '@blocksuite/affine-shared/types'; +import type { ExtensionType } from '@blocksuite/block-std'; +import { + createIdentifier, + type ServiceIdentifier, +} from '@blocksuite/global/di'; + +import type { InlineMarkdownMatch } from './type.js'; + +export const MarkdownMatcherIdentifier = createIdentifier< + InlineMarkdownMatch +>('AffineMarkdownMatcher'); + +export function InlineMarkdownExtension( + matcher: InlineMarkdownMatch +): ExtensionType & { + identifier: ServiceIdentifier>; +} { + const identifier = MarkdownMatcherIdentifier(matcher.name); + + return { + setup: di => { + di.addImpl(identifier, () => ({ ...matcher })); + }, + identifier, + }; +} diff --git a/blocksuite/affine/components/src/rich-text/extension/ref-node-slots.ts b/blocksuite/affine/components/src/rich-text/extension/ref-node-slots.ts new file mode 100644 index 0000000000..27f2a7abde --- /dev/null +++ b/blocksuite/affine/components/src/rich-text/extension/ref-node-slots.ts @@ -0,0 +1,20 @@ +import type { ExtensionType } from '@blocksuite/block-std'; +import { createIdentifier } from '@blocksuite/global/di'; +import { Slot } from '@blocksuite/global/utils'; + +import type { RefNodeSlots } from '../inline/index.js'; + +export const RefNodeSlotsProvider = + createIdentifier('AffineRefNodeSlots'); + +export function RefNodeSlotsExtension( + slots: RefNodeSlots = { + docLinkClicked: new Slot(), + } +): ExtensionType { + return { + setup: di => { + di.addImpl(RefNodeSlotsProvider, () => slots); + }, + }; +} diff --git a/blocksuite/affine/components/src/rich-text/extension/type.ts b/blocksuite/affine/components/src/rich-text/extension/type.ts new file mode 100644 index 0000000000..b667bcfc0e --- /dev/null +++ b/blocksuite/affine/components/src/rich-text/extension/type.ts @@ -0,0 +1,39 @@ +import type { + AttributeRenderer, + BaseTextAttributes, + DeltaInsert, + InlineEditor, + InlineRange, + KeyboardBindingHandler, +} from '@blocksuite/inline'; +import type { Y } from '@blocksuite/store'; +import type { ZodTypeAny } from 'zod'; + +export type InlineSpecs< + AffineTextAttributes extends BaseTextAttributes = BaseTextAttributes, +> = { + name: keyof AffineTextAttributes | string; + schema: ZodTypeAny; + match: (delta: DeltaInsert) => boolean; + renderer: AttributeRenderer; + embed?: boolean; +}; + +export type InlineMarkdownMatchAction< + // @ts-expect-error We allow to covariance for AffineTextAttributes + in AffineTextAttributes extends BaseTextAttributes = BaseTextAttributes, +> = (props: { + inlineEditor: InlineEditor; + prefixText: string; + inlineRange: InlineRange; + pattern: RegExp; + undoManager: Y.UndoManager; +}) => ReturnType; + +export type InlineMarkdownMatch< + AffineTextAttributes extends BaseTextAttributes = BaseTextAttributes, +> = { + name: string; + pattern: RegExp; + action: InlineMarkdownMatchAction; +}; diff --git a/blocksuite/affine/components/src/rich-text/format/config.ts b/blocksuite/affine/components/src/rich-text/format/config.ts new file mode 100644 index 0000000000..c3fe5de3f4 --- /dev/null +++ b/blocksuite/affine/components/src/rich-text/format/config.ts @@ -0,0 +1,119 @@ +import type { EditorHost } from '@blocksuite/block-std'; +import type { TemplateResult } from 'lit'; + +import { + BoldIcon, + CodeIcon, + ItalicIcon, + LinkIcon, + StrikethroughIcon, + UnderlineIcon, +} from '../../icons/index.js'; + +export interface TextFormatConfig { + id: string; + name: string; + icon: TemplateResult<1>; + hotkey?: string; + activeWhen: (host: EditorHost) => boolean; + action: (host: EditorHost) => void; +} + +export const textFormatConfigs: TextFormatConfig[] = [ + { + id: 'bold', + name: 'Bold', + icon: BoldIcon, + hotkey: 'Mod-b', + activeWhen: host => { + const [result] = host.std.command + .chain() + .isTextStyleActive({ key: 'bold' }) + .run(); + return result; + }, + action: host => { + host.std.command.chain().toggleBold().run(); + }, + }, + { + id: 'italic', + name: 'Italic', + icon: ItalicIcon, + hotkey: 'Mod-i', + activeWhen: host => { + const [result] = host.std.command + .chain() + .isTextStyleActive({ key: 'italic' }) + .run(); + return result; + }, + action: host => { + host.std.command.chain().toggleItalic().run(); + }, + }, + { + id: 'underline', + name: 'Underline', + icon: UnderlineIcon, + hotkey: 'Mod-u', + activeWhen: host => { + const [result] = host.std.command + .chain() + .isTextStyleActive({ key: 'underline' }) + .run(); + return result; + }, + action: host => { + host.std.command.chain().toggleUnderline().run(); + }, + }, + { + id: 'strike', + name: 'Strikethrough', + icon: StrikethroughIcon, + hotkey: 'Mod-shift-s', + activeWhen: host => { + const [result] = host.std.command + .chain() + .isTextStyleActive({ key: 'strike' }) + .run(); + return result; + }, + action: host => { + host.std.command.chain().toggleStrike().run(); + }, + }, + { + id: 'code', + name: 'Code', + icon: CodeIcon, + hotkey: 'Mod-e', + activeWhen: host => { + const [result] = host.std.command + .chain() + .isTextStyleActive({ key: 'code' }) + .run(); + return result; + }, + action: host => { + host.std.command.chain().toggleCode().run(); + }, + }, + { + id: 'link', + name: 'Link', + icon: LinkIcon, + hotkey: 'Mod-k', + activeWhen: host => { + const [result] = host.std.command + .chain() + .isTextStyleActive({ key: 'link' }) + .run(); + return result; + }, + action: host => { + host.std.command.chain().toggleLink().run(); + }, + }, +]; diff --git a/blocksuite/affine/components/src/rich-text/format/consts.ts b/blocksuite/affine/components/src/rich-text/format/consts.ts new file mode 100644 index 0000000000..8c91daa9ad --- /dev/null +++ b/blocksuite/affine/components/src/rich-text/format/consts.ts @@ -0,0 +1,14 @@ +// corresponding to `formatText` command +export const FORMAT_TEXT_SUPPORT_FLAVOURS = [ + 'affine:paragraph', + 'affine:list', + 'affine:code', +]; +// corresponding to `formatBlock` command +export const FORMAT_BLOCK_SUPPORT_FLAVOURS = [ + 'affine:paragraph', + 'affine:list', + 'affine:code', +]; +// corresponding to `formatNative` command +export const FORMAT_NATIVE_SUPPORT_FLAVOURS = ['affine:database']; diff --git a/blocksuite/affine/components/src/rich-text/format/delete-text.ts b/blocksuite/affine/components/src/rich-text/format/delete-text.ts new file mode 100644 index 0000000000..829c7db3cf --- /dev/null +++ b/blocksuite/affine/components/src/rich-text/format/delete-text.ts @@ -0,0 +1,83 @@ +import { matchFlavours } from '@blocksuite/affine-shared/utils'; +import type { Command, TextSelection } from '@blocksuite/block-std'; +import type { Text } from '@blocksuite/store'; + +export const deleteTextCommand: Command< + 'currentTextSelection', + never, + { + textSelection?: TextSelection; + } +> = (ctx, next) => { + const textSelection = ctx.textSelection ?? ctx.currentTextSelection; + if (!textSelection) return; + + const range = ctx.std.range.textSelectionToRange(textSelection); + if (!range) return; + const selectedElements = ctx.std.range.getSelectedBlockComponentsByRange( + range, + { + mode: 'flat', + } + ); + + const { from, to } = textSelection; + + const fromElement = selectedElements.find(el => from.blockId === el.blockId); + if (!fromElement) return; + + let fromText: Text | undefined; + if (matchFlavours(fromElement.model, ['affine:page'])) { + fromText = fromElement.model.title; + } else { + fromText = fromElement.model.text; + } + if (!fromText) return; + if (!to) { + fromText.delete(from.index, from.length); + ctx.std.selection.setGroup('note', [ + ctx.std.selection.create('text', { + from: { + blockId: from.blockId, + index: from.index, + length: 0, + }, + to: null, + }), + ]); + return next(); + } + + const toElement = selectedElements.find(el => to.blockId === el.blockId); + if (!toElement) return; + + const toText = toElement.model.text; + if (!toText) return; + + fromText.delete(from.index, from.length); + toText.delete(0, to.length); + + fromText.join(toText); + + selectedElements + .filter(el => el.model.id !== fromElement.model.id) + .forEach(el => { + ctx.std.doc.deleteBlock(el.model, { + bringChildrenTo: + el.model.id === toElement.model.id ? fromElement.model : undefined, + }); + }); + + ctx.std.selection.setGroup('note', [ + ctx.std.selection.create('text', { + from: { + blockId: from.blockId, + index: from.index, + length: 0, + }, + to: null, + }), + ]); + + next(); +}; diff --git a/blocksuite/affine/components/src/rich-text/format/format-block.ts b/blocksuite/affine/components/src/rich-text/format/format-block.ts new file mode 100644 index 0000000000..be155240d3 --- /dev/null +++ b/blocksuite/affine/components/src/rich-text/format/format-block.ts @@ -0,0 +1,71 @@ +import type { AffineTextAttributes } from '@blocksuite/affine-shared/types'; +import type { BlockSelection, Command } from '@blocksuite/block-std'; +import { assertExists } from '@blocksuite/global/utils'; +import { INLINE_ROOT_ATTR, type InlineRootElement } from '@blocksuite/inline'; + +import { FORMAT_BLOCK_SUPPORT_FLAVOURS } from './consts.js'; + +// for block selection +export const formatBlockCommand: Command< + 'currentBlockSelections', + never, + { + blockSelections?: BlockSelection[]; + styles: AffineTextAttributes; + mode?: 'replace' | 'merge'; + } +> = (ctx, next) => { + const blockSelections = ctx.blockSelections ?? ctx.currentBlockSelections; + assertExists( + blockSelections, + '`blockSelections` is required, you need to pass it in args or use `getBlockSelections` command before adding this command to the pipeline.' + ); + + if (blockSelections.length === 0) return; + + const styles = ctx.styles; + const mode = ctx.mode ?? 'merge'; + + const success = ctx.std.command + .chain() + .getSelectedBlocks({ + blockSelections, + filter: el => + FORMAT_BLOCK_SUPPORT_FLAVOURS.includes( + el.model.flavour as BlockSuite.Flavour + ), + types: ['block'], + }) + .inline((ctx, next) => { + const { selectedBlocks } = ctx; + assertExists(selectedBlocks); + + const selectedInlineEditors = selectedBlocks.flatMap(el => { + const inlineRoot = el.querySelector< + InlineRootElement + >(`[${INLINE_ROOT_ATTR}]`); + if (inlineRoot) { + return inlineRoot.inlineEditor; + } + return []; + }); + + selectedInlineEditors.forEach(inlineEditor => { + inlineEditor.formatText( + { + index: 0, + length: inlineEditor.yTextLength, + }, + styles, + { + mode, + } + ); + }); + + next(); + }) + .run(); + + if (success) next(); +}; diff --git a/blocksuite/affine/components/src/rich-text/format/format-native.ts b/blocksuite/affine/components/src/rich-text/format/format-native.ts new file mode 100644 index 0000000000..a4fa57bd48 --- /dev/null +++ b/blocksuite/affine/components/src/rich-text/format/format-native.ts @@ -0,0 +1,56 @@ +import type { AffineTextAttributes } from '@blocksuite/affine-shared/types'; +import { + BLOCK_ID_ATTR, + type BlockComponent, + type Command, +} from '@blocksuite/block-std'; +import { INLINE_ROOT_ATTR, type InlineRootElement } from '@blocksuite/inline'; + +import { FORMAT_NATIVE_SUPPORT_FLAVOURS } from './consts.js'; + +// for native range +export const formatNativeCommand: Command< + never, + never, + { + range?: Range; + styles: AffineTextAttributes; + mode?: 'replace' | 'merge'; + } +> = (ctx, next) => { + const { styles, mode = 'merge' } = ctx; + + let range = ctx.range; + if (!range) { + const selection = document.getSelection(); + if (!selection || selection.rangeCount === 0) return; + range = selection.getRangeAt(0); + } + if (!range) return; + + const selectedInlineEditors = Array.from( + ctx.std.host.querySelectorAll(`[${INLINE_ROOT_ATTR}]`) + ) + .filter(el => range?.intersectsNode(el)) + .filter(el => { + const block = el.closest(`[${BLOCK_ID_ATTR}]`); + if (block) { + return FORMAT_NATIVE_SUPPORT_FLAVOURS.includes( + block.model.flavour as BlockSuite.Flavour + ); + } + return false; + }) + .map(el => el.inlineEditor); + + selectedInlineEditors.forEach(inlineEditor => { + const inlineRange = inlineEditor.getInlineRange(); + if (!inlineRange) return; + + inlineEditor.formatText(inlineRange, styles, { + mode, + }); + }); + + next(); +}; diff --git a/blocksuite/affine/components/src/rich-text/format/format-text.ts b/blocksuite/affine/components/src/rich-text/format/format-text.ts new file mode 100644 index 0000000000..e0c81f5519 --- /dev/null +++ b/blocksuite/affine/components/src/rich-text/format/format-text.ts @@ -0,0 +1,93 @@ +import type { AffineTextAttributes } from '@blocksuite/affine-shared/types'; +import type { Command, TextSelection } from '@blocksuite/block-std'; +import { INLINE_ROOT_ATTR, type InlineRootElement } from '@blocksuite/inline'; + +import { FORMAT_TEXT_SUPPORT_FLAVOURS } from './consts.js'; +import { clearMarksOnDiscontinuousInput } from './utils.js'; + +// for text selection +export const formatTextCommand: Command< + 'currentTextSelection', + never, + { + textSelection?: TextSelection; + styles: AffineTextAttributes; + mode?: 'replace' | 'merge'; + } +> = (ctx, next) => { + const { styles, mode = 'merge' } = ctx; + + const textSelection = ctx.textSelection ?? ctx.currentTextSelection; + if (!textSelection) return; + + const success = ctx.std.command + .chain() + .getSelectedBlocks({ + textSelection, + filter: el => + FORMAT_TEXT_SUPPORT_FLAVOURS.includes( + el.model.flavour as BlockSuite.Flavour + ), + types: ['text'], + }) + .inline((ctx, next) => { + const { selectedBlocks } = ctx; + if (!selectedBlocks) return; + + const selectedInlineEditors = selectedBlocks.flatMap(el => { + const inlineRoot = el.querySelector< + InlineRootElement + >(`[${INLINE_ROOT_ATTR}]`); + if (inlineRoot && inlineRoot.inlineEditor.getInlineRange()) { + return inlineRoot.inlineEditor; + } + return []; + }); + + selectedInlineEditors.forEach(inlineEditor => { + const inlineRange = inlineEditor.getInlineRange(); + if (!inlineRange) return; + + if (inlineRange.length === 0) { + const delta = inlineEditor.getDeltaByRangeIndex(inlineRange.index); + + inlineEditor.setMarks({ + ...inlineEditor.marks, + ...Object.fromEntries( + Object.entries(styles).map(([key, value]) => { + if (typeof value === 'boolean') { + return [ + key, + (inlineEditor.marks && + inlineEditor.marks[key as keyof AffineTextAttributes]) || + (delta && + delta.attributes && + delta.attributes[key as keyof AffineTextAttributes]) + ? null + : value, + ]; + } + return [key, value]; + }) + ), + }); + clearMarksOnDiscontinuousInput(inlineEditor); + } else { + inlineEditor.formatText(inlineRange, styles, { + mode, + }); + } + }); + + Promise.all(selectedBlocks.map(el => el.updateComplete)) + .then(() => { + ctx.std.range.syncTextSelectionToRange(textSelection); + }) + .catch(console.error); + + next(); + }) + .run(); + + if (success) next(); +}; diff --git a/blocksuite/affine/components/src/rich-text/format/index.ts b/blocksuite/affine/components/src/rich-text/format/index.ts new file mode 100644 index 0000000000..3b3163e0e1 --- /dev/null +++ b/blocksuite/affine/components/src/rich-text/format/index.ts @@ -0,0 +1,49 @@ +import { getTextSelectionCommand } from '@blocksuite/affine-shared/commands'; +import type { BlockCommands } from '@blocksuite/block-std'; + +import { deleteTextCommand } from './delete-text.js'; +export type { TextFormatConfig } from './config.js'; +export { textFormatConfigs } from './config.js'; +import { formatBlockCommand } from './format-block.js'; +export { + FORMAT_BLOCK_SUPPORT_FLAVOURS, + FORMAT_NATIVE_SUPPORT_FLAVOURS, + FORMAT_TEXT_SUPPORT_FLAVOURS, +} from './consts.js'; +import { formatNativeCommand } from './format-native.js'; +import { formatTextCommand } from './format-text.js'; +import { insertInlineLatex } from './insert-inline-latex.js'; +import { + getTextStyle, + isTextStyleActive, + toggleBold, + toggleCode, + toggleItalic, + toggleLink, + toggleStrike, + toggleTextStyleCommand, + toggleUnderline, +} from './text-style.js'; +export { + clearMarksOnDiscontinuousInput, + insertContent, + isFormatSupported, +} from './utils.js'; + +export const textCommands: BlockCommands = { + deleteText: deleteTextCommand, + formatBlock: formatBlockCommand, + formatNative: formatNativeCommand, + formatText: formatTextCommand, + toggleBold: toggleBold, + toggleItalic: toggleItalic, + toggleUnderline: toggleUnderline, + toggleStrike: toggleStrike, + toggleCode: toggleCode, + toggleLink: toggleLink, + toggleTextStyle: toggleTextStyleCommand, + isTextStyleActive: isTextStyleActive, + getTextStyle: getTextStyle, + getTextSelection: getTextSelectionCommand, + insertInlineLatex: insertInlineLatex, +}; diff --git a/blocksuite/affine/components/src/rich-text/format/insert-inline-latex.ts b/blocksuite/affine/components/src/rich-text/format/insert-inline-latex.ts new file mode 100644 index 0000000000..fd3d3c21fc --- /dev/null +++ b/blocksuite/affine/components/src/rich-text/format/insert-inline-latex.ts @@ -0,0 +1,58 @@ +import type { Command, TextSelection } from '@blocksuite/block-std'; + +export const insertInlineLatex: Command< + 'currentTextSelection', + never, + { + textSelection?: TextSelection; + } +> = (ctx, next) => { + const textSelection = ctx.textSelection ?? ctx.currentTextSelection; + if (!textSelection || !textSelection.isCollapsed()) return; + + const blockComponent = ctx.std.view.getBlock(textSelection.from.blockId); + if (!blockComponent) return; + + const richText = blockComponent.querySelector('rich-text'); + if (!richText) return; + + const inlineEditor = richText.inlineEditor; + if (!inlineEditor) return; + + inlineEditor.insertText( + { + index: textSelection.from.index, + length: 0, + }, + ' ' + ); + inlineEditor.formatText( + { + index: textSelection.from.index, + length: 1, + }, + { + latex: '', + } + ); + inlineEditor.setInlineRange({ + index: textSelection.from.index, + length: 1, + }); + + inlineEditor + .waitForUpdate() + .then(async () => { + await inlineEditor.waitForUpdate(); + + const textPoint = inlineEditor.getTextPoint(textSelection.from.index + 1); + if (!textPoint) return; + const [text] = textPoint; + const latexNode = text.parentElement?.closest('affine-latex-node'); + if (!latexNode) return; + latexNode.toggleEditor(); + }) + .catch(console.error); + + next(); +}; diff --git a/blocksuite/affine/components/src/rich-text/format/text-style.ts b/blocksuite/affine/components/src/rich-text/format/text-style.ts new file mode 100644 index 0000000000..cc625a4109 --- /dev/null +++ b/blocksuite/affine/components/src/rich-text/format/text-style.ts @@ -0,0 +1,132 @@ +import type { AffineTextAttributes } from '@blocksuite/affine-shared/types'; +import type { Command } from '@blocksuite/block-std'; +import { INLINE_ROOT_ATTR, type InlineRootElement } from '@blocksuite/inline'; + +import { toggleLinkPopup } from '../inline/index.js'; +import { getCombinedTextStyle } from './utils.js'; + +export const toggleTextStyleCommand: Command< + never, + never, + { + key: Extract< + keyof AffineTextAttributes, + 'bold' | 'italic' | 'underline' | 'strike' | 'code' + >; + } +> = (ctx, next) => { + const { std, key } = ctx; + const [active] = std.command.chain().isTextStyleActive({ key }).run(); + + const payload: { + styles: AffineTextAttributes; + mode?: 'replace' | 'merge'; + } = { + styles: { + [key]: active ? null : true, + }, + }; + + const [result] = std.command + .chain() + .try(chain => [ + chain.getTextSelection().formatText(payload), + chain.getBlockSelections().formatBlock(payload), + chain.formatNative(payload), + ]) + .run(); + + if (result) { + return next(); + } + + return false; +}; + +const toggleTextStyleCommandWrapper = ( + key: Extract< + keyof AffineTextAttributes, + 'bold' | 'italic' | 'underline' | 'strike' | 'code' + > +): Command => { + return (ctx, next) => { + const { success } = ctx.std.command.exec('toggleTextStyle', { key }); + if (success) next(); + return false; + }; +}; + +export const toggleBold = toggleTextStyleCommandWrapper('bold'); +export const toggleItalic = toggleTextStyleCommandWrapper('italic'); +export const toggleUnderline = toggleTextStyleCommandWrapper('underline'); +export const toggleStrike = toggleTextStyleCommandWrapper('strike'); +export const toggleCode = toggleTextStyleCommandWrapper('code'); + +export const toggleLink: Command = (_ctx, next) => { + const selection = document.getSelection(); + if (!selection || selection.rangeCount === 0) return false; + + const range = selection.getRangeAt(0); + if (range.collapsed) return false; + const inlineRoot = range.startContainer.parentElement?.closest< + InlineRootElement + >(`[${INLINE_ROOT_ATTR}]`); + if (!inlineRoot) return false; + + const inlineEditor = inlineRoot.inlineEditor; + const targetInlineRange = inlineEditor.getInlineRange(); + + if (!targetInlineRange || targetInlineRange.length === 0) return false; + + const format = inlineEditor.getFormat(targetInlineRange); + if (format.link) { + inlineEditor.formatText(targetInlineRange, { link: null }); + return next(); + } + + const abortController = new AbortController(); + const popup = toggleLinkPopup( + inlineEditor, + 'create', + targetInlineRange, + abortController + ); + abortController.signal.addEventListener('abort', () => popup.remove()); + return next(); +}; + +export const getTextStyle: Command = (ctx, next) => { + const [result, innerCtx] = getCombinedTextStyle( + ctx.std.command.chain() + ).run(); + if (!result) { + return false; + } + + return next({ textStyle: innerCtx.textStyle }); +}; + +export const isTextStyleActive: Command< + never, + never, + { key: keyof AffineTextAttributes } +> = (ctx, next) => { + const key = ctx.key; + const [result] = getCombinedTextStyle(ctx.std.command.chain()) + .inline((ctx, next) => { + const { textStyle } = ctx; + + if (textStyle && key in textStyle) { + return next(); + } + + return false; + }) + .run(); + + if (!result) { + return false; + } + + return next(); +}; diff --git a/blocksuite/affine/components/src/rich-text/format/utils.ts b/blocksuite/affine/components/src/rich-text/format/utils.ts new file mode 100644 index 0000000000..13db010753 --- /dev/null +++ b/blocksuite/affine/components/src/rich-text/format/utils.ts @@ -0,0 +1,247 @@ +import type { AffineTextAttributes } from '@blocksuite/affine-shared/types'; +import { + BLOCK_ID_ATTR, + type BlockComponent, + type Chain, + type CommandKeyToData, + type EditorHost, + type InitCommandCtx, +} from '@blocksuite/block-std'; +import { assertExists } from '@blocksuite/global/utils'; +import { + INLINE_ROOT_ATTR, + type InlineEditor, + type InlineRange, + type InlineRootElement, +} from '@blocksuite/inline'; +import type { BlockModel } from '@blocksuite/store'; +import { effect } from '@preact/signals-core'; + +import { getInlineEditorByModel } from '../dom.js'; +import type { AffineInlineEditor } from '../inline/index.js'; +import { + FORMAT_BLOCK_SUPPORT_FLAVOURS, + FORMAT_NATIVE_SUPPORT_FLAVOURS, + FORMAT_TEXT_SUPPORT_FLAVOURS, +} from './consts.js'; + +function getCombinedFormatFromInlineEditors( + inlineEditors: [AffineInlineEditor, InlineRange | null][] +): AffineTextAttributes { + const formatArr: AffineTextAttributes[] = []; + inlineEditors.forEach(([inlineEditor, inlineRange]) => { + if (!inlineRange) return; + + const format = inlineEditor.getFormat(inlineRange); + formatArr.push(format); + }); + + if (formatArr.length === 0) return {}; + + // format will be active only when all inline editors have the same format. + return formatArr.reduce((acc, cur) => { + const newFormat: AffineTextAttributes = {}; + for (const key in acc) { + const typedKey = key as keyof AffineTextAttributes; + if (acc[typedKey] === cur[typedKey]) { + // This cast is secure because we have checked that the value of the key is the same. + + newFormat[typedKey] = acc[typedKey] as any; + } + } + return newFormat; + }); +} + +function getSelectedInlineEditors( + blocks: BlockComponent[], + filter: ( + inlineRoot: InlineRootElement + ) => InlineEditor | [] +) { + return blocks.flatMap(el => { + const inlineRoot = el.querySelector< + InlineRootElement + >(`[${INLINE_ROOT_ATTR}]`); + + if (inlineRoot) { + return filter(inlineRoot); + } + return []; + }); +} + +function handleCurrentSelection< + InlineOut extends BlockSuite.CommandDataName = never, +>( + chain: Chain, + handler: ( + type: 'text' | 'block' | 'native', + inlineEditors: InlineEditor[] + ) => CommandKeyToData | boolean | void +) { + return chain.try(chain => [ + // text selection, corresponding to `formatText` command + chain + .getTextSelection() + .getSelectedBlocks({ + types: ['text'], + filter: el => FORMAT_TEXT_SUPPORT_FLAVOURS.includes(el.model.flavour), + }) + .inline((ctx, next) => { + const { selectedBlocks } = ctx; + assertExists(selectedBlocks); + + const selectedInlineEditors = getSelectedInlineEditors( + selectedBlocks, + inlineRoot => { + const inlineRange = inlineRoot.inlineEditor.getInlineRange(); + if (!inlineRange) return []; + return inlineRoot.inlineEditor; + } + ); + + const result = handler('text', selectedInlineEditors); + if (!result) return false; + if (result === true) { + return next(); + } + return next(result); + }), + // block selection, corresponding to `formatBlock` command + chain + .getBlockSelections() + .getSelectedBlocks({ + types: ['block'], + filter: el => FORMAT_BLOCK_SUPPORT_FLAVOURS.includes(el.model.flavour), + }) + .inline((ctx, next) => { + const { selectedBlocks } = ctx; + assertExists(selectedBlocks); + + const selectedInlineEditors = getSelectedInlineEditors( + selectedBlocks, + inlineRoot => + inlineRoot.inlineEditor.yTextLength > 0 + ? inlineRoot.inlineEditor + : [] + ); + + const result = handler('block', selectedInlineEditors); + if (!result) return false; + if (result === true) { + return next(); + } + return next(result); + }), + // native selection, corresponding to `formatNative` command + chain.inline((ctx, next) => { + const selectedInlineEditors = Array.from( + ctx.std.host.querySelectorAll(`[${INLINE_ROOT_ATTR}]`) + ) + .filter(el => { + const selection = document.getSelection(); + if (!selection || selection.rangeCount === 0) return false; + const range = selection.getRangeAt(0); + + return range.intersectsNode(el); + }) + .filter(el => { + const block = el.closest(`[${BLOCK_ID_ATTR}]`); + if (block) { + return FORMAT_NATIVE_SUPPORT_FLAVOURS.includes(block.model.flavour); + } + return false; + }) + .map((el): AffineInlineEditor => el.inlineEditor); + + const result = handler('native', selectedInlineEditors); + if (!result) return false; + if (result === true) { + return next(); + } + return next(result); + }), + ]); +} + +export function getCombinedTextStyle(chain: Chain) { + return handleCurrentSelection<'textStyle'>(chain, (type, inlineEditors) => { + if (type === 'text') { + return { + textStyle: getCombinedFormatFromInlineEditors( + inlineEditors.map(e => [e, e.getInlineRange()]) + ), + }; + } + if (type === 'block') { + return { + textStyle: getCombinedFormatFromInlineEditors( + inlineEditors.map(e => [e, { index: 0, length: e.yTextLength }]) + ), + }; + } + if (type === 'native') { + return { + textStyle: getCombinedFormatFromInlineEditors( + inlineEditors.map(e => [e, e.getInlineRange()]) + ), + }; + } + return false; + }); +} + +export function isFormatSupported(chain: Chain) { + return handleCurrentSelection( + chain, + (_type, inlineEditors) => inlineEditors.length > 0 + ); +} + +// When the user selects a range, check if it matches the previous selection. +// If it does, apply the marks from the previous selection. +// If it does not, remove the marks from the previous selection. +export function clearMarksOnDiscontinuousInput( + inlineEditor: InlineEditor +): void { + let inlineRange = inlineEditor.getInlineRange(); + const dispose = effect(() => { + const r = inlineEditor.inlineRange$.value; + if ( + inlineRange && + r && + (inlineRange.index === r.index || inlineRange.index === r.index + 1) + ) { + inlineRange = r; + } else { + inlineEditor.resetMarks(); + dispose(); + } + }); +} + +export function insertContent( + editorHost: EditorHost, + model: BlockModel, + text: string, + attributes?: AffineTextAttributes +) { + if (!model.text) { + console.error("Can't insert text! Text not found"); + return; + } + const inlineEditor = getInlineEditorByModel(editorHost, model); + if (!inlineEditor) { + console.error("Can't insert text! Inline editor not found"); + return; + } + const inlineRange = inlineEditor.getInlineRange(); + const index = inlineRange ? inlineRange.index : model.text.length; + model.text.insert(text, index, attributes as Record); + // Update the caret to the end of the inserted text + inlineEditor.setInlineRange({ + index: index + text.length, + length: 0, + }); +} diff --git a/blocksuite/affine/components/src/rich-text/hooks.ts b/blocksuite/affine/components/src/rich-text/hooks.ts new file mode 100644 index 0000000000..cda63ac4df --- /dev/null +++ b/blocksuite/affine/components/src/rich-text/hooks.ts @@ -0,0 +1,116 @@ +import type { AffineTextAttributes } from '@blocksuite/affine-shared/types'; +import { isStrictUrl } from '@blocksuite/affine-shared/utils'; +import type { + BeforeinputHookCtx, + CompositionEndHookCtx, + HookContext, +} from '@blocksuite/inline'; + +const EDGE_IGNORED_ATTRIBUTES = ['code', 'link'] as const; +const GLOBAL_IGNORED_ATTRIBUTES = [] as const; + +const autoIdentifyLink = (ctx: HookContext) => { + // auto identify link only on pressing space + if (ctx.data !== ' ') { + return; + } + + // space is typed at the end of link, remove the link attribute on typed space + if (ctx.attributes?.link) { + if (ctx.inlineRange.index === ctx.inlineEditor.yText.length) { + delete ctx.attributes['link']; + } + return; + } + + const lineInfo = ctx.inlineEditor.getLine(ctx.inlineRange.index); + if (!lineInfo) { + return; + } + const { line, lineIndex, rangeIndexRelatedToLine } = lineInfo; + + if (lineIndex !== 0) { + return; + } + + const verifyData = line.vTextContent + .slice(0, rangeIndexRelatedToLine) + .split(' '); + + const verifyStr = verifyData[verifyData.length - 1]; + + const isUrl = isStrictUrl(verifyStr); + + if (!isUrl) { + return; + } + + const startIndex = ctx.inlineRange.index - verifyStr.length; + + ctx.inlineEditor.formatText( + { + index: startIndex, + length: verifyStr.length, + }, + { + link: verifyStr, + } + ); +}; + +function handleExtendedAttributes( + ctx: + | BeforeinputHookCtx + | CompositionEndHookCtx +) { + const { data, inlineEditor, inlineRange } = ctx; + const deltas = inlineEditor.getDeltasByInlineRange(inlineRange); + // eslint-disable-next-line sonarjs/no-collapsible-if + if (data && data.length > 0 && data !== '\n') { + if ( + // cursor is in the between of two deltas + (deltas.length > 1 || + // cursor is in the end of line or in the middle of a delta + (deltas.length === 1 && inlineRange.index !== 0)) && + !inlineEditor.isEmbed(deltas[0][0]) // embeds should not be extended + ) { + // each new text inserted by inline editor will not contain any attributes, + // but we want to keep the attributes of previous text or current text where the cursor is in + // here are two cases: + // 1. aaa**b|bb**ccc --input 'd'--> aaa**bdbb**ccc, d should extend the bold attribute + // 2. aaa**bbb|**ccc --input 'd'--> aaa**bbbd**ccc, d should extend the bold attribute + const { attributes } = deltas[0][0]; + if ( + deltas.length !== 1 || + inlineRange.index === inlineEditor.yText.length + ) { + // `EDGE_IGNORED_ATTRIBUTES` is which attributes should be ignored in case 2 + EDGE_IGNORED_ATTRIBUTES.forEach(attr => { + delete attributes?.[attr]; + }); + } + + // `GLOBAL_IGNORED_ATTRIBUTES` is which attributes should be ignored in case 1, 2 + GLOBAL_IGNORED_ATTRIBUTES.forEach(attr => { + delete attributes?.[attr]; + }); + + ctx.attributes = attributes ?? {}; + } + } + + return ctx; +} + +export const onVBeforeinput = ( + ctx: BeforeinputHookCtx +) => { + handleExtendedAttributes(ctx); + autoIdentifyLink(ctx); +}; + +export const onVCompositionEnd = ( + ctx: CompositionEndHookCtx +) => { + handleExtendedAttributes(ctx); +}; diff --git a/blocksuite/affine/components/src/rich-text/index.ts b/blocksuite/affine/components/src/rich-text/index.ts new file mode 100644 index 0000000000..b8297f1a0d --- /dev/null +++ b/blocksuite/affine/components/src/rich-text/index.ts @@ -0,0 +1,27 @@ +export * from './all-extensions.js'; +export { + asyncGetRichText, + asyncSetInlineRange, + focusTextModel, + getInlineEditorByModel, + getRichTextByModel, + selectTextModel, +} from './dom.js'; +export * from './effects.js'; +export * from './extension/index.js'; +export { + clearMarksOnDiscontinuousInput, + FORMAT_BLOCK_SUPPORT_FLAVOURS, + FORMAT_NATIVE_SUPPORT_FLAVOURS, + FORMAT_TEXT_SUPPORT_FLAVOURS, + insertContent, + isFormatSupported, + textCommands, + type TextFormatConfig, + textFormatConfigs, +} from './format/index.js'; +export * from './inline/index.js'; +export { textKeymap } from './keymap/index.js'; +export { insertLinkedNode } from './linked-node.js'; +export { markdownInput } from './markdown/index.js'; +export { RichText } from './rich-text.js'; diff --git a/blocksuite/affine/components/src/rich-text/inline/index.ts b/blocksuite/affine/components/src/rich-text/inline/index.ts new file mode 100644 index 0000000000..5fbeaff826 --- /dev/null +++ b/blocksuite/affine/components/src/rich-text/inline/index.ts @@ -0,0 +1,3 @@ +export * from './presets/affine-inline-specs.js'; +export * from './presets/markdown.js'; +export * from './presets/nodes/index.js'; diff --git a/blocksuite/affine/components/src/rich-text/inline/presets/affine-inline-specs.ts b/blocksuite/affine/components/src/rich-text/inline/presets/affine-inline-specs.ts new file mode 100644 index 0000000000..04518ea785 --- /dev/null +++ b/blocksuite/affine/components/src/rich-text/inline/presets/affine-inline-specs.ts @@ -0,0 +1,193 @@ +import { ReferenceInfoSchema } from '@blocksuite/affine-model'; +import type { AffineTextAttributes } from '@blocksuite/affine-shared/types'; +import { StdIdentifier } from '@blocksuite/block-std'; +import type { InlineEditor, InlineRootElement } from '@blocksuite/inline'; +import { html } from 'lit'; +import { z } from 'zod'; + +import { InlineSpecExtension } from '../../extension/index.js'; +import { + ReferenceNodeConfigIdentifier, + ReferenceNodeConfigProvider, +} from './nodes/reference-node/reference-config.js'; + +export type AffineInlineEditor = InlineEditor; +export type AffineInlineRootElement = InlineRootElement; + +export const BoldInlineSpecExtension = InlineSpecExtension({ + name: 'bold', + schema: z.literal(true).optional().nullable().catch(undefined), + match: delta => { + return !!delta.attributes?.bold; + }, + renderer: ({ delta }) => { + return html``; + }, +}); + +export const ItalicInlineSpecExtension = InlineSpecExtension({ + name: 'italic', + schema: z.literal(true).optional().nullable().catch(undefined), + match: delta => { + return !!delta.attributes?.italic; + }, + renderer: ({ delta }) => { + return html``; + }, +}); + +export const UnderlineInlineSpecExtension = InlineSpecExtension({ + name: 'underline', + schema: z.literal(true).optional().nullable().catch(undefined), + match: delta => { + return !!delta.attributes?.underline; + }, + renderer: ({ delta }) => { + return html``; + }, +}); + +export const StrikeInlineSpecExtension = InlineSpecExtension({ + name: 'strike', + schema: z.literal(true).optional().nullable().catch(undefined), + match: delta => { + return !!delta.attributes?.strike; + }, + renderer: ({ delta }) => { + return html``; + }, +}); + +export const CodeInlineSpecExtension = InlineSpecExtension({ + name: 'code', + schema: z.literal(true).optional().nullable().catch(undefined), + match: delta => { + return !!delta.attributes?.code; + }, + renderer: ({ delta }) => { + return html``; + }, +}); + +export const BackgroundInlineSpecExtension = InlineSpecExtension({ + name: 'background', + schema: z.string().optional().nullable().catch(undefined), + match: delta => { + return !!delta.attributes?.background; + }, + renderer: ({ delta }) => { + return html``; + }, +}); + +export const ColorInlineSpecExtension = InlineSpecExtension({ + name: 'color', + schema: z.string().optional().nullable().catch(undefined), + match: delta => { + return !!delta.attributes?.color; + }, + renderer: ({ delta }) => { + return html``; + }, +}); + +export const LatexInlineSpecExtension = InlineSpecExtension( + 'latex', + provider => { + const std = provider.get(StdIdentifier); + return { + name: 'latex', + schema: z.string().optional().nullable().catch(undefined), + match: delta => typeof delta.attributes?.latex === 'string', + renderer: ({ delta, selected, editor, startOffset, endOffset }) => { + return html``; + }, + embed: true, + }; + } +); + +export const ReferenceInlineSpecExtension = InlineSpecExtension( + 'reference', + provider => { + const std = provider.get(StdIdentifier); + const configProvider = new ReferenceNodeConfigProvider(std); + const config = provider.getOptional(ReferenceNodeConfigIdentifier) ?? {}; + if (config.customContent) { + configProvider.setCustomContent(config.customContent); + } + if (config.interactable !== undefined) { + configProvider.setInteractable(config.interactable); + } + if (config.hidePopup !== undefined) { + configProvider.setHidePopup(config.hidePopup); + } + return { + name: 'reference', + schema: z + .object({ + type: z.enum([ + // @deprecated Subpage is deprecated, use LinkedPage instead + 'Subpage', + 'LinkedPage', + ]), + }) + .merge(ReferenceInfoSchema) + .optional() + .nullable() + .catch(undefined), + match: delta => { + return !!delta.attributes?.reference; + }, + renderer: ({ delta, selected }) => { + return html``; + }, + embed: true, + }; + } +); + +export const LinkInlineSpecExtension = InlineSpecExtension({ + name: 'link', + schema: z.string().optional().nullable().catch(undefined), + match: delta => { + return !!delta.attributes?.link; + }, + renderer: ({ delta }) => { + return html``; + }, +}); + +export const LatexEditorUnitSpecExtension = InlineSpecExtension({ + name: 'latex-editor-unit', + schema: z.undefined(), + match: () => true, + renderer: ({ delta }) => { + return html``; + }, +}); + +export const InlineSpecExtensions = [ + BoldInlineSpecExtension, + ItalicInlineSpecExtension, + UnderlineInlineSpecExtension, + StrikeInlineSpecExtension, + CodeInlineSpecExtension, + BackgroundInlineSpecExtension, + ColorInlineSpecExtension, + LatexInlineSpecExtension, + ReferenceInlineSpecExtension, + LinkInlineSpecExtension, + LatexEditorUnitSpecExtension, +]; diff --git a/blocksuite/affine/components/src/rich-text/inline/presets/markdown.ts b/blocksuite/affine/components/src/rich-text/inline/presets/markdown.ts new file mode 100644 index 0000000000..74aae1e603 --- /dev/null +++ b/blocksuite/affine/components/src/rich-text/inline/presets/markdown.ts @@ -0,0 +1,608 @@ +/* eslint-disable no-useless-escape */ +import type { BlockComponent, ExtensionType } from '@blocksuite/block-std'; +import { + KEYBOARD_ALLOW_DEFAULT, + KEYBOARD_PREVENT_DEFAULT, +} from '@blocksuite/inline'; + +import { InlineMarkdownExtension } from '../../extension/markdown-matcher.js'; + +// inline markdown match rules: +// covert: ***test*** + space +// covert: ***t est*** + space +// not convert: *** test*** + space +// not convert: ***test *** + space +// not convert: *** test *** + space + +export const BoldItalicMarkdown = InlineMarkdownExtension({ + name: 'bolditalic', + pattern: /(?:\*\*\*)([^\s\*](?:[^*]*?[^\s\*])?)(?:\*\*\*)$/g, + action: ({ inlineEditor, prefixText, inlineRange, pattern, undoManager }) => { + const match = pattern.exec(prefixText); + if (!match) { + return KEYBOARD_ALLOW_DEFAULT; + } + + const annotatedText = match[0]; + const startIndex = inlineRange.index - annotatedText.length; + + inlineEditor.insertText( + { + index: startIndex + annotatedText.length, + length: 0, + }, + ' ' + ); + inlineEditor.setInlineRange({ + index: startIndex + annotatedText.length + 1, + length: 0, + }); + + undoManager.stopCapturing(); + + inlineEditor.formatText( + { + index: startIndex, + length: annotatedText.length, + }, + { + bold: true, + italic: true, + } + ); + + inlineEditor.deleteText({ + index: startIndex + annotatedText.length, + length: 1, + }); + inlineEditor.deleteText({ + index: startIndex + annotatedText.length - 3, + length: 3, + }); + inlineEditor.deleteText({ + index: startIndex, + length: 3, + }); + + inlineEditor.setInlineRange({ + index: startIndex + annotatedText.length - 6, + length: 0, + }); + + return KEYBOARD_PREVENT_DEFAULT; + }, +}); + +export const BoldMarkdown = InlineMarkdownExtension({ + name: 'bold', + pattern: /(?:\*\*)([^\s\*](?:[^*]*?[^\s\*])?)(?:\*\*)$/g, + action: ({ inlineEditor, prefixText, inlineRange, pattern, undoManager }) => { + const match = pattern.exec(prefixText); + if (!match) { + return KEYBOARD_ALLOW_DEFAULT; + } + const annotatedText = match[0]; + const startIndex = inlineRange.index - annotatedText.length; + + inlineEditor.insertText( + { + index: startIndex + annotatedText.length, + length: 0, + }, + ' ' + ); + inlineEditor.setInlineRange({ + index: startIndex + annotatedText.length + 1, + length: 0, + }); + + undoManager.stopCapturing(); + + inlineEditor.formatText( + { + index: startIndex, + length: annotatedText.length, + }, + { + bold: true, + } + ); + + inlineEditor.deleteText({ + index: startIndex + annotatedText.length, + length: 1, + }); + inlineEditor.deleteText({ + index: startIndex + annotatedText.length - 2, + length: 2, + }); + inlineEditor.deleteText({ + index: startIndex, + length: 2, + }); + + inlineEditor.setInlineRange({ + index: startIndex + annotatedText.length - 4, + length: 0, + }); + + return KEYBOARD_PREVENT_DEFAULT; + }, +}); + +export const ItalicExtension = InlineMarkdownExtension({ + name: 'italic', + pattern: /(?:\*)([^\s\*](?:[^*]*?[^\s\*])?)(?:\*)$/g, + action: ({ inlineEditor, prefixText, inlineRange, pattern, undoManager }) => { + const match = pattern.exec(prefixText); + if (!match) { + return KEYBOARD_ALLOW_DEFAULT; + } + const annotatedText = match[0]; + const startIndex = inlineRange.index - annotatedText.length; + + inlineEditor.insertText( + { + index: startIndex + annotatedText.length, + length: 0, + }, + ' ' + ); + inlineEditor.setInlineRange({ + index: startIndex + annotatedText.length + 1, + length: 0, + }); + + undoManager.stopCapturing(); + + inlineEditor.formatText( + { + index: startIndex, + length: annotatedText.length, + }, + { + italic: true, + } + ); + + inlineEditor.deleteText({ + index: startIndex + annotatedText.length, + length: 1, + }); + inlineEditor.deleteText({ + index: startIndex + annotatedText.length - 1, + length: 1, + }); + inlineEditor.deleteText({ + index: startIndex, + length: 1, + }); + + inlineEditor.setInlineRange({ + index: startIndex + annotatedText.length - 2, + length: 0, + }); + + return KEYBOARD_PREVENT_DEFAULT; + }, +}); + +export const StrikethroughExtension = InlineMarkdownExtension({ + name: 'strikethrough', + pattern: /(?:~~)([^\s~](?:[^~]*?[^\s~])?)(?:~~)$/g, + action: ({ inlineEditor, prefixText, inlineRange, pattern, undoManager }) => { + const match = pattern.exec(prefixText); + if (!match) { + return KEYBOARD_ALLOW_DEFAULT; + } + const annotatedText = match[0]; + const startIndex = inlineRange.index - annotatedText.length; + + inlineEditor.insertText( + { + index: startIndex + annotatedText.length, + length: 0, + }, + ' ' + ); + inlineEditor.setInlineRange({ + index: startIndex + annotatedText.length + 1, + length: 0, + }); + + undoManager.stopCapturing(); + + inlineEditor.formatText( + { + index: startIndex, + length: annotatedText.length, + }, + { + strike: true, + } + ); + + inlineEditor.deleteText({ + index: startIndex + annotatedText.length, + length: 1, + }); + inlineEditor.deleteText({ + index: startIndex + annotatedText.length - 2, + length: 2, + }); + inlineEditor.deleteText({ + index: startIndex, + length: 2, + }); + + inlineEditor.setInlineRange({ + index: startIndex + annotatedText.length - 4, + length: 0, + }); + + return KEYBOARD_PREVENT_DEFAULT; + }, +}); + +export const UnderthroughExtension = InlineMarkdownExtension({ + name: 'underthrough', + pattern: /(?:~)([^\s~](?:[^~]*?[^\s~])?)(?:~)$/g, + action: ({ inlineEditor, prefixText, inlineRange, pattern, undoManager }) => { + const match = pattern.exec(prefixText); + if (!match) { + return KEYBOARD_ALLOW_DEFAULT; + } + const annotatedText = match[0]; + const startIndex = inlineRange.index - annotatedText.length; + + inlineEditor.insertText( + { + index: startIndex + annotatedText.length, + length: 0, + }, + ' ' + ); + inlineEditor.setInlineRange({ + index: startIndex + annotatedText.length + 1, + length: 0, + }); + + undoManager.stopCapturing(); + + inlineEditor.formatText( + { + index: startIndex, + length: annotatedText.length, + }, + { + underline: true, + } + ); + + inlineEditor.deleteText({ + index: startIndex + annotatedText.length, + length: 1, + }); + inlineEditor.deleteText({ + index: inlineRange.index - 1, + length: 1, + }); + inlineEditor.deleteText({ + index: startIndex, + length: 1, + }); + + inlineEditor.setInlineRange({ + index: startIndex + annotatedText.length - 2, + length: 0, + }); + + return KEYBOARD_PREVENT_DEFAULT; + }, +}); + +export const CodeExtension = InlineMarkdownExtension({ + name: 'code', + pattern: /(?:`)([^\s`](?:[^`]*?[^\s`])?)(?:`)$/g, + action: ({ inlineEditor, prefixText, inlineRange, pattern, undoManager }) => { + const match = pattern.exec(prefixText); + if (!match) { + return KEYBOARD_ALLOW_DEFAULT; + } + const annotatedText = match[0]; + const startIndex = inlineRange.index - annotatedText.length; + + if (prefixText.match(/^([* \n]+)$/g)) { + return KEYBOARD_ALLOW_DEFAULT; + } + + inlineEditor.insertText( + { + index: startIndex + annotatedText.length, + length: 0, + }, + ' ' + ); + inlineEditor.setInlineRange({ + index: startIndex + annotatedText.length + 1, + length: 0, + }); + + undoManager.stopCapturing(); + + inlineEditor.formatText( + { + index: startIndex, + length: annotatedText.length, + }, + { + code: true, + } + ); + + inlineEditor.deleteText({ + index: startIndex + annotatedText.length, + length: 1, + }); + inlineEditor.deleteText({ + index: startIndex + annotatedText.length - 1, + length: 1, + }); + inlineEditor.deleteText({ + index: startIndex, + length: 1, + }); + + inlineEditor.setInlineRange({ + index: startIndex + annotatedText.length - 2, + length: 0, + }); + + return KEYBOARD_PREVENT_DEFAULT; + }, +}); + +export const LinkExtension = InlineMarkdownExtension({ + name: 'link', + pattern: /(?:\[(.+?)\])(?:\((.+?)\))$/g, + action: ({ inlineEditor, prefixText, inlineRange, pattern, undoManager }) => { + const startIndex = prefixText.search(pattern); + const matchedText = prefixText.match(pattern)?.[0]; + const hrefText = prefixText.match(/(?:\[(.*?)\])/g)?.[0]; + const hrefLink = prefixText.match(/(?:\((.*?)\))/g)?.[0]; + if (startIndex === -1 || !matchedText || !hrefText || !hrefLink) { + return KEYBOARD_ALLOW_DEFAULT; + } + const start = inlineRange.index - matchedText.length; + + inlineEditor.insertText( + { + index: inlineRange.index, + length: 0, + }, + ' ' + ); + inlineEditor.setInlineRange({ + index: inlineRange.index + 1, + length: 0, + }); + + undoManager.stopCapturing(); + + inlineEditor.formatText( + { + index: start, + length: hrefText.length, + }, + { + link: hrefLink.slice(1, hrefLink.length - 1), + } + ); + + inlineEditor.deleteText({ + index: inlineRange.index + matchedText.length, + length: 1, + }); + inlineEditor.deleteText({ + index: inlineRange.index - hrefLink.length - 1, + length: hrefLink.length + 1, + }); + inlineEditor.deleteText({ + index: start, + length: 1, + }); + + inlineEditor.setInlineRange({ + index: start + hrefText.length - 1, + length: 0, + }); + + return KEYBOARD_PREVENT_DEFAULT; + }, +}); + +export const LatexExtension = InlineMarkdownExtension({ + name: 'latex', + + pattern: + /(?:\$\$)(?[^\$]+)(?:\$\$)$|(?\$\$\$\$)|(?\$\$)$/g, + action: ({ inlineEditor, prefixText, inlineRange, pattern, undoManager }) => { + const match = pattern.exec(prefixText); + if (!match || !match.groups) { + return KEYBOARD_ALLOW_DEFAULT; + } + const content = match.groups['content']; + const inlinePrefix = match.groups['inlinePrefix']; + const blockPrefix = match.groups['blockPrefix']; + + if (blockPrefix === '$$$$') { + inlineEditor.insertText( + { + index: inlineRange.index, + length: 0, + }, + ' ' + ); + inlineEditor.setInlineRange({ + index: inlineRange.index + 1, + length: 0, + }); + + undoManager.stopCapturing(); + + const blockComponent = + inlineEditor.rootElement.closest('[data-block-id]'); + if (!blockComponent) return KEYBOARD_ALLOW_DEFAULT; + + const doc = blockComponent.doc; + const parentComponent = blockComponent.parentComponent; + if (!parentComponent) return KEYBOARD_ALLOW_DEFAULT; + + const index = parentComponent.model.children.indexOf( + blockComponent.model + ); + if (index === -1) return KEYBOARD_ALLOW_DEFAULT; + + inlineEditor.deleteText({ + index: inlineRange.index - 4, + length: 5, + }); + + const id = doc.addBlock( + 'affine:latex', + { + latex: '', + }, + parentComponent.model, + index + 1 + ); + blockComponent.host.updateComplete + .then(() => { + const latexBlock = blockComponent.std.view.getBlock(id); + if (!latexBlock || latexBlock.flavour !== 'affine:latex') return; + + //FIXME(@Flrande): wait for refactor + // @ts-expect-error FIXME: ts error + latexBlock.toggleEditor(); + }) + .catch(console.error); + + return KEYBOARD_PREVENT_DEFAULT; + } + + if (inlinePrefix === '$$') { + inlineEditor.insertText( + { + index: inlineRange.index, + length: 0, + }, + ' ' + ); + inlineEditor.setInlineRange({ + index: inlineRange.index + 1, + length: 0, + }); + + undoManager.stopCapturing(); + + inlineEditor.deleteText({ + index: inlineRange.index - 2, + length: 3, + }); + inlineEditor.insertText( + { + index: inlineRange.index - 2, + length: 0, + }, + ' ' + ); + inlineEditor.formatText( + { + index: inlineRange.index - 2, + length: 1, + }, + { + latex: '', + } + ); + + inlineEditor + .waitForUpdate() + .then(async () => { + await inlineEditor.waitForUpdate(); + + const textPoint = inlineEditor.getTextPoint( + inlineRange.index - 2 + 1 + ); + if (!textPoint) return; + + const [text] = textPoint; + const latexNode = text.parentElement?.closest('affine-latex-node'); + if (!latexNode) return; + + latexNode.toggleEditor(); + }) + .catch(console.error); + + return KEYBOARD_PREVENT_DEFAULT; + } + + if (!content || content.length === 0) { + return KEYBOARD_ALLOW_DEFAULT; + } + + inlineEditor.insertText( + { + index: inlineRange.index, + length: 0, + }, + ' ' + ); + inlineEditor.setInlineRange({ + index: inlineRange.index + 1, + length: 0, + }); + + undoManager.stopCapturing(); + + const startIndex = inlineRange.index - 2 - content.length - 2; + inlineEditor.deleteText({ + index: startIndex, + length: 2 + content.length + 2 + 1, + }); + inlineEditor.insertText( + { + index: startIndex, + length: 0, + }, + ' ' + ); + inlineEditor.formatText( + { + index: startIndex, + length: 1, + }, + { + latex: String.raw`${content}`, + } + ); + + inlineEditor.setInlineRange({ + index: startIndex + 1, + length: 0, + }); + + return KEYBOARD_PREVENT_DEFAULT; + }, +}); + +export const MarkdownExtensions: ExtensionType[] = [ + BoldItalicMarkdown, + BoldMarkdown, + ItalicExtension, + StrikethroughExtension, + UnderthroughExtension, + CodeExtension, + LinkExtension, + LatexExtension, +]; diff --git a/blocksuite/affine/components/src/rich-text/inline/presets/nodes/affine-text.ts b/blocksuite/affine/components/src/rich-text/inline/presets/nodes/affine-text.ts new file mode 100644 index 0000000000..1ee82a2a20 --- /dev/null +++ b/blocksuite/affine/components/src/rich-text/inline/presets/nodes/affine-text.ts @@ -0,0 +1,69 @@ +import type { AffineTextAttributes } from '@blocksuite/affine-shared/types'; +import { ShadowlessElement } from '@blocksuite/block-std'; +import { type DeltaInsert, ZERO_WIDTH_SPACE } from '@blocksuite/inline'; +import { html } from 'lit'; +import { property } from 'lit/decorators.js'; +import { type StyleInfo, styleMap } from 'lit/directives/style-map.js'; + +export function affineTextStyles( + props: AffineTextAttributes, + override?: Readonly +): StyleInfo { + let textDecorations = ''; + if (props.underline) { + textDecorations += 'underline'; + } + if (props.strike) { + textDecorations += ' line-through'; + } + + let inlineCodeStyle = {}; + if (props.code) { + inlineCodeStyle = { + 'font-family': 'var(--affine-font-code-family)', + background: 'var(--affine-background-code-block)', + border: '1px solid var(--affine-border-color)', + 'border-radius': '4px', + color: 'var(--affine-text-primary-color)', + 'font-variant-ligatures': 'none', + 'line-height': 'auto', + }; + } + + return { + 'font-weight': props.bold ? 'bolder' : 'inherit', + 'font-style': props.italic ? 'italic' : 'normal', + 'background-color': props.background ? props.background : undefined, + color: props.color ? props.color : undefined, + 'text-decoration': textDecorations.length > 0 ? textDecorations : 'none', + ...inlineCodeStyle, + ...override, + }; +} + +export class AffineText extends ShadowlessElement { + override render() { + const style = this.delta.attributes + ? affineTextStyles(this.delta.attributes) + : {}; + + // we need to avoid \n appearing before and after the span element, which will + // cause the unexpected space + if (this.delta.attributes?.code) { + return html``; + } + + // we need to avoid \n appearing before and after the span element, which will + // cause the unexpected space + return html``; + } + + @property({ type: Object }) + accessor delta: DeltaInsert = { + insert: ZERO_WIDTH_SPACE, + }; +} diff --git a/blocksuite/affine/components/src/rich-text/inline/presets/nodes/consts.ts b/blocksuite/affine/components/src/rich-text/inline/presets/nodes/consts.ts new file mode 100644 index 0000000000..9b5b260e0c --- /dev/null +++ b/blocksuite/affine/components/src/rich-text/inline/presets/nodes/consts.ts @@ -0,0 +1,2 @@ +export const REFERENCE_NODE = ' '; +export const DEFAULT_DOC_NAME = 'Untitled'; diff --git a/blocksuite/affine/components/src/rich-text/inline/presets/nodes/index.ts b/blocksuite/affine/components/src/rich-text/inline/presets/nodes/index.ts new file mode 100644 index 0000000000..19d4d1d93a --- /dev/null +++ b/blocksuite/affine/components/src/rich-text/inline/presets/nodes/index.ts @@ -0,0 +1,5 @@ +export { DEFAULT_DOC_NAME, REFERENCE_NODE } from './consts.js'; +export { AffineLink, toggleLinkPopup } from './link-node/index.js'; +export * from './reference-node/reference-config.js'; +export { AffineReference } from './reference-node/reference-node.js'; +export type { RefNodeSlots } from './reference-node/types.js'; diff --git a/blocksuite/affine/components/src/rich-text/inline/presets/nodes/latex-node/latex-editor-menu.ts b/blocksuite/affine/components/src/rich-text/inline/presets/nodes/latex-node/latex-editor-menu.ts new file mode 100644 index 0000000000..a2ddbe05b4 --- /dev/null +++ b/blocksuite/affine/components/src/rich-text/inline/presets/nodes/latex-node/latex-editor-menu.ts @@ -0,0 +1,197 @@ +import { ColorScheme } from '@blocksuite/affine-model'; +import { ThemeProvider } from '@blocksuite/affine-shared/services'; +import { unsafeCSSVar } from '@blocksuite/affine-shared/theme'; +import { type BlockStdScope, ShadowlessElement } from '@blocksuite/block-std'; +import { noop, SignalWatcher, WithDisposable } from '@blocksuite/global/utils'; +import { DoneIcon } from '@blocksuite/icons/lit'; +import type { Y } from '@blocksuite/store'; +import { DocCollection } from '@blocksuite/store'; +import { effect, type Signal, signal } from '@preact/signals-core'; +import { css, html } from 'lit'; +import { property } from 'lit/decorators.js'; +import { codeToTokensBase, type ThemedToken } from 'shiki'; + +import { InlineManagerExtension } from '../../../../extension/index.js'; +import { LatexEditorUnitSpecExtension } from '../../affine-inline-specs.js'; + +export const LatexEditorInlineManagerExtension = InlineManagerExtension({ + id: 'latex-inline-editor', + enableMarkdown: false, + specs: [LatexEditorUnitSpecExtension.identifier], +}); + +export class LatexEditorMenu extends SignalWatcher( + WithDisposable(ShadowlessElement) +) { + static override styles = css` + .latex-editor-container { + display: grid; + grid-template-columns: 1fr auto; + grid-template-rows: auto auto; + grid-template-areas: + 'editor-box confirm-box' + 'hint-box hint-box'; + + padding: 8px; + border-radius: 8px; + border: 0.5px solid ${unsafeCSSVar('borderColor')}; + background: ${unsafeCSSVar('backgroundOverlayPanelColor')}; + + /* light/toolbarShadow */ + box-shadow: 0px 6px 16px 0px rgba(0, 0, 0, 0.14); + } + + .latex-editor { + grid-area: editor-box; + width: 280px; + padding: 4px 10px; + + border-radius: 4px; + background: ${unsafeCSSVar('white10')}; + + /* light/activeShadow */ + box-shadow: 0px 0px 0px 2px rgba(30, 150, 235, 0.3); + + font-family: ${unsafeCSSVar('fontCodeFamily')}; + border: 1px solid transparent; + } + .latex-editor:focus-within { + border: 1px solid ${unsafeCSSVar('blue700')}; + } + + .latex-editor-confirm { + grid-area: confirm-box; + display: flex; + align-items: flex-end; + padding-left: 10px; + } + + .latex-editor-hint { + grid-area: hint-box; + padding-top: 6px; + + color: ${unsafeCSSVar('placeholderColor')}; + + /* MobileTypeface/caption */ + font-family: 'SF Pro Text'; + font-size: 12px; + font-style: normal; + font-weight: 400; + line-height: 16px; /* 133.333% */ + letter-spacing: -0.24px; + } + `; + + highlightTokens$: Signal = signal([]); + + yText!: Y.Text; + + get inlineManager() { + return this.std.get(LatexEditorInlineManagerExtension.identifier); + } + + get richText() { + return this.querySelector('rich-text'); + } + + private _updateHighlightTokens(text: string) { + const editorTheme = this.std.get(ThemeProvider).theme; + const theme = editorTheme === ColorScheme.Dark ? 'dark-plus' : 'light-plus'; + + codeToTokensBase(text, { + lang: 'latex', + theme, + }) + .then(token => { + this.highlightTokens$.value = token; + }) + .catch(console.error); + } + + override connectedCallback(): void { + super.connectedCallback(); + + const doc = new DocCollection.Y.Doc(); + this.yText = doc.getText('latex'); + this.yText.insert(0, this.latexSignal.value); + + const yTextObserver = () => { + const text = this.yText.toString(); + this.latexSignal.value = text; + + this._updateHighlightTokens(text); + }; + this.yText.observe(yTextObserver); + this.disposables.add(() => { + this.yText.unobserve(yTextObserver); + }); + + this.disposables.add( + effect(() => { + noop(this.highlightTokens$.value); + this.richText?.inlineEditor?.render(); + }) + ); + + this.disposables.add( + this.std.get(ThemeProvider).theme$.subscribe(() => { + this._updateHighlightTokens(this.yText.toString()); + }) + ); + + this.disposables.addFromEvent(this, 'keydown', e => { + if (e.key === 'Enter' && !e.shiftKey) { + e.preventDefault(); + e.stopPropagation(); + this.abortController.abort(); + } + }); + + this.disposables.addFromEvent(this, 'pointerdown', e => { + e.stopPropagation(); + }); + this.disposables.addFromEvent(this, 'pointerup', e => { + e.stopPropagation(); + }); + + this.updateComplete + .then(async () => { + await this.richText?.updateComplete; + + setTimeout(() => { + this.richText?.inlineEditor?.focusEnd(); + }); + }) + .catch(console.error); + } + + override render() { + return html`
+
+ +
+
+ this.abortController.abort()} + >${DoneIcon({ + width: '24', + height: '24', + })} +
+
Shift Enter to line break
+
`; + } + + @property({ attribute: false }) + accessor abortController!: AbortController; + + @property({ attribute: false }) + accessor latexSignal!: Signal; + + @property({ attribute: false }) + accessor std!: BlockStdScope; +} diff --git a/blocksuite/affine/components/src/rich-text/inline/presets/nodes/latex-node/latex-editor-unit.ts b/blocksuite/affine/components/src/rich-text/inline/presets/nodes/latex-node/latex-editor-unit.ts new file mode 100644 index 0000000000..bf13db7645 --- /dev/null +++ b/blocksuite/affine/components/src/rich-text/inline/presets/nodes/latex-node/latex-editor-unit.ts @@ -0,0 +1,54 @@ +import type { AffineTextAttributes } from '@blocksuite/affine-shared/types'; +import { ShadowlessElement } from '@blocksuite/block-std'; +import { type DeltaInsert, ZERO_WIDTH_SPACE } from '@blocksuite/inline'; +import { html } from 'lit'; +import { property } from 'lit/decorators.js'; +import { styleMap } from 'lit/directives/style-map.js'; + +export class LatexEditorUnit extends ShadowlessElement { + get latexMenu() { + return this.closest('latex-editor-menu'); + } + + get vElement() { + return this.closest('v-element'); + } + + override render() { + const plainContent = html``; + + const latexMenu = this.latexMenu; + const vElement = this.vElement; + if (!latexMenu || !vElement) { + return plainContent; + } + + const lineIndex = this.vElement.lineIndex; + const tokens = latexMenu.highlightTokens$.value[lineIndex] ?? []; + if ( + tokens.length === 0 || + tokens.reduce((acc, token) => acc + token.content, '') !== + this.delta.insert + ) { + return plainContent; + } + + return html`${tokens.map(token => { + return html``; + })}`; + } + + @property({ attribute: false }) + accessor delta: DeltaInsert = { + insert: ZERO_WIDTH_SPACE, + }; +} diff --git a/blocksuite/affine/components/src/rich-text/inline/presets/nodes/latex-node/latex-node.ts b/blocksuite/affine/components/src/rich-text/inline/presets/nodes/latex-node/latex-node.ts new file mode 100644 index 0000000000..6e6e79b779 --- /dev/null +++ b/blocksuite/affine/components/src/rich-text/inline/presets/nodes/latex-node/latex-node.ts @@ -0,0 +1,237 @@ +import { unsafeCSSVar, unsafeCSSVarV2 } from '@blocksuite/affine-shared/theme'; +import type { AffineTextAttributes } from '@blocksuite/affine-shared/types'; +import { + type BlockComponent, + type BlockStdScope, + ShadowlessElement, +} from '@blocksuite/block-std'; +import { SignalWatcher, WithDisposable } from '@blocksuite/global/utils'; +import { + type DeltaInsert, + type InlineEditor, + ZERO_WIDTH_NON_JOINER, + ZERO_WIDTH_SPACE, +} from '@blocksuite/inline'; +import { effect, signal } from '@preact/signals-core'; +import katex from 'katex'; +import { css, html, render } from 'lit'; +import { property } from 'lit/decorators.js'; + +import { createLitPortal } from '../../../../../portal/helper.js'; + +export class AffineLatexNode extends SignalWatcher( + WithDisposable(ShadowlessElement) +) { + static override styles = css` + affine-latex-node { + display: inline-block; + } + + affine-latex-node .affine-latex { + white-space: nowrap; + word-break: break-word; + color: ${unsafeCSSVar('textPrimaryColor')}; + fill: var(--affine-icon-color); + border-radius: 4px; + text-decoration: none; + cursor: pointer; + user-select: none; + padding: 1px 2px 1px 0; + display: grid; + grid-template-columns: auto 0; + place-items: center; + padding: 0 4px; + margin: 0 2px; + } + affine-latex-node .affine-latex:hover { + background: ${unsafeCSSVar('hoverColor')}; + } + affine-latex-node .affine-latex[data-selected='true'] { + background: ${unsafeCSSVar('hoverColor')}; + } + + affine-latex-node .error-placeholder { + display: flex; + padding: 2px 4px; + justify-content: center; + align-items: flex-start; + gap: 10px; + + border-radius: 4px; + background: ${ + // @ts-expect-error FIXME: ts error + unsafeCSSVarV2('label/red') + }; + + color: ${unsafeCSSVarV2('text/highlight/fg/red')}; + font-family: Inter; + font-size: 12px; + font-weight: 500; + line-height: normal; + } + + affine-latex-node .placeholder { + display: flex; + padding: 2px 4px; + justify-content: center; + align-items: flex-start; + + border-radius: 4px; + background: ${unsafeCSSVarV2('layer/background/secondary')}; + + color: ${unsafeCSSVarV2('text/secondary')}; + font-family: Inter; + font-size: 12px; + font-weight: 500; + line-height: normal; + } + `; + + private _editorAbortController: AbortController | null = null; + + readonly latex$ = signal(''); + + get deltaLatex() { + return this.delta.attributes?.latex as string; + } + + get latexContainer() { + return this.querySelector('.latex-container'); + } + + override connectedCallback() { + const result = super.connectedCallback(); + + this.latex$.value = this.deltaLatex; + + this.disposables.add( + effect(() => { + const latex = this.latex$.value; + + if (latex !== this.deltaLatex) { + this.editor.formatText( + { + index: this.startOffset, + length: this.endOffset - this.startOffset, + }, + { + latex, + } + ); + } + + this.updateComplete + .then(() => { + const latexContainer = this.latexContainer; + if (!latexContainer) return; + + latexContainer.replaceChildren(); + // @ts-expect-error FIXME: ts error + delete latexContainer['_$litPart$']; + + if (latex.length === 0) { + render( + html`Equation`, + latexContainer + ); + } else { + try { + katex.render(latex, latexContainer, { + displayMode: true, + output: 'mathml', + }); + } catch { + latexContainer.replaceChildren(); + // @ts-expect-error FIXME: ts error + delete latexContainer['_$litPart$']; + render( + html`Error equation`, + latexContainer + ); + } + } + }) + .catch(console.error); + }) + ); + + this._editorAbortController?.abort(); + this._editorAbortController = new AbortController(); + this.disposables.add(() => { + this._editorAbortController?.abort(); + }); + + this.disposables.addFromEvent(this, 'click', e => { + e.preventDefault(); + e.stopPropagation(); + this.toggleEditor(); + }); + + return result; + } + + override render() { + return html`
+
`; + } + + toggleEditor() { + const blockComponent = this.closest('[data-block-id]'); + if (!blockComponent) return; + + this._editorAbortController?.abort(); + this._editorAbortController = new AbortController(); + + const portal = createLitPortal({ + template: html``, + container: blockComponent.host, + computePosition: { + referenceElement: this, + placement: 'bottom-start', + autoUpdate: { + animationFrame: true, + }, + }, + closeOnClickAway: true, + abortController: this._editorAbortController, + shadowDom: false, + portalStyles: { + zIndex: 'var(--affine-z-index-popover)', + }, + }); + + this._editorAbortController.signal.addEventListener( + 'abort', + () => { + portal.remove(); + }, + { once: true } + ); + } + + @property({ attribute: false }) + accessor delta: DeltaInsert = { + insert: ZERO_WIDTH_SPACE, + }; + + @property({ attribute: false }) + accessor editor!: InlineEditor; + + @property({ attribute: false }) + accessor endOffset!: number; + + @property({ attribute: false }) + accessor selected = false; + + @property({ attribute: false }) + accessor startOffset!: number; + + @property({ attribute: false }) + accessor std!: BlockStdScope; +} diff --git a/blocksuite/affine/components/src/rich-text/inline/presets/nodes/link-node/affine-link.ts b/blocksuite/affine/components/src/rich-text/inline/presets/nodes/link-node/affine-link.ts new file mode 100644 index 0000000000..69674cd18a --- /dev/null +++ b/blocksuite/affine/components/src/rich-text/inline/presets/nodes/link-node/affine-link.ts @@ -0,0 +1,187 @@ +import type { ReferenceInfo } from '@blocksuite/affine-model'; +import { ParseDocUrlProvider } from '@blocksuite/affine-shared/services'; +import type { AffineTextAttributes } from '@blocksuite/affine-shared/types'; +import type { BlockComponent } from '@blocksuite/block-std'; +import { BLOCK_ID_ATTR, ShadowlessElement } from '@blocksuite/block-std'; +import { + type DeltaInsert, + INLINE_ROOT_ATTR, + type InlineRootElement, + ZERO_WIDTH_SPACE, +} from '@blocksuite/inline'; +import { css, html } from 'lit'; +import { property } from 'lit/decorators.js'; +import { ref } from 'lit/directives/ref.js'; +import { type StyleInfo, styleMap } from 'lit/directives/style-map.js'; + +import { HoverController } from '../../../../../hover/index.js'; +import { RefNodeSlotsProvider } from '../../../../extension/index.js'; +import { affineTextStyles } from '../affine-text.js'; +import { toggleLinkPopup } from './link-popup/toggle-link-popup.js'; + +export class AffineLink extends ShadowlessElement { + static override styles = css` + affine-link a:hover [data-v-text='true'] { + text-decoration: underline; + } + `; + + // The link has been identified. + private _identified: boolean = false; + + // see https://github.com/toeverything/AFFiNE/issues/1540 + private _onMouseUp = () => { + const anchorElement = this.querySelector('a'); + if (!anchorElement || !anchorElement.isContentEditable) return; + anchorElement.contentEditable = 'false'; + setTimeout(() => { + anchorElement.removeAttribute('contenteditable'); + }, 0); + }; + + private _referenceInfo: ReferenceInfo | null = null; + + openLink = (e?: MouseEvent) => { + if (!this._identified) { + this._identified = true; + this._identify(); + } + + const referenceInfo = this._referenceInfo; + if (!referenceInfo) return; + + const refNodeSlotsProvider = this.std?.getOptional(RefNodeSlotsProvider); + if (!refNodeSlotsProvider) return; + + e?.preventDefault(); + + refNodeSlotsProvider.docLinkClicked.emit(referenceInfo); + }; + + private _whenHover = new HoverController( + this, + ({ abortController }) => { + if (this.block?.doc.readonly) { + return null; + } + if (!this.inlineEditor || !this.selfInlineRange) { + return null; + } + + const selection = this.std?.selection; + const textSelection = selection?.find('text'); + if (!!textSelection && !textSelection.isCollapsed()) { + return null; + } + + const blockSelections = selection?.filter('block'); + if (blockSelections?.length) { + return null; + } + + return { + template: toggleLinkPopup( + this.inlineEditor, + 'view', + this.selfInlineRange, + abortController, + (e?: MouseEvent) => { + this.openLink(e); + abortController.abort(); + } + ), + }; + }, + { enterDelay: 500 } + ); + + // Workaround for links not working in contenteditable div + // see also https://stackoverflow.com/questions/12059211/how-to-make-clickable-anchor-in-contenteditable-div + // + // Note: We cannot use JS to directly open a new page as this may be blocked by the browser. + // + // Please also note that when readonly mode active, + // this workaround is not necessary and links work normally. + get block() { + const block = this.inlineEditor?.rootElement.closest( + `[${BLOCK_ID_ATTR}]` + ); + return block; + } + + get inlineEditor() { + const inlineRoot = this.closest>( + `[${INLINE_ROOT_ATTR}]` + ); + return inlineRoot?.inlineEditor; + } + + get link() { + return this.delta.attributes?.link ?? ''; + } + + get selfInlineRange() { + const selfInlineRange = this.inlineEditor?.getInlineRangeFromElement(this); + return selfInlineRange; + } + + get std() { + const std = this.block?.std; + return std; + } + + // Identify if url is an internal link + private _identify() { + const link = this.link; + if (!link) return; + + const result = this.std + ?.getOptional(ParseDocUrlProvider) + ?.parseDocUrl(link); + if (!result) return; + + const { docId: pageId, ...params } = result; + + this._referenceInfo = { pageId, params }; + } + + private _renderLink(style: StyleInfo) { + return html``; + } + + override render() { + const linkStyle = { + color: 'var(--affine-link-color)', + fill: 'var(--affine-link-color)', + 'text-decoration': 'none', + cursor: 'pointer', + }; + + if (this.delta.attributes && this.delta.attributes?.code) { + const codeStyle = affineTextStyles(this.delta.attributes); + return html` + ${this._renderLink(linkStyle)} + `; + } + + const style = this.delta.attributes + ? affineTextStyles(this.delta.attributes, linkStyle) + : {}; + + return this._renderLink(style); + } + + @property({ type: Object }) + accessor delta: DeltaInsert = { + insert: ZERO_WIDTH_SPACE, + }; +} diff --git a/blocksuite/affine/components/src/rich-text/inline/presets/nodes/link-node/index.ts b/blocksuite/affine/components/src/rich-text/inline/presets/nodes/link-node/index.ts new file mode 100644 index 0000000000..7546542a9e --- /dev/null +++ b/blocksuite/affine/components/src/rich-text/inline/presets/nodes/link-node/index.ts @@ -0,0 +1,2 @@ +export { AffineLink } from './affine-link.js'; +export { toggleLinkPopup } from './link-popup/toggle-link-popup.js'; diff --git a/blocksuite/affine/components/src/rich-text/inline/presets/nodes/link-node/link-popup/link-popup.ts b/blocksuite/affine/components/src/rich-text/inline/presets/nodes/link-node/link-popup/link-popup.ts new file mode 100644 index 0000000000..f4e1445f91 --- /dev/null +++ b/blocksuite/affine/components/src/rich-text/inline/presets/nodes/link-node/link-popup/link-popup.ts @@ -0,0 +1,689 @@ +import { + EmbedOptionProvider, + type LinkEventType, + type TelemetryEvent, + TelemetryProvider, +} from '@blocksuite/affine-shared/services'; +import type { EmbedOptions } from '@blocksuite/affine-shared/types'; +import { + getHostName, + isValidUrl, + normalizeUrl, + stopPropagation, +} from '@blocksuite/affine-shared/utils'; +import { + BLOCK_ID_ATTR, + type BlockComponent, + type BlockStdScope, +} from '@blocksuite/block-std'; +import { WithDisposable } from '@blocksuite/global/utils'; +import type { InlineRange } from '@blocksuite/inline/types'; +import { computePosition, inline, offset, shift } from '@floating-ui/dom'; +import { html, LitElement, nothing } from 'lit'; +import { property, query } from 'lit/decorators.js'; +import { choose } from 'lit/directives/choose.js'; +import { join } from 'lit/directives/join.js'; +import { repeat } from 'lit/directives/repeat.js'; + +import { + ConfirmIcon, + CopyIcon, + DeleteIcon, + EditIcon, + MoreVerticalIcon, + OpenIcon, + SmallArrowDownIcon, + UnlinkIcon, +} from '../../../../../../icons/index.js'; +import { toast } from '../../../../../../toast/index.js'; +import type { EditorIconButton } from '../../../../../../toolbar/index.js'; +import { + renderActions, + renderToolbarSeparator, +} from '../../../../../../toolbar/index.js'; +import type { AffineInlineEditor } from '../../../affine-inline-specs.js'; +import { linkPopupStyle } from './styles.js'; + +export class LinkPopup extends WithDisposable(LitElement) { + static override styles = linkPopupStyle; + + private _bodyOverflowStyle = ''; + + private _createTemplate = () => { + this.updateComplete + .then(() => { + this.linkInput?.focus(); + + this._updateConfirmBtn(); + }) + .catch(console.error); + + return html` + + `; + }; + + private _delete = () => { + if (this.inlineEditor.isValidInlineRange(this.targetInlineRange)) { + this.inlineEditor.deleteText(this.targetInlineRange); + } + this.abortController.abort(); + }; + + private _edit = () => { + if (!this.host) return; + + this.type = 'edit'; + + track(this.host.std, 'OpenedAliasPopup', { control: 'edit' }); + }; + + private _editTemplate = () => { + this.updateComplete + .then(() => { + if ( + !this.textInput || + !this.linkInput || + !this.currentText || + !this.currentLink + ) + return; + + this.textInput.value = this.currentText; + this.linkInput.value = this.currentLink; + + this.textInput.select(); + + this._updateConfirmBtn(); + }) + .catch(console.error); + + return html` + + `; + }; + + private _embedOptions: EmbedOptions | null = null; + + private _openLink = () => { + if (this.openLink) { + this.openLink(); + return; + } + + let link = this.currentLink; + if (!link) return; + if (!link.match(/^[a-zA-Z]+:\/\//)) { + link = 'https://' + link; + } + window.open(link, '_blank'); + this.abortController.abort(); + }; + + private _removeLink = () => { + if (this.inlineEditor.isValidInlineRange(this.targetInlineRange)) { + this.inlineEditor.formatText(this.targetInlineRange, { + link: null, + }); + } + this.abortController.abort(); + }; + + private _toggleViewSelector = (e: Event) => { + if (!this.host) return; + + const opened = (e as CustomEvent).detail; + if (!opened) return; + + track(this.host.std, 'OpenedViewSelector', { control: 'switch view' }); + }; + + private _trackViewSelected = (type: string) => { + if (!this.host) return; + + track(this.host.std, 'SelectedView', { + control: 'select view', + type: `${type} view`, + }); + }; + + private _viewTemplate = () => { + if (!this.currentLink) return; + + this._embedOptions = + this.std + ?.get(EmbedOptionProvider) + .getEmbedBlockOptions(this.currentLink) ?? null; + + const buttons = [ + html` + this.openLink?.(e)} + > + ${getHostName(this.currentLink)} + + + + ${CopyIcon} + + + + ${EditIcon} + + `, + + this._viewSelector(), + + html` + + ${MoreVerticalIcon} + + `} + > +
+ ${this._moreActions()} +
+
+ `, + ]; + + return html` + + ${join( + buttons.filter(button => button !== nothing), + renderToolbarSeparator + )} + + `; + }; + + private get _canConvertToEmbedView() { + return this._embedOptions?.viewType === 'embed'; + } + + private get _isBookmarkAllowed() { + const block = this.block; + if (!block) return false; + const schema = block.doc.schema; + const parent = block.doc.getParent(block.model); + if (!parent) return false; + const bookmarkSchema = schema.flavourSchemaMap.get('affine:bookmark'); + if (!bookmarkSchema) return false; + const parentSchema = schema.flavourSchemaMap.get(parent.flavour); + if (!parentSchema) return false; + + try { + schema.validateSchema(bookmarkSchema, parentSchema); + } catch { + return false; + } + + return true; + } + + get block() { + const { rootElement } = this.inlineEditor; + if (!rootElement) return null; + + const block = rootElement.closest(`[${BLOCK_ID_ATTR}]`); + if (!block) return null; + return block; + } + + get currentLink() { + return this.inlineEditor.getFormat(this.targetInlineRange).link; + } + + get currentText() { + return this.inlineEditor.yTextString.slice( + this.targetInlineRange.index, + this.targetInlineRange.index + this.targetInlineRange.length + ); + } + + get host() { + return this.block?.host; + } + + get std() { + return this.block?.std; + } + + private _confirmBtnTemplate() { + return html` + + ${ConfirmIcon} + + `; + } + + private _convertToCardView() { + if (!this.inlineEditor.isValidInlineRange(this.targetInlineRange)) { + return; + } + + let targetFlavour = 'affine:bookmark'; + + if (this._embedOptions && this._embedOptions.viewType === 'card') { + targetFlavour = this._embedOptions.flavour; + } + + const block = this.block; + if (!block) return; + const url = this.currentLink; + const title = this.currentText; + const props = { + url, + title: title === url ? '' : title, + }; + const doc = block.doc; + const parent = doc.getParent(block.model); + if (!parent) return; + const index = parent.children.indexOf(block.model); + doc.addBlock(targetFlavour as never, props, parent, index + 1); + + const totalTextLength = this.inlineEditor.yTextLength; + const inlineTextLength = this.targetInlineRange.length; + if (totalTextLength === inlineTextLength) { + doc.deleteBlock(block.model); + } else { + this.inlineEditor.formatText(this.targetInlineRange, { link: null }); + } + + this.abortController.abort(); + } + + private _convertToEmbedView() { + if (!this._embedOptions || this._embedOptions.viewType !== 'embed') { + return; + } + + const { flavour } = this._embedOptions; + const url = this.currentLink; + + const block = this.block; + if (!block) return; + const doc = block.doc; + const parent = doc.getParent(block.model); + if (!parent) return; + const index = parent.children.indexOf(block.model); + + doc.addBlock(flavour as never, { url }, parent, index + 1); + + const totalTextLength = this.inlineEditor.yTextLength; + const inlineTextLength = this.targetInlineRange.length; + if (totalTextLength === inlineTextLength) { + doc.deleteBlock(block.model); + } else { + this.inlineEditor.formatText(this.targetInlineRange, { link: null }); + } + + this.abortController.abort(); + } + + private _copyUrl() { + if (!this.currentLink) return; + navigator.clipboard.writeText(this.currentLink).catch(console.error); + if (!this.host) return; + toast(this.host, 'Copied link to clipboard'); + this.abortController.abort(); + + track(this.host.std, 'CopiedLink', { control: 'copy link' }); + } + + private _moreActions() { + return renderActions([ + [ + { + label: 'Open', + type: 'open', + icon: OpenIcon, + action: this._openLink, + }, + + { + label: 'Copy', + type: 'copy', + icon: CopyIcon, + action: this._copyUrl, + }, + + { + label: 'Remove link', + type: 'remove-link', + icon: UnlinkIcon, + action: this._removeLink, + }, + ], + + [ + { + type: 'delete', + label: 'Delete', + icon: DeleteIcon, + action: this._delete, + }, + ], + ]); + } + + private _onConfirm() { + if (!this.inlineEditor.isValidInlineRange(this.targetInlineRange)) return; + if (!this.linkInput) return; + + const linkInputValue = this.linkInput.value; + if (!linkInputValue || !isValidUrl(linkInputValue)) return; + + const link = normalizeUrl(linkInputValue); + + if (this.type === 'create') { + this.inlineEditor.formatText(this.targetInlineRange, { + link: link, + reference: null, + }); + this.inlineEditor.setInlineRange(this.targetInlineRange); + const textSelection = this.host?.selection.find('text'); + if (!textSelection) return; + + this.std?.range.syncTextSelectionToRange(textSelection); + } else if (this.type === 'edit') { + const text = this.textInput?.value ?? link; + this.inlineEditor.insertText(this.targetInlineRange, text, { + link: link, + reference: null, + }); + this.inlineEditor.setInlineRange({ + index: this.targetInlineRange.index, + length: text.length, + }); + const textSelection = this.host?.selection.find('text'); + if (!textSelection) return; + + this.std?.range.syncTextSelectionToRange(textSelection); + } + + this.abortController.abort(); + } + + private _onKeydown(e: KeyboardEvent) { + e.stopPropagation(); + if (e.key === 'Enter' && !e.isComposing) { + e.preventDefault(); + this._onConfirm(); + } + } + + private _updateConfirmBtn() { + if (!this.confirmButton) { + return; + } + const link = this.linkInput?.value.trim(); + const disabled = !(link && isValidUrl(link)); + this.confirmButton.disabled = disabled; + this.confirmButton.active = !disabled; + this.confirmButton.requestUpdate(); + } + + private _viewSelector() { + if (!this._isBookmarkAllowed) return nothing; + + const buttons = []; + + buttons.push({ + type: 'inline', + label: 'Inline view', + }); + + buttons.push({ + type: 'card', + label: 'Card view', + action: () => this._convertToCardView(), + }); + + if (this._canConvertToEmbedView) { + buttons.push({ + type: 'embed', + label: 'Embed view', + action: () => this._convertToEmbedView(), + }); + } + + return html` + +
Inline view
+ ${SmallArrowDownIcon} + + `} + @toggle=${this._toggleViewSelector} + > +
+ ${repeat( + buttons, + button => button.type, + ({ type, label, action }) => html` + { + action?.(); + this._trackViewSelected(type); + }} + > + ${label} + + ` + )} +
+
+ `; + } + + override connectedCallback() { + super.connectedCallback(); + + if (this.targetInlineRange.length === 0) { + return; + } + + if (this.type === 'edit' || this.type === 'create') { + // disable body scroll + this._bodyOverflowStyle = document.body.style.overflow; + document.body.style.overflow = 'hidden'; + this.disposables.add({ + dispose: () => { + document.body.style.overflow = this._bodyOverflowStyle; + }, + }); + } + } + + protected override firstUpdated() { + if (!this.linkInput) return; + + this._disposables.addFromEvent(this.linkInput, 'copy', stopPropagation); + this._disposables.addFromEvent(this.linkInput, 'cut', stopPropagation); + this._disposables.addFromEvent(this.linkInput, 'paste', stopPropagation); + } + + override render() { + return html` +
+ ${this.type === 'view' + ? nothing + : html` + + `} + +
+
+ `; + } + + override updated() { + const range = this.inlineEditor.toDomRange(this.targetInlineRange); + if (!range) { + return; + } + + if (this.type !== 'view') { + const domRects = range.getClientRects(); + + Object.values(domRects).forEach(domRect => { + if (!this.mockSelectionContainer) { + return; + } + const mockSelection = document.createElement('div'); + mockSelection.classList.add('mock-selection'); + mockSelection.style.left = `${domRect.left}px`; + mockSelection.style.top = `${domRect.top}px`; + mockSelection.style.width = `${domRect.width}px`; + mockSelection.style.height = `${domRect.height}px`; + + this.mockSelectionContainer.append(mockSelection); + }); + } + + const visualElement = { + getBoundingClientRect: () => range.getBoundingClientRect(), + getClientRects: () => range.getClientRects(), + }; + computePosition(visualElement, this.popupContainer, { + middleware: [ + offset(10), + inline(), + shift({ + padding: 6, + }), + ], + }) + .then(({ x, y }) => { + const popupContainer = this.popupContainer; + if (!popupContainer) return; + popupContainer.style.left = `${x}px`; + popupContainer.style.top = `${y}px`; + }) + .catch(console.error); + } + + @property({ attribute: false }) + accessor abortController!: AbortController; + + @query('.affine-confirm-button') + accessor confirmButton: EditorIconButton | null = null; + + @property({ attribute: false }) + accessor inlineEditor!: AffineInlineEditor; + + @query('#link-input') + accessor linkInput: HTMLInputElement | null = null; + + @query('.mock-selection-container') + accessor mockSelectionContainer!: HTMLDivElement; + + @property({ attribute: false }) + accessor openLink: ((e?: MouseEvent) => void) | null = null; + + @query('.affine-link-popover-container') + accessor popupContainer!: HTMLDivElement; + + @property({ attribute: false }) + accessor targetInlineRange!: InlineRange; + + @query('#text-input') + accessor textInput: HTMLInputElement | null = null; + + @property() + accessor type: 'create' | 'edit' | 'view' = 'create'; +} + +function track( + std: BlockStdScope, + event: LinkEventType, + props: Partial +) { + std.getOptional(TelemetryProvider)?.track(event, { + segment: 'toolbar', + page: 'doc editor', + module: 'link toolbar', + type: 'inline view', + category: 'link', + ...props, + }); +} diff --git a/blocksuite/affine/components/src/rich-text/inline/presets/nodes/link-node/link-popup/styles.ts b/blocksuite/affine/components/src/rich-text/inline/presets/nodes/link-node/link-popup/styles.ts new file mode 100644 index 0000000000..4c13bf89a0 --- /dev/null +++ b/blocksuite/affine/components/src/rich-text/inline/presets/nodes/link-node/link-popup/styles.ts @@ -0,0 +1,191 @@ +import { FONT_XS, PANEL_BASE } from '@blocksuite/affine-shared/styles'; +import { css } from 'lit'; + +const editLinkStyle = css` + .affine-link-edit-popover { + ${PANEL_BASE}; + display: grid; + grid-template-columns: auto auto; + grid-template-rows: repeat(2, 1fr); + grid-template-areas: + 'text-area .' + 'link-area btn'; + justify-items: center; + align-items: center; + width: 320px; + gap: 8px 12px; + padding: 12px; + box-sizing: content-box; + } + + .affine-link-edit-popover label { + box-sizing: border-box; + color: var(--affine-icon-color); + ${FONT_XS}; + font-weight: 400; + } + + .affine-link-edit-popover input { + color: inherit; + padding: 0; + border: none; + background: transparent; + color: var(--affine-text-primary-color); + ${FONT_XS}; + } + .affine-link-edit-popover input::placeholder { + color: var(--affine-placeholder-color); + } + input:focus { + outline: none; + } + .affine-link-edit-popover input:focus ~ label, + .affine-link-edit-popover input:active ~ label { + color: var(--affine-primary-color); + } + + .affine-edit-area { + width: 280px; + padding: 4px 10px; + display: grid; + gap: 8px; + grid-template-columns: 26px auto; + grid-template-rows: repeat(1, 1fr); + grid-template-areas: 'label input'; + user-select: none; + box-sizing: border-box; + + border: 1px solid var(--affine-border-color); + box-sizing: border-box; + + outline: none; + border-radius: 4px; + background: transparent; + } + .affine-edit-area:focus-within { + border-color: var(--affine-blue-700); + box-shadow: var(--affine-active-shadow); + } + + .affine-edit-area.text { + grid-area: text-area; + } + + .affine-edit-area.link { + grid-area: link-area; + } + + .affine-edit-label { + grid-area: label; + } + + .affine-edit-input { + grid-area: input; + } + + .affine-confirm-button { + grid-area: btn; + user-select: none; + } +`; + +export const linkPopupStyle = css` + :host { + box-sizing: border-box; + } + + .mock-selection { + position: absolute; + background-color: rgba(35, 131, 226, 0.28); + } + + .affine-link-popover-container { + z-index: var(--affine-z-index-popover); + animation: affine-popover-fade-in 0.2s ease; + position: absolute; + } + + @keyframes affine-popover-fade-in { + from { + opacity: 0; + transform: translateY(-3px); + } + to { + opacity: 1; + transform: translateY(0); + } + } + + .affine-link-popover-overlay-mask { + position: fixed; + top: 0; + left: 0; + width: 100vw; + height: 100vh; + z-index: var(--affine-z-index-popover); + } + + .affine-link-preview { + display: flex; + justify-content: flex-start; + min-width: 60px; + max-width: 140px; + padding: var(--1, 0px); + border-radius: var(--1, 0px); + opacity: var(--add, 1); + user-select: none; + cursor: pointer; + + color: var(--affine-link-color); + font-feature-settings: + 'clig' off, + 'liga' off; + font-family: var(--affine-font-family); + font-size: var(--affine-font-sm); + font-style: normal; + font-weight: 400; + text-decoration: none; + text-wrap: nowrap; + } + + .affine-link-preview > span { + display: inline-block; + -webkit-line-clamp: 1; + -webkit-box-orient: vertical; + + text-overflow: ellipsis; + overflow: hidden; + opacity: var(--add, 1); + } + + .affine-link-popover.create { + ${PANEL_BASE}; + gap: 12px; + padding: 12px; + + color: var(--affine-text-primary-color); + } + + .affine-link-popover-input { + min-width: 280px; + height: 30px; + box-sizing: border-box; + padding: 4px 10px; + background: var(--affine-white-10); + border-radius: 4px; + border-width: 1px; + border-style: solid; + border-color: var(--affine-border-color); + color: var(--affine-text-primary-color); + ${FONT_XS}; + } + .affine-link-popover-input::placeholder { + color: var(--affine-placeholder-color); + } + .affine-link-popover-input:focus { + border-color: var(--affine-blue-700); + box-shadow: var(--affine-active-shadow); + } + + ${editLinkStyle} +`; diff --git a/blocksuite/affine/components/src/rich-text/inline/presets/nodes/link-node/link-popup/toggle-link-popup.ts b/blocksuite/affine/components/src/rich-text/inline/presets/nodes/link-node/link-popup/toggle-link-popup.ts new file mode 100644 index 0000000000..8ef9186b96 --- /dev/null +++ b/blocksuite/affine/components/src/rich-text/inline/presets/nodes/link-node/link-popup/toggle-link-popup.ts @@ -0,0 +1,23 @@ +import type { InlineRange } from '@blocksuite/inline'; + +import type { AffineInlineEditor } from '../../../affine-inline-specs.js'; +import { LinkPopup } from './link-popup.js'; + +export function toggleLinkPopup( + inlineEditor: AffineInlineEditor, + type: LinkPopup['type'], + targetInlineRange: InlineRange, + abortController: AbortController, + openLink: ((e?: MouseEvent) => void) | null = null +): LinkPopup { + const popup = new LinkPopup(); + popup.inlineEditor = inlineEditor; + popup.type = type; + popup.targetInlineRange = targetInlineRange; + popup.openLink = openLink; + popup.abortController = abortController; + + document.body.append(popup); + + return popup; +} diff --git a/blocksuite/affine/components/src/rich-text/inline/presets/nodes/reference-node/reference-alias-popup.ts b/blocksuite/affine/components/src/rich-text/inline/presets/nodes/reference-node/reference-alias-popup.ts new file mode 100644 index 0000000000..1abdb3f3f2 --- /dev/null +++ b/blocksuite/affine/components/src/rich-text/inline/presets/nodes/reference-node/reference-alias-popup.ts @@ -0,0 +1,284 @@ +import type { ReferenceInfo } from '@blocksuite/affine-model'; +import { + type LinkEventType, + type TelemetryEvent, + TelemetryProvider, +} from '@blocksuite/affine-shared/services'; +import { FONT_XS, PANEL_BASE } from '@blocksuite/affine-shared/styles'; +import type { AffineTextAttributes } from '@blocksuite/affine-shared/types'; +import { type BlockStdScope, ShadowlessElement } from '@blocksuite/block-std'; +import { + assertExists, + SignalWatcher, + WithDisposable, +} from '@blocksuite/global/utils'; +import { DoneIcon, ResetIcon } from '@blocksuite/icons/lit'; +import type { DeltaInsert, InlineRange } from '@blocksuite/inline'; +import { computePosition, inline, offset, shift } from '@floating-ui/dom'; +import { signal } from '@preact/signals-core'; +import { css, html } from 'lit'; +import { property, query } from 'lit/decorators.js'; +import { live } from 'lit/directives/live.js'; + +import type { EditorIconButton } from '../../../../../toolbar/index.js'; +import type { AffineInlineEditor } from '../../affine-inline-specs.js'; +import { REFERENCE_NODE } from '../consts.js'; + +export class ReferenceAliasPopup extends SignalWatcher( + WithDisposable(ShadowlessElement) +) { + static override styles = css` + :host { + box-sizing: border-box; + } + + .overlay-mask { + position: fixed; + z-index: var(--affine-z-index-popover); + top: 0; + left: 0; + width: 100vw; + height: 100vh; + } + + .alias-form-popup { + ${PANEL_BASE}; + position: absolute; + display: flex; + width: 321px; + height: 37px; + gap: 8px; + box-sizing: content-box; + justify-content: space-between; + align-items: center; + animation: affine-popover-fade-in 0.2s ease; + z-index: var(--affine-z-index-popover); + } + + @keyframes affine-popover-fade-in { + from { + opacity: 0; + transform: translateY(-3px); + } + to { + opacity: 1; + transform: translateY(0); + } + } + + input { + display: flex; + flex: 1; + padding: 0; + border: none; + background: transparent; + color: var(--affine-text-primary-color); + ${FONT_XS}; + } + input::placeholder { + color: var(--affine-placeholder-color); + } + input:focus { + outline: none; + } + + editor-icon-button.save .label { + ${FONT_XS}; + color: inherit; + text-transform: none; + } + `; + + private _onSave = () => { + const title = this.title$.value.trim(); + if (!title) { + this.remove(); + return; + } + + this._setTitle(title); + + track(this.std, 'SavedAlias', { control: 'save' }); + + this.remove(); + }; + + private _updateTitle = (e: InputEvent) => { + const target = e.target as HTMLInputElement; + const value = target.value; + this.title$.value = value; + }; + + private _onKeydown(e: KeyboardEvent) { + e.stopPropagation(); + if (!e.isComposing) { + if (e.key === 'Escape') { + e.preventDefault(); + this.remove(); + return; + } + if (e.key === 'Enter') { + e.preventDefault(); + this._onSave(); + } + } + } + + private _onReset() { + this.title$.value = this.docTitle; + + this._setTitle(); + + track(this.std, 'ResetedAlias', { control: 'reset' }); + + this.remove(); + } + + private _setTitle(title?: string) { + const reference: AffineTextAttributes['reference'] = { + type: 'LinkedPage', + ...this.referenceInfo, + }; + + if (title) { + reference.title = title; + } else { + delete reference.title; + delete reference.description; + } + + this.inlineEditor.insertText(this.inlineRange, REFERENCE_NODE, { + reference, + }); + this.inlineEditor.setInlineRange({ + index: this.inlineRange.index + REFERENCE_NODE.length, + length: 0, + }); + } + + override connectedCallback() { + super.connectedCallback(); + + this.title$.value = this.referenceInfo.title ?? this.docTitle; + } + + override firstUpdated() { + this.disposables.addFromEvent(this.overlayMask, 'click', e => { + e.stopPropagation(); + this.remove(); + }); + this.disposables.addFromEvent(this, 'keydown', this._onKeydown); + + this.inputElement.focus(); + this.inputElement.select(); + } + + override render() { + return html` +
+
+
+ + + ${ResetIcon({ width: '16px', height: '16px' })} + + + + ${DoneIcon({ width: '16px', height: '16px' })} + Save + +
+
+ `; + } + + override updated() { + const range = this.inlineEditor.toDomRange(this.inlineRange); + assertExists(range); + + const visualElement = { + getBoundingClientRect: () => range.getBoundingClientRect(), + getClientRects: () => range.getClientRects(), + }; + computePosition(visualElement, this.popupContainer, { + middleware: [ + offset(10), + inline(), + shift({ + padding: 6, + }), + ], + }) + .then(({ x, y }) => { + const popupContainer = this.popupContainer; + if (!popupContainer) return; + popupContainer.style.left = `${x}px`; + popupContainer.style.top = `${y}px`; + }) + .catch(console.error); + } + + @property({ type: Object }) + accessor delta!: DeltaInsert; + + @property({ attribute: false }) + accessor docTitle!: string; + + @property({ attribute: false }) + accessor inlineEditor!: AffineInlineEditor; + + @property({ attribute: false }) + accessor inlineRange!: InlineRange; + + @query('input#alias-title') + accessor inputElement!: HTMLInputElement; + + @query('.overlay-mask') + accessor overlayMask!: HTMLDivElement; + + @query('.alias-form-popup') + accessor popupContainer!: HTMLDivElement; + + @property({ type: Object }) + accessor referenceInfo!: ReferenceInfo; + + @query('editor-icon-button.save') + accessor saveButton!: EditorIconButton; + + @property({ attribute: false }) + accessor std!: BlockStdScope; + + accessor title$ = signal(''); +} + +function track( + std: BlockStdScope, + event: LinkEventType, + props: Partial +) { + std.getOptional(TelemetryProvider)?.track(event, { + segment: 'toolbar', + page: 'doc editor', + module: 'reference edit popup', + type: 'inline view', + category: 'linked doc', + ...props, + }); +} diff --git a/blocksuite/affine/components/src/rich-text/inline/presets/nodes/reference-node/reference-config.ts b/blocksuite/affine/components/src/rich-text/inline/presets/nodes/reference-node/reference-config.ts new file mode 100644 index 0000000000..b4347c5038 --- /dev/null +++ b/blocksuite/affine/components/src/rich-text/inline/presets/nodes/reference-node/reference-config.ts @@ -0,0 +1,64 @@ +import type { BlockStdScope, ExtensionType } from '@blocksuite/block-std'; +import { createIdentifier } from '@blocksuite/global/di'; +import type { TemplateResult } from 'lit'; + +import type { AffineReference } from './reference-node.js'; + +export interface ReferenceNodeConfig { + customContent?: (reference: AffineReference) => TemplateResult; + interactable?: boolean; + hidePopup?: boolean; +} + +export const ReferenceNodeConfigIdentifier = + createIdentifier('AffineReferenceNodeConfig'); + +export function ReferenceNodeConfigExtension( + config: ReferenceNodeConfig +): ExtensionType { + return { + setup: di => { + di.addImpl(ReferenceNodeConfigIdentifier, () => ({ ...config })); + }, + }; +} + +export class ReferenceNodeConfigProvider { + private _customContent: + | ((reference: AffineReference) => TemplateResult) + | undefined = undefined; + + private _hidePopup = false; + + private _interactable = true; + + get customContent() { + return this._customContent; + } + + get doc() { + return this.std.doc; + } + + get hidePopup() { + return this._hidePopup; + } + + get interactable() { + return this._interactable; + } + + constructor(readonly std: BlockStdScope) {} + + setCustomContent(content: ReferenceNodeConfigProvider['_customContent']) { + this._customContent = content; + } + + setHidePopup(hidePopup: boolean) { + this._hidePopup = hidePopup; + } + + setInteractable(interactable: boolean) { + this._interactable = interactable; + } +} diff --git a/blocksuite/affine/components/src/rich-text/inline/presets/nodes/reference-node/reference-node.ts b/blocksuite/affine/components/src/rich-text/inline/presets/nodes/reference-node/reference-node.ts new file mode 100644 index 0000000000..6879b51c2c --- /dev/null +++ b/blocksuite/affine/components/src/rich-text/inline/presets/nodes/reference-node/reference-node.ts @@ -0,0 +1,318 @@ +import type { ReferenceInfo } from '@blocksuite/affine-model'; +import { DocDisplayMetaProvider } from '@blocksuite/affine-shared/services'; +import type { AffineTextAttributes } from '@blocksuite/affine-shared/types'; +import { + cloneReferenceInfo, + referenceToNode, +} from '@blocksuite/affine-shared/utils'; +import { + BLOCK_ID_ATTR, + type BlockComponent, + ShadowlessElement, +} from '@blocksuite/block-std'; +import { BlockSuiteError, ErrorCode } from '@blocksuite/global/exceptions'; +import { WithDisposable } from '@blocksuite/global/utils'; +import { LinkedPageIcon } from '@blocksuite/icons/lit'; +import { + type DeltaInsert, + INLINE_ROOT_ATTR, + type InlineRootElement, + ZERO_WIDTH_NON_JOINER, + ZERO_WIDTH_SPACE, +} from '@blocksuite/inline'; +import type { Doc, DocMeta } from '@blocksuite/store'; +import { css, html, nothing } from 'lit'; +import { property, state } from 'lit/decorators.js'; +import { choose } from 'lit/directives/choose.js'; +import { ifDefined } from 'lit/directives/if-defined.js'; +import { ref } from 'lit/directives/ref.js'; +import { styleMap } from 'lit/directives/style-map.js'; + +import { HoverController } from '../../../../../hover/index.js'; +import { Peekable } from '../../../../../peek/index.js'; +import { RefNodeSlotsProvider } from '../../../../extension/index.js'; +import { affineTextStyles } from '../affine-text.js'; +import { DEFAULT_DOC_NAME, REFERENCE_NODE } from '../consts.js'; +import type { ReferenceNodeConfigProvider } from './reference-config.js'; +import { toggleReferencePopup } from './reference-popup.js'; + +@Peekable({ action: false }) +export class AffineReference extends WithDisposable(ShadowlessElement) { + static override styles = css` + .affine-reference { + white-space: normal; + word-break: break-word; + color: var(--affine-text-primary-color); + fill: var(--affine-icon-color); + border-radius: 4px; + text-decoration: none; + cursor: pointer; + user-select: none; + padding: 1px 2px 1px 0; + } + .affine-reference:hover { + background: var(--affine-hover-color); + } + + .affine-reference[data-selected='true'] { + background: var(--affine-hover-color); + } + + .affine-reference-title { + margin-left: 4px; + border-bottom: 0.5px solid var(--affine-divider-color); + transition: border 0.2s ease-out; + } + .affine-reference-title:hover { + border-bottom: 0.5px solid var(--affine-icon-color); + } + `; + + private _updateRefMeta = (doc: Doc) => { + const refAttribute = this.delta.attributes?.reference; + if (!refAttribute) { + return; + } + + const refMeta = doc.collection.meta.docMetas.find( + doc => doc.id === refAttribute.pageId + ); + this.refMeta = refMeta + ? { + ...refMeta, + } + : undefined; + }; + + // Since the linked doc may be deleted, the `_refMeta` could be undefined. + @state() + accessor refMeta: DocMeta | undefined = undefined; + + private _whenHover: HoverController = new HoverController( + this, + ({ abortController }) => { + if ( + this.config.hidePopup || + this.doc?.readonly || + this.closest('.prevent-reference-popup') || + !this.selfInlineRange || + !this.inlineEditor + ) { + return null; + } + + const selection = this.std?.selection; + if (!selection) { + return null; + } + const textSelection = selection.find('text'); + if (!!textSelection && !textSelection.isCollapsed()) { + return null; + } + + const blockSelections = selection.filter('block'); + if (blockSelections.length) { + return null; + } + + return { + template: toggleReferencePopup( + this, + this.referenceToNode(), + this.referenceInfo, + this.inlineEditor, + this.selfInlineRange, + this.refMeta?.title ?? DEFAULT_DOC_NAME, + abortController + ), + }; + }, + { enterDelay: 500 } + ); + + get _icon() { + const { pageId, params, title } = this.referenceInfo; + return this.block?.std + ?.get(DocDisplayMetaProvider) + .icon(pageId, { params, title, referenced: true }).value; + } + + get _title() { + const { pageId, params, title } = this.referenceInfo; + return ( + title || + this.block?.std + ?.get(DocDisplayMetaProvider) + .title(pageId, { params, title, referenced: true }).value + ); + } + + get block() { + const block = this.inlineEditor?.rootElement.closest( + `[${BLOCK_ID_ATTR}]` + ); + return block; + } + + get customContent() { + return this.config.customContent; + } + + get doc() { + const doc = this.config.doc; + return doc; + } + + get inlineEditor() { + const inlineRoot = this.closest>( + `[${INLINE_ROOT_ATTR}]` + ); + return inlineRoot?.inlineEditor; + } + + get referenceInfo(): ReferenceInfo { + const reference = this.delta.attributes?.reference; + const id = this.doc?.id ?? ''; + if (!reference) return { pageId: id }; + return cloneReferenceInfo(reference); + } + + get selfInlineRange() { + const selfInlineRange = this.inlineEditor?.getInlineRangeFromElement(this); + return selfInlineRange; + } + + get std() { + const std = this.block?.std; + if (!std) { + throw new BlockSuiteError( + ErrorCode.ValueNotExists, + 'std not found in reference node' + ); + } + return std; + } + + private _onClick() { + if (!this.config.interactable) return; + this.std + .getOptional(RefNodeSlotsProvider) + ?.docLinkClicked.emit(this.referenceInfo); + } + + override connectedCallback() { + super.connectedCallback(); + + if (!this.config) { + console.error('`reference-node` need `ReferenceNodeConfig`.'); + return; + } + + if (this.delta.insert !== REFERENCE_NODE) { + console.error( + `Reference node must be initialized with '${REFERENCE_NODE}', but got '${this.delta.insert}'` + ); + } + + const doc = this.doc; + if (doc) { + this._disposables.add( + doc.collection.slots.docUpdated.on(() => this._updateRefMeta(doc)) + ); + } + + this.updateComplete + .then(() => { + if (!this.inlineEditor || !doc) return; + + // observe yText update + this.disposables.add( + this.inlineEditor.slots.textChange.on(() => this._updateRefMeta(doc)) + ); + }) + .catch(console.error); + } + + // reference to block/element + referenceToNode() { + return referenceToNode(this.referenceInfo); + } + + override render() { + const refMeta = this.refMeta; + const isDeleted = !refMeta; + + const attributes = this.delta.attributes; + const reference = attributes?.reference; + const type = reference?.type; + if (!attributes || !type) { + return nothing; + } + + const title = this._title; + const icon = choose(type, [ + ['LinkedPage', () => this._icon], + [ + 'Subpage', + () => + LinkedPageIcon({ + width: '1.25em', + height: '1.25em', + style: + 'user-select:none;flex-shrink:0;vertical-align:middle;font-size:inherit;margin-bottom:0.1em;', + }), + ], + ]); + + const style = affineTextStyles( + attributes, + isDeleted + ? { + color: 'var(--affine-text-disable-color)', + textDecoration: 'line-through', + fill: 'var(--affine-text-disable-color)', + } + : {} + ); + + const content = this.customContent + ? this.customContent(this) + : html`${icon}${title}`; + + // we need to add `` in an + // embed element to make sure inline range calculation is correct + return html`${content}`; + } + + override willUpdate(_changedProperties: Map) { + super.willUpdate(_changedProperties); + + const doc = this.doc; + if (doc) { + this._updateRefMeta(doc); + } + } + + @property({ attribute: false }) + accessor config!: ReferenceNodeConfigProvider; + + @property({ type: Object }) + accessor delta: DeltaInsert = { + insert: ZERO_WIDTH_SPACE, + attributes: {}, + }; + + @property({ type: Boolean }) + accessor selected = false; +} diff --git a/blocksuite/affine/components/src/rich-text/inline/presets/nodes/reference-node/reference-popup.ts b/blocksuite/affine/components/src/rich-text/inline/presets/nodes/reference-node/reference-popup.ts new file mode 100644 index 0000000000..d85675de9f --- /dev/null +++ b/blocksuite/affine/components/src/rich-text/inline/presets/nodes/reference-node/reference-popup.ts @@ -0,0 +1,559 @@ +import type { ReferenceInfo } from '@blocksuite/affine-model'; +import { + GenerateDocUrlProvider, + type LinkEventType, + type TelemetryEvent, + TelemetryProvider, +} from '@blocksuite/affine-shared/services'; +import { + cloneReferenceInfoWithoutAliases, + isInsideBlockByFlavour, +} from '@blocksuite/affine-shared/utils'; +import { + BLOCK_ID_ATTR, + type BlockComponent, + type BlockStdScope, +} from '@blocksuite/block-std'; +import { assertExists, WithDisposable } from '@blocksuite/global/utils'; +import type { InlineRange } from '@blocksuite/inline'; +import { computePosition, inline, offset, shift } from '@floating-ui/dom'; +import { effect } from '@preact/signals-core'; +import { html, LitElement, nothing } from 'lit'; +import { property, query } from 'lit/decorators.js'; +import { ifDefined } from 'lit/directives/if-defined.js'; +import { join } from 'lit/directives/join.js'; +import { repeat } from 'lit/directives/repeat.js'; + +import { + CenterPeekIcon, + CopyIcon, + DeleteIcon, + EditIcon, + ExpandFullSmallIcon, + MoreVerticalIcon, + OpenIcon, + SmallArrowDownIcon, +} from '../../../../../icons/index.js'; +import { notifyLinkedDocSwitchedToEmbed } from '../../../../../notification/index.js'; +import { isPeekable, peek } from '../../../../../peek/index.js'; +import { toast } from '../../../../../toast/toast.js'; +import { + type MenuItem, + renderActions, + renderToolbarSeparator, +} from '../../../../../toolbar/index.js'; +import { RefNodeSlotsProvider } from '../../../../extension/index.js'; +import type { AffineInlineEditor } from '../../affine-inline-specs.js'; +import { ReferenceAliasPopup } from './reference-alias-popup.js'; +import { styles } from './styles.js'; + +export class ReferencePopup extends WithDisposable(LitElement) { + static override styles = styles; + + private _copyLink = () => { + const url = this.std + .getOptional(GenerateDocUrlProvider) + ?.generateDocUrl(this.referenceInfo.pageId, this.referenceInfo.params); + + if (url) { + navigator.clipboard.writeText(url).catch(console.error); + toast(this.std.host, 'Copied link to clipboard'); + } + + this.abortController.abort(); + + track(this.std, 'CopiedLink', { control: 'copy link' }); + }; + + private _openDoc = () => { + this.std + .getOptional(RefNodeSlotsProvider) + ?.docLinkClicked.emit(this.referenceInfo); + }; + + private _openEditPopup = (e: MouseEvent) => { + e.stopPropagation(); + + if (document.body.querySelector('reference-alias-popup')) { + return; + } + + const { + std, + docTitle, + referenceInfo, + inlineEditor, + targetInlineRange, + abortController, + } = this; + + const aliasPopup = new ReferenceAliasPopup(); + + aliasPopup.std = std; + aliasPopup.docTitle = docTitle; + aliasPopup.referenceInfo = referenceInfo; + aliasPopup.inlineEditor = inlineEditor; + aliasPopup.inlineRange = targetInlineRange; + + document.body.append(aliasPopup); + + abortController.abort(); + + track(std, 'OpenedAliasPopup', { control: 'edit' }); + }; + + private _toggleViewSelector = (e: Event) => { + const opened = (e as CustomEvent).detail; + if (!opened) return; + + track(this.std, 'OpenedViewSelector', { control: 'switch view' }); + }; + + private _trackViewSelected = (type: string) => { + track(this.std, 'SelectedView', { + control: 'select view', + type: `${type} view`, + }); + }; + + get _embedViewButtonDisabled() { + if ( + this.block.doc.readonly || + isInsideBlockByFlavour( + this.block.doc, + this.block.model, + 'affine:edgeless-text' + ) + ) { + return true; + } + return ( + !!this.block.closest('affine-embed-synced-doc-block') || + this.referenceDocId === this.doc.id + ); + } + + get _openButtonDisabled() { + return this.referenceDocId === this.doc.id; + } + + get block() { + const block = this.inlineEditor.rootElement.closest( + `[${BLOCK_ID_ATTR}]` + ); + assertExists(block); + return block; + } + + get doc() { + const doc = this.block.doc; + assertExists(doc); + return doc; + } + + get referenceDocId() { + const docId = this.inlineEditor.getFormat(this.targetInlineRange).reference + ?.pageId; + assertExists(docId); + return docId; + } + + get std() { + const std = this.block.std; + assertExists(std); + return std; + } + + private _convertToCardView() { + const block = this.block; + const doc = block.host.doc; + const parent = doc.getParent(block.model); + assertExists(parent); + + const index = parent.children.indexOf(block.model); + + doc.addBlock( + 'affine:embed-linked-doc', + this.referenceInfo, + parent, + index + 1 + ); + + const totalTextLength = this.inlineEditor.yTextLength; + const inlineTextLength = this.targetInlineRange.length; + if (totalTextLength === inlineTextLength) { + doc.deleteBlock(block.model); + } else { + this.inlineEditor.insertText(this.targetInlineRange, this.docTitle); + } + + this.abortController.abort(); + } + + private _convertToEmbedView() { + const block = this.block; + const std = block.std; + const doc = block.host.doc; + const parent = doc.getParent(block.model); + assertExists(parent); + + const index = parent.children.indexOf(block.model); + const referenceInfo = this.referenceInfo; + const hasTitleAlias = Boolean(referenceInfo.title); + + doc.addBlock( + 'affine:embed-synced-doc', + cloneReferenceInfoWithoutAliases(referenceInfo), + parent, + index + 1 + ); + + const totalTextLength = this.inlineEditor.yTextLength; + const inlineTextLength = this.targetInlineRange.length; + if (totalTextLength === inlineTextLength) { + doc.deleteBlock(block.model); + } else { + this.inlineEditor.insertText(this.targetInlineRange, this.docTitle); + } + + if (hasTitleAlias) { + notifyLinkedDocSwitchedToEmbed(std); + } + + this.abortController.abort(); + } + + private _delete() { + if (this.inlineEditor.isValidInlineRange(this.targetInlineRange)) { + this.inlineEditor.deleteText(this.targetInlineRange); + } + this.abortController.abort(); + } + + private _moreActions() { + return renderActions([ + [ + { + type: 'delete', + label: 'Delete', + icon: DeleteIcon, + disabled: this.doc.readonly, + action: () => this._delete(), + }, + ], + ]); + } + + private _openMenuButton() { + const buttons: MenuItem[] = [ + { + label: 'Open this doc', + type: 'open-this-doc', + icon: ExpandFullSmallIcon, + action: this._openDoc, + disabled: this._openButtonDisabled, + }, + ]; + + // open in new tab + + if (isPeekable(this.target)) { + buttons.push({ + label: 'Open in center peek', + type: 'open-in-center-peek', + icon: CenterPeekIcon, + action: () => peek(this.target), + }); + } + + // open in split view + + if (buttons.length === 0) { + return nothing; + } + + return html` + + ${OpenIcon}${SmallArrowDownIcon} + + `} + > +
+ ${repeat( + buttons, + button => button.label, + ({ label, icon, action, disabled }) => html` + + ${icon}${label} + + ` + )} +
+
+ `; + } + + private _viewSelector() { + // synced doc entry controlled by awareness flag + const isSyncedDocEnabled = this.doc.awarenessStore.getFlag( + 'enable_synced_doc_block' + ); + const buttons = []; + + buttons.push({ + type: 'inline', + label: 'Inline view', + }); + + buttons.push({ + type: 'card', + label: 'Card view', + action: () => this._convertToCardView(), + disabled: this.doc.readonly, + }); + + if (isSyncedDocEnabled) { + buttons.push({ + type: 'embed', + label: 'Embed view', + action: () => this._convertToEmbedView(), + disabled: + this.doc.readonly || + this.isLinkedNode || + this._embedViewButtonDisabled, + }); + } + + return html` + + Inline view + ${SmallArrowDownIcon} + + `} + @toggle=${this._toggleViewSelector} + > +
+ ${repeat( + buttons, + button => button.type, + ({ type, label, action, disabled }) => html` + { + action?.(); + this._trackViewSelected(type); + }} + > + ${label} + + ` + )} +
+
+ `; + } + + override connectedCallback() { + super.connectedCallback(); + + if (this.targetInlineRange.length === 0) { + return; + } + + const parent = this.block.host.doc.getParent(this.block.model); + assertExists(parent); + + this.disposables.add( + effect(() => { + const children = parent.children; + if (children.includes(this.block.model)) return; + this.abortController.abort(); + }) + ); + } + + override render() { + const titleButton = this.referenceInfo.title + ? html` + + ${this.docTitle} + + ` + : nothing; + + const buttons = [ + this._openMenuButton(), + + html` + ${titleButton} + + + ${CopyIcon} + + + + ${EditIcon} + + `, + + this._viewSelector(), + + html` + + ${MoreVerticalIcon} + + `} + > +
+ ${this._moreActions()} +
+
+ `, + ]; + + return html` +
+
+ + ${join( + buttons.filter(button => button !== nothing), + renderToolbarSeparator + )} + +
+
+ `; + } + + override updated() { + assertExists(this.popupContainer); + const range = this.inlineEditor.toDomRange(this.targetInlineRange); + assertExists(range); + + const visualElement = { + getBoundingClientRect: () => range.getBoundingClientRect(), + getClientRects: () => range.getClientRects(), + }; + computePosition(visualElement, this.popupContainer, { + middleware: [ + offset(10), + inline(), + shift({ + padding: 6, + }), + ], + }) + .then(({ x, y }) => { + const popupContainer = this.popupContainer; + if (!popupContainer) return; + popupContainer.style.left = `${x}px`; + popupContainer.style.top = `${y}px`; + }) + .catch(console.error); + } + + @property({ attribute: false }) + accessor abortController!: AbortController; + + @property({ attribute: false }) + accessor docTitle!: string; + + @property({ attribute: false }) + accessor inlineEditor!: AffineInlineEditor; + + @property({ attribute: false }) + accessor isLinkedNode!: boolean; + + @query('.affine-reference-popover-container') + accessor popupContainer!: HTMLDivElement; + + @property({ type: Object }) + accessor referenceInfo!: ReferenceInfo; + + @property({ attribute: false }) + accessor target!: LitElement; + + @property({ attribute: false }) + accessor targetInlineRange!: InlineRange; +} + +export function toggleReferencePopup( + target: LitElement, + isLinkedNode: boolean, + referenceInfo: ReferenceInfo, + inlineEditor: AffineInlineEditor, + targetInlineRange: InlineRange, + docTitle: string, + abortController: AbortController +): ReferencePopup { + const popup = new ReferencePopup(); + popup.target = target; + popup.isLinkedNode = isLinkedNode; + popup.referenceInfo = referenceInfo; + popup.inlineEditor = inlineEditor; + popup.targetInlineRange = targetInlineRange; + popup.docTitle = docTitle; + popup.abortController = abortController; + + document.body.append(popup); + + return popup; +} + +function track( + std: BlockStdScope, + event: LinkEventType, + props: Partial +) { + std.getOptional(TelemetryProvider)?.track(event, { + segment: 'toolbar', + page: 'doc editor', + module: 'reference toolbar', + type: 'inline view', + category: 'linked doc', + ...props, + }); +} diff --git a/blocksuite/affine/components/src/rich-text/inline/presets/nodes/reference-node/styles.ts b/blocksuite/affine/components/src/rich-text/inline/presets/nodes/reference-node/styles.ts new file mode 100644 index 0000000000..0079ee412f --- /dev/null +++ b/blocksuite/affine/components/src/rich-text/inline/presets/nodes/reference-node/styles.ts @@ -0,0 +1,44 @@ +import { css } from 'lit'; + +export const styles = css` + :host { + box-sizing: border-box; + } + + .affine-reference-popover-container { + z-index: var(--affine-z-index-popover); + animation: affine-popover-fade-in 0.2s ease; + position: absolute; + } + + @keyframes affine-popover-fade-in { + from { + opacity: 0; + transform: translateY(-3px); + } + to { + opacity: 1; + transform: translateY(0); + } + } + + editor-icon-button.doc-title .label { + max-width: 110px; + display: inline-block; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + user-select: none; + cursor: pointer; + color: var(--affine-link-color); + font-feature-settings: + 'clig' off, + 'liga' off; + font-family: var(--affine-font-family); + font-size: var(--affine-font-sm); + font-style: normal; + font-weight: 400; + text-decoration: none; + text-wrap: nowrap; + } +`; diff --git a/blocksuite/affine/components/src/rich-text/inline/presets/nodes/reference-node/types.ts b/blocksuite/affine/components/src/rich-text/inline/presets/nodes/reference-node/types.ts new file mode 100644 index 0000000000..d5e4501d9d --- /dev/null +++ b/blocksuite/affine/components/src/rich-text/inline/presets/nodes/reference-node/types.ts @@ -0,0 +1,6 @@ +import type { ReferenceInfo } from '@blocksuite/affine-model'; +import type { Slot } from '@blocksuite/global/utils'; + +export type RefNodeSlots = { + docLinkClicked: Slot; +}; diff --git a/blocksuite/affine/components/src/rich-text/keymap/basic.ts b/blocksuite/affine/components/src/rich-text/keymap/basic.ts new file mode 100644 index 0000000000..296cff4e85 --- /dev/null +++ b/blocksuite/affine/components/src/rich-text/keymap/basic.ts @@ -0,0 +1,72 @@ +import type { BlockStdScope, UIEventHandler } from '@blocksuite/block-std'; + +import { + focusTextModel, + getInlineEditorByModel, + selectTextModel, +} from '../dom.js'; + +export const textCommonKeymap = ( + std: BlockStdScope +): Record => { + return { + ArrowUp: () => { + const text = std.selection.find('text'); + if (!text) return; + const inline = getInlineEditorByModel(std.host, text.from.blockId); + if (!inline) return; + return !inline.isFirstLine(inline.getInlineRange()); + }, + ArrowDown: () => { + const text = std.selection.find('text'); + if (!text) return; + const inline = getInlineEditorByModel(std.host, text.from.blockId); + if (!inline) return; + return !inline.isLastLine(inline.getInlineRange()); + }, + Escape: ctx => { + const text = std.selection.find('text'); + if (!text) return; + + selectBlock(std, text.from.blockId); + ctx.get('keyboardState').raw.stopPropagation(); + return true; + }, + 'Mod-a': ctx => { + const text = std.selection.find('text'); + if (!text) return; + + const model = std.doc.getBlock(text.from.blockId)?.model; + if (!model || !model.text) return; + + ctx.get('keyboardState').raw.preventDefault(); + + if ( + text.from.index === 0 && + text.from.length === model.text.yText.length + ) { + selectBlock(std, text.from.blockId); + return true; + } + + selectTextModel(std, text.from.blockId, 0, model.text.yText.length); + return true; + }, + Enter: ctx => { + const blocks = std.selection.filter('block'); + const blockId = blocks.at(-1)?.blockId; + + if (!blockId) return; + const model = std.doc.getBlock(blockId)?.model; + if (!model || !model.text) return; + + ctx.get('keyboardState').raw.preventDefault(); + focusTextModel(std, blockId, model.text.yText.length); + return true; + }, + }; +}; + +function selectBlock(std: BlockStdScope, blockId: string) { + std.selection.setGroup('note', [std.selection.create('block', { blockId })]); +} diff --git a/blocksuite/affine/components/src/rich-text/keymap/bracket.ts b/blocksuite/affine/components/src/rich-text/keymap/bracket.ts new file mode 100644 index 0000000000..fd89816de8 --- /dev/null +++ b/blocksuite/affine/components/src/rich-text/keymap/bracket.ts @@ -0,0 +1,161 @@ +import { BRACKET_PAIRS } from '@blocksuite/affine-shared/consts'; +import { + createDefaultDoc, + matchFlavours, +} from '@blocksuite/affine-shared/utils'; +import type { BlockStdScope, UIEventHandler } from '@blocksuite/block-std'; +import type { InlineEditor } from '@blocksuite/inline'; + +import { getInlineEditorByModel } from '../dom.js'; +import { insertLinkedNode } from '../linked-node.js'; + +export const bracketKeymap = ( + std: BlockStdScope +): Record => { + const keymap = BRACKET_PAIRS.reduce( + (acc, pair) => { + return { + ...acc, + [pair.right]: ctx => { + const { doc, selection } = std; + if (doc.readonly) return; + + const textSelection = selection.find('text'); + if (!textSelection) return; + const model = doc.getBlock(textSelection.from.blockId)?.model; + if (!model) return; + if (!matchFlavours(model, ['affine:code'])) return; + const inlineEditor = getInlineEditorByModel( + std.host, + textSelection.from.blockId + ); + if (!inlineEditor) return; + const inlineRange = inlineEditor.getInlineRange(); + if (!inlineRange) return; + const left = inlineEditor.yText.toString()[inlineRange.index - 1]; + const right = inlineEditor.yText.toString()[inlineRange.index]; + if (pair.left === left && pair.right === right) { + inlineEditor.setInlineRange({ + index: inlineRange.index + 1, + length: 0, + }); + ctx.get('keyboardState').raw.preventDefault(); + } + }, + [pair.left]: ctx => { + const { doc, selection } = std; + if (doc.readonly) return; + + const textSelection = selection.find('text'); + if (!textSelection) return; + const model = doc.getBlock(textSelection.from.blockId)?.model; + if (!model) return; + + const isCodeBlock = matchFlavours(model, ['affine:code']); + // When selection is collapsed, only trigger auto complete in code block + if (textSelection.isCollapsed() && !isCodeBlock) return; + if (!textSelection.isInSameBlock()) return; + + ctx.get('keyboardState').raw.preventDefault(); + + const inlineEditor = getInlineEditorByModel( + std.host, + textSelection.from.blockId + ); + if (!inlineEditor) return; + const inlineRange = inlineEditor.getInlineRange(); + if (!inlineRange) return; + const selectedText = inlineEditor.yText + .toString() + .slice(inlineRange.index, inlineRange.index + inlineRange.length); + if (!isCodeBlock && pair.name === 'square bracket') { + // [[Selected text]] should automatically be converted to a Linked doc with the title "Selected text". + // See https://github.com/toeverything/blocksuite/issues/2730 + const success = tryConvertToLinkedDoc(std, inlineEditor); + if (success) return true; + } + inlineEditor.insertText( + inlineRange, + pair.left + selectedText + pair.right + ); + + inlineEditor.setInlineRange({ + index: inlineRange.index + 1, + length: inlineRange.length, + }); + + return true; + }, + }; + }, + {} as Record + ); + + return { + ...keymap, + '`': ctx => { + const { doc, selection } = std; + if (doc.readonly) return; + + const textSelection = selection.find('text'); + if (!textSelection || textSelection.isCollapsed()) return; + if (!textSelection.isInSameBlock()) return; + const model = doc.getBlock(textSelection.from.blockId)?.model; + if (!model) return; + + ctx.get('keyboardState').raw.preventDefault(); + const inlineEditor = getInlineEditorByModel( + std.host, + textSelection.from.blockId + ); + if (!inlineEditor) return; + const inlineRange = inlineEditor.getInlineRange(); + if (!inlineRange) return; + inlineEditor.formatText(inlineRange, { code: true }); + + inlineEditor.setInlineRange({ + index: inlineRange.index, + length: inlineRange.length, + }); + + return true; + }, + }; +}; + +function tryConvertToLinkedDoc(std: BlockStdScope, inlineEditor: InlineEditor) { + const root = std.doc.root; + if (!root) return false; + const linkedDocWidgetEle = std.view.getWidget( + 'affine-linked-doc-widget', + root.id + ); + if (!linkedDocWidgetEle) return false; + + const inlineRange = inlineEditor.getInlineRange(); + if (!inlineRange) return false; + const text = inlineEditor.yText.toString(); + const left = text[inlineRange.index - 1]; + const right = text[inlineRange.index + inlineRange.length]; + const needConvert = left === '[' && right === ']'; + if (!needConvert) return false; + + const docName = text.slice( + inlineRange.index, + inlineRange.index + inlineRange.length + ); + inlineEditor.deleteText({ + index: inlineRange.index - 1, + length: inlineRange.length + 2, + }); + inlineEditor.setInlineRange({ index: inlineRange.index - 1, length: 0 }); + + const doc = createDefaultDoc(std.doc.collection, { + title: docName, + }); + insertLinkedNode({ + inlineEditor, + docId: doc.id, + }); + return true; +} diff --git a/blocksuite/affine/components/src/rich-text/keymap/format.ts b/blocksuite/affine/components/src/rich-text/keymap/format.ts new file mode 100644 index 0000000000..c51d31b68c --- /dev/null +++ b/blocksuite/affine/components/src/rich-text/keymap/format.ts @@ -0,0 +1,26 @@ +import type { BlockStdScope, UIEventHandler } from '@blocksuite/block-std'; + +import { textFormatConfigs } from '../format/index.js'; + +export const textFormatKeymap = (std: BlockStdScope) => + textFormatConfigs + .filter(config => config.hotkey) + .reduce( + (acc, config) => { + return { + ...acc, + [config.hotkey as string]: ctx => { + const { doc, selection } = std; + if (doc.readonly) return; + + const textSelection = selection.find('text'); + if (!textSelection) return; + + config.action(std.host); + ctx.get('keyboardState').raw.preventDefault(); + return true; + }, + }; + }, + {} as Record + ); diff --git a/blocksuite/affine/components/src/rich-text/keymap/index.ts b/blocksuite/affine/components/src/rich-text/keymap/index.ts new file mode 100644 index 0000000000..9983ade1f0 --- /dev/null +++ b/blocksuite/affine/components/src/rich-text/keymap/index.ts @@ -0,0 +1,15 @@ +import type { BlockStdScope, UIEventHandler } from '@blocksuite/block-std'; + +import { textCommonKeymap } from './basic.js'; +import { bracketKeymap } from './bracket.js'; +import { textFormatKeymap } from './format.js'; + +export const textKeymap = ( + std: BlockStdScope +): Record => { + return { + ...textCommonKeymap(std), + ...textFormatKeymap(std), + ...bracketKeymap(std), + }; +}; diff --git a/blocksuite/affine/components/src/rich-text/linked-node.ts b/blocksuite/affine/components/src/rich-text/linked-node.ts new file mode 100644 index 0000000000..7f25517d3d --- /dev/null +++ b/blocksuite/affine/components/src/rich-text/linked-node.ts @@ -0,0 +1,20 @@ +import { type AffineInlineEditor, REFERENCE_NODE } from './inline/index.js'; + +export function insertLinkedNode({ + inlineEditor, + docId, +}: { + inlineEditor: AffineInlineEditor; + docId: string; +}) { + if (!inlineEditor) return; + const inlineRange = inlineEditor.getInlineRange(); + if (!inlineRange) return; + inlineEditor.insertText(inlineRange, REFERENCE_NODE, { + reference: { type: 'LinkedPage', pageId: docId }, + }); + inlineEditor.setInlineRange({ + index: inlineRange.index + 1, + length: 0, + }); +} diff --git a/blocksuite/affine/components/src/rich-text/markdown/divider.ts b/blocksuite/affine/components/src/rich-text/markdown/divider.ts new file mode 100644 index 0000000000..328956931d --- /dev/null +++ b/blocksuite/affine/components/src/rich-text/markdown/divider.ts @@ -0,0 +1,38 @@ +import { matchFlavours } from '@blocksuite/affine-shared/utils'; +import type { BlockStdScope } from '@blocksuite/block-std'; +import type { BlockModel } from '@blocksuite/store'; + +import { focusTextModel } from '../dom.js'; +import { beforeConvert } from './utils.js'; + +export function toDivider( + std: BlockStdScope, + model: BlockModel, + prefix: string +) { + const { doc } = std; + if ( + matchFlavours(model, ['affine:divider']) || + (matchFlavours(model, ['affine:paragraph']) && model.type === 'quote') + ) { + return; + } + + const parent = doc.getParent(model); + if (!parent) return; + + const index = parent.children.indexOf(model); + beforeConvert(std, model, prefix.length); + const blockProps = { + children: model.children, + }; + doc.addBlock('affine:divider', blockProps, parent, index); + + const nextBlock = parent.children[index + 1]; + let id = nextBlock?.id; + if (!id) { + id = doc.addBlock('affine:paragraph', {}, parent); + } + focusTextModel(std, id); + return id; +} diff --git a/blocksuite/affine/components/src/rich-text/markdown/index.ts b/blocksuite/affine/components/src/rich-text/markdown/index.ts new file mode 100644 index 0000000000..c2a452224a --- /dev/null +++ b/blocksuite/affine/components/src/rich-text/markdown/index.ts @@ -0,0 +1 @@ +export { markdownInput } from './markdown-input.js'; diff --git a/blocksuite/affine/components/src/rich-text/markdown/list.ts b/blocksuite/affine/components/src/rich-text/markdown/list.ts new file mode 100644 index 0000000000..35bb5ff9a1 --- /dev/null +++ b/blocksuite/affine/components/src/rich-text/markdown/list.ts @@ -0,0 +1,50 @@ +import type { ListProps, ListType } from '@blocksuite/affine-model'; +import { matchFlavours, toNumberedList } from '@blocksuite/affine-shared/utils'; +import type { BlockStdScope } from '@blocksuite/block-std'; +import type { BlockModel } from '@blocksuite/store'; + +import { focusTextModel } from '../dom.js'; +import { beforeConvert } from './utils.js'; + +export function toList( + std: BlockStdScope, + model: BlockModel, + listType: ListType, + prefix: string, + otherProperties?: Partial +) { + if (!matchFlavours(model, ['affine:paragraph'])) { + return; + } + const { doc } = std; + const parent = doc.getParent(model); + if (!parent) return; + + beforeConvert(std, model, prefix.length); + + if (listType !== 'numbered') { + const index = parent.children.indexOf(model); + const blockProps = { + type: listType, + text: model.text?.clone(), + children: model.children, + ...otherProperties, + }; + doc.deleteBlock(model, { + deleteChildren: false, + }); + + const id = doc.addBlock('affine:list', blockProps, parent, index); + focusTextModel(std, id); + return id; + } + + let order = parseInt(prefix.slice(0, -1)); + if (!Number.isInteger(order)) order = 1; + + const id = toNumberedList(std, model, order); + if (!id) return; + + focusTextModel(std, id); + return id; +} diff --git a/blocksuite/affine/components/src/rich-text/markdown/markdown-input.ts b/blocksuite/affine/components/src/rich-text/markdown/markdown-input.ts new file mode 100644 index 0000000000..a97b36bfb4 --- /dev/null +++ b/blocksuite/affine/components/src/rich-text/markdown/markdown-input.ts @@ -0,0 +1,85 @@ +import { + isMarkdownPrefix, + matchFlavours, +} from '@blocksuite/affine-shared/utils'; +import type { BlockStdScope } from '@blocksuite/block-std'; + +import { getInlineEditorByModel } from '../dom.js'; +import { toDivider } from './divider.js'; +import { toList } from './list.js'; +import { toParagraph } from './paragraph.js'; +import { toCode } from './to-code.js'; +import { getPrefixText } from './utils.js'; + +export function markdownInput( + std: BlockStdScope, + id?: string +): string | undefined { + if (!id) { + const selection = std.selection; + const text = selection.find('text'); + id = text?.from.blockId; + } + if (!id) return; + const model = std.doc.getBlock(id)?.model; + if (!model) return; + const inline = getInlineEditorByModel(std.host, model); + if (!inline) return; + const range = inline.getInlineRange(); + if (!range) return; + + const prefixText = getPrefixText(inline); + if (!isMarkdownPrefix(prefixText)) return; + + const isParagraph = matchFlavours(model, ['affine:paragraph']); + const isHeading = isParagraph && model.type.startsWith('h'); + const isParagraphQuoteBlock = isParagraph && model.type === 'quote'; + const isCodeBlock = matchFlavours(model, ['affine:code']); + if (isHeading || isParagraphQuoteBlock || isCodeBlock) return; + + const lineInfo = inline.getLine(range.index); + if (!lineInfo) return; + + const { lineIndex, rangeIndexRelatedToLine } = lineInfo; + if (lineIndex !== 0 || rangeIndexRelatedToLine > prefixText.length) return; + + // try to add code block + const codeMatch = prefixText.match(/^```([a-zA-Z0-9]*)$/g); + if (codeMatch) { + return toCode(std, model, prefixText, codeMatch[0].slice(3)); + } + + switch (prefixText.trim()) { + case '[]': + case '[ ]': + return toList(std, model, 'todo', prefixText, { + checked: false, + }); + case '[x]': + return toList(std, model, 'todo', prefixText, { + checked: true, + }); + case '-': + case '*': + return toList(std, model, 'bulleted', prefixText); + case '***': + case '---': + return toDivider(std, model, prefixText); + case '#': + return toParagraph(std, model, 'h1', prefixText); + case '##': + return toParagraph(std, model, 'h2', prefixText); + case '###': + return toParagraph(std, model, 'h3', prefixText); + case '####': + return toParagraph(std, model, 'h4', prefixText); + case '#####': + return toParagraph(std, model, 'h5', prefixText); + case '######': + return toParagraph(std, model, 'h6', prefixText); + case '>': + return toParagraph(std, model, 'quote', prefixText); + default: + return toList(std, model, 'numbered', prefixText); + } +} diff --git a/blocksuite/affine/components/src/rich-text/markdown/paragraph.ts b/blocksuite/affine/components/src/rich-text/markdown/paragraph.ts new file mode 100644 index 0000000000..32f336ffb5 --- /dev/null +++ b/blocksuite/affine/components/src/rich-text/markdown/paragraph.ts @@ -0,0 +1,46 @@ +import type { ParagraphType } from '@blocksuite/affine-model'; +import { matchFlavours } from '@blocksuite/affine-shared/utils'; +import type { BlockStdScope } from '@blocksuite/block-std'; +import type { BlockModel } from '@blocksuite/store'; + +import { focusTextModel } from '../dom.js'; +import { beforeConvert } from './utils.js'; + +export function toParagraph( + std: BlockStdScope, + model: BlockModel, + type: ParagraphType, + prefix: string +) { + const { doc } = std; + if (!matchFlavours(model, ['affine:paragraph'])) { + const parent = doc.getParent(model); + if (!parent) return; + + const index = parent.children.indexOf(model); + + beforeConvert(std, model, prefix.length); + + const blockProps = { + type: type, + text: model.text?.clone(), + children: model.children, + }; + doc.deleteBlock(model, { deleteChildren: false }); + const id = doc.addBlock('affine:paragraph', blockProps, parent, index); + + focusTextModel(std, id); + return id; + } + + if (matchFlavours(model, ['affine:paragraph']) && model.type !== type) { + beforeConvert(std, model, prefix.length); + + doc.updateBlock(model, { type }); + + focusTextModel(std, model.id); + } + + // If the model is already a paragraph with the same type, do nothing + return model.id; +} diff --git a/blocksuite/affine/components/src/rich-text/markdown/to-code.ts b/blocksuite/affine/components/src/rich-text/markdown/to-code.ts new file mode 100644 index 0000000000..113eedec54 --- /dev/null +++ b/blocksuite/affine/components/src/rich-text/markdown/to-code.ts @@ -0,0 +1,38 @@ +import { matchFlavours } from '@blocksuite/affine-shared/utils'; +import type { BlockStdScope } from '@blocksuite/block-std'; +import type { BlockModel } from '@blocksuite/store'; + +import { focusTextModel } from '../dom.js'; + +export function toCode( + std: BlockStdScope, + model: BlockModel, + prefixText: string, + language: string | null +) { + if (matchFlavours(model, ['affine:paragraph']) && model.type === 'quote') { + return; + } + + const doc = model.doc; + const parent = doc.getParent(model); + if (!parent) { + return; + } + + doc.captureSync(); + const index = parent.children.indexOf(model); + + const codeId = doc.addBlock('affine:code', { language }, parent, index); + + if (model.text && model.text.length > prefixText.length) { + const text = model.text.clone(); + doc.addBlock('affine:paragraph', { text }, parent, index + 1); + text.delete(0, prefixText.length); + } + doc.deleteBlock(model, { bringChildrenTo: parent }); + + focusTextModel(std, codeId); + + return codeId; +} diff --git a/blocksuite/affine/components/src/rich-text/markdown/utils.ts b/blocksuite/affine/components/src/rich-text/markdown/utils.ts new file mode 100644 index 0000000000..5aebb07c39 --- /dev/null +++ b/blocksuite/affine/components/src/rich-text/markdown/utils.ts @@ -0,0 +1,39 @@ +import type { BlockStdScope } from '@blocksuite/block-std'; +import type { InlineEditor } from '@blocksuite/inline'; +import type { BlockModel } from '@blocksuite/store'; + +import { focusTextModel } from '../dom.js'; + +export function getPrefixText(inlineEditor: InlineEditor) { + const inlineRange = inlineEditor.getInlineRange(); + if (!inlineRange) return ''; + const firstLineEnd = inlineEditor.yTextString.search(/\n/); + if (firstLineEnd !== -1 && inlineRange.index > firstLineEnd) { + return ''; + } + const textPoint = inlineEditor.getTextPoint(inlineRange.index); + if (!textPoint) return ''; + const [leafStart, offsetStart] = textPoint; + return leafStart.textContent + ? leafStart.textContent.slice(0, offsetStart) + : ''; +} + +export function beforeConvert( + std: BlockStdScope, + model: BlockModel, + index: number +) { + const { text } = model; + if (!text) return; + // Add a space after the text, then stop capturing + // So when the user undo, the prefix will be restored with a `space` + // Ex. (| is the cursor position) + // *| <- user input + // -> bullet list + // *| -> undo + text.insert(' ', index); + focusTextModel(std, model.id, index + 1); + std.doc.captureSync(); + text.delete(0, index + 1); +} diff --git a/blocksuite/affine/components/src/rich-text/rich-text.ts b/blocksuite/affine/components/src/rich-text/rich-text.ts new file mode 100644 index 0000000000..4427ceaaf2 --- /dev/null +++ b/blocksuite/affine/components/src/rich-text/rich-text.ts @@ -0,0 +1,429 @@ +import type { AffineTextAttributes } from '@blocksuite/affine-shared/types'; +import { ShadowlessElement } from '@blocksuite/block-std'; +import { assertExists, WithDisposable } from '@blocksuite/global/utils'; +import { + type AttributeRenderer, + createInlineKeyDownHandler, + type DeltaInsert, + InlineEditor, + type InlineRange, + type InlineRangeProvider, + type KeyboardBindingContext, + type VLine, +} from '@blocksuite/inline'; +import type { Y } from '@blocksuite/store'; +import { DocCollection, Text } from '@blocksuite/store'; +import { effect } from '@preact/signals-core'; +import { css, html, type TemplateResult } from 'lit'; +import { property, query } from 'lit/decorators.js'; +import { classMap } from 'lit/directives/class-map.js'; +import { z } from 'zod'; + +import { onVBeforeinput, onVCompositionEnd } from './hooks.js'; +import type { AffineInlineEditor } from './inline/index.js'; + +interface RichTextStackItem { + meta: Map<'richtext-v-range', InlineRange | null>; +} + +export class RichText extends WithDisposable(ShadowlessElement) { + static override styles = css` + rich-text { + display: block; + height: 100%; + width: 100%; + overflow-x: auto; + overflow-y: hidden; + + scroll-margin-top: 50px; + scroll-margin-bottom: 30px; + } + + .inline-editor { + height: 100%; + width: 100%; + outline: none; + cursor: text; + } + + .inline-editor.readonly { + cursor: default; + } + + rich-text .nowrap-lines v-text span, + rich-text .nowrap-lines v-element span { + white-space: pre !important; + } + `; + + #verticalScrollContainer: HTMLElement | null = null; + + private _inlineEditor: AffineInlineEditor | null = null; + + private _onCopy = (e: ClipboardEvent) => { + const inlineEditor = this.inlineEditor; + if (!inlineEditor) return; + + const inlineRange = inlineEditor.getInlineRange(); + if (!inlineRange) return; + + const text = inlineEditor.yTextString.slice( + inlineRange.index, + inlineRange.index + inlineRange.length + ); + + e.clipboardData?.setData('text/plain', text); + e.preventDefault(); + e.stopPropagation(); + }; + + private _onCut = (e: ClipboardEvent) => { + const inlineEditor = this.inlineEditor; + if (!inlineEditor) return; + + const inlineRange = inlineEditor.getInlineRange(); + if (!inlineRange) return; + + const text = inlineEditor.yTextString.slice( + inlineRange.index, + inlineRange.index + inlineRange.length + ); + inlineEditor.deleteText(inlineRange); + inlineEditor.setInlineRange({ + index: inlineRange.index, + length: 0, + }); + + e.clipboardData?.setData('text/plain', text); + e.preventDefault(); + e.stopPropagation(); + }; + + private _onPaste = (e: ClipboardEvent) => { + const inlineEditor = this.inlineEditor; + if (!inlineEditor) return; + + const inlineRange = inlineEditor.getInlineRange(); + if (!inlineRange) return; + + const text = e.clipboardData + ?.getData('text/plain') + ?.replace(/\r?\n|\r/g, '\n'); + if (!text) return; + + inlineEditor.insertText(inlineRange, text); + inlineEditor.setInlineRange({ + index: inlineRange.index + text.length, + length: 0, + }); + + e.preventDefault(); + e.stopPropagation(); + }; + + private _onStackItemAdded = (event: { stackItem: RichTextStackItem }) => { + const inlineRange = this.inlineEditor?.getInlineRange(); + if (inlineRange) { + event.stackItem.meta.set('richtext-v-range', inlineRange); + } + }; + + private _onStackItemPopped = (event: { stackItem: RichTextStackItem }) => { + const inlineRange = event.stackItem.meta.get('richtext-v-range'); + if (inlineRange && this.inlineEditor?.isValidInlineRange(inlineRange)) { + this.inlineEditor?.setInlineRange(inlineRange); + } + }; + + private get _yText() { + return this.yText instanceof Text ? this.yText.yText : this.yText; + } + + // It will listen ctrl+z/ctrl+shift+z and call undoManager.undo/redo, keydown event will not + get inlineEditor() { + return this._inlineEditor; + } + + get inlineEditorContainer() { + assertExists(this._inlineEditorContainer); + return this._inlineEditorContainer; + } + + private _init() { + if (this._inlineEditor) { + console.error('Inline editor already exists.'); + return; + } + + if (!this.enableFormat) { + this.attributesSchema = z.object({}); + } + + // init inline editor + this._inlineEditor = new InlineEditor(this._yText, { + isEmbed: delta => this.embedChecker(delta), + hooks: { + beforeinput: onVBeforeinput, + compositionEnd: onVCompositionEnd, + }, + inlineRangeProvider: this.inlineRangeProvider, + vLineRenderer: this.vLineRenderer, + }); + if (this.attributesSchema) { + this._inlineEditor.setAttributeSchema(this.attributesSchema); + } + if (this.attributeRenderer) { + this._inlineEditor.setAttributeRenderer(this.attributeRenderer); + } + const inlineEditor = this._inlineEditor; + + const markdownShortcutHandler = this.markdownShortcutHandler; + if (markdownShortcutHandler) { + const keyDownHandler = createInlineKeyDownHandler(inlineEditor, { + inputRule: { + key: [' ', 'Enter'], + handler: context => + markdownShortcutHandler(context, this.undoManager), + }, + }); + + inlineEditor.disposables.addFromEvent( + this.inlineEventSource ?? this.inlineEditorContainer, + 'keydown', + keyDownHandler + ); + } + + // init auto scroll + inlineEditor.disposables.add( + effect(() => { + const inlineRange = inlineEditor.inlineRange$.value; + if (!inlineRange) return; + + // lazy + const verticalScrollContainer = + this.#verticalScrollContainer || + (this.#verticalScrollContainer = + this.verticalScrollContainerGetter?.() || null); + + inlineEditor + .waitForUpdate() + .then(() => { + if (!inlineEditor.mounted || inlineEditor.rendering) return; + + const range = inlineEditor.toDomRange(inlineRange); + if (!range) return; + + if (verticalScrollContainer) { + const nativeRange = inlineEditor.getNativeRange(); + if ( + !nativeRange || + nativeRange.commonAncestorContainer.parentElement?.contains( + inlineEditor.rootElement + ) + ) + return; + + const containerRect = + verticalScrollContainer.getBoundingClientRect(); + const rangeRect = range.getBoundingClientRect(); + + if (rangeRect.top < containerRect.top) { + this.scrollIntoView({ block: 'start' }); + } else if (rangeRect.bottom > containerRect.bottom) { + this.scrollIntoView({ block: 'end' }); + } + } + + // scroll container is this + if (this.enableAutoScrollHorizontally) { + const containerRect = this.getBoundingClientRect(); + const rangeRect = range.getBoundingClientRect(); + + let scrollLeft = this.scrollLeft; + if ( + rangeRect.left + rangeRect.width > + containerRect.left + containerRect.width + ) { + scrollLeft += + rangeRect.left + + rangeRect.width - + (containerRect.left + containerRect.width) + + 2; + } + this.scrollLeft = scrollLeft; + } + }) + .catch(console.error); + }) + ); + + inlineEditor.mount( + this.inlineEditorContainer, + this.inlineEventSource, + this.readonly + ); + } + + private _unmount() { + if (this.inlineEditor?.mounted) { + this.inlineEditor.unmount(); + } + this._inlineEditor = null; + } + + override connectedCallback() { + super.connectedCallback(); + + if (!this._yText) { + console.error('rich-text need yText to init.'); + return; + } + if (!this._yText.doc) { + console.error('yText should be bind to yDoc.'); + return; + } + + if (!this.undoManager) { + this.undoManager = new DocCollection.Y.UndoManager(this._yText, { + trackedOrigins: new Set([this._yText.doc.clientID]), + }); + } + + if (this.enableUndoRedo) { + this.disposables.addFromEvent(this, 'keydown', (e: KeyboardEvent) => { + // eslint-disable-next-line sonarjs/no-collapsible-if + if (e.ctrlKey || e.metaKey) { + if (e.key === 'z' || e.key === 'Z') { + if (e.shiftKey) { + this.undoManager.redo(); + } else { + this.undoManager.undo(); + } + e.stopPropagation(); + } + } + }); + + this.undoManager.on('stack-item-added', this._onStackItemAdded); + this.undoManager.on('stack-item-popped', this._onStackItemPopped); + this.disposables.add({ + dispose: () => { + this.undoManager.off('stack-item-added', this._onStackItemAdded); + this.undoManager.off('stack-item-popped', this._onStackItemPopped); + }, + }); + } + + if (this.enableClipboard) { + this.disposables.addFromEvent(this, 'copy', this._onCopy); + this.disposables.addFromEvent(this, 'cut', this._onCut); + this.disposables.addFromEvent(this, 'paste', this._onPaste); + } + + this.updateComplete + .then(() => { + this._unmount(); + this._init(); + + this.disposables.add({ + dispose: () => { + this._unmount(); + }, + }); + }) + .catch(console.error); + } + + override async getUpdateComplete(): Promise { + const result = await super.getUpdateComplete(); + await this.inlineEditor?.waitForUpdate(); + return result; + } + + // If it is true rich-text will handle undo/redo by itself. (including v-range restore) + override render() { + const classes = classMap({ + 'inline-editor': true, + 'nowrap-lines': !this.wrapText, + readonly: this.readonly, + }); + + return html`
`; + } + + override updated(changedProperties: Map) { + if (this._inlineEditor && changedProperties.has('readonly')) { + this._inlineEditor.setReadonly(this.readonly); + } + } + + @query('.inline-editor') + private accessor _inlineEditorContainer!: HTMLDivElement; + + @property({ attribute: false }) + accessor attributeRenderer: AttributeRenderer | undefined = undefined; + + @property({ attribute: false }) + accessor attributesSchema: z.ZodSchema | undefined = undefined; + + @property({ attribute: false }) + accessor embedChecker: < + TextAttributes extends AffineTextAttributes = AffineTextAttributes, + >( + delta: DeltaInsert + ) => boolean = () => false; + + @property({ attribute: false }) + accessor enableAutoScrollHorizontally = true; + + // If it is true rich-text will prevent events related to clipboard bubbling up and handle them by itself. + @property({ attribute: false }) + accessor enableClipboard = true; + + // `attributesSchema` will be overwritten to `z.object({})` if `enableFormat` is false. + @property({ attribute: false }) + accessor enableFormat = true; + + // bubble up if pressed ctrl+z/ctrl+shift+z. + @property({ attribute: false }) + accessor enableUndoRedo = true; + + @property({ attribute: false }) + accessor inlineEventSource: HTMLElement | undefined = undefined; + + @property({ attribute: false }) + accessor inlineRangeProvider: InlineRangeProvider | undefined = undefined; + + @property({ attribute: false }) + accessor markdownShortcutHandler: + | (( + context: KeyboardBindingContext, + undoManager: Y.UndoManager + ) => boolean) + | undefined = undefined; + + @property({ attribute: false }) + accessor readonly = false; + + // rich-text will create a undoManager if it is not provided. + @property({ attribute: false }) + accessor undoManager!: Y.UndoManager; + + @property({ attribute: false }) + accessor verticalScrollContainerGetter: + | (() => HTMLElement | null) + | undefined = undefined; + + @property({ attribute: false }) + accessor vLineRenderer: ((vLine: VLine) => TemplateResult) | undefined; + + @property({ attribute: false }) + accessor wrapText = true; + + @property({ attribute: false }) + accessor yText!: Y.Text | Text; +} diff --git a/blocksuite/affine/components/src/toast/create.ts b/blocksuite/affine/components/src/toast/create.ts new file mode 100644 index 0000000000..ee225a78cd --- /dev/null +++ b/blocksuite/affine/components/src/toast/create.ts @@ -0,0 +1,39 @@ +import type { BlockComponent, EditorHost } from '@blocksuite/block-std'; +import { html } from 'lit'; + +import { htmlToElement } from './html-to-element.js'; + +export const createToastContainer = (editorHost: EditorHost) => { + const styles = ` + position: fixed; + z-index: 9999; + top: 16px; + left: 16px; + right: 16px; + bottom: 78px; + pointer-events: none; + display: flex; + flex-direction: column-reverse; + align-items: center; + `; + const template = html`
`; + const element = htmlToElement(template); + const { std, doc } = editorHost; + + let container = document.body; + if (doc.root) { + const rootComponent = std.view.getBlock(doc.root.id) as BlockComponent & { + viewportElement: HTMLElement; + }; + if (rootComponent) { + const viewportElement = rootComponent.viewportElement; + const editorContainer = viewportElement.parentElement; + if (editorContainer) { + container = editorContainer; + } + } + } + container.append(element); + + return element; +}; diff --git a/blocksuite/affine/components/src/toast/html-to-element.ts b/blocksuite/affine/components/src/toast/html-to-element.ts new file mode 100644 index 0000000000..8416260c66 --- /dev/null +++ b/blocksuite/affine/components/src/toast/html-to-element.ts @@ -0,0 +1,19 @@ +import type { TemplateResult } from 'lit'; + +/** + * DO NOT USE FOR USER INPUT + * See https://stackoverflow.com/questions/494143/creating-a-new-dom-element-from-an-html-string-using-built-in-dom-methods-or-pro/35385518#35385518 + */ +export const htmlToElement = ( + html: string | TemplateResult +) => { + const template = document.createElement('template'); + if (typeof html === 'string') { + html = html.trim(); // Never return a text node of whitespace as the result + template.innerHTML = html; + } else { + const htmlString = String.raw(html.strings, ...html.values); + template.innerHTML = htmlString; + } + return template.content.firstChild as T; +}; diff --git a/blocksuite/affine/components/src/toast/index.ts b/blocksuite/affine/components/src/toast/index.ts new file mode 100644 index 0000000000..1f1c4ac4bf --- /dev/null +++ b/blocksuite/affine/components/src/toast/index.ts @@ -0,0 +1 @@ +export { toast } from './toast.js'; diff --git a/blocksuite/affine/components/src/toast/toast.ts b/blocksuite/affine/components/src/toast/toast.ts new file mode 100644 index 0000000000..4d40b460b0 --- /dev/null +++ b/blocksuite/affine/components/src/toast/toast.ts @@ -0,0 +1,80 @@ +import type { EditorHost } from '@blocksuite/block-std'; +import { baseTheme } from '@toeverything/theme'; +import { html } from 'lit'; + +import { createToastContainer } from './create.js'; +import { htmlToElement } from './html-to-element.js'; + +let ToastContainer: HTMLDivElement | null = null; + +/** + * @example + * ```ts + * toast('Hello World'); + * ``` + */ +export const toast = ( + editorHost: EditorHost, + message: string, + duration = 2500 +) => { + if (!ToastContainer) { + ToastContainer = createToastContainer(editorHost); + } + + const styles = ` + max-width: 480px; + text-align: center; + font-family: ${baseTheme.fontSansFamily}; + font-size: var(--affine-font-sm); + padding: 6px 12px; + margin: 10px 0 0 0; + color: var(--affine-white); + background: var(--affine-tooltip); + box-shadow: var(--affine-float-button-shadow); + border-radius: 10px; + transition: all 230ms cubic-bezier(0.21, 1.02, 0.73, 1); + opacity: 0; + `; + + const template = html`
`; + const element = htmlToElement(template); + // message is not trusted + element.textContent = message; + ToastContainer?.append(element); + + const fadeIn = [ + { + opacity: 0, + }, + { opacity: 1 }, + ]; + const options = { + duration: 230, + easing: 'cubic-bezier(0.21, 1.02, 0.73, 1)', + fill: 'forwards' as const, + }; // satisfies KeyframeAnimationOptions; + element.animate(fadeIn, options); + + setTimeout(() => { + const fadeOut = fadeIn.reverse(); + const animation = element.animate(fadeOut, options); + animation.finished + .then(() => { + element.style.maxHeight = '0'; + element.style.margin = '0'; + element.style.padding = '0'; + element.addEventListener( + 'transitionend', + () => { + element.remove(); + }, + { + once: true, + } + ); + }) + .catch(console.error); + }, duration); + return element; +}; diff --git a/blocksuite/affine/components/src/toggle-button/index.ts b/blocksuite/affine/components/src/toggle-button/index.ts new file mode 100644 index 0000000000..fb40121b48 --- /dev/null +++ b/blocksuite/affine/components/src/toggle-button/index.ts @@ -0,0 +1,7 @@ +import { TOGGLE_BUTTON_PARENT_CLASS, ToggleButton } from './toggle-button.js'; + +export function effects() { + customElements.define('blocksuite-toggle-button', ToggleButton); +} + +export { TOGGLE_BUTTON_PARENT_CLASS, ToggleButton }; diff --git a/blocksuite/affine/components/src/toggle-button/toggle-button.ts b/blocksuite/affine/components/src/toggle-button/toggle-button.ts new file mode 100644 index 0000000000..3ddd1be0f9 --- /dev/null +++ b/blocksuite/affine/components/src/toggle-button/toggle-button.ts @@ -0,0 +1,81 @@ +import { ShadowlessElement } from '@blocksuite/block-std'; +import { WithDisposable } from '@blocksuite/global/utils'; +import { css, unsafeCSS } from 'lit'; +import { property } from 'lit/decorators.js'; +import { html } from 'lit-html'; + +import { toggleDown, toggleRight } from '../icons/list.js'; + +export const TOGGLE_BUTTON_PARENT_CLASS = 'blocksuite-toggle-button-parent'; + +export class ToggleButton extends WithDisposable(ShadowlessElement) { + static override styles = css` + .toggle-icon { + display: flex; + align-items: start; + margin-top: 0.45em; + position: absolute; + left: 0; + transform: translateX(-100%); + border-radius: 4px; + cursor: pointer; + opacity: 0; + transition: opacity 0.2s ease-in-out; + } + .toggle-icon:hover { + background: var(--affine-hover-color); + } + + .toggle-icon[data-collapsed='true'] { + opacity: 1; + } + + .${unsafeCSS(TOGGLE_BUTTON_PARENT_CLASS)}:hover .toggle-icon { + opacity: 1; + } + + .with-drag-handle .toggle-icon { + opacity: 1; + } + .with-drag-handle .affine-block-children-container .toggle-icon { + opacity: 0; + } + `; + + override render() { + const toggleDownTemplate = html` +
this.updateCollapsed(!this.collapsed)} + > + ${toggleDown} +
+ `; + + const toggleRightTemplate = html` +
this.updateCollapsed(!this.collapsed)} + > + ${toggleRight} +
+ `; + + return this.collapsed ? toggleRightTemplate : toggleDownTemplate; + } + + @property({ attribute: false }) + accessor collapsed!: boolean; + + @property({ attribute: false }) + accessor updateCollapsed!: (collapsed: boolean) => void; +} + +declare global { + interface HTMLElementTagNameMap { + 'blocksuite-toggle-button': ToggleButton; + } +} diff --git a/blocksuite/affine/components/src/toolbar/icon-button.ts b/blocksuite/affine/components/src/toolbar/icon-button.ts new file mode 100644 index 0000000000..c020f8d8ef --- /dev/null +++ b/blocksuite/affine/components/src/toolbar/icon-button.ts @@ -0,0 +1,211 @@ +import type { Placement } from '@floating-ui/dom'; +import type { TemplateResult } from 'lit'; +import { css, html, LitElement, nothing } from 'lit'; +import { property } from 'lit/decorators.js'; +import { cache } from 'lit/directives/cache.js'; +import { styleMap } from 'lit/directives/style-map.js'; + +export class EditorIconButton extends LitElement { + static override styles = css` + :host([disabled]), + :host(:disabled) { + pointer-events: none; + cursor: not-allowed; + color: var(--affine-text-disable-color); + } + + .icon-container { + position: relative; + display: flex; + align-items: center; + padding: var(--icon-container-padding); + color: var(--affine-icon-color); + border-radius: 4px; + cursor: pointer; + white-space: nowrap; + box-sizing: border-box; + width: var(--icon-container-width, unset); + justify-content: var(--justify, unset); + user-select: none; + } + + :host([active]) .icon-container.active-mode-color { + color: var(--affine-primary-color); + } + + :host([active]) .icon-container.active-mode-background { + background: var(--affine-hover-color); + } + + .icon-container[coming] { + cursor: not-allowed; + color: var(--affine-text-disable-color); + } + + ::slotted(svg) { + flex-shrink: 0; + width: var(--icon-size, unset); + height: var(--icon-size, unset); + } + + ::slotted(.label) { + flex: 1; + padding: 0 4px; + overflow: hidden; + white-space: nowrap; + line-height: var(--label-height, inherit); + } + ::slotted(.label.padding0) { + padding: 0; + } + ::slotted(.label.ellipsis) { + text-overflow: ellipsis; + } + ::slotted(.label.medium) { + font-weight: 500; + } + + .icon-container[with-hover]::before { + content: ''; + display: block; + background: var(--affine-hover-color); + position: absolute; + width: 100%; + height: 100%; + top: 0; + left: 0; + border-radius: 4px; + } + `; + + constructor() { + super(); + + // Allow activate button by pressing Enter key + this.addEventListener('keypress', event => { + if (this.disabled) { + return; + } + if (event.key === 'Enter' && !event.isComposing) { + this.click(); + } + }); + + // Prevent click event when disabled + this.addEventListener( + 'click', + event => { + if (this.disabled) { + event.stopPropagation(); + event.preventDefault(); + } + }, + { capture: true } + ); + } + + override connectedCallback() { + super.connectedCallback(); + this.tabIndex = 0; + this.role = 'button'; + } + + override render() { + const tooltip = this.coming ? '(Coming soon)' : this.tooltip; + const classnames = `icon-container active-mode-${this.activeMode} ${this.hoverState ? 'hovered' : ''}`; + const padding = this.iconContainerPadding; + const iconContainerStyles = styleMap({ + '--icon-container-width': this.iconContainerWidth, + '--icon-container-padding': Array.isArray(padding) + ? padding.map(v => `${v}px`).join(' ') + : `${padding}px`, + '--icon-size': this.iconSize, + '--justify': this.justify, + '--label-height': this.labelHeight, + }); + + return html` + +
+ + ${cache( + this.showTooltip && tooltip + ? html`${tooltip}` + : nothing + )} +
+ `; + } + + @property({ type: Boolean, reflect: true }) + accessor active = false; + + @property({ attribute: false }) + accessor activeMode: 'color' | 'background' = 'color'; + + @property({ attribute: false }) + accessor arrow = true; + + @property({ attribute: false }) + accessor coming = false; + + @property({ type: Boolean, reflect: true }) + accessor disabled = false; + + @property({ attribute: false }) + accessor hover = true; + + @property({ attribute: false }) + accessor hoverState = false; + + @property({ attribute: false }) + accessor iconContainerPadding: number | number[] = 2; + + @property({ attribute: false }) + accessor iconContainerWidth: string | undefined = undefined; + + @property({ attribute: false }) + accessor iconSize: string | undefined = undefined; + + @property({ attribute: false }) + accessor justify: string | undefined = undefined; + + @property({ attribute: false }) + accessor labelHeight: string | undefined = undefined; + + @property({ type: Boolean }) + accessor showTooltip = true; + + @property({ attribute: false }) + accessor tipPosition: Placement = 'top'; + + @property({ attribute: false }) + accessor tooltip!: string | TemplateResult<1>; + + @property({ attribute: false }) + accessor tooltipOffset = 8; + + @property({ attribute: false }) + accessor withHover: boolean | undefined = undefined; +} + +declare global { + interface HTMLElementTagNameMap { + 'editor-icon-button': EditorIconButton; + } +} diff --git a/blocksuite/affine/components/src/toolbar/index.ts b/blocksuite/affine/components/src/toolbar/index.ts new file mode 100644 index 0000000000..43aa802784 --- /dev/null +++ b/blocksuite/affine/components/src/toolbar/index.ts @@ -0,0 +1,43 @@ +import { EditorIconButton } from './icon-button.js'; +import { + EditorMenuAction, + EditorMenuButton, + EditorMenuContent, +} from './menu-button.js'; +import { EditorToolbarSeparator } from './separator.js'; +import { EditorToolbar } from './toolbar.js'; +import { Tooltip } from './tooltip.js'; + +export { EditorIconButton } from './icon-button.js'; +export { + EditorMenuAction, + EditorMenuButton, + EditorMenuContent, +} from './menu-button.js'; +export { EditorToolbarSeparator } from './separator.js'; +export { darkToolbarStyles, lightToolbarStyles } from './styles.js'; +export { EditorToolbar } from './toolbar.js'; +export { Tooltip } from './tooltip.js'; +export type { + AdvancedMenuItem, + FatMenuItems, + MenuItem, + MenuItemGroup, +} from './types.js'; +export { + cloneGroups, + groupsToActions, + renderActions, + renderGroups, + renderToolbarSeparator, +} from './utils.js'; + +export function effects() { + customElements.define('editor-toolbar-separator', EditorToolbarSeparator); + customElements.define('editor-toolbar', EditorToolbar); + customElements.define('editor-icon-button', EditorIconButton); + customElements.define('editor-menu-button', EditorMenuButton); + customElements.define('editor-menu-content', EditorMenuContent); + customElements.define('editor-menu-action', EditorMenuAction); + customElements.define('affine-tooltip', Tooltip); +} diff --git a/blocksuite/affine/components/src/toolbar/menu-button.ts b/blocksuite/affine/components/src/toolbar/menu-button.ts new file mode 100644 index 0000000000..2d70f92978 --- /dev/null +++ b/blocksuite/affine/components/src/toolbar/menu-button.ts @@ -0,0 +1,226 @@ +import { PANEL_BASE } from '@blocksuite/affine-shared/styles'; +import { createButtonPopper } from '@blocksuite/affine-shared/utils'; +import { WithDisposable } from '@blocksuite/global/utils'; +import { + css, + html, + LitElement, + type PropertyValues, + type TemplateResult, +} from 'lit'; +import { property, query } from 'lit/decorators.js'; + +import type { EditorIconButton } from './icon-button.js'; + +export class EditorMenuButton extends WithDisposable(LitElement) { + static override styles = css` + :host { + display: flex; + align-items: center; + justify-content: center; + gap: 8px; + } + `; + + private _popper!: ReturnType; + + override firstUpdated() { + this._popper = createButtonPopper( + this._trigger, + this._content, + ({ display }) => { + const opened = display === 'show'; + this._trigger.showTooltip = !opened; + + this.dispatchEvent( + new CustomEvent('toggle', { + detail: opened, + bubbles: false, + cancelable: false, + composed: true, + }) + ); + }, + { + mainAxis: 12, + ignoreShift: true, + } + ); + this._disposables.addFromEvent(this, 'keydown', (e: KeyboardEvent) => { + e.stopPropagation(); + if (e.key === 'Escape') { + this._popper.hide(); + } + }); + this._disposables.addFromEvent(this._trigger, 'click', (_: MouseEvent) => { + this._popper.toggle(); + if (this._popper.state === 'show') { + this._content.focus({ preventScroll: true }); + } + }); + this._disposables.add(this._popper); + } + + hide() { + this._popper?.hide(); + } + + override render() { + return html` + ${this.button} + + + + `; + } + + show(force = false) { + this._popper?.show(force); + } + + override willUpdate(changedProperties: PropertyValues) { + if (changedProperties.has('contentPadding')) { + this.style.setProperty('--content-padding', this.contentPadding ?? ''); + } + } + + @query('editor-menu-content') + private accessor _content!: EditorMenuContent; + + @query('editor-icon-button') + private accessor _trigger!: EditorIconButton; + + @property({ attribute: false }) + accessor button!: string | TemplateResult<1>; + + @property({ attribute: false }) + accessor contentPadding: string | undefined = undefined; +} + +export class EditorMenuContent extends LitElement { + static override styles = css` + :host { + --packed-height: 6px; + --offset-height: calc(-1 * var(--packed-height)); + display: none; + outline: none; + } + + :host::before, + :host::after { + content: ''; + display: block; + position: absolute; + height: var(--packed-height); + width: 100%; + } + + :host::before { + top: var(--offset-height); + } + + :host::after { + bottom: var(--offset-height); + } + + :host([data-show]) { + ${PANEL_BASE}; + justify-content: center; + padding: var(--content-padding, 0 6px); + } + + ::slotted(:not(.custom)) { + display: flex; + align-items: center; + align-self: stretch; + gap: 8px; + min-height: 36px; + } + + ::slotted([data-size]) { + min-width: 146px; + } + + ::slotted([data-size='small']) { + min-width: 164px; + } + + ::slotted([data-size='large']) { + min-width: 176px; + } + + ::slotted([data-orientation='vertical']) { + flex-direction: column; + align-items: stretch; + gap: unset; + min-height: unset; + } + `; + + override render() { + return html``; + } +} + +export class EditorMenuAction extends LitElement { + static override styles = css` + :host { + display: flex; + width: 100%; + align-items: center; + white-space: nowrap; + box-sizing: border-box; + padding: 4px 8px; + border-radius: 4px; + overflow: hidden; + text-overflow: ellipsis; + cursor: pointer; + gap: 8px; + color: var(--affine-text-primary-color); + font-weight: 400; + min-height: 30px; // 22 + 8 + } + + :host(:hover), + :host([data-selected]) { + background-color: var(--affine-hover-color); + } + + :host([data-selected]) { + pointer-events: none; + } + + :host(:hover.delete), + :host(:hover.delete) ::slotted(svg) { + background-color: var(--affine-background-error-color); + color: var(--affine-error-color); + } + + :host([disabled]) { + pointer-events: none; + cursor: not-allowed; + color: var(--affine-text-disable-color); + } + + ::slotted(svg) { + color: var(--affine-icon-color); + } + `; + + override connectedCallback() { + super.connectedCallback(); + this.role = 'button'; + } + + override render() { + return html``; + } +} + +declare global { + interface HTMLElementTagNameMap { + 'editor-menu-button': EditorMenuButton; + 'editor-menu-content': EditorMenuContent; + 'editor-menu-action': EditorMenuAction; + } +} diff --git a/blocksuite/affine/components/src/toolbar/separator.ts b/blocksuite/affine/components/src/toolbar/separator.ts new file mode 100644 index 0000000000..509a1ae1b2 --- /dev/null +++ b/blocksuite/affine/components/src/toolbar/separator.ts @@ -0,0 +1,39 @@ +import { css, LitElement } from 'lit'; + +export class EditorToolbarSeparator extends LitElement { + static override styles = css` + :host { + display: flex; + align-items: center; + justify-content: center; + align-self: stretch; + flex-shrink: 0; + + width: 4px; + } + + :host::after { + content: ''; + display: flex; + width: 0.5px; + height: 100%; + background-color: var(--affine-border-color); + } + + :host([data-orientation='horizontal']) { + height: 8px; + width: unset; + } + + :host([data-orientation='horizontal'])::after { + height: 0.5px; + width: 100%; + } + `; +} + +declare global { + interface HTMLElementTagNameMap { + 'editor-toolbar-separator': EditorToolbarSeparator; + } +} diff --git a/blocksuite/affine/components/src/toolbar/styles.ts b/blocksuite/affine/components/src/toolbar/styles.ts new file mode 100644 index 0000000000..9718aae547 --- /dev/null +++ b/blocksuite/affine/components/src/toolbar/styles.ts @@ -0,0 +1,29 @@ +import { + type AffineCssVariables, + combinedDarkCssVariables, + combinedLightCssVariables, +} from '@toeverything/theme'; +import { unsafeCSS } from 'lit'; + +const toolbarColorKeys: Array = [ + '--affine-background-overlay-panel-color', + '--affine-v2-layer-background-overlayPanel' as never, + '--affine-background-error-color', + '--affine-background-primary-color', + '--affine-background-tertiary-color', + '--affine-icon-color', + '--affine-icon-secondary', + '--affine-border-color', + '--affine-divider-color', + '--affine-text-primary-color', + '--affine-hover-color', + '--affine-hover-color-filled', +]; + +export const lightToolbarStyles = toolbarColorKeys.map( + key => `${key}: ${unsafeCSS(combinedLightCssVariables[key])};` +); + +export const darkToolbarStyles = toolbarColorKeys.map( + key => `${key}: ${unsafeCSS(combinedDarkCssVariables[key])};` +); diff --git a/blocksuite/affine/components/src/toolbar/toolbar.ts b/blocksuite/affine/components/src/toolbar/toolbar.ts new file mode 100644 index 0000000000..6ed94ae69f --- /dev/null +++ b/blocksuite/affine/components/src/toolbar/toolbar.ts @@ -0,0 +1,50 @@ +import { PANEL_BASE } from '@blocksuite/affine-shared/styles'; +import { stopPropagation } from '@blocksuite/affine-shared/utils'; +import { WithDisposable } from '@blocksuite/global/utils'; +import { css, html, LitElement } from 'lit'; + +export class EditorToolbar extends WithDisposable(LitElement) { + static override styles = css` + :host { + ${PANEL_BASE}; + height: 36px; + box-sizing: content-box; + } + + :host([data-without-bg]) { + border-color: transparent; + background: transparent; + box-shadow: none; + } + + ::slotted(*) { + display: flex; + height: 100%; + justify-content: center; + align-items: center; + gap: 8px; + color: var(--affine-text-primary-color); + fill: currentColor; + } + `; + + override connectedCallback() { + super.connectedCallback(); + + this._disposables.addFromEvent(this, 'pointerdown', (e: PointerEvent) => { + e.stopPropagation(); + e.preventDefault(); + }); + this._disposables.addFromEvent(this, 'wheel', stopPropagation); + } + + override render() { + return html``; + } +} + +declare global { + interface HTMLElementTagNameMap { + 'editor-toolbar': EditorToolbar; + } +} diff --git a/blocksuite/affine/components/src/toolbar/tooltip.ts b/blocksuite/affine/components/src/toolbar/tooltip.ts new file mode 100644 index 0000000000..9cc12e15d2 --- /dev/null +++ b/blocksuite/affine/components/src/toolbar/tooltip.ts @@ -0,0 +1,279 @@ +import { assertExists } from '@blocksuite/global/utils'; +import { + arrow, + type ComputePositionReturn, + flip, + offset, + type Placement, +} from '@floating-ui/dom'; +import type { CSSResult } from 'lit'; +import { css, html, LitElement, unsafeCSS } from 'lit'; +import { property } from 'lit/decorators.js'; +import { type StyleInfo, styleMap } from 'lit/directives/style-map.js'; + +import { HoverController, type HoverOptions } from '../hover/index.js'; + +const styles = css` + .affine-tooltip { + box-sizing: border-box; + max-width: 280px; + min-height: 32px; + font-family: var(--affine-font-family); + font-size: var(--affine-font-sm); + border-radius: 4px; + padding: 6px 12px; + color: var(--affine-white); + background: var(--affine-tooltip); + + display: flex; + justify-content: center; + align-items: center; + overflow-wrap: anywhere; + white-space: pre-wrap; + } + + .arrow { + position: absolute; + + width: 0; + height: 0; + } +`; + +// See http://apps.eky.hk/css-triangle-generator/ +const TRIANGLE_HEIGHT = 6; +const triangleMap = { + top: { + bottom: '-6px', + borderStyle: 'solid', + borderWidth: '6px 5px 0 5px', + borderColor: 'var(--affine-tooltip) transparent transparent transparent', + }, + right: { + left: '-6px', + borderStyle: 'solid', + borderWidth: '5px 6px 5px 0', + borderColor: 'transparent var(--affine-tooltip) transparent transparent', + }, + bottom: { + top: '-6px', + borderStyle: 'solid', + borderWidth: '0 5px 6px 5px', + borderColor: 'transparent transparent var(--affine-tooltip) transparent', + }, + left: { + right: '-6px', + borderStyle: 'solid', + borderWidth: '5px 0 5px 6px', + borderColor: 'transparent transparent transparent var(--affine-tooltip)', + }, +}; + +// Ported from https://floating-ui.com/docs/tutorial#arrow-middleware +const updateArrowStyles = ({ + placement, + middlewareData, +}: ComputePositionReturn): StyleInfo => { + const arrowX = middlewareData.arrow?.x; + const arrowY = middlewareData.arrow?.y; + + const triangleStyles = + triangleMap[placement.split('-')[0] as keyof typeof triangleMap]; + + return { + left: arrowX != null ? `${arrowX}px` : '', + top: arrowY != null ? `${arrowY}px` : '', + ...triangleStyles, + }; +}; + +/** + * @example + * ```ts + * // Simple usage + * html` + * Content + * ` + * // With placement + * html` + * + * Content + * + * ` + * + * // With custom properties + * html` + * + * Content + * + * ` + * ``` + */ +export class Tooltip extends LitElement { + static override styles = css` + :host { + display: none; + } + `; + + private _hoverController!: HoverController; + + private _setUpHoverController = () => { + this._hoverController = new HoverController( + this, + () => { + // const parentElement = this.parentElement; + // if ( + // parentElement && + // 'disabled' in parentElement && + // parentElement.disabled + // ) + // return null; + if (this.hidden) return null; + let arrowStyles: StyleInfo = {}; + return { + template: ({ positionSlot, updatePortal }) => { + positionSlot.on(data => { + // The tooltip placement may change, + // so we need to update the arrow position + if (this.arrow) { + arrowStyles = updateArrowStyles(data); + } else { + arrowStyles = {}; + } + updatePortal(); + }); + + const children = Array.from(this.childNodes).map(node => + node.cloneNode(true) + ); + + return html` + + +
+ `; + }, + computePosition: portalRoot => ({ + referenceElement: this.parentElement!, + placement: this.placement, + middleware: [ + this.autoFlip && flip({ padding: 12 }), + offset((this.arrow ? TRIANGLE_HEIGHT : 0) + this.offset), + arrow({ + element: portalRoot.shadowRoot!.querySelector('.arrow')!, + }), + ], + autoUpdate: true, + }), + }; + }, + { + leaveDelay: 0, + // The tooltip is not interactive by default + safeBridge: false, + allowMultiple: true, + ...this.hoverOptions, + } + ); + + const parent = this.parentElement; + assertExists(parent, 'Tooltip must have a parent element'); + + // Wait for render + setTimeout(() => { + this._hoverController.setReference(parent); + }, 0); + }; + + private _getStyles() { + return css` + ${styles} + :host { + z-index: ${unsafeCSS(this.zIndex)}; + opacity: 0; + ${ + // All the styles are applied to the portal element + unsafeCSS(this.style.cssText) + } + } + + ${this.allowInteractive + ? css`` + : css` + :host { + pointer-events: none; + } + `} + + ${this.tooltipStyle} + `; + } + + override connectedCallback() { + super.connectedCallback(); + + this._setUpHoverController(); + } + + getPortal() { + return this._hoverController.portal; + } + + /** + * Allow the tooltip to be interactive. + * eg. allow the user to select text in the tooltip. + */ + @property({ attribute: false }) + accessor allowInteractive = false; + + /** + * Show a triangle arrow pointing to the reference element. + */ + @property({ attribute: false }) + accessor arrow = true; + + /** + * changes the placement of the floating element in order to keep it in view, + * with the ability to flip to any placement. + * + * See https://floating-ui.com/docs/flip + */ + @property({ attribute: false }) + accessor autoFlip = true; + + @property({ attribute: false }) + accessor hoverOptions: Partial = {}; + + /** + * Default is `4px` + * + * See https://floating-ui.com/docs/offset + */ + @property({ attribute: false }) + accessor offset = 4; + + @property({ attribute: 'tip-position' }) + accessor placement: Placement = 'top'; + + @property({ attribute: false }) + accessor tooltipStyle: CSSResult = css``; + + @property({ attribute: false }) + accessor zIndex: number | string = 'var(--affine-z-index-popover)'; +} + +declare global { + interface HTMLElementTagNameMap { + 'affine-tooltip': Tooltip; + } +} diff --git a/blocksuite/affine/components/src/toolbar/types.ts b/blocksuite/affine/components/src/toolbar/types.ts new file mode 100644 index 0000000000..825ec01345 --- /dev/null +++ b/blocksuite/affine/components/src/toolbar/types.ts @@ -0,0 +1,31 @@ +import type { nothing, TemplateResult } from 'lit'; + +export type MenuItemPart = { + action: () => void; + disabled?: boolean; + render?: (item: MenuItem) => TemplateResult<1>; +}; + +export type MenuItem = { + type: string; + label?: string; + tooltip?: string; + icon?: TemplateResult<1>; +} & MenuItemPart; + +export type AdvancedMenuItem = Omit & { + action?: (context: T) => void | Promise; + disabled?: boolean | ((context: T) => boolean); + when?: (context: T) => boolean; + // Generates action at runtime + generate?: (context: T) => MenuItemPart | void; +}; + +export type MenuItemGroup = { + type: string; + items: AdvancedMenuItem[]; + when?: (context: T) => boolean; +}; + +// Group Actions +export type FatMenuItems = (MenuItem | typeof nothing)[][]; diff --git a/blocksuite/affine/components/src/toolbar/utils.ts b/blocksuite/affine/components/src/toolbar/utils.ts new file mode 100644 index 0000000000..f6226916f3 --- /dev/null +++ b/blocksuite/affine/components/src/toolbar/utils.ts @@ -0,0 +1,103 @@ +import { html, nothing } from 'lit'; +import { ifDefined } from 'lit/directives/if-defined.js'; +import { join } from 'lit/directives/join.js'; +import { repeat } from 'lit/directives/repeat.js'; + +import type { FatMenuItems, MenuItem, MenuItemGroup } from './types.js'; + +export function groupsToActions( + groups: MenuItemGroup[], + context: T +): MenuItem[][] { + return groups + .filter(group => group.when?.(context) ?? true) + .map(({ items }) => + items + .filter(item => item.when?.(context) ?? true) + .map(({ type, label, tooltip, icon, action, disabled, generate }) => { + if (action && typeof action === 'function') { + return { + type, + label, + tooltip, + icon, + action: () => { + action(context)?.catch(console.error); + }, + disabled: + typeof disabled === 'function' ? disabled(context) : disabled, + }; + } + + if (generate && typeof generate === 'function') { + const result = generate(context); + + if (!result) return null; + + return { + type, + label, + tooltip, + icon, + ...result, + }; + } + + return null; + }) + .filter(item => !!item) + ); +} + +export function renderActions( + fatMenuItems: FatMenuItems, + action?: (item: MenuItem) => Promise | void, + selectedName?: string +) { + return join( + fatMenuItems + .filter(g => g.length) + .map(g => g.filter(a => a !== nothing) as MenuItem[]) + .filter(g => g.length) + .map(items => + repeat( + items, + item => item.type, + item => + item.render?.(item) ?? + html` + action?.(item)} + > + ${item.icon}${item.label + ? html`${item.label}` + : nothing} + + ` + ) + ), + () => html` + + ` + ); +} + +export function cloneGroups(groups: MenuItemGroup[]) { + return groups.map(group => ({ ...group, items: [...group.items] })); +} + +export function renderGroups(groups: MenuItemGroup[], context: T) { + return renderActions(groupsToActions(groups, context)); +} + +export function renderToolbarSeparator() { + return html``; +} diff --git a/blocksuite/affine/components/src/virtual-keyboard/controller.ts b/blocksuite/affine/components/src/virtual-keyboard/controller.ts new file mode 100644 index 0000000000..b9bf58a631 --- /dev/null +++ b/blocksuite/affine/components/src/virtual-keyboard/controller.ts @@ -0,0 +1,161 @@ +import { IS_IOS } from '@blocksuite/global/env'; +import { DisposableGroup } from '@blocksuite/global/utils'; +import { signal } from '@preact/signals-core'; +import type { ReactiveController, ReactiveControllerHost } from 'lit'; + +function notSupportedWarning() { + console.warn('VirtualKeyboard API and VisualViewport API are not supported'); +} + +export type VirtualKeyboardControllerConfig = { + useScreenHeight: boolean; + inputElement: HTMLElement; +}; + +export class VirtualKeyboardController implements ReactiveController { + private _disposables = new DisposableGroup(); + + private readonly _keyboardHeight$ = signal(0); + + private readonly _keyboardOpened$ = signal(false); + + private _storeInitialInputElementAttributes = () => { + const { inputElement } = this.config; + if (navigator.virtualKeyboard) { + const { overlaysContent } = navigator.virtualKeyboard; + const { virtualKeyboardPolicy } = inputElement; + + this._disposables.add(() => { + if (!navigator.virtualKeyboard) return; + navigator.virtualKeyboard.overlaysContent = overlaysContent; + inputElement.virtualKeyboardPolicy = virtualKeyboardPolicy; + }); + } else if (visualViewport) { + const { inputMode } = inputElement; + this._disposables.add(() => { + inputElement.inputMode = inputMode; + }); + } + }; + + private readonly _updateKeyboardHeight = () => { + const { virtualKeyboard } = navigator; + if (virtualKeyboard) { + this._keyboardOpened$.value = virtualKeyboard.boundingRect.height > 0; + this._keyboardHeight$.value = virtualKeyboard.boundingRect.height; + } else if (visualViewport) { + const windowHeight = this.config.useScreenHeight + ? window.screen.height + : window.innerHeight; + + /** + * ┌───────────────┐ - window top + * │ │ + * │ │ + * │ │ + * │ │ + * │ │ + * └───────────────┘ - keyboard top -- + * │ │ │ keyboard height in layout viewport + * └───────────────┘ - page(html) bottom -- + * │ │ │ visualViewport.offsetTop + * └───────────────┘ - window bottom -- + */ + this._keyboardOpened$.value = windowHeight - visualViewport.height > 0; + this._keyboardHeight$.value = + windowHeight - + visualViewport.height - + (IS_IOS ? 0 : visualViewport.offsetTop); + } else { + notSupportedWarning(); + } + }; + + hide = () => { + if (navigator.virtualKeyboard) { + navigator.virtualKeyboard.hide(); + } else { + this.config.inputElement.inputMode = 'none'; + } + }; + + host: ReactiveControllerHost & { + virtualKeyboardControllerConfig: VirtualKeyboardControllerConfig; + hasUpdated: boolean; + }; + + show = () => { + if (navigator.virtualKeyboard) { + navigator.virtualKeyboard.show(); + } else { + this.config.inputElement.inputMode = ''; + } + }; + + toggle = () => { + if (this.opened) { + this.hide(); + } else { + this.show(); + } + }; + + get config() { + return this.host.virtualKeyboardControllerConfig; + } + + /** + * Return the height of keyboard in layout viewport + * see comment in the `_updateKeyboardHeight` method + */ + get keyboardHeight() { + return this._keyboardHeight$.value; + } + + get opened() { + return this._keyboardOpened$.value; + } + + constructor(host: VirtualKeyboardController['host']) { + (this.host = host).addController(this); + } + + hostConnected() { + this._storeInitialInputElementAttributes(); + + const { inputElement } = this.config; + + if (navigator.virtualKeyboard) { + navigator.virtualKeyboard.overlaysContent = true; + this.config.inputElement.virtualKeyboardPolicy = 'manual'; + + this._disposables.addFromEvent( + navigator.virtualKeyboard, + 'geometrychange', + this._updateKeyboardHeight + ); + } else if (visualViewport) { + this._disposables.addFromEvent( + visualViewport, + 'resize', + this._updateKeyboardHeight + ); + this._disposables.addFromEvent( + visualViewport, + 'scroll', + this._updateKeyboardHeight + ); + } else { + notSupportedWarning(); + } + + this._disposables.addFromEvent(inputElement, 'focus', this.show); + this._disposables.addFromEvent(inputElement, 'blur', this.hide); + + this._updateKeyboardHeight(); + } + + hostDisconnected() { + this._disposables.dispose(); + } +} diff --git a/blocksuite/affine/components/src/virtual-keyboard/index.ts b/blocksuite/affine/components/src/virtual-keyboard/index.ts new file mode 100644 index 0000000000..534a0e5917 --- /dev/null +++ b/blocksuite/affine/components/src/virtual-keyboard/index.ts @@ -0,0 +1 @@ +export * from './controller.js'; diff --git a/blocksuite/affine/components/tsconfig.json b/blocksuite/affine/components/tsconfig.json new file mode 100644 index 0000000000..9a95b45230 --- /dev/null +++ b/blocksuite/affine/components/tsconfig.json @@ -0,0 +1,29 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "rootDir": "./src/", + "outDir": "./dist/", + "noEmit": false + }, + "include": ["./src"], + "references": [ + { + "path": "../../framework/global" + }, + { + "path": "../../framework/store" + }, + { + "path": "../../framework/block-std" + }, + { + "path": "../../framework/inline" + }, + { + "path": "../shared" + }, + { + "path": "../model" + } + ] +} diff --git a/blocksuite/affine/components/typedoc.json b/blocksuite/affine/components/typedoc.json new file mode 100644 index 0000000000..101e923dba --- /dev/null +++ b/blocksuite/affine/components/typedoc.json @@ -0,0 +1,4 @@ +{ + "extends": ["../../../typedoc.base.json"], + "entryPoints": ["src/index.ts"] +} diff --git a/blocksuite/affine/components/vitest.config.ts b/blocksuite/affine/components/vitest.config.ts new file mode 100644 index 0000000000..e2eab294b3 --- /dev/null +++ b/blocksuite/affine/components/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/affine-components', + }, + /** + * 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/blocksuite/affine/data-view/package.json b/blocksuite/affine/data-view/package.json new file mode 100644 index 0000000000..a7aaa480d6 --- /dev/null +++ b/blocksuite/affine/data-view/package.json @@ -0,0 +1,45 @@ +{ + "name": "@blocksuite/data-view", + "description": "Views of database in affine", + "type": "module", + "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": "MIT", + "dependencies": { + "@blocksuite/affine-components": "workspace:*", + "@blocksuite/affine-shared": "workspace:*", + "@blocksuite/block-std": "workspace:*", + "@blocksuite/global": "workspace:*", + "@blocksuite/icons": "^2.1.75", + "@blocksuite/store": "workspace:*", + "@emotion/hash": "^0.9.2", + "@floating-ui/dom": "^1.6.10", + "@lit/context": "^1.1.2", + "@preact/signals-core": "^1.8.0", + "@toeverything/theme": "^1.1.1", + "date-fns": "^4.0.0", + "lit": "^3.2.0", + "zod": "^3.23.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" + }, + "files": [ + "src", + "dist", + "!src/__tests__", + "!dist/__tests__" + ] +} diff --git a/blocksuite/affine/data-view/src/core/common/css-variable.ts b/blocksuite/affine/data-view/src/core/common/css-variable.ts new file mode 100644 index 0000000000..27ed19e14e --- /dev/null +++ b/blocksuite/affine/data-view/src/core/common/css-variable.ts @@ -0,0 +1,63 @@ +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, .dv-hover.active{ + background-color: var(--affine-hover-color); + cursor: pointer; + } + .dv-icon-16{ + font-size: 16px; + } + .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/blocksuite/affine/data-view/src/core/common/index.ts b/blocksuite/affine/data-view/src/core/common/index.ts new file mode 100644 index 0000000000..bab3f2282b --- /dev/null +++ b/blocksuite/affine/data-view/src/core/common/index.ts @@ -0,0 +1,9 @@ +export * from '../data-source/index.js'; +export * from '../detail/detail.js'; +export * from '../group-by/default.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 './css-variable.js'; +export * from './selection-schema.js'; +export * from './types.js'; diff --git a/blocksuite/affine/data-view/src/core/common/properties.ts b/blocksuite/affine/data-view/src/core/common/properties.ts new file mode 100644 index 0000000000..2192221e12 --- /dev/null +++ b/blocksuite/affine/data-view/src/core/common/properties.ts @@ -0,0 +1,287 @@ +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 { computed } from '@preact/signals-core'; +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 { dragHandler } from '../utils/wc-dnd/dnd-context.js'; +import { defaultActivators } from '../utils/wc-dnd/sensors/index.js'; +import { + createSortContext, + sortable, +} from '../utils/wc-dnd/sort/sort-context.js'; +import { verticalListSortingStrategy } from '../utils/wc-dnd/sort/strategies/index.js'; +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-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; + } + `; + + @property({ attribute: false }) + accessor view!: SingleView; + + items$ = computed(() => { + return this.view.propertiesWithoutFilter$.value; + }); + + 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}
+
`; + }; + + sortContext = createSortContext({ + activators: defaultActivators, + container: this, + onDragEnd: evt => { + const over = evt.over; + const activeId = evt.active.id; + if (over && over.id !== activeId) { + const properties = this.items$.value; + const activeIndex = properties.findIndex(id => id === activeId); + const overIndex = properties.findIndex(id => id === over.id); + + this.view.propertyMove( + activeId, + activeIndex > overIndex + ? { + before: true, + id: over.id, + } + : { + before: false, + id: over.id, + } + ); + } + }, + modifiers: [ + ({ transform }) => { + return { + ...transform, + x: 0, + }; + }, + ], + items: this.items$, + strategy: verticalListSortingStrategy, + }); + + 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 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; +} + +declare global { + interface HTMLElementTagNameMap { + 'data-view-properties-setting': DataViewPropertiesSettingView; + } +} + +export const popPropertiesSetting = ( + target: PopupTarget, + props: { + view: SingleView; + onClose?: () => void; + onBack?: () => void; + } +) => { + popMenu(target, { + options: { + title: { + text: 'Properties', + onBack: props.onBack, + postfix: () => { + const items = props.view.propertiesWithoutFilter$.value.map(id => + props.view.propertyGet(id) + ); + const isAllShowed = items.every(v => !v.hide$.value); + const clickChangeAll = () => { + props.view.propertiesWithoutFilter$.value.forEach(id => { + if (props.view.propertyTypeGet(id) !== 'title') { + props.view.propertyHideSet(id, isAllShowed); + } + }); + }; + return html`
+ ${isAllShowed ? 'Hide All' : 'Show All'} +
`; + }, + }, + items: [ + menu.group({ + items: [ + () => + html` `, + ], + }), + ], + }, + }); + + // const view = new DataViewPropertiesSettingView(); + // view.view = props.view; + // view.onBack = () => { + // close(); + // props.onBack?.(); + // }; + // const close = createPopup(target, view, { onClose: props.onClose }); +}; diff --git a/blocksuite/affine/data-view/src/core/common/property-menu.ts b/blocksuite/affine/data-view/src/core/common/property-menu.ts new file mode 100644 index 0000000000..b5fea71f20 --- /dev/null +++ b/blocksuite/affine/data-view/src/core/common/property-menu.ts @@ -0,0 +1,76 @@ +import { menu } from '@blocksuite/affine-components/context-menu'; +import { IS_MOBILE } from '@blocksuite/global/env'; +import { html } from 'lit/static-html.js'; + +import { renderUniLit } from '../utils/uni-component/index.js'; +import type { Property } from '../view-manager/property.js'; + +export const inputConfig = (property: Property) => { + if (IS_MOBILE) { + return menu.input({ + prefix: html` +
+ ${renderUniLit(property.icon)} +
+ `, + initialValue: property.name$.value, + onChange: text => { + property.nameSet(text); + }, + }); + } + return menu.input({ + prefix: html` +
+ ${renderUniLit(property.icon)} +
+ `, + initialValue: property.name$.value, + onComplete: text => { + property.nameSet(text); + }, + }); +}; +export const typeConfig = (property: Property) => { + return menu.group({ + items: [ + 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: { + title: { + text: 'Property type', + }, + items: [ + menu.group({ + items: property.view.propertyMetas.map(config => { + return menu.action({ + isSelected: config.type === property.type$.value, + name: config.config.name, + prefix: renderUniLit( + property.view.propertyIconGet(config.type) + ), + select: () => { + if (property.type$.value === config.type) { + return; + } + property.typeSet?.(config.type); + }, + }); + }), + }), + ], + }, + }), + ], + }); +}; diff --git a/blocksuite/affine/data-view/src/core/common/selection-schema.ts b/blocksuite/affine/data-view/src/core/common/selection-schema.ts new file mode 100644 index 0000000000..db6ee5bd56 --- /dev/null +++ b/blocksuite/affine/data-view/src/core/common/selection-schema.ts @@ -0,0 +1,133 @@ +import { BaseSelection, SelectionExtension } from '@blocksuite/block-std'; +import { z } from 'zod'; + +import type { DataViewSelection, GetDataViewSelection } from '../types.js'; + +const TableViewSelectionSchema = z.union([ + z.object({ + viewId: z.string(), + type: z.literal('table'), + selectionType: z.literal('area'), + rowsSelection: z.object({ + start: z.number(), + end: z.number(), + }), + columnsSelection: z.object({ + start: z.number(), + end: z.number(), + }), + focus: z.object({ + rowIndex: z.number(), + columnIndex: z.number(), + }), + isEditing: z.boolean(), + }), + z.object({ + viewId: z.string(), + type: z.literal('table'), + selectionType: z.literal('row'), + rows: z.array( + z.object({ id: z.string(), groupKey: z.string().optional() }) + ), + }), +]); + +const KanbanCellSelectionSchema = z.object({ + selectionType: z.literal('cell'), + groupKey: z.string(), + cardId: z.string(), + columnId: z.string(), + isEditing: z.boolean(), +}); + +const KanbanCardSelectionSchema = z.object({ + selectionType: z.literal('card'), + cards: z.array( + z.object({ + groupKey: z.string(), + cardId: z.string(), + }) + ), +}); + +const KanbanGroupSelectionSchema = z.object({ + selectionType: z.literal('group'), + groupKeys: z.array(z.string()), +}); + +const DatabaseSelectionSchema = z.object({ + blockId: z.string(), + viewSelection: z.union([ + TableViewSelectionSchema, + KanbanCellSelectionSchema, + KanbanCardSelectionSchema, + KanbanGroupSelectionSchema, + ]), +}); + +export class DatabaseSelection extends BaseSelection { + static override group = 'note'; + + static override type = 'database'; + + readonly viewSelection: DataViewSelection; + + get viewId() { + return this.viewSelection.viewId; + } + + constructor({ + blockId, + viewSelection, + }: { + blockId: string; + viewSelection: DataViewSelection; + }) { + super({ + blockId, + }); + + this.viewSelection = viewSelection; + } + + static override fromJSON(json: Record): DatabaseSelection { + DatabaseSelectionSchema.parse(json); + return new DatabaseSelection({ + blockId: json.blockId as string, + viewSelection: json.viewSelection as DataViewSelection, + }); + } + + override equals(other: BaseSelection): boolean { + if (!(other instanceof DatabaseSelection)) { + return false; + } + return this.blockId === other.blockId; + } + + getSelection( + type: T + ): GetDataViewSelection | undefined { + return this.viewSelection.type === type + ? (this.viewSelection as GetDataViewSelection) + : undefined; + } + + override toJSON(): Record { + return { + type: 'database', + blockId: this.blockId, + viewSelection: this.viewSelection, + }; + } +} + +declare global { + namespace BlockSuite { + interface Selection { + database: typeof DatabaseSelection; + } + } +} + +export const DatabaseSelectionExtension = SelectionExtension(DatabaseSelection); diff --git a/blocksuite/affine/data-view/src/core/common/types.ts b/blocksuite/affine/data-view/src/core/common/types.ts new file mode 100644 index 0000000000..b798aa684c --- /dev/null +++ b/blocksuite/affine/data-view/src/core/common/types.ts @@ -0,0 +1,13 @@ +export type GroupBy = { + type: 'groupBy'; + columnId: string; + name: string; + sort?: { + desc: boolean; + }; +}; +export type GroupProperty = { + key: string; + hide?: boolean; + manuallyCardSort: string[]; +}; diff --git a/blocksuite/affine/data-view/src/core/component/button/button.ts b/blocksuite/affine/data-view/src/core/component/button/button.ts new file mode 100644 index 0000000000..c3814bffa2 --- /dev/null +++ b/blocksuite/affine/data-view/src/core/component/button/button.ts @@ -0,0 +1,89 @@ +import { unsafeCSSVarV2 } from '@blocksuite/affine-shared/theme'; +import { ShadowlessElement } from '@blocksuite/block-std'; +import { SignalWatcher, WithDisposable } from '@blocksuite/global/utils'; +import { css, html, type TemplateResult } from 'lit'; +import { property } from 'lit/decorators.js'; + +export class Button extends SignalWatcher(WithDisposable(ShadowlessElement)) { + static override styles = css` + data-view-component-button { + border-radius: 4px; + border: 1px solid ${unsafeCSSVarV2('layer/insideBorder/border')}; + display: flex; + padding: 4px 8px; + align-items: center; + gap: 4px; + font-size: 14px; + font-weight: 400; + line-height: 22px; + color: ${unsafeCSSVarV2('text/primary')}; + cursor: pointer; + transition: + color 0.2s, + background-color 0.2s, + border-color 0.2s; + white-space: nowrap; + } + + data-view-component-button.border:hover, + data-view-component-button.border.active { + color: ${unsafeCSSVarV2('text/emphasis')}; + border-color: ${unsafeCSSVarV2('icon/activated')}; + } + + data-view-component-button.background:hover, + data-view-component-button.background.active { + background-color: ${unsafeCSSVarV2('layer/background/hoverOverlay')}; + } + + .button-icon { + font-size: 16px; + display: flex; + align-items: center; + transition: color 0.2s; + color: ${unsafeCSSVarV2('icon/primary')}; + } + + data-view-component-button.border:hover .button-icon, + data-view-component-button.border.active .button-icon { + color: ${unsafeCSSVarV2('icon/activated')}; + } + `; + + override connectedCallback() { + super.connectedCallback(); + this.classList.add(this.hoverType); + if (this.onClick) { + this.disposables.addFromEvent(this, 'click', this.onClick); + } + } + + override render() { + return html` +
${this.icon}
+ ${this.text} +
${this.postfix}
+ `; + } + + @property() + accessor hoverType: 'background' | 'border' = 'background'; + + @property({ attribute: false }) + accessor icon: TemplateResult | undefined; + + @property({ attribute: false }) + accessor onClick: ((event: MouseEvent) => void) | undefined; + + @property({ attribute: false }) + accessor postfix: TemplateResult | string | undefined; + + @property({ attribute: false }) + accessor text: TemplateResult | string | undefined; +} + +declare global { + interface HTMLElementTagNameMap { + 'data-view-component-button': Button; + } +} diff --git a/blocksuite/affine/data-view/src/core/component/index.ts b/blocksuite/affine/data-view/src/core/component/index.ts new file mode 100644 index 0000000000..a5f53a65d9 --- /dev/null +++ b/blocksuite/affine/data-view/src/core/component/index.ts @@ -0,0 +1,3 @@ +export * from './button/button.js'; +export * from './overflow/overflow.js'; +export * from './tags/index.js'; diff --git a/blocksuite/affine/data-view/src/core/component/overflow/overflow.ts b/blocksuite/affine/data-view/src/core/component/overflow/overflow.ts new file mode 100644 index 0000000000..dd9bde3894 --- /dev/null +++ b/blocksuite/affine/data-view/src/core/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` + component-overflow { + display: flex; + flex-wrap: wrap; + width: 100%; + position: relative; + } + + .component-overflow-item { + } + .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({ + '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 > .component-overflow-item') + accessor items!: HTMLDivElement[] & NodeList; + + @query(':scope > .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/blocksuite/affine/data-view/src/core/component/tags/colors.ts b/blocksuite/affine/data-view/src/core/component/tags/colors.ts new file mode 100644 index 0000000000..36f852050c --- /dev/null +++ b/blocksuite/affine/data-view/src/core/component/tags/colors.ts @@ -0,0 +1,87 @@ +import { cssVarV2 } from '@toeverything/theme/v2'; + +export type SelectOptionColor = { + oldColor: string; + color: string; + name: string; +}; +export const selectOptionColors: SelectOptionColor[] = [ + { + oldColor: 'var(--affine-tag-red)', + color: cssVarV2('chip/label/red'), + name: 'Red', + }, + { + oldColor: 'var(--affine-tag-pink)', + color: cssVarV2('chip/label/magenta'), + name: 'Magenta', + }, + { + oldColor: 'var(--affine-tag-orange)', + color: cssVarV2('chip/label/orange'), + name: 'Orange', + }, + { + oldColor: 'var(--affine-tag-yellow)', + color: cssVarV2('chip/label/yellow'), + name: 'Yellow', + }, + { + oldColor: 'var(--affine-tag-green)', + color: cssVarV2('chip/label/green'), + name: 'Green', + }, + { + oldColor: 'var(--affine-tag-teal)', + color: cssVarV2('chip/label/teal'), + name: 'Teal', + }, + { + oldColor: 'var(--affine-tag-blue)', + color: cssVarV2('chip/label/blue'), + name: 'Blue', + }, + { + oldColor: 'var(--affine-tag-purple)', + color: cssVarV2('chip/label/purple'), + name: 'Purple', + }, + { + oldColor: 'var(--affine-tag-gray)', + color: cssVarV2('chip/label/grey'), + name: 'Grey', + }, + { + oldColor: 'var(--affine-tag-white)', + color: cssVarV2('chip/label/white'), + name: 'White', + }, +]; + +const oldColorMap = Object.fromEntries( + selectOptionColors.map(tag => [tag.oldColor, tag.color]) +); + +export const getColorByColor = (color: string) => { + if (color.startsWith('--affine-tag')) { + return oldColorMap[color] ?? color; + } + return color; +}; + +/** select tag color poll */ +const selectTagColorPoll = selectOptionColors.map(color => color.color); + +function tagColorHelper() { + let colors = [...selectTagColorPoll]; + return () => { + if (colors.length === 0) { + colors = [...selectTagColorPoll]; + } + const index = Math.floor(Math.random() * colors.length); + const color = colors.splice(index, 1)[0]; + return color; + }; +} + +export const getTagColor = tagColorHelper(); diff --git a/blocksuite/affine/data-view/src/core/component/tags/index.ts b/blocksuite/affine/data-view/src/core/component/tags/index.ts new file mode 100644 index 0000000000..acb9a8bbf2 --- /dev/null +++ b/blocksuite/affine/data-view/src/core/component/tags/index.ts @@ -0,0 +1,3 @@ +export * from './colors.js'; +export * from './multi-tag-select.js'; +export * from './multi-tag-view.js'; diff --git a/blocksuite/affine/data-view/src/core/component/tags/multi-tag-select.ts b/blocksuite/affine/data-view/src/core/component/tags/multi-tag-select.ts new file mode 100644 index 0000000000..d38dc1f358 --- /dev/null +++ b/blocksuite/affine/data-view/src/core/component/tags/multi-tag-select.ts @@ -0,0 +1,574 @@ +import { + createPopup, + menu, + popMenu, + type PopupTarget, + popupTargetFromElement, +} from '@blocksuite/affine-components/context-menu'; +import { unsafeCSSVarV2 } from '@blocksuite/affine-shared/theme'; +import { rangeWrap } from '@blocksuite/affine-shared/utils'; +import { ShadowlessElement } from '@blocksuite/block-std'; +import { IS_MOBILE } from '@blocksuite/global/env'; +import { SignalWatcher, WithDisposable } from '@blocksuite/global/utils'; +import { + CloseIcon, + DeleteIcon, + MoreHorizontalIcon, +} from '@blocksuite/icons/lit'; +import { nanoid } from '@blocksuite/store'; +import { flip, offset } from '@floating-ui/dom'; +import { computed, type ReadonlySignal, signal } from '@preact/signals-core'; +import { cssVarV2 } from '@toeverything/theme/v2'; +import { nothing } from 'lit'; +import { property, query, state } from 'lit/decorators.js'; +import { classMap } from 'lit/directives/class-map.js'; +import { repeat } from 'lit/directives/repeat.js'; +import { styleMap } from 'lit/directives/style-map.js'; +import { html } from 'lit/static-html.js'; + +import type { SelectTag } from '../../logical/index.js'; +import { stopPropagation } from '../../utils/event.js'; +import { dragHandler } from '../../utils/wc-dnd/dnd-context.js'; +import { defaultActivators } from '../../utils/wc-dnd/sensors/index.js'; +import { + createSortContext, + sortable, +} from '../../utils/wc-dnd/sort/sort-context.js'; +import { verticalListSortingStrategy } from '../../utils/wc-dnd/sort/strategies/index.js'; +import { arrayMove } from '../../utils/wc-dnd/utils/array-move.js'; +import { getTagColor, selectOptionColors } from './colors.js'; +import { styles } from './styles.js'; + +type RenderOption = { + value: string; + id: string; + color: string; + isCreate: boolean; + select: () => void; +}; +export type TagManagerOptions = { + mode?: 'single' | 'multi'; + value: ReadonlySignal; + onChange: (value: string[]) => void; + options: ReadonlySignal; + onOptionsChange: (options: SelectTag[]) => void; + onComplete?: () => void; +}; + +class TagManager { + changeTag = (option: SelectTag) => { + this.ops.onOptionsChange( + this.ops.options.value.map(item => { + if (item.id === option.id) { + return { + ...item, + ...option, + }; + } + return item; + }) + ); + }; + + color = signal(getTagColor()); + + createOption = () => { + const value = this.text.value.trim(); + if (value === '') return; + const id = nanoid(); + this.ops.onOptionsChange([ + { + id: id, + value: value, + color: this.color.value, + }, + ...this.ops.options.value, + ]); + this.selectTag(id); + this.text.value = ''; + this.color.value = getTagColor(); + if (this.isSingleMode) { + this.ops.onComplete?.(); + } + }; + + deleteOption = (id: string) => { + this.ops.onOptionsChange( + this.ops.options.value.filter(item => item.id !== id) + ); + }; + + filteredOptions$ = computed(() => { + let matched = false; + const options: RenderOption[] = []; + for (const option of this.options.value) { + if ( + !this.text.value || + option.value + .toLocaleLowerCase() + .includes(this.text.value.toLocaleLowerCase()) + ) { + options.push({ + ...option, + isCreate: false, + select: () => this.selectTag(option.id), + }); + } + if (option.value === this.text.value) { + matched = true; + } + } + if (this.text.value && !matched) { + options.push({ + id: 'create', + color: this.color.value, + value: this.text.value, + isCreate: true, + select: this.createOption, + }); + } + return options; + }); + + optionsMap$ = computed(() => { + return new Map( + this.ops.options.value.map(v => [v.id, v]) + ); + }); + + text = signal(''); + + get isSingleMode() { + return this.ops.mode === 'single'; + } + + get options() { + return this.ops.options; + } + + get value() { + return this.ops.value; + } + + constructor(private ops: TagManagerOptions) {} + + deleteTag(id: string) { + this.ops.onChange(this.value.value.filter(item => item !== id)); + } + + isSelected(id: string) { + return this.value.value.includes(id); + } + + selectTag(id: string) { + if (this.isSelected(id)) { + return; + } + const newValue = this.isSingleMode ? [id] : [...this.value.value, id]; + this.ops.onChange(newValue); + this.text.value = ''; + if (this.isSingleMode) { + requestAnimationFrame(() => { + this.ops.onComplete?.(); + }); + } + } +} + +export class MultiTagSelect extends SignalWatcher( + WithDisposable(ShadowlessElement) +) { + static override styles = styles; + + private _clickItemOption = (e: MouseEvent, id: string) => { + e.stopPropagation(); + const option = this.options.value.find(v => v.id === id); + if (!option) { + return; + } + popMenu(popupTargetFromElement(e.currentTarget as HTMLElement), { + options: { + items: [ + menu.input({ + initialValue: option.value, + onChange: text => { + this.tagManager.changeTag({ + ...option, + value: text, + }); + }, + }), + menu.action({ + name: 'Delete', + prefix: DeleteIcon(), + class: { + 'delete-item': true, + }, + select: () => { + this.tagManager.deleteOption(id); + }, + }), + menu.group({ + name: 'color', + items: selectOptionColors.map(item => { + const styles = styleMap({ + backgroundColor: item.color, + borderRadius: '50%', + width: '20px', + height: '20px', + }); + return menu.action({ + name: item.name, + prefix: html`
`, + isSelected: option.color === item.color, + select: () => { + this.tagManager.changeTag({ + ...option, + color: item.color, + }); + }, + }); + }), + }), + ], + }, + }); + }; + + private _onInput = (event: KeyboardEvent) => { + this.tagManager.text.value = (event.target as HTMLInputElement).value; + }; + + private _onInputKeydown = (event: KeyboardEvent) => { + event.stopPropagation(); + const inputValue = this.text.value.trim(); + if (event.key === 'Backspace' && inputValue === '') { + this.tagManager.deleteTag(this.value.value[this.value.value.length - 1]); + } else if (event.key === 'Enter' && !event.isComposing) { + this.selectedTag$.value?.select(); + } else if (event.key === 'ArrowUp') { + event.preventDefault(); + this.setSelectedOption(this.selectedIndex - 1); + } else if (event.key === 'ArrowDown') { + event.preventDefault(); + this.setSelectedOption(this.selectedIndex + 1); + } else if (event.key === 'Escape') { + this.onComplete(); + } + }; + + private tagManager = new TagManager(this); + + private selectedTag$ = computed(() => { + return this.tagManager.filteredOptions$.value[this.selectedIndex]; + }); + + sortContext = createSortContext({ + activators: defaultActivators, + container: this, + onDragEnd: evt => { + const over = evt.over; + const activeId = evt.active.id; + if (over && over.id !== activeId) { + this.onOptionsChange( + arrayMove( + this.options.value, + this.options.value.findIndex(v => v.id === activeId), + this.options.value.findIndex(v => v.id === over.id) + ) + ); + this.requestUpdate(); + // const properties = this.filteredOptions$.value.map(v=>v.id); + // const activeIndex = properties.findIndex(id => id === activeId); + // const overIndex = properties.findIndex(id => id === over.id); + } + }, + modifiers: [ + ({ transform }) => { + return { + ...transform, + x: 0, + }; + }, + ], + items: computed(() => { + return this.tagManager.filteredOptions$.value.map(v => v.id); + }), + strategy: verticalListSortingStrategy, + }); + + private get text() { + return this.tagManager.text; + } + + private renderInput() { + return html` +
+ ${this.value.value.map(id => { + const option = this.tagManager.optionsMap$.value.get(id); + if (!option) { + return null; + } + return this.renderTag(option.value, option.color, () => + this.tagManager.deleteTag(id) + ); + })} + +
+ `; + } + + private renderTag(name: string, color: string, onDelete?: () => void) { + const style = styleMap({ + backgroundColor: color, + }); + return html`
+
${name}
+ ${onDelete + ? html`
+ ${CloseIcon()} +
` + : nothing} +
`; + } + + private renderTags() { + return html` +
+
Select tag or create one
+
+ ${repeat( + this.tagManager.filteredOptions$.value, + select => select.id, + (select, index) => { + const isSelected = index === this.selectedIndex; + const mouseenter = () => { + this.setSelectedOption(index); + }; + const classes = classMap({ + 'select-option': true, + selected: isSelected, + }); + const clickOption = (e: MouseEvent) => { + e.stopPropagation(); + this._clickItemOption(e, select.id); + }; + return html` +
+
+ ${select.isCreate + ? html`
Create
` + : html` +
+ `} + ${this.renderTag(select.value, select.color)} +
+ ${!select.isCreate + ? html`
+ ${MoreHorizontalIcon()} +
` + : null} +
+ `; + } + )} +
+ `; + } + + private setSelectedOption(index: number) { + this.selectedIndex = rangeWrap( + index, + 0, + this.tagManager.filteredOptions$.value.length + ); + } + + protected override firstUpdated() { + requestAnimationFrame(() => { + this._selectInput.focus(); + }); + this._disposables.addFromEvent(this, 'click', () => { + this._selectInput.focus(); + }); + + this._disposables.addFromEvent(this._selectInput, 'copy', e => { + e.stopPropagation(); + }); + this._disposables.addFromEvent(this._selectInput, 'cut', e => { + e.stopPropagation(); + }); + } + + override render() { + this.setSelectedOption(this.selectedIndex); + return html` ${this.renderInput()} ${this.renderTags()} `; + } + + @query('.tag-select-input') + private accessor _selectInput!: HTMLInputElement; + + @property() + accessor mode: 'multi' | 'single' = 'multi'; + + @property({ attribute: false }) + accessor onChange!: (value: string[]) => void; + + @property({ attribute: false }) + accessor onComplete!: () => void; + + @property({ attribute: false }) + accessor onOptionsChange!: (options: SelectTag[]) => void; + + @property({ attribute: false }) + accessor options!: ReadonlySignal; + + @state() + private accessor selectedIndex = 0; + + @property({ attribute: false }) + accessor value!: ReadonlySignal; +} + +declare global { + interface HTMLElementTagNameMap { + 'affine-multi-tag-select': MultiTagSelect; + } +} + +const popMobileTagSelect = (target: PopupTarget, ops: TagSelectOptions) => { + const tagManager = new TagManager(ops); + const onInput = (e: InputEvent) => { + tagManager.text.value = (e.target as HTMLInputElement).value; + }; + return popMenu(target, { + options: { + onClose: () => { + ops.onComplete?.(); + }, + title: { + text: ops.name, + }, + items: [ + () => { + return html` +
+ ${ops.value.value.map(id => { + const option = ops.options.value.find(v => v.id === id); + if (!option) { + return null; + } + const style = styleMap({ + backgroundColor: option.color, + width: 'max-content', + }); + return html`
+
${option.value}
+
`; + })} + +
+ `; + }, + menu.group({ + items: [ + menu.dynamic(() => { + const options = tagManager.filteredOptions$.value; + return options.map(option => + menu.action({ + name: option.value, + label: () => { + const style = styleMap({ + backgroundColor: option.color, + width: 'max-content', + }); + return html` +
+ ${option.isCreate + ? html`
Create
` + : ''} +
+
${option.value}
+
+
+ `; + }, + select: () => { + option.select(); + return false; + }, + }) + ); + }), + ], + }), + ], + }, + }); +}; + +export type TagSelectOptions = { + name: string; + minWidth?: number; + container?: HTMLElement; +} & TagManagerOptions; +export const popTagSelect = (target: PopupTarget, ops: TagSelectOptions) => { + if (IS_MOBILE) { + const handler = popMobileTagSelect(target, ops); + return () => { + handler.close(); + }; + } + const component = new MultiTagSelect(); + if (ops.mode) { + component.mode = ops.mode; + } + const width = target.targetRect.getBoundingClientRect().width; + component.style.width = `${Math.max(ops.minWidth ?? width, width)}px`; + component.value = ops.value; + component.onChange = ops.onChange; + component.options = ops.options; + component.onOptionsChange = ops.onOptionsChange; + component.onComplete = () => { + ops.onComplete?.(); + remove(); + }; + const remove = createPopup(target, component, { + onClose: ops.onComplete, + middleware: [flip(), offset({ mainAxis: -28, crossAxis: 112 })], + container: ops.container, + }); + return remove; +}; diff --git a/blocksuite/affine/data-view/src/core/component/tags/multi-tag-view.ts b/blocksuite/affine/data-view/src/core/component/tags/multi-tag-view.ts new file mode 100644 index 0000000000..91bbcb76a6 --- /dev/null +++ b/blocksuite/affine/data-view/src/core/component/tags/multi-tag-view.ts @@ -0,0 +1,85 @@ +import { unsafeCSSVarV2 } from '@blocksuite/affine-shared/theme'; +import { ShadowlessElement } from '@blocksuite/block-std'; +import { WithDisposable } from '@blocksuite/global/utils'; +import { css } from 'lit'; +import { property, query } from 'lit/decorators.js'; +import { repeat } from 'lit/directives/repeat.js'; +import { styleMap } from 'lit/directives/style-map.js'; +import { html } from 'lit/static-html.js'; + +import type { SelectTag } from '../../logical/index.js'; +import { getColorByColor } from './colors.js'; + +export class MultiTagView extends WithDisposable(ShadowlessElement) { + static override styles = css` + affine-multi-tag-view { + display: flex; + align-items: center; + width: 100%; + height: 100%; + min-height: 22px; + } + + .affine-select-cell-container * { + box-sizing: border-box; + } + + .affine-select-cell-container { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 6px; + width: 100%; + font-size: var(--affine-font-sm); + } + + .affine-select-cell-container .select-selected { + height: 22px; + font-size: 14px; + line-height: 20px; + padding: 0 8px; + border-radius: 4px; + white-space: nowrap; + background: var(--affine-tag-white); + overflow: hidden; + text-overflow: ellipsis; + border: 1px solid ${unsafeCSSVarV2('database/border')}; + } + `; + + override render() { + const values = this.value; + const map = new Map(this.options?.map(v => [v.id, v])); + return html` +
+ ${repeat(values, id => { + const option = map.get(id); + if (!option) { + return; + } + const style = styleMap({ + backgroundColor: getColorByColor(option.color), + }); + return html`${option.value}`; + })} +
+ `; + } + + @property({ attribute: false }) + accessor options: SelectTag[] = []; + + @query('.affine-select-cell-container') + accessor selectContainer!: HTMLElement; + + @property({ attribute: false }) + accessor value: string[] = []; +} + +declare global { + interface HTMLElementTagNameMap { + 'affine-multi-tag-view': MultiTagView; + } +} diff --git a/blocksuite/affine/data-view/src/core/component/tags/styles.ts b/blocksuite/affine/data-view/src/core/component/tags/styles.ts new file mode 100644 index 0000000000..6d710c1de8 --- /dev/null +++ b/blocksuite/affine/data-view/src/core/component/tags/styles.ts @@ -0,0 +1,251 @@ +import { unsafeCSSVar, unsafeCSSVarV2 } from '@blocksuite/affine-shared/theme'; +import { baseTheme } from '@toeverything/theme'; +import { css, unsafeCSS } from 'lit'; + +export const styles = css` + affine-multi-tag-select { + position: absolute; + z-index: 2; + color: ${unsafeCSSVarV2('text/primary')}; + border: 0.5px solid ${unsafeCSSVarV2('layer/insideBorder/blackBorder')}; + border-radius: 8px; + background: ${unsafeCSSVarV2('layer/background/primary')}; + box-shadow: ${unsafeCSSVar('overlayPanelShadow')}; + font-family: var(--affine-font-family); + max-width: 400px; + padding: 8px; + display: flex; + flex-direction: column; + gap: 4px; + } + + @media print { + affine-multi-tag-select { + display: none; + } + } + + .tag-select-input-container { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 6px; + padding: 4px; + } + + .tag-select-input { + flex: 1 1 0; + border: none; + font-family: ${unsafeCSS(baseTheme.fontSansFamily)}; + color: ${unsafeCSSVarV2('text/primary')}; + background-color: transparent; + line-height: 22px; + font-size: 14px; + outline: none; + } + + .tag-select-input::placeholder { + color: var(--affine-placeholder-color); + } + + .select-options-tips { + padding: 4px; + color: ${unsafeCSSVarV2('text/secondary')}; + font-size: 14px; + font-weight: 500; + line-height: 22px; + user-select: none; + } + + .select-options-container { + max-height: 400px; + overflow-y: auto; + user-select: none; + display: flex; + flex-direction: column; + gap: 4px; + } + + .select-option { + display: flex; + justify-content: space-between; + align-items: center; + padding: 4px 4px 4px 0; + border-radius: 4px; + cursor: pointer; + } + + .tag-container { + display: flex; + align-items: center; + padding: 0 8px; + gap: 4px; + border-radius: 4px; + white-space: nowrap; + text-overflow: ellipsis; + overflow: hidden; + border: 1px solid ${unsafeCSSVarV2('database/border')}; + user-select: none; + } + + .tag-text { + font-size: 14px; + line-height: 22px; + overflow: hidden; + text-overflow: ellipsis; + } + + .tag-delete-icon { + display: flex; + align-items: center; + color: ${unsafeCSSVarV2('chip/label/text')}; + } + + .select-option.selected { + background: ${unsafeCSSVarV2('layer/background/hoverOverlay')}; + } + .select-option-content { + display: flex; + align-items: center; + overflow: hidden; + } + + .select-option-icon { + display: flex; + justify-content: center; + align-items: center; + font-size: 20px; + border-radius: 4px; + cursor: pointer; + visibility: hidden; + color: ${unsafeCSSVarV2('icon/primary')}; + margin-left: 4px; + } + + .select-option.selected .select-option-icon { + visibility: visible; + } + + .select-option-icon:hover { + background: ${unsafeCSSVarV2('layer/background/hoverOverlay')}; + } + + .select-option-drag-handler { + width: 4px; + height: 12px; + border-radius: 1px; + background-color: ${unsafeCSSVarV2('button/grabber/default')}; + margin-right: 4px; + cursor: -webkit-grab; + flex-shrink: 0; + } + + .select-option-new-icon { + font-size: 14px; + line-height: 22px; + color: ${unsafeCSSVarV2('text/primary')}; + margin-right: 8px; + margin-left: 4px; + } + + // .select-selected-text { + // width: calc(100% - 16px); + // white-space: nowrap; + // text-overflow: ellipsis; + // overflow: hidden; + // } + // + // .select-selected > .close-icon { + // display: flex; + // align-items: center; + // } + // + // .select-selected > .close-icon:hover { + // cursor: pointer; + // } + // + // .select-selected > .close-icon > svg { + // fill: var(--affine-black-90); + // } + // + // .select-option-new { + // display: flex; + // flex-direction: row; + // align-items: center; + // height: 36px; + // padding: 4px; + // gap: 5px; + // border-radius: 4px; + // background: var(--affine-selected-color); + // } + // + // .select-option-new-text { + // overflow: hidden; + // white-space: nowrap; + // text-overflow: ellipsis; + // height: 28px; + // padding: 2px 10px; + // border-radius: 4px; + // background: var(--affine-tag-red); + // } + // + // .select-option-new-icon { + // display: flex; + // align-items: center; + // gap: 6px; + // height: 28px; + // color: var(--affine-text-primary-color); + // margin-right: 8px; + // } + // + // .select-option-new-icon svg { + // width: 16px; + // height: 16px; + // } + // + // .select-option { + // position: relative; + // display: flex; + // justify-content: space-between; + // align-items: center; + // padding: 4px; + // border-radius: 4px; + // margin-bottom: 4px; + // cursor: pointer; + // } + // + // .select-option.selected { + // background: var(--affine-hover-color); + // } + // + // .select-option-text-container { + // width: 100%; + // overflow: hidden; + // display: flex; + // } + // + // .select-option-group-name { + // font-size: 9px; + // padding: 0 2px; + // border-radius: 2px; + // } + // + // .select-option-name { + // padding: 4px 8px; + // border-radius: 4px; + // white-space: nowrap; + // text-overflow: ellipsis; + // overflow: hidden; + // } + // + // + // .select-option-icon:hover { + // background: var(--affine-hover-color); + // } + // + // .select-option-icon svg { + // width: 16px; + // height: 16px; + // pointer-events: none; + // } +`; diff --git a/blocksuite/affine/data-view/src/core/data-source/base.ts b/blocksuite/affine/data-view/src/core/data-source/base.ts new file mode 100644 index 0000000000..e7643e5d4a --- /dev/null +++ b/blocksuite/affine/data-view/src/core/data-source/base.ts @@ -0,0 +1,226 @@ +import type { InsertToPosition } from '@blocksuite/affine-shared/utils'; +import { computed, type ReadonlySignal } from '@preact/signals-core'; + +import type { TypeInstance } from '../logical/type.js'; +import type { PropertyMetaConfig } from '../property/property-config.js'; +import type { DatabaseFlags } from '../types.js'; +import type { ViewConvertConfig } from '../view/convert.js'; +import type { DataViewDataType, ViewMeta } from '../view/data-view.js'; +import type { ViewManager } from '../view-manager/view-manager.js'; +import type { DataViewContextKey } from './context.js'; + +export interface DataSource { + readonly$: ReadonlySignal; + properties$: ReadonlySignal; + featureFlags$: ReadonlySignal; + + cellValueGet(rowId: string, propertyId: string): unknown; + cellValueGet$( + rowId: string, + propertyId: string + ): ReadonlySignal; + cellValueChange(rowId: string, propertyId: string, value: unknown): void; + + rows$: ReadonlySignal; + rowAdd(InsertToPosition: InsertToPosition | number): string; + rowDelete(ids: string[]): void; + rowMove(rowId: string, position: InsertToPosition): void; + + propertyMetas: PropertyMetaConfig[]; + + propertyNameGet$(propertyId: string): ReadonlySignal; + propertyNameGet(propertyId: string): string; + propertyNameSet(propertyId: string, name: string): void; + + propertyTypeGet(propertyId: string): string | undefined; + propertyTypeGet$(propertyId: string): ReadonlySignal; + propertyTypeSet(propertyId: string, type: string): void; + + propertyDataGet(propertyId: string): Record; + propertyDataGet$( + propertyId: string + ): ReadonlySignal | undefined>; + propertyDataSet(propertyId: string, data: Record): void; + + propertyDataTypeGet(propertyId: string): TypeInstance | undefined; + propertyDataTypeGet$( + propertyId: string + ): ReadonlySignal; + + propertyReadonlyGet(propertyId: string): boolean; + propertyReadonlyGet$(propertyId: string): ReadonlySignal; + + propertyMetaGet(type: string): PropertyMetaConfig; + propertyAdd(insertToPosition: InsertToPosition, type?: string): string; + propertyDuplicate(propertyId: string): string; + propertyDelete(id: string): void; + + contextGet(key: DataViewContextKey): T; + + viewConverts: ViewConvertConfig[]; + viewManager: ViewManager; + viewMetas: ViewMeta[]; + viewDataList$: ReadonlySignal; + + viewDataGet(viewId: string): DataViewDataType | undefined; + viewDataGet$(viewId: string): ReadonlySignal; + + viewDataAdd(viewData: DataViewDataType): string; + viewDataDuplicate(id: string): string; + viewDataDelete(viewId: string): void; + viewDataMoveTo(id: string, position: InsertToPosition): void; + viewDataUpdate( + id: string, + updater: (data: ViewData) => Partial + ): void; + + viewMetaGet(type: string): ViewMeta; + viewMetaGet$(type: string): ReadonlySignal; + + viewMetaGetById(viewId: string): ViewMeta; + viewMetaGetById$(viewId: string): ReadonlySignal; +} + +export abstract class DataSourceBase implements DataSource { + context = new Map(); + + abstract featureFlags$: ReadonlySignal; + + abstract properties$: ReadonlySignal; + + abstract propertyMetas: PropertyMetaConfig[]; + + abstract readonly$: ReadonlySignal; + + abstract rows$: ReadonlySignal; + + abstract viewConverts: ViewConvertConfig[]; + + abstract viewDataList$: ReadonlySignal; + + abstract viewManager: ViewManager; + + abstract viewMetas: ViewMeta[]; + + abstract cellValueChange( + rowId: string, + propertyId: string, + value: unknown + ): void; + + abstract cellValueChange( + rowId: string, + propertyId: string, + value: unknown + ): void; + + abstract cellValueGet(rowId: string, propertyId: string): unknown; + + cellValueGet$( + rowId: string, + propertyId: string + ): ReadonlySignal { + return computed(() => this.cellValueGet(rowId, propertyId)); + } + + contextGet(key: DataViewContextKey): T { + return (this.context.get(key.key) as T) ?? key.defaultValue; + } + + contextSet(key: DataViewContextKey, value: T): void { + this.context.set(key.key, value); + } + + abstract propertyAdd( + insertToPosition: InsertToPosition, + type?: string + ): string; + + abstract propertyDataGet(propertyId: string): Record; + + propertyDataGet$( + propertyId: string + ): ReadonlySignal | undefined> { + return computed(() => this.propertyDataGet(propertyId)); + } + + abstract propertyDataSet( + propertyId: string, + data: Record + ): void; + + abstract propertyDataTypeGet(propertyId: string): TypeInstance | undefined; + + propertyDataTypeGet$( + propertyId: string + ): ReadonlySignal { + return computed(() => this.propertyDataTypeGet(propertyId)); + } + + abstract propertyDelete(id: string): void; + + abstract propertyDuplicate(propertyId: string): string; + + abstract propertyMetaGet(type: string): PropertyMetaConfig; + + abstract propertyNameGet(propertyId: string): string; + + propertyNameGet$(propertyId: string): ReadonlySignal { + return computed(() => this.propertyNameGet(propertyId)); + } + + abstract propertyNameSet(propertyId: string, name: string): void; + + propertyReadonlyGet(_propertyId: string): boolean { + return false; + } + + propertyReadonlyGet$(propertyId: string): ReadonlySignal { + return computed(() => this.propertyReadonlyGet(propertyId)); + } + + abstract propertyTypeGet(propertyId: string): string; + + propertyTypeGet$(propertyId: string): ReadonlySignal { + return computed(() => this.propertyTypeGet(propertyId)); + } + + abstract propertyTypeSet(propertyId: string, type: string): void; + + abstract rowAdd(InsertToPosition: InsertToPosition | number): string; + + abstract rowDelete(ids: string[]): void; + + abstract rowMove(rowId: string, position: InsertToPosition): void; + + abstract viewDataAdd(viewData: DataViewDataType): string; + + abstract viewDataDelete(viewId: string): void; + + abstract viewDataDuplicate(id: string): string; + + abstract viewDataGet(viewId: string): DataViewDataType; + + viewDataGet$(viewId: string): ReadonlySignal { + return computed(() => this.viewDataGet(viewId)); + } + + abstract viewDataMoveTo(id: string, position: InsertToPosition): void; + + abstract viewDataUpdate( + id: string, + updater: (data: ViewData) => Partial + ): void; + + abstract viewMetaGet(type: string): ViewMeta; + + viewMetaGet$(type: string): ReadonlySignal { + return computed(() => this.viewMetaGet(type)); + } + + abstract viewMetaGetById(viewId: string): ViewMeta; + + viewMetaGetById$(viewId: string): ReadonlySignal { + return computed(() => this.viewMetaGetById(viewId)); + } +} diff --git a/blocksuite/affine/data-view/src/core/data-source/context.ts b/blocksuite/affine/data-view/src/core/data-source/context.ts new file mode 100644 index 0000000000..f81b56973c --- /dev/null +++ b/blocksuite/affine/data-view/src/core/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/blocksuite/affine/data-view/src/core/data-source/index.ts b/blocksuite/affine/data-view/src/core/data-source/index.ts new file mode 100644 index 0000000000..ffe27a8284 --- /dev/null +++ b/blocksuite/affine/data-view/src/core/data-source/index.ts @@ -0,0 +1,2 @@ +export * from './base.js'; +export * from './context.js'; diff --git a/blocksuite/affine/data-view/src/core/data-view.ts b/blocksuite/affine/data-view/src/core/data-view.ts new file mode 100644 index 0000000000..8cc56bc00c --- /dev/null +++ b/blocksuite/affine/data-view/src/core/data-view.ts @@ -0,0 +1,233 @@ +import type { + DatabaseAllEvents, + EventTraceFn, +} from '@blocksuite/affine-shared/services'; +import { ShadowlessElement } from '@blocksuite/block-std'; +import { IS_MOBILE } from '@blocksuite/global/env'; +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 { dataViewCommonStyle } from './common/css-variable.js'; +import type { DataViewSelection, DataViewSelectionState } from './types.js'; +import { renderUniLit } from './utils/uni-component/index.js'; +import type { DataViewInstance, DataViewProps } from './view/types.js'; +import type { SingleView } from './view-manager/single-view.js'; + +type ViewProps = { + view: SingleView; + selection$: ReadonlySignal; + setSelection: (selection?: DataViewSelectionState) => void; + bindHotkey: DataViewProps['bindHotkey']; + handleEvent: DataViewProps['handleEvent']; +}; + +export type DataViewRendererConfig = Pick< + DataViewProps, + | 'bindHotkey' + | 'handleEvent' + | 'virtualPadding$' + | 'clipboard' + | 'dataSource' + | 'headerWidget' + | 'onDrag' + | 'notification' +> & { + selection$: ReadonlySignal; + setSelection: (selection: DataViewSelection | undefined) => void; + eventTrace: EventTraceFn; + detailPanelConfig: { + openDetailPanel: ( + target: HTMLElement, + data: { + view: SingleView; + rowId: string; + } + ) => Promise; + }; +}; + +export class DataViewRenderer extends SignalWatcher( + WithDisposable(ShadowlessElement) +) { + static override styles = css` + ${unsafeCSS(dataViewCommonStyle('affine-data-view-renderer'))} + affine-data-view-renderer { + background-color: var(--affine-background-primary-color); + display: contents; + } + `; + + private _view = createRef<{ + expose: DataViewInstance; + }>(); + + @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(); + }; + + openDetailPanel = (ops: { + view: SingleView; + rowId: string; + onClose?: () => void; + }) => { + const openDetailPanel = this.config.detailPanelConfig.openDetailPanel; + if (openDetailPanel) { + openDetailPanel(this, { + view: ops.view, + rowId: ops.rowId, + }) + .catch(console.error) + .finally(ops.onClose); + } + }; + + get view() { + return this._view.value; + } + + private renderView(viewData?: ViewProps) { + if (!viewData) { + return; + } + const props: DataViewProps = { + dataViewEle: this, + headerWidget: this.config.headerWidget, + onDrag: this.config.onDrag, + dataSource: this.config.dataSource, + virtualPadding$: this.config.virtualPadding$, + clipboard: this.config.clipboard, + notification: this.config.notification, + view: viewData.view, + selection$: viewData.selection$, + setSelection: viewData.setSelection, + bindHotkey: viewData.bindHotkey, + handleEvent: viewData.handleEvent, + eventTrace: (key, params) => { + this.config.eventTrace(key, { + ...(params as DatabaseAllEvents[typeof key]), + viewId: viewData.view.id, + viewType: viewData.view.type, + }); + }, + }; + const renderer = viewData.view.meta.renderer; + const view = + (IS_MOBILE ? renderer.mobileView : renderer.view) ?? renderer.view; + return keyed( + viewData.view.id, + renderUniLit( + 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-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/blocksuite/affine/data-view/src/core/detail/detail.ts b/blocksuite/affine/data-view/src/core/detail/detail.ts new file mode 100644 index 0000000000..dea9ecda71 --- /dev/null +++ b/blocksuite/affine/data-view/src/core/detail/detail.ts @@ -0,0 +1,297 @@ +import { + menu, + popMenu, + 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 { dataViewCommonStyle } from '../common/css-variable.js'; +import { + renderUniLit, + type UniComponent, +} from '../utils/uni-component/uni-component.js'; +import type { SingleView } from '../view-manager/single-view.js'; +import { DetailSelection } from './selection.js'; + +export type DetailSlotProps = { + view: SingleView; + rowId: string; + openDoc: (docId: string) => void; +}; + +export interface DetailSlots { + header?: UniComponent; + note?: UniComponent; +} + +const styles = css` + ${unsafeCSS(dataViewCommonStyle('affine-data-view-record-detail'))} + affine-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%; + box-sizing: border-box; + } + + .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 = () => { + popMenu(popupTargetFromElement(this.addPropertyButton), { + options: { + title: { + text: 'Add property', + }, + items: [ + menu.group({ + items: this.view.propertyMetas.map(meta => { + return menu.action({ + name: meta.config.name, + prefix: renderUniLit(this.view.propertyIconGet(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, + openDoc: this.openDoc, + }; + return renderUniLit(header, props); + } + return undefined; + } + + private renderNote() { + const note = this.detailSlots?.note; + if (note) { + const props: DetailSlotProps = { + view: this.view, + rowId: this.rowId, + openDoc: this.openDoc, + }; + 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 openDoc!: (docId: string) => void; + + @property({ attribute: false }) + accessor rowId!: string; +} + +declare global { + interface HTMLElementTagNameMap { + 'affine-data-view-record-detail': RecordDetail; + } +} +export const createRecordDetail = (ops: { + view: SingleView; + rowId: string; + detail: DetailSlots; + openDoc: (docId: string) => void; +}) => { + return html` `; +}; diff --git a/blocksuite/affine/data-view/src/core/detail/field.ts b/blocksuite/affine/data-view/src/core/detail/field.ts new file mode 100644 index 0000000000..2531a1bf12 --- /dev/null +++ b/blocksuite/affine/data-view/src/core/detail/field.ts @@ -0,0 +1,289 @@ +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 { inputConfig, typeConfig } from '../common/property-menu.js'; +import type { + CellRenderProps, + DataViewCellLifeCycle, +} from '../property/index.js'; +import { renderUniLit } from '../utils/uni-component/uni-component.js'; +import type { Property } from '../view-manager/property.js'; +import type { SingleView } from '../view-manager/single-view.js'; + +export class RecordField extends SignalWatcher( + WithDisposable(ShadowlessElement) +) { + static override styles = css` + affine-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-data-view-record-field .icon { + display: flex; + align-items: center; + width: 16px; + height: 16px; + } + + affine-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-database-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: { + title: { + text: 'Property settings', + }, + items: [ + menu.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-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-data-view-record-field': RecordField; + } +} diff --git a/blocksuite/affine/data-view/src/core/detail/selection.ts b/blocksuite/affine/data-view/src/core/detail/selection.ts new file mode 100644 index 0000000000..aa300050a0 --- /dev/null +++ b/blocksuite/affine/data-view/src/core/detail/selection.ts @@ -0,0 +1,154 @@ +import type { KanbanCard } from '../../view-presets/kanban/pc/card.js'; +import { KanbanCell } from '../../view-presets/kanban/pc/cell.js'; +import type { KanbanCardSelection } from '../../view-presets/kanban/types.js'; +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; + } + const nextContainer = + this.getFocusCellContainer(selection)?.nextElementSibling; + if (nextContainer instanceof KanbanCell) { + this.selection = { + propertyId: nextContainer.column.id, + isEditing: false, + }; + } + } + + focusFirstCell() { + const firstId = this.viewEle.querySelector('affine-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-data-view-record-field[data-column-id="${selection.propertyId}"]` + ) as RecordField | undefined; + } + + getSelectCard(selection: KanbanCardSelection) { + const { groupKey, cardId } = selection.cards[0]; + + return this.viewEle + .querySelector(`affine-data-view-kanban-group[data-key="${groupKey}"]`) + ?.querySelector( + `affine-data-view-kanban-card[data-card-id="${cardId}"]` + ) as KanbanCard | undefined; + } +} diff --git a/blocksuite/affine/data-view/src/core/expression/index.ts b/blocksuite/affine/data-view/src/core/expression/index.ts new file mode 100644 index 0000000000..0647936f00 --- /dev/null +++ b/blocksuite/affine/data-view/src/core/expression/index.ts @@ -0,0 +1,3 @@ +export * from '../filter/literal/index.js'; +export * from './ref/index.js'; +export * from './types.js'; diff --git a/blocksuite/affine/data-view/src/core/expression/ref/index.ts b/blocksuite/affine/data-view/src/core/expression/ref/index.ts new file mode 100644 index 0000000000..d0afe300e7 --- /dev/null +++ b/blocksuite/affine/data-view/src/core/expression/ref/index.ts @@ -0,0 +1,2 @@ +export * from './ref.js'; +export * from './ref-view.js'; diff --git a/blocksuite/affine/data-view/src/core/expression/ref/ref-view.ts b/blocksuite/affine/data-view/src/core/expression/ref/ref-view.ts new file mode 100644 index 0000000000..a589a9b2c9 --- /dev/null +++ b/blocksuite/affine/data-view/src/core/expression/ref/ref-view.ts @@ -0,0 +1,94 @@ +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 { renderUniLit } from '../../utils/uni-component/uni-component.js'; +import type { Variable, VariableRef } from '../types.js'; + +export class VariableRefView extends WithDisposable(ShadowlessElement) { + static override styles = css` + variable-ref-view { + font-size: 12px; + line-height: 20px; + display: flex; + align-items: center; + gap: 6px; + padding: 0 4px; + border-radius: 4px; + cursor: pointer; + } + + variable-ref-view:hover { + background-color: var(--affine-hover-color); + } + + variable-ref-view svg { + width: 16px; + height: 16px; + fill: var(--affine-icon-color); + color: var(--affine-icon-color); + } + `; + + get field() { + if (!this.data) { + return; + } + return this.data.name; + } + + get fieldData() { + const id = this.field; + if (!id) { + return; + } + return this.vars.find(v => v.id === id); + } + + 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: VariableRef | undefined = undefined; + + @property({ attribute: false }) + accessor setData!: (filter: VariableRef) => void; + + @property({ attribute: false }) + accessor vars!: Variable[]; +} + +declare global { + interface HTMLElementTagNameMap { + 'variable-ref-view': VariableRefView; + } +} diff --git a/blocksuite/affine/data-view/src/core/expression/ref/ref.ts b/blocksuite/affine/data-view/src/core/expression/ref/ref.ts new file mode 100644 index 0000000000..aff4f8c661 --- /dev/null +++ b/blocksuite/affine/data-view/src/core/expression/ref/ref.ts @@ -0,0 +1,5 @@ +import type { Variable, VariableRef } from '../types.js'; + +export const getRefType = (vars: Variable[], ref: VariableRef) => { + return vars.find(v => v.id === ref.name)?.type; +}; diff --git a/blocksuite/affine/data-view/src/core/expression/types.ts b/blocksuite/affine/data-view/src/core/expression/types.ts new file mode 100644 index 0000000000..13b1056579 --- /dev/null +++ b/blocksuite/affine/data-view/src/core/expression/types.ts @@ -0,0 +1,21 @@ +import type { TypeInstance } from '../logical/type.js'; +import type { UniComponent } from '../utils/index.js'; + +export type VariableRef = { + type: 'ref'; + name: string; +}; + +export type Variable = { + name: string; + type: TypeInstance; + propertyType: string; + id: string; + icon?: UniComponent; +}; +export type Literal = { + type: 'literal'; + value: unknown; +}; +// TODO support VariableRef +export type Value = Literal; diff --git a/blocksuite/affine/data-view/src/core/filter/add-filter.ts b/blocksuite/affine/data-view/src/core/filter/add-filter.ts new file mode 100644 index 0000000000..bf82d91fcd --- /dev/null +++ b/blocksuite/affine/data-view/src/core/filter/add-filter.ts @@ -0,0 +1,67 @@ +import { + menu, + popMenu, + type PopupTarget, +} from '@blocksuite/affine-components/context-menu'; +import { AddCursorIcon } from '@blocksuite/icons/lit'; +import type { Middleware } from '@floating-ui/dom'; +import type { ReadonlySignal } from '@preact/signals-core'; + +import type { Variable } from '../expression/index.js'; +import { renderUniLit } from '../utils/index.js'; +import type { Filter } from './types.js'; +import { firstFilterByRef, firstFilterInGroup } from './utils.js'; + +export const popCreateFilter = ( + target: PopupTarget, + props: { + vars: ReadonlySignal; + onSelect: (filter: Filter) => void; + onClose?: () => void; + onBack?: () => void; + }, + ops?: { + middleware?: Middleware[]; + } +) => { + popMenu(target, { + middleware: ops?.middleware, + options: { + onClose: props.onClose, + title: { + onBack: props.onBack, + text: 'New filter', + }, + items: [ + menu.group({ + items: props.vars.value.map(v => + menu.action({ + name: v.name, + prefix: renderUniLit(v.icon, {}), + select: () => { + props.onSelect( + firstFilterByRef(props.vars.value, { + type: 'ref', + name: v.id, + }) + ); + }, + }) + ), + }), + menu.group({ + name: '', + items: [ + menu.action({ + name: 'Add filter group', + prefix: AddCursorIcon(), + select: () => { + props.onSelect(firstFilterInGroup(props.vars.value)); + }, + }), + ], + }), + ], + }, + }); +}; diff --git a/blocksuite/affine/data-view/src/core/filter/eval.ts b/blocksuite/affine/data-view/src/core/filter/eval.ts new file mode 100644 index 0000000000..9e860f3a9f --- /dev/null +++ b/blocksuite/affine/data-view/src/core/filter/eval.ts @@ -0,0 +1,53 @@ +import type { Value, VariableRef } from '../expression/types.js'; +import { filterMatcher } from './filter-fn/matcher.js'; +import type { Filter } from './types.js'; + +const evalRef = (ref: VariableRef, row: Record): unknown => { + return row[ref.name]; +}; + +const evalValue = (value?: Value): unknown => { + return value?.value; +}; +export const evalFilter = ( + filterGroup: Filter, + row: Record +): boolean => { + const evalF = (filter: Filter): boolean => { + if (filter.type === 'filter') { + const value = evalRef(filter.left, row); + const func = filterMatcher.getFilterByName(filter.function); + if (!func) { + return true; + } + const expectArgLen = func.args.length; + const args: unknown[] = []; + for (let i = 0; i < expectArgLen; i++) { + const argValue = evalValue(filter.args[i]); + const argType = func.args[i]; + if (argValue == null) { + return true; + } + if (!argType.valueValidate(argValue)) { + return true; + } + args.push(argValue); + } + const impl = func.impl; + try { + return impl(value ?? undefined, ...args); + } catch (e) { + console.error(e); + return true; + } + } else if (filter.type === 'group') { + if (filter.op === 'and') { + return filter.conditions.every(f => evalF(f)); + } else if (filter.op === 'or') { + return filter.conditions.some(f => evalF(f)); + } + } + return true; + }; + return evalF(filterGroup); +}; diff --git a/blocksuite/affine/data-view/src/core/filter/filter-fn/boolean.ts b/blocksuite/affine/data-view/src/core/filter/filter-fn/boolean.ts new file mode 100644 index 0000000000..0a4e79ef37 --- /dev/null +++ b/blocksuite/affine/data-view/src/core/filter/filter-fn/boolean.ts @@ -0,0 +1,27 @@ +import { t } from '../../logical/type-presets.js'; +import { createFilter } from './create.js'; + +export const booleanFilter = [ + createFilter({ + name: 'isChecked', + self: t.boolean.instance(), + args: [], + label: 'Is checked', + shortString: () => ': Checked', + impl: value => { + return !!value; + }, + defaultValue: () => true, + }), + createFilter({ + name: 'isUnchecked', + self: t.boolean.instance(), + args: [], + label: 'Is unchecked', + shortString: () => ': Unchecked', + impl: value => { + return !value; + }, + defaultValue: () => false, + }), +]; diff --git a/blocksuite/affine/data-view/src/core/filter/filter-fn/create.ts b/blocksuite/affine/data-view/src/core/filter/filter-fn/create.ts new file mode 100644 index 0000000000..4358450fdb --- /dev/null +++ b/blocksuite/affine/data-view/src/core/filter/filter-fn/create.ts @@ -0,0 +1,64 @@ +import type { ArrayTypeInstance } from '../../logical/composite-type.js'; +import type { DTInstance } from '../../logical/data-type.js'; +import type { TypeInstance, ValueTypeOf } from '../../logical/type.js'; +import type { + TypeVarDefinitionInstance, + TypeVarReferenceInstance, +} from '../../logical/type-variable.js'; + +export type FilterConfig< + Self extends TypeInstance = TypeInstance, + Args extends TypeInstance[] = TypeInstance[], + Vars extends TypeVarDefinitionInstance[] = TypeVarDefinitionInstance[], +> = { + name: string; + label: string; + shortString: ( + ...args: { + [K in keyof Args]: + | { + value: ValueTypeOf>; + type: ReplaceVar; + } + | undefined; + } + ) => string | undefined; + self: Self; + vars?: Vars; + args: Args; + impl: ( + self: ValueTypeOf> | undefined, + ...args: { [K in keyof Args]: ValueTypeOf> } + ) => boolean; + defaultValue?: (args: { + [K in keyof Args]: ValueTypeOf>; + }) => ValueTypeOf>; +}; +type FindVar< + Vars extends TypeVarDefinitionInstance[], + Name extends string, +> = Vars[number] extends infer Var + ? Var extends TypeVarDefinitionInstance + ? R + : never + : never; +type ReplaceVar< + Arg, + Vars extends TypeVarDefinitionInstance[], +> = Arg extends TypeVarReferenceInstance + ? FindVar + : Arg extends ArrayTypeInstance + ? ArrayTypeInstance> + : Arg extends DTInstance + ? Arg + : Arg; + +export const createFilter = < + Self extends TypeInstance, + Args extends TypeInstance[], + Vars extends TypeVarDefinitionInstance[], +>( + config: FilterConfig +) => { + return config; +}; diff --git a/blocksuite/affine/data-view/src/core/filter/filter-fn/date.ts b/blocksuite/affine/data-view/src/core/filter/filter-fn/date.ts new file mode 100644 index 0000000000..1b69e0a115 --- /dev/null +++ b/blocksuite/affine/data-view/src/core/filter/filter-fn/date.ts @@ -0,0 +1,37 @@ +import { addDays } from 'date-fns/addDays'; +import { format } from 'date-fns/format'; +import { subDays } from 'date-fns/subDays'; + +import { t } from '../../logical/type-presets.js'; +import { createFilter } from './create.js'; + +export const dateFilter = [ + createFilter({ + name: 'before', + self: t.date.instance(), + args: [t.date.instance()] as const, + label: 'Before', + shortString: v => (v ? ` < ${format(v.value, 'yyyy/MM/dd')}` : undefined), + impl: (self, value) => { + if (self == null) { + return false; + } + return self < value; + }, + defaultValue: args => subDays(args[0], 1).getTime(), + }), + createFilter({ + name: 'after', + self: t.date.instance(), + args: [t.date.instance()] as const, + label: 'After', + shortString: v => (v ? ` > ${format(v.value, 'yyyy/MM/dd')}` : undefined), + impl: (self, value) => { + if (self == null) { + return false; + } + return self > value; + }, + defaultValue: args => addDays(args[0], 1).getTime(), + }), +]; diff --git a/blocksuite/affine/data-view/src/core/filter/filter-fn/matcher.ts b/blocksuite/affine/data-view/src/core/filter/filter-fn/matcher.ts new file mode 100644 index 0000000000..effd5c2023 --- /dev/null +++ b/blocksuite/affine/data-view/src/core/filter/filter-fn/matcher.ts @@ -0,0 +1,61 @@ +import { ct } from '../../logical/composite-type.js'; +import { t } from '../../logical/index.js'; +import type { TypeInstance } from '../../logical/type.js'; +import { typeSystem } from '../../logical/type-system.js'; +import { booleanFilter } from './boolean.js'; +import type { FilterConfig } from './create.js'; +import { dateFilter } from './date.js'; +import { multiTagFilter } from './multi-tag.js'; +import { numberFilter } from './number.js'; +import { stringFilter } from './string.js'; +import { tagFilter } from './tag.js'; +import { unknownFilter } from './unknown.js'; + +const allFilter = [ + ...dateFilter, + ...multiTagFilter, + ...numberFilter, + ...stringFilter, + ...tagFilter, + ...booleanFilter, + ...unknownFilter, +] as FilterConfig[]; + +const getPredicate = (selfType: TypeInstance) => (filter: FilterConfig) => { + const fn = ct.fn.instance( + [filter.self, ...filter.args], + t.boolean.instance(), + filter.vars + ); + const staticType = fn.subst( + Object.fromEntries( + filter.vars?.map(v => [ + v.varName, + { + define: v, + type: v.typeConstraint, + }, + ]) ?? [] + ) + ); + if (!staticType) { + return false; + } + const firstArg = staticType.args[0]; + return firstArg && typeSystem.unify(selfType, firstArg); +}; + +export const filterMatcher = { + filterListBySelfType: (selfType: TypeInstance) => { + return allFilter.filter(getPredicate(selfType)); + }, + firstMatchedBySelfType: (selfType: TypeInstance) => { + return allFilter.find(getPredicate(selfType)); + }, + getFilterByName: (name?: string) => { + if (!name) { + return; + } + return allFilter.find(v => v.name === name); + }, +}; diff --git a/blocksuite/affine/data-view/src/core/filter/filter-fn/multi-tag.ts b/blocksuite/affine/data-view/src/core/filter/filter-fn/multi-tag.ts new file mode 100644 index 0000000000..3e75e09b68 --- /dev/null +++ b/blocksuite/affine/data-view/src/core/filter/filter-fn/multi-tag.ts @@ -0,0 +1,83 @@ +import { ct } from '../../logical/composite-type.js'; +import { t } from '../../logical/type-presets.js'; +import { tRef, tVar } from '../../logical/type-variable.js'; +import { createFilter } from './create.js'; +import { tagToString } from './utils.js'; + +const optionName = 'option' as const; +export const multiTagFilter = [ + createFilter({ + name: 'containsOneOf', + vars: [tVar(optionName, t.tag.instance())] as const, + self: ct.array.instance(tRef(optionName)), + args: [ct.array.instance(tRef(optionName))] as const, + label: 'Contains one of', + shortString: v => + v ? `: ${tagToString(v.value, v.type.element)}` : undefined, + impl: (self, value) => { + if (!value.length) { + return true; + } + if (self == null) { + return false; + } + return value.some(v => self.includes(v)); + }, + defaultValue: args => [args[0][0]], + }), + createFilter({ + name: 'doesNotContainOneOf', + vars: [tVar(optionName, t.tag.instance())] as const, + self: ct.array.instance(tRef(optionName)), + args: [ct.array.instance(tRef(optionName))] as const, + label: 'Does not contains one of', + shortString: v => + v ? `: Not ${tagToString(v.value, v.type.element)}` : undefined, + impl: (self, value) => { + if (!value.length) { + return true; + } + if (self == null) { + return true; + } + return value.every(v => !self.includes(v)); + }, + }), + createFilter({ + name: 'containsAll', + vars: [tVar(optionName, t.tag.instance())] as const, + self: ct.array.instance(tRef(optionName)), + args: [ct.array.instance(tRef(optionName))] as const, + label: 'Contains all', + shortString: v => + v ? `: ${tagToString(v.value, v.type.element)}` : undefined, + impl: (self, value) => { + if (!value.length) { + return true; + } + if (self == null) { + return false; + } + return value.every(v => self.includes(v)); + }, + defaultValue: args => args[0], + }), + createFilter({ + name: 'doesNotContainAll', + vars: [tVar(optionName, t.tag.instance())] as const, + self: ct.array.instance(tRef(optionName)), + args: [ct.array.instance(tRef(optionName))] as const, + label: 'Does not contains all', + shortString: v => + v ? `: Not ${tagToString(v.value, v.type.element)}` : undefined, + impl: (self, value) => { + if (!value.length) { + return true; + } + if (self == null) { + return true; + } + return !value.every(v => self.includes(v)); + }, + }), +]; diff --git a/blocksuite/affine/data-view/src/core/filter/filter-fn/number.ts b/blocksuite/affine/data-view/src/core/filter/filter-fn/number.ts new file mode 100644 index 0000000000..aae8549ee2 --- /dev/null +++ b/blocksuite/affine/data-view/src/core/filter/filter-fn/number.ts @@ -0,0 +1,88 @@ +import { t } from '../../logical/type-presets.js'; +import { createFilter } from './create.js'; + +export const numberFilter = [ + createFilter({ + name: 'equal', + self: t.number.instance(), + args: [t.number.instance()] as const, + label: '=', + shortString: v => (v ? ` = ${v.value}` : undefined), + impl: (self, target) => { + if (self == null) { + return false; + } + return self == target; + }, + defaultValue: args => args[0], + }), + createFilter({ + name: 'notEqual', + self: t.number.instance(), + args: [t.number.instance()] as const, + label: '≠', + shortString: v => (v ? ` ≠ ${v.value}` : undefined), + impl: (self, target) => { + if (self == null) { + return false; + } + return self != target; + }, + }), + createFilter({ + name: 'greatThan', + self: t.number.instance(), + args: [t.number.instance()] as const, + label: '>', + shortString: v => (v ? ` > ${v.value}` : undefined), + impl: (self, target) => { + if (self == null) { + return false; + } + return self > target; + }, + defaultValue: args => args[0] + 1, + }), + createFilter({ + name: 'lessThan', + self: t.number.instance(), + args: [t.number.instance()] as const, + label: '<', + shortString: v => (v ? ` < ${v.value}` : undefined), + impl: (self, target) => { + if (self == null) { + return false; + } + return self < target; + }, + defaultValue: args => args[0] - 1, + }), + createFilter({ + name: 'greatThanOrEqual', + self: t.number.instance(), + args: [t.number.instance()] as const, + label: '≥', + shortString: v => (v ? ` ≥ ${v.value}` : undefined), + impl: (self, target) => { + if (self == null) { + return false; + } + return self >= target; + }, + defaultValue: args => args[0], + }), + createFilter({ + name: 'lessThanOrEqual', + self: t.number.instance(), + args: [t.number.instance()] as const, + label: '≤', + shortString: v => (v ? ` ≤ ${v.value}` : undefined), + impl: (self, target) => { + if (self == null) { + return false; + } + return self <= target; + }, + defaultValue: args => args[0], + }), +]; diff --git a/blocksuite/affine/data-view/src/core/filter/filter-fn/string.ts b/blocksuite/affine/data-view/src/core/filter/filter-fn/string.ts new file mode 100644 index 0000000000..512e2efd17 --- /dev/null +++ b/blocksuite/affine/data-view/src/core/filter/filter-fn/string.ts @@ -0,0 +1,69 @@ +import { t } from '../../logical/type-presets.js'; +import { createFilter } from './create.js'; + +export const stringFilter = [ + createFilter({ + name: 'contains', + self: t.string.instance(), + args: [t.string.instance()] as const, + label: 'Contains', + shortString: v => (v ? `: ${v.value}` : undefined), + impl: (self = '', value) => { + return self.toLowerCase().includes(value.toLowerCase()); + }, + defaultValue: args => args[0], + }), + createFilter({ + name: 'doesNoContains', + self: t.string.instance(), + args: [t.string.instance()] as const, + label: 'Does no contains', + shortString: v => (v ? `: Not ${v.value}` : undefined), + impl: (self = '', value) => { + return !self.toLowerCase().includes(value.toLowerCase()); + }, + }), + createFilter({ + name: 'startsWith', + self: t.string.instance(), + args: [t.string.instance()] as const, + label: 'Starts with', + shortString: v => (v ? `: Starts with ${v.value}` : undefined), + impl: (self = '', value) => { + return self.toLowerCase().startsWith(value.toLowerCase()); + }, + defaultValue: args => args[0], + }), + createFilter({ + name: 'endsWith', + self: t.string.instance(), + args: [t.string.instance()] as const, + label: 'Ends with', + shortString: v => (v ? `: Ends with ${v.value}` : undefined), + impl: (self = '', value) => { + return self.toLowerCase().endsWith(value.toLowerCase()); + }, + defaultValue: args => args[0], + }), + createFilter({ + name: 'is', + self: t.string.instance(), + args: [t.string.instance()] as const, + label: 'Is', + shortString: v => (v ? `: ${v.value}` : undefined), + impl: (self = '', value) => { + return self.toLowerCase() == value.toLowerCase(); + }, + defaultValue: args => args[0], + }), + createFilter({ + name: 'isNot', + self: t.string.instance(), + args: [t.string.instance()] as const, + label: 'Is not', + shortString: v => (v ? `: Not ${v.value}` : undefined), + impl: (self = '', value) => { + return self.toLowerCase() != value.toLowerCase(); + }, + }), +]; diff --git a/blocksuite/affine/data-view/src/core/filter/filter-fn/tag.ts b/blocksuite/affine/data-view/src/core/filter/filter-fn/tag.ts new file mode 100644 index 0000000000..00bf5148ac --- /dev/null +++ b/blocksuite/affine/data-view/src/core/filter/filter-fn/tag.ts @@ -0,0 +1,46 @@ +import { ct } from '../../logical/composite-type.js'; +import { t } from '../../logical/type-presets.js'; +import { tRef, tVar } from '../../logical/type-variable.js'; +import { createFilter } from './create.js'; +import { tagToString } from './utils.js'; + +const optionName = 'options' as const; +export const tagFilter = [ + createFilter({ + name: 'isOneOf', + vars: [tVar(optionName, t.tag.instance())] as const, + self: tRef(optionName), + args: [ct.array.instance(tRef(optionName))] as const, + label: 'Is one of', + shortString: v => + v ? `: ${tagToString(v.value, v.type.element)}` : undefined, + impl: (self, value) => { + if (!value.length) { + return true; + } + if (self == null) { + return false; + } + return value.includes(self); + }, + defaultValue: args => args[0][0], + }), + createFilter({ + name: 'isNotOneOf', + vars: [tVar(optionName, t.tag.instance())] as const, + self: tRef(optionName), + args: [ct.array.instance(tRef(optionName))] as const, + label: 'Is not one of', + shortString: v => + v ? `: Not ${tagToString(v.value, v.type.element)}` : undefined, + impl: (self, value) => { + if (!value.length) { + return true; + } + if (self == null) { + return true; + } + return !value.includes(self); + }, + }), +]; diff --git a/blocksuite/affine/data-view/src/core/filter/filter-fn/unknown.ts b/blocksuite/affine/data-view/src/core/filter/filter-fn/unknown.ts new file mode 100644 index 0000000000..2562ceae10 --- /dev/null +++ b/blocksuite/affine/data-view/src/core/filter/filter-fn/unknown.ts @@ -0,0 +1,37 @@ +import { t } from '../../logical/type-presets.js'; +import { createFilter } from './create.js'; + +export const unknownFilter = [ + createFilter({ + name: 'isNotEmpty', + self: t.unknown.instance(), + args: [] as const, + label: 'Is not empty', + shortString: () => ': Is not empty', + impl: self => { + if (Array.isArray(self)) { + return self.length > 0; + } + if (typeof self === 'string') { + return !!self; + } + return self != null; + }, + }), + createFilter({ + name: 'isEmpty', + self: t.unknown.instance(), + args: [] as const, + label: 'Is empty', + shortString: () => ': Is empty', + impl: self => { + if (Array.isArray(self)) { + return self.length === 0; + } + if (typeof self === 'string') { + return !self; + } + return self == null; + }, + }), +]; diff --git a/blocksuite/affine/data-view/src/core/filter/filter-fn/utils.ts b/blocksuite/affine/data-view/src/core/filter/filter-fn/utils.ts new file mode 100644 index 0000000000..e12eae39bc --- /dev/null +++ b/blocksuite/affine/data-view/src/core/filter/filter-fn/utils.ts @@ -0,0 +1,20 @@ +import type { DataTypeOf } from '../../logical/data-type.js'; +import type { t } from '../../logical/index.js'; + +export const tagToString = ( + value: (string | undefined)[], + type: DataTypeOf +) => { + if (!type.data) { + return; + } + const map = new Map(type.data.map(v => [v.id, v.value])); + return value + .flatMap(id => { + if (id) { + return map.get(id); + } + return []; + }) + .join(', '); +}; diff --git a/blocksuite/affine/data-view/src/core/filter/generate-default-values.ts b/blocksuite/affine/data-view/src/core/filter/generate-default-values.ts new file mode 100644 index 0000000000..d274696885 --- /dev/null +++ b/blocksuite/affine/data-view/src/core/filter/generate-default-values.ts @@ -0,0 +1,43 @@ +import type { Variable } from '../expression/index.js'; +import type { DVJSON } from '../property/types.js'; +import { filterMatcher } from './filter-fn/matcher.js'; +import type { FilterGroup, SingleFilter } from './types.js'; + +/** + * Generate default values for a new row based on current filter conditions. + * If a property has multiple conditions, no value will be set to avoid conflicts. + */ +export function generateDefaultValues( + filter: FilterGroup, + _vars: Variable[] +): Record { + const defaultValues: Record = {}; + const propertyConditions = new Map(); + + // Only collect top-level filters + for (const condition of filter.conditions) { + if (condition.type === 'filter') { + const propertyId = condition.left.name; + if (!propertyConditions.has(propertyId)) { + propertyConditions.set(propertyId, []); + } + propertyConditions.get(propertyId)?.push(condition); + } + } + + for (const [propertyId, conditions] of propertyConditions) { + if (conditions.length === 1) { + const condition = conditions[0]; + const filterConfig = filterMatcher.getFilterByName(condition.function); + if (filterConfig?.defaultValue) { + const argValues = condition.args.map(arg => arg.value); + const defaultValue = filterConfig.defaultValue(argValues); + if (defaultValue != null) { + defaultValues[propertyId] = defaultValue; + } + } + } + } + + return defaultValues; +} diff --git a/blocksuite/affine/data-view/src/core/filter/index.ts b/blocksuite/affine/data-view/src/core/filter/index.ts new file mode 100644 index 0000000000..dae4c6ee86 --- /dev/null +++ b/blocksuite/affine/data-view/src/core/filter/index.ts @@ -0,0 +1,5 @@ +export * from './add-filter.js'; +export * from './eval.js'; +export * from './generate-default-values.js'; +export * from './types.js'; +export * from './utils.js'; diff --git a/blocksuite/affine/data-view/src/core/filter/literal/create.ts b/blocksuite/affine/data-view/src/core/filter/literal/create.ts new file mode 100644 index 0000000000..92ffe85803 --- /dev/null +++ b/blocksuite/affine/data-view/src/core/filter/literal/create.ts @@ -0,0 +1,5 @@ +import type { CreateLiteralItemsConfig } from './types.js'; + +export const createLiteral: CreateLiteralItemsConfig = config => { + return config; +}; diff --git a/blocksuite/affine/data-view/src/core/filter/literal/define.ts b/blocksuite/affine/data-view/src/core/filter/literal/define.ts new file mode 100644 index 0000000000..1cc668898a --- /dev/null +++ b/blocksuite/affine/data-view/src/core/filter/literal/define.ts @@ -0,0 +1,154 @@ +import { menu } from '@blocksuite/affine-components/context-menu'; +import { unsafeCSSVarV2 } from '@blocksuite/affine-shared/theme'; +import { CheckBoxCkeckSolidIcon, CheckBoxUnIcon } from '@blocksuite/icons/lit'; +import { html } from 'lit'; + +import { t } from '../../logical/type-presets.js'; +import { createLiteral } from './create.js'; +import type { LiteralItemsConfig } from './types.js'; + +export const allLiteralConfig: LiteralItemsConfig[] = [ + createLiteral({ + type: t.date.instance(), + getItems: (_type, value, onChange) => { + return [ + () => { + return html` `; + }, + ]; + }, + }), + createLiteral({ + type: t.boolean.instance(), + getItems: (_type, _value, _onChange) => { + return [ + // menu.action({ + // name: 'Unchecked', + // isSelected: !value.value, + // select: () => { + // onChange(false); + // return false; + // }, + // }), + // menu.action({ + // name: 'Checked', + // isSelected: !!value.value, + // select: () => { + // onChange(true); + // return false; + // }, + // }), + ]; + }, + }), + createLiteral({ + type: t.string.instance(), + getItems: (_type, value, onChange) => { + return [ + menu.input({ + initialValue: value.value ?? '', + onChange: onChange, + placeholder: 'Type a value...', + }), + ]; + }, + }), + createLiteral({ + type: t.number.instance(), + getItems: (_type, value, onChange) => { + return [ + menu.input({ + initialValue: value.value?.toString(10) ?? '', + placeholder: 'Type a value...', + onChange: text => { + const number = Number.parseFloat(text); + if (Number.isNaN(number)) { + return; + } + onChange(number); + }, + }), + ]; + }, + }), + createLiteral({ + type: t.array.instance(t.tag.instance()), + getItems: (type, value, onChange) => { + const set = new Set(value.value); + return [ + menu.group({ + items: + type.element.data?.map(tag => { + const selected = set.has(tag.id); + const prefix = selected + ? CheckBoxCkeckSolidIcon({ style: `color:#1E96EB` }) + : CheckBoxUnIcon(); + return menu.action({ + name: tag.value, + prefix, + label: () => + html`${tag.value}`, + select: () => { + if (selected) { + set.delete(tag.id); + } else { + set.add(tag.id); + } + onChange([...set]); + return false; + }, + }); + }) ?? [], + }), + ]; + }, + }), + createLiteral({ + type: t.tag.instance(), + getItems: (type, value, onChange) => { + return [ + menu.group({ + items: + type.data?.map(tag => { + return menu.action({ + name: tag.value, + label: () => + html`${tag.value}`, + isSelected: value.value === tag.id, + select: () => { + onChange(tag.id); + return false; + }, + }); + }) ?? [], + }), + ]; + }, + }), +]; diff --git a/blocksuite/affine/data-view/src/core/filter/literal/index.ts b/blocksuite/affine/data-view/src/core/filter/literal/index.ts new file mode 100644 index 0000000000..7562576df7 --- /dev/null +++ b/blocksuite/affine/data-view/src/core/filter/literal/index.ts @@ -0,0 +1,3 @@ +export * from './define.js'; +export * from './matcher.js'; +export * from './types.js'; diff --git a/blocksuite/affine/data-view/src/core/filter/literal/matcher.ts b/blocksuite/affine/data-view/src/core/filter/literal/matcher.ts new file mode 100644 index 0000000000..3e52177ad5 --- /dev/null +++ b/blocksuite/affine/data-view/src/core/filter/literal/matcher.ts @@ -0,0 +1,20 @@ +import type { ReadonlySignal } from '@preact/signals-core'; + +import { typeSystem } from '../../logical/index.js'; +import type { TypeInstance } from '../../logical/type.js'; +import { allLiteralConfig } from './define.js'; + +export const literalItemsMatcher = { + getItems: ( + type: TypeInstance, + value: ReadonlySignal, + onChange: (value: unknown) => void + ) => { + for (const config of allLiteralConfig) { + if (typeSystem.unify(type, config.type)) { + return config.getItems(type, value, onChange); + } + } + return []; + }, +}; diff --git a/blocksuite/affine/data-view/src/core/filter/literal/types.ts b/blocksuite/affine/data-view/src/core/filter/literal/types.ts new file mode 100644 index 0000000000..1ad782f0c2 --- /dev/null +++ b/blocksuite/affine/data-view/src/core/filter/literal/types.ts @@ -0,0 +1,19 @@ +import type { MenuConfig } from '@blocksuite/affine-components/context-menu'; +import type { ReadonlySignal } from '@preact/signals-core'; + +import type { TypeInstance, ValueTypeOf } from '../../logical/type.js'; + +export type CreateLiteralItemsConfig = < + Type extends TypeInstance = TypeInstance, +>( + config: LiteralItemsConfig +) => LiteralItemsConfig; + +export type LiteralItemsConfig = { + type: Type; + getItems: ( + type: Type, + value: ReadonlySignal | undefined>, + onChange: (value: ValueTypeOf) => void + ) => MenuConfig[]; +}; diff --git a/blocksuite/affine/data-view/src/core/filter/trait.ts b/blocksuite/affine/data-view/src/core/filter/trait.ts new file mode 100644 index 0000000000..a6d3556759 --- /dev/null +++ b/blocksuite/affine/data-view/src/core/filter/trait.ts @@ -0,0 +1,25 @@ +import { computed, type ReadonlySignal } from '@preact/signals-core'; + +import { createTraitKey } from '../traits/key.js'; +import type { SingleView } from '../view-manager/index.js'; +import type { FilterGroup } from './types.js'; + +export class FilterTrait { + filterSet = (filter: FilterGroup) => { + this.config.filterSet(filter); + }; + + hasFilter$ = computed(() => { + return this.filter$.value.conditions.length > 0; + }); + + constructor( + readonly filter$: ReadonlySignal, + readonly view: SingleView, + readonly config: { + filterSet: (filter: FilterGroup) => void; + } + ) {} +} + +export const filterTraitKey = createTraitKey('filter'); diff --git a/blocksuite/affine/data-view/src/core/filter/types.ts b/blocksuite/affine/data-view/src/core/filter/types.ts new file mode 100644 index 0000000000..02a403ba67 --- /dev/null +++ b/blocksuite/affine/data-view/src/core/filter/types.ts @@ -0,0 +1,14 @@ +import type { Value, VariableRef } from '../expression/types.js'; + +export type SingleFilter = { + type: 'filter'; + left: VariableRef; + function?: string; + args: Value[]; +}; +export type FilterGroup = { + type: 'group'; + op: 'and' | 'or'; + conditions: Filter[]; +}; +export type Filter = SingleFilter | FilterGroup; diff --git a/blocksuite/affine/data-view/src/core/filter/utils.ts b/blocksuite/affine/data-view/src/core/filter/utils.ts new file mode 100644 index 0000000000..04de5ec1ab --- /dev/null +++ b/blocksuite/affine/data-view/src/core/filter/utils.ts @@ -0,0 +1,59 @@ +import { BlockSuiteError, ErrorCode } from '@blocksuite/global/exceptions'; + +import { getRefType } from '../expression/ref/ref.js'; +import type { Variable, VariableRef } from '../expression/types.js'; +import { filterMatcher } from './filter-fn/matcher.js'; +import type { FilterGroup, SingleFilter } from './types.js'; + +export const firstFilterName = (vars: Variable[], ref: VariableRef) => { + const type = getRefType(vars, ref); + if (!type) { + throw new BlockSuiteError( + ErrorCode.DatabaseBlockError, + `can't resolve ref type` + ); + } + return filterMatcher.firstMatchedBySelfType(type)?.name; +}; +export const firstFilterByRef = ( + vars: Variable[], + ref: VariableRef +): SingleFilter => { + return { + type: 'filter', + left: ref, + function: firstFilterName(vars, ref), + args: [], + }; +}; +export const firstFilter = (vars: Variable[]): SingleFilter => { + const ref: VariableRef = { + type: 'ref', + name: vars[0].id, + }; + const filter = firstFilterName(vars, ref); + if (!filter) { + throw new BlockSuiteError( + ErrorCode.DatabaseBlockError, + `can't match any filter` + ); + } + return { + type: 'filter', + left: ref, + function: filter, + args: [], + }; +}; +export const firstFilterInGroup = (vars: Variable[]): FilterGroup => { + return { + type: 'group', + op: 'and', + conditions: [firstFilter(vars)], + }; +}; +export const emptyFilterGroup: FilterGroup = { + type: 'group', + op: 'and', + conditions: [], +}; diff --git a/blocksuite/affine/data-view/src/core/group-by/default.ts b/blocksuite/affine/data-view/src/core/group-by/default.ts new file mode 100644 index 0000000000..c1f62474f2 --- /dev/null +++ b/blocksuite/affine/data-view/src/core/group-by/default.ts @@ -0,0 +1,22 @@ +import type { GroupBy } from '../common/types.js'; +import type { DataSource } from '../data-source/index.js'; +import type { PropertyMetaConfig } from '../property/property-config.js'; +import { groupByMatcher } from './matcher.js'; + +export const defaultGroupBy = ( + dataSource: DataSource, + propertyMeta: PropertyMetaConfig, + propertyId: string, + data: NonNullable +): GroupBy | undefined => { + const name = groupByMatcher.match( + propertyMeta.config.type({ data, dataSource }) + )?.name; + return name != null + ? { + type: 'groupBy', + columnId: propertyId, + name: name, + } + : undefined; +}; diff --git a/blocksuite/affine/data-view/src/core/group-by/define.ts b/blocksuite/affine/data-view/src/core/group-by/define.ts new file mode 100644 index 0000000000..d2f23edc0b --- /dev/null +++ b/blocksuite/affine/data-view/src/core/group-by/define.ts @@ -0,0 +1,169 @@ +import hash from '@emotion/hash'; + +import { MatcherCreator } from '../logical/matcher.js'; +import { t } from '../logical/type-presets.js'; +import { createUniComponentFromWebComponent } from '../utils/uni-component/uni-component.js'; +import { BooleanGroupView } from './renderer/boolean-group.js'; +import { NumberGroupView } from './renderer/number-group.js'; +import { SelectGroupView } from './renderer/select-group.js'; +import { StringGroupView } from './renderer/string-group.js'; +import type { GroupByConfig } from './types.js'; + +const groupByMatcherCreator = new MatcherCreator(); +const ungroups = { + key: 'Ungroups', + value: null, +}; +export const groupByMatchers = [ + groupByMatcherCreator.createMatcher(t.tag.instance(), { + name: 'select', + groupName: (type, value) => { + if (t.tag.is(type) && type.data) { + return type.data.find(v => v.id === value)?.value ?? ''; + } + return ''; + }, + defaultKeys: type => { + if (t.tag.is(type) && type.data) { + return [ + ungroups, + ...type.data.map(v => ({ + key: v.id, + value: v.id, + })), + ]; + } + return [ungroups]; + }, + valuesGroup: (value, _type) => { + if (value == null) { + return [ungroups]; + } + return [ + { + key: `${value}`, + value: value.toString(), + }, + ]; + }, + view: createUniComponentFromWebComponent(SelectGroupView), + }), + groupByMatcherCreator.createMatcher(t.array.instance(t.tag.instance()), { + name: 'multi-select', + groupName: (type, value) => { + if (t.tag.is(type) && type.data) { + return type.data.find(v => v.id === value)?.value ?? ''; + } + return ''; + }, + defaultKeys: type => { + if (t.array.is(type) && t.tag.is(type.element) && type.element.data) { + return [ + ungroups, + ...type.element.data.map(v => ({ + key: v.id, + value: v.id, + })), + ]; + } + return [ungroups]; + }, + valuesGroup: (value, _type) => { + if (value == null) { + return [ungroups]; + } + if (Array.isArray(value) && value.length) { + return value.map(id => ({ + key: `${id}`, + value: id, + })); + } + return [ungroups]; + }, + addToGroup: (value, old) => { + if (value == null) { + return old; + } + return Array.isArray(old) ? [...old, value] : [value]; + }, + removeFromGroup: (value, old) => { + if (Array.isArray(old)) { + return old.filter(v => v !== value); + } + return old; + }, + view: createUniComponentFromWebComponent(SelectGroupView), + }), + groupByMatcherCreator.createMatcher(t.string.instance(), { + name: 'text', + groupName: (_type, value) => { + return `${value ?? ''}`; + }, + defaultKeys: _type => { + return [ungroups]; + }, + valuesGroup: (value, _type) => { + if (typeof value !== 'string' || !value) { + return [ungroups]; + } + return [ + { + key: hash(value), + value, + }, + ]; + }, + view: createUniComponentFromWebComponent(StringGroupView), + }), + groupByMatcherCreator.createMatcher(t.number.instance(), { + name: 'number', + groupName: (_type, value) => { + return `${value ?? ''}`; + }, + defaultKeys: _type => { + return [ungroups]; + }, + valuesGroup: (value, _type) => { + if (typeof value !== 'number') { + return [ungroups]; + } + return [ + { + key: `g:${Math.floor(value / 10)}`, + value: Math.floor(value / 10), + }, + ]; + }, + addToGroup: value => (typeof value === 'number' ? value * 10 : undefined), + view: createUniComponentFromWebComponent(NumberGroupView), + }), + groupByMatcherCreator.createMatcher(t.boolean.instance(), { + name: 'boolean', + groupName: (_type, value) => { + return `${value?.toString() ?? ''}`; + }, + defaultKeys: _type => { + return [ + { key: 'true', value: true }, + { key: 'false', value: false }, + ]; + }, + valuesGroup: (value, _type) => { + if (typeof value !== 'boolean') { + return [ + { + key: 'false', + value: false, + }, + ]; + } + return [ + { + key: value.toString(), + value: value, + }, + ]; + }, + view: createUniComponentFromWebComponent(BooleanGroupView), + }), +]; diff --git a/blocksuite/affine/data-view/src/core/group-by/group-title.ts b/blocksuite/affine/data-view/src/core/group-by/group-title.ts new file mode 100644 index 0000000000..baec82916c --- /dev/null +++ b/blocksuite/affine/data-view/src/core/group-by/group-title.ts @@ -0,0 +1,210 @@ +import { unsafeCSSVarV2 } from '@blocksuite/affine-shared/theme'; +import { IS_MOBILE } from '@blocksuite/global/env'; +import { MoreHorizontalIcon, PlusIcon } from '@blocksuite/icons/lit'; +import { nothing } from 'lit'; +import { html } from 'lit/static-html.js'; + +import { renderUniLit } from '../utils/uni-component/uni-component.js'; +import type { GroupData } from './trait.js'; +import type { GroupRenderProps } from './types.js'; + +function GroupHeaderCount(group: GroupData) { + const cards = group.rows; + if (!cards.length) { + return; + } + return html`
${cards.length}
`; +} +const GroupTitleMobile = ( + 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()} +
+
`} + `; +}; + +export const GroupTitle = ( + groupData: GroupData, + ops: { + readonly: boolean; + clickAdd: (evt: MouseEvent) => void; + clickOps: (evt: MouseEvent) => void; + } +) => { + if (IS_MOBILE) { + return GroupTitleMobile(groupData, ops); + } + 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/blocksuite/affine/data-view/src/core/group-by/matcher.ts b/blocksuite/affine/data-view/src/core/group-by/matcher.ts new file mode 100644 index 0000000000..7b3cd536ec --- /dev/null +++ b/blocksuite/affine/data-view/src/core/group-by/matcher.ts @@ -0,0 +1,5 @@ +import { Matcher } from '../logical/matcher.js'; +import { groupByMatchers } from './define.js'; +import type { GroupByConfig } from './types.js'; + +export const groupByMatcher = new Matcher(groupByMatchers); diff --git a/blocksuite/affine/data-view/src/core/group-by/renderer/base.ts b/blocksuite/affine/data-view/src/core/group-by/renderer/base.ts new file mode 100644 index 0000000000..608762b8ec --- /dev/null +++ b/blocksuite/affine/data-view/src/core/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/blocksuite/affine/data-view/src/core/group-by/renderer/boolean-group.ts b/blocksuite/affine/data-view/src/core/group-by/renderer/boolean-group.ts new file mode 100644 index 0000000000..04655ebfb9 --- /dev/null +++ b/blocksuite/affine/data-view/src/core/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/blocksuite/affine/data-view/src/core/group-by/renderer/number-group.ts b/blocksuite/affine/data-view/src/core/group-by/renderer/number-group.ts new file mode 100644 index 0000000000..da3917f1e7 --- /dev/null +++ b/blocksuite/affine/data-view/src/core/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}` : '', + onChange: 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/blocksuite/affine/data-view/src/core/group-by/renderer/select-group.ts b/blocksuite/affine/data-view/src/core/group-by/renderer/select-group.ts new file mode 100644 index 0000000000..2d6b45589b --- /dev/null +++ b/blocksuite/affine/data-view/src/core/group-by/renderer/select-group.ts @@ -0,0 +1,119 @@ +import { + menu, + popMenu, + popupTargetFromElement, +} from '@blocksuite/affine-components/context-menu'; +import { css, html } from 'lit'; +import { classMap } from 'lit/directives/class-map.js'; +import { styleMap } from 'lit/directives/style-map.js'; + +import { selectOptionColors } from '../../component/tags/colors.js'; +import type { SelectTag } from '../../logical/index.js'; +import { BaseGroup } from './base.js'; + +export class SelectGroupView extends BaseGroup< + { + options: SelectTag[]; + }, + string +> { + static override styles = css` + data-view-group-title-select-view { + overflow: hidden; + } + + .data-view-group-title-select-view { + width: 100%; + cursor: pointer; + } + + .data-view-group-title-select-view.readonly { + cursor: inherit; + } + + .tag { + padding: 0 8px; + border-radius: 4px; + font-size: var(--data-view-cell-text-size); + line-height: var(--data-view-cell-text-line-height); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + `; + + private _click = (e: MouseEvent) => { + if (this.readonly) { + return; + } + e.stopPropagation(); + popMenu(popupTargetFromElement(this), { + options: { + items: [ + menu.input({ + initialValue: this.tag?.value ?? '', + onChange: text => { + this.updateTag({ value: text }); + }, + }), + ...selectOptionColors.map(({ color, name }) => { + const styles = styleMap({ + backgroundColor: color, + borderRadius: '50%', + width: '20px', + height: '20px', + }); + return menu.action({ + name: name, + isSelected: this.tag?.color === color, + prefix: html`
`, + select: () => { + this.updateTag({ color }); + }, + }); + }), + ], + }, + }); + }; + + get tag() { + return this.data.options.find(v => v.id === this.value); + } + + protected override render(): unknown { + const tag = this.tag; + if (!tag) { + return html`
+ Ungroups +
`; + } + const style = styleMap({ + backgroundColor: tag.color, + }); + const classList = classMap({ + 'data-view-group-title-select-view': true, + readonly: this.readonly, + }); + return html`
+
${tag.value}
+
`; + } + + updateTag(tag: Partial) { + this.updateData?.({ + ...this.data, + options: this.data.options.map(v => { + if (v.id === this.value) { + return { + ...v, + ...tag, + }; + } + return v; + }), + }); + } +} diff --git a/blocksuite/affine/data-view/src/core/group-by/renderer/string-group.ts b/blocksuite/affine/data-view/src/core/group-by/renderer/string-group.ts new file mode 100644 index 0000000000..8d1f6df0c6 --- /dev/null +++ b/blocksuite/affine/data-view/src/core/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/blocksuite/affine/data-view/src/core/group-by/setting.ts b/blocksuite/affine/data-view/src/core/group-by/setting.ts new file mode 100644 index 0000000000..259bd11ca3 --- /dev/null +++ b/blocksuite/affine/data-view/src/core/group-by/setting.ts @@ -0,0 +1,312 @@ +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 { computed } from '@preact/signals-core'; +import { css, html, unsafeCSS } from 'lit'; +import { property, query } from 'lit/decorators.js'; +import { repeat } from 'lit/directives/repeat.js'; + +import { KanbanSingleView } from '../../view-presets/kanban/kanban-view-manager.js'; +import { TableSingleView } from '../../view-presets/table/table-view-manager.js'; +import { dataViewCssVariable } from '../common/css-variable.js'; +import { renderUniLit } from '../utils/uni-component/uni-component.js'; +import { dragHandler } from '../utils/wc-dnd/dnd-context.js'; +import { defaultActivators } from '../utils/wc-dnd/sensors/index.js'; +import { + createSortContext, + sortable, +} from '../utils/wc-dnd/sort/sort-context.js'; +import { verticalListSortingStrategy } from '../utils/wc-dnd/sort/strategies/index.js'; +import { groupByMatcher } from './matcher.js'; +import type { GroupTrait } from './trait.js'; +import type { GroupRenderProps } from './types.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; + } + `; + + @property({ attribute: false }) + accessor groupTrait!: GroupTrait; + + groups$ = computed(() => { + return this.groupTrait.groupsDataList$.value; + }); + + sortContext = createSortContext({ + activators: defaultActivators, + container: this, + onDragEnd: evt => { + const over = evt.over; + const activeId = evt.active.id; + const groups = this.groups$.value; + if (over && over.id !== activeId && groups) { + const activeIndex = groups.findIndex(data => data.key === activeId); + const overIndex = groups.findIndex(data => data.key === over.id); + + this.groupTrait.moveGroupTo( + activeId, + activeIndex > overIndex + ? { + before: true, + id: over.id, + } + : { + before: false, + id: over.id, + } + ); + } + }, + modifiers: [ + ({ transform }) => { + return { + ...transform, + x: 0, + }; + }, + ], + items: computed(() => { + return this.groupTrait.groupsDataList$.value?.map(v => v.key) ?? []; + }), + strategy: verticalListSortingStrategy, + }); + + override connectedCallback() { + super.connectedCallback(); + this._disposables.addFromEvent(this, 'pointerdown', e => { + e.stopPropagation(); + }); + } + + protected override render(): unknown { + const groups = this.groupTrait.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; +} + +export const selectGroupByProperty = ( + group: GroupTrait, + ops?: { + onSelect?: (id?: string) => void; + onClose?: () => void; + onBack?: () => void; + } +): MenuOptions => { + const view = group.view; + return { + onClose: ops?.onClose, + title: { + text: 'Group by', + onBack: ops?.onBack, + }, + items: [ + menu.group({ + 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: group.property$.value?.id === id, + prefix: html` `, + select: () => { + group.changeGroup(id); + ops?.onSelect?.(id); + }, + }); + }), + }), + menu.group({ + items: [ + menu.action({ + prefix: DeleteIcon(), + hide: () => + view instanceof KanbanSingleView || group.property$.value == null, + class: { 'delete-item': true }, + name: 'Remove Grouping', + select: () => { + group.changeGroup(undefined); + ops?.onSelect?.(); + }, + }), + ], + }), + ], + }; +}; +export const popSelectGroupByProperty = ( + target: PopupTarget, + group: GroupTrait, + ops?: { + onSelect?: () => void; + onClose?: () => void; + onBack?: () => void; + } +) => { + popMenu(target, { + options: selectGroupByProperty(group, ops), + }); +}; +export const popGroupSetting = ( + target: PopupTarget, + group: GroupTrait, + onBack: () => void +) => { + const view = group.view; + const groupProperty = group.property$.value; + if (groupProperty == null) { + return; + } + const type = groupProperty.type$.value; + if (!type) { + return; + } + const icon = view.propertyIconGet(type); + const menuHandler = popMenu(target, { + options: { + title: { + text: 'Group', + onBack: onBack, + }, + items: [ + menu.group({ + items: [ + menu.subMenu({ + name: 'Group By', + postfix: html` +
+ ${renderUniLit(icon, {})} ${groupProperty.name$.value} +
+ `, + label: () => html` +
+ Group By +
+ `, + options: selectGroupByProperty(group, { + onSelect: () => { + menuHandler.close(); + popGroupSetting(target, group, 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: () => { + group.changeGroup(undefined); + }, + }), + ], + }), + ], + }, + }); +}; diff --git a/blocksuite/affine/data-view/src/core/group-by/trait.ts b/blocksuite/affine/data-view/src/core/group-by/trait.ts new file mode 100644 index 0000000000..25c785558d --- /dev/null +++ b/blocksuite/affine/data-view/src/core/group-by/trait.ts @@ -0,0 +1,313 @@ +import { + insertPositionToIndex, + type InsertToPosition, +} from '@blocksuite/affine-shared/utils'; +import { computed, type ReadonlySignal } from '@preact/signals-core'; + +import type { GroupBy, GroupProperty } from '../common/types.js'; +import type { TypeInstance } from '../logical/type.js'; +import type { DVJSON } from '../property/types.js'; +import { createTraitKey } from '../traits/key.js'; +import type { Property } from '../view-manager/property.js'; +import type { SingleView } from '../view-manager/single-view.js'; +import { defaultGroupBy } from './default.js'; +import { groupByMatcher } from './matcher.js'; + +export type GroupData = { + manager: GroupTrait; + property: Property; + key: string; + name: string; + type: TypeInstance; + value: DVJSON; + rows: string[]; +}; + +export class GroupTrait { + private preDataList: GroupData[] | undefined; + + 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.view.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.view.rows$.value.forEach(id => { + const value = this.view.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; + }); + + private _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 (this.preDataList = sortedGroup.map(key => groupMap[key])); + }); + + groupsDataList$ = computed(() => { + if (this.view.isLocked$.value) { + return this.preDataList; + } + return (this.preDataList = this._groupsDataList$.value); + }); + + updateData = (data: NonNullable) => { + const propertyId = this.propertyId; + if (!propertyId) { + return; + } + this.view.propertyDataSet(propertyId, data); + }; + + get addGroup() { + const type = this.property$.value?.type$.value; + if (!type) { + return; + } + return this.view.propertyMetaGet(type)?.config.addGroup; + } + + get propertyId() { + return this.groupBy$.value?.columnId; + } + + constructor( + private groupBy$: ReadonlySignal, + public view: SingleView, + private ops: { + groupBySet: (groupBy: GroupBy | undefined) => void; + 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.view.cellJsonValueGet(rowId, propertyId) + ); + this.view.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 + ); + } + + changeGroup(columnId: string | undefined) { + if (columnId == null) { + this.ops.groupBySet(undefined); + return; + } + const column = this.view.propertyGet(columnId); + const propertyMeta = this.view.propertyMetaGet(column.type$.value); + if (propertyMeta) { + this.ops.groupBySet( + defaultGroupBy( + this.view.manager.dataSource, + propertyMeta, + column.id, + column.data$.value + ) + ); + } + } + + 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.view.cellJsonValueGet(rowId, propertyId) + ); + } + const addTo = this.config$.value?.addToGroup ?? (value => value); + newValue = addTo(groupMap[toGroupKey].value, newValue); + this.view.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.view.cellJsonValueGet(rowId, propertyId) + ); + this.view.cellValueSet(rowId, propertyId, newValue); + } + + updateValue(rows: string[], value: DVJSON) { + const propertyId = this.propertyId; + if (!propertyId) { + return; + } + rows.forEach(id => { + this.view.cellJsonValueSet(id, propertyId, value); + }); + } +} + +export const groupTraitKey = createTraitKey('group'); + +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/blocksuite/affine/data-view/src/core/group-by/types.ts b/blocksuite/affine/data-view/src/core/group-by/types.ts new file mode 100644 index 0000000000..032b8a601a --- /dev/null +++ b/blocksuite/affine/data-view/src/core/group-by/types.ts @@ -0,0 +1,33 @@ +import type { TypeInstance } from '../logical/type.js'; +import type { DVJSON } from '../property/types.js'; +import type { UniComponent } from '../utils/index.js'; + +export interface GroupRenderProps< + Data extends NonNullable = NonNullable, + Value = DVJSON, +> { + data: Data; + updateData?: (data: Data) => void; + value: Value; + updateValue?: (value: Value) => void; + readonly: boolean; +} + +export type GroupByConfig = { + name: string; + groupName: (type: TypeInstance, value: unknown) => string; + defaultKeys: (type: TypeInstance) => { + key: string; + value: DVJSON; + }[]; + valuesGroup: ( + value: unknown, + type: TypeInstance + ) => { + key: string; + value: DVJSON; + }[]; + addToGroup?: (value: unknown, oldValue: unknown) => unknown; + removeFromGroup?: (value: unknown, oldValue: unknown) => unknown; + view: UniComponent; +}; diff --git a/blocksuite/affine/data-view/src/core/index.ts b/blocksuite/affine/data-view/src/core/index.ts new file mode 100644 index 0000000000..004f392af4 --- /dev/null +++ b/blocksuite/affine/data-view/src/core/index.ts @@ -0,0 +1,13 @@ +export * from './common/index.js'; +export * from './component/index.js'; +export { DataSourceBase } from './data-source/base.js'; +export { DataView } from './data-view.js'; +export * from './filter/index.js'; +export * from './logical/index.js'; +export * from './property/index.js'; +export type { DataViewSelection } from './types.js'; +export * from './types.js'; +export * from './utils/index.js'; +export * from './view/index.js'; +export * from './view-manager/index.js'; +export * from './widget/index.js'; diff --git a/blocksuite/affine/data-view/src/core/logical/composite-type.ts b/blocksuite/affine/data-view/src/core/logical/composite-type.ts new file mode 100644 index 0000000000..164a0f35cf --- /dev/null +++ b/blocksuite/affine/data-view/src/core/logical/composite-type.ts @@ -0,0 +1,138 @@ +import Zod from 'zod'; + +import type { + AnyTypeInstance, + TypeInstance, + Unify, + ValueTypeOf, +} from './type.js'; +import type { + TypeVarContext, + TypeVarDefinitionInstance, +} from './type-variable.js'; + +type FnValueType< + Args extends readonly TypeInstance[], + Return extends TypeInstance, +> = ( + ...args: { [K in keyof Args]: ValueTypeOf } +) => ValueTypeOf; + +export class FnTypeInstance< + Args extends readonly TypeInstance[] = readonly TypeInstance[], + Return extends TypeInstance = TypeInstance, +> implements TypeInstance +{ + _validate = fnSchema; + + readonly _valueType = undefined as never as FnValueType; + + name = 'function'; + + constructor( + readonly args: Args, + readonly rt: Return, + readonly vars: TypeVarDefinitionInstance[] + ) {} + + subst(ctx: TypeVarContext) { + const newCtx = { ...ctx }; + const args: TypeInstance[] = []; + for (const arg of this.args) { + const newArg = arg.subst(newCtx); + if (!newArg) { + return; + } + args.push(newArg); + } + const rt = this.rt.subst(newCtx); + if (!rt) { + return; + } + return ct.fn.instance(args, rt); + } + + unify(ctx: TypeVarContext, template: FnTypeInstance, unify: Unify): boolean { + const newCtx = { ...ctx }; + + // eslint-disable-next-line @typescript-eslint/prefer-for-of + for (let i = 0; i < template.args.length; i++) { + const arg = template.args[i]; + const realArg = this.args[i]; + if (arg == null) { + return false; + } + // eslint-disable-next-line sonarjs/no-collapsible-if + if (realArg != null) { + if (!unify(newCtx, realArg, arg)) { + return false; + } + } + } + return unify(newCtx, template.rt, this.rt); + } + + valueValidate(value: unknown): value is FnValueType { + return fnSchema.safeParse(value).success; + } +} + +const fnSchema = Zod.function(); + +export class ArrayTypeInstance + implements TypeInstance +{ + readonly _validate; + + readonly _valueType = undefined as never as ValueTypeOf[]; + + readonly name = 'array'; + + constructor(readonly element: Element) { + this._validate = Zod.array(element._validate); + } + + subst(ctx: TypeVarContext) { + const ele = this.element.subst(ctx); + if (!ele) { + return; + } + return ct.array.instance(ele); + } + + unify(ctx: TypeVarContext, type: ArrayTypeInstance, unify: Unify): boolean { + return unify(ctx, this.element, type.element); + } + + valueValidate(value: unknown): value is ValueTypeOf[] { + return this._validate.safeParse(value).success; + } +} + +export const ct = { + fn: { + is: (type: AnyTypeInstance): type is FnTypeInstance => { + return type.name === 'function'; + }, + instance: < + Args extends readonly TypeInstance[], + Return extends TypeInstance, + >( + args: Args, + rt: Return, + vars?: TypeVarDefinitionInstance[] + ) => { + return new FnTypeInstance(args, rt, vars ?? []); + }, + }, + array: { + is: (type: AnyTypeInstance): type is ArrayTypeInstance => { + return type.name === 'array'; + }, + instance: ( + element: Element + ): ArrayTypeInstance => { + return new ArrayTypeInstance(element); + }, + }, +}; diff --git a/blocksuite/affine/data-view/src/core/logical/data-type.ts b/blocksuite/affine/data-view/src/core/logical/data-type.ts new file mode 100644 index 0000000000..91c2ddabe2 --- /dev/null +++ b/blocksuite/affine/data-view/src/core/logical/data-type.ts @@ -0,0 +1,79 @@ +import type Zod from 'zod'; + +import type { + AnyTypeInstance, + TypeDefinition, + TypeInstance, + Unify, +} from './type.js'; +import type { TypeVarContext } from './type-variable.js'; + +export type DataTypeOf = ReturnType; + +export class DTInstance< + Name extends string = string, + Data = unknown, + ValueSchema extends Zod.ZodType = Zod.ZodType, +> implements TypeInstance +{ + readonly _valueType = undefined as never as Zod.TypeOf; + + constructor( + readonly name: Name, + readonly _validate: ValueSchema, + readonly data?: Data + ) {} + + subst(_ctx: TypeVarContext): void | TypeInstance { + return this; + } + + unify(_ctx: TypeVarContext, type: DTInstance, _unify: Unify): boolean { + if (this.name !== type.name) { + return false; + } + if (type.data == null) { + return true; + } + return this.data != null; + } + + valueValidate(value: unknown): value is this['_valueType'] { + return this._validate.safeParse(value).success; + } +} + +export class DataType< + Name extends string = string, + DataSchema extends Zod.ZodType = Zod.ZodType, + ValueSchema extends Zod.ZodType = Zod.ZodType, +> implements TypeDefinition +{ + constructor( + private name: Name, + _dataSchema: DataSchema, + private valueSchema: ValueSchema + ) {} + + instance(literal?: Zod.TypeOf) { + return new DTInstance(this.name, this.valueSchema, literal); + } + + is( + type: AnyTypeInstance + ): type is DTInstance, ValueSchema> { + return type.name === this.name; + } +} + +export const defineDataType = < + Name extends string, + Data extends Zod.ZodType, + Value extends Zod.ZodType, +>( + name: Name, + validateData: Data, + validateValue: Value +) => { + return new DataType(name, validateData, validateValue); +}; diff --git a/blocksuite/affine/data-view/src/core/logical/index.ts b/blocksuite/affine/data-view/src/core/logical/index.ts new file mode 100644 index 0000000000..72329432c5 --- /dev/null +++ b/blocksuite/affine/data-view/src/core/logical/index.ts @@ -0,0 +1,3 @@ +export * from './type.js'; +export * from './type-presets.js'; +export * from './type-system.js'; diff --git a/blocksuite/affine/data-view/src/core/logical/matcher.ts b/blocksuite/affine/data-view/src/core/logical/matcher.ts new file mode 100644 index 0000000000..1ca41326a7 --- /dev/null +++ b/blocksuite/affine/data-view/src/core/logical/matcher.ts @@ -0,0 +1,70 @@ +import type { TypeInstance } from './type.js'; +import { typeSystem } from './type-system.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: TypeInstance) => boolean = ( + type, + target + ) => typeSystem.unify(target, type) + ) {} + + all(): MatcherData[] { + return this.list; + } + + allMatched(type: TypeInstance): MatcherData[] { + const result: MatcherData[] = []; + for (const t of this.list) { + if (this._match(t.type, type)) { + result.push(t); + } + } + return result; + } + + allMatchedData(type: TypeInstance): 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: TypeInstance) { + return this._match(type, target); + } + + match(type: TypeInstance) { + for (const t of this.list) { + if (this._match(t.type, type)) { + return t.data; + } + } + return; + } +} diff --git a/blocksuite/affine/data-view/src/core/logical/type-presets.ts b/blocksuite/affine/data-view/src/core/logical/type-presets.ts new file mode 100644 index 0000000000..5a18d90a7d --- /dev/null +++ b/blocksuite/affine/data-view/src/core/logical/type-presets.ts @@ -0,0 +1,57 @@ +import * as zod from 'zod'; +import Zod from 'zod'; + +import { ct } from './composite-type.js'; +import { defineDataType } from './data-type.js'; +import type { TypeConvertConfig, TypeInstance, ValueTypeOf } from './type.js'; +import { tv } from './type-variable.js'; + +export type SelectTag = Zod.TypeOf; +export const SelectTagSchema = Zod.object({ + id: Zod.string(), + color: Zod.string(), + value: Zod.string(), + parentId: Zod.string().optional(), +}); +export const unknown = defineDataType('Unknown', zod.never(), zod.unknown()); +export const dt = { + number: defineDataType('Number', zod.number(), zod.number()), + string: defineDataType('String', zod.string(), zod.string()), + boolean: defineDataType('Boolean', zod.boolean(), zod.boolean()), + richText: defineDataType('RichText', zod.string(), zod.string()), + date: defineDataType('Date', zod.number(), zod.number()), + url: defineDataType('URL', zod.string(), zod.string()), + image: defineDataType('Image', zod.string(), zod.string()), + tag: defineDataType('Tag', zod.array(SelectTagSchema), zod.string()), +}; +export const t = { + unknown, + ...dt, + ...tv, + ...ct, +}; +const createTypeConvert = ( + from: From, + to: To, + convert: (value: ValueTypeOf) => ValueTypeOf +): TypeConvertConfig => { + return { + from, + to, + convert, + }; +}; +export const converts: TypeConvertConfig[] = [ + ...Object.values(dt).map(from => ({ + from: from.instance(), + to: t.unknown.instance(), + convert: (value: unknown) => value, + })), + createTypeConvert( + t.array.instance(unknown.instance()), + unknown.instance(), + value => value + ), + createTypeConvert(t.richText.instance(), t.string.instance(), value => value), + createTypeConvert(t.url.instance(), t.string.instance(), value => value), +]; diff --git a/blocksuite/affine/data-view/src/core/logical/type-system.ts b/blocksuite/affine/data-view/src/core/logical/type-system.ts new file mode 100644 index 0000000000..0222727d61 --- /dev/null +++ b/blocksuite/affine/data-view/src/core/logical/type-system.ts @@ -0,0 +1,196 @@ +import type { FnTypeInstance } from './composite-type.js'; +import type { TypeConvertConfig, TypeInstance, Unify } from './type.js'; +import { converts } from './type-presets.js'; +import { + tv, + type TypeVarContext, + type TypeVarReferenceInstance, +} from './type-variable.js'; + +type From = string; +type To = string; +const setMap2 = ( + map2: Map>, + key1: string, + key2: string, + value: T +) => { + let map = map2.get(key1); + if (!map) { + map2.set(key1, (map = new Map())); + } + map.set(key2, value); + return map; +}; + +const getMap2 = ( + map2: Map>, + key1: string, + key2: string +) => { + return map2.get(key1)?.get(key2); +}; + +export class TypeSystem { + private _unify: Unify = ( + ctx: TypeVarContext, + left: TypeInstance | undefined, + right: TypeInstance | undefined + ): boolean => { + if (left == null) return true; + if (right == null) return false; + if (tv.typeVarReference.is(left)) { + return this.unifyReference(ctx, left, right); + } + if (tv.typeVarReference.is(right)) { + return this.unifyReference(ctx, right, left, false); + } + return this.unifyNormalType(ctx, left, right); + }; + + convertMapFromTo = new Map< + From, + Map< + To, + { + level: number; + from: TypeInstance; + to: TypeInstance; + convert: (value: unknown) => unknown; + } + > + >(); + + convertMapToFrom = new Map< + From, + Map< + To, + { + level: number; + from: TypeInstance; + to: TypeInstance; + convert: (value: unknown) => unknown; + } + > + >(); + + unify = ( + left: TypeInstance | undefined, + right: T | undefined + ): left is T => { + return this._unify({}, left, right); + }; + + constructor(converts: TypeConvertConfig[]) { + converts.forEach(config => { + this.registerConvert(config.from, config.to, config.convert); + }); + } + + private registerConvert( + from: TypeInstance, + to: TypeInstance, + convert: (value: unknown) => unknown, + level = 0 + ) { + const currentConfig = getMap2(this.convertMapFromTo, from.name, to.name); + if (currentConfig && currentConfig.level <= level) { + return; + } + const config = { + level, + from, + to, + convert, + }; + setMap2(this.convertMapFromTo, from.name, to.name, config); + setMap2(this.convertMapToFrom, to.name, from.name, config); + this.convertMapToFrom.get(from.name)?.forEach(config => { + this.registerConvert(config.from, to, value => + convert(config.convert(value)) + ); + }); + } + + private unifyNormalType( + ctx: TypeVarContext, + left: TypeInstance | undefined, + right: TypeInstance | undefined, + covariance: boolean = true + ): boolean { + if (!left || !right) { + return false; + } + if (left.name !== right.name) { + [left, right] = covariance ? [left, right] : [right, left]; + const convertConfig = this.convertMapFromTo + .get(left.name) + ?.get(right.name); + if (convertConfig == null) { + return false; + } + left = convertConfig.to; + } + return left.unify(ctx, right, this._unify); + } + + private unifyReference( + ctx: TypeVarContext, + left: TypeVarReferenceInstance, + right: TypeInstance | undefined, + covariance: boolean = true + ): boolean { + if (!right) { + return false; + } + let leftDefine = ctx[left.varName]; + if (!leftDefine) { + ctx[left.varName] = leftDefine = { + define: tv.typeVarDefine.create(left.varName), + }; + } + const leftType = leftDefine.type; + if (tv.typeVarReference.is(right)) { + return this.unifyReference(ctx, right, leftType, !covariance); + } + if (!leftType) { + leftDefine.type = right; + return true; + } + return this.unifyNormalType(ctx, leftType, right, covariance); + } + + instanceFn( + template: FnTypeInstance, + realArgs: TypeInstance[], + realRt: TypeInstance, + ctx: TypeVarContext + ): FnTypeInstance | void { + const newCtx = { + ...ctx, + }; + template.vars.forEach(v => { + newCtx[v.varName] = { + define: v, + }; + }); + for (let i = 0; i < template.args.length; i++) { + const arg = template.args[i]; + const realArg = realArgs[i]; + if (arg == null) { + return; + } + // eslint-disable-next-line sonarjs/no-collapsible-if + if (realArg != null) { + if (!this._unify(newCtx, realArg, arg)) { + console.log('arg', realArg, arg); + return; + } + } + } + this._unify(newCtx, template.rt, realRt); + return template.subst(newCtx); + } +} + +export const typeSystem = new TypeSystem(converts); diff --git a/blocksuite/affine/data-view/src/core/logical/type-variable.ts b/blocksuite/affine/data-view/src/core/logical/type-variable.ts new file mode 100644 index 0000000000..d6676ed17b --- /dev/null +++ b/blocksuite/affine/data-view/src/core/logical/type-variable.ts @@ -0,0 +1,76 @@ +import { BlockSuiteError, ErrorCode } from '@blocksuite/global/exceptions'; +import Zod from 'zod'; + +import type { TypeInstance, Unify } from './type.js'; + +const unknownSchema = Zod.unknown(); + +export class TypeVarDefinitionInstance< + Name extends string = string, + Type extends TypeInstance = TypeInstance, +> { + readonly name = '__TypeVarDefine'; + + constructor( + readonly varName: Name, + readonly typeConstraint?: Type + ) {} +} + +export class TypeVarReferenceInstance + implements TypeInstance +{ + readonly _validate = unknownSchema; + + readonly _valueType = undefined as unknown; + + readonly name = '__TypeVarReference'; + + constructor(readonly varName: Name) {} + + subst(ctx: TypeVarContext): void | TypeInstance { + return ctx[this.varName].type; + } + + unify(_ctx: TypeVarContext, _type: TypeInstance, _unify: Unify): boolean { + throw new BlockSuiteError( + ErrorCode.DatabaseBlockError, + 'unexpected type unify, type var reference' + ); + } + + valueValidate(_value: unknown): _value is unknown { + return true; + } +} + +export const tv = { + typeVarDefine: { + create: < + Name extends string = string, + Type extends TypeInstance = TypeInstance, + >( + name: Name, + typeConstraint?: Type + ) => { + return new TypeVarDefinitionInstance(name, typeConstraint); + }, + }, + typeVarReference: { + create: (name: Name) => { + return new TypeVarReferenceInstance(name); + }, + is: (type: TypeInstance): type is TypeVarReferenceInstance => { + return type.name === '__TypeVarReference'; + }, + }, +}; + +export type TypeVarDefine = { + define: TypeVarDefinitionInstance; + type?: TypeInstance; +}; + +export type TypeVarContext = Record; +export const tRef = tv.typeVarReference.create; +export const tVar = tv.typeVarDefine.create; diff --git a/blocksuite/affine/data-view/src/core/logical/type.ts b/blocksuite/affine/data-view/src/core/logical/type.ts new file mode 100644 index 0000000000..6f7bdc9643 --- /dev/null +++ b/blocksuite/affine/data-view/src/core/logical/type.ts @@ -0,0 +1,38 @@ +import type Zod from 'zod'; + +import type { TypeVarContext } from './type-variable.js'; + +export type AnyTypeInstance = { + readonly name: string; +}; + +export interface TypeDefinition { + is(typeInstance: AnyTypeInstance): boolean; +} + +export interface TypeInstance extends AnyTypeInstance { + readonly _valueType: any; + readonly _validate: Zod.ZodSchema; + + valueValidate(value: unknown): value is this['_valueType']; + + subst(ctx: TypeVarContext): TypeInstance | void; + + unify(ctx: TypeVarContext, type: TypeInstance, unify: Unify): boolean; +} + +export type ValueTypeOf = T extends TypeInstance ? T['_valueType'] : never; + +export type Unify = ( + ctx: TypeVarContext, + type: TypeInstance | undefined, + expect: TypeInstance | undefined +) => boolean; +export type TypeConvertConfig< + From extends TypeInstance = TypeInstance, + To extends TypeInstance = TypeInstance, +> = { + from: From; + to: To; + convert: (value: ValueTypeOf) => ValueTypeOf; +}; diff --git a/blocksuite/affine/data-view/src/core/property/base-cell.ts b/blocksuite/affine/data-view/src/core/property/base-cell.ts new file mode 100644 index 0000000000..054d8bbceb --- /dev/null +++ b/blocksuite/affine/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/blocksuite/affine/data-view/src/core/property/convert.ts b/blocksuite/affine/data-view/src/core/property/convert.ts new file mode 100644 index 0000000000..cc3945cd6b --- /dev/null +++ b/blocksuite/affine/data-view/src/core/property/convert.ts @@ -0,0 +1,30 @@ +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 = < + From extends PropertyModel, + To extends PropertyModel, +>( + from: From, + to: To, + convert: ConvertFunction +) => { + return { + from: from.type, + to: to.type, + convert, + }; +}; diff --git a/blocksuite/affine/data-view/src/core/property/index.ts b/blocksuite/affine/data-view/src/core/property/index.ts new file mode 100644 index 0000000000..5ca2e92683 --- /dev/null +++ b/blocksuite/affine/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/blocksuite/affine/data-view/src/core/property/manager.ts b/blocksuite/affine/data-view/src/core/property/manager.ts new file mode 100644 index 0000000000..1e27a5818f --- /dev/null +++ b/blocksuite/affine/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/blocksuite/affine/data-view/src/core/property/property-config.ts b/blocksuite/affine/data-view/src/core/property/property-config.ts new file mode 100644 index 0000000000..540cfaa7ff --- /dev/null +++ b/blocksuite/affine/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/blocksuite/affine/data-view/src/core/property/renderer.ts b/blocksuite/affine/data-view/src/core/property/renderer.ts new file mode 100644 index 0000000000..aa7ffd7980 --- /dev/null +++ b/blocksuite/affine/data-view/src/core/property/renderer.ts @@ -0,0 +1,24 @@ +import { + createUniComponentFromWebComponent, + type UniComponent, +} from '../utils/uni-component/index.js'; +import type { BaseCellRenderer } from './base-cell.js'; +import type { CellRenderer, DataViewCellComponent } from './manager.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/blocksuite/affine/data-view/src/core/property/types.ts b/blocksuite/affine/data-view/src/core/property/types.ts new file mode 100644 index 0000000000..8793187db9 --- /dev/null +++ b/blocksuite/affine/data-view/src/core/property/types.ts @@ -0,0 +1,98 @@ +import type { Disposable } from '@blocksuite/global/utils'; + +import type { DataSource } from '../data-source/base.js'; +import type { TypeInstance } from '../logical/type.js'; + +export type WithCommonPropertyConfig = T & { + dataSource: DataSource; +}; +export type GetPropertyDataFromConfig = + T extends PropertyConfig ? R : never; +export type GetCellDataFromConfig = + T extends PropertyConfig ? R : never; +export type PropertyConfig< + Data extends NonNullable = NonNullable, + Value = unknown, +> = { + name: string; + defaultData: () => Data; + type: ( + config: WithCommonPropertyConfig<{ + data: Data; + }> + ) => TypeInstance; + formatValue?: ( + config: WithCommonPropertyConfig<{ + value: Value; + data: Data; + }> + ) => Value; + isEmpty: ( + config: WithCommonPropertyConfig<{ + value?: Value; + }> + ) => boolean; + minWidth?: number; + values?: ( + config: WithCommonPropertyConfig<{ + value?: Value; + }> + ) => unknown[]; + cellToString: ( + config: WithCommonPropertyConfig<{ + value: Value; + data: Data; + }> + ) => string; + cellFromString: ( + config: WithCommonPropertyConfig<{ + value: string; + data: Data; + }> + ) => { + value: unknown; + data?: Record; + }; + cellToJson: ( + config: WithCommonPropertyConfig<{ + value: Value; + data: Data; + }> + ) => DVJSON; + cellFromJson: ( + config: WithCommonPropertyConfig<{ + value: DVJSON; + data: Data; + }> + ) => Value | undefined; + addGroup?: ( + config: WithCommonPropertyConfig<{ + text: string; + oldData: Data; + }> + ) => Data; + onUpdate?: ( + config: WithCommonPropertyConfig<{ + value: Value; + data: Data; + callback: () => void; + }> + ) => Disposable; + valueUpdate?: ( + config: WithCommonPropertyConfig<{ + value: Value; + data: Data; + newValue: Value; + }> + ) => Value; +}; + +export type DVJSON = + | null + | number + | string + | boolean + | DVJSON[] + | { + [k: string]: DVJSON; + }; diff --git a/blocksuite/affine/data-view/src/core/sort/add-sort.ts b/blocksuite/affine/data-view/src/core/sort/add-sort.ts new file mode 100644 index 0000000000..cf0d1cd321 --- /dev/null +++ b/blocksuite/affine/data-view/src/core/sort/add-sort.ts @@ -0,0 +1,53 @@ +import { + menu, + popMenu, + type PopupTarget, +} from '@blocksuite/affine-components/context-menu'; + +import { renderUniLit } from '../utils/index.js'; +import type { SortUtils } from './utils.js'; + +export const popCreateSort = ( + target: PopupTarget, + props: { + sortUtils: SortUtils; + onClose?: () => void; + onBack?: () => void; + } +) => { + popMenu(target, { + options: { + onClose: props.onClose, + title: { + text: 'New sort', + onBack: props.onBack, + }, + items: [ + menu.group({ + items: props.sortUtils.vars$.value + .filter( + v => + !props.sortUtils.sortList$.value.some( + sort => sort.ref.name === v.id + ) + ) + .map(v => + menu.action({ + name: v.name, + prefix: renderUniLit(v.icon, {}), + select: () => { + props.sortUtils.add({ + ref: { + type: 'ref', + name: v.id, + }, + desc: false, + }); + }, + }) + ), + }), + ], + }, + }); +}; diff --git a/blocksuite/affine/data-view/src/core/sort/eval.ts b/blocksuite/affine/data-view/src/core/sort/eval.ts new file mode 100644 index 0000000000..f5510b3b75 --- /dev/null +++ b/blocksuite/affine/data-view/src/core/sort/eval.ts @@ -0,0 +1,184 @@ +import type { VariableRef } from '../expression/types.js'; +import type { ArrayTypeInstance } from '../logical/composite-type.js'; +import type { DataTypeOf } from '../logical/data-type.js'; +import { t } from '../logical/index.js'; +import type { TypeInstance } from '../logical/type.js'; +import { typeSystem } from '../logical/type-system.js'; +import type { SingleView } from '../view-manager/index.js'; +import type { Sort } from './types.js'; + +export const Compare = { + GT: 'GT', + LT: 'LT', +} as const; +export type CompareType = keyof typeof Compare | number; +const evalRef = ( + view: SingleView, + ref: VariableRef +): + | ((row: string) => { + value: unknown; + ttype?: TypeInstance; + }) + | undefined => { + const ttype = view.propertyDataTypeGet(ref.name); + return row => ({ + value: view.cellJsonValueGet(row, ref.name), + ttype, + }); +}; +const compareList = ( + listA: T[], + listB: T[], + compare: (a: T, b: T) => CompareType +) => { + let i = 0; + while (i < listA.length && i < listB.length) { + const result = compare(listA[i], listB[i]); + if (result !== 0) { + return result; + } + i++; + } + return 0; +}; +const compareString = (a: unknown, b: unknown): CompareType => { + if (typeof a != 'string' || a === '') { + return Compare.GT; + } + if (typeof b != 'string' || b === '') { + return Compare.LT; + } + const listA = a.split('.'); + const listB = b.split('.'); + return compareList(listA, listB, (a, b) => { + const lowA = a.toLowerCase(); + const lowB = b.toLowerCase(); + const numberA = Number.parseInt(lowA); + const numberB = Number.parseInt(lowB); + const aIsNaN = Number.isNaN(numberA); + const bIsNaN = Number.isNaN(numberB); + if (aIsNaN && !bIsNaN) { + return 1; + } + if (!aIsNaN && bIsNaN) { + return -1; + } + if (!aIsNaN && !bIsNaN && numberA !== numberB) { + return numberA - numberB; + } + + return lowA.localeCompare(lowB); + }); +}; +const compareNumber = (a: unknown, b: unknown) => { + if (a == null) { + return Compare.GT; + } + if (b == null) { + return Compare.LT; + } + return Number(a) - Number(b); +}; +const compareBoolean = (a: unknown, b: unknown) => { + a = Boolean(a); + b = Boolean(b); + const bA = a ? 1 : 0; + const bB = b ? 1 : 0; + return bA - bB; +}; +const compareArray = (type: ArrayTypeInstance, a: unknown, b: unknown) => { + if (!Array.isArray(a)) { + return Compare.GT; + } + if (!Array.isArray(b)) { + return Compare.LT; + } + return compareList(a, b, (a, b) => { + return compare(type.element, a, b); + }); +}; +const compareAny = (a: unknown, b: unknown) => { + if (!a) { + return Compare.GT; + } + if (!b) { + return Compare.LT; + } + // @ts-expect-error FIXME: ts error + return a - b; +}; + +const compareTag = (type: DataTypeOf, a: unknown, b: unknown) => { + if (a == null) { + return Compare.GT; + } + if (b == null) { + return Compare.LT; + } + const indexA = type.data?.findIndex(tag => tag.id === a); + const indexB = type.data?.findIndex(tag => tag.id === b); + return compareNumber(indexA, indexB); +}; + +const compare = (type: TypeInstance, a: unknown, b: unknown): CompareType => { + if (typeSystem.unify(type, t.richText.instance())) { + return compareString(a?.toString(), b?.toString()); + } + if (typeSystem.unify(type, t.string.instance())) { + return compareString(a, b); + } + if (typeSystem.unify(type, t.number.instance())) { + return compareNumber(a, b); + } + if (typeSystem.unify(type, t.date.instance())) { + return compareNumber(a, b); + } + if (typeSystem.unify(type, t.boolean.instance())) { + return compareBoolean(a, b); + } + if (typeSystem.unify(type, t.tag.instance())) { + return compareTag(type, a, b); + } + if (t.array.is(type)) { + return compareArray(type, a, b); + } + return compareAny(a, b); +}; + +export const evalSort = ( + sort: Sort, + view: SingleView +): ((rowA: string, rowB: string) => number) | undefined => { + if (sort.sortBy.length) { + const sortBy = sort.sortBy.map(sort => { + return { + ref: evalRef(view, sort.ref), + desc: sort.desc, + }; + }); + return (rowA, rowB) => { + for (const sort of sortBy) { + const refA = sort.ref?.(rowA); + const refB = sort.ref?.(rowB); + const result = compare( + refA?.ttype ?? t.unknown.instance(), + refA?.value, + refB?.value + ); + if (typeof result === 'number' && result !== 0) { + return sort.desc ? -result : result; + } + if (result === Compare.GT) { + return 1; + } + if (result === Compare.LT) { + return -1; + } + continue; + } + return 0; + }; + } + return; +}; diff --git a/blocksuite/affine/data-view/src/core/sort/manager.ts b/blocksuite/affine/data-view/src/core/sort/manager.ts new file mode 100644 index 0000000000..976be11ce5 --- /dev/null +++ b/blocksuite/affine/data-view/src/core/sort/manager.ts @@ -0,0 +1,43 @@ +import { computed, type ReadonlySignal } from '@preact/signals-core'; + +import { createTraitKey } from '../traits/key.js'; +import type { SingleView } from '../view-manager/index.js'; +import { evalSort } from './eval.js'; +import type { Sort, SortBy } from './types.js'; + +export class SortManager { + hasSort$ = computed(() => (this.sort$.value?.sortBy?.length ?? 0) > 0); + + setSortList = (sortList: SortBy[]) => { + this.ops.setSortList({ + manuallySort: [], + ...this.sort$.value, + sortBy: sortList, + }); + }; + + sort = (rows: string[]) => { + if (!this.sort$.value) { + return rows; + } + const compare = evalSort(this.sort$.value, this.view); + if (!compare) { + return rows; + } + const newRows = rows.slice(); + newRows.sort(compare); + return newRows; + }; + + sortList$ = computed(() => this.sort$.value?.sortBy ?? []); + + constructor( + readonly sort$: ReadonlySignal, + readonly view: SingleView, + private ops: { + setSortList: (sortList: Sort) => void; + } + ) {} +} + +export const sortTraitKey = createTraitKey('sort'); diff --git a/blocksuite/affine/data-view/src/core/sort/types.ts b/blocksuite/affine/data-view/src/core/sort/types.ts new file mode 100644 index 0000000000..4c7c25a4f5 --- /dev/null +++ b/blocksuite/affine/data-view/src/core/sort/types.ts @@ -0,0 +1,10 @@ +import type { VariableRef } from '../expression/types.js'; + +export type SortBy = { + ref: VariableRef; + desc: boolean; +}; +export type Sort = { + sortBy: SortBy[]; + manuallySort: string[]; +}; diff --git a/blocksuite/affine/data-view/src/core/sort/utils.ts b/blocksuite/affine/data-view/src/core/sort/utils.ts new file mode 100644 index 0000000000..120163feef --- /dev/null +++ b/blocksuite/affine/data-view/src/core/sort/utils.ts @@ -0,0 +1,108 @@ +import type { + DatabaseAllViewEvents, + EventTraceFn, + SortParams, +} from '@blocksuite/affine-shared/services'; +import { computed, type ReadonlySignal } from '@preact/signals-core'; + +import type { Variable } from '../expression/index.js'; +import { arrayMove } from '../utils/wc-dnd/utils/array-move.js'; +import type { SortManager } from './manager.js'; +import type { SortBy } from './types.js'; + +export interface SortUtils { + sortList$: ReadonlySignal; + vars$: ReadonlySignal; + add: (sort: SortBy) => void; + move: (from: number, to: number) => void; + change: (index: number, sort: SortBy) => void; + remove: (index: number) => void; + removeAll: () => void; +} + +export const createSortUtils = ( + sortTrait: SortManager, + eventTrace: EventTraceFn +): SortUtils => { + const view = sortTrait.view; + const varsMap$ = computed(() => { + return new Map(view.vars$.value.map(v => [v.id, v])); + }); + const sortList$ = sortTrait.sortList$; + const sortParams = ( + sort?: SortBy, + index?: number + ): SortParams | undefined => { + if (!sort) { + return; + } + const v = varsMap$.value.get(sort.ref.name); + return { + fieldId: sort.ref.name, + fieldType: v?.propertyType ?? '', + orderType: sort.desc ? 'desc' : 'asc', + orderIndex: + index ?? sortList$.value.findIndex(v => v.ref.name === sort.ref.name), + }; + }; + return { + vars$: view.vars$, + sortList$: sortList$, + add: sort => { + const list = sortTrait.sortList$.value; + sortTrait.setSortList([...list, sort]); + const params = sortParams(sort, list.length); + if (params) { + eventTrace('DatabaseSortAdd', params); + } + }, + move: (fromIndex, toIndex) => { + const list = sortTrait.sortList$.value; + const from = sortParams(list[fromIndex], fromIndex); + const newList = arrayMove(list, fromIndex, toIndex); + sortTrait.setSortList(newList); + const prev = sortParams(newList[toIndex - 1], toIndex - 1); + const next = sortParams(newList[toIndex + 1], toIndex + 1); + if (from) { + eventTrace('DatabaseSortReorder', { + ...from, + prevFieldType: prev?.fieldType ?? '', + nextFieldType: next?.fieldType ?? '', + newOrderIndex: toIndex, + }); + } + }, + change: (index, sort) => { + const list = sortTrait.sortList$.value.slice(); + const old = sortParams(list[index], index); + list[index] = sort; + sortTrait.setSortList(list); + + const params = sortParams(sort, index); + if (params && old) { + eventTrace('DatabaseSortModify', { + ...params, + oldOrderType: old.orderType, + oldFieldType: old.fieldType, + oldFieldId: old.fieldId, + }); + } + }, + remove: index => { + const list = sortTrait.sortList$.value.slice(); + const old = sortParams(list[index], index); + list.splice(index, 1); + sortTrait.setSortList([...list]); + if (old) { + eventTrace('DatabaseSortRemove', old); + } + }, + removeAll: () => { + const count = sortTrait.sortList$.value.length; + sortTrait.setSortList([]); + eventTrace('DatabaseSortClear', { + rulesCount: count, + }); + }, + }; +}; diff --git a/blocksuite/affine/data-view/src/core/statistics/any.ts b/blocksuite/affine/data-view/src/core/statistics/any.ts new file mode 100644 index 0000000000..3f946e95a9 --- /dev/null +++ b/blocksuite/affine/data-view/src/core/statistics/any.ts @@ -0,0 +1,106 @@ +import { t } from '../logical/index.js'; +import { createStatisticConfig } from './create.js'; +import type { StatisticsConfig } from './types.js'; + +export const anyTypeStatsFunctions: StatisticsConfig[] = [ + createStatisticConfig({ + group: 'Count', + menuName: 'Count All', + displayName: 'All', + type: 'count-all', + dataType: t.unknown.instance(), + impl: data => { + return data.length.toString(); + }, + }), + createStatisticConfig({ + group: 'Count', + menuName: 'Count Values', + displayName: 'Values', + type: 'count-values', + dataType: t.unknown.instance(), + impl: (data, { meta, dataSource }) => { + const values = data + .flatMap(v => { + if (meta.config.values) { + return meta.config.values({ value: v, dataSource }); + } + return v; + }) + .filter(v => v != null); + return values.length.toString(); + }, + }), + createStatisticConfig({ + group: 'Count', + menuName: 'Count Unique Values', + displayName: 'Unique Values', + type: 'count-unique-values', + dataType: t.unknown.instance(), + impl: (data, { meta, dataSource }) => { + const values = data + .flatMap(v => { + if (meta.config.values) { + return meta.config.values({ value: v, dataSource }); + } + return v; + }) + .filter(v => v != null); + return new Set(values).size.toString(); + }, + }), + createStatisticConfig({ + group: 'Count', + menuName: 'Count Empty', + displayName: 'Empty', + type: 'count-empty', + dataType: t.unknown.instance(), + impl: (data, { meta, dataSource }) => { + const emptyList = data.filter(value => + meta.config.isEmpty({ value, dataSource }) + ); + return emptyList.length.toString(); + }, + }), + createStatisticConfig({ + group: 'Count', + menuName: 'Count Not Empty', + displayName: 'Not Empty', + type: 'count-not-empty', + dataType: t.unknown.instance(), + impl: (data, { meta, dataSource }) => { + const notEmptyList = data.filter( + value => !meta.config.isEmpty({ value, dataSource }) + ); + return notEmptyList.length.toString(); + }, + }), + createStatisticConfig({ + group: 'Percent', + menuName: 'Percent Empty', + displayName: 'Empty', + type: 'percent-empty', + dataType: t.unknown.instance(), + impl: (data, { meta, dataSource }) => { + if (data.length === 0) return ''; + const emptyList = data.filter(value => + meta.config.isEmpty({ value, dataSource }) + ); + return ((emptyList.length / data.length) * 100).toFixed(2) + '%'; + }, + }), + createStatisticConfig({ + group: 'Percent', + menuName: 'Percent Not Empty', + displayName: 'Not Empty', + type: 'percent-not-empty', + dataType: t.unknown.instance(), + impl: (data, { meta, dataSource }) => { + if (data.length === 0) return ''; + const notEmptyList = data.filter( + value => !meta.config.isEmpty({ value, dataSource }) + ); + return ((notEmptyList.length / data.length) * 100).toFixed(2) + '%'; + }, + }), +]; diff --git a/blocksuite/affine/data-view/src/core/statistics/checkbox.ts b/blocksuite/affine/data-view/src/core/statistics/checkbox.ts new file mode 100644 index 0000000000..c933fda79e --- /dev/null +++ b/blocksuite/affine/data-view/src/core/statistics/checkbox.ts @@ -0,0 +1,62 @@ +import { t } from '../logical/index.js'; +import { createStatisticConfig } from './create.js'; +import type { StatisticsConfig } from './types.js'; + +export const checkboxTypeStatsFunctions: StatisticsConfig[] = [ + createStatisticConfig({ + group: 'Count', + type: 'count-values', + dataType: t.boolean.instance(), + }), + createStatisticConfig({ + group: 'Count', + type: 'count-unique-values', + dataType: t.boolean.instance(), + }), + createStatisticConfig({ + group: 'Count', + type: 'count-empty', + dataType: t.boolean.instance(), + menuName: 'Count Unchecked', + displayName: 'Unchecked', + impl: data => { + const emptyList = data.filter(value => !value); + return emptyList.length.toString(); + }, + }), + createStatisticConfig({ + group: 'Count', + type: 'count-not-empty', + dataType: t.boolean.instance(), + menuName: 'Count Checked', + displayName: 'Checked', + impl: data => { + const notEmptyList = data.filter(value => !!value); + return notEmptyList.length.toString(); + }, + }), + createStatisticConfig({ + group: 'Percent', + type: 'percent-empty', + dataType: t.boolean.instance(), + menuName: 'Percent Unchecked', + displayName: 'Unchecked', + impl: data => { + if (data.length === 0) return ''; + const emptyList = data.filter(value => !value); + return ((emptyList.length / data.length) * 100).toFixed(2) + '%'; + }, + }), + createStatisticConfig({ + group: 'Percent', + type: 'percent-not-empty', + dataType: t.boolean.instance(), + menuName: 'Percent Checked', + displayName: 'Checked', + impl: data => { + if (data.length === 0) return ''; + const notEmptyList = data.filter(value => !!value); + return ((notEmptyList.length / data.length) * 100).toFixed(2) + '%'; + }, + }), +]; diff --git a/blocksuite/affine/data-view/src/core/statistics/create.ts b/blocksuite/affine/data-view/src/core/statistics/create.ts new file mode 100644 index 0000000000..5a2f66a51e --- /dev/null +++ b/blocksuite/affine/data-view/src/core/statistics/create.ts @@ -0,0 +1,8 @@ +import type { TypeInstance } from '../logical/index.js'; +import type { StatisticsConfig } from './types.js'; + +export const createStatisticConfig = ( + config: StatisticsConfig +) => { + return config; +}; diff --git a/blocksuite/affine/data-view/src/core/statistics/index.ts b/blocksuite/affine/data-view/src/core/statistics/index.ts new file mode 100644 index 0000000000..800f159e45 --- /dev/null +++ b/blocksuite/affine/data-view/src/core/statistics/index.ts @@ -0,0 +1,10 @@ +import { anyTypeStatsFunctions } from './any.js'; +import { checkboxTypeStatsFunctions } from './checkbox.js'; +import { numberStatsFunctions } from './number.js'; +import type { StatisticsConfig } from './types.js'; + +export const statsFunctions: StatisticsConfig[] = [ + ...anyTypeStatsFunctions, + ...numberStatsFunctions, + ...checkboxTypeStatsFunctions, +]; diff --git a/blocksuite/affine/data-view/src/core/statistics/number.ts b/blocksuite/affine/data-view/src/core/statistics/number.ts new file mode 100644 index 0000000000..26662f6cd4 --- /dev/null +++ b/blocksuite/affine/data-view/src/core/statistics/number.ts @@ -0,0 +1,125 @@ +import { t } from '../logical/index.js'; +import { createStatisticConfig } from './create.js'; +import type { StatisticsConfig } from './types.js'; + +export const numberStatsFunctions: StatisticsConfig[] = [ + createStatisticConfig({ + group: 'More options', + menuName: 'Sum', + type: 'sum', + displayName: 'Sum', + dataType: t.number.instance(), + impl: data => { + const numbers = withoutNull(data); + if (numbers.length === 0) { + return 'None'; + } + return parseFloat( + numbers.reduce((a, b) => a + b, 0).toFixed(2) + ).toString(); + }, + }), + createStatisticConfig({ + group: 'More options', + menuName: 'Average', + displayName: 'Average', + type: 'average', + dataType: t.number.instance(), + impl: data => { + const numbers = withoutNull(data); + if (numbers.length === 0) { + return 'None'; + } + return (numbers.reduce((a, b) => a + b, 0) / numbers.length).toString(); + }, + }), + createStatisticConfig({ + group: 'More options', + menuName: 'Median', + displayName: 'Median', + type: 'median', + dataType: t.number.instance(), + impl: data => { + const arr = withoutNull(data).sort((a, b) => a - b); + let result = 0; + if (arr.length % 2 === 1) { + result = arr[(arr.length - 1) / 2]; + } else { + const index = arr.length / 2; + result = (arr[index] + arr[index - 1]) / 2; + } + return result?.toString() ?? 'None'; + }, + }), + createStatisticConfig({ + group: 'More options', + menuName: 'Min', + displayName: 'Min', + type: 'min', + dataType: t.number.instance(), + impl: data => { + let min: number | null = null; + for (const num of data) { + if (num != null) { + if (min == null) { + min = num; + } else { + min = Math.min(min, num); + } + } + } + return min?.toString() ?? 'None'; + }, + }), + createStatisticConfig({ + group: 'More options', + menuName: 'Max', + displayName: 'Max', + type: 'max', + dataType: t.number.instance(), + impl: data => { + let max: number | null = null; + for (const num of data) { + if (num != null) { + if (max == null) { + max = num; + } else { + max = Math.max(max, num); + } + } + } + return max?.toString() ?? 'None'; + }, + }), + createStatisticConfig({ + group: 'More options', + menuName: 'Range', + displayName: 'Range', + type: 'range', + dataType: t.number.instance(), + impl: data => { + let min: number | null = null; + let max: number | null = null; + for (const num of data) { + if (num != null) { + if (max == null) { + max = num; + } else { + max = Math.max(max, num); + } + if (min == null) { + min = num; + } else { + min = Math.min(min, num); + } + } + } + if (min == null || max == null) { + return 'None'; + } + return (max - min).toString(); + }, + }), +]; +const withoutNull = (arr: readonly (number | null | undefined)[]): number[] => + arr.filter(v => v != null); diff --git a/blocksuite/affine/data-view/src/core/statistics/types.ts b/blocksuite/affine/data-view/src/core/statistics/types.ts new file mode 100644 index 0000000000..8ac0187621 --- /dev/null +++ b/blocksuite/affine/data-view/src/core/statistics/types.ts @@ -0,0 +1,18 @@ +import type { DataSource } from '../data-source/index.js'; +import type { TypeInstance, ValueTypeOf } from '../logical/type.js'; +import type { PropertyMetaConfig } from '../property/property-config.js'; + +export type StatisticsConfig = { + group: string; + type: string; + dataType: T; + menuName?: string; + displayName?: string; + impl?: ( + data: ReadonlyArray | undefined>, + info: { + meta: PropertyMetaConfig; + dataSource: DataSource; + } + ) => string; +}; diff --git a/blocksuite/affine/data-view/src/core/traits/key.ts b/blocksuite/affine/data-view/src/core/traits/key.ts new file mode 100644 index 0000000000..6b056a6227 --- /dev/null +++ b/blocksuite/affine/data-view/src/core/traits/key.ts @@ -0,0 +1,10 @@ +export interface TraitKey { + key: symbol; + __type?: T; +} + +export function createTraitKey(name: string): TraitKey { + return { + key: Symbol(name), + }; +} diff --git a/blocksuite/affine/data-view/src/core/types.ts b/blocksuite/affine/data-view/src/core/types.ts new file mode 100644 index 0000000000..033dd04e7f --- /dev/null +++ b/blocksuite/affine/data-view/src/core/types.ts @@ -0,0 +1,26 @@ +import type { KanbanViewSelectionWithType } from '../view-presets/kanban/types.js'; +import type { TableViewSelectionWithType } from '../view-presets/table/types.js'; + +export type DataViewSelection = + | TableViewSelectionWithType + | KanbanViewSelectionWithType; +export type GetDataViewSelection< + K extends DataViewSelection['type'], + T = DataViewSelection, +> = T extends { + type: K; +} + ? T + : never; +export type DataViewSelectionState = DataViewSelection | undefined; +export type PropertyDataUpdater< + Data extends Record = Record, +> = (data: Data) => Partial; + +export interface DatabaseFlags { + enable_number_formatting: boolean; +} + +export const defaultDatabaseFlags: Readonly = { + enable_number_formatting: false, +}; diff --git a/blocksuite/affine/data-view/src/core/utils/auto-scroll.ts b/blocksuite/affine/data-view/src/core/utils/auto-scroll.ts new file mode 100644 index 0000000000..f02dc1abf1 --- /dev/null +++ b/blocksuite/affine/data-view/src/core/utils/auto-scroll.ts @@ -0,0 +1,78 @@ +import { effect, type ReadonlySignal } from '@preact/signals-core'; + +const timeWeight = 1 / 16; +const distanceWeight = 1 / 8; + +export const autoScrollOnBoundary = ( + container: HTMLElement, + box: ReadonlySignal<{ + left: number; + right: number; + top: number; + bottom: number; + }>, + ops?: { + onScroll?: () => void; + } +) => { + let updateTask = 0; + const startUpdate = () => { + if (updateTask) { + return; + } + const update = (preTime: number) => { + const now = Date.now(); + const delta = now - preTime; + updateTask = 0; + const { left, right, top, bottom } = box.value; + const rect = container.getBoundingClientRect(); + const getResult = (diff: number) => + (diff * distanceWeight + 1) * delta * timeWeight; + let move = false; + if (left < rect.left) { + const diff = getResult(rect.left - left); + container.scrollLeft -= diff; + if (diff !== 0) { + move = true; + } + } + if (right > rect.right) { + const diff = getResult(right - rect.right); + container.scrollLeft += diff; + if (diff !== 0) { + move = true; + } + } + if (top < rect.top) { + const diff = getResult(rect.top - top); + container.scrollTop -= diff; + if (diff !== 0) { + move = true; + } + } + if (bottom > rect.bottom) { + const diff = getResult(bottom - rect.bottom); + container.scrollTop += diff; + if (diff !== 0) { + move = true; + } + } + if (move) { + ops?.onScroll?.(); + updateTask = requestAnimationFrame(() => update(now)); + } + }; + const now = Date.now(); + updateTask = requestAnimationFrame(() => update(now)); + }; + + const cancelBoxListen = effect(() => { + box.value; + startUpdate(); + }); + + return () => { + cancelBoxListen(); + cancelAnimationFrame(updateTask); + }; +}; diff --git a/blocksuite/affine/data-view/src/core/utils/drag.ts b/blocksuite/affine/data-view/src/core/utils/drag.ts new file mode 100644 index 0000000000..b5443c1aaf --- /dev/null +++ b/blocksuite/affine/data-view/src/core/utils/drag.ts @@ -0,0 +1,69 @@ +import { signal } from '@preact/signals-core'; + +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; + cursor?: string; + } +) => { + const oldCursor = document.body.style.cursor; + document.body.style.cursor = ops.cursor ?? 'grab'; + const mousePosition = signal<{ x: number; y: number }>({ + x: evt.clientX, + y: evt.clientY, + }); + const transform = ops?.transform ?? (e => e as P); + const param = transform(evt); + const result = { + data: ops.onDrag(param), + last: param, + mousePosition, + move: (p: P) => { + result.data = ops.onMove(p); + }, + }; + const clear = () => { + window.removeEventListener('pointermove', move); + window.removeEventListener('pointerup', up); + window.removeEventListener('keydown', keydown); + document.body.style.cursor = oldCursor; + ops.onClear(); + }; + const keydown = (evt: KeyboardEvent) => { + if (evt.key === 'Escape') { + clear(); + } + }; + const move = (evt: PointerEvent) => { + evt.preventDefault(); + mousePosition.value = { + x: evt.clientX, + y: evt.clientY, + }; + 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/blocksuite/affine/data-view/src/core/utils/event.ts b/blocksuite/affine/data-view/src/core/utils/event.ts new file mode 100644 index 0000000000..ab7001f337 --- /dev/null +++ b/blocksuite/affine/data-view/src/core/utils/event.ts @@ -0,0 +1,3 @@ +export function stopPropagation(event: Event) { + event.stopPropagation(); +} diff --git a/blocksuite/affine/data-view/src/core/utils/index.ts b/blocksuite/affine/data-view/src/core/utils/index.ts new file mode 100644 index 0000000000..09090f1e8c --- /dev/null +++ b/blocksuite/affine/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/blocksuite/affine/data-view/src/core/utils/menu-title.ts b/blocksuite/affine/data-view/src/core/utils/menu-title.ts new file mode 100644 index 0000000000..10c1187237 --- /dev/null +++ b/blocksuite/affine/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/blocksuite/affine/data-view/src/core/utils/uni-component/index.ts b/blocksuite/affine/data-view/src/core/utils/uni-component/index.ts new file mode 100644 index 0000000000..f035644582 --- /dev/null +++ b/blocksuite/affine/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/blocksuite/affine/data-view/src/core/utils/uni-component/operation.ts b/blocksuite/affine/data-view/src/core/utils/uni-component/operation.ts new file mode 100644 index 0000000000..559ba1fbdb --- /dev/null +++ b/blocksuite/affine/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/blocksuite/affine/data-view/src/core/utils/uni-component/render-template.ts b/blocksuite/affine/data-view/src/core/utils/uni-component/render-template.ts new file mode 100644 index 0000000000..32e42bb103 --- /dev/null +++ b/blocksuite/affine/data-view/src/core/utils/uni-component/render-template.ts @@ -0,0 +1,24 @@ +import { ShadowlessElement } from '@blocksuite/block-std'; +import { SignalWatcher } from '@blocksuite/global/utils'; +import type { TemplateResult } from 'lit'; +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/blocksuite/affine/data-view/src/core/utils/uni-component/uni-component.ts b/blocksuite/affine/data-view/src/core/utils/uni-component/uni-component.ts new file mode 100644 index 0000000000..284f635e46 --- /dev/null +++ b/blocksuite/affine/data-view/src/core/utils/uni-component/uni-component.ts @@ -0,0 +1,160 @@ +import { ShadowlessElement } from '@blocksuite/block-std'; +import { SignalWatcher } from '@blocksuite/global/utils'; +import type { LitElement, PropertyValues, TemplateResult } from 'lit'; +import { css, html } from 'lit'; +import { property } from 'lit/decorators.js'; +import type { Ref } from 'lit/directives/ref.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` + 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 FIXME: ts 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/blocksuite/affine/data-view/src/core/utils/uni-icon.ts b/blocksuite/affine/data-view/src/core/utils/uni-icon.ts new file mode 100644 index 0000000000..6ae0802d63 --- /dev/null +++ b/blocksuite/affine/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-lit-icon { + display: flex; + align-items: center; + justify-content: center; + } + + affine-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/blocksuite/affine/data-view/src/core/utils/utils.ts b/blocksuite/affine/data-view/src/core/utils/utils.ts new file mode 100644 index 0000000000..010d5447f2 --- /dev/null +++ b/blocksuite/affine/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/blocksuite/affine/data-view/src/core/utils/wc-dnd/dnd-context.ts b/blocksuite/affine/data-view/src/core/utils/wc-dnd/dnd-context.ts new file mode 100644 index 0000000000..0d761c801d --- /dev/null +++ b/blocksuite/affine/data-view/src/core/utils/wc-dnd/dnd-context.ts @@ -0,0 +1,549 @@ +import { computed, effect, signal } from '@preact/signals-core'; + +import type { + Activators, + Active, + CollisionDetection, + Coordinates, + DndClientRect, + DndSession, + DndSessionCreator, + DragCancelEvent, + DragEndEvent, + DragMoveEvent, + DragOverEvent, + DragStartEvent, + DroppableNodes, + Modifiers, + Over, + UniqueIdentifier, +} from './types.js'; +import { add } from './utils/adjustment.js'; +import { applyModifiers } from './utils/apply-modifiers.js'; +import { closestCenter } from './utils/closest-center.js'; +import { createDataDirective } from './utils/data-directive.js'; +import { asHTMLElement } from './utils/element.js'; +import { getFirstScrollableAncestor } from './utils/get-scrollable-ancestors.js'; +import { raf } from './utils/raf.js'; +import { getClientRect } from './utils/rect.js'; +import { getAdjustedRect } from './utils/rect-adjustment.js'; +import { computedCache } from './utils/signal.js'; + +export interface OverlayData { + overlay: HTMLElement; + cleanup?: () => void; +} + +export type DndContextConfig = { + container: HTMLElement; + collisionDetection?: CollisionDetection; + modifiers?: Modifiers; + activators: Activators; + onDragStart?(event: DragStartEvent): void; + onDragMove?(event: DragMoveEvent): void; + onDragOver?(event: DragOverEvent): void; + onDragEnd?(event: DragEndEvent): void; + onDragCancel?(event: DragCancelEvent): void; + createOverlay?: (active: Active) => OverlayData | undefined; +}; +const timeWeight = 1 / 16; +const distanceWeight = 2 / 8; +const moveDistance = (diff: number, delta: number) => + (diff * distanceWeight + (diff / Math.abs(diff)) * 2) * delta * timeWeight; +const defaultCoordinates: Coordinates = { + x: 0, + y: 0, +}; + +export class DndContext { + private dragMove = (coordinates: Coordinates) => { + this.activationCoordinates$.value = coordinates; + this.autoScroll(); + }; + + private droppableNodes$ = signal(new Map()); + + private initialCoordinates$ = signal(); + + private initScrollOffset$ = signal(defaultCoordinates); + + private session$ = signal(); + + private startSession = ( + id: UniqueIdentifier, + activeNode: HTMLElement, + sessionCreator: DndSessionCreator + ) => { + this.collectDroppableNodes(); + this.session$.value = sessionCreator({ + onStart: coordinates => { + const { onDragStart } = this.config; + const active = { + id, + node: activeNode, + rect: getClientRect(activeNode), + }; + onDragStart?.({ + active: active, + }); + this.dragStart(active, coordinates); + }, + onCancel: this.dragComplete(true), + onEnd: this.dragComplete(), + onMove: this.dragMove, + }); + }; + + activationCoordinates$ = signal(); + + private translate$ = computed(() => { + const init = this.initialCoordinates$.value; + const current = this.activationCoordinates$.value; + if (!init || !current) { + return defaultCoordinates; + } + return { + x: current.x - init.x, + y: current.y - init.y, + }; + }); + + active$ = signal(); + + initActiveRect$ = signal(); + + activeNodeRectDelta$ = computed(() => { + const initCoord = this.initialCoordinates$.value; + const initNodeRect = this.initActiveRect$.value; + if (!initNodeRect || !initCoord) { + return defaultCoordinates; + } + return { + x: initCoord.x - initNodeRect.left, + y: initCoord.y - initNodeRect.top, + }; + }); + + collisionRect$ = computed(() => { + return this.active$.value?.rect + ? getAdjustedRect(this.active$.value.rect, this.appliedTranslate$.value) + : undefined; + }); + + enabledDroppableContainers$ = computed(() => { + return [...this.droppableNodes$.value.values()].filter( + node => !node.disabled + ); + }); + + droppableRects$ = computed(() => { + const map = new Map(); + this.enabledDroppableContainers$.value.forEach(container => { + const element = container.node; + if (element) { + map.set(container.id, container.rect); + } + }); + return map; + }); + + collisions$ = computed(() => { + return this.active$.value && this.collisionRect$.value + ? this.collisionDetection({ + active: this.active$.value, + collisionRect: this.collisionRect$.value, + droppableRects: this.droppableRects$.value, + droppableContainers: this.enabledDroppableContainers$.value, + pointerCoordinates: this.activationCoordinates$.value, + }) + : undefined; + }); + + overId$ = computed(() => { + return this.collisions$.value?.[0]?.id; + }); + + over$ = computedCache(() => { + const active = this.active$.value; + if (!active) { + return; + } + const id = this.overId$.value; + const overContainer = this.getDroppableNode(id); + return overContainer && overContainer.rect + ? { + id: overContainer.id, + rect: overContainer.rect, + disabled: overContainer.disabled, + } + : undefined; + }); + + overlay$ = signal<{ + node: HTMLElement; + rect: DndClientRect; + }>(); + + scrollableAncestor$ = computed(() => { + if (!this.active$.value) { + return; + } + const scrollableAncestor = getFirstScrollableAncestor( + this.active$.value.node + ); + if (!scrollableAncestor) { + return; + } + return { + node: scrollableAncestor, + rect: getClientRect(scrollableAncestor), + max: { + x: scrollableAncestor.scrollWidth - scrollableAncestor.clientWidth, + y: scrollableAncestor.scrollHeight - scrollableAncestor.clientHeight, + }, + }; + }); + + modifiedTranslate$ = computed(() => { + if (!this.active$.value) { + return defaultCoordinates; + } + return applyModifiers(this.config.modifiers, { + transform: { + x: this.translate$.value.x - this.activeNodeRectDelta$.value.x, + y: this.translate$.value.y - this.activeNodeRectDelta$.value.y, + scaleX: 1, + scaleY: 1, + }, + active: this.active$.value, + activeNodeRect: this.active$.value.rect, + over: this.over$.preValue, + scrollContainerRect: this.scrollableAncestor$.value?.rect, + overlayNodeRect: this.overlay$.value?.rect, + }); + }); + + scrollOffset$ = signal(defaultCoordinates); + + appliedTranslate$ = computed(() => { + return add(this.modifiedTranslate$.value, this.scrollOffset$.value); + }); + + disposables: Array<() => void> = []; + + dragEndCleanupQueue: Array<() => void> = []; + + scale$ = signal<{ + x: number; + y: number; + }>({ x: 1, y: 1 }); + + scrollAdjustedTranslate$ = computed(() => { + const translate = this.translate$.value; + const scrollOffset = this.scrollOffset$.value; + return add(translate, scrollOffset); + }); + + transform$ = computed(() => { + return this.appliedTranslate$.value; + }); + + get activators() { + return this.config.activators; + } + + get collisionDetection() { + return this.config.collisionDetection ?? closestCenter; + } + + get container() { + return this.config.container; + } + + constructor(protected config: DndContextConfig) { + this.listenActivators(); + this.listenMoveEvent(); + this.listenOverEvent(); + } + + private addActiveClass(node: HTMLElement) { + const hasClass = node.classList.contains('dnd-active'); + if (hasClass) { + return; + } + node.classList.add('dnd-active'); + this.dragEndCleanupQueue.push(() => { + node.classList.remove('dnd-active'); + }); + } + + private addTransition(node: HTMLElement) { + const old = node.style.transition; + node.style.transition = 'transform 0.2s'; + this.dragEndCleanupQueue.push(() => { + node.style.transition = old; + }); + } + + private autoScroll() { + const currentOverlayRect = this.overlay$.value + ? getClientRect(this.overlay$.value.node) + : { + top: this.activationCoordinates$.value?.y ?? 0, + left: this.activationCoordinates$.value?.x ?? 0, + width: 0, + height: 0, + bottom: this.activationCoordinates$.value?.y ?? 0, + right: this.activationCoordinates$.value?.x ?? 0, + }; + const scrollableAncestor = this.scrollableAncestor$.value; + if (!scrollableAncestor) { + return; + } + const { node, rect, max } = scrollableAncestor; + let topDiff = 0; + let leftDiff = 0; + if (currentOverlayRect.top < rect.top) { + topDiff = currentOverlayRect.top - rect.top; + } + if (currentOverlayRect.left < rect.left) { + leftDiff = currentOverlayRect.left - rect.left; + } + if (currentOverlayRect.bottom > rect.bottom) { + topDiff = currentOverlayRect.bottom - rect.bottom; + } + if (currentOverlayRect.right > rect.right) { + leftDiff = currentOverlayRect.right - rect.right; + } + if (topDiff || leftDiff) { + const run = (delta: number) => { + if (leftDiff) { + const newScrollLeft = node.scrollLeft + moveDistance(leftDiff, delta); + if (newScrollLeft < 0) { + node.scrollLeft = 0; + } else if (newScrollLeft > max.x) { + node.scrollLeft = max.x; + } else { + node.scrollLeft = newScrollLeft; + } + } + if (topDiff) { + const newScrollTop = node.scrollTop + moveDistance(topDiff, delta); + if (newScrollTop < 0) { + node.scrollTop = 0; + } else if (newScrollTop > max.y) { + node.scrollTop = max.y; + } else { + node.scrollTop = newScrollTop; + } + } + this.onScroll(node.scrollLeft, node.scrollTop); + raf(run); + }; + raf(run); + } else { + raf(); + } + } + + private collectDroppableNodes() { + const map: DroppableNodes = new Map(); + const droppableNodes = this.container.querySelectorAll( + `[${droppableDataName.attribute}]` + ); + droppableNodes.forEach(node => { + const ele = asHTMLElement(node); + const id = ele?.dataset[droppableDataName.dataset]; + if (id) { + map.set(id, { + id, + disabled: false, + node: ele, + rect: getClientRect(ele), + }); + } + }); + this.droppableNodes$.value = map; + } + + private createOverlay(active: Active) { + const overlay = this.config.createOverlay?.(active); + if (!overlay) { + return; + } + this.overlay$.value = { + node: overlay.overlay, + rect: getClientRect(overlay.overlay), + }; + this.dragEndCleanupQueue.push(() => { + overlay.cleanup?.(); + }); + } + + private dragComplete(cancel: boolean = false) { + return () => { + let event: DragEndEvent | null = null; + const active = this.active$.peek(); + if (active && this.modifiedTranslate$.value) { + event = { + active: active, + collisions: this.collisions$.peek(), + delta: this.modifiedTranslate$.peek(), + over: this.over$.peek(), + }; + } + this.dragEndCleanup(); + if (event) { + this.config[cancel ? 'onDragCancel' : 'onDragEnd']?.(event); + } + }; + } + + private dragEndCleanup() { + this.active$.value?.node.classList?.remove('dnd-active'); + this.activationCoordinates$.value = undefined; + this.active$.value = undefined; + this.session$.value = undefined; + raf(); + this.dragEndCleanupQueue.forEach(f => f()); + } + + private dragStart(active: Active, coordinates: Coordinates) { + this.active$.value = active; + this.initialCoordinates$.value = coordinates; + this.scale$.value = { + x: active.rect.width / active.node.offsetWidth, + y: active.rect.height / active.node.offsetHeight, + }; + this.createOverlay(active); + this.listenScroll(); + this.addActiveClass(active.node); + this.setPointerEvents(this.config.container); + this.droppableNodes$.value.forEach(v => { + this.addTransition(v.node); + }); + } + + private getDroppableNode(id: Identifier) { + if (id == null) { + return; + } + return this.droppableNodes$.value.get(id); + } + + private listenActivators() { + const unsubList = this.activators.map(activator => { + return activator(this.container, this.startSession); + }); + this.disposables.push(() => { + unsubList.forEach(unsub => { + unsub(); + }); + }); + } + + private listenMoveEvent() { + this.disposables.push( + effect(() => { + const active = this.active$.value; + if (!active) { + return; + } + const translate = this.modifiedTranslate$.value; + this.config.onDragMove?.({ + active, + collisions: this.collisions$.value, + delta: { + x: translate.x, + y: translate.y, + }, + over: this.over$.value, + }); + if (this.overlay$.value) { + const transform = this.transform$.value; + const scale = this.scale$.value; + this.overlay$.value.node.style.transform = `translate(${transform.x / scale.x}px,${transform.y / scale.y}px)`; + } + }) + ); + } + + private listenOverEvent() { + this.disposables.push( + effect(() => { + if (!this.active$.value) { + return; + } + this.config.onDragOver?.({ + active: this.active$.value, + collisions: this.collisions$.peek(), + delta: { + x: this.modifiedTranslate$.peek().x, + y: this.modifiedTranslate$.peek().y, + }, + over: this.over$.value, + }); + }) + ); + } + + private listenScroll() { + const scrollAncestor = this.scrollableAncestor$.value?.node; + if (!scrollAncestor) { + return; + } + this.initScrollOffset$.value = { + x: scrollAncestor.scrollLeft, + y: scrollAncestor.scrollTop, + }; + this.scrollOffset$.value = defaultCoordinates; + const onScroll = () => { + this.onScroll(scrollAncestor.scrollLeft, scrollAncestor.scrollTop); + }; + scrollAncestor.addEventListener('scroll', onScroll); + this.dragEndCleanupQueue.push(() => { + scrollAncestor.removeEventListener('scroll', onScroll); + }); + } + + private onScroll(x: number, y: number) { + this.scrollOffset$.value = { + x: (x - this.initScrollOffset$.value.x) * this.scale$.value.x, + y: (y - this.initScrollOffset$.value.y) * this.scale$.value.y, + }; + } + + private setPointerEvents(container: HTMLElement) { + const old = container.style.pointerEvents; + container.style.pointerEvents = 'none'; + this.dragEndCleanupQueue.push(() => { + container.style.pointerEvents = old; + }); + } +} + +type Identifier = UniqueIdentifier | null | undefined; + +export const createDndContext = (config: DndContextConfig) => { + return new DndContext(config); +}; +export const draggableDataName = { + dataset: 'wcDndDraggableId', + attribute: 'data-wc-dnd-draggable-id', +}; + +export const draggable = createDataDirective(draggableDataName); + +export const dragHandlerDataName = { + dataset: 'wcDndDragHandlerId', + attribute: 'data-wc-dnd-drag-handler-id', +}; + +export const dragHandler = createDataDirective(dragHandlerDataName); + +export const droppableDataName = { + dataset: 'wcDndDroppableId', + attribute: 'data-wc-dnd-droppable-id', +}; + +export const droppable = createDataDirective(droppableDataName); diff --git a/blocksuite/affine/data-view/src/core/utils/wc-dnd/sensors/index.ts b/blocksuite/affine/data-view/src/core/utils/wc-dnd/sensors/index.ts new file mode 100644 index 0000000000..380fb7de0e --- /dev/null +++ b/blocksuite/affine/data-view/src/core/utils/wc-dnd/sensors/index.ts @@ -0,0 +1,5 @@ +import { mouseSensor } from './mouse.js'; + +export const defaultActivators = [ + mouseSensor({ activationConstraint: { distance: 6 } }), +]; diff --git a/blocksuite/affine/data-view/src/core/utils/wc-dnd/sensors/mouse.ts b/blocksuite/affine/data-view/src/core/utils/wc-dnd/sensors/mouse.ts new file mode 100644 index 0000000000..c34837de79 --- /dev/null +++ b/blocksuite/affine/data-view/src/core/utils/wc-dnd/sensors/mouse.ts @@ -0,0 +1,227 @@ +import { stopPropagation } from '../../event.js'; +import { dragHandlerDataName } from '../dnd-context.js'; +import type { + Coordinates, + DistanceMeasurement, + DndSession, + DndSessionProps, + Sensor, +} from '../types.js'; +import { subtract } from '../utils/adjustment.js'; +import { asHTMLElement } from '../utils/element.js'; +import { preventDefault } from '../utils/events.js'; +import { hasExceededDistance } from '../utils/has-exceeded-distance.js'; +import { Listeners } from '../utils/listeners.js'; + +interface DistanceConstraint { + distance: DistanceMeasurement; +} + +export type PointerActivationConstraint = DistanceConstraint; + +export type MouseSensorProps = { + activationConstraint?: PointerActivationConstraint; +}; +const findActivatorElement = (target: unknown) => { + let ele; + if (target instanceof HTMLElement) { + ele = target; + } else if (target instanceof Node) { + ele = target.parentElement; + } else { + return; + } + while (ele) { + const dndDraggableId = ele.dataset[dragHandlerDataName.dataset]; + if (dndDraggableId) { + const activeNode = asHTMLElement( + ele.closest('[data-wc-dnd-draggable-id]') + ); + if (activeNode) { + return { dndDraggableId, ele: activeNode }; + } + } + ele = ele.parentElement; + } + return; +}; +export const mouseSensor: Sensor = props => { + return (container, startSession) => { + const mousedown = (event: Event) => { + const result = findActivatorElement(event.target); + if (result) { + startSession( + result.dndDraggableId, + result.ele, + sessionProps => new MouseSession(event, sessionProps, props) + ); + } + }; + container.addEventListener('pointerdown', mousedown); + return () => { + container.removeEventListener('pointerdown', mousedown); + }; + }; +}; +const defaultCoordinates: Coordinates = { + x: 0, + y: 0, +}; + +export function hasViewportRelativeCoordinates( + event: Event +): event is Event & Pick { + return 'clientX' in event && 'clientY' in event; +} + +const getEventCoordinates = (event: Event) => { + if (event instanceof TouchEvent) { + if (event.touches && event.touches.length) { + const { clientX: x, clientY: y } = event.touches[0]; + + return { + x, + y, + }; + } else if (event.changedTouches && event.changedTouches.length) { + const { clientX: x, clientY: y } = event.changedTouches[0]; + + return { + x, + y, + }; + } + } + + if (hasViewportRelativeCoordinates(event)) { + return { + x: event.clientX, + y: event.clientY, + }; + } + + return null; +}; + +export class MouseSession implements DndSession { + private activated: boolean = false; + + autoScrollEnabled = true; + + documentListeners = new Listeners(document); + + handleCancel = () => { + const { onCancel } = this.sessionProps; + + this.detach(); + onCancel(); + }; + + handleEnd = () => { + const { onEnd } = this.sessionProps; + + this.detach(); + onEnd(); + }; + + handleKeydown = (event: KeyboardEvent) => { + if (event.key === 'Escape') { + this.handleCancel(); + } + }; + + handleMove = (event: Event) => { + const { activated, initialCoordinates } = this; + const { activationConstraint } = this.props; + const { onMove } = this.sessionProps; + + if (!initialCoordinates) { + return; + } + + const coordinates = getEventCoordinates(event) ?? defaultCoordinates; + + // Constraint validation + if (!activated && activationConstraint) { + const delta = subtract(initialCoordinates, coordinates); + if ( + activationConstraint.distance && + hasExceededDistance(delta, activationConstraint.distance) + ) { + return this.handleStart(); + } + return; + } + + if (event.cancelable) { + event.preventDefault(); + } + event.stopPropagation(); + onMove(coordinates); + }; + + handleStart = () => { + const { initialCoordinates } = this; + const { onStart } = this.sessionProps; + + if (initialCoordinates) { + this.activated = true; + + // Stop propagation of click events once activation constraints are met + this.documentListeners.add('click', stopPropagation, { + capture: true, + }); + + // Remove any text selection from the document + this.removeTextSelection(); + + // Prevent further text selection while dragging + this.documentListeners.add('selectionchange', this.removeTextSelection); + + onStart(initialCoordinates); + } + }; + + initialCoordinates: Coordinates; + + removeTextSelection = () => { + document.getSelection()?.removeAllRanges(); + }; + + windowListeners = new Listeners(window); + + constructor( + event: Event, + private sessionProps: DndSessionProps, + private props: MouseSensorProps + ) { + this.initialCoordinates = getEventCoordinates(event) ?? defaultCoordinates; + this.attach(); + } + + private attach() { + this.windowListeners.add('pointermove', this.handleMove, { + capture: true, + }); + this.windowListeners.add('pointerup', this.handleEnd); + this.windowListeners.add('resize', this.handleCancel); + this.windowListeners.add('dragstart', preventDefault); + this.windowListeners.add('visibilitichange', this.handleCancel); + this.windowListeners.add('contextmenu', preventDefault); + this.documentListeners.add('keydown', this.handleKeydown); + const { activationConstraint } = this.props; + if (activationConstraint && activationConstraint.distance != null) { + return; + } + + this.handleStart(); + } + + private detach() { + this.windowListeners.removeAll(); + + // Wait until the next event loop before removing document listeners + // This is necessary because we listen for `click` and `selection` events on the document + setTimeout(this.documentListeners.removeAll, 50); + } +} diff --git a/blocksuite/affine/data-view/src/core/utils/wc-dnd/sort/sort-context.ts b/blocksuite/affine/data-view/src/core/utils/wc-dnd/sort/sort-context.ts new file mode 100644 index 0000000000..102082c423 --- /dev/null +++ b/blocksuite/affine/data-view/src/core/utils/wc-dnd/sort/sort-context.ts @@ -0,0 +1,90 @@ +import { computed, effect, type ReadonlySignal } from '@preact/signals-core'; + +import { + DndContext, + type DndContextConfig, + draggableDataName, + droppableDataName, +} from '../dnd-context.js'; +import type { Disabled, SortingStrategy, UniqueIdentifier } from '../types.js'; +import { createDataDirective } from '../utils/data-directive.js'; +import { asHTMLElement } from '../utils/element.js'; + +export type CommonSortContextConfig = {}; +export type SortContextConfig = { + strategy: SortingStrategy; + items: ReadonlySignal; + id?: string; + disabled?: ReadonlySignal; +} & DndContextConfig; + +export class SortContext extends DndContext { + disabled = computed(() => { + const disabled = this.config.disabled?.value ?? false; + return typeof disabled === 'boolean' + ? { draggable: disabled, droppable: disabled } + : { + draggable: disabled.draggable ?? false, + droppable: disabled.droppable ?? false, + }; + }); + + dragSourceList$ = computed(() => { + if (!this.active$.value) { + return; + } + return this.items.value.flatMap(id => { + const ele = asHTMLElement( + this.container.querySelector(`[${draggableDataName.attribute}='${id}']`) + ); + if (!ele) { + return []; + } + return { + node: ele, + id, + rect: ele.getBoundingClientRect(), + }; + }); + }); + + get items() { + return this.config.items; + } + + get strategy() { + return this.config.strategy; + } + + constructor(override config: SortContextConfig) { + super(config); + effect(() => { + const list = this.dragSourceList$.value; + if (list) { + const transforms = this.strategy({ + rects: list.map(v => v.rect), + activeNodeRect: this.collisionRect$.value, + activeIndex: list.findIndex(v => v.id === this.active$.value?.id), + overIndex: list.findIndex(v => v.id === this.overId$.value), + }); + transforms.forEach((transform, i) => { + const node = list[i].node; + if (transform != null) { + node.style.transform = `translate3d(${Math.round(transform.x)}px,${Math.round(transform.y)}px,0) + scaleX(${transform.scaleX}) scaleY(${transform.scaleY})`; + } else { + node.style.transform = ''; + } + }); + } + }); + } +} + +export const createSortContext = (config: SortContextConfig) => { + return new SortContext(config); +}; +const _sortable = createDataDirective(draggableDataName, droppableDataName); +export const sortable = (id: string) => { + return _sortable(id, id); +}; diff --git a/blocksuite/affine/data-view/src/core/utils/wc-dnd/sort/strategies/horizontal-list-sorting.ts b/blocksuite/affine/data-view/src/core/utils/wc-dnd/sort/strategies/horizontal-list-sorting.ts new file mode 100644 index 0000000000..e8512257e5 --- /dev/null +++ b/blocksuite/affine/data-view/src/core/utils/wc-dnd/sort/strategies/horizontal-list-sorting.ts @@ -0,0 +1,84 @@ +import type { DndClientRect, SortingStrategy } from '../../types.js'; + +const defaultScale = { + scaleX: 1, + scaleY: 1, +}; + +export const horizontalListSortingStrategy: SortingStrategy = ({ + rects, + activeNodeRect: fallbackActiveRect, + activeIndex, + overIndex, +}) => { + const activeNodeRect = rects[activeIndex] ?? fallbackActiveRect; + const strategy = (index: number) => { + const itemGap = getItemGap(rects, index, activeIndex); + + if (index === activeIndex) { + const newIndexRect = rects[overIndex]; + + if (!newIndexRect) { + return; + } + + return { + x: + activeIndex < overIndex + ? newIndexRect.left + + newIndexRect.width - + (activeNodeRect.left + activeNodeRect.width) + : newIndexRect.left - activeNodeRect.left, + y: 0, + ...defaultScale, + }; + } + + if (index > activeIndex && index <= overIndex) { + return { + x: -activeNodeRect.width - itemGap, + y: 0, + ...defaultScale, + }; + } + + if (index < activeIndex && index >= overIndex) { + return { + x: activeNodeRect.width + itemGap, + y: 0, + ...defaultScale, + }; + } + + return { + x: 0, + y: 0, + ...defaultScale, + }; + }; + return rects.map((_, index) => strategy(index)); +}; + +function getItemGap( + rects: DndClientRect[], + index: number, + activeIndex: number +) { + const currentRect: DndClientRect | undefined = rects[index]; + const previousRect: DndClientRect | undefined = rects[index - 1]; + const nextRect: DndClientRect | undefined = rects[index + 1]; + + if (!currentRect || (!previousRect && !nextRect)) { + return 0; + } + + if (activeIndex < index) { + return previousRect + ? currentRect.left - (previousRect.left + previousRect.width) + : nextRect.left - (currentRect.left + currentRect.width); + } + + return nextRect + ? nextRect.left - (currentRect.left + currentRect.width) + : currentRect.left - (previousRect.left + previousRect.width); +} diff --git a/blocksuite/affine/data-view/src/core/utils/wc-dnd/sort/strategies/index.ts b/blocksuite/affine/data-view/src/core/utils/wc-dnd/sort/strategies/index.ts new file mode 100644 index 0000000000..5fe880b72c --- /dev/null +++ b/blocksuite/affine/data-view/src/core/utils/wc-dnd/sort/strategies/index.ts @@ -0,0 +1,2 @@ +export { horizontalListSortingStrategy } from './horizontal-list-sorting.js'; +export { verticalListSortingStrategy } from './vertical-list-sorting.js'; diff --git a/blocksuite/affine/data-view/src/core/utils/wc-dnd/sort/strategies/vertical-list-sorting.ts b/blocksuite/affine/data-view/src/core/utils/wc-dnd/sort/strategies/vertical-list-sorting.ts new file mode 100644 index 0000000000..03d7908af9 --- /dev/null +++ b/blocksuite/affine/data-view/src/core/utils/wc-dnd/sort/strategies/vertical-list-sorting.ts @@ -0,0 +1,89 @@ +import type { DndClientRect, SortingStrategy } from '../../types.js'; + +const defaultScale = { + scaleX: 1, + scaleY: 1, +}; + +export const verticalListSortingStrategy: SortingStrategy = ({ + activeIndex, + activeNodeRect: fallbackActiveRect, + rects, + overIndex, +}) => { + const activeNodeRect = rects[activeIndex] ?? fallbackActiveRect; + + const strategy = (index: number) => { + if (index === activeIndex) { + const overIndexRect = rects[overIndex]; + + if (!overIndexRect) { + return undefined; + } + + return { + x: 0, + y: + activeIndex < overIndex + ? overIndexRect.top + + overIndexRect.height - + (activeNodeRect.top + activeNodeRect.height) + : overIndexRect.top - activeNodeRect.top, + ...defaultScale, + }; + } + + const itemGap = getItemGap(rects, index, activeIndex); + + if (index > activeIndex && index <= overIndex) { + return { + x: 0, + y: -activeNodeRect.height - itemGap, + ...defaultScale, + }; + } + + if (index < activeIndex && index >= overIndex) { + return { + x: 0, + y: activeNodeRect.height + itemGap, + ...defaultScale, + }; + } + + return { + x: 0, + y: 0, + ...defaultScale, + }; + }; + return rects.map((_, index) => strategy(index)); +}; + +function getItemGap( + clientRects: DndClientRect[], + index: number, + activeIndex: number +) { + const currentRect: DndClientRect | undefined = clientRects[index]; + const previousRect: DndClientRect | undefined = clientRects[index - 1]; + const nextRect: DndClientRect | undefined = clientRects[index + 1]; + + if (!currentRect) { + return 0; + } + + if (activeIndex < index) { + return previousRect + ? currentRect.top - (previousRect.top + previousRect.height) + : nextRect + ? nextRect.top - (currentRect.top + currentRect.height) + : 0; + } + + return nextRect + ? nextRect.top - (currentRect.top + currentRect.height) + : previousRect + ? currentRect.top - (previousRect.top + previousRect.height) + : 0; +} diff --git a/blocksuite/affine/data-view/src/core/utils/wc-dnd/types.ts b/blocksuite/affine/data-view/src/core/utils/wc-dnd/types.ts new file mode 100644 index 0000000000..967b51f17c --- /dev/null +++ b/blocksuite/affine/data-view/src/core/utils/wc-dnd/types.ts @@ -0,0 +1,123 @@ +export type UniqueIdentifier = string; + +export interface DndClientRect { + width: number; + height: number; + top: number; + left: number; + right: number; + bottom: number; +} + +export type Coordinates = { + x: number; + y: number; +}; + +export interface Active { + id: UniqueIdentifier; + node: HTMLElement; + rect: DndClientRect; +} + +export type RectMap = Map; + +export interface DroppableContainer { + id: UniqueIdentifier; + disabled: boolean; + node: HTMLElement; + rect: DndClientRect; +} + +export interface Collision { + id: UniqueIdentifier; +} + +export type CollisionDetection = (args: { + active: Active; + collisionRect: DndClientRect; + droppableRects: RectMap; + droppableContainers: DroppableContainer[]; + pointerCoordinates: Coordinates | undefined; +}) => Collision[]; +export type Translate = Coordinates; + +export interface Over { + id: UniqueIdentifier; + rect: DndClientRect; + disabled: boolean; +} + +interface DragEvent { + active: Active; + collisions: Collision[] | undefined; + delta: Translate; + over: Over | undefined; +} + +export interface DragStartEvent extends Pick {} + +export interface DragMoveEvent extends DragEvent {} + +export interface DragOverEvent extends DragMoveEvent {} + +export interface DragEndEvent extends DragEvent {} + +export interface DragCancelEvent extends DragEndEvent {} + +export type Transform = { + x: number; + y: number; + scaleX: number; + scaleY: number; +}; +export type Modifier = (args: { + active: Active | undefined; + activeNodeRect: DndClientRect; + over: Over | undefined; + transform: Transform; + overlayNodeRect: DndClientRect | undefined; + scrollContainerRect: DndClientRect | undefined; +}) => Transform; + +export type Modifiers = Modifier[]; +export type SortingStrategy = (args: { + activeNodeRect: DndClientRect | undefined; + activeIndex: number; + rects: DndClientRect[]; + overIndex: number; +}) => Array; + +export interface Disabled { + draggable?: boolean; + droppable?: boolean; +} + +export type DroppableNodes = Map; +export type Unsubscription = () => void; +export type DndSessionProps = { + onStart(coordinates: Coordinates): void; + onCancel(): void; + onMove(coordinates: Coordinates): void; + onEnd(): void; +}; +export type DndSessionCreator = (props: DndSessionProps) => DndSession; +export type DndSession = { + autoScrollEnabled: boolean; +}; +export type Activator = ( + container: HTMLElement, + startSession: ( + id: UniqueIdentifier, + activeNode: HTMLElement, + sessionCreator: DndSessionCreator + ) => void +) => Unsubscription; +export type Sensor = (config: T) => Activator; +export type Activators = Activator[]; + +export type DistanceMeasurement = + | number + | Coordinates + | Pick + | Pick; diff --git a/blocksuite/affine/data-view/src/core/utils/wc-dnd/utils/adjustment.ts b/blocksuite/affine/data-view/src/core/utils/wc-dnd/utils/adjustment.ts new file mode 100644 index 0000000000..95cb7d4c17 --- /dev/null +++ b/blocksuite/affine/data-view/src/core/utils/wc-dnd/utils/adjustment.ts @@ -0,0 +1,28 @@ +function createAdjustmentFn(modifier: number) { + return , U extends string>( + object: T, + ...adjustments: Partial[] + ): T => { + return adjustments.reduce( + (accumulator, adjustment) => { + const entries = Object.entries(adjustment) as [U, number][]; + + for (const [key, valueAdjustment] of entries) { + const value = accumulator[key]; + + if (value != null) { + accumulator[key] = (value + modifier * valueAdjustment) as T[U]; + } + } + + return accumulator; + }, + { + ...object, + } + ); + }; +} + +export const add = createAdjustmentFn(1); +export const subtract = createAdjustmentFn(-1); diff --git a/blocksuite/affine/data-view/src/core/utils/wc-dnd/utils/apply-modifiers.ts b/blocksuite/affine/data-view/src/core/utils/wc-dnd/utils/apply-modifiers.ts new file mode 100644 index 0000000000..42b0902031 --- /dev/null +++ b/blocksuite/affine/data-view/src/core/utils/wc-dnd/utils/apply-modifiers.ts @@ -0,0 +1,15 @@ +import type { Modifier, Modifiers, Transform } from '../types.js'; + +export function applyModifiers( + modifiers: Modifiers | undefined, + { transform, ...args }: Parameters[0] +): Transform { + return modifiers?.length + ? modifiers.reduce((accumulator, modifier) => { + return modifier({ + transform: accumulator, + ...args, + }); + }, transform) + : transform; +} diff --git a/blocksuite/affine/data-view/src/core/utils/wc-dnd/utils/array-move.ts b/blocksuite/affine/data-view/src/core/utils/wc-dnd/utils/array-move.ts new file mode 100644 index 0000000000..d0f89b6a9f --- /dev/null +++ b/blocksuite/affine/data-view/src/core/utils/wc-dnd/utils/array-move.ts @@ -0,0 +1,13 @@ +/** + * Move an array item to a different position. Returns a new array with the item moved to the new position. + */ +export function arrayMove(array: T[], from: number, to: number): T[] { + const newArray = array.slice(); + newArray.splice( + to < 0 ? newArray.length + to : to, + 0, + newArray.splice(from, 1)[0] + ); + + return newArray; +} diff --git a/blocksuite/affine/data-view/src/core/utils/wc-dnd/utils/closest-center.ts b/blocksuite/affine/data-view/src/core/utils/wc-dnd/utils/closest-center.ts new file mode 100644 index 0000000000..6b709244d4 --- /dev/null +++ b/blocksuite/affine/data-view/src/core/utils/wc-dnd/utils/closest-center.ts @@ -0,0 +1,44 @@ +import type { + CollisionDetection, + Coordinates, + DndClientRect, +} from '../types.js'; +import { distanceBetween } from './distance-between-points.js'; + +/** + * Returns the coordinates of the center of a given ClientRect + */ +const centerOfRectangle = ( + rect: DndClientRect, + left = rect.left, + top = rect.top +): Coordinates => ({ + x: left + rect.width * 0.5, + y: top + rect.height * 0.5, +}); + +/** + * Returns the closest rectangles from an array of rectangles to the center of a given + * rectangle. + */ +export const closestCenter: CollisionDetection = ({ + collisionRect, + droppableRects, + droppableContainers, +}) => { + let closest: { id: string; value: number } | undefined; + const centerRect = centerOfRectangle(collisionRect); + + for (const droppableContainer of droppableContainers) { + const { id } = droppableContainer; + const rect = droppableRects.get(id); + + if (rect) { + const distBetween = distanceBetween(centerOfRectangle(rect), centerRect); + if (!closest || distBetween < closest.value) { + closest = { id, value: distBetween }; + } + } + } + return closest ? [closest] : []; +}; diff --git a/blocksuite/affine/data-view/src/core/utils/wc-dnd/utils/data-directive.ts b/blocksuite/affine/data-view/src/core/utils/wc-dnd/utils/data-directive.ts new file mode 100644 index 0000000000..4f3ff47feb --- /dev/null +++ b/blocksuite/affine/data-view/src/core/utils/wc-dnd/utils/data-directive.ts @@ -0,0 +1,27 @@ +import type { AttributePart } from 'lit'; +import { Directive, directive, type PartInfo } from 'lit/directive.js'; + +export type DataName = { + dataset: string; + attribute: string; +}; +export const createDataDirective = (...names: T) => { + return directive( + class DraggableDirective extends Directive { + constructor(partInfo: PartInfo) { + super(partInfo); + } + + override render(..._ids: { [K in keyof T]: string }): unknown { + return; + } + + override update(part: AttributePart, ids: string[]): unknown { + names.forEach((name, index) => { + part.element.dataset[name.dataset] = ids[index]; + }); + return; + } + } + ); +}; diff --git a/blocksuite/affine/data-view/src/core/utils/wc-dnd/utils/distance-between-points.ts b/blocksuite/affine/data-view/src/core/utils/wc-dnd/utils/distance-between-points.ts new file mode 100644 index 0000000000..3111aed654 --- /dev/null +++ b/blocksuite/affine/data-view/src/core/utils/wc-dnd/utils/distance-between-points.ts @@ -0,0 +1,8 @@ +import type { Coordinates } from '../types.js'; + +/** + * Returns the distance between two points + */ +export function distanceBetween(p1: Coordinates, p2: Coordinates) { + return Math.sqrt(Math.pow(p1.x - p2.x, 2) + Math.pow(p1.y - p2.y, 2)); +} diff --git a/blocksuite/affine/data-view/src/core/utils/wc-dnd/utils/element.ts b/blocksuite/affine/data-view/src/core/utils/wc-dnd/utils/element.ts new file mode 100644 index 0000000000..debe30928f --- /dev/null +++ b/blocksuite/affine/data-view/src/core/utils/wc-dnd/utils/element.ts @@ -0,0 +1,3 @@ +export const asHTMLElement = (ele: unknown): HTMLElement | undefined => { + return ele instanceof HTMLElement ? ele : undefined; +}; diff --git a/blocksuite/affine/data-view/src/core/utils/wc-dnd/utils/events.ts b/blocksuite/affine/data-view/src/core/utils/wc-dnd/utils/events.ts new file mode 100644 index 0000000000..9fb4cf8315 --- /dev/null +++ b/blocksuite/affine/data-view/src/core/utils/wc-dnd/utils/events.ts @@ -0,0 +1,7 @@ +export const preventDefault = (event: Event) => { + event.preventDefault(); +}; + +export const stopPropagation = (event: Event) => { + event.stopPropagation(); +}; diff --git a/blocksuite/affine/data-view/src/core/utils/wc-dnd/utils/get-scrollable-ancestors.ts b/blocksuite/affine/data-view/src/core/utils/wc-dnd/utils/get-scrollable-ancestors.ts new file mode 100644 index 0000000000..b19fd7967b --- /dev/null +++ b/blocksuite/affine/data-view/src/core/utils/wc-dnd/utils/get-scrollable-ancestors.ts @@ -0,0 +1,63 @@ +export function isFixed( + node: HTMLElement, + computedStyle: CSSStyleDeclaration = window.getComputedStyle(node) +): boolean { + return computedStyle.position === 'fixed'; +} + +export function isScrollable( + element: HTMLElement, + computedStyle: CSSStyleDeclaration = window.getComputedStyle(element) +): boolean { + const overflowRegex = /(auto|scroll|overlay)/; + const properties = ['overflow', 'overflowX', 'overflowY']; + + return properties.some(property => { + const value = computedStyle[property as keyof CSSStyleDeclaration]; + + return typeof value === 'string' ? overflowRegex.test(value) : false; + }); +} + +export function getScrollableAncestors( + element: Node | null, + limit?: number +): Element[] { + const scrollParents: Element[] = []; + + if (!element) { + return scrollParents; + } + + let currentNode: Node | null = element; + + while (currentNode) { + if (limit != null && scrollParents.length >= limit) { + break; + } + + if (!(currentNode instanceof HTMLElement)) { + break; + } + + const computedStyle = window.getComputedStyle(currentNode); + + if (currentNode !== element && isScrollable(currentNode, computedStyle)) { + scrollParents.push(currentNode); + } + + if (isFixed(currentNode, computedStyle)) { + break; + } + + currentNode = currentNode.parentNode; + } + + return scrollParents; +} + +export function getFirstScrollableAncestor(node: Node | null): Element | null { + const [firstScrollableAncestor] = getScrollableAncestors(node, 1); + + return firstScrollableAncestor ?? null; +} diff --git a/blocksuite/affine/data-view/src/core/utils/wc-dnd/utils/has-exceeded-distance.ts b/blocksuite/affine/data-view/src/core/utils/wc-dnd/utils/has-exceeded-distance.ts new file mode 100644 index 0000000000..6444778704 --- /dev/null +++ b/blocksuite/affine/data-view/src/core/utils/wc-dnd/utils/has-exceeded-distance.ts @@ -0,0 +1,27 @@ +import type { Coordinates, DistanceMeasurement } from '../types.js'; + +export function hasExceededDistance( + delta: Coordinates, + measurement: DistanceMeasurement +): boolean { + const dx = Math.abs(delta.x); + const dy = Math.abs(delta.y); + + if (typeof measurement === 'number') { + return Math.sqrt(dx ** 2 + dy ** 2) > measurement; + } + + if ('x' in measurement && 'y' in measurement) { + return dx > measurement.x && dy > measurement.y; + } + + if ('x' in measurement) { + return dx > measurement.x; + } + + if ('y' in measurement) { + return dy > measurement.y; + } + + return false; +} diff --git a/blocksuite/affine/data-view/src/core/utils/wc-dnd/utils/linear-move.ts b/blocksuite/affine/data-view/src/core/utils/wc-dnd/utils/linear-move.ts new file mode 100644 index 0000000000..317d679a5a --- /dev/null +++ b/blocksuite/affine/data-view/src/core/utils/wc-dnd/utils/linear-move.ts @@ -0,0 +1,60 @@ +import type { + CollisionDetection, + Coordinates, + DndClientRect, +} from '../types.js'; + +const centerOfRectangle = ( + rect: DndClientRect, + left = rect.left, + top = rect.top +): Coordinates => ({ + x: left + rect.width * 0.5, + y: top + rect.height * 0.5, +}); +type Target = { + id: string; + value: number; +}; +export const linearMove = + (horizontal: boolean): CollisionDetection => + ({ active, collisionRect, droppableRects, droppableContainers }) => { + let target: Target | undefined; + const activeCenter = centerOfRectangle(active.rect); + for (const droppableContainer of droppableContainers) { + const { id } = droppableContainer; + const rect = droppableRects.get(id); + const center = rect && centerOfRectangle(rect); + if (!center || id === active?.id) { + continue; + } + if (horizontal) { + if (center.x < activeCenter.x && collisionRect.left < center.x) { + const diff = center.x - collisionRect.left; + if (!target || diff < target.value) { + target = { id, value: diff }; + } + } + if (center.x > activeCenter.x && collisionRect.right > center.x) { + const diff = collisionRect.right - center.x; + if (!target || diff < target.value) { + target = { id, value: diff }; + } + } + } else { + if (center.y < activeCenter.y && collisionRect.top < center.y) { + const diff = center.y - collisionRect.top; + if (!target || diff < target.value) { + target = { id, value: diff }; + } + } + if (center.y > activeCenter.y && collisionRect.bottom > center.y) { + const diff = collisionRect.bottom - center.y; + if (!target || diff < target.value) { + target = { id, value: diff }; + } + } + } + } + return target ? [target] : []; + }; diff --git a/blocksuite/affine/data-view/src/core/utils/wc-dnd/utils/listeners.ts b/blocksuite/affine/data-view/src/core/utils/wc-dnd/utils/listeners.ts new file mode 100644 index 0000000000..4550e615ae --- /dev/null +++ b/blocksuite/affine/data-view/src/core/utils/wc-dnd/utils/listeners.ts @@ -0,0 +1,20 @@ +export class Listeners { + private listeners: [ + string, + EventListenerOrEventListenerObject | null, + AddEventListenerOptions | boolean | undefined, + ][] = []; + + add: this['target']['addEventListener'] = (eventName, handler, options) => { + this.target.addEventListener(eventName, handler, options); + this.listeners.push([eventName, handler, options]); + }; + + removeAll = () => { + this.listeners.forEach(listener => + this.target.removeEventListener(...listener) + ); + }; + + constructor(public target: T) {} +} diff --git a/blocksuite/affine/data-view/src/core/utils/wc-dnd/utils/raf.ts b/blocksuite/affine/data-view/src/core/utils/wc-dnd/utils/raf.ts new file mode 100644 index 0000000000..d79e2af1bd --- /dev/null +++ b/blocksuite/affine/data-view/src/core/utils/wc-dnd/utils/raf.ts @@ -0,0 +1,24 @@ +let rafId: number | null = null; +let rafCallback: ((delta: number) => void) | null = null; + +export function raf(callback?: (delta: number) => void) { + if (!callback) { + rafCallback = null; + if (rafId) { + cancelAnimationFrame(rafId); + rafId = null; + } + return; + } + const lastTime = performance.now(); + rafCallback = () => { + rafId = null; + callback(performance.now() - lastTime); + }; + + if (rafId === null) { + rafId = requestAnimationFrame(time => { + rafCallback?.(time); + }); + } +} diff --git a/blocksuite/affine/data-view/src/core/utils/wc-dnd/utils/rect-adjustment.ts b/blocksuite/affine/data-view/src/core/utils/wc-dnd/utils/rect-adjustment.ts new file mode 100644 index 0000000000..d09f0e6358 --- /dev/null +++ b/blocksuite/affine/data-view/src/core/utils/wc-dnd/utils/rect-adjustment.ts @@ -0,0 +1,14 @@ +import type { Coordinates, DndClientRect } from '../types.js'; + +export const getAdjustedRect = ( + rect: DndClientRect, + adjustment: Coordinates +) => { + return { + ...rect, + top: rect.top + adjustment.y, + bottom: rect.bottom + adjustment.y, + left: rect.left + adjustment.x, + right: rect.right + adjustment.x, + }; +}; diff --git a/blocksuite/affine/data-view/src/core/utils/wc-dnd/utils/rect.ts b/blocksuite/affine/data-view/src/core/utils/wc-dnd/utils/rect.ts new file mode 100644 index 0000000000..697d8446f2 --- /dev/null +++ b/blocksuite/affine/data-view/src/core/utils/wc-dnd/utils/rect.ts @@ -0,0 +1,16 @@ +/** + * Returns the bounding client rect of an element relative to the viewport. + */ +export function getClientRect(element: Element) { + const { top, left, width, height, bottom, right } = + element.getBoundingClientRect(); + + return { + top, + left, + width, + height, + bottom, + right, + }; +} diff --git a/blocksuite/affine/data-view/src/core/utils/wc-dnd/utils/signal.ts b/blocksuite/affine/data-view/src/core/utils/wc-dnd/utils/signal.ts new file mode 100644 index 0000000000..48eef8e6cc --- /dev/null +++ b/blocksuite/affine/data-view/src/core/utils/wc-dnd/utils/signal.ts @@ -0,0 +1,18 @@ +import { computed, type ReadonlySignal } from '@preact/signals-core'; + +export const computedCache = ( + cb: () => T +): ReadonlySignal & { + preValue: T; +} => { + let value: T; + const result = computed(() => { + return (value = cb()); + }); + Object.defineProperty(result, 'preValue', { + get(): T { + return value; + }, + }); + return result as never; +}; diff --git a/blocksuite/affine/data-view/src/core/view-manager/cell.ts b/blocksuite/affine/data-view/src/core/view-manager/cell.ts new file mode 100644 index 0000000000..81400c492c --- /dev/null +++ b/blocksuite/affine/data-view/src/core/view-manager/cell.ts @@ -0,0 +1,82 @@ +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({ + value: this.value$.value, + dataSource: this.view.manager.dataSource, + }); + }); + + 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/blocksuite/affine/data-view/src/core/view-manager/index.ts b/blocksuite/affine/data-view/src/core/view-manager/index.ts new file mode 100644 index 0000000000..98a19b6eb9 --- /dev/null +++ b/blocksuite/affine/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/blocksuite/affine/data-view/src/core/view-manager/property.ts b/blocksuite/affine/data-view/src/core/view-manager/property.ts new file mode 100644 index 0000000000..e7d62b2b7e --- /dev/null +++ b/blocksuite/affine/data-view/src/core/view-manager/property.ts @@ -0,0 +1,169 @@ +import { computed, type ReadonlySignal } from '@preact/signals-core'; + +import type { TypeInstance } from '../logical/type.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.propertyIconGet(this.type$.value); + } + + get id(): string { + return this.propertyId; + } + + get index(): number { + return this.view.propertyIndexGet(this.id); + } + + get isFirst(): boolean { + return this.view.propertyIndexGet(this.id) === 0; + } + + get isLast(): boolean { + return ( + this.view.propertyIndexGet(this.id) === + this.view.properties$.value.length - 1 + ); + } + + get typeSet(): undefined | ((type: string) => void) { + return type => this.view.propertyTypeSet(this.id, type); + } + + constructor( + public view: SingleView, + public propertyId: string + ) {} + + cellGet(rowId: string): Cell { + return this.view.cellGet(rowId, this.id) as Cell; + } + + dataUpdate(updater: PropertyDataUpdater): void { + const data = this.data$.value; + this.view.propertyDataSet(this.id, { + ...data, + ...updater(data), + }); + } + + hideSet(hide: boolean): void { + this.view.propertyHideSet(this.id, hide); + } + + nameSet(name: string): void { + this.view.propertyNameSet(this.id, name); + } + + stringValueGet(rowId: string): string { + return this.cellGet(rowId).stringValue$.value; + } + + valueGet(rowId: string): Value | undefined { + return this.cellGet(rowId).value$.value; + } + + valueSet(rowId: string, value: Value | undefined): void { + return this.cellGet(rowId).valueSet(value); + } + + valueSetFromString(rowId: string, value: string): void { + const data = this.view.propertyParseValueFromString(this.id, value); + if (!data) { + return; + } + if (data.data) { + this.dataUpdate(() => data.data as Data); + } + this.valueSet(rowId, data.value as Value); + } +} diff --git a/blocksuite/affine/data-view/src/core/view-manager/row.ts b/blocksuite/affine/data-view/src/core/view-manager/row.ts new file mode 100644 index 0000000000..b32de1f9d0 --- /dev/null +++ b/blocksuite/affine/data-view/src/core/view-manager/row.ts @@ -0,0 +1,22 @@ +import { computed, type ReadonlySignal } from '@preact/signals-core'; + +import { type Cell, CellBase } from './cell.js'; +import type { SingleView } from './single-view.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/blocksuite/affine/data-view/src/core/view-manager/single-view.ts b/blocksuite/affine/data-view/src/core/view-manager/single-view.ts new file mode 100644 index 0000000000..65f7630f67 --- /dev/null +++ b/blocksuite/affine/data-view/src/core/view-manager/single-view.ts @@ -0,0 +1,486 @@ +import type { InsertToPosition } from '@blocksuite/affine-shared/utils'; +import { computed, type ReadonlySignal, signal } from '@preact/signals-core'; + +import type { DataViewContextKey } from '../data-source/context.js'; +import type { Variable } from '../expression/types.js'; +import type { DVJSON } from '../index.js'; +import type { TypeInstance } from '../logical/type.js'; +import type { PropertyMetaConfig } from '../property/property-config.js'; +import type { TraitKey } from '../traits/key.js'; +import type { DatabaseFlags } from '../types.js'; +import type { UniComponent } from '../utils/uni-component/index.js'; +import type { DataViewDataType, ViewMeta } from '../view/data-view.js'; +import { type Cell, CellBase } from './cell.js'; +import type { Property } from './property.js'; +import { type Row, RowBase } from './row.js'; +import type { ViewManager } from './view-manager.js'; + +export type MainProperties = { + titleColumn?: string; + iconColumn?: string; + imageColumn?: string; +}; + +export interface SingleView { + readonly id: string; + readonly type: string; + readonly manager: ViewManager; + readonly meta: ViewMeta; + readonly readonly$: ReadonlySignal; + + delete(): void; + + duplicate(): 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; + + readonly featureFlags$: ReadonlySignal; + + cellValueGet(rowId: string, propertyId: string): unknown; + + cellValueSet(rowId: string, propertyId: string, value: unknown): void; + + cellJsonValueGet(rowId: string, propertyId: string): unknown; + + cellJsonValueSet(rowId: string, propertyId: string, value: DVJSON): void; + + cellStringValueGet(rowId: string, propertyId: string): string | undefined; + + cellGet(rowId: string, propertyId: string): Cell; + + propertyParseValueFromString( + propertyId: string, + value: string + ): + | { + value: unknown; + data?: Record; + } + | undefined; + + rowAdd(insertPosition: InsertToPosition): string; + + rowDelete(ids: string[]): void; + + rowMove(rowId: string, position: InsertToPosition): void; + + rowGet(rowId: string): Row; + + rowPrevGet(rowId: string): string; + + rowNextGet(rowId: string): string; + + readonly propertyMetas: PropertyMetaConfig[]; + + propertyAdd(toAfterOfProperty: InsertToPosition, type?: string): string; + + propertyDelete(propertyId: string): void; + + propertyDuplicate(propertyId: string): void; + + propertyGet(propertyId: string): Property; + + propertyMetaGet(type: string): PropertyMetaConfig | undefined; + + propertyPreGet(propertyId: string): Property | undefined; + + propertyNextGet(propertyId: string): Property | undefined; + + propertyNameGet(propertyId: string): string; + + propertyNameSet(propertyId: string, name: string): void; + + propertyTypeGet(propertyId: string): string | undefined; + + propertyTypeSet(propertyId: string, type: string): void; + + propertyHideGet(propertyId: string): boolean; + + propertyHideSet(propertyId: string, hide: boolean): void; + + propertyDataGet(propertyId: string): Record; + + propertyDataSet(propertyId: string, data: Record): void; + + propertyDataTypeGet(propertyId: string): TypeInstance | undefined; + + propertyIndexGet(propertyId: string): number; + + propertyIdGetByIndex(index: number): string; + + propertyReadonlyGet(propertyId: string): boolean; + + propertyMove(propertyId: string, position: InsertToPosition): void; + + propertyIconGet(type: string): UniComponent | undefined; + + contextGet(key: DataViewContextKey): T; + + traitGet(key: TraitKey): T | undefined; + + mainProperties$: ReadonlySignal; + + lockRows(lock: boolean): void; + + isLocked$: ReadonlySignal; +} + +export abstract class SingleViewBase< + ViewData extends DataViewDataType = DataViewDataType, +> implements SingleView +{ + private searchString = signal(''); + + private traitMap = new Map(); + + data$ = computed(() => { + return this.dataSource.viewDataGet(this.id) as ViewData | undefined; + }); + + abstract detailProperties$: ReadonlySignal; + + protected lockRows$ = signal(false); + + isLocked$ = computed(() => { + return this.lockRows$.value; + }); + + abstract mainProperties$: ReadonlySignal; + + name$: ReadonlySignal = computed(() => { + return this.data$.value?.name ?? ''; + }); + + preRows: string[] = []; + + abstract propertyIds$: ReadonlySignal; + + properties$ = computed(() => { + return this.propertyIds$.value.map( + id => this.propertyGet(id) as ReturnType + ); + }); + + abstract propertiesWithoutFilter$: ReadonlySignal; + + abstract readonly$: ReadonlySignal; + + rows$ = computed(() => { + if (this.lockRows$.value) { + return this.preRows; + } + return (this.preRows = this.rowsMapping(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({ + data: v.data$.value, + dataSource: this.dataSource, + }), + icon: v.icon, + propertyType: v.type$.value, + }; + }); + }); + + protected get dataSource() { + return this.manager.dataSource; + } + + get featureFlags$() { + return this.dataSource.featureFlags$; + } + + get isLocked() { + return this.lockRows$.value; + } + + get meta() { + return this.dataSource.viewMetaGet(this.type); + } + + get propertyMetas(): PropertyMetaConfig[] { + return this.dataSource.propertyMetas; + } + + abstract get type(): string; + + constructor( + public manager: ViewManager, + public id: string + ) {} + + private searchRowsMapping(rows: string[], searchString: string): string[] { + return rows.filter(id => { + if (searchString) { + const containsSearchString = this.propertyIds$.value.some( + propertyId => { + return this.cellStringValueGet(id, propertyId) + ?.toLowerCase() + .includes(searchString?.toLowerCase()); + } + ); + if (!containsSearchString) { + return false; + } + } + return this.isShow(id); + }); + } + + cellGet(rowId: string, propertyId: string): Cell { + return new CellBase(this, propertyId, rowId); + } + + cellJsonValueGet(rowId: string, propertyId: string): unknown { + const type = this.propertyTypeGet(propertyId); + if (!type) { + return; + } + return this.dataSource.propertyMetaGet(type).config.cellToJson({ + value: this.dataSource.cellValueGet(rowId, propertyId), + data: this.propertyDataGet(propertyId), + dataSource: this.dataSource, + }); + } + + cellJsonValueSet(rowId: string, propertyId: string, value: DVJSON): void { + const type = this.propertyTypeGet(propertyId); + if (!type) { + return; + } + const fromJson = this.dataSource.propertyMetaGet(type).config.cellFromJson; + this.dataSource.cellValueChange( + rowId, + propertyId, + fromJson({ + value, + data: this.propertyDataGet(propertyId), + dataSource: this.dataSource, + }) + ); + } + + cellStringValueGet(rowId: string, propertyId: string): string | undefined { + const type = this.propertyTypeGet(propertyId); + if (!type) { + return; + } + return ( + this.dataSource.propertyMetaGet(type).config.cellToString({ + value: this.dataSource.cellValueGet(rowId, propertyId), + data: this.propertyDataGet(propertyId), + dataSource: this.dataSource, + }) ?? '' + ); + } + + 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?.({ + value: cellValue, + data: this.propertyDataGet(propertyId), + dataSource: this.dataSource, + }) ?? cellValue + ); + } + + cellValueSet(rowId: string, propertyId: string, value: unknown): void { + this.dataSource.cellValueChange(rowId, propertyId, value); + } + + contextGet(key: DataViewContextKey): T { + return this.dataSource.contextGet(key); + } + + dataUpdate(updater: (viewData: ViewData) => Partial): void { + this.dataSource.viewDataUpdate(this.id, updater); + } + + delete(): void { + this.manager.viewDelete(this.id); + } + + duplicate(): void { + this.manager.viewDuplicate(this.id); + } + + abstract isShow(rowId: string): boolean; + + lockRows(lock: boolean) { + this.lockRows$.value = lock; + } + + 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): TypeInstance | undefined { + const type = this.propertyTypeGet(propertyId); + if (!type) { + return; + } + return this.dataSource.propertyMetaGet(type).config.type({ + data: this.propertyDataGet(propertyId), + dataSource: this.dataSource, + }); + } + + 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; + + propertyIconGet(type: string): UniComponent | undefined { + return this.dataSource.propertyMetaGet(type).renderer.icon; + } + + 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({ + value: cellData, + data: this.propertyDataGet(propertyId), + dataSource: this.dataSource, + }) ?? '' + ); + } + + propertyPreGet(propertyId: string): Property | undefined { + return this.propertyGet( + this.propertyIdGetByIndex(this.propertyIndexGet(propertyId) - 1) + ); + } + + propertyReadonlyGet(propertyId: string): boolean { + return this.dataSource.propertyReadonlyGet(propertyId); + } + + propertyTypeGet(propertyId: string): string | undefined { + return this.dataSource.propertyTypeGet(propertyId); + } + + propertyTypeSet(propertyId: string, type: string): void { + this.dataSource.propertyTypeSet(propertyId, type); + } + + rowAdd(insertPosition: InsertToPosition | number): string { + return this.dataSource.rowAdd(insertPosition); + } + + rowDelete(ids: string[]): void { + this.dataSource.rowDelete(ids); + } + + rowGet(rowId: string): Row { + return new RowBase(this, rowId); + } + + rowMove(rowId: string, position: InsertToPosition): void { + this.dataSource.rowMove(rowId, position); + } + + abstract rowNextGet(rowId: string): string; + + abstract rowPrevGet(rowId: string): string; + + protected rowsMapping(rows: string[]): string[] { + return this.searchRowsMapping(rows, this.searchString.value); + } + + setSearch(str: string): void { + this.searchString.value = str; + } + + traitGet(key: TraitKey): T | undefined { + return this.traitMap.get(key.key) as T | undefined; + } + + protected traitSet(key: TraitKey, value: T): T { + this.traitMap.set(key.key, value); + return value; + } +} diff --git a/blocksuite/affine/data-view/src/core/view-manager/view-manager.ts b/blocksuite/affine/data-view/src/core/view-manager/view-manager.ts new file mode 100644 index 0000000000..431769bcc1 --- /dev/null +++ b/blocksuite/affine/data-view/src/core/view-manager/view-manager.ts @@ -0,0 +1,127 @@ +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 '../data-source/base.js'; +import type { + DataViewDataType, + DataViewMode, + ViewMeta, +} from '../view/data-view.js'; +import type { SingleView } from './single-view.js'; + +export interface ViewManager { + viewMetas: ViewMeta[]; + dataSource: DataSource; + readonly$: ReadonlySignal; + + currentViewId$: ReadonlySignal; + currentView$: ReadonlySignal; + + setCurrentView(id: string): void; + + views$: ReadonlySignal; + + viewGet(id: string): SingleView; + + viewAdd(type: DataViewMode): string; + + viewDelete(id: string): void; + + viewDuplicate(id: string): void; + + viewDataGet(id: string): DataViewDataType | undefined; + + moveTo(id: string, position: InsertToPosition): void; + + viewChangeType(id: string, type: string): void; +} + +export class ViewManagerBase implements ViewManager { + _currentViewId$ = signal(undefined); + + views$ = computed(() => { + return this.dataSource.viewDataList$.value.map(data => data.id); + }); + + currentViewId$ = computed(() => { + return this._currentViewId$.value ?? this.views$.value[0]; + }); + + currentView$ = computed(() => { + return this.viewGet(this.currentViewId$.value); + }); + + readonly$ = computed(() => { + return this.dataSource.readonly$.value; + }); + + get viewMetas() { + return this.dataSource.viewMetas; + } + + constructor(public dataSource: DataSource) {} + + moveTo(id: string, position: InsertToPosition): void { + this.dataSource.viewDataMoveTo(id, position); + } + + setCurrentView(id: string): void { + this._currentViewId$.value = id; + } + + viewAdd(type: DataViewMode): string { + const meta = this.dataSource.viewMetaGet(type); + const data = meta.model.defaultData(this); + const id = this.dataSource.viewDataAdd({ + ...data, + id: nanoid(), + name: meta.model.defaultName, + mode: type, + }); + this.setCurrentView(id); + return id; + } + + viewChangeType(id: string, type: string): void { + const from = this.viewGet(id).type; + const meta = this.dataSource.viewMetaGet(type); + this.dataSource.viewDataUpdate(id, old => { + let data = { + ...meta.model.defaultData(this), + id: old.id, + name: old.name, + mode: type, + }; + const convertFunction = this.dataSource.viewConverts.find( + v => v.from === from && v.to === type + ); + if (convertFunction) { + data = { + ...data, + ...convertFunction.convert(old), + }; + } + return data; + }); + } + + viewDataGet(id: string): DataViewDataType | undefined { + return this.dataSource.viewDataGet(id); + } + + viewDelete(id: string): void { + this.dataSource.viewDataDelete(id); + this.setCurrentView(this.views$.value[0]); + } + + viewDuplicate(id: string): void { + const newId = this.dataSource.viewDataDuplicate(id); + this.setCurrentView(newId); + } + + viewGet(id: string): SingleView { + const meta = this.dataSource.viewMetaGetById(id); + return new meta.model.dataViewManager(this, id); + } +} diff --git a/blocksuite/affine/data-view/src/core/view/convert.ts b/blocksuite/affine/data-view/src/core/view/convert.ts new file mode 100644 index 0000000000..9ad392c23a --- /dev/null +++ b/blocksuite/affine/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/blocksuite/affine/data-view/src/core/view/data-view-base.ts b/blocksuite/affine/data-view/src/core/view/data-view-base.ts new file mode 100644 index 0000000000..440b0e629e --- /dev/null +++ b/blocksuite/affine/data-view/src/core/view/data-view-base.ts @@ -0,0 +1,17 @@ +import { ShadowlessElement } from '@blocksuite/block-std'; +import { SignalWatcher, WithDisposable } from '@blocksuite/global/utils'; +import { property } from 'lit/decorators.js'; + +import type { DataViewSelection } from '../types.js'; +import type { SingleView } from '../view-manager/single-view.js'; +import type { DataViewInstance, DataViewProps } from './types.js'; + +export abstract class DataViewBase< + T extends SingleView = SingleView, + Selection extends DataViewSelection = DataViewSelection, +> extends SignalWatcher(WithDisposable(ShadowlessElement)) { + abstract expose: DataViewInstance; + + @property({ attribute: false }) + accessor props!: DataViewProps; +} diff --git a/blocksuite/affine/data-view/src/core/view/data-view.ts b/blocksuite/affine/data-view/src/core/view/data-view.ts new file mode 100644 index 0000000000..875f5bc42c --- /dev/null +++ b/blocksuite/affine/data-view/src/core/view/data-view.ts @@ -0,0 +1,79 @@ +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 { DataViewInstance, 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; + +type DataViewComponent = UniComponent< + { + props: DataViewProps; + }, + { + expose: DataViewInstance; + } +>; + +export interface DataViewRendererConfig { + view: DataViewComponent; + mobileView?: DataViewComponent; + 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/blocksuite/affine/data-view/src/core/view/index.ts b/blocksuite/affine/data-view/src/core/view/index.ts new file mode 100644 index 0000000000..3b38c96b44 --- /dev/null +++ b/blocksuite/affine/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/blocksuite/affine/data-view/src/core/view/types.ts b/blocksuite/affine/data-view/src/core/view/types.ts new file mode 100644 index 0000000000..dcb175c90d --- /dev/null +++ b/blocksuite/affine/data-view/src/core/view/types.ts @@ -0,0 +1,70 @@ +import type { + DatabaseAllViewEvents, + EventTraceFn, +} from '@blocksuite/affine-shared/services'; +import type { InsertToPosition } from '@blocksuite/affine-shared/utils'; +import type { + Clipboard, + EventName, + UIEventHandler, +} from '@blocksuite/block-std'; +import type { Disposable } from '@blocksuite/global/utils'; +import type { ReadonlySignal } from '@preact/signals-core'; + +import type { DataSource } from '../common/index.js'; +import type { DataViewRenderer } from '../data-view.js'; +import type { DataViewSelection } from '../types.js'; +import type { SingleView } from '../view-manager/index.js'; +import type { DataViewWidget } from '../widget/index.js'; + +export interface DataViewProps< + T extends SingleView = SingleView, + Selection extends DataViewSelection = DataViewSelection, +> { + dataViewEle: DataViewRenderer; + + headerWidget?: DataViewWidget; + + view: T; + dataSource: DataSource; + + bindHotkey: (hotkeys: Record) => Disposable; + + handleEvent: (name: EventName, handler: UIEventHandler) => Disposable; + + setSelection: (selection?: Selection) => void; + + selection$: ReadonlySignal; + + virtualPadding$: ReadonlySignal; + + onDrag?: (evt: MouseEvent, id: string) => () => void; + + clipboard: Clipboard; + + notification: { + toast: (message: string) => void; + }; + + eventTrace: EventTraceFn; +} + +export interface DataViewInstance { + addRow?(position: InsertToPosition | number): void; + + getSelection?(): DataViewSelection | undefined; + + focusFirstCell(): void; + + showIndicator?(evt: MouseEvent): boolean; + + hideIndicator?(): void; + + moveTo?(id: string, evt: MouseEvent): void; + + view: T; + + eventTrace: EventTraceFn; + + clearSelection(): void; +} diff --git a/blocksuite/affine/data-view/src/core/widget/index.ts b/blocksuite/affine/data-view/src/core/widget/index.ts new file mode 100644 index 0000000000..d4702960d5 --- /dev/null +++ b/blocksuite/affine/data-view/src/core/widget/index.ts @@ -0,0 +1 @@ +export * from './types.js'; diff --git a/blocksuite/affine/data-view/src/core/widget/types.ts b/blocksuite/affine/data-view/src/core/widget/types.ts new file mode 100644 index 0000000000..0be0f6f7b9 --- /dev/null +++ b/blocksuite/affine/data-view/src/core/widget/types.ts @@ -0,0 +1,7 @@ +import type { UniComponent } from '../utils/uni-component/index.js'; +import type { DataViewInstance } from '../view/types.js'; + +export type DataViewWidgetProps = { + dataViewInstance: DataViewInstance; +}; +export type DataViewWidget = UniComponent; diff --git a/blocksuite/affine/data-view/src/core/widget/widget-base.ts b/blocksuite/affine/data-view/src/core/widget/widget-base.ts new file mode 100644 index 0000000000..e284852372 --- /dev/null +++ b/blocksuite/affine/data-view/src/core/widget/widget-base.ts @@ -0,0 +1,31 @@ +import { ShadowlessElement } from '@blocksuite/block-std'; +import { SignalWatcher, WithDisposable } from '@blocksuite/global/utils'; +import { property } from 'lit/decorators.js'; + +import type { DataViewInstance } from '../view/types.js'; +import type { SingleView } from '../view-manager/index.js'; +import type { DataViewWidgetProps } from './types.js'; + +export class WidgetBase + extends SignalWatcher(WithDisposable(ShadowlessElement)) + implements DataViewWidgetProps +{ + get dataSource() { + return this.view.manager.dataSource; + } + + get view() { + return this.dataViewInstance.view; + } + + get viewManager() { + return this.view.manager; + } + + get viewMethods() { + return this.dataViewInstance; + } + + @property({ attribute: false }) + accessor dataViewInstance!: DataViewInstance; +} diff --git a/blocksuite/affine/data-view/src/effects.ts b/blocksuite/affine/data-view/src/effects.ts new file mode 100644 index 0000000000..fff93b3f80 --- /dev/null +++ b/blocksuite/affine/data-view/src/effects.ts @@ -0,0 +1,201 @@ +import { DataViewPropertiesSettingView } from './core/common/properties.js'; +import { Button } from './core/component/button/button.js'; +import { Overflow } from './core/component/overflow/overflow.js'; +import { MultiTagSelect, MultiTagView } from './core/component/tags/index.js'; +import { DataViewRenderer } from './core/data-view.js'; +import { RecordDetail } from './core/detail/detail.js'; +import { RecordField } from './core/detail/field.js'; +import { VariableRefView } from './core/expression/ref/ref-view.js'; +import { BooleanGroupView } from './core/group-by/renderer/boolean-group.js'; +import { NumberGroupView } from './core/group-by/renderer/number-group.js'; +import { SelectGroupView } from './core/group-by/renderer/select-group.js'; +import { StringGroupView } from './core/group-by/renderer/string-group.js'; +import { GroupSetting } from './core/group-by/setting.js'; +import { AffineLitIcon, UniAnyRender, UniLit } from './core/index.js'; +import { AnyRender } from './core/utils/uni-component/render-template.js'; +import { CheckboxCell } from './property-presets/checkbox/cell-renderer.js'; +import { + DateCell, + DateCellEditing, +} from './property-presets/date/cell-renderer.js'; +import { TextCell as ImageTextCell } from './property-presets/image/cell-renderer.js'; +import { + MultiSelectCell, + MultiSelectCellEditing, +} from './property-presets/multi-select/cell-renderer.js'; +import { + NumberCell, + NumberCellEditing, +} from './property-presets/number/cell-renderer.js'; +import { + ProgressCell, + ProgressCellEditing, +} from './property-presets/progress/cell-renderer.js'; +import { + SelectCell, + SelectCellEditing, +} from './property-presets/select/cell-renderer.js'; +import { + TextCell, + TextCellEditing, +} from './property-presets/text/cell-renderer.js'; +import { DataViewKanban, DataViewTable } from './view-presets/index.js'; +import { MobileKanbanCard } from './view-presets/kanban/mobile/card.js'; +import { MobileKanbanCell } from './view-presets/kanban/mobile/cell.js'; +import { MobileKanbanGroup } from './view-presets/kanban/mobile/group.js'; +import { MobileDataViewKanban } from './view-presets/kanban/mobile/kanban-view.js'; +import { KanbanCard } from './view-presets/kanban/pc/card.js'; +import { KanbanCell } from './view-presets/kanban/pc/cell.js'; +import { KanbanGroup } from './view-presets/kanban/pc/group.js'; +import { KanbanHeader } from './view-presets/kanban/pc/header.js'; +import { MobileTableCell } from './view-presets/table/mobile/cell.js'; +import { MobileTableColumnHeader } from './view-presets/table/mobile/column-header.js'; +import { MobileTableGroup } from './view-presets/table/mobile/group.js'; +import { MobileTableHeader } from './view-presets/table/mobile/header.js'; +import { MobileTableRow } from './view-presets/table/mobile/row.js'; +import { MobileDataViewTable } from './view-presets/table/mobile/table-view.js'; +import { DatabaseCellContainer } from './view-presets/table/pc/cell.js'; +import { DragToFillElement } from './view-presets/table/pc/controller/drag-to-fill.js'; +import { SelectionElement } from './view-presets/table/pc/controller/selection.js'; +import { TableGroup } from './view-presets/table/pc/group.js'; +import { DatabaseColumnHeader } from './view-presets/table/pc/header/column-header.js'; +import { DataViewColumnPreview } from './view-presets/table/pc/header/column-renderer.js'; +import { DatabaseHeaderColumn } from './view-presets/table/pc/header/database-header-column.js'; +import { DatabaseNumberFormatBar } from './view-presets/table/pc/header/number-format-bar.js'; +import { TableVerticalIndicator } from './view-presets/table/pc/header/vertical-indicator.js'; +import { TableRow } from './view-presets/table/pc/row/row.js'; +import { RowSelectCheckbox } from './view-presets/table/pc/row/row-select-checkbox.js'; +import { DataBaseColumnStats } from './view-presets/table/stats/column-stats-bar.js'; +import { DatabaseColumnStatsCell } from './view-presets/table/stats/column-stats-column.js'; +import { FilterConditionView } from './widget-presets/quick-setting-bar/filter/condition-view.js'; +import { FilterGroupView } from './widget-presets/quick-setting-bar/filter/group-panel-view.js'; +import { FilterBar } from './widget-presets/quick-setting-bar/filter/list-view.js'; +import { FilterRootView } from './widget-presets/quick-setting-bar/filter/root-panel-view.js'; +import { SortRootView } from './widget-presets/quick-setting-bar/sort/root-panel.js'; +import { DataViewHeaderToolsFilter } from './widget-presets/tools/presets/filter/filter.js'; +import { DataViewHeaderToolsSearch } from './widget-presets/tools/presets/search/search.js'; +import { DataViewHeaderToolsSort } from './widget-presets/tools/presets/sort/sort.js'; +import { DataViewHeaderToolsAddRow } from './widget-presets/tools/presets/table-add-row/add-row.js'; +import { NewRecordPreview } from './widget-presets/tools/presets/table-add-row/new-record-preview.js'; +import { DataViewHeaderToolsViewOptions } from './widget-presets/tools/presets/view-options/view-options.js'; +import { DataViewHeaderTools } from './widget-presets/tools/tools-view.js'; +import { DataViewHeaderViews } from './widget-presets/views-bar/views-view.js'; + +export function effects() { + customElements.define('affine-database-progress-cell', ProgressCell); + customElements.define( + 'affine-database-progress-cell-editing', + ProgressCellEditing + ); + customElements.define('data-view-header-tools', DataViewHeaderTools); + customElements.define('affine-database-number-cell', NumberCell); + customElements.define( + 'affine-database-number-cell-editing', + NumberCellEditing + ); + customElements.define( + 'affine-database-cell-container', + DatabaseCellContainer + ); + customElements.define('mobile-table-cell', MobileTableCell); + customElements.define('affine-data-view-renderer', DataViewRenderer); + customElements.define('any-render', AnyRender); + customElements.define('affine-database-image-cell', ImageTextCell); + customElements.define('affine-database-date-cell', DateCell); + customElements.define('affine-database-date-cell-editing', DateCellEditing); + customElements.define( + 'data-view-properties-setting', + DataViewPropertiesSettingView + ); + customElements.define('affine-database-checkbox-cell', CheckboxCell); + customElements.define('affine-database-text-cell', TextCell); + customElements.define('affine-database-text-cell-editing', TextCellEditing); + customElements.define('affine-database-select-cell', SelectCell); + customElements.define( + 'affine-database-select-cell-editing', + SelectCellEditing + ); + customElements.define('affine-database-multi-select-cell', MultiSelectCell); + customElements.define( + 'affine-database-multi-select-cell-editing', + MultiSelectCellEditing + ); + customElements.define('affine-data-view-record-field', RecordField); + customElements.define('data-view-drag-to-fill', DragToFillElement); + customElements.define('affine-data-view-table-group', TableGroup); + customElements.define('mobile-table-group', MobileTableGroup); + customElements.define( + 'affine-data-view-column-preview', + DataViewColumnPreview + ); + customElements.define('data-view-component-button', Button); + customElements.define('component-overflow', Overflow); + customElements.define('data-view-group-title-select-view', SelectGroupView); + customElements.define('data-view-group-title-string-view', StringGroupView); + customElements.define('affine-data-view-kanban-card', KanbanCard); + customElements.define('mobile-kanban-card', MobileKanbanCard); + customElements.define('filter-bar', FilterBar); + customElements.define('data-view-group-title-number-view', NumberGroupView); + customElements.define('affine-data-view-kanban-cell', KanbanCell); + customElements.define('mobile-kanban-cell', MobileKanbanCell); + customElements.define('affine-lit-icon', AffineLitIcon); + customElements.define('filter-condition-view', FilterConditionView); + customElements.define('data-view-group-setting', GroupSetting); + customElements.define('affine-multi-tag-select', MultiTagSelect); + customElements.define('data-view-group-title-boolean-view', BooleanGroupView); + customElements.define('affine-database-table', DataViewTable); + customElements.define('mobile-data-view-table', MobileDataViewTable); + customElements.define('affine-multi-tag-view', MultiTagView); + customElements.define( + 'data-view-header-tools-search', + DataViewHeaderToolsSearch + ); + customElements.define('uni-lit', UniLit); + customElements.define('uni-any-render', UniAnyRender); + customElements.define('filter-group-view', FilterGroupView); + customElements.define( + 'data-view-header-tools-add-row', + DataViewHeaderToolsAddRow + ); + customElements.define('data-view-table-selection', SelectionElement); + customElements.define('affine-database-new-record-preview', NewRecordPreview); + customElements.define('affine-data-view-kanban-group', KanbanGroup); + customElements.define('mobile-kanban-group', MobileKanbanGroup); + customElements.define( + 'data-view-header-tools-filter', + DataViewHeaderToolsFilter + ); + customElements.define('data-view-header-tools-sort', DataViewHeaderToolsSort); + customElements.define( + 'data-view-header-tools-view-options', + DataViewHeaderToolsViewOptions + ); + customElements.define('affine-data-view-kanban', DataViewKanban); + customElements.define('mobile-data-view-kanban', MobileDataViewKanban); + customElements.define('affine-data-view-kanban-header', KanbanHeader); + customElements.define('variable-ref-view', VariableRefView); + customElements.define('affine-data-view-record-detail', RecordDetail); + customElements.define('filter-root-view', FilterRootView); + customElements.define('sort-root-view', SortRootView); + customElements.define('affine-database-column-header', DatabaseColumnHeader); + customElements.define('mobile-table-header', MobileTableHeader); + customElements.define('data-view-header-views', DataViewHeaderViews); + customElements.define( + 'affine-database-number-format-bar', + DatabaseNumberFormatBar + ); + customElements.define('affine-database-header-column', DatabaseHeaderColumn); + customElements.define('mobile-table-column-header', MobileTableColumnHeader); + customElements.define('row-select-checkbox', RowSelectCheckbox); + customElements.define( + 'data-view-table-vertical-indicator', + TableVerticalIndicator + ); + customElements.define('data-view-table-row', TableRow); + customElements.define('mobile-table-row', MobileTableRow); + customElements.define('affine-database-column-stats', DataBaseColumnStats); + customElements.define( + 'affine-database-column-stats-cell', + DatabaseColumnStatsCell + ); +} diff --git a/blocksuite/affine/data-view/src/index.ts b/blocksuite/affine/data-view/src/index.ts new file mode 100644 index 0000000000..17f45946d4 --- /dev/null +++ b/blocksuite/affine/data-view/src/index.ts @@ -0,0 +1 @@ +export * from './core/index.js'; diff --git a/blocksuite/affine/data-view/src/property-presets/checkbox/cell-renderer.ts b/blocksuite/affine/data-view/src/property-presets/checkbox/cell-renderer.ts new file mode 100644 index 0000000000..a079152627 --- /dev/null +++ b/blocksuite/affine/data-view/src/property-presets/checkbox/cell-renderer.ts @@ -0,0 +1,121 @@ +import { unsafeCSSVarV2 } from '@blocksuite/affine-shared/theme'; +import { CheckBoxCkeckSolidIcon, CheckBoxUnIcon } from '@blocksuite/icons/lit'; +import { css, html } from 'lit'; +import { query } from 'lit/decorators.js'; + +import { BaseCellRenderer } from '../../core/property/index.js'; +import { createFromBaseCellRenderer } from '../../core/property/renderer.js'; +import { createIcon } from '../../core/utils/uni-icon.js'; +import { checkboxPropertyModelConfig } from './define.js'; + +const playCheckAnimation = async ( + refElement: Element, + { left = 0, size = 20 }: { left?: number; size?: number } = {} +) => { + const sparkingEl = document.createElement('div'); + sparkingEl.classList.add('affine-check-animation'); + if (size < 20) { + console.warn('If the size is less than 20, the animation may be abnormal.'); + } + sparkingEl.style.cssText = ` + position: absolute; + width: ${size}px; + height: ${size}px; + border-radius: 50%; + `; + sparkingEl.style.left = `${left}px`; + refElement.append(sparkingEl); + + await sparkingEl.animate( + [ + { + boxShadow: + '0 -18px 0 -8px #1e96eb, 16px -8px 0 -8px #1e96eb, 16px 8px 0 -8px #1e96eb, 0 18px 0 -8px #1e96eb, -16px 8px 0 -8px #1e96eb, -16px -8px 0 -8px #1e96eb', + }, + ], + { duration: 240, easing: 'ease', fill: 'forwards' } + ).finished; + await sparkingEl.animate( + [ + { + boxShadow: + '0 -36px 0 -10px transparent, 32px -16px 0 -10px transparent, 32px 16px 0 -10px transparent, 0 36px 0 -10px transparent, -32px 16px 0 -10px transparent, -32px -16px 0 -10px transparent', + }, + ], + { duration: 360, easing: 'ease', fill: 'forwards' } + ).finished; + + sparkingEl.remove(); +}; + +export class CheckboxCell extends BaseCellRenderer { + static override styles = css` + affine-database-checkbox-cell { + display: block; + width: 100%; + cursor: pointer; + } + + .affine-database-checkbox-container { + height: 100%; + } + + .affine-database-checkbox { + display: flex; + align-items: center; + height: var(--data-view-cell-text-line-height); + width: 100%; + position: relative; + font-size: 24px; + color: ${unsafeCSSVarV2('database/textSecondary')}; + margin-left: -1px; + } + `; + + override beforeEnterEditMode() { + const checked = !this.value; + + this.onChange(checked); + if (checked) { + playCheckAnimation(this._checkbox, { left: 2 }).catch(console.error); + } + return false; + } + + override onCopy(_e: ClipboardEvent) { + _e.preventDefault(); + } + + override onCut(_e: ClipboardEvent) { + _e.preventDefault(); + } + + override onPaste(_e: ClipboardEvent) { + _e.preventDefault(); + } + + override render() { + const checked = this.value ?? false; + const icon = checked + ? CheckBoxCkeckSolidIcon({ style: `color:#1E96EB` }) + : CheckBoxUnIcon(); + return html`
+
+ ${icon} +
+
`; + } + + @query('.affine-database-checkbox') + private accessor _checkbox!: HTMLDivElement; +} + +export const checkboxPropertyConfig = + checkboxPropertyModelConfig.createPropertyMeta({ + icon: createIcon('CheckBoxCheckLinearIcon'), + cellRenderer: { + view: createFromBaseCellRenderer(CheckboxCell), + }, + }); diff --git a/blocksuite/affine/data-view/src/property-presets/checkbox/define.ts b/blocksuite/affine/data-view/src/property-presets/checkbox/define.ts new file mode 100644 index 0000000000..3fda1bcb3b --- /dev/null +++ b/blocksuite/affine/data-view/src/property-presets/checkbox/define.ts @@ -0,0 +1,22 @@ +import { t } from '../../core/logical/type-presets.js'; +import { propertyType } from '../../core/property/property-config.js'; + +export const checkboxPropertyType = propertyType('checkbox'); + +export const checkboxPropertyModelConfig = + checkboxPropertyType.modelConfig({ + name: 'Checkbox', + type: () => t.boolean.instance(), + defaultData: () => ({}), + cellToString: ({ value }) => (value ? 'True' : 'False'), + cellFromString: ({ value }) => { + return { + value: value !== 'False', + }; + }, + cellToJson: ({ value }) => value ?? null, + cellFromJson: ({ value }) => + typeof value !== 'boolean' ? undefined : value, + isEmpty: () => false, + minWidth: 34, + }); diff --git a/blocksuite/affine/data-view/src/property-presets/converts.ts b/blocksuite/affine/data-view/src/property-presets/converts.ts new file mode 100644 index 0000000000..ca3c059ffd --- /dev/null +++ b/blocksuite/affine/data-view/src/property-presets/converts.ts @@ -0,0 +1,45 @@ +import { clamp } from '@blocksuite/affine-shared/utils'; + +import { createPropertyConvert } from '../core/index.js'; +import { multiSelectPropertyModelConfig } from './multi-select/define.js'; +import { numberPropertyModelConfig } from './number/define.js'; +import { progressPropertyModelConfig } from './progress/define.js'; +import { selectPropertyModelConfig } from './select/define.js'; + +export const presetPropertyConverts = [ + createPropertyConvert( + multiSelectPropertyModelConfig, + selectPropertyModelConfig, + (property, cells) => ({ + property, + cells: cells.map(v => v?.[0]), + }) + ), + createPropertyConvert( + numberPropertyModelConfig, + progressPropertyModelConfig, + (_property, cells) => ({ + property: {}, + cells: cells.map(v => clamp(v ?? 0, 0, 100)), + }) + ), + createPropertyConvert( + progressPropertyModelConfig, + numberPropertyModelConfig, + (_property, cells) => ({ + property: { + decimal: 0, + format: 'number' as const, + }, + cells: cells.map(v => v), + }) + ), + createPropertyConvert( + selectPropertyModelConfig, + multiSelectPropertyModelConfig, + (property, cells) => ({ + property, + cells: cells.map(v => (v ? [v] : undefined)), + }) + ), +]; diff --git a/blocksuite/affine/data-view/src/property-presets/date/cell-renderer.ts b/blocksuite/affine/data-view/src/property-presets/date/cell-renderer.ts new file mode 100644 index 0000000000..3df9503b91 --- /dev/null +++ b/blocksuite/affine/data-view/src/property-presets/date/cell-renderer.ts @@ -0,0 +1,222 @@ +import { + popMenu, + popupTargetFromElement, +} from '@blocksuite/affine-components/context-menu'; +import { DatePicker } from '@blocksuite/affine-components/date-picker'; +import { createLitPortal } from '@blocksuite/affine-components/portal'; +import { unsafeCSSVarV2 } from '@blocksuite/affine-shared/theme'; +import { IS_MOBILE } from '@blocksuite/global/env'; +import { flip, offset } from '@floating-ui/dom'; +import { signal } from '@preact/signals-core'; +import { baseTheme } from '@toeverything/theme'; +import { format } from 'date-fns/format'; +import { css, html, unsafeCSS } from 'lit'; + +import { BaseCellRenderer } from '../../core/property/index.js'; +import { createFromBaseCellRenderer } from '../../core/property/renderer.js'; +import { createIcon } from '../../core/utils/uni-icon.js'; +import { datePropertyModelConfig } from './define.js'; + +export class DateCell extends BaseCellRenderer { + static override styles = css` + affine-database-date-cell { + width: 100%; + } + + .affine-database-date { + display: flex; + align-items: center; + width: 100%; + padding: 0; + border: none; + font-family: ${unsafeCSS(baseTheme.fontSansFamily)}; + color: var(--affine-text-primary-color); + font-weight: 400; + background-color: transparent; + font-size: var(--data-view-cell-text-size); + line-height: var(--data-view-cell-text-line-height); + height: var(--data-view-cell-text-line-height); + } + + input.affine-database-date[type='date']::-webkit-calendar-picker-indicator { + display: none; + } + `; + + override render() { + const value = this.value ? format(this.value, 'yyyy/MM/dd') : ''; + if (!value) { + return ''; + } + return html`
${value}
`; + } +} + +export class DateCellEditing extends BaseCellRenderer { + static override styles = css` + affine-database-date-cell-editing { + width: 100%; + cursor: text; + } + + .affine-database-date:focus { + outline: none; + } + `; + + private _prevPortalAbortController: AbortController | null = null; + + private openDatePicker = () => { + if ( + this._prevPortalAbortController && + !this._prevPortalAbortController.signal.aborted + ) + return; + + this.tempValue$.value = this.value ? new Date(this.value) : undefined; + + this._prevPortalAbortController?.abort(); + const abortController = new AbortController(); + abortController.signal.addEventListener( + 'abort', + () => { + this.selectCurrentCell(false); + }, + { once: true } + ); + this._prevPortalAbortController = abortController; + if (IS_MOBILE) { + popMenu(popupTargetFromElement(this), { + options: { + title: { + text: this.property.name$.value, + }, + onClose: () => { + abortController.abort(); + }, + items: [ + () => + html`
+ ${this.dateString} +
`, + () => { + const datePicker = new DatePicker(); + datePicker.padding = 0; + datePicker.value = this.tempValue$.value?.getTime() ?? Date.now(); + datePicker.onChange = (date: Date) => { + this.tempValue$.value = date; + }; + datePicker.onClear = () => { + this.tempValue$.value = undefined; + }; + datePicker.onEscape = () => { + abortController.abort(); + }; + requestAnimationFrame(() => datePicker.focusDateCell()); + return html`
+ ${datePicker} +
`; + }, + ], + }, + }); + } else { + const root = createLitPortal({ + abortController, + closeOnClickAway: true, + computePosition: { + referenceElement: this, + placement: 'bottom', + middleware: [offset(10), flip()], + }, + template: () => { + const datePicker = new DatePicker(); + datePicker.value = this.tempValue$.value?.getTime() ?? Date.now(); + datePicker.popup = true; + datePicker.onClear = () => { + this.tempValue$.value = undefined; + }; + datePicker.onChange = (date: Date) => { + this.tempValue$.value = date; + }; + datePicker.onEscape = () => { + abortController.abort(); + }; + requestAnimationFrame(() => datePicker.focusDateCell()); + return datePicker; + }, + }); + // TODO: use z-index from variable, + // for now the slide-layout-modal's z-index is `1001` + // the z-index of popover should be higher than it + // root.style.zIndex = 'var(--affine-z-index-popover)'; + root.style.zIndex = '1002'; + } + }; + + private updateValue = () => { + const tempValue = this.tempValue$.value; + const currentValue = this.value; + + if ( + (!tempValue && !currentValue) || + (tempValue && currentValue && tempValue.getTime() === currentValue) + ) { + return; + } + + this.onChange(tempValue?.getTime()); + this.tempValue$.value = undefined; + }; + + tempValue$ = signal(); + + get dateString() { + const value = this.tempValue; + return value ? format(value, 'yyyy/MM/dd') : ''; + } + + get tempValue() { + return this.tempValue$.value; + } + + override firstUpdated() { + this.openDatePicker(); + } + + override onExitEditMode() { + this.updateValue(); + this._prevPortalAbortController?.abort(); + } + + override render() { + return html`
+ ${this.dateString} +
`; + } +} + +export const datePropertyConfig = datePropertyModelConfig.createPropertyMeta({ + icon: createIcon('DateTimeIcon'), + cellRenderer: { + view: createFromBaseCellRenderer(DateCell), + edit: createFromBaseCellRenderer(DateCellEditing), + }, +}); diff --git a/blocksuite/affine/data-view/src/property-presets/date/define.ts b/blocksuite/affine/data-view/src/property-presets/date/define.ts new file mode 100644 index 0000000000..27df9e771b --- /dev/null +++ b/blocksuite/affine/data-view/src/property-presets/date/define.ts @@ -0,0 +1,20 @@ +import { t } from '../../core/logical/type-presets.js'; +import { propertyType } from '../../core/property/property-config.js'; + +export const datePropertyType = propertyType('date'); +export const datePropertyModelConfig = datePropertyType.modelConfig({ + name: 'Date', + type: () => t.date.instance(), + defaultData: () => ({}), + cellToString: ({ value }) => value?.toString() ?? '', + cellFromString: ({ value }) => { + const isDateFormat = !isNaN(Date.parse(value)); + + return { + value: isDateFormat ? +new Date(value) : null, + }; + }, + cellToJson: ({ value }) => value ?? null, + cellFromJson: ({ value }) => (typeof value !== 'number' ? undefined : value), + isEmpty: ({ value }) => value == null, +}); diff --git a/blocksuite/affine/data-view/src/property-presets/image/cell-renderer.ts b/blocksuite/affine/data-view/src/property-presets/image/cell-renderer.ts new file mode 100644 index 0000000000..81f4589145 --- /dev/null +++ b/blocksuite/affine/data-view/src/property-presets/image/cell-renderer.ts @@ -0,0 +1,32 @@ +import { css, html } from 'lit'; + +import { BaseCellRenderer } from '../../core/property/index.js'; +import { createFromBaseCellRenderer } from '../../core/property/renderer.js'; +import { createIcon } from '../../core/utils/uni-icon.js'; +import { imagePropertyModelConfig } from './define.js'; + +export class TextCell extends BaseCellRenderer { + static override styles = css` + affine-database-image-cell { + width: 100%; + height: 100%; + display: flex; + align-items: center; + } + affine-database-image-cell img { + width: 20px; + height: 20px; + } + `; + + override render() { + return html``; + } +} + +export const imagePropertyConfig = imagePropertyModelConfig.createPropertyMeta({ + icon: createIcon('ImageIcon'), + cellRenderer: { + view: createFromBaseCellRenderer(TextCell), + }, +}); diff --git a/blocksuite/affine/data-view/src/property-presets/image/define.ts b/blocksuite/affine/data-view/src/property-presets/image/define.ts new file mode 100644 index 0000000000..3bdf8b6b2a --- /dev/null +++ b/blocksuite/affine/data-view/src/property-presets/image/define.ts @@ -0,0 +1,19 @@ +import { t } from '../../core/logical/type-presets.js'; +import { propertyType } from '../../core/property/property-config.js'; + +export const imagePropertyType = propertyType('image'); + +export const imagePropertyModelConfig = imagePropertyType.modelConfig({ + name: 'image', + type: () => t.image.instance(), + defaultData: () => ({}), + cellToString: ({ value }) => value ?? '', + cellFromString: ({ value }) => { + return { + value: value, + }; + }, + cellToJson: ({ value }) => value ?? null, + cellFromJson: ({ value }) => (typeof value !== 'string' ? undefined : value), + isEmpty: ({ value }) => value == null, +}); diff --git a/blocksuite/affine/data-view/src/property-presets/index.ts b/blocksuite/affine/data-view/src/property-presets/index.ts new file mode 100644 index 0000000000..b1b8b3d227 --- /dev/null +++ b/blocksuite/affine/data-view/src/property-presets/index.ts @@ -0,0 +1,22 @@ +import { checkboxPropertyConfig } from './checkbox/cell-renderer.js'; +import { datePropertyConfig } from './date/cell-renderer.js'; +import { imagePropertyConfig } from './image/cell-renderer.js'; +import { multiSelectPropertyConfig } from './multi-select/cell-renderer.js'; +import { numberPropertyConfig } from './number/cell-renderer.js'; +import { progressPropertyConfig } from './progress/cell-renderer.js'; +import { selectPropertyConfig } from './select/cell-renderer.js'; +import { textPropertyConfig } from './text/cell-renderer.js'; + +export * from './converts.js'; +export * from './number/types.js'; +export * from './select/define.js'; +export const propertyPresets = { + checkboxPropertyConfig, + datePropertyConfig, + imagePropertyConfig, + multiSelectPropertyConfig, + numberPropertyConfig, + progressPropertyConfig, + selectPropertyConfig, + textPropertyConfig, +}; diff --git a/blocksuite/affine/data-view/src/property-presets/multi-select/cell-renderer.ts b/blocksuite/affine/data-view/src/property-presets/multi-select/cell-renderer.ts new file mode 100644 index 0000000000..f46a266745 --- /dev/null +++ b/blocksuite/affine/data-view/src/property-presets/multi-select/cell-renderer.ts @@ -0,0 +1,100 @@ +import { popupTargetFromElement } from '@blocksuite/affine-components/context-menu'; +import { computed, signal } from '@preact/signals-core'; +import { html } from 'lit/static-html.js'; + +import { popTagSelect } from '../../core/component/tags/multi-tag-select.js'; +import type { SelectTag } from '../../core/index.js'; +import { BaseCellRenderer } from '../../core/property/index.js'; +import { createFromBaseCellRenderer } from '../../core/property/renderer.js'; +import { createIcon } from '../../core/utils/uni-icon.js'; +import type { SelectPropertyData } from '../select/define.js'; +import { multiSelectPropertyModelConfig } from './define.js'; + +export class MultiSelectCell extends BaseCellRenderer< + string[], + SelectPropertyData +> { + override render() { + return html` + + `; + } +} + +export class MultiSelectCellEditing extends BaseCellRenderer< + string[], + SelectPropertyData +> { + private popTagSelect = () => { + const value = signal(this._value); + this._disposables.add({ + dispose: popTagSelect( + popupTargetFromElement( + this.querySelector('affine-multi-tag-view') ?? this + ), + { + name: this.cell.property.name$.value, + options: this.options$, + onOptionsChange: this._onOptionsChange, + value: value, + onChange: v => { + this._onChange(v); + value.value = v; + }, + onComplete: this._editComplete, + minWidth: 400, + } + ), + }); + }; + + _editComplete = () => { + this.selectCurrentCell(false); + }; + + _onChange = (ids: string[]) => { + this.onChange(ids); + }; + + _onOptionsChange = (options: SelectTag[]) => { + this.property.dataUpdate(data => { + return { + ...data, + options, + }; + }); + }; + + options$ = computed(() => { + return this.property.data$.value.options; + }); + + get _value() { + return this.value ?? []; + } + + override firstUpdated() { + this.popTagSelect(); + } + + override render() { + return html` + + `; + } +} + +export const multiSelectPropertyConfig = + multiSelectPropertyModelConfig.createPropertyMeta({ + icon: createIcon('MultiSelectIcon'), + cellRenderer: { + view: createFromBaseCellRenderer(MultiSelectCell), + edit: createFromBaseCellRenderer(MultiSelectCellEditing), + }, + }); diff --git a/blocksuite/affine/data-view/src/property-presets/multi-select/define.ts b/blocksuite/affine/data-view/src/property-presets/multi-select/define.ts new file mode 100644 index 0000000000..1a9182f5a0 --- /dev/null +++ b/blocksuite/affine/data-view/src/property-presets/multi-select/define.ts @@ -0,0 +1,69 @@ +import { nanoid } from '@blocksuite/store'; + +import { getTagColor } from '../../core/component/tags/colors.js'; +import { type SelectTag, t } from '../../core/index.js'; +import { propertyType } from '../../core/property/property-config.js'; +import type { SelectPropertyData } from '../select/define.js'; + +export const multiSelectPropertyType = propertyType('multi-select'); +export const multiSelectPropertyModelConfig = + multiSelectPropertyType.modelConfig({ + name: 'Multi-select', + type: ({ data }) => t.array.instance(t.tag.instance(data.options)), + defaultData: () => ({ + options: [], + }), + addGroup: ({ text, oldData }) => { + return { + options: [ + ...(oldData.options ?? []), + { + id: nanoid(), + value: text, + color: getTagColor(), + }, + ], + }; + }, + formatValue: ({ value }) => { + if (Array.isArray(value)) { + return value.filter(v => v != null); + } + return []; + }, + cellToString: ({ value, data }) => + value?.map(id => data.options.find(v => v.id === id)?.value).join(','), + cellFromString: ({ value: oldValue, data }) => { + const optionMap = Object.fromEntries(data.options.map(v => [v.value, v])); + const optionNames = oldValue + .split(',') + .map(v => v.trim()) + .filter(v => v); + + const value: string[] = []; + optionNames.forEach(name => { + if (!optionMap[name]) { + const newOption: SelectTag = { + id: nanoid(), + value: name, + color: getTagColor(), + }; + data.options.push(newOption); + value.push(newOption.id); + } else { + value.push(optionMap[name].id); + } + }); + + return { + value, + data: data, + }; + }, + cellToJson: ({ value }) => value ?? null, + cellFromJson: ({ value }) => + Array.isArray(value) && value.every(v => typeof v === 'string') + ? value + : undefined, + isEmpty: ({ value }) => value == null || value.length === 0, + }); diff --git a/blocksuite/affine/data-view/src/property-presets/number/cell-renderer.ts b/blocksuite/affine/data-view/src/property-presets/number/cell-renderer.ts new file mode 100644 index 0000000000..5d753bd620 --- /dev/null +++ b/blocksuite/affine/data-view/src/property-presets/number/cell-renderer.ts @@ -0,0 +1,196 @@ +import { IS_MAC } from '@blocksuite/global/env'; +import { baseTheme } from '@toeverything/theme'; +import { css, html, unsafeCSS } from 'lit'; +import { query } from 'lit/decorators.js'; + +import { BaseCellRenderer } from '../../core/property/index.js'; +import { createFromBaseCellRenderer } from '../../core/property/renderer.js'; +import { stopPropagation } from '../../core/utils/event.js'; +import { createIcon } from '../../core/utils/uni-icon.js'; +import { numberPropertyModelConfig } from './define.js'; +import type { NumberPropertyDataType } from './types.js'; +import { + formatNumber, + type NumberFormat, + parseNumber, +} from './utils/formatter.js'; + +export class NumberCell extends BaseCellRenderer< + number, + NumberPropertyDataType +> { + static override styles = css` + affine-database-number-cell { + display: block; + width: 100%; + } + + .affine-database-number { + overflow: hidden; + display: flex; + align-items: center; + justify-content: flex-end; + width: 100%; + padding: 0; + border: none; + font-family: ${unsafeCSS(baseTheme.fontSansFamily)}; + font-size: var(--data-view-cell-text-size); + line-height: var(--data-view-cell-text-line-height); + color: var(--affine-text-primary-color); + font-weight: 400; + background-color: transparent; + word-break: break-all; + } + `; + + private _getFormattedString() { + const enableNewFormatting = + this.view.featureFlags$.value.enable_number_formatting; + const decimals = this.property.data$.value.decimal ?? 0; + const formatMode = (this.property.data$.value.format ?? + 'number') as NumberFormat; + + return this.value != undefined + ? enableNewFormatting + ? formatNumber(this.value, formatMode, decimals) + : this.value.toString() + : ''; + } + + override render() { + return html`
+ ${this._getFormattedString()} +
`; + } +} + +export class NumberCellEditing extends BaseCellRenderer< + number, + NumberPropertyDataType +> { + static override styles = css` + affine-database-number-cell-editing { + display: block; + width: 100%; + cursor: text; + } + + .affine-database-number { + display: flex; + align-items: center; + width: 100%; + padding: 0; + border: none; + font-family: ${unsafeCSS(baseTheme.fontSansFamily)}; + font-size: var(--data-view-cell-text-size); + line-height: var(--data-view-cell-text-line-height); + color: var(--affine-text-primary-color); + font-weight: 400; + background-color: transparent; + text-align: right; + } + + .affine-database-number:focus { + outline: none; + } + `; + + private _getFormattedString = (value: number) => { + const enableNewFormatting = + this.view.featureFlags$.value.enable_number_formatting; + const decimals = this.property.data$.value.decimal ?? 0; + const formatMode = (this.property.data$.value.format ?? + 'number') as NumberFormat; + return enableNewFormatting + ? formatNumber(value, formatMode, decimals) + : value.toString(); + }; + + private _keydown = (e: KeyboardEvent) => { + const ctrlKey = IS_MAC ? e.metaKey : e.ctrlKey; + + if (e.key.toLowerCase() === 'z' && ctrlKey) { + e.stopPropagation(); + return; + } + + if (e.key === 'Enter' && !e.isComposing) { + requestAnimationFrame(() => { + this.selectCurrentCell(false); + }); + } + }; + + private _setValue = (str: string = this._inputEle.value) => { + if (!str) { + this.onChange(undefined); + return; + } + + const enableNewFormatting = + this.view.featureFlags$.value.enable_number_formatting; + const value = enableNewFormatting ? parseNumber(str) : parseFloat(str); + if (isNaN(value)) { + this._inputEle.value = this.value + ? this._getFormattedString(this.value) + : ''; + return; + } + + this._inputEle.value = this._getFormattedString(value); + this.onChange(value); + }; + + focusEnd = () => { + const end = this._inputEle.value.length; + this._inputEle.focus(); + this._inputEle.setSelectionRange(end, end); + }; + + _blur() { + this.selectCurrentCell(false); + } + + _focus() { + if (!this.isEditing) { + this.selectCurrentCell(true); + } + } + + override firstUpdated() { + requestAnimationFrame(() => { + this.focusEnd(); + }); + } + + override onExitEditMode() { + this._setValue(); + } + + override render() { + const formatted = this.value ? this._getFormattedString(this.value) : ''; + + return html``; + } + + @query('input') + private accessor _inputEle!: HTMLInputElement; +} + +export const numberPropertyConfig = + numberPropertyModelConfig.createPropertyMeta({ + icon: createIcon('NumberIcon'), + cellRenderer: { + view: createFromBaseCellRenderer(NumberCell), + edit: createFromBaseCellRenderer(NumberCellEditing), + }, + }); diff --git a/blocksuite/affine/data-view/src/property-presets/number/define.ts b/blocksuite/affine/data-view/src/property-presets/number/define.ts new file mode 100644 index 0000000000..b39932b59b --- /dev/null +++ b/blocksuite/affine/data-view/src/property-presets/number/define.ts @@ -0,0 +1,24 @@ +import { t } from '../../core/logical/type-presets.js'; +import { propertyType } from '../../core/property/property-config.js'; +import type { NumberPropertyDataType } from './types.js'; + +export const numberPropertyType = propertyType('number'); + +export const numberPropertyModelConfig = numberPropertyType.modelConfig< + number, + NumberPropertyDataType +>({ + name: 'Number', + type: () => t.number.instance(), + defaultData: () => ({ decimal: 0, format: 'number' }), + cellToString: ({ value }) => value?.toString() ?? '', + cellFromString: ({ value }) => { + const num = value ? Number(value) : NaN; + return { + value: isNaN(num) ? null : num, + }; + }, + cellToJson: ({ value }) => value ?? null, + cellFromJson: ({ value }) => (typeof value !== 'number' ? undefined : value), + isEmpty: ({ value }) => value == null, +}); diff --git a/blocksuite/affine/data-view/src/property-presets/number/index.ts b/blocksuite/affine/data-view/src/property-presets/number/index.ts new file mode 100644 index 0000000000..d4702960d5 --- /dev/null +++ b/blocksuite/affine/data-view/src/property-presets/number/index.ts @@ -0,0 +1 @@ +export * from './types.js'; diff --git a/blocksuite/affine/data-view/src/property-presets/number/types.ts b/blocksuite/affine/data-view/src/property-presets/number/types.ts new file mode 100644 index 0000000000..f0f0f790a7 --- /dev/null +++ b/blocksuite/affine/data-view/src/property-presets/number/types.ts @@ -0,0 +1,6 @@ +import type { NumberFormat } from './utils/formatter.js'; + +export type NumberPropertyDataType = { + decimal?: number; + format?: NumberFormat; +}; diff --git a/blocksuite/affine/data-view/src/property-presets/number/utils/formats.ts b/blocksuite/affine/data-view/src/property-presets/number/utils/formats.ts new file mode 100644 index 0000000000..e143757148 --- /dev/null +++ b/blocksuite/affine/data-view/src/property-presets/number/utils/formats.ts @@ -0,0 +1,19 @@ +import type { NumberFormat } from './formatter.js'; + +export type NumberCellFormat = { + type: NumberFormat; + label: string; + symbol: string; // New property for symbol +}; + +export const numberFormats: NumberCellFormat[] = [ + { type: 'number', label: 'Number', symbol: '#' }, + { type: 'numberWithCommas', label: 'Number With Commas', symbol: '#' }, + { type: 'percent', label: 'Percent', symbol: '%' }, + { type: 'currencyYen', label: 'Japanese Yen', symbol: '¥' }, + { type: 'currencyCNY', label: 'Chinese Yuan', symbol: '¥' }, + { type: 'currencyINR', label: 'Indian Rupee', symbol: '₹' }, + { type: 'currencyUSD', label: 'US Dollar', symbol: '$' }, + { type: 'currencyEUR', label: 'Euro', symbol: '€' }, + { type: 'currencyGBP', label: 'British Pound', symbol: '£' }, +]; diff --git a/blocksuite/affine/data-view/src/property-presets/number/utils/formatter.ts b/blocksuite/affine/data-view/src/property-presets/number/utils/formatter.ts new file mode 100644 index 0000000000..4b418d5eea --- /dev/null +++ b/blocksuite/affine/data-view/src/property-presets/number/utils/formatter.ts @@ -0,0 +1,101 @@ +export type NumberFormat = + | 'number' + | 'numberWithCommas' + | 'percent' + | 'currencyYen' + | 'currencyINR' + | 'currencyCNY' + | 'currencyUSD' + | 'currencyEUR' + | 'currencyGBP'; + +const currency = (currency: string): Intl.NumberFormatOptions => ({ + style: 'currency', + currency, + currencyDisplay: 'symbol', +}); + +const numberFormatDefaultConfig: Record< + NumberFormat, + Intl.NumberFormatOptions +> = { + number: { style: 'decimal', useGrouping: false }, + numberWithCommas: { style: 'decimal', useGrouping: true }, + percent: { style: 'percent', useGrouping: false }, + currencyINR: currency('INR'), + currencyYen: currency('JPY'), + currencyCNY: currency('CNY'), + currencyUSD: currency('USD'), + currencyEUR: currency('EUR'), + currencyGBP: currency('GBP'), +}; + +export function formatNumber( + value: number, + format: NumberFormat, + decimals?: number +) { + const formatterOptions = { ...numberFormatDefaultConfig[format] }; + if (decimals !== undefined) { + // for feature flag should default to 0 after release + Object.assign(formatterOptions, { + minimumFractionDigits: decimals, + maximumFractionDigits: decimals, + }); + } + const formatter = new Intl.NumberFormat(navigator.language, formatterOptions); + return formatter.format(value); +} + +export function getLocaleDecimalSeparator(locale?: string) { + return (1.1).toLocaleString(locale ?? navigator.language).slice(1, 2); +} + +// Since we Intl does not provide a parse function we just made it ourself +export function parseNumber(value: string, decimalSeparator?: string): number { + decimalSeparator = decimalSeparator ?? getLocaleDecimalSeparator(); + + // Normalize decimal separator to a period for consistency + const normalizedValue = value.replace( + new RegExp(`\\${decimalSeparator}`, 'g'), + '.' + ); + + // Remove any leading and trailing non-numeric characters except valid signs, decimal points, and exponents + let sanitizedValue = normalizedValue.replace(/^[^\d-+eE.]+|[^\d]+$/g, ''); + + // Remove non-numeric characters except decimal points, exponents, and valid signs + sanitizedValue = sanitizedValue.replace(/[^0-9.eE+-]/g, ''); + + // Handle multiple signs: Keep only the first sign + sanitizedValue = sanitizedValue.replace(/([-+]){2,}/g, '$1'); + + // Handle misplaced signs: Keep only the leading sign and sign after 'e' or 'E' + sanitizedValue = sanitizedValue.replace( + /^([-+]?)[^eE]*([eE][-+]?\d+)?$/, + (_, p1, p2) => + p1 + + sanitizedValue.replace(/[eE].*/, '').replace(/[^\d.]/g, '') + + (p2 || '') + ); + + // Handle multiple decimal points: Keep only the first one in the main part + sanitizedValue = sanitizedValue.replace(/(\..*)\./g, '$1'); + + // If there is an 'e' or 'E', handle the scientific notation + if (/[eE]/.test(sanitizedValue)) { + const [base, exp] = sanitizedValue.split(/[eE]/); + if ( + !base || + !exp || + exp.includes('.') || + exp.includes('e') || + exp.includes('E') + ) { + return NaN; // Invalid scientific notation + } + return parseFloat(sanitizedValue); + } + + return parseFloat(sanitizedValue); +} diff --git a/blocksuite/affine/data-view/src/property-presets/progress/cell-renderer.ts b/blocksuite/affine/data-view/src/property-presets/progress/cell-renderer.ts new file mode 100644 index 0000000000..157192f5fa --- /dev/null +++ b/blocksuite/affine/data-view/src/property-presets/progress/cell-renderer.ts @@ -0,0 +1,225 @@ +import { css, html } from 'lit'; +import { query, state } from 'lit/decorators.js'; +import { styleMap } from 'lit/directives/style-map.js'; + +import { BaseCellRenderer } from '../../core/property/index.js'; +import { createFromBaseCellRenderer } from '../../core/property/renderer.js'; +import { startDrag } from '../../core/utils/drag.js'; +import { createIcon } from '../../core/utils/uni-icon.js'; +import { progressPropertyModelConfig } from './define.js'; + +const styles = css` + affine-database-progress-cell-editing { + display: block; + width: 100%; + padding: 0 4px; + } + + affine-database-progress-cell { + display: block; + width: 100%; + padding: 0 4px; + } + + .affine-database-progress { + display: flex; + align-items: center; + height: var(--data-view-cell-text-line-height); + gap: 4px; + } + + .affine-database-progress-bar { + position: relative; + width: 104px; + } + + .affine-database-progress-bg { + overflow: hidden; + width: 100%; + height: 10px; + border-radius: 22px; + } + + .affine-database-progress-fg { + height: 100%; + } + + .affine-database-progress-drag-handle { + position: absolute; + top: 0; + left: 0; + transform: translate(0px, -1px); + width: 6px; + height: 12px; + border-radius: 2px; + opacity: 1; + cursor: ew-resize; + background: var(--affine-primary-color); + transition: opacity 0.2s ease-in-out; + } + + .progress-number { + display: flex; + justify-content: center; + align-items: center; + height: 18px; + width: 25px; + color: var(--affine-text-secondary-color); + font-size: 14px; + } +`; + +const progressColors = { + empty: 'var(--affine-black-10)', + processing: 'var(--affine-processing-color)', + success: 'var(--affine-success-color)', +}; + +export class ProgressCell extends BaseCellRenderer { + static override styles = styles; + + protected override render() { + const progress = this.value ?? 0; + let backgroundColor = progressColors.processing; + if (progress === 100) { + backgroundColor = progressColors.success; + } + const fgStyles = styleMap({ + width: `${progress}%`, + backgroundColor, + }); + const bgStyles = styleMap({ + backgroundColor: + progress === 0 ? progressColors.empty : 'var(--affine-hover-color)', + }); + + return html`
+
+
+
+
+
+
${progress}
+
`; + } +} + +export class ProgressCellEditing extends BaseCellRenderer { + static override styles = styles; + + startDrag = (event: MouseEvent) => { + const bgRect = this._progressBg.getBoundingClientRect(); + const min = bgRect.left; + const max = bgRect.right; + const setValue = (x: number) => { + this.tempValue = Math.round( + ((Math.min(max, Math.max(min, x)) - min) / (max - min)) * 100 + ); + }; + startDrag(event, { + onDrag: ({ x }) => { + setValue(x); + return; + }, + onMove: ({ x }) => { + setValue(x); + return; + }, + onDrop: () => { + // + }, + onClear: () => { + // + }, + }); + }; + + get _value() { + return this.tempValue ?? this.value ?? 0; + } + + _onChange(value?: number) { + this.tempValue = value; + } + + override firstUpdated() { + const disposables = this._disposables; + + disposables.addFromEvent(this._progressBg, 'pointerdown', this.startDrag); + disposables.addFromEvent(window, 'keydown', evt => { + if (evt.key === 'ArrowDown') { + evt.preventDefault(); + this._onChange(Math.max(0, this._value - 1)); + return; + } + if (evt.key === 'ArrowUp') { + evt.preventDefault(); + this._onChange(Math.min(100, this._value + 1)); + return; + } + }); + } + + override onCopy(_e: ClipboardEvent) { + _e.preventDefault(); + } + + override onCut(_e: ClipboardEvent) { + _e.preventDefault(); + } + + override onExitEditMode() { + this.onChange(this._value); + } + + override onPaste(_e: ClipboardEvent) { + _e.preventDefault(); + } + + protected override render() { + const progress = this._value; + let backgroundColor = progressColors.processing; + if (progress === 100) { + backgroundColor = progressColors.success; + } + const fgStyles = styleMap({ + width: `${progress}%`, + backgroundColor, + }); + const bgStyles = styleMap({ + backgroundColor: + progress === 0 ? progressColors.empty : 'var(--affine-hover-color)', + }); + const handleStyles = styleMap({ + left: `calc(${progress}% - 3px)`, + }); + + return html`
+
+
+
+
+
+
+
${progress}
+
`; + } + + @query('.affine-database-progress-bg') + private accessor _progressBg!: HTMLElement; + + @state() + private accessor tempValue: number | undefined = undefined; +} + +export const progressPropertyConfig = + progressPropertyModelConfig.createPropertyMeta({ + icon: createIcon('ProgressIcon'), + cellRenderer: { + view: createFromBaseCellRenderer(ProgressCell), + edit: createFromBaseCellRenderer(ProgressCellEditing), + }, + }); diff --git a/blocksuite/affine/data-view/src/property-presets/progress/define.ts b/blocksuite/affine/data-view/src/property-presets/progress/define.ts new file mode 100644 index 0000000000..4fbc660cfc --- /dev/null +++ b/blocksuite/affine/data-view/src/property-presets/progress/define.ts @@ -0,0 +1,24 @@ +import { t } from '../../core/logical/type-presets.js'; +import { propertyType } from '../../core/property/property-config.js'; + +export const progressPropertyType = propertyType('progress'); + +export const progressPropertyModelConfig = + progressPropertyType.modelConfig({ + name: 'Progress', + type: () => t.number.instance(), + defaultData: () => ({}), + cellToString: ({ value }) => value?.toString() ?? '', + cellFromString: ({ value }) => { + const num = value ? Number(value) : NaN; + return { + value: isNaN(num) ? null : num, + }; + }, + cellToJson: ({ value }) => value ?? null, + cellFromJson: ({ value }) => { + if (typeof value !== 'number') return undefined; + return value; + }, + isEmpty: () => false, + }); diff --git a/blocksuite/affine/data-view/src/property-presets/pure-index.ts b/blocksuite/affine/data-view/src/property-presets/pure-index.ts new file mode 100644 index 0000000000..3987e75fc8 --- /dev/null +++ b/blocksuite/affine/data-view/src/property-presets/pure-index.ts @@ -0,0 +1,19 @@ +import { checkboxPropertyModelConfig } from './checkbox/define.js'; +import { datePropertyModelConfig } from './date/define.js'; +import { imagePropertyModelConfig } from './image/define.js'; +import { multiSelectPropertyModelConfig } from './multi-select/define.js'; +import { numberPropertyModelConfig } from './number/define.js'; +import { progressPropertyModelConfig } from './progress/define.js'; +import { selectPropertyModelConfig } from './select/define.js'; +import { textPropertyModelConfig } from './text/define.js'; + +export const propertyModelPresets = { + checkboxPropertyModelConfig, + datePropertyModelConfig, + imagePropertyModelConfig, + multiSelectPropertyModelConfig, + numberPropertyModelConfig, + progressPropertyModelConfig, + selectPropertyModelConfig, + textPropertyModelConfig, +}; diff --git a/blocksuite/affine/data-view/src/property-presets/select/cell-renderer.ts b/blocksuite/affine/data-view/src/property-presets/select/cell-renderer.ts new file mode 100644 index 0000000000..ae3347ee39 --- /dev/null +++ b/blocksuite/affine/data-view/src/property-presets/select/cell-renderer.ts @@ -0,0 +1,102 @@ +import { popupTargetFromElement } from '@blocksuite/affine-components/context-menu'; +import { computed, signal } from '@preact/signals-core'; +import { html } from 'lit/static-html.js'; + +import { popTagSelect } from '../../core/component/tags/multi-tag-select.js'; +import type { SelectTag } from '../../core/index.js'; +import { BaseCellRenderer } from '../../core/property/index.js'; +import { createFromBaseCellRenderer } from '../../core/property/renderer.js'; +import { createIcon } from '../../core/utils/uni-icon.js'; +import { + type SelectPropertyData, + selectPropertyModelConfig, +} from './define.js'; + +export class SelectCell extends BaseCellRenderer { + override render() { + const value = this.value ? [this.value] : []; + return html` + + `; + } +} + +export class SelectCellEditing extends BaseCellRenderer< + string, + SelectPropertyData +> { + private popTagSelect = () => { + const value = signal(this._value); + this._disposables.add({ + dispose: popTagSelect( + popupTargetFromElement( + this.querySelector('affine-multi-tag-view') ?? this + ), + { + name: this.cell.property.name$.value, + mode: 'single', + options: this.options$, + onOptionsChange: this._onOptionsChange, + value: signal(this._value), + onChange: v => { + this._onChange(v); + value.value = v; + }, + onComplete: this._editComplete, + minWidth: 400, + } + ), + }); + }; + + _editComplete = () => { + this.selectCurrentCell(false); + }; + + _onChange = ([id]: string[]) => { + this.onChange(id); + }; + + _onOptionsChange = (options: SelectTag[]) => { + this.property.dataUpdate(data => { + return { + ...data, + options, + }; + }); + }; + + options$ = computed(() => { + return this.property.data$.value.options; + }); + + get _value() { + const value = this.value; + return value ? [value] : []; + } + + override firstUpdated() { + this.popTagSelect(); + } + + override render() { + return html` + + `; + } +} + +export const selectPropertyConfig = + selectPropertyModelConfig.createPropertyMeta({ + icon: createIcon('SingleSelectIcon'), + cellRenderer: { + view: createFromBaseCellRenderer(SelectCell), + edit: createFromBaseCellRenderer(SelectCellEditing), + }, + }); diff --git a/blocksuite/affine/data-view/src/property-presets/select/define.ts b/blocksuite/affine/data-view/src/property-presets/select/define.ts new file mode 100644 index 0000000000..4503eb4d3f --- /dev/null +++ b/blocksuite/affine/data-view/src/property-presets/select/define.ts @@ -0,0 +1,63 @@ +import { nanoid } from '@blocksuite/store'; + +import { getTagColor } from '../../core/component/tags/colors.js'; +import { type SelectTag, t } from '../../core/index.js'; +import { propertyType } from '../../core/property/property-config.js'; + +export const selectPropertyType = propertyType('select'); + +export type SelectPropertyData = { + options: SelectTag[]; +}; +export const selectPropertyModelConfig = selectPropertyType.modelConfig< + string, + SelectPropertyData +>({ + name: 'Select', + type: ({ data }) => t.tag.instance(data.options), + defaultData: () => ({ + options: [], + }), + addGroup: ({ text, oldData }) => { + return { + options: [ + ...(oldData.options ?? []), + { id: nanoid(), value: text, color: getTagColor() }, + ], + }; + }, + cellToString: ({ value, data }) => + data.options.find(v => v.id === value)?.value ?? '', + cellFromString: ({ value: oldValue, data }) => { + if (!oldValue) { + return { value: null, data: data }; + } + const optionMap = Object.fromEntries(data.options.map(v => [v.value, v])); + const name = oldValue + .split(',') + .map(v => v.trim()) + .filter(v => v)[0]; + + let value: string | undefined; + const option = optionMap[name]; + if (!option) { + const newOption: SelectTag = { + id: nanoid(), + value: name, + color: getTagColor(), + }; + data.options.push(newOption); + value = newOption.id; + } else { + value = option.id; + } + + return { + value, + data: data, + }; + }, + cellToJson: ({ value }) => value ?? null, + cellFromJson: ({ value }) => (typeof value !== 'string' ? undefined : value), + isEmpty: ({ value }) => value == null, +}); diff --git a/blocksuite/affine/data-view/src/property-presets/text/cell-renderer.ts b/blocksuite/affine/data-view/src/property-presets/text/cell-renderer.ts new file mode 100644 index 0000000000..7102f7a53e --- /dev/null +++ b/blocksuite/affine/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-database-text-cell { + display: block; + width: 100%; + height: 100%; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + .affine-database-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-database-text-cell-editing { + display: block; + width: 100%; + height: 100%; + cursor: text; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + .affine-database-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-database-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/blocksuite/affine/data-view/src/property-presets/text/define.ts b/blocksuite/affine/data-view/src/property-presets/text/define.ts new file mode 100644 index 0000000000..8df3b2a312 --- /dev/null +++ b/blocksuite/affine/data-view/src/property-presets/text/define.ts @@ -0,0 +1,19 @@ +import { t } from '../../core/index.js'; +import { propertyType } from '../../core/property/property-config.js'; + +export const textPropertyType = propertyType('text'); + +export const textPropertyModelConfig = textPropertyType.modelConfig({ + name: 'Plain-Text', + type: () => t.string.instance(), + defaultData: () => ({}), + cellToString: ({ value }) => value ?? '', + cellFromString: ({ value }) => { + return { + value: value, + }; + }, + cellToJson: ({ value }) => value ?? null, + cellFromJson: ({ value }) => (typeof value !== 'string' ? undefined : value), + isEmpty: ({ value }) => value == null || value.length === 0, +}); diff --git a/blocksuite/affine/data-view/src/view-presets/convert.ts b/blocksuite/affine/data-view/src/view-presets/convert.ts new file mode 100644 index 0000000000..19e57fa210 --- /dev/null +++ b/blocksuite/affine/data-view/src/view-presets/convert.ts @@ -0,0 +1,21 @@ +import { createViewConvert } from '../core/view/convert.js'; +import { kanbanViewModel } from './kanban/index.js'; +import { tableViewModel } from './table/index.js'; + +export const viewConverts = [ + createViewConvert(tableViewModel, kanbanViewModel, data => { + if (data.groupBy) { + return { + filter: data.filter, + groupBy: data.groupBy, + }; + } + return { + filter: data.filter, + }; + }), + createViewConvert(kanbanViewModel, tableViewModel, data => ({ + filter: data.filter, + groupBy: data.groupBy, + })), +]; diff --git a/blocksuite/affine/data-view/src/view-presets/index.ts b/blocksuite/affine/data-view/src/view-presets/index.ts new file mode 100644 index 0000000000..b4b46ce58b --- /dev/null +++ b/blocksuite/affine/data-view/src/view-presets/index.ts @@ -0,0 +1,11 @@ +import { kanbanViewMeta } from './kanban/index.js'; +import { tableViewMeta } from './table/index.js'; + +export * from './convert.js'; +export * from './kanban/index.js'; +export * from './table/index.js'; + +export const viewPresets = { + tableViewMeta: tableViewMeta, + kanbanViewMeta: kanbanViewMeta, +}; diff --git a/blocksuite/affine/data-view/src/view-presets/kanban/define.ts b/blocksuite/affine/data-view/src/view-presets/kanban/define.ts new file mode 100644 index 0000000000..e8bfb3b22e --- /dev/null +++ b/blocksuite/affine/data-view/src/view-presets/kanban/define.ts @@ -0,0 +1,85 @@ +import { BlockSuiteError, ErrorCode } from '@blocksuite/global/exceptions'; + +import type { GroupBy, GroupProperty } from '../../core/common/types.js'; +import type { FilterGroup } from '../../core/filter/types.js'; +import { defaultGroupBy, groupByMatcher, t } from '../../core/index.js'; +import type { Sort } from '../../core/sort/types.js'; +import { type BasicViewDataType, viewType } from '../../core/view/data-view.js'; +import { KanbanSingleView } from './kanban-view-manager.js'; + +export const kanbanViewType = viewType('kanban'); + +export type KanbanViewColumn = { + id: string; + hide?: boolean; +}; + +type DataType = { + columns: KanbanViewColumn[]; + filter: FilterGroup; + groupBy?: GroupBy; + sort?: Sort; + header: { + titleColumn?: string; + iconColumn?: string; + coverColumn?: string; + }; + groupProperties: GroupProperty[]; +}; +export type KanbanViewData = BasicViewDataType< + typeof kanbanViewType.type, + DataType +>; +export const kanbanViewModel = kanbanViewType.createModel({ + defaultName: 'Kanban View', + dataViewManager: KanbanSingleView, + defaultData: viewManager => { + const columns = viewManager.dataSource.properties$.value; + const allowList = columns.filter(columnId => { + const dataType = viewManager.dataSource.propertyDataTypeGet(columnId); + return dataType && !!groupByMatcher.match(dataType); + }); + const getWeight = (columnId: string) => { + const dataType = viewManager.dataSource.propertyDataTypeGet(columnId); + if (!dataType || t.string.is(dataType) || t.richText.is(dataType)) { + return 0; + } + if (t.tag.is(dataType)) { + return 3; + } + if (t.array.is(dataType)) { + return 2; + } + return 1; + }; + const columnId = allowList.sort((a, b) => getWeight(b) - getWeight(a))[0]; + const type = viewManager.dataSource.propertyTypeGet(columnId); + const meta = type && viewManager.dataSource.propertyMetaGet(type); + const data = viewManager.dataSource.propertyDataGet(columnId); + if (!columnId || !meta || !data) { + throw new BlockSuiteError( + ErrorCode.DatabaseBlockError, + 'not implement yet' + ); + } + return { + columns: columns.map(id => ({ + id: id, + hide: false, + })), + filter: { + type: 'group', + op: 'and', + conditions: [], + }, + groupBy: defaultGroupBy(viewManager.dataSource, meta, columnId, data), + header: { + titleColumn: viewManager.dataSource.properties$.value.find( + id => viewManager.dataSource.propertyTypeGet(id) === 'title' + ), + iconColumn: 'type', + }, + groupProperties: [], + }; + }, +}); diff --git a/blocksuite/affine/data-view/src/view-presets/kanban/index.ts b/blocksuite/affine/data-view/src/view-presets/kanban/index.ts new file mode 100644 index 0000000000..b83489afcb --- /dev/null +++ b/blocksuite/affine/data-view/src/view-presets/kanban/index.ts @@ -0,0 +1,4 @@ +export * from './define.js'; +export * from './kanban-view-manager.js'; +export * from './pc/kanban-view.js'; +export * from './renderer.js'; diff --git a/blocksuite/affine/data-view/src/view-presets/kanban/kanban-view-manager.ts b/blocksuite/affine/data-view/src/view-presets/kanban/kanban-view-manager.ts new file mode 100644 index 0000000000..e0c8580ce5 --- /dev/null +++ b/blocksuite/affine/data-view/src/view-presets/kanban/kanban-view-manager.ts @@ -0,0 +1,315 @@ +import { + insertPositionToIndex, + type InsertToPosition, +} from '@blocksuite/affine-shared/utils'; +import { computed, type ReadonlySignal } from '@preact/signals-core'; + +import { evalFilter } from '../../core/filter/eval.js'; +import { generateDefaultValues } from '../../core/filter/generate-default-values.js'; +import { FilterTrait, filterTraitKey } from '../../core/filter/trait.js'; +import type { FilterGroup } from '../../core/filter/types.js'; +import { emptyFilterGroup } from '../../core/filter/utils.js'; +import { + GroupTrait, + groupTraitKey, + sortByManually, +} from '../../core/group-by/trait.js'; +import { PropertyBase } from '../../core/view-manager/property.js'; +import { SingleViewBase } from '../../core/view-manager/single-view.js'; +import type { KanbanViewData } from './define.js'; + +export class KanbanSingleView extends SingleViewBase { + propertiesWithoutFilter$ = computed(() => { + const needShow = new Set(this.dataSource.properties$.value); + const result: string[] = []; + this.data$.value?.columns.forEach(v => { + if (needShow.has(v.id)) { + result.push(v.id); + needShow.delete(v.id); + } + }); + result.push(...needShow); + return result; + }); + + detailProperties$ = computed(() => { + return this.propertiesWithoutFilter$.value.filter( + id => this.propertyTypeGet(id) !== 'title' + ); + }); + + filter$ = computed(() => { + return this.data$.value?.filter ?? emptyFilterGroup; + }); + + filterTrait = this.traitSet( + filterTraitKey, + new FilterTrait(this.filter$, this, { + filterSet: filter => { + this.dataUpdate(() => { + return { + filter, + }; + }); + }, + }) + ); + + groupBy$ = computed(() => { + return this.data$.value?.groupBy; + }); + + groupTrait = this.traitSet( + groupTraitKey, + new GroupTrait(this.groupBy$, this, { + groupBySet: groupBy => { + this.dataUpdate(() => { + return { + groupBy: groupBy, + }; + }); + }, + sortGroup: ids => + sortByManually( + ids, + v => v, + this.view?.groupProperties.map(v => v.key) ?? [] + ), + sortRow: (key, ids) => { + const property = this.view?.groupProperties.find(v => v.key === key); + return sortByManually(ids, v => v, property?.manuallyCardSort ?? []); + }, + changeGroupSort: keys => { + const map = new Map(this.view?.groupProperties.map(v => [v.key, v])); + this.dataUpdate(() => { + return { + groupProperties: keys.map(key => { + const property = map.get(key); + if (property) { + return property; + } + return { + key, + hide: false, + manuallyCardSort: [], + }; + }), + }; + }); + }, + changeRowSort: (groupKeys, groupKey, keys) => { + const map = new Map(this.view?.groupProperties.map(v => [v.key, v])); + this.dataUpdate(() => { + return { + groupProperties: groupKeys.map(key => { + if (key === groupKey) { + const group = map.get(key); + return group + ? { + ...group, + manuallyCardSort: keys, + } + : { + key, + hide: false, + manuallyCardSort: keys, + }; + } else { + return ( + map.get(key) ?? { + key, + hide: false, + manuallyCardSort: [], + } + ); + } + }), + }; + }); + }, + }) + ); + + mainProperties$ = computed(() => { + return ( + this.data$.value?.header ?? { + titleColumn: this.propertiesWithoutFilter$.value.find( + id => this.propertyTypeGet(id) === 'title' + ), + iconColumn: 'type', + } + ); + }); + + propertyIds$: ReadonlySignal = computed(() => { + return this.propertiesWithoutFilter$.value.filter( + id => !this.propertyHideGet(id) + ); + }); + + readonly$ = computed(() => { + return this.manager.readonly$.value; + }); + + get columns(): string[] { + return this.propertiesWithoutFilter$.value.filter( + id => !this.propertyHideGet(id) + ); + } + + get filter(): FilterGroup { + return this.view?.filter ?? emptyFilterGroup; + } + + get header() { + return this.view?.header; + } + + get type(): string { + return this.view?.mode ?? 'kanban'; + } + + get view() { + return this.data$.value; + } + + addCard(position: InsertToPosition, group: string) { + const id = this.rowAdd(position); + this.groupTrait.addToGroup(id, group); + + const filter = this.filter$.value; + if (filter.conditions.length > 0) { + const defaultValues = generateDefaultValues(filter, this.vars$.value); + Object.entries(defaultValues).forEach(([propertyId, jsonValue]) => { + const property = this.propertyGet(propertyId); + const propertyMeta = this.propertyMetaGet(property.type$.value); + if (propertyMeta?.config.cellFromJson) { + const value = propertyMeta.config.cellFromJson({ + value: jsonValue, + data: property.data$.value, + dataSource: this.dataSource, + }); + this.cellValueSet(id, propertyId, value); + } + }); + } + + return id; + } + + getHeaderCover(_rowId: string): KanbanColumn | undefined { + const columnId = this.view?.header.coverColumn; + if (!columnId) { + return; + } + return this.propertyGet(columnId); + } + + getHeaderIcon(_rowId: string): KanbanColumn | undefined { + const columnId = this.view?.header.iconColumn; + if (!columnId) { + return; + } + return this.propertyGet(columnId); + } + + getHeaderTitle(_rowId: string): KanbanColumn | undefined { + const columnId = this.view?.header.titleColumn; + if (!columnId) { + return; + } + return this.propertyGet(columnId); + } + + hasHeader(_rowId: string): boolean { + const hd = this.view?.header; + if (!hd) { + return false; + } + return !!hd.titleColumn || !!hd.iconColumn || !!hd.coverColumn; + } + + isInHeader(columnId: string) { + const hd = this.view?.header; + if (!hd) { + return false; + } + return ( + hd.titleColumn === columnId || + hd.iconColumn === columnId || + hd.coverColumn === columnId + ); + } + + isShow(rowId: string): boolean { + if (this.filter$.value?.conditions.length) { + const rowMap = Object.fromEntries( + this.properties$.value.map(column => [ + column.id, + column.cellGet(rowId).jsonValue$.value, + ]) + ); + return evalFilter(this.filter$.value, rowMap); + } + return true; + } + + propertyGet(columnId: string): KanbanColumn { + return new KanbanColumn(this, columnId); + } + + propertyHideGet(columnId: string): boolean { + return this.view?.columns.find(v => v.id === columnId)?.hide ?? false; + } + + propertyHideSet(columnId: string, hide: boolean): void { + this.dataUpdate(view => { + return { + columns: view.columns.map(v => + v.id === columnId + ? { + ...v, + hide, + } + : v + ), + }; + }); + } + + propertyMove(columnId: string, toAfterOfColumn: InsertToPosition): void { + this.dataUpdate(view => { + const columnIndex = view.columns.findIndex(v => v.id === columnId); + if (columnIndex < 0) { + return {}; + } + const columns = [...view.columns]; + const [column] = columns.splice(columnIndex, 1); + const index = insertPositionToIndex(toAfterOfColumn, columns); + columns.splice(index, 0, column); + return { + columns, + }; + }); + } + + override rowMove(rowId: string, position: InsertToPosition): void { + this.dataSource.rowMove(rowId, position); + } + + override rowNextGet(rowId: string): string { + const index = this.rows$.value.indexOf(rowId); + return this.rows$.value[index + 1]; + } + + override rowPrevGet(rowId: string): string { + const index = this.rows$.value.indexOf(rowId); + return this.rows$.value[index - 1]; + } +} + +export class KanbanColumn extends PropertyBase { + constructor(dataViewManager: KanbanSingleView, columnId: string) { + super(dataViewManager, columnId); + } +} diff --git a/blocksuite/affine/data-view/src/view-presets/kanban/mobile/card.ts b/blocksuite/affine/data-view/src/view-presets/kanban/mobile/card.ts new file mode 100644 index 0000000000..66380a02a1 --- /dev/null +++ b/blocksuite/affine/data-view/src/view-presets/kanban/mobile/card.ts @@ -0,0 +1,224 @@ +import { popupTargetFromElement } from '@blocksuite/affine-components/context-menu'; +import { unsafeCSSVarV2 } from '@blocksuite/affine-shared/theme'; +import { ShadowlessElement } from '@blocksuite/block-std'; +import { SignalWatcher, WithDisposable } from '@blocksuite/global/utils'; +import { CenterPeekIcon, MoreHorizontalIcon } from '@blocksuite/icons/lit'; +import { css } from 'lit'; +import { property, state } from 'lit/decorators.js'; +import { classMap } from 'lit/directives/class-map.js'; +import { repeat } from 'lit/directives/repeat.js'; +import { html } from 'lit/static-html.js'; + +import type { DataViewRenderer } from '../../../core/data-view.js'; +import type { KanbanColumn, KanbanSingleView } from '../kanban-view-manager.js'; +import { popCardMenu } from './menu.js'; + +const styles = css` + mobile-kanban-card { + display: flex; + position: relative; + flex-direction: column; + border: 0.5px solid var(--affine-border-color); + box-shadow: 0px 2px 3px 0px rgba(0, 0, 0, 0.05); + border-radius: 8px; + background-color: var(--affine-background-kanban-card-color); + } + + .mobile-card-header { + padding: 8px; + display: flex; + flex-direction: column; + gap: 8px; + } + + .mobile-card-header-title uni-lit { + width: 100%; + } + + .mobile-card-header.has-divider { + border-bottom: 0.5px solid var(--affine-border-color); + } + + .mobile-card-header-title { + font-size: var(--data-view-cell-text-size); + line-height: var(--data-view-cell-text-line-height); + } + + .mobile-card-header-icon { + padding: 4px; + background-color: var(--affine-background-secondary-color); + display: flex; + align-items: center; + border-radius: 4px; + width: max-content; + font-size: 16px; + color: ${unsafeCSSVarV2('icon/primary')}; + } + + .mobile-card-body { + display: flex; + flex-direction: column; + padding: 8px; + gap: 4px; + } + + mobile-kanban-card:has([data-editing='true']) .card-ops { + visibility: hidden; + } + + .mobile-card-ops { + position: absolute; + right: 8px; + top: 8px; + display: flex; + gap: 4px; + } + + .mobile-card-op { + display: flex; + position: relative; + padding: 4px; + border-radius: 4px; + box-shadow: 0px 0px 4px 0px rgba(66, 65, 73, 0.14); + background-color: var(--affine-background-primary-color); + font-size: 16px; + color: ${unsafeCSSVarV2('icon/primary')}; + } +`; + +export class MobileKanbanCard extends SignalWatcher( + WithDisposable(ShadowlessElement) +) { + static override styles = styles; + + private clickCenterPeek = (e: MouseEvent) => { + e.stopPropagation(); + this.dataViewEle.openDetailPanel({ + view: this.view, + rowId: this.cardId, + }); + }; + + private clickMore = (e: MouseEvent) => { + e.stopPropagation(); + popCardMenu( + popupTargetFromElement(e.currentTarget as HTMLElement), + this.view, + this.groupKey, + this.cardId, + this.dataViewEle + ); + }; + + private renderBody(columns: KanbanColumn[]) { + if (columns.length === 0) { + return ''; + } + return html`
+ ${repeat( + columns, + v => v.id, + column => { + if (this.view.isInHeader(column.id)) { + return ''; + } + return html` `; + } + )} +
`; + } + + private renderHeader(columns: KanbanColumn[]) { + if (!this.view.hasHeader(this.cardId)) { + return ''; + } + const classList = classMap({ + 'mobile-card-header': true, + 'mobile-has-divider': columns.length > 0, + }); + return html` +
${this.renderTitle()} ${this.renderIcon()}
+ `; + } + + private renderIcon() { + const icon = this.view.getHeaderIcon(this.cardId); + if (!icon) { + return; + } + return html`
+ ${icon.cellGet(this.cardId).value$.value} +
`; + } + + private renderOps() { + if (this.view.readonly$.value) { + return; + } + return html` +
+
+ ${CenterPeekIcon()} +
+
+ ${MoreHorizontalIcon()} +
+
+ `; + } + + private renderTitle() { + const title = this.view.getHeaderTitle(this.cardId); + if (!title) { + return; + } + return html`
+ +
`; + } + + override render() { + const columns = this.view.properties$.value.filter( + v => !this.view.isInHeader(v.id) + ); + return html` + ${this.renderHeader(columns)} ${this.renderBody(columns)} + ${this.renderOps()} + `; + } + + @property({ attribute: false }) + accessor cardId!: string; + + @property({ attribute: false }) + accessor dataViewEle!: DataViewRenderer; + + @property({ attribute: false }) + accessor groupKey!: string; + + @state() + accessor isFocus = false; + + @property({ attribute: false }) + accessor view!: KanbanSingleView; +} + +declare global { + interface HTMLElementTagNameMap { + 'mobile-kanban-card': MobileKanbanCard; + } +} diff --git a/blocksuite/affine/data-view/src/view-presets/kanban/mobile/cell.ts b/blocksuite/affine/data-view/src/view-presets/kanban/mobile/cell.ts new file mode 100644 index 0000000000..e30e48446c --- /dev/null +++ b/blocksuite/affine/data-view/src/view-presets/kanban/mobile/cell.ts @@ -0,0 +1,182 @@ +// related component + +import { unsafeCSSVarV2 } from '@blocksuite/affine-shared/theme'; +import { ShadowlessElement } from '@blocksuite/block-std'; +import { SignalWatcher, WithDisposable } from '@blocksuite/global/utils'; +import { computed, effect } from '@preact/signals-core'; +import { css } from 'lit'; +import { property, state } from 'lit/decorators.js'; +import { createRef } from 'lit/directives/ref.js'; +import { html } from 'lit/static-html.js'; + +import type { + CellRenderProps, + DataViewCellLifeCycle, +} from '../../../core/property/index.js'; +import { renderUniLit } from '../../../core/utils/uni-component/uni-component.js'; +import type { Property } from '../../../core/view-manager/property.js'; +import type { KanbanSingleView } from '../kanban-view-manager.js'; + +const styles = css` + mobile-kanban-cell { + border-radius: 4px; + display: flex; + align-items: center; + padding: 4px; + min-height: 20px; + border: 1px solid transparent; + box-sizing: border-box; + } + + .mobile-kanban-cell { + flex: 1; + display: block; + width: 196px; + } + + .mobile-kanban-cell-icon { + display: flex; + align-items: center; + justify-content: center; + align-self: start; + margin-right: 12px; + height: var(--data-view-cell-text-line-height); + font-size: 16px; + color: ${unsafeCSSVarV2('icon/primary')}; + } +`; + +export class MobileKanbanCell extends SignalWatcher( + WithDisposable(ShadowlessElement) +) { + static override styles = styles; + + private _cell = createRef(); + + isEditing$ = computed(() => { + const selection = this.kanban?.props.selection$.value; + if (selection?.selectionType !== 'cell') { + return false; + } + if (selection.groupKey !== this.groupKey) { + return false; + } + if (selection.cardId !== this.cardId) { + return false; + } + if (selection.columnId !== this.column.id) { + return false; + } + return selection.isEditing; + }); + + selectCurrentCell = (editing: boolean) => { + if (this.view.readonly$.value) { + return; + } + const setSelection = this.kanban?.props.setSelection; + const viewId = this.kanban?.props.view.id; + if (setSelection && viewId) { + if (editing && this.cell?.beforeEnterEditMode() === false) { + return; + } + setSelection({ + viewId, + type: 'kanban', + selectionType: 'cell', + groupKey: this.groupKey, + cardId: this.cardId, + columnId: this.column.id, + isEditing: editing, + }); + } + }; + + get cell(): DataViewCellLifeCycle | undefined { + return this._cell.value; + } + + get kanban() { + return this.closest('mobile-data-view-kanban'); + } + + get selection() { + return this.closest('mobile-data-view-kanban')?.props.selection$.value; + } + + override connectedCallback() { + super.connectedCallback(); + if (this.column.readonly$.value) return; + this.disposables.add( + effect(() => { + const isEditing = this.isEditing$.value; + if (isEditing) { + this.isEditing = true; + this._cell.value?.onEnterEditMode(); + } else { + this._cell.value?.onExitEditMode(); + this.isEditing = false; + } + }) + ); + this._disposables.addFromEvent(this, 'click', e => { + e.stopPropagation(); + if (!this.isEditing) { + this.selectCurrentCell(!this.column.readonly$.value); + } + }); + } + + override render() { + const props: CellRenderProps = { + cell: this.column.cellGet(this.cardId), + isEditing: this.isEditing, + selectCurrentCell: this.selectCurrentCell, + }; + const renderer = this.column.renderer$.value; + if (!renderer) return; + const { view, edit } = renderer; + this.view.lockRows(this.isEditing); + this.dataset['editing'] = `${this.isEditing}`; + return html` ${this.renderIcon()} + ${renderUniLit(this.isEditing && edit ? edit : view, props, { + ref: this._cell, + class: 'mobile-kanban-cell', + style: { display: 'block', flex: '1', overflow: 'hidden' }, + })}`; + } + + renderIcon() { + if (this.contentOnly) { + return; + } + return html` `; + } + + @property({ attribute: false }) + accessor cardId!: string; + + @property({ attribute: false }) + accessor column!: Property; + + @property({ attribute: false }) + accessor contentOnly = false; + + @property({ attribute: false }) + accessor groupKey!: string; + + @state() + accessor isEditing = false; + + @property({ attribute: false }) + accessor view!: KanbanSingleView; +} + +declare global { + interface HTMLElementTagNameMap { + 'mobile-kanban-cell': MobileKanbanCell; + } +} diff --git a/blocksuite/affine/data-view/src/view-presets/kanban/mobile/group.ts b/blocksuite/affine/data-view/src/view-presets/kanban/mobile/group.ts new file mode 100644 index 0000000000..b01a99c3f1 --- /dev/null +++ b/blocksuite/affine/data-view/src/view-presets/kanban/mobile/group.ts @@ -0,0 +1,150 @@ +import { + menu, + popFilterableSimpleMenu, + popupTargetFromElement, +} from '@blocksuite/affine-components/context-menu'; +import { ShadowlessElement } from '@blocksuite/block-std'; +import { SignalWatcher, WithDisposable } from '@blocksuite/global/utils'; +import { AddCursorIcon } from '@blocksuite/icons/lit'; +import { css, nothing } from 'lit'; +import { property } from 'lit/decorators.js'; +import { repeat } from 'lit/directives/repeat.js'; +import { html } from 'lit/static-html.js'; + +import type { DataViewRenderer } from '../../../core/data-view.js'; +import { GroupTitle } from '../../../core/group-by/group-title.js'; +import type { GroupData } from '../../../core/group-by/trait.js'; +import { dragHandler } from '../../../core/utils/wc-dnd/dnd-context.js'; +import type { KanbanSingleView } from '../kanban-view-manager.js'; + +const styles = css` + mobile-kanban-group { + width: 260px; + flex-shrink: 0; + border-radius: 8px; + display: flex; + flex-direction: column; + } + + .mobile-group-header { + height: 32px; + padding: 6px 4px; + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + overflow: hidden; + } + + .mobile-group-body { + margin-top: 4px; + display: flex; + flex-direction: column; + padding: 0 4px; + gap: 12px; + } + + .mobile-add-card { + display: flex; + align-items: center; + padding: 4px; + border-radius: 4px; + font-size: var(--data-view-cell-text-size); + line-height: var(--data-view-cell-text-line-height); + color: var(--affine-text-secondary-color); + } +`; + +export class MobileKanbanGroup extends SignalWatcher( + WithDisposable(ShadowlessElement) +) { + static override styles = styles; + + private clickAddCard = () => { + this.view.addCard('end', this.group.key); + }; + + private clickAddCardInStart = () => { + this.view.addCard('start', this.group.key); + }; + + private clickGroupOptions = (e: MouseEvent) => { + const ele = e.currentTarget as HTMLElement; + popFilterableSimpleMenu(popupTargetFromElement(ele), [ + menu.group({ + items: [ + menu.action({ + name: 'Ungroup', + hide: () => this.group.value == null, + select: () => { + this.group.rows.forEach(id => { + this.group.manager.removeFromGroup(id, this.group.key); + }); + }, + }), + menu.action({ + name: 'Delete Cards', + select: () => { + this.view.rowDelete(this.group.rows); + }, + }), + ], + }), + ]); + }; + + override render() { + const cards = this.group.rows; + return html` +
+ ${GroupTitle(this.group, { + readonly: this.view.readonly$.value, + clickAdd: this.clickAddCardInStart, + clickOps: this.clickGroupOptions, + })} +
+
+ ${repeat( + cards, + id => id, + id => { + return html` + + `; + } + )} + ${this.view.readonly$.value + ? nothing + : html`
+
+ ${AddCursorIcon()} +
+ Add +
`} +
+ `; + } + + @property({ attribute: false }) + accessor dataViewEle!: DataViewRenderer; + + @property({ attribute: false }) + accessor group!: GroupData; + + @property({ attribute: false }) + accessor view!: KanbanSingleView; +} + +declare global { + interface HTMLElementTagNameMap { + 'mobile-kanban-group': MobileKanbanGroup; + } +} diff --git a/blocksuite/affine/data-view/src/view-presets/kanban/mobile/kanban-view.ts b/blocksuite/affine/data-view/src/view-presets/kanban/mobile/kanban-view.ts new file mode 100644 index 0000000000..73128a9a3b --- /dev/null +++ b/blocksuite/affine/data-view/src/view-presets/kanban/mobile/kanban-view.ts @@ -0,0 +1,149 @@ +import { + menu, + popMenu, + popupTargetFromElement, +} from '@blocksuite/affine-components/context-menu'; +import { unsafeCSSVarV2 } from '@blocksuite/affine-shared/theme'; +import { AddCursorIcon } from '@blocksuite/icons/lit'; +import { css } from 'lit'; +import { repeat } from 'lit/directives/repeat.js'; +import { styleMap } from 'lit/directives/style-map.js'; +import { html } from 'lit/static-html.js'; + +import { type DataViewInstance, renderUniLit } from '../../../core/index.js'; +import { sortable } from '../../../core/utils/wc-dnd/sort/sort-context.js'; +import { DataViewBase } from '../../../core/view/data-view-base.js'; +import type { KanbanSingleView } from '../kanban-view-manager.js'; +import type { KanbanViewSelectionWithType } from '../types.js'; + +const styles = css` + mobile-data-view-kanban { + user-select: none; + display: flex; + flex-direction: column; + } + + .mobile-kanban-groups { + position: relative; + z-index: 1; + display: flex; + gap: 20px; + padding-bottom: 4px; + overflow-x: scroll; + overflow-y: hidden; + } + + .mobile-add-group { + height: 32px; + flex-shrink: 0; + display: flex; + align-items: center; + padding: 4px; + border-radius: 4px; + font-size: 16px; + color: ${unsafeCSSVarV2('icon/primary')}; + } +`; + +export class MobileDataViewKanban extends DataViewBase< + KanbanSingleView, + KanbanViewSelectionWithType +> { + static override styles = styles; + + renderAddGroup = () => { + const addGroup = this.groupManager.addGroup; + if (!addGroup) { + return; + } + const add = (e: MouseEvent) => { + const ele = e.currentTarget as HTMLElement; + popMenu(popupTargetFromElement(ele), { + options: { + items: [ + menu.input({ + onComplete: text => { + const column = this.groupManager.property$.value; + if (column) { + column.dataUpdate( + () => + addGroup({ + text, + oldData: column.data$.value, + dataSource: this.props.view.manager.dataSource, + }) as never + ); + } + }, + }), + ], + }, + }); + }; + return html`
+ ${AddCursorIcon()} +
`; + }; + + get expose(): DataViewInstance { + return { + clearSelection: () => {}, + focusFirstCell: () => {}, + getSelection: () => { + return this.props.selection$.value; + }, + hideIndicator: () => {}, + moveTo: () => {}, + showIndicator: () => { + return false; + }, + view: this.props.view, + eventTrace: this.props.eventTrace, + }; + } + + get groupManager() { + return this.props.view.groupTrait; + } + + override render() { + const groups = this.groupManager.groupsDataList$.value; + if (!groups) { + return html``; + } + const vPadding = this.props.virtualPadding$.value; + const wrapperStyle = styleMap({ + marginLeft: `-${vPadding}px`, + marginRight: `-${vPadding}px`, + paddingLeft: `${vPadding}px`, + paddingRight: `${vPadding}px`, + }); + return html` + ${renderUniLit(this.props.headerWidget, { + dataViewInstance: this.expose, + })} +
+ ${repeat( + groups, + group => group.key, + group => { + return html` `; + } + )} + ${this.renderAddGroup()} +
+ `; + } +} + +declare global { + interface HTMLElementTagNameMap { + 'mobile-data-view-kanban': MobileDataViewKanban; + } +} diff --git a/blocksuite/affine/data-view/src/view-presets/kanban/mobile/menu.ts b/blocksuite/affine/data-view/src/view-presets/kanban/mobile/menu.ts new file mode 100644 index 0000000000..b69fbb7857 --- /dev/null +++ b/blocksuite/affine/data-view/src/view-presets/kanban/mobile/menu.ts @@ -0,0 +1,115 @@ +import { + menu, + popFilterableSimpleMenu, + type PopupTarget, +} from '@blocksuite/affine-components/context-menu'; +import { + ArrowRightBigIcon, + DeleteIcon, + ExpandFullIcon, + MoveLeftIcon, + MoveRightIcon, +} from '@blocksuite/icons/lit'; +import { html } from 'lit'; + +import type { DataViewRenderer } from '../../../core/data-view.js'; +import { groupTraitKey } from '../../../core/group-by/trait.js'; +import type { KanbanSingleView } from '../kanban-view-manager.js'; + +export const popCardMenu = ( + ele: PopupTarget, + view: KanbanSingleView, + groupKey: string, + cardId: string, + dataViewEle: DataViewRenderer +) => { + const groupTrait = view.traitGet(groupTraitKey); + if (!groupTrait) { + return; + } + popFilterableSimpleMenu(ele, [ + menu.group({ + items: [ + menu.action({ + name: 'Expand Card', + prefix: ExpandFullIcon(), + select: () => { + dataViewEle.openDetailPanel({ + view: view, + rowId: cardId, + }); + }, + }), + ], + }), + menu.group({ + items: [ + menu.subMenu({ + name: 'Move To', + prefix: ArrowRightBigIcon(), + options: { + items: + groupTrait.groupsDataList$.value + ?.filter(v => { + return v.key !== groupKey; + }) + .map(group => { + return menu.action({ + name: group.value != null ? group.name : 'Ungroup', + select: () => { + groupTrait.moveCardTo( + cardId, + groupKey, + group.key, + 'start' + ); + }, + }); + }) ?? [], + }, + }), + ], + }), + menu.group({ + name: '', + items: [ + menu.action({ + name: 'Insert Before', + prefix: html`
+ ${MoveLeftIcon()} +
`, + select: () => { + view.addCard({ before: true, id: cardId }, groupKey); + }, + }), + menu.action({ + name: 'Insert After', + prefix: html`
+ ${MoveRightIcon()} +
`, + select: () => { + view.addCard({ before: false, id: cardId }, groupKey); + }, + }), + ], + }), + menu.group({ + items: [ + menu.action({ + name: 'Delete Card', + class: { + 'delete-item': true, + }, + prefix: DeleteIcon(), + select: () => { + view.rowDelete([cardId]); + }, + }), + ], + }), + ]); +}; diff --git a/blocksuite/affine/data-view/src/view-presets/kanban/pc/card.ts b/blocksuite/affine/data-view/src/view-presets/kanban/pc/card.ts new file mode 100644 index 0000000000..0009d590e8 --- /dev/null +++ b/blocksuite/affine/data-view/src/view-presets/kanban/pc/card.ts @@ -0,0 +1,335 @@ +import { popupTargetFromElement } from '@blocksuite/affine-components/context-menu'; +import { ShadowlessElement } from '@blocksuite/block-std'; +import { SignalWatcher, WithDisposable } from '@blocksuite/global/utils'; +import { CenterPeekIcon, MoreHorizontalIcon } from '@blocksuite/icons/lit'; +import { css } from 'lit'; +import { property, state } from 'lit/decorators.js'; +import { classMap } from 'lit/directives/class-map.js'; +import { repeat } from 'lit/directives/repeat.js'; +import { html } from 'lit/static-html.js'; + +import type { DataViewRenderer } from '../../../core/data-view.js'; +import type { KanbanColumn, KanbanSingleView } from '../kanban-view-manager.js'; +import { openDetail, popCardMenu } from './menu.js'; + +const styles = css` + affine-data-view-kanban-card { + display: flex; + position: relative; + flex-direction: column; + border: 1px solid var(--affine-border-color); + box-shadow: 0px 2px 3px 0px rgba(0, 0, 0, 0.05); + border-radius: 8px; + transition: background-color 100ms ease-in-out; + background-color: var(--affine-background-kanban-card-color); + } + + affine-data-view-kanban-card:hover { + background-color: var(--affine-hover-color); + } + + affine-data-view-kanban-card .card-header { + padding: 8px; + display: flex; + flex-direction: column; + gap: 8px; + } + + affine-data-view-kanban-card .card-header-title uni-lit { + width: 100%; + } + + .card-header.has-divider { + border-bottom: 0.5px solid var(--affine-border-color); + } + + affine-data-view-kanban-card .card-header-title { + font-size: var(--data-view-cell-text-size); + line-height: var(--data-view-cell-text-line-height); + } + + affine-data-view-kanban-card .card-header-icon { + padding: 4px; + background-color: var(--affine-background-secondary-color); + display: flex; + align-items: center; + border-radius: 4px; + width: max-content; + } + + affine-data-view-kanban-card .card-header-icon svg { + width: 16px; + height: 16px; + fill: var(--affine-icon-color); + color: var(--affine-icon-color); + } + + affine-data-view-kanban-card .card-body { + display: flex; + flex-direction: column; + padding: 8px; + gap: 4px; + } + + affine-data-view-kanban-card:hover .card-ops { + visibility: visible; + } + affine-data-view-kanban-card:has(.active) .card-ops { + visibility: visible; + } + + affine-data-view-kanban-card:has([data-editing='true']) .card-ops { + visibility: hidden; + } + + .card-ops { + position: absolute; + right: 8px; + top: 8px; + visibility: hidden; + display: flex; + gap: 4px; + cursor: pointer; + } + + .card-op { + display: flex; + position: relative; + padding: 4px; + border-radius: 4px; + box-shadow: 0px 0px 4px 0px rgba(66, 65, 73, 0.14); + background-color: var(--affine-background-primary-color); + } + + .card-op:hover:before { + content: ''; + border-radius: 4px; + position: absolute; + left: 0; + right: 0; + top: 0; + bottom: 0; + background-color: var(--affine-hover-color); + } + + .card-op svg { + fill: var(--affine-icon-color); + color: var(--affine-icon-color); + width: 16px; + height: 16px; + } +`; + +export class KanbanCard extends SignalWatcher( + WithDisposable(ShadowlessElement) +) { + static override styles = styles; + + private clickEdit = (e: MouseEvent) => { + e.stopPropagation(); + const selection = this.getSelection(); + if (selection) { + openDetail(this.dataViewEle, this.cardId, selection); + } + }; + + private clickMore = (e: MouseEvent) => { + e.stopPropagation(); + const selection = this.getSelection(); + const ele = e.currentTarget as HTMLElement; + if (selection) { + selection.selection = { + selectionType: 'card', + cards: [ + { + groupKey: this.groupKey, + cardId: this.cardId, + }, + ], + }; + popCardMenu( + this.dataViewEle, + popupTargetFromElement(ele), + this.cardId, + selection + ); + } + }; + + private contextMenu = (e: MouseEvent) => { + e.stopPropagation(); + e.preventDefault(); + const selection = this.getSelection(); + if (selection) { + selection.selection = { + selectionType: 'card', + cards: [ + { + groupKey: this.groupKey, + cardId: this.cardId, + }, + ], + }; + const target = e.target as HTMLElement; + const ref = target.closest('affine-data-view-kanban-cell') ?? this; + popCardMenu( + this.dataViewEle, + popupTargetFromElement(ref), + this.cardId, + selection + ); + } + }; + + private getSelection() { + return this.closest('affine-data-view-kanban')?.selectionController; + } + + private renderBody(columns: KanbanColumn[]) { + if (columns.length === 0) { + return ''; + } + return html`
+ ${repeat( + columns, + v => v.id, + column => { + if (this.view.isInHeader(column.id)) { + return ''; + } + return html` `; + } + )} +
`; + } + + private renderHeader(columns: KanbanColumn[]) { + if (!this.view.hasHeader(this.cardId)) { + return ''; + } + const classList = classMap({ + 'card-header': true, + 'has-divider': columns.length > 0, + }); + return html` +
${this.renderTitle()} ${this.renderIcon()}
+ `; + } + + private renderIcon() { + const icon = this.view.getHeaderIcon(this.cardId); + if (!icon) { + return; + } + return html`
+ ${icon.cellGet(this.cardId).value$.value} +
`; + } + + private renderOps() { + if (this.view.readonly$.value) { + return; + } + return html` +
+
+ ${CenterPeekIcon()} +
+
+ ${MoreHorizontalIcon()} +
+
+ `; + } + + private renderTitle() { + const title = this.view.getHeaderTitle(this.cardId); + if (!title) { + return; + } + return html`
+ +
`; + } + + override connectedCallback() { + super.connectedCallback(); + if (this.view.readonly$.value) { + return; + } + this._disposables.addFromEvent(this, 'contextmenu', e => { + this.contextMenu(e); + }); + this._disposables.addFromEvent(this, 'click', e => { + if (e.shiftKey) { + this.getSelection()?.shiftClickCard(e); + return; + } + const selection = this.getSelection(); + const preSelection = selection?.selection; + + if (preSelection?.selectionType !== 'card') return; + + if (selection) { + selection.selection = undefined; + } + this.dataViewEle.openDetailPanel({ + view: this.view, + rowId: this.cardId, + onClose: () => { + if (selection) { + selection.selection = preSelection; + } + }, + }); + }); + } + + override render() { + const columns = this.view.properties$.value.filter( + v => !this.view.isInHeader(v.id) + ); + this.style.border = this.isFocus + ? '1px solid var(--affine-primary-color)' + : ''; + return html` + ${this.renderHeader(columns)} ${this.renderBody(columns)} + ${this.renderOps()} + `; + } + + @property({ attribute: false }) + accessor cardId!: string; + + @property({ attribute: false }) + accessor dataViewEle!: DataViewRenderer; + + @property({ attribute: false }) + accessor groupKey!: string; + + @state() + accessor isFocus = false; + + @property({ attribute: false }) + accessor view!: KanbanSingleView; +} + +declare global { + interface HTMLElementTagNameMap { + 'affine-data-view-kanban-card': KanbanCard; + } +} diff --git a/blocksuite/affine/data-view/src/view-presets/kanban/pc/cell.ts b/blocksuite/affine/data-view/src/view-presets/kanban/pc/cell.ts new file mode 100644 index 0000000000..1c10e99c82 --- /dev/null +++ b/blocksuite/affine/data-view/src/view-presets/kanban/pc/cell.ts @@ -0,0 +1,188 @@ +// related component + +import { ShadowlessElement } from '@blocksuite/block-std'; +import { SignalWatcher, WithDisposable } from '@blocksuite/global/utils'; +import { css } from 'lit'; +import { property, state } from 'lit/decorators.js'; +import { createRef } from 'lit/directives/ref.js'; +import { html } from 'lit/static-html.js'; + +import type { + CellRenderProps, + DataViewCellLifeCycle, +} from '../../../core/property/index.js'; +import { renderUniLit } from '../../../core/utils/uni-component/uni-component.js'; +import type { Property } from '../../../core/view-manager/property.js'; +import type { KanbanSingleView } from '../kanban-view-manager.js'; +import type { KanbanViewSelection } from '../types.js'; + +const styles = css` + affine-data-view-kanban-cell { + border-radius: 4px; + display: flex; + align-items: center; + padding: 4px; + min-height: 20px; + border: 1px solid transparent; + box-sizing: border-box; + } + + affine-data-view-kanban-cell:hover { + background-color: var(--affine-hover-color); + } + + affine-data-view-kanban-cell .icon { + display: flex; + align-items: center; + justify-content: center; + align-self: start; + margin-right: 12px; + height: var(--data-view-cell-text-line-height); + } + + affine-data-view-kanban-cell .icon svg { + width: 16px; + height: 16px; + fill: var(--affine-icon-color); + color: var(--affine-icon-color); + } + + .kanban-cell { + flex: 1; + display: block; + width: 196px; + } +`; + +export class KanbanCell extends SignalWatcher( + WithDisposable(ShadowlessElement) +) { + static override styles = styles; + + private _cell = createRef(); + + selectCurrentCell = (editing: boolean) => { + const selectionView = this.closest( + 'affine-data-view-kanban' + )?.selectionController; + if (!selectionView) return; + if (selectionView) { + const selection = selectionView.selection; + if (selection && this.isSelected(selection) && editing) { + selectionView.selection = { + selectionType: 'cell', + groupKey: this.groupKey, + cardId: this.cardId, + columnId: this.column.id, + isEditing: true, + }; + } else { + selectionView.selection = { + selectionType: 'cell', + groupKey: this.groupKey, + cardId: this.cardId, + columnId: this.column.id, + isEditing: false, + }; + } + } + }; + + get cell(): DataViewCellLifeCycle | undefined { + return this._cell.value; + } + + get selection() { + return this.closest('affine-data-view-kanban')?.selectionController; + } + + override connectedCallback() { + super.connectedCallback(); + this._disposables.addFromEvent(this, 'click', e => { + if (e.shiftKey) { + return; + } + e.stopPropagation(); + const selectionElement = this.closest( + 'affine-data-view-kanban' + )?.selectionController; + if (!selectionElement) return; + if (e.shiftKey) return; + + if (!this.editing) { + this.selectCurrentCell(!this.column.readonly$.value); + } + }); + } + + isSelected(selection: KanbanViewSelection) { + if ( + selection.selectionType !== 'cell' || + selection.groupKey !== this.groupKey + ) { + return; + } + return ( + selection.cardId === this.cardId && selection.columnId === this.column.id + ); + } + + override render() { + const props: CellRenderProps = { + cell: this.column.cellGet(this.cardId), + isEditing: this.editing, + selectCurrentCell: this.selectCurrentCell, + }; + const renderer = this.column.renderer$.value; + if (!renderer) return; + const { view, edit } = renderer; + this.view.lockRows(this.editing); + this.dataset['editing'] = `${this.editing}`; + this.style.border = this.isFocus + ? '1px solid var(--affine-primary-color)' + : ''; + this.style.boxShadow = this.editing + ? '0px 0px 0px 2px rgba(30, 150, 235, 0.30)' + : ''; + return html` ${this.renderIcon()} + ${renderUniLit(this.editing && edit ? edit : view, props, { + ref: this._cell, + class: 'kanban-cell', + style: { display: 'block', flex: '1', overflow: 'hidden' }, + })}`; + } + + renderIcon() { + if (this.contentOnly) { + return; + } + return html` `; + } + + @property({ attribute: false }) + accessor cardId!: string; + + @property({ attribute: false }) + accessor column!: Property; + + @property({ attribute: false }) + accessor contentOnly = false; + + @state() + accessor editing = false; + + @property({ attribute: false }) + accessor groupKey!: string; + + @state() + accessor isFocus = false; + + @property({ attribute: false }) + accessor view!: KanbanSingleView; +} + +declare global { + interface HTMLElementTagNameMap { + 'affine-data-view-kanban-cell': KanbanCell; + } +} diff --git a/blocksuite/affine/data-view/src/view-presets/kanban/pc/controller/clipboard.ts b/blocksuite/affine/data-view/src/view-presets/kanban/pc/controller/clipboard.ts new file mode 100644 index 0000000000..8fe68fc462 --- /dev/null +++ b/blocksuite/affine/data-view/src/view-presets/kanban/pc/controller/clipboard.ts @@ -0,0 +1,49 @@ +import type { UIEventStateContext } from '@blocksuite/block-std'; +import type { ReactiveController } from 'lit'; + +import type { KanbanViewSelectionWithType } from '../../types.js'; +import type { DataViewKanban } from '../kanban-view.js'; + +export class KanbanClipboardController implements ReactiveController { + private _onCopy = ( + _context: UIEventStateContext, + _kanbanSelection: KanbanViewSelectionWithType + ) => { + // todo + return true; + }; + + private _onPaste = (_context: UIEventStateContext) => { + // todo + return true; + }; + + private get readonly() { + return this.host.props.view.readonly$.value; + } + + constructor(public host: DataViewKanban) { + host.addController(this); + } + + hostConnected() { + this.host.disposables.add( + this.host.props.handleEvent('copy', ctx => { + const kanbanSelection = this.host.selectionController.selection; + if (!kanbanSelection) return false; + + this._onCopy(ctx, kanbanSelection); + return true; + }) + ); + + this.host.disposables.add( + this.host.props.handleEvent('paste', ctx => { + if (this.readonly) return false; + + this._onPaste(ctx); + return true; + }) + ); + } +} diff --git a/blocksuite/affine/data-view/src/view-presets/kanban/pc/controller/drag.ts b/blocksuite/affine/data-view/src/view-presets/kanban/pc/controller/drag.ts new file mode 100644 index 0000000000..e585112433 --- /dev/null +++ b/blocksuite/affine/data-view/src/view-presets/kanban/pc/controller/drag.ts @@ -0,0 +1,244 @@ +import type { InsertToPosition } from '@blocksuite/affine-shared/utils'; +import { assertExists, Point, Rect } from '@blocksuite/global/utils'; +import { computed } from '@preact/signals-core'; +import type { ReactiveController } from 'lit'; + +import { autoScrollOnBoundary } from '../../../../core/utils/auto-scroll.js'; +import { startDrag } from '../../../../core/utils/drag.js'; +import { KanbanCard } from '../card.js'; +import { KanbanGroup } from '../group.js'; +import type { DataViewKanban } from '../kanban-view.js'; + +export class KanbanDragController implements ReactiveController { + dragStart = (ele: KanbanCard, evt: PointerEvent) => { + const eleRect = ele.getBoundingClientRect(); + const offsetLeft = evt.x - eleRect.left; + const offsetTop = evt.y - eleRect.top; + const preview = createDragPreview( + ele, + evt.x - offsetLeft, + evt.y - offsetTop + ); + const currentGroup = ele.closest('affine-data-view-kanban-group'); + const drag = startDrag< + | { type: 'out'; callback: () => void } + | { + type: 'self'; + key: string; + position: InsertToPosition; + } + | undefined, + PointerEvent + >(evt, { + onDrag: () => undefined, + onMove: evt => { + if (!(evt.target instanceof HTMLElement)) { + return; + } + preview.display(evt.x - offsetLeft, evt.y - offsetTop); + if (!Rect.fromDOM(this.host).isPointIn(Point.from(evt))) { + const callback = this.host.props.onDrag; + if (callback) { + this.dropPreview.remove(); + return { + type: 'out', + callback: callback(evt, ele.cardId), + }; + } + return; + } + const result = this.shooIndicator(evt, ele); + if (result) { + return { + type: 'self', + key: result.group.group.key, + position: result.position, + }; + } + return; + }, + onClear: () => { + preview.remove(); + this.dropPreview.remove(); + cancelScroll(); + }, + onDrop: result => { + if (!result) { + return; + } + if (result.type === 'out') { + result.callback(); + return; + } + if (result && currentGroup) { + currentGroup.group.manager.moveCardTo( + ele.cardId, + currentGroup.group.key, + result.key, + result.position + ); + } + }, + }); + const cancelScroll = autoScrollOnBoundary( + this.scrollContainer, + computed(() => { + return { + left: drag.mousePosition.value.x, + right: drag.mousePosition.value.x, + top: drag.mousePosition.value.y, + bottom: drag.mousePosition.value.y, + }; + }) + ); + }; + + dropPreview = createDropPreview(); + + getInsertPosition = ( + evt: MouseEvent + ): + | { group: KanbanGroup; card?: KanbanCard; position: InsertToPosition } + | undefined => { + const eles = document.elementsFromPoint(evt.x, evt.y); + const target = eles.find(v => v instanceof KanbanGroup) as KanbanGroup; + if (target) { + const card = getCardByPoint(target, evt.y); + return { + group: target, + card, + position: card + ? { + before: true, + id: card.cardId, + } + : 'end', + }; + } else { + return; + } + }; + + shooIndicator = ( + evt: MouseEvent, + self: KanbanCard | undefined + ): { group: KanbanGroup; position: InsertToPosition } | undefined => { + const position = this.getInsertPosition(evt); + if (position) { + this.dropPreview.display(position.group, self, position.card); + } else { + this.dropPreview.remove(); + } + return position; + }; + + get scrollContainer() { + const scrollContainer = this.host.querySelector( + '.affine-data-view-kanban-groups' + ) as HTMLElement; + assertExists(scrollContainer); + return scrollContainer; + } + + constructor(private host: DataViewKanban) { + this.host.addController(this); + } + + hostConnected() { + if (this.host.props.view.readonly$.value) { + return; + } + this.host.disposables.add( + this.host.props.handleEvent('dragStart', context => { + const event = context.get('pointerState').raw; + const target = event.target; + if (target instanceof Element) { + const cell = target.closest('affine-data-view-kanban-cell'); + if (cell?.editing) { + return; + } + cell?.selectCurrentCell(false); + const card = target.closest('affine-data-view-kanban-card'); + if (card) { + this.dragStart(card, event); + } + } + return true; + }) + ); + } +} + +const createDragPreview = (card: KanbanCard, x: number, y: number) => { + const preOpacity = card.style.opacity; + card.style.opacity = '0.5'; + const div = document.createElement('div'); + const kanbanCard = new KanbanCard(); + kanbanCard.cardId = card.cardId; + kanbanCard.view = card.view; + kanbanCard.isFocus = true; + kanbanCard.style.backgroundColor = 'var(--affine-background-primary-color)'; + div.append(kanbanCard); + div.className = 'with-data-view-css-variable'; + div.style.width = `${card.getBoundingClientRect().width}px`; + div.style.position = 'fixed'; + // div.style.pointerEvents = 'none'; + div.style.transform = 'rotate(-3deg)'; + div.style.left = `${x}px`; + div.style.top = `${y}px`; + div.style.zIndex = '9999'; + document.body.append(div); + return { + display(x: number, y: number) { + div.style.left = `${Math.round(x)}px`; + div.style.top = `${Math.round(y)}px`; + }, + remove() { + card.style.opacity = preOpacity; + div.remove(); + }, + }; +}; +const createDropPreview = () => { + const div = document.createElement('div'); + div.style.height = '2px'; + div.style.borderRadius = '1px'; + div.style.backgroundColor = 'var(--affine-primary-color)'; + div.style.boxShadow = '0px 0px 8px 0px rgba(30, 150, 235, 0.35)'; + return { + display( + group: KanbanGroup, + self: KanbanCard | undefined, + card?: KanbanCard + ) { + const target = card ?? group.querySelector('.add-card'); + assertExists(target); + if (target.previousElementSibling === self || target === self) { + div.remove(); + return; + } + if (target.previousElementSibling === div) { + return; + } + target.insertAdjacentElement('beforebegin', div); + }, + remove() { + div.remove(); + }, + }; +}; + +const getCardByPoint = ( + group: KanbanGroup, + y: number +): KanbanCard | undefined => { + const cards = Array.from( + group.querySelectorAll('affine-data-view-kanban-card') + ); + const positions = cards.map(v => { + const rect = v.getBoundingClientRect(); + return (rect.top + rect.bottom) / 2; + }); + const index = positions.findIndex(v => v > y); + return cards[index]; +}; diff --git a/blocksuite/affine/data-view/src/view-presets/kanban/pc/controller/hotkeys.ts b/blocksuite/affine/data-view/src/view-presets/kanban/pc/controller/hotkeys.ts new file mode 100644 index 0000000000..1c74cf59fe --- /dev/null +++ b/blocksuite/affine/data-view/src/view-presets/kanban/pc/controller/hotkeys.ts @@ -0,0 +1,63 @@ +import type { ReactiveController } from 'lit'; + +import type { DataViewKanban } from '../kanban-view.js'; + +export class KanbanHotkeysController implements ReactiveController { + private get hasSelection() { + return !!this.host.selectionController.selection; + } + + constructor(private host: DataViewKanban) { + this.host.addController(this); + } + + hostConnected() { + this.host.disposables.add( + this.host.props.bindHotkey({ + Escape: () => { + this.host.selectionController.focusOut(); + return true; + }, + Enter: () => { + this.host.selectionController.focusIn(); + }, + ArrowUp: context => { + if (!this.hasSelection) return false; + + this.host.selectionController.focusNext('up'); + context.get('keyboardState').raw.preventDefault(); + return true; + }, + ArrowDown: context => { + if (!this.hasSelection) return false; + + this.host.selectionController.focusNext('down'); + context.get('keyboardState').raw.preventDefault(); + return true; + }, + Tab: context => { + if (!this.hasSelection) return false; + + this.host.selectionController.focusNext('down'); + context.get('keyboardState').raw.preventDefault(); + return true; + }, + ArrowLeft: () => { + if (!this.hasSelection) return false; + + this.host.selectionController.focusNext('left'); + return true; + }, + ArrowRight: () => { + if (!this.hasSelection) return false; + + this.host.selectionController.focusNext('right'); + return true; + }, + Backspace: () => { + this.host.selectionController.deleteCard(); + }, + }) + ); + } +} diff --git a/blocksuite/affine/data-view/src/view-presets/kanban/pc/controller/selection.ts b/blocksuite/affine/data-view/src/view-presets/kanban/pc/controller/selection.ts new file mode 100644 index 0000000000..1bf0df1f0c --- /dev/null +++ b/blocksuite/affine/data-view/src/view-presets/kanban/pc/controller/selection.ts @@ -0,0 +1,754 @@ +import { BlockSuiteError, ErrorCode } from '@blocksuite/global/exceptions'; +import { assertExists } from '@blocksuite/global/utils'; +import type { ReactiveController } from 'lit'; + +import type { + KanbanCardSelection, + KanbanCardSelectionCard, + KanbanCellSelection, + KanbanGroupSelection, + KanbanViewSelection, + KanbanViewSelectionWithType, +} from '../../types.js'; +import { KanbanCard } from '../card.js'; +import { KanbanCell } from '../cell.js'; +import type { KanbanGroup } from '../group.js'; +import type { DataViewKanban } from '../kanban-view.js'; + +export class KanbanSelectionController implements ReactiveController { + private _selection?: KanbanViewSelectionWithType; + + shiftClickCard = (event: MouseEvent) => { + event.preventDefault(); + + const selection = this.selection; + const target = event.target as HTMLElement; + const closestCardId = target.closest( + 'affine-data-view-kanban-card' + )?.cardId; + const closestGroupKey = target.closest('affine-data-view-kanban-group') + ?.group.key; + if (!closestCardId) return; + if (!closestGroupKey) return; + const cards = selection?.selectionType === 'card' ? selection.cards : []; + + const newCards = cards.some(card => card.cardId === closestCardId) + ? cards.filter(card => card.cardId !== closestCardId) + : [...cards, { cardId: closestCardId, groupKey: closestGroupKey }]; + this.selection = atLeastOne(newCards) + ? { + selectionType: 'card', + cards: newCards, + } + : undefined; + }; + + get selection(): KanbanViewSelectionWithType | undefined { + return this._selection; + } + + set selection(data: KanbanViewSelection | undefined) { + if (!data) { + this.host.props.setSelection(); + return; + } + const selection: KanbanViewSelectionWithType = { + ...data, + viewId: this.host.props.view.id, + type: 'kanban', + }; + + if (selection.selectionType === 'cell' && selection.isEditing) { + const container = getFocusCell(this.host, selection); + const cell = container?.cell; + const isEditing = cell + ? cell.beforeEnterEditMode() + ? selection.isEditing + : false + : false; + this.host.props.setSelection({ + ...selection, + isEditing, + }); + } else { + this.host.props.setSelection(selection); + } + } + + get view() { + return this.host.props.view; + } + + constructor(private host: DataViewKanban) { + this.host.addController(this); + } + + blur(selection: KanbanViewSelection) { + if (selection.selectionType !== 'cell') { + const selectCards = getSelectedCards(this.host, selection); + selectCards.forEach(card => (card.isFocus = false)); + return; + } + const container = getFocusCell(this.host, selection); + if (!container) { + return; + } + container.isFocus = false; + const cell = container?.cell; + + if (selection.isEditing) { + requestAnimationFrame(() => { + cell?.onExitEditMode(); + }); + if (cell?.blurCell()) { + container.blur(); + } + container.editing = false; + } else { + container.blur(); + } + } + + clear() { + this.selection = undefined; + } + + deleteCard() { + const selection = this.selection; + if (!selection || selection.selectionType === 'cell') { + return; + } + if (selection.selectionType === 'card') { + this.host.props.view.rowDelete(selection.cards.map(v => v.cardId)); + this.selection = undefined; + } + } + + focus(selection: KanbanViewSelection) { + if (selection.selectionType !== 'cell') { + const selectCards = getSelectedCards(this.host, selection); + selectCards.forEach((card, index) => { + if (index === 0) { + card.scrollIntoView({ block: 'nearest', inline: 'nearest' }); + } + card.isFocus = true; + }); + return; + } + const container = getFocusCell(this.host, selection); + if (!container) { + return; + } + container.scrollIntoView({ block: 'nearest', inline: 'nearest' }); + container.isFocus = true; + const cell = container?.cell; + if (selection.isEditing) { + cell?.onEnterEditMode(); + if (cell?.focusCell()) { + container.focus(); + } + container.editing = true; + } else { + container.focus(); + } + } + + focusFirstCell() { + const group = this.host.groupManager?.groupsDataList$.value?.[0]; + const card = group?.rows[0]; + const columnId = card && this.host.props.view.getHeaderTitle(card)?.id; + if (group && card && columnId) { + this.selection = { + selectionType: 'cell', + groupKey: group.key, + cardId: card, + columnId, + isEditing: false, + }; + } + } + + focusIn() { + const selection = this.selection; + if (!selection) return; + if (selection.selectionType === 'cell' && selection.isEditing) return; + + if (selection.selectionType === 'cell') { + this.selection = { + ...selection, + isEditing: true, + }; + return; + } + if (selection.selectionType === 'card') { + const card = getSelectedCards(this.host, selection)[0]; + const cell = card?.querySelector('affine-data-view-kanban-cell'); + if (cell) { + this.selection = { + groupKey: card.groupKey, + cardId: card.cardId, + selectionType: 'cell', + columnId: cell.column.id, + isEditing: false, + }; + } + } else { + // Not yet implement + } + } + + focusNext(position: 'up' | 'down' | 'left' | 'right') { + const selection = this.selection; + if (!selection) { + return; + } + + if (selection.selectionType === 'cell' && !selection.isEditing) { + // cell focus + const kanbanCells = getCardCellsBySelection(this.host, selection); + const index = kanbanCells.findIndex( + cell => cell.column.id === selection.columnId + ); + const { cell, cardId, groupKey } = this.getNextFocusCell( + selection, + index, + position + ); + if (cell instanceof KanbanCell) { + this.selection = { + ...selection, + cardId: cardId ?? selection.cardId, + groupKey: groupKey ?? selection.groupKey, + columnId: cell.column.id, + } satisfies KanbanCellSelection; + } + } else if (selection.selectionType === 'card') { + // card focus + const group = this.host.querySelector( + `affine-data-view-kanban-group[data-key="${selection.cards[0].groupKey}"]` + ); + const cardElements = Array.from( + group?.querySelectorAll('affine-data-view-kanban-card') ?? [] + ); + + const index = cardElements.findIndex( + card => card.cardId === selection.cards[0].cardId + ); + const { card, cards } = this.getNextFocusCard(selection, index, position); + if (card instanceof KanbanCard) { + const newCards = cards ?? selection.cards; + this.selection = atLeastOne(newCards) + ? { + ...selection, + cards: newCards, + } + : undefined; + } + } + } + + focusOut() { + const selection = this.selection; + if (selection?.selectionType === 'card') { + if (atLeastOne(selection.cards)) { + this.selection = { + ...selection, + cards: [selection.cards[0]], + }; + } else { + // Not yet implement + return; + } + } + if (selection?.selectionType !== 'cell') { + return; + } + + if (selection.isEditing) { + this.selection = { + ...selection, + isEditing: false, + }; + } else { + this.selection = { + selectionType: 'card', + cards: [ + { + cardId: selection.cardId, + groupKey: selection.groupKey, + }, + ], + }; + } + } + + getNextFocusCard( + selection: KanbanCardSelection, + index: number, + nextPosition: 'up' | 'down' | 'left' | 'right' + ): { + card: KanbanCard; + cards: KanbanCardSelectionCard[]; + } { + const group = this.host.querySelector( + `affine-data-view-kanban-group[data-key="${selection.cards[0].groupKey}"]` + ); + const kanbanCards = Array.from( + group?.querySelectorAll('affine-data-view-kanban-card') ?? [] + ); + + if (nextPosition === 'up') { + const nextIndex = index - 1; + const nextCardIndex = nextIndex < 0 ? kanbanCards.length - 1 : nextIndex; + const card = kanbanCards[nextCardIndex]; + + return { + card, + cards: [ + { + cardId: card.cardId, + groupKey: card.groupKey, + }, + ], + }; + } + + if (nextPosition === 'down') { + const nextIndex = index + 1; + const nextCardIndex = nextIndex > kanbanCards.length - 1 ? 0 : nextIndex; + const card = kanbanCards[nextCardIndex]; + + return { + card, + cards: [ + { + cardId: card.cardId, + groupKey: card.groupKey, + }, + ], + }; + } + + const groups = Array.from( + this.host.querySelectorAll('affine-data-view-kanban-group') + ); + + if (nextPosition === 'right') { + return getNextGroupFocusElement( + this.host, + groups, + selection, + groupIndex => (groupIndex === groups.length - 1 ? 0 : groupIndex + 1) + ); + } + + if (nextPosition === 'left') { + return getNextGroupFocusElement( + this.host, + groups, + selection, + groupIndex => (groupIndex === 0 ? groups.length - 1 : groupIndex - 1) + ); + } + throw new BlockSuiteError( + ErrorCode.DatabaseBlockError, + 'Unknown arrow keys, only support: up, down, left, and right keys.' + ); + } + + getNextFocusCell( + selection: KanbanCellSelection, + index: number, + nextPosition: 'up' | 'down' | 'left' | 'right' + ): { + cell: KanbanCell; + cardId?: string; + groupKey?: string; + } { + const kanbanCells = getCardCellsBySelection(this.host, selection); + const group = this.host.querySelector( + `affine-data-view-kanban-group[data-key="${selection.groupKey}"]` + ); + const cards = Array.from( + group?.querySelectorAll('affine-data-view-kanban-card') ?? [] + ); + + if (nextPosition === 'up') { + const nextIndex = index - 1; + if (nextIndex < 0) { + if (cards.length > 1) { + return getNextCardFocusCell( + nextPosition, + cards, + selection, + cardIndex => (cardIndex === 0 ? cards.length - 1 : cardIndex - 1) + ); + } else { + return { + cell: kanbanCells[kanbanCells.length - 1], + }; + } + } + return { + cell: kanbanCells[nextIndex], + }; + } + + if (nextPosition === 'down') { + const nextIndex = index + 1; + if (nextIndex >= kanbanCells.length) { + if (cards.length > 1) { + return getNextCardFocusCell( + nextPosition, + cards, + selection, + cardIndex => (cardIndex === cards.length - 1 ? 0 : cardIndex + 1) + ); + } else { + return { + cell: kanbanCells[0], + }; + } + } + return { + cell: kanbanCells[nextIndex], + }; + } + + const groups = Array.from( + this.host.querySelectorAll('affine-data-view-kanban-group') + ); + + if (nextPosition === 'right') { + return getNextGroupFocusElement( + this.host, + groups, + selection, + groupIndex => (groupIndex === groups.length - 1 ? 0 : groupIndex + 1) + ); + } + + if (nextPosition === 'left') { + return getNextGroupFocusElement( + this.host, + groups, + selection, + groupIndex => (groupIndex === 0 ? groups.length - 1 : groupIndex - 1) + ); + } + throw new BlockSuiteError( + ErrorCode.DatabaseBlockError, + 'Unknown arrow keys, only support: up, down, left, and right keys.' + ); + } + + hostConnected() { + this.host.disposables.add( + this.host.props.selection$.subscribe(selection => { + const old = this._selection; + if (old) { + this.blur(old); + } + this._selection = selection; + if (selection) { + this.focus(selection); + } + }) + ); + } + + insertRowAfter() { + const selection = this.selection; + if (selection?.selectionType !== 'card') { + return; + } + + const { cardId, groupKey } = selection.cards[0]; + const id = this.view.addCard({ before: false, id: cardId }, groupKey); + + requestAnimationFrame(() => { + const columnId = this.view.mainProperties$.value.titleColumn; + if (columnId) { + this.selection = { + selectionType: 'cell', + groupKey, + cardId: id, + columnId, + isEditing: true, + }; + } else { + this.selection = { + selectionType: 'card', + cards: [ + { + cardId: id, + groupKey, + }, + ], + }; + } + }); + } + + insertRowBefore() { + const selection = this.selection; + if (selection?.selectionType !== 'card') { + return; + } + + const { cardId, groupKey } = selection.cards[0]; + const id = this.view.addCard({ before: true, id: cardId }, groupKey); + + requestAnimationFrame(() => { + const columnId = this.view.mainProperties$.value.titleColumn; + if (columnId) { + this.selection = { + selectionType: 'cell', + groupKey, + cardId: id, + columnId, + isEditing: true, + }; + } else { + this.selection = { + selectionType: 'card', + cards: [ + { + cardId: id, + groupKey, + }, + ], + }; + } + }); + } + + moveCard(rowId: string, key: string) { + const selection = this.selection; + if (selection?.selectionType !== 'card') { + return; + } + this.view.groupTrait.moveCardTo( + rowId, + selection.cards[0].groupKey, + key, + 'start' + ); + requestAnimationFrame(() => { + if (this.selection?.selectionType !== 'card') return; + + const newCards = selection.cards.map(card => ({ + ...card, + groupKey: card.groupKey, + })); + this.selection = atLeastOne(newCards) + ? { + ...selection, + cards: newCards, + } + : undefined; + }); + } +} + +type NextFocusCell = { + cell: KanbanCell; + cardId: string; + groupKey: string; +}; +type NextFocusCard = { + card: KanbanCard; + cards: { + cardId: string; + groupKey: string; + }[]; +}; + +function getNextGroupFocusElement( + viewElement: Element, + groups: KanbanGroup[], + selection: KanbanCellSelection, + getNextGroupIndex: (groupIndex: number) => number +): NextFocusCell; +function getNextGroupFocusElement( + viewElement: Element, + groups: KanbanGroup[], + selection: KanbanCardSelection, + getNextGroupIndex: (groupIndex: number) => number +): NextFocusCard; +function getNextGroupFocusElement( + viewElement: Element, + groups: KanbanGroup[], + selection: KanbanCellSelection | KanbanCardSelection, + getNextGroupIndex: (groupIndex: number) => number +): NextFocusCell | NextFocusCard { + const groupIndex = groups.findIndex(group => { + if (selection.selectionType === 'cell') { + return group.group.key === selection.groupKey; + } + return group.group.key === selection.cards[0].groupKey; + }); + + let nextGroupIndex = getNextGroupIndex(groupIndex); + let nextGroup = groups[nextGroupIndex]; + while (nextGroup.group.rows.length === 0) { + nextGroupIndex = getNextGroupIndex(nextGroupIndex); + nextGroup = groups[nextGroupIndex]; + } + + const element = + selection.selectionType === 'cell' + ? getFocusCell(viewElement, selection) + : getSelectedCards(viewElement, selection)[0]; + assertExists(element); + const rect = element.getBoundingClientRect(); + const nextCards = Array.from( + nextGroup.querySelectorAll('affine-data-view-kanban-card') + ); + const cardPos = nextCards + .map((card, index) => { + const targetRect = card.getBoundingClientRect(); + return { + offsetY: getYOffset(rect, targetRect), + index, + }; + }) + .reduce((prev, curr) => { + if (prev.offsetY < curr.offsetY) { + return prev; + } + return curr; + }); + const nextCard = nextCards[cardPos.index]; + + if (selection.selectionType === 'card') { + return { + card: nextCard, + cards: [ + { + cardId: nextCard.cardId, + groupKey: nextGroup.group.key, + }, + ], + }; + } + + const cells = Array.from( + nextCard.querySelectorAll('affine-data-view-kanban-cell') + ); + const cellPos = cells + .map((card, index) => { + const targetRect = card.getBoundingClientRect(); + return { + offsetY: getYOffset(rect, targetRect), + index, + }; + }) + .reduce((prev, curr) => { + if (prev.offsetY < curr.offsetY) { + return prev; + } + return curr; + }); + const nextCell = cells[cellPos.index]; + + return { + cell: nextCell, + cardId: nextCard.cardId, + groupKey: nextGroup.group.key, + }; +} + +function getNextCardFocusCell( + nextPosition: 'up' | 'down', + cards: KanbanCard[], + selection: KanbanCellSelection, + getNextCardIndex: (cardIndex: number) => number +): { + cell: KanbanCell; + cardId: string; +} { + const cardIndex = cards.findIndex(card => card.cardId === selection.cardId); + const nextCardIndex = getNextCardIndex(cardIndex); + const nextCard = cards[nextCardIndex]; + const nextCells = Array.from( + nextCard.querySelectorAll('affine-data-view-kanban-cell') + ); + const nextCellIndex = nextPosition === 'up' ? nextCells.length - 1 : 0; + return { + cell: nextCells[nextCellIndex], + cardId: nextCard.cardId, + }; +} + +function getCardCellsBySelection( + viewElement: Element, + selection: KanbanCellSelection +) { + const card = getSelectedCard(viewElement, selection); + return Array.from( + card?.querySelectorAll('affine-data-view-kanban-cell') ?? [] + ); +} + +function getSelectedCard( + viewElement: Element, + selection: KanbanCellSelection +): KanbanCard | null { + const group = viewElement.querySelector( + `affine-data-view-kanban-group[data-key="${selection.groupKey}"]` + ); + + if (!group) return null; + return group.querySelector( + `affine-data-view-kanban-card[data-card-id="${selection.cardId}"]` + ); +} + +function getSelectedCards( + viewElement: Element, + selection: KanbanCardSelection | KanbanGroupSelection +): KanbanCard[] { + if (selection.selectionType === 'group') return []; + + const groupKeys = selection.cards.map(card => card.groupKey); + const groups = groupKeys + .map(key => + viewElement.querySelector( + `affine-data-view-kanban-group[data-key="${key}"]` + ) + ) + .filter((group): group is Element => group !== null); + + const cardIds = selection.cards.map(card => card.cardId); + const cards = groups + .flatMap(group => + cardIds.map(id => + group.querySelector( + `affine-data-view-kanban-card[data-card-id="${id}"]` + ) + ) + ) + .filter((card): card is KanbanCard => card !== null); + + return cards; +} + +function getFocusCell(viewElement: Element, selection: KanbanCellSelection) { + const card = getSelectedCard(viewElement, selection); + return card?.querySelector( + `affine-data-view-kanban-cell[data-column-id="${selection.columnId}"]` + ); +} + +function getYOffset(srcRect: DOMRect, targetRect: DOMRect) { + return Math.abs( + srcRect.top + + (srcRect.bottom - srcRect.top) / 2 - + (targetRect.top + (targetRect.bottom - targetRect.top) / 2) + ); +} + +const atLeastOne = (v: T[]): v is [T, ...T[]] => { + return v.length > 0; +}; diff --git a/blocksuite/affine/data-view/src/view-presets/kanban/pc/group.ts b/blocksuite/affine/data-view/src/view-presets/kanban/pc/group.ts new file mode 100644 index 0000000000..7544f32fd0 --- /dev/null +++ b/blocksuite/affine/data-view/src/view-presets/kanban/pc/group.ts @@ -0,0 +1,210 @@ +import { + menu, + popFilterableSimpleMenu, + popupTargetFromElement, +} from '@blocksuite/affine-components/context-menu'; +import { ShadowlessElement } from '@blocksuite/block-std'; +import { SignalWatcher, WithDisposable } from '@blocksuite/global/utils'; +import { AddCursorIcon } from '@blocksuite/icons/lit'; +import { css, nothing } from 'lit'; +import { property } from 'lit/decorators.js'; +import { repeat } from 'lit/directives/repeat.js'; +import { html } from 'lit/static-html.js'; + +import type { DataViewRenderer } from '../../../core/data-view.js'; +import { GroupTitle } from '../../../core/group-by/group-title.js'; +import type { GroupData } from '../../../core/group-by/trait.js'; +import { dragHandler } from '../../../core/utils/wc-dnd/dnd-context.js'; +import type { KanbanSingleView } from '../kanban-view-manager.js'; + +const styles = css` + affine-data-view-kanban-group { + width: 260px; + flex-shrink: 0; + border-radius: 8px; + display: flex; + flex-direction: column; + } + + .group-header { + height: 32px; + padding: 6px 4px; + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + overflow: hidden; + } + + .group-header-title { + overflow: hidden; + display: flex; + align-items: center; + gap: 8px; + font-size: var(--data-view-cell-text-size); + } + + affine-data-view-kanban-group:hover .group-header-op { + visibility: visible; + opacity: 1; + } + + .group-body { + margin-top: 4px; + display: flex; + flex-direction: column; + padding: 0 4px; + gap: 12px; + } + + .add-card { + display: flex; + align-items: center; + padding: 4px; + border-radius: 4px; + cursor: pointer; + font-size: var(--data-view-cell-text-size); + line-height: var(--data-view-cell-text-line-height); + visibility: hidden; + opacity: 0; + transition: all 150ms cubic-bezier(0.42, 0, 1, 1); + color: var(--affine-text-secondary-color); + } + + affine-data-view-kanban-group:hover .add-card { + visibility: visible; + opacity: 1; + } + + affine-data-view-kanban-group .add-card:hover { + background-color: var(--affine-hover-color); + color: var(--affine-text-primary-color); + } + + .sortable-ghost { + background-color: var(--affine-hover-color); + opacity: 0.5; + } + + .sortable-drag { + background-color: var(--affine-background-primary-color); + } +`; + +export class KanbanGroup extends SignalWatcher( + WithDisposable(ShadowlessElement) +) { + static override styles = styles; + + private clickAddCard = () => { + const id = this.view.addCard('end', this.group.key); + requestAnimationFrame(() => { + const kanban = this.closest('affine-data-view-kanban'); + if (kanban) { + kanban.selectionController.selection = { + selectionType: 'cell', + groupKey: this.group.key, + cardId: id, + columnId: + this.view.mainProperties$.value.titleColumn || + this.view.propertyIds$.value[0], + isEditing: true, + }; + } + }); + }; + + private clickAddCardInStart = () => { + const id = this.view.addCard('start', this.group.key); + requestAnimationFrame(() => { + const kanban = this.closest('affine-data-view-kanban'); + if (kanban) { + kanban.selectionController.selection = { + selectionType: 'cell', + groupKey: this.group.key, + cardId: id, + columnId: + this.view.mainProperties$.value.titleColumn || + this.view.propertyIds$.value[0], + isEditing: true, + }; + } + }); + }; + + private clickGroupOptions = (e: MouseEvent) => { + const ele = e.currentTarget as HTMLElement; + popFilterableSimpleMenu(popupTargetFromElement(ele), [ + menu.action({ + name: 'Ungroup', + hide: () => this.group.value == null, + select: () => { + this.group.rows.forEach(id => { + this.group.manager.removeFromGroup(id, this.group.key); + }); + }, + }), + menu.action({ + name: 'Delete Cards', + select: () => { + this.view.rowDelete(this.group.rows); + }, + }), + ]); + }; + + override render() { + const cards = this.group.rows; + return html` +
+ ${GroupTitle(this.group, { + readonly: this.view.readonly$.value, + clickAdd: this.clickAddCardInStart, + clickOps: this.clickGroupOptions, + })} +
+
+ ${repeat( + cards, + id => id, + id => { + return html` + + `; + } + )} + ${this.view.readonly$.value + ? nothing + : html`
+
+ ${AddCursorIcon()} +
+ Add +
`} +
+ `; + } + + @property({ attribute: false }) + accessor dataViewEle!: DataViewRenderer; + + @property({ attribute: false }) + accessor group!: GroupData; + + @property({ attribute: false }) + accessor view!: KanbanSingleView; +} + +declare global { + interface HTMLElementTagNameMap { + 'affine-data-view-kanban-group': KanbanGroup; + } +} diff --git a/blocksuite/affine/data-view/src/view-presets/kanban/pc/header.ts b/blocksuite/affine/data-view/src/view-presets/kanban/pc/header.ts new file mode 100644 index 0000000000..d4378246e4 --- /dev/null +++ b/blocksuite/affine/data-view/src/view-presets/kanban/pc/header.ts @@ -0,0 +1,76 @@ +import { + menu, + popMenu, + popupTargetFromElement, +} from '@blocksuite/affine-components/context-menu'; +import { ShadowlessElement } from '@blocksuite/block-std'; +import { SignalWatcher, WithDisposable } from '@blocksuite/global/utils'; +import { css } from 'lit'; +import { property } from 'lit/decorators.js'; +import { html } from 'lit/static-html.js'; + +import { groupTraitKey } from '../../../core/group-by/trait.js'; +import type { SingleView } from '../../../core/index.js'; + +const styles = css` + affine-data-view-kanban-header { + display: flex; + justify-content: space-between; + padding: 4px; + } + + .select-group { + border-radius: 8px; + padding: 4px 8px; + cursor: pointer; + } + + .select-group:hover { + background-color: var(--affine-hover-color); + } +`; + +export class KanbanHeader extends SignalWatcher( + WithDisposable(ShadowlessElement) +) { + static override styles = styles; + + private clickGroup = (e: MouseEvent) => { + const groupTrait = this.view.traitGet(groupTraitKey); + if (!groupTrait) { + return; + } + popMenu(popupTargetFromElement(e.target as HTMLElement), { + options: { + items: this.view.properties$.value + .filter(column => column.id !== groupTrait.property$.value?.id) + .map(column => { + return menu.action({ + name: column.name$.value, + select: () => { + groupTrait.changeGroup(column.id); + }, + }); + }), + }, + }); + }; + + override render() { + return html` +
+
+
Group
+
+ `; + } + + @property({ attribute: false }) + accessor view!: SingleView; +} + +declare global { + interface HTMLElementTagNameMap { + 'affine-data-view-kanban-header': KanbanHeader; + } +} diff --git a/blocksuite/affine/data-view/src/view-presets/kanban/pc/kanban-view.ts b/blocksuite/affine/data-view/src/view-presets/kanban/pc/kanban-view.ts new file mode 100644 index 0000000000..83a55978b2 --- /dev/null +++ b/blocksuite/affine/data-view/src/view-presets/kanban/pc/kanban-view.ts @@ -0,0 +1,295 @@ +import { + menu, + popMenu, + popupTargetFromElement, +} from '@blocksuite/affine-components/context-menu'; +import { AddCursorIcon } from '@blocksuite/icons/lit'; +import { computed } from '@preact/signals-core'; +import { css } from 'lit'; +import { query } from 'lit/decorators.js'; +import { repeat } from 'lit/directives/repeat.js'; +import { styleMap } from 'lit/directives/style-map.js'; +import { html } from 'lit/static-html.js'; + +import { type DataViewInstance, renderUniLit } from '../../../core/index.js'; +import { defaultActivators } from '../../../core/utils/wc-dnd/sensors/index.js'; +import { + createSortContext, + sortable, +} from '../../../core/utils/wc-dnd/sort/sort-context.js'; +import { horizontalListSortingStrategy } from '../../../core/utils/wc-dnd/sort/strategies/index.js'; +import { DataViewBase } from '../../../core/view/data-view-base.js'; +import type { KanbanSingleView } from '../kanban-view-manager.js'; +import type { KanbanViewSelectionWithType } from '../types.js'; +import { KanbanClipboardController } from './controller/clipboard.js'; +import { KanbanDragController } from './controller/drag.js'; +import { KanbanHotkeysController } from './controller/hotkeys.js'; +import { KanbanSelectionController } from './controller/selection.js'; + +const styles = css` + affine-data-view-kanban { + user-select: none; + display: flex; + flex-direction: column; + } + + .affine-data-view-kanban-groups { + position: relative; + z-index: 1; + display: flex; + gap: 20px; + padding-bottom: 4px; + overflow-x: scroll; + overflow-y: hidden; + } + + .affine-data-view-kanban-groups:hover { + padding-bottom: 0px; + } + + .affine-data-view-kanban-groups::-webkit-scrollbar { + -webkit-appearance: none; + display: block; + } + + .affine-data-view-kanban-groups::-webkit-scrollbar:horizontal { + height: 4px; + } + + .affine-data-view-kanban-groups::-webkit-scrollbar-thumb { + border-radius: 2px; + background-color: transparent; + } + + .affine-data-view-kanban-groups:hover::-webkit-scrollbar:horizontal { + height: 8px; + } + + .affine-data-view-kanban-groups:hover::-webkit-scrollbar-thumb { + border-radius: 16px; + background-color: var(--affine-black-30); + } + + .affine-data-view-kanban-groups:hover::-webkit-scrollbar-track { + background-color: var(--affine-hover-color); + } + + .add-group-icon { + padding: 4px; + border-radius: 4px; + display: flex; + align-items: center; + cursor: pointer; + } + + .add-group-icon:hover { + background-color: var(--affine-hover-color); + } + + .add-group-icon svg { + width: 16px; + height: 16px; + fill: var(--affine-icon-color); + color: var(--affine-icon-color); + } +`; + +export class DataViewKanban extends DataViewBase< + KanbanSingleView, + KanbanViewSelectionWithType +> { + static override styles = styles; + + private dragController = new KanbanDragController(this); + + clipboardController = new KanbanClipboardController(this); + + hotkeysController = new KanbanHotkeysController(this); + + onWheel = (event: WheelEvent) => { + if (event.metaKey || event.ctrlKey) { + return; + } + const ele = event.currentTarget; + if (ele instanceof HTMLElement) { + if (ele.scrollWidth === ele.clientWidth) { + return; + } + event.stopPropagation(); + } + }; + + renderAddGroup = () => { + const addGroup = this.groupManager.addGroup; + if (!addGroup) { + return; + } + const add = (e: MouseEvent) => { + const ele = e.currentTarget as HTMLElement; + popMenu(popupTargetFromElement(ele), { + options: { + items: [ + menu.input({ + onComplete: text => { + const column = this.groupManager.property$.value; + if (column) { + column.dataUpdate( + () => + addGroup({ + text, + oldData: column.data$.value, + dataSource: this.props.view.manager.dataSource, + }) as never + ); + } + }, + }), + ], + }, + }); + }; + return html`
+
${AddCursorIcon()}
+
`; + }; + + selectionController = new KanbanSelectionController(this); + + sortContext = createSortContext({ + activators: defaultActivators, + container: this, + onDragEnd: evt => { + const over = evt.over; + const activeId = evt.active.id; + const groups = this.groupManager.groupsDataList$.value; + if (over && over.id !== activeId && groups) { + const activeIndex = groups.findIndex(data => data.key === activeId); + const overIndex = groups.findIndex(data => data.key === over.id); + + this.groupManager.moveGroupTo( + activeId, + activeIndex > overIndex + ? { + before: true, + id: over.id, + } + : { + before: false, + id: over.id, + } + ); + } + }, + modifiers: [ + ({ transform }) => { + return { + ...transform, + y: 0, + }; + }, + ], + items: computed(() => { + return this.groupManager.groupsDataList$.value?.map(v => v.key) ?? []; + }), + strategy: horizontalListSortingStrategy, + }); + + get expose(): DataViewInstance { + return { + clearSelection: () => { + this.selectionController.clear(); + }, + addRow: position => { + if (this.props.view.readonly$.value) return; + const rowId = this.props.view.rowAdd(position); + if (rowId) { + this.props.dataViewEle.openDetailPanel({ + view: this.props.view, + rowId, + }); + } + return rowId; + }, + focusFirstCell: () => { + this.selectionController.focusFirstCell(); + }, + getSelection: () => { + return this.selectionController.selection; + }, + hideIndicator: () => { + this.dragController.dropPreview.remove(); + }, + moveTo: (id, evt) => { + const position = this.dragController.getInsertPosition(evt); + if (position) { + position.group.group.manager.moveCardTo( + id, + '', + position.group.group.key, + position.position + ); + } + }, + showIndicator: evt => { + return this.dragController.shooIndicator(evt, undefined) != null; + }, + view: this.props.view, + eventTrace: this.props.eventTrace, + }; + } + + get groupManager() { + return this.props.view.groupTrait; + } + + override render() { + const groups = this.groupManager.groupsDataList$.value; + if (!groups) { + return html``; + } + const vPadding = this.props.virtualPadding$.value; + const wrapperStyle = styleMap({ + marginLeft: `-${vPadding}px`, + marginRight: `-${vPadding}px`, + paddingLeft: `${vPadding}px`, + paddingRight: `${vPadding}px`, + }); + return html` + ${renderUniLit(this.props.headerWidget, { + dataViewInstance: this.expose, + })} +
+ ${repeat( + groups, + group => group.key, + group => { + return html` `; + } + )} + ${this.renderAddGroup()} +
+ `; + } + + @query('.affine-data-view-kanban-groups') + accessor groups!: HTMLElement; +} + +declare global { + interface HTMLElementTagNameMap { + 'affine-data-view-kanban': DataViewKanban; + } +} diff --git a/blocksuite/affine/data-view/src/view-presets/kanban/pc/menu.ts b/blocksuite/affine/data-view/src/view-presets/kanban/pc/menu.ts new file mode 100644 index 0000000000..743b8e4cbc --- /dev/null +++ b/blocksuite/affine/data-view/src/view-presets/kanban/pc/menu.ts @@ -0,0 +1,114 @@ +import { + menu, + popFilterableSimpleMenu, + type PopupTarget, +} from '@blocksuite/affine-components/context-menu'; +import { + ArrowRightBigIcon, + DeleteIcon, + ExpandFullIcon, + MoveLeftIcon, + MoveRightIcon, +} from '@blocksuite/icons/lit'; +import { html } from 'lit'; + +import type { DataViewRenderer } from '../../../core/data-view.js'; +import type { KanbanSelectionController } from './controller/selection.js'; + +export const openDetail = ( + dataViewEle: DataViewRenderer, + rowId: string, + selection: KanbanSelectionController +) => { + const old = selection.selection; + selection.selection = undefined; + dataViewEle.openDetailPanel({ + view: selection.view, + rowId: rowId, + onClose: () => { + selection.selection = old; + }, + }); +}; + +export const popCardMenu = ( + dataViewEle: DataViewRenderer, + ele: PopupTarget, + rowId: string, + selection: KanbanSelectionController +) => { + popFilterableSimpleMenu(ele, [ + menu.action({ + name: 'Expand Card', + prefix: ExpandFullIcon(), + select: () => { + openDetail(dataViewEle, rowId, selection); + }, + }), + menu.subMenu({ + name: 'Move To', + prefix: ArrowRightBigIcon(), + options: { + items: + selection.view.groupTrait.groupsDataList$.value + ?.filter(v => { + const cardSelection = selection.selection; + if (cardSelection?.selectionType === 'card') { + return v.key !== cardSelection?.cards[0].groupKey; + } + return false; + }) + .map(group => { + return menu.action({ + name: group.value != null ? group.name : 'Ungroup', + select: () => { + selection.moveCard(rowId, group.key); + }, + }); + }) ?? [], + }, + }), + menu.group({ + name: '', + items: [ + menu.action({ + name: 'Insert Before', + prefix: html`
+ ${MoveLeftIcon()} +
`, + select: () => { + selection.insertRowBefore(); + }, + }), + menu.action({ + name: 'Insert After', + prefix: html`
+ ${MoveRightIcon()} +
`, + select: () => { + selection.insertRowAfter(); + }, + }), + ], + }), + menu.group({ + name: '', + items: [ + menu.action({ + name: 'Delete Card', + class: { + 'delete-item': true, + }, + prefix: DeleteIcon(), + select: () => { + selection.deleteCard(); + }, + }), + ], + }), + ]); +}; diff --git a/blocksuite/affine/data-view/src/view-presets/kanban/renderer.ts b/blocksuite/affine/data-view/src/view-presets/kanban/renderer.ts new file mode 100644 index 0000000000..de9a40cfcc --- /dev/null +++ b/blocksuite/affine/data-view/src/view-presets/kanban/renderer.ts @@ -0,0 +1,11 @@ +import { createUniComponentFromWebComponent } from '../../core/index.js'; +import { createIcon } from '../../core/utils/uni-icon.js'; +import { kanbanViewModel } from './define.js'; +import { MobileDataViewKanban } from './mobile/kanban-view.js'; +import { DataViewKanban } from './pc/kanban-view.js'; + +export const kanbanViewMeta = kanbanViewModel.createMeta({ + icon: createIcon('DatabaseKanbanViewIcon'), + view: createUniComponentFromWebComponent(DataViewKanban), + mobileView: createUniComponentFromWebComponent(MobileDataViewKanban), +}); diff --git a/blocksuite/affine/data-view/src/view-presets/kanban/types.ts b/blocksuite/affine/data-view/src/view-presets/kanban/types.ts new file mode 100644 index 0000000000..f79dbdcf51 --- /dev/null +++ b/blocksuite/affine/data-view/src/view-presets/kanban/types.ts @@ -0,0 +1,32 @@ +type WithKanbanViewType = T extends unknown + ? { + viewId: string; + type: 'kanban'; + } & T + : never; + +export type KanbanCellSelection = { + selectionType: 'cell'; + groupKey: string; + cardId: string; + columnId: string; + isEditing: boolean; +}; +export type KanbanCardSelectionCard = { + groupKey: string; + cardId: string; +}; +export type KanbanCardSelection = { + selectionType: 'card'; + cards: [KanbanCardSelectionCard, ...KanbanCardSelectionCard[]]; +}; +export type KanbanGroupSelection = { + selectionType: 'group'; + groupKeys: [string, ...string[]]; +}; +export type KanbanViewSelection = + | KanbanCellSelection + | KanbanCardSelection + | KanbanGroupSelection; +export type KanbanViewSelectionWithType = + WithKanbanViewType; diff --git a/blocksuite/affine/data-view/src/view-presets/table/consts.ts b/blocksuite/affine/data-view/src/view-presets/table/consts.ts new file mode 100644 index 0000000000..80f26e0a17 --- /dev/null +++ b/blocksuite/affine/data-view/src/view-presets/table/consts.ts @@ -0,0 +1,10 @@ +/** column default width */ +export const DEFAULT_COLUMN_WIDTH = 180; +/** column min width */ +export const DEFAULT_COLUMN_MIN_WIDTH = 100; +/** column title height */ +export const DEFAULT_COLUMN_TITLE_HEIGHT = 34; +/** column title height */ +export const DEFAULT_ADD_BUTTON_WIDTH = 40; +export const LEFT_TOOL_BAR_WIDTH = 24; +export const STATS_BAR_HEIGHT = 34; diff --git a/blocksuite/affine/data-view/src/view-presets/table/define.ts b/blocksuite/affine/data-view/src/view-presets/table/define.ts new file mode 100644 index 0000000000..ba411863e6 --- /dev/null +++ b/blocksuite/affine/data-view/src/view-presets/table/define.ts @@ -0,0 +1,51 @@ +import type { GroupBy, GroupProperty } from '../../core/common/types.js'; +import type { FilterGroup } from '../../core/filter/types.js'; +import type { Sort } from '../../core/sort/types.js'; +import { type BasicViewDataType, viewType } from '../../core/view/data-view.js'; +import { TableSingleView } from './table-view-manager.js'; + +export const tableViewType = viewType('table'); + +export type TableViewColumn = { + id: string; + width: number; + statCalcType?: string; + hide?: boolean; +}; +type DataType = { + columns: TableViewColumn[]; + filter: FilterGroup; + groupBy?: GroupBy; + groupProperties?: GroupProperty[]; + sort?: Sort; + header?: { + titleColumn?: string; + iconColumn?: string; + imageColumn?: string; + }; +}; +export type TableViewData = BasicViewDataType< + typeof tableViewType.type, + DataType +>; +export const tableViewModel = tableViewType.createModel({ + defaultName: 'Table View', + dataViewManager: TableSingleView, + defaultData: viewManager => { + return { + mode: 'table', + columns: [], + filter: { + type: 'group', + op: 'and', + conditions: [], + }, + header: { + titleColumn: viewManager.dataSource.properties$.value.find( + id => viewManager.dataSource.propertyTypeGet(id) === 'title' + ), + iconColumn: 'type', + }, + }; + }, +}); diff --git a/blocksuite/affine/data-view/src/view-presets/table/index.ts b/blocksuite/affine/data-view/src/view-presets/table/index.ts new file mode 100644 index 0000000000..7f11c0fe16 --- /dev/null +++ b/blocksuite/affine/data-view/src/view-presets/table/index.ts @@ -0,0 +1,4 @@ +export * from './define.js'; +export * from './pc/table-view.js'; +export * from './renderer.js'; +export * from './table-view-manager.js'; diff --git a/blocksuite/affine/data-view/src/view-presets/table/mobile/cell.ts b/blocksuite/affine/data-view/src/view-presets/table/mobile/cell.ts new file mode 100644 index 0000000000..41d0c436d3 --- /dev/null +++ b/blocksuite/affine/data-view/src/view-presets/table/mobile/cell.ts @@ -0,0 +1,174 @@ +import { ShadowlessElement } from '@blocksuite/block-std'; +import { SignalWatcher, WithDisposable } from '@blocksuite/global/utils'; +import { computed, effect } from '@preact/signals-core'; +import { css } from 'lit'; +import { property, state } from 'lit/decorators.js'; +import { createRef } from 'lit/directives/ref.js'; + +import { + type CellRenderProps, + type DataViewCellLifeCycle, + renderUniLit, + type SingleView, +} from '../../../core/index.js'; +import type { TableColumn } from '../table-view-manager.js'; +import { TableAreaSelection } from '../types.js'; + +export class MobileTableCell extends SignalWatcher( + WithDisposable(ShadowlessElement) +) { + static override styles = css` + mobile-table-cell { + display: flex; + align-items: start; + width: 100%; + height: 100%; + border: none; + outline: none; + } + + mobile-table-cell * { + box-sizing: border-box; + } + + mobile-table-cell uni-lit > *:first-child { + padding: 6px; + } + `; + + private _cell = createRef(); + + @property({ attribute: false }) + accessor column!: TableColumn; + + @property({ attribute: false }) + accessor rowId!: string; + + cell$ = computed(() => { + return this.column.cellGet(this.rowId); + }); + + isEditing$ = computed(() => { + const selection = this.table?.props.selection$.value; + if (selection?.selectionType !== 'area') { + return false; + } + if (selection.groupKey !== this.groupKey) { + return false; + } + if (selection.focus.columnIndex !== this.columnIndex) { + return false; + } + if (selection.focus.rowIndex !== this.rowIndex) { + return false; + } + return selection.isEditing; + }); + + selectCurrentCell = (editing: boolean) => { + if (this.view.readonly$.value) { + return; + } + const setSelection = this.table?.props.setSelection; + const viewId = this.table?.props.view.id; + if (setSelection && viewId) { + if (editing && this.cell?.beforeEnterEditMode() === false) { + return; + } + setSelection({ + viewId, + type: 'table', + ...TableAreaSelection.create({ + groupKey: this.groupKey, + focus: { + rowIndex: this.rowIndex, + columnIndex: this.columnIndex, + }, + isEditing: editing, + }), + }); + } + }; + + get cell(): DataViewCellLifeCycle | undefined { + return this._cell.value; + } + + private get groupKey() { + return this.closest('mobile-table-group')?.group?.key; + } + + private get readonly() { + return this.column.readonly$.value; + } + + private get table() { + return this.closest('mobile-data-view-table'); + } + + override connectedCallback() { + super.connectedCallback(); + if (this.column.readonly$.value) return; + this.disposables.add( + effect(() => { + const isEditing = this.isEditing$.value; + if (isEditing) { + this.isEditing = true; + this._cell.value?.onEnterEditMode(); + } else { + this._cell.value?.onExitEditMode(); + this.isEditing = false; + } + }) + ); + this.disposables.addFromEvent(this, 'click', () => { + if (!this.isEditing) { + this.selectCurrentCell(!this.column.readonly$.value); + } + }); + } + + override render() { + const renderer = this.column.renderer$.value; + if (!renderer) { + return; + } + const { edit, view } = renderer; + const uni = !this.readonly && this.isEditing && edit != null ? edit : view; + this.view.lockRows(this.isEditing); + this.dataset['editing'] = `${this.isEditing}`; + const props: CellRenderProps = { + cell: this.cell$.value, + isEditing: this.isEditing, + selectCurrentCell: this.selectCurrentCell, + }; + + return renderUniLit(uni, props, { + ref: this._cell, + style: { + display: 'contents', + }, + }); + } + + @property({ attribute: false }) + accessor columnId!: string; + + @property({ attribute: false }) + accessor columnIndex!: number; + + @state() + accessor isEditing = false; + + @property({ attribute: false }) + accessor rowIndex!: number; + + @property({ attribute: false }) + accessor view!: SingleView; +} + +declare global { + interface HTMLElementTagNameMap { + 'mobile-table-cell': MobileTableCell; + } +} diff --git a/blocksuite/affine/data-view/src/view-presets/table/mobile/column-header.ts b/blocksuite/affine/data-view/src/view-presets/table/mobile/column-header.ts new file mode 100644 index 0000000000..0a8cfed8c7 --- /dev/null +++ b/blocksuite/affine/data-view/src/view-presets/table/mobile/column-header.ts @@ -0,0 +1,286 @@ +import { + menu, + type MenuConfig, + popMenu, + popupTargetFromElement, +} from '@blocksuite/affine-components/context-menu'; +import { unsafeCSSVarV2 } from '@blocksuite/affine-shared/theme'; +import { ShadowlessElement } from '@blocksuite/block-std'; +import { SignalWatcher, WithDisposable } from '@blocksuite/global/utils'; +import { + DeleteIcon, + DuplicateIcon, + InsertLeftIcon, + InsertRightIcon, + MoveLeftIcon, + MoveRightIcon, + ViewIcon, +} from '@blocksuite/icons/lit'; +import { css } from 'lit'; +import { property } from 'lit/decorators.js'; +import { styleMap } from 'lit/directives/style-map.js'; +import { html } from 'lit/static-html.js'; + +import { inputConfig, typeConfig } from '../../../core/common/property-menu.js'; +import type { Property } from '../../../core/view-manager/property.js'; +import type { NumberPropertyDataType } from '../../../property-presets/index.js'; +import { numberFormats } from '../../../property-presets/number/utils/formats.js'; +import { DEFAULT_COLUMN_TITLE_HEIGHT } from '../consts.js'; +import type { TableColumn, TableSingleView } from '../table-view-manager.js'; + +export class MobileTableColumnHeader extends SignalWatcher( + WithDisposable(ShadowlessElement) +) { + static override styles = css` + .mobile-table-column-header { + display: flex; + padding: 6px; + gap: 6px; + align-items: center; + } + + .mobile-table-column-header-icon { + font-size: 18px; + color: ${unsafeCSSVarV2('database/textSecondary')}; + display: flex; + align-items: center; + } + + .mobile-table-column-header-name { + font-weight: 500; + font-size: 14px; + color: ${unsafeCSSVarV2('database/textSecondary')}; + } + `; + + private _clickColumn = () => { + if (this.tableViewManager.readonly$.value) { + return; + } + this.popMenu(); + }; + + editTitle = () => { + this._clickColumn(); + }; + + private popMenu(ele?: HTMLElement) { + const enableNumberFormatting = + this.tableViewManager.featureFlags$.value.enable_number_formatting; + + popMenu(popupTargetFromElement(ele ?? this), { + options: { + title: { + text: 'Property settings', + }, + items: [ + inputConfig(this.column), + typeConfig(this.column), + // Number format begin + ...(enableNumberFormatting + ? [ + menu.subMenu({ + name: 'Number Format', + hide: () => + !this.column.dataUpdate || + this.column.type$.value !== 'number', + options: { + title: { + text: 'Number Format', + }, + items: [ + numberFormatConfig(this.column), + ...numberFormats.map(format => { + const data = ( + this.column as Property< + number, + NumberPropertyDataType + > + ).data$.value; + return menu.action({ + isSelected: data.format === format.type, + prefix: html`${format.symbol}`, + name: format.label, + select: () => { + if (data.format === format.type) return; + this.column.dataUpdate(() => ({ + format: format.type, + })); + }, + }); + }), + ], + }, + }), + ] + : []), + // Number format end + menu.group({ + items: [ + menu.action({ + name: 'Hide In View', + prefix: ViewIcon(), + hide: () => + this.column.hide$.value || + this.column.type$.value === 'title', + select: () => { + this.column.hideSet(true); + }, + }), + ], + }), + menu.group({ + items: [ + menu.action({ + name: 'Insert Left Column', + prefix: InsertLeftIcon(), + select: () => { + this.tableViewManager.propertyAdd({ + id: this.column.id, + before: true, + }); + Promise.resolve() + .then(() => { + const pre = + this.previousElementSibling?.previousElementSibling; + if (pre instanceof MobileTableColumnHeader) { + pre.editTitle(); + pre.scrollIntoView({ + inline: 'nearest', + block: 'nearest', + }); + } + }) + .catch(console.error); + }, + }), + menu.action({ + name: 'Insert Right Column', + prefix: InsertRightIcon(), + select: () => { + this.tableViewManager.propertyAdd({ + id: this.column.id, + before: false, + }); + Promise.resolve() + .then(() => { + const next = this.nextElementSibling?.nextElementSibling; + if (next instanceof MobileTableColumnHeader) { + next.editTitle(); + next.scrollIntoView({ + inline: 'nearest', + block: 'nearest', + }); + } + }) + .catch(console.error); + }, + }), + menu.action({ + name: 'Move Left', + prefix: MoveLeftIcon(), + hide: () => this.column.isFirst, + select: () => { + const preId = this.tableViewManager.propertyPreGet( + this.column.id + )?.id; + if (!preId) { + return; + } + this.tableViewManager.propertyMove(this.column.id, { + id: preId, + before: true, + }); + }, + }), + menu.action({ + name: 'Move Right', + prefix: MoveRightIcon(), + hide: () => this.column.isLast, + select: () => { + const nextId = this.tableViewManager.propertyNextGet( + this.column.id + )?.id; + if (!nextId) { + return; + } + this.tableViewManager.propertyMove(this.column.id, { + id: nextId, + before: false, + }); + }, + }), + ], + }), + menu.group({ + items: [ + menu.action({ + name: 'Duplicate', + prefix: DuplicateIcon(), + hide: () => + !this.column.duplicate || this.column.type$.value === 'title', + select: () => { + this.column.duplicate?.(); + }, + }), + menu.action({ + name: 'Delete', + prefix: DeleteIcon(), + hide: () => + !this.column.delete || this.column.type$.value === 'title', + select: () => { + this.column.delete?.(); + }, + class: { + 'delete-item': true, + }, + }), + ], + }), + ], + }, + }); + } + + override render() { + const column = this.column; + const style = styleMap({ + height: DEFAULT_COLUMN_TITLE_HEIGHT + 'px', + }); + return html` +
+ +
${column.name$.value}
+
+ `; + } + + @property({ attribute: false }) + accessor column!: TableColumn; + + @property({ attribute: false }) + accessor tableViewManager!: TableSingleView; +} + +function numberFormatConfig(column: Property): MenuConfig { + return () => + html` `; +} + +declare global { + interface HTMLElementTagNameMap { + 'mobile-table-column-header': MobileTableColumnHeader; + } +} diff --git a/blocksuite/affine/data-view/src/view-presets/table/mobile/group.ts b/blocksuite/affine/data-view/src/view-presets/table/mobile/group.ts new file mode 100644 index 0000000000..cff0ad8f4d --- /dev/null +++ b/blocksuite/affine/data-view/src/view-presets/table/mobile/group.ts @@ -0,0 +1,200 @@ +import { + menu, + popFilterableSimpleMenu, + popupTargetFromElement, +} from '@blocksuite/affine-components/context-menu'; +import { ShadowlessElement } from '@blocksuite/block-std'; +import { SignalWatcher, WithDisposable } from '@blocksuite/global/utils'; +import { PlusIcon } from '@blocksuite/icons/lit'; +import { css, html } from 'lit'; +import { property, query } from 'lit/decorators.js'; +import { repeat } from 'lit/directives/repeat.js'; + +import type { DataViewRenderer } from '../../../core/data-view.js'; +import { GroupTitle } from '../../../core/group-by/group-title.js'; +import type { GroupData } from '../../../core/group-by/trait.js'; +import { LEFT_TOOL_BAR_WIDTH } from '../consts.js'; +import type { DataViewTable } from '../pc/table-view.js'; +import type { TableSingleView } from '../table-view-manager.js'; +import { TableAreaSelection } from '../types.js'; + +const styles = css` + .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); + } + + .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 MobileTableGroup extends SignalWatcher( + WithDisposable(ShadowlessElement) +) { + static override styles = styles; + + private clickAddRow = () => { + this.view.rowAdd('end', this.group?.key); + requestAnimationFrame(() => { + const selectionController = this.viewEle.selectionController; + const index = this.view.properties$.value.findIndex( + v => v.type$.value === 'title' + ); + selectionController.selection = TableAreaSelection.create({ + groupKey: this.group?.key, + focus: { + rowIndex: this.rows.length - 1, + columnIndex: index, + }, + isEditing: true, + }); + }); + }; + + private clickAddRowInStart = () => { + this.view.rowAdd('start', this.group?.key); + requestAnimationFrame(() => { + const selectionController = this.viewEle.selectionController; + const index = this.view.properties$.value.findIndex( + v => v.type$.value === 'title' + ); + selectionController.selection = TableAreaSelection.create({ + groupKey: this.group?.key, + focus: { + rowIndex: 0, + columnIndex: index, + }, + isEditing: true, + }); + }); + }; + + private clickGroupOptions = (e: MouseEvent) => { + const group = this.group; + if (!group) { + return; + } + const ele = e.currentTarget as HTMLElement; + popFilterableSimpleMenu(popupTargetFromElement(ele), [ + menu.action({ + name: 'Ungroup', + hide: () => group.value == null, + select: () => { + group.rows.forEach(id => { + group.manager.removeFromGroup(id, group.key); + }); + }, + }), + menu.action({ + name: 'Delete Cards', + select: () => { + this.view.rowDelete(group.rows); + }, + }), + ]); + }; + + private renderGroupHeader = () => { + if (!this.group) { + return null; + } + return html` +
+ ${GroupTitle(this.group, { + readonly: this.view.readonly$.value, + clickAdd: this.clickAddRowInStart, + clickOps: this.clickGroupOptions, + })} +
+ `; + }; + + get rows() { + return this.group?.rows ?? this.view.rows$.value; + } + + private renderRows(ids: string[]) { + return html` + +
+ ${repeat( + ids, + id => id, + (id, idx) => { + return html` `; + } + )} +
+ ${this.view.readonly$.value + ? null + : html`
+
+ ${PlusIcon()}New Record +
+
`} + + + `; + } + + override render() { + return this.renderRows(this.rows); + } + + @property({ attribute: false }) + accessor dataViewEle!: DataViewRenderer; + + @property({ attribute: false }) + accessor group: GroupData | undefined = undefined; + + @query('.affine-database-block-rows') + accessor rowsContainer: HTMLElement | null = null; + + @property({ attribute: false }) + accessor view!: TableSingleView; + + @property({ attribute: false }) + accessor viewEle!: DataViewTable; +} + +declare global { + interface HTMLElementTagNameMap { + 'mobile-table-group': MobileTableGroup; + } +} diff --git a/blocksuite/affine/data-view/src/view-presets/table/mobile/header.ts b/blocksuite/affine/data-view/src/view-presets/table/mobile/header.ts new file mode 100644 index 0000000000..8e0fc2eaba --- /dev/null +++ b/blocksuite/affine/data-view/src/view-presets/table/mobile/header.ts @@ -0,0 +1,89 @@ +import { unsafeCSSVarV2 } from '@blocksuite/affine-shared/theme'; +import { ShadowlessElement } from '@blocksuite/block-std'; +import { SignalWatcher, WithDisposable } from '@blocksuite/global/utils'; +import { PlusIcon } from '@blocksuite/icons/lit'; +import { css, type TemplateResult } from 'lit'; +import { property, query } from 'lit/decorators.js'; +import { repeat } from 'lit/directives/repeat.js'; +import { styleMap } from 'lit/directives/style-map.js'; +import { html } from 'lit/static-html.js'; + +import type { TableSingleView } from '../table-view-manager.js'; + +export class MobileTableHeader extends SignalWatcher( + WithDisposable(ShadowlessElement) +) { + static override styles = css` + .mobile-table-add-column { + font-size: 18px; + color: ${unsafeCSSVarV2('icon/primary')}; + margin-left: 8px; + display: flex; + align-items: center; + } + `; + + private _onAddColumn = () => { + if (this.readonly) return; + this.tableViewManager.propertyAdd('end'); + this.editLastColumnTitle(); + }; + + editLastColumnTitle = () => { + const columns = this.querySelectorAll('mobile-table-column-header'); + const column = columns.item(columns.length - 1); + column.editTitle(); + }; + + private get readonly() { + return this.tableViewManager.readonly$.value; + } + + override render() { + return html` + ${this.renderGroupHeader?.()} +
+ ${repeat( + this.tableViewManager.properties$.value, + column => column.id, + (column, index) => { + const style = styleMap({ + width: `${column.width$.value}px`, + border: index === 0 ? 'none' : undefined, + }); + return html` + +
+ `; + } + )} +
+ ${PlusIcon()} +
+
+
+ `; + } + + @property({ attribute: false }) + accessor renderGroupHeader: (() => TemplateResult) | undefined; + + @query('.scale-div') + accessor scaleDiv!: HTMLDivElement; + + @property({ attribute: false }) + accessor tableViewManager!: TableSingleView; +} + +declare global { + interface HTMLElementTagNameMap { + 'mobile-table-header': MobileTableHeader; + } +} diff --git a/blocksuite/affine/data-view/src/view-presets/table/mobile/menu.ts b/blocksuite/affine/data-view/src/view-presets/table/mobile/menu.ts new file mode 100644 index 0000000000..4ded6e42f1 --- /dev/null +++ b/blocksuite/affine/data-view/src/view-presets/table/mobile/menu.ts @@ -0,0 +1,46 @@ +import { + menu, + popFilterableSimpleMenu, + type PopupTarget, +} from '@blocksuite/affine-components/context-menu'; +import { DeleteIcon, ExpandFullIcon } from '@blocksuite/icons/lit'; + +import type { DataViewRenderer } from '../../../core/data-view.js'; +import type { SingleView } from '../../../core/index.js'; + +export const popMobileRowMenu = ( + target: PopupTarget, + rowId: string, + dataViewEle: DataViewRenderer, + view: SingleView +) => { + popFilterableSimpleMenu(target, [ + menu.group({ + items: [ + menu.action({ + name: 'Expand Row', + prefix: ExpandFullIcon(), + select: () => { + dataViewEle.openDetailPanel({ + view: view, + rowId: rowId, + }); + }, + }), + ], + }), + menu.group({ + name: '', + items: [ + menu.action({ + name: 'Delete Row', + class: { 'delete-item': true }, + prefix: DeleteIcon(), + select: () => { + view.rowDelete([rowId]); + }, + }), + ], + }), + ]); +}; diff --git a/blocksuite/affine/data-view/src/view-presets/table/mobile/row.ts b/blocksuite/affine/data-view/src/view-presets/table/mobile/row.ts new file mode 100644 index 0000000000..b31527d033 --- /dev/null +++ b/blocksuite/affine/data-view/src/view-presets/table/mobile/row.ts @@ -0,0 +1,148 @@ +import { popupTargetFromElement } from '@blocksuite/affine-components/context-menu'; +import { unsafeCSSVarV2 } from '@blocksuite/affine-shared/theme'; +import { ShadowlessElement } from '@blocksuite/block-std'; +import { SignalWatcher, WithDisposable } from '@blocksuite/global/utils'; +import { CenterPeekIcon, MoreHorizontalIcon } from '@blocksuite/icons/lit'; +import { css, nothing } from 'lit'; +import { property } from 'lit/decorators.js'; +import { repeat } from 'lit/directives/repeat.js'; +import { styleMap } from 'lit/directives/style-map.js'; +import { html } from 'lit/static-html.js'; + +import type { DataViewRenderer } from '../../../core/data-view.js'; +import type { TableSingleView } from '../table-view-manager.js'; +import { popMobileRowMenu } from './menu.js'; + +export class MobileTableRow extends SignalWatcher( + WithDisposable(ShadowlessElement) +) { + static override styles = css` + .mobile-table-row { + width: 100%; + display: flex; + flex-direction: row; + border-bottom: 1px solid var(--affine-border-color); + position: relative; + min-height: 34px; + } + + .mobile-row-ops { + position: relative; + width: 0; + margin-top: 5px; + height: max-content; + display: flex; + gap: 4px; + cursor: pointer; + justify-content: end; + right: 8px; + } + + .affine-database-block-row:has([data-editing='true']) .mobile-row-ops { + visibility: hidden; + opacity: 0; + } + + .mobile-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; + font-size: 16px; + color: ${unsafeCSSVarV2('icon/primary')}; + } + `; + + get groupKey() { + return this.closest('affine-data-view-table-group')?.group?.key; + } + + override connectedCallback() { + super.connectedCallback(); + this.classList.add('mobile-table-row'); + } + + protected override render(): unknown { + const view = this.view; + return html` + ${repeat( + view.properties$.value, + v => v.id, + (column, i) => { + const clickDetail = () => { + this.dataViewEle.openDetailPanel({ + view: this.view, + rowId: this.rowId, + }); + }; + const openMenu = (e: MouseEvent) => { + const ele = e.currentTarget as HTMLElement; + popMobileRowMenu( + popupTargetFromElement(ele), + this.rowId, + this.dataViewEle, + this.view + ); + }; + 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 view!: TableSingleView; +} + +declare global { + interface HTMLElementTagNameMap { + 'mobile-table-row': MobileTableRow; + } +} diff --git a/blocksuite/affine/data-view/src/view-presets/table/mobile/table-view.ts b/blocksuite/affine/data-view/src/view-presets/table/mobile/table-view.ts new file mode 100644 index 0000000000..65f546dbce --- /dev/null +++ b/blocksuite/affine/data-view/src/view-presets/table/mobile/table-view.ts @@ -0,0 +1,209 @@ +import { + menu, + popMenu, + popupTargetFromElement, +} from '@blocksuite/affine-components/context-menu'; +import type { InsertToPosition } from '@blocksuite/affine-shared/utils'; +import { BlockSuiteError, ErrorCode } from '@blocksuite/global/exceptions'; +import { AddCursorIcon } from '@blocksuite/icons/lit'; +import { css } from 'lit'; +import { repeat } from 'lit/directives/repeat.js'; +import { styleMap } from 'lit/directives/style-map.js'; +import { html } from 'lit/static-html.js'; + +import type { GroupTrait } from '../../../core/group-by/trait.js'; +import type { DataViewInstance } from '../../../core/index.js'; +import { renderUniLit } from '../../../core/utils/uni-component/uni-component.js'; +import { DataViewBase } from '../../../core/view/data-view-base.js'; +import { LEFT_TOOL_BAR_WIDTH } from '../consts.js'; +import type { TableSingleView } from '../table-view-manager.js'; +import type { TableViewSelectionWithType } from '../types.js'; + +export class MobileDataViewTable extends DataViewBase< + TableSingleView, + TableViewSelectionWithType +> { + static override styles = css` + .mobile-affine-database-table-wrapper { + position: relative; + width: 100%; + padding-bottom: 4px; + overflow-x: scroll; + overflow-y: hidden; + } + + .mobile-affine-database-table-container { + position: relative; + width: fit-content; + min-width: 100%; + } + + .cell-divider { + width: 1px; + height: 100%; + background-color: var(--affine-border-color); + } + `; + + private _addRow = ( + tableViewManager: TableSingleView, + position: InsertToPosition | number + ) => { + if (this.readonly) return; + tableViewManager.rowAdd(position); + }; + + 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: GroupTrait) => { + 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, + oldData: column.data$.value, + dataSource: this.props.view.manager.dataSource, + }) as never + ); + } + }, + }), + ], + }, + }); + }; + return html`
+
+
${AddCursorIcon()}
+
New Group
+
+
`; + }; + + get expose(): DataViewInstance { + return { + clearSelection: () => {}, + addRow: position => { + this._addRow(this.props.view, position); + }, + focusFirstCell: () => {}, + showIndicator: _evt => { + return false; + }, + 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: () => { + throw new BlockSuiteError( + ErrorCode.DatabaseBlockError, + 'Not implemented' + ); + }, + view: this.props.view, + eventTrace: this.props.eventTrace, + }; + } + + private get readonly() { + return this.props.view.readonly$.value; + } + + private renderTable() { + const groups = this.props.view.groupTrait.groupsDataList$.value; + if (groups) { + return html` +
+ ${repeat( + groups, + v => v.key, + group => { + return html` `; + } + )} + ${this.renderAddGroup(this.props.view.groupTrait)} +
+ `; + } + return html` `; + } + + override render() { + const vPadding = this.props.virtualPadding$.value; + const wrapperStyle = styleMap({ + marginLeft: `-${vPadding}px`, + marginRight: `-${vPadding}px`, + }); + const containerStyle = styleMap({ + paddingLeft: `${vPadding}px`, + paddingRight: `${vPadding}px`, + }); + return html` + ${renderUniLit(this.props.headerWidget, { + dataViewInstance: this.expose, + })} +
+
+ ${this.renderTable()} +
+
+ `; + } +} + +declare global { + interface HTMLElementTagNameMap { + 'mobile-data-view-table': MobileDataViewTable; + } +} diff --git a/blocksuite/affine/data-view/src/view-presets/table/pc/cell.ts b/blocksuite/affine/data-view/src/view-presets/table/pc/cell.ts new file mode 100644 index 0000000000..7f199616e8 --- /dev/null +++ b/blocksuite/affine/data-view/src/view-presets/table/pc/cell.ts @@ -0,0 +1,174 @@ +import { ShadowlessElement } from '@blocksuite/block-std'; +import { + assertExists, + SignalWatcher, + WithDisposable, +} from '@blocksuite/global/utils'; +import { computed } from '@preact/signals-core'; +import { css } from 'lit'; +import { property, state } from 'lit/decorators.js'; +import { createRef } from 'lit/directives/ref.js'; + +import { renderUniLit } from '../../../core/index.js'; +import type { + CellRenderProps, + DataViewCellLifeCycle, +} from '../../../core/property/index.js'; +import type { SingleView } from '../../../core/view-manager/single-view.js'; +import type { TableColumn } from '../table-view-manager.js'; +import { + TableAreaSelection, + type TableViewSelectionWithType, +} from '../types.js'; + +export class DatabaseCellContainer extends SignalWatcher( + WithDisposable(ShadowlessElement) +) { + static override styles = css` + affine-database-cell-container { + display: flex; + align-items: start; + width: 100%; + height: 100%; + border: none; + outline: none; + } + + affine-database-cell-container * { + box-sizing: border-box; + } + + affine-database-cell-container uni-lit > *:first-child { + padding: 6px; + } + `; + + private _cell = createRef(); + + @property({ attribute: false }) + accessor column!: TableColumn; + + @property({ attribute: false }) + accessor rowId!: string; + + cell$ = computed(() => { + return this.column.cellGet(this.rowId); + }); + + selectCurrentCell = (editing: boolean) => { + if (this.view.readonly$.value) { + return; + } + const selectionView = this.selectionView; + if (selectionView) { + const selection = selectionView.selection; + if (selection && this.isSelected(selection) && editing) { + selectionView.selection = TableAreaSelection.create({ + groupKey: this.groupKey, + focus: { + rowIndex: this.rowIndex, + columnIndex: this.columnIndex, + }, + isEditing: true, + }); + } else { + selectionView.selection = TableAreaSelection.create({ + groupKey: this.groupKey, + focus: { + rowIndex: this.rowIndex, + columnIndex: this.columnIndex, + }, + isEditing: false, + }); + } + } + }; + + get cell(): DataViewCellLifeCycle | undefined { + return this._cell.value; + } + + private get groupKey() { + return this.closest('affine-data-view-table-group')?.group?.key; + } + + private get readonly() { + return this.column.readonly$.value; + } + + private get selectionView() { + return this.closest('affine-database-table')?.selectionController; + } + + get table() { + const table = this.closest('affine-database-table'); + assertExists(table); + return table; + } + + override connectedCallback() { + super.connectedCallback(); + this._disposables.addFromEvent(this, 'click', () => { + if (!this.isEditing) { + this.selectCurrentCell(!this.column.readonly$.value); + } + }); + } + + isSelected(selection: TableViewSelectionWithType) { + if (selection.selectionType !== 'area') { + return false; + } + if (selection.groupKey !== this.groupKey) { + return; + } + if (selection.focus.columnIndex !== this.columnIndex) { + return; + } + return selection.focus.rowIndex === this.rowIndex; + } + + override render() { + const renderer = this.column.renderer$.value; + if (!renderer) { + return; + } + const { edit, view } = renderer; + const uni = !this.readonly && this.isEditing && edit != null ? edit : view; + this.view.lockRows(this.isEditing); + this.dataset['editing'] = `${this.isEditing}`; + const props: CellRenderProps = { + cell: this.cell$.value, + isEditing: this.isEditing, + selectCurrentCell: this.selectCurrentCell, + }; + + return renderUniLit(uni, props, { + ref: this._cell, + style: { + display: 'contents', + }, + }); + } + + @property({ attribute: false }) + accessor columnId!: string; + + @property({ attribute: false }) + accessor columnIndex!: number; + + @state() + accessor isEditing = false; + + @property({ attribute: false }) + accessor rowIndex!: number; + + @property({ attribute: false }) + accessor view!: SingleView; +} + +declare global { + interface HTMLElementTagNameMap { + 'affine-database-cell-container': DatabaseCellContainer; + } +} diff --git a/blocksuite/affine/data-view/src/view-presets/table/pc/controller/clipboard.ts b/blocksuite/affine/data-view/src/view-presets/table/pc/controller/clipboard.ts new file mode 100644 index 0000000000..688a430e13 --- /dev/null +++ b/blocksuite/affine/data-view/src/view-presets/table/pc/controller/clipboard.ts @@ -0,0 +1,285 @@ +import type { UIEventStateContext } from '@blocksuite/block-std'; +import type { ReactiveController } from 'lit'; + +import type { Cell } from '../../../../core/view-manager/cell.js'; +import type { Row } from '../../../../core/view-manager/row.js'; +import { + TableAreaSelection, + TableRowSelection, + type TableViewSelection, + type TableViewSelectionWithType, +} from '../../types.js'; +import type { DataViewTable } from '../table-view.js'; + +const BLOCKSUITE_DATABASE_TABLE = 'blocksuite/database/table'; +type JsonAreaData = string[][]; +const TEXT = 'text/plain'; + +export class TableClipboardController implements ReactiveController { + private _onCopy = ( + tableSelection: TableViewSelectionWithType, + isCut = false + ) => { + const table = this.host; + + const area = getSelectedArea(tableSelection, table); + if (!area) { + return; + } + const stringResult = area + .map(row => row.cells.map(cell => cell.stringValue$.value).join('\t')) + .join('\n'); + const jsonResult: JsonAreaData = area.map(row => + row.cells.map(cell => cell.stringValue$.value) + ); + if (isCut) { + const deleteRows: string[] = []; + for (const row of area) { + if (row.row) { + deleteRows.push(row.row.rowId); + } else { + for (const cell of row.cells) { + cell.valueSet(undefined); + } + } + } + if (deleteRows.length) { + this.props.view.rowDelete(deleteRows); + } + } + this.clipboard + .writeToClipboard(items => { + return { + ...items, + [TEXT]: stringResult, + [BLOCKSUITE_DATABASE_TABLE]: JSON.stringify(jsonResult), + }; + }) + .then(() => { + if (area[0]?.row) { + this.notification.toast( + `${area.length} row${area.length > 1 ? 's' : ''} copied to clipboard` + ); + } else { + const count = area.flatMap(row => row.cells).length; + this.notification.toast( + `${count} cell${count > 1 ? 's' : ''} copied to clipboard` + ); + } + }) + .catch(console.error); + + return true; + }; + + private _onCut = (tableSelection: TableViewSelectionWithType) => { + this._onCopy(tableSelection, true); + }; + + private _onPaste = async (_context: UIEventStateContext) => { + const event = _context.get('clipboardState').raw; + event.stopPropagation(); + const view = this.host; + + const clipboardData = event.clipboardData; + if (!clipboardData) return; + + const tableSelection = this.host.selectionController.selection; + if (TableRowSelection.is(tableSelection)) { + return; + } + if (tableSelection) { + const json = await this.clipboard.readFromClipboard(clipboardData); + const dataString = json[BLOCKSUITE_DATABASE_TABLE]; + if (!dataString) return; + const jsonAreaData = JSON.parse(dataString) as JsonAreaData; + pasteToCells(view, jsonAreaData, tableSelection); + } + + return true; + }; + + private get clipboard() { + return this.props.clipboard; + } + + private get notification() { + return this.props.notification; + } + + get props() { + return this.host.props; + } + + private get readonly() { + return this.props.view.readonly$.value; + } + + constructor(public host: DataViewTable) { + host.addController(this); + } + + copy() { + const tableSelection = this.host.selectionController.selection; + if (!tableSelection) { + return; + } + this._onCopy(tableSelection); + } + + cut() { + const tableSelection = this.host.selectionController.selection; + if (!tableSelection) { + return; + } + this._onCopy(tableSelection, true); + } + + hostConnected() { + this.host.disposables.add( + this.props.handleEvent('copy', _ctx => { + const tableSelection = this.host.selectionController.selection; + if (!tableSelection) return false; + + this._onCopy(tableSelection); + return true; + }) + ); + + this.host.disposables.add( + this.props.handleEvent('cut', _ctx => { + const tableSelection = this.host.selectionController.selection; + if (!tableSelection) return false; + + this._onCut(tableSelection); + return true; + }) + ); + + this.host.disposables.add( + this.props.handleEvent('paste', ctx => { + if (this.readonly) return false; + + this._onPaste(ctx).catch(console.error); + return true; + }) + ); + } +} + +function getSelectedArea( + selection: TableViewSelection, + table: DataViewTable +): SelectedArea | undefined { + const view = table.props.view; + if (TableRowSelection.is(selection)) { + const rows = TableRowSelection.rows(selection) + .map(row => { + const y = + table.selectionController + .getRow(row.groupKey, row.id) + ?.getBoundingClientRect().y ?? 0; + return { + y, + row, + }; + }) + .sort((a, b) => a.y - b.y) + .map(v => v.row); + return rows.map(r => { + const row = view.rowGet(r.id); + return { + row, + cells: row.cells$.value, + }; + }); + } + const { rowsSelection, columnsSelection, groupKey } = selection; + const data: SelectedArea = []; + const rows = groupKey + ? view.groupTrait.groupDataMap$.value?.[groupKey].rows + : view.rows$.value; + const columns = view.propertyIds$.value; + if (!rows) { + return; + } + for (let i = rowsSelection.start; i <= rowsSelection.end; i++) { + const row: SelectedArea[number] = { + cells: [], + }; + const rowId = rows[i]; + for (let j = columnsSelection.start; j <= columnsSelection.end; j++) { + const columnId = columns[j]; + const cell = view.cellGet(rowId, columnId); + row.cells.push(cell); + } + data.push(row); + } + + return data; +} + +type SelectedArea = { + row?: Row; + cells: Cell[]; +}[]; + +function getTargetRangeFromSelection( + selection: TableAreaSelection, + data: JsonAreaData +) { + const { rowsSelection, columnsSelection, focus } = selection; + return TableAreaSelection.isFocus(selection) + ? { + row: { + start: focus.rowIndex, + length: data.length, + }, + column: { + start: focus.columnIndex, + length: data[0].length, + }, + } + : { + row: { + start: rowsSelection.start, + length: rowsSelection.end - rowsSelection.start + 1, + }, + column: { + start: columnsSelection.start, + length: columnsSelection.end - columnsSelection.start + 1, + }, + }; +} + +function pasteToCells( + table: DataViewTable, + rows: JsonAreaData, + selection: TableAreaSelection +) { + const srcRowLength = rows.length; + const srcColumnLength = rows[0].length; + const targetRange = getTargetRangeFromSelection(selection, rows); + for (let i = 0; i < targetRange.row.length; i++) { + for (let j = 0; j < targetRange.column.length; j++) { + const rowIndex = targetRange.row.start + i; + const columnIndex = targetRange.column.start + j; + + const srcRowIndex = i % srcRowLength; + const srcColumnIndex = j % srcColumnLength; + const dataString = rows[srcRowIndex][srcColumnIndex]; + + const targetContainer = table.selectionController.getCellContainer( + selection.groupKey, + rowIndex, + columnIndex + ); + const rowId = targetContainer?.dataset.rowId; + const columnId = targetContainer?.dataset.columnId; + + if (rowId && columnId) { + targetContainer?.column.valueSetFromString(rowId, dataString); + } + } + } +} diff --git a/blocksuite/affine/data-view/src/view-presets/table/pc/controller/drag-to-fill.ts b/blocksuite/affine/data-view/src/view-presets/table/pc/controller/drag-to-fill.ts new file mode 100644 index 0000000000..fee979f3de --- /dev/null +++ b/blocksuite/affine/data-view/src/view-presets/table/pc/controller/drag-to-fill.ts @@ -0,0 +1,110 @@ +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 { t } from '../../../../core/index.js'; +import type { TableAreaSelection } from '../../types.js'; +import type { DataViewTable } from '../table-view.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 (t.richText.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/blocksuite/affine/data-view/src/view-presets/table/pc/controller/drag.ts b/blocksuite/affine/data-view/src/view-presets/table/pc/controller/drag.ts new file mode 100644 index 0000000000..3d6d2b09fa --- /dev/null +++ b/blocksuite/affine/data-view/src/view-presets/table/pc/controller/drag.ts @@ -0,0 +1,212 @@ +// related component + +import type { InsertToPosition } from '@blocksuite/affine-shared/utils'; +import type { ReactiveController } from 'lit'; + +import { startDrag } from '../../../../core/utils/drag.js'; +import { TableRow } from '../row/row.js'; +import type { DataViewTable } from '../table-view.js'; + +export class TableDragController implements ReactiveController { + dragStart = (row: TableRow, evt: PointerEvent) => { + const eleRect = row.getBoundingClientRect(); + const offsetLeft = evt.x - eleRect.left; + const offsetTop = evt.y - eleRect.top; + const preview = createDragPreview( + row, + evt.x - offsetLeft, + evt.y - offsetTop + ); + const fromGroup = row.groupKey; + + startDrag< + | undefined + | { + type: 'self'; + groupKey?: string; + position: InsertToPosition; + } + | { type: 'out'; callback: () => void }, + PointerEvent + >(evt, { + onDrag: () => undefined, + onMove: evt => { + preview.display(evt.x - offsetLeft, evt.y - offsetTop); + if (!this.host.contains(evt.target as Node)) { + const callback = this.host.props.onDrag; + if (callback) { + this.dropPreview.remove(); + return { + type: 'out', + callback: callback(evt, row.rowId), + }; + } + return; + } + const result = this.showIndicator(evt); + if (result) { + return { + type: 'self', + groupKey: result.groupKey, + position: result.position, + }; + } + return; + }, + onClear: () => { + preview.remove(); + this.dropPreview.remove(); + }, + onDrop: result => { + if (!result) { + return; + } + if (result.type === 'out') { + result.callback(); + return; + } + if (result.type === 'self') { + this.host.props.view.rowMove( + row.rowId, + result.position, + fromGroup, + result.groupKey + ); + } + }, + }); + }; + + dropPreview = createDropPreview(); + + getInsertPosition = ( + evt: MouseEvent + ): + | { + groupKey: string | undefined; + position: InsertToPosition; + y: number; + width: number; + x: number; + } + | undefined => { + const y = evt.y; + const tableRect = this.host + .querySelector('affine-data-view-table-group') + ?.getBoundingClientRect(); + const rows = this.host.querySelectorAll('data-view-table-row'); + if (!rows || !tableRect || y < tableRect.top) { + return; + } + for (let i = 0; i < rows.length; i++) { + const row = rows.item(i); + const rect = row.getBoundingClientRect(); + const mid = (rect.top + rect.bottom) / 2; + if (y < rect.bottom) { + return { + groupKey: row.groupKey, + position: { + id: row.dataset.rowId as string, + before: y < mid, + }, + y: y < mid ? rect.top : rect.bottom, + width: tableRect.width, + x: tableRect.left, + }; + } + } + return; + }; + + showIndicator = (evt: MouseEvent) => { + const position = this.getInsertPosition(evt); + if (position) { + this.dropPreview.display(position.x, position.y, position.width); + } else { + this.dropPreview.remove(); + } + return position; + }; + + constructor(private host: DataViewTable) { + this.host.addController(this); + } + + hostConnected() { + if (this.host.props.view.readonly$.value) { + return; + } + this.host.disposables.add( + this.host.props.handleEvent('dragStart', context => { + const event = context.get('pointerState').raw; + const target = event.target; + if ( + target instanceof Element && + this.host.contains(target) && + target.closest('.data-view-table-view-drag-handler') + ) { + event.preventDefault(); + const row = target.closest('data-view-table-row'); + if (row) { + getSelection()?.removeAllRanges(); + this.dragStart(row, event); + } + return true; + } + return false; + }) + ); + } +} + +const createDragPreview = (row: TableRow, x: number, y: number) => { + const div = document.createElement('div'); + const cloneRow = new TableRow(); + cloneRow.view = row.view; + cloneRow.rowIndex = row.rowIndex; + cloneRow.rowId = row.rowId; + cloneRow.dataViewEle = row.dataViewEle; + div.append(cloneRow); + div.className = 'with-data-view-css-variable'; + div.style.width = `${row.getBoundingClientRect().width}px`; + div.style.position = 'fixed'; + div.style.pointerEvents = 'none'; + div.style.opacity = '0.5'; + 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/blocksuite/affine/data-view/src/view-presets/table/pc/controller/hotkeys.ts b/blocksuite/affine/data-view/src/view-presets/table/pc/controller/hotkeys.ts new file mode 100644 index 0000000000..a0bb910a15 --- /dev/null +++ b/blocksuite/affine/data-view/src/view-presets/table/pc/controller/hotkeys.ts @@ -0,0 +1,383 @@ +import { popupTargetFromElement } from '@blocksuite/affine-components/context-menu'; +import type { ReactiveController } from 'lit'; + +import { TableAreaSelection, TableRowSelection } from '../../types.js'; +import { popRowMenu } from '../menu.js'; +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({ + Backspace: () => { + const selection = this.selectionController.selection; + if (!selection) { + return; + } + if (TableRowSelection.is(selection)) { + const rows = TableRowSelection.rowsIds(selection); + this.selectionController.selection = undefined; + this.host.props.view.rowDelete(rows); + return; + } + const { + focus, + rowsSelection, + columnsSelection, + isEditing, + groupKey, + } = selection; + if (focus && !isEditing) { + if (rowsSelection && columnsSelection) { + // multi cell + for (let i = rowsSelection.start; i <= rowsSelection.end; i++) { + const { start, end } = columnsSelection; + for (let j = start; j <= end; j++) { + const container = this.selectionController.getCellContainer( + groupKey, + i, + j + ); + const rowId = container?.dataset.rowId; + const columnId = container?.dataset.columnId; + if (rowId && columnId) { + container?.column.valueSetFromString(rowId, ''); + } + } + } + } else { + // single cell + const container = this.selectionController.getCellContainer( + groupKey, + focus.rowIndex, + focus.columnIndex + ); + const rowId = container?.dataset.rowId; + const columnId = container?.dataset.columnId; + if (rowId && columnId) { + container?.column.valueSetFromString(rowId, ''); + } + } + } + }, + Escape: () => { + const selection = this.selectionController.selection; + if (!selection) { + return false; + } + if (TableRowSelection.is(selection)) { + const result = this.selectionController.rowsToArea( + selection.rows.map(v => v.id) + ); + if (result) { + this.selectionController.selection = TableAreaSelection.create({ + groupKey: result.groupKey, + focus: { + rowIndex: result.start, + columnIndex: 0, + }, + rowsSelection: { + start: result.start, + end: result.end, + }, + isEditing: false, + }); + } else { + this.selectionController.selection = undefined; + } + } else if (selection.isEditing) { + this.selectionController.selection = { + ...selection, + isEditing: false, + }; + } else { + const rows = this.selectionController.areaToRows(selection); + this.selectionController.rowSelectionChange({ + add: rows, + remove: [], + }); + } + return true; + }, + Enter: context => { + const selection = this.selectionController.selection; + if (!selection) { + return false; + } + if (TableRowSelection.is(selection)) { + const result = this.selectionController.rowsToArea( + selection.rows.map(v => v.id) + ); + if (result) { + this.selectionController.selection = TableAreaSelection.create({ + groupKey: result.groupKey, + focus: { + rowIndex: result.start, + columnIndex: 0, + }, + rowsSelection: { + start: result.start, + end: result.end, + }, + isEditing: false, + }); + } + } else if (selection.isEditing) { + return false; + } else { + this.selectionController.selection = { + ...selection, + isEditing: true, + }; + } + context.get('keyboardState').raw.preventDefault(); + return true; + }, + 'Shift-Enter': () => { + const selection = this.selectionController.selection; + if ( + !selection || + TableRowSelection.is(selection) || + selection.isEditing + ) { + return false; + } + const cell = this.selectionController.getCellContainer( + selection.groupKey, + selection.focus.rowIndex, + selection.focus.columnIndex + ); + if (cell) { + this.selectionController.insertRowAfter( + selection.groupKey, + cell.rowId + ); + } + return true; + }, + Tab: ctx => { + const selection = this.selectionController.selection; + if ( + !selection || + TableRowSelection.is(selection) || + selection.isEditing + ) { + return false; + } + ctx.get('keyboardState').raw.preventDefault(); + this.selectionController.focusToCell('right'); + return true; + }, + 'Shift-Tab': ctx => { + const selection = this.selectionController.selection; + if ( + !selection || + TableRowSelection.is(selection) || + selection.isEditing + ) { + return false; + } + ctx.get('keyboardState').raw.preventDefault(); + this.selectionController.focusToCell('left'); + return true; + }, + ArrowLeft: context => { + const selection = this.selectionController.selection; + if ( + !selection || + TableRowSelection.is(selection) || + selection.isEditing + ) { + return false; + } + this.selectionController.focusToCell('left'); + context.get('keyboardState').raw.preventDefault(); + return true; + }, + ArrowRight: context => { + const selection = this.selectionController.selection; + if ( + !selection || + TableRowSelection.is(selection) || + selection.isEditing + ) { + return false; + } + this.selectionController.focusToCell('right'); + context.get('keyboardState').raw.preventDefault(); + return true; + }, + ArrowUp: context => { + const selection = this.selectionController.selection; + if (!selection) { + return false; + } + + if (TableRowSelection.is(selection)) { + this.selectionController.navigateRowSelection('up', false); + } else if (selection.isEditing) { + return false; + } else { + this.selectionController.focusToCell('up'); + } + + context.get('keyboardState').raw.preventDefault(); + return true; + }, + ArrowDown: context => { + const selection = this.selectionController.selection; + if (!selection) { + return false; + } + + if (TableRowSelection.is(selection)) { + this.selectionController.navigateRowSelection('down', false); + } else if (selection.isEditing) { + return false; + } else { + this.selectionController.focusToCell('down'); + } + + context.get('keyboardState').raw.preventDefault(); + return true; + }, + + 'Shift-ArrowUp': context => { + const selection = this.selectionController.selection; + if (!selection) { + return false; + } + + if (TableRowSelection.is(selection)) { + this.selectionController.navigateRowSelection('up', true); + } else if (selection.isEditing) { + return false; + } else { + this.selectionController.selectionAreaUp(); + } + + context.get('keyboardState').raw.preventDefault(); + return true; + }, + + 'Shift-ArrowDown': context => { + const selection = this.selectionController.selection; + if (!selection) { + return false; + } + + if (TableRowSelection.is(selection)) { + this.selectionController.navigateRowSelection('down', true); + } else if (selection.isEditing) { + return false; + } else { + this.selectionController.selectionAreaDown(); + } + + context.get('keyboardState').raw.preventDefault(); + return true; + }, + + 'Shift-ArrowLeft': context => { + const selection = this.selectionController.selection; + if ( + !selection || + TableRowSelection.is(selection) || + selection.isEditing || + this.selectionController.isRowSelection() + ) { + return false; + } + + this.selectionController.selectionAreaLeft(); + + context.get('keyboardState').raw.preventDefault(); + return true; + }, + + 'Shift-ArrowRight': context => { + const selection = this.selectionController.selection; + if ( + !selection || + TableRowSelection.is(selection) || + selection.isEditing || + this.selectionController.isRowSelection() + ) { + return false; + } + + this.selectionController.selectionAreaRight(); + + context.get('keyboardState').raw.preventDefault(); + return true; + }, + + 'Mod-a': context => { + const selection = this.selectionController.selection; + if (TableRowSelection.is(selection)) { + return false; + } + if (selection?.isEditing) { + return true; + } + if (selection) { + context.get('keyboardState').raw.preventDefault(); + this.selectionController.selection = TableRowSelection.create({ + rows: + this.host.props.view.groupTrait.groupsDataList$.value?.flatMap( + group => group.rows.map(id => ({ groupKey: group.key, id })) + ) ?? + this.host.props.view.rows$.value.map(id => ({ + groupKey: undefined, + id, + })), + }); + return true; + } + return; + }, + '/': context => { + const selection = this.selectionController.selection; + if (!selection) { + return; + } + if (TableRowSelection.is(selection)) { + // open multi-rows context-menu + return; + } + if (selection.isEditing) { + return; + } + const cell = this.selectionController.getCellContainer( + selection.groupKey, + selection.focus.rowIndex, + selection.focus.columnIndex + ); + if (cell) { + context.get('keyboardState').raw.preventDefault(); + const row = { + id: cell.rowId, + groupKey: selection.groupKey, + }; + this.selectionController.selection = TableRowSelection.create({ + rows: [row], + }); + popRowMenu( + this.host.props.dataViewEle, + popupTargetFromElement(cell), + this.selectionController + ); + } + }, + }) + ); + } +} diff --git a/blocksuite/affine/data-view/src/view-presets/table/pc/controller/selection.ts b/blocksuite/affine/data-view/src/view-presets/table/pc/controller/selection.ts new file mode 100644 index 0000000000..61733ed79f --- /dev/null +++ b/blocksuite/affine/data-view/src/view-presets/table/pc/controller/selection.ts @@ -0,0 +1,1158 @@ +import { ShadowlessElement } from '@blocksuite/block-std'; +import { WithDisposable } from '@blocksuite/global/utils'; +import { computed, effect } from '@preact/signals-core'; +import type { ReactiveController } from 'lit'; +import { css, html } from 'lit'; +import { property } from 'lit/decorators.js'; +import type { Ref } from 'lit/directives/ref.js'; +import { createRef, ref } from 'lit/directives/ref.js'; + +import { autoScrollOnBoundary } from '../../../../core/utils/auto-scroll.js'; +import { startDrag } from '../../../../core/utils/drag.js'; +import { + type CellFocus, + type MultiSelection, + RowWithGroup, + TableAreaSelection, + TableRowSelection, + type TableViewSelection, + type TableViewSelectionWithType, +} from '../../types.js'; +import type { DatabaseCellContainer } from '../cell.js'; +import type { TableRow } from '../row/row.js'; +import type { DataViewTable } from '../table-view.js'; +import { + DragToFillElement, + fillSelectionWithFocusCellData, +} from './drag-to-fill.js'; + +export class TableSelectionController implements ReactiveController { + private _tableViewSelection?: TableViewSelectionWithType; + + private getFocusCellContainer = () => { + if ( + !this._tableViewSelection || + this._tableViewSelection.selectionType !== 'area' + ) + return null; + const { groupKey, focus } = this._tableViewSelection; + + const dragStartCell = this.getCellContainer( + groupKey, + focus.rowIndex, + focus.columnIndex + ); + return dragStartCell ?? null; + }; + + __dragToFillElement = new DragToFillElement(); + + __selectionElement; + + selectionStyleUpdateTask = 0; + + private get areaSelectionElement() { + return this.__selectionElement.selectionRef.value; + } + + get dragToFillDraggable() { + return this.__dragToFillElement.dragToFillRef.value; + } + + private get focusSelectionElement() { + return this.__selectionElement.focusRef.value; + } + + get selection(): TableViewSelectionWithType | undefined { + return this._tableViewSelection; + } + + set selection(data: TableViewSelection | undefined) { + if (!data) { + this.clearSelection(); + return; + } + const selection: TableViewSelectionWithType = { + ...data, + viewId: this.view.id, + type: 'table', + }; + if (selection.selectionType === 'area' && selection.isEditing) { + const focus = selection.focus; + const container = this.getCellContainer( + selection.groupKey, + focus.rowIndex, + focus.columnIndex + ); + const cell = container?.cell; + const isEditing = cell ? cell.beforeEnterEditMode() : true; + this.host.props.setSelection({ + ...selection, + isEditing, + }); + } else { + this.host.props.setSelection(selection); + } + } + + get tableContainer() { + return this.host.querySelector('.affine-database-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.groupTrait.moveCardTo(id, undefined, groupKey, { + before, + id: rowId, + }); + } + const rows = + groupKey != null + ? this.view.groupTrait.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: DatabaseCellContainer | null, fillValues: boolean] { + let cell: DatabaseCellContainer | null; + const fillValues = !!target.dataset.dragToFill; + if (fillValues) { + const focusCellContainer = this.getFocusCellContainer(); + cell = focusCellContainer ?? null; + } else { + cell = target.closest('affine-database-cell-container'); + } + return [cell, fillValues]; + } + + private scrollToAreaSelection() { + this.areaSelectionElement?.scrollIntoView({ + block: 'nearest', + inline: 'nearest', + }); + } + + private scrollToFocus() { + this.focusSelectionElement?.scrollIntoView({ + block: 'nearest', + inline: 'nearest', + }); + } + + areaToRows(selection: TableAreaSelection) { + const rows = this.rows(selection.groupKey) ?? []; + const ids = Array.from({ + length: selection.rowsSelection.end - selection.rowsSelection.start + 1, + }) + .map((_, index) => index + selection.rowsSelection.start) + .map(row => rows[row]?.rowId); + return ids.map(id => ({ id, groupKey: selection.groupKey })); + } + + cellPosition(groupKey: string | undefined) { + const rows = this.rows(groupKey); + const cells = rows + ?.item(0) + .querySelectorAll('affine-database-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, + }; + }; + } + + clear() { + this.selection = undefined; + } + + deleteRow(rowId: string) { + this.view.rowDelete([rowId]); + this.focusToCell('up'); + } + + focusFirstCell() { + this.selection = TableAreaSelection.create({ + focus: { + rowIndex: 0, + columnIndex: 0, + }, + isEditing: false, + }); + } + + focusToArea(selection: TableAreaSelection) { + return { + ...selection, + rowsSelection: selection.rowsSelection ?? { + start: selection.focus.rowIndex, + end: selection.focus.rowIndex, + }, + columnsSelection: selection.columnsSelection ?? { + start: selection.focus.columnIndex, + end: selection.focus.columnIndex, + }, + isEditing: false, + } satisfies TableAreaSelection; + } + + focusToCell(position: 'left' | 'right' | 'up' | 'down') { + if (!this.selection || this.selection.selectionType !== 'area') { + return; + } + const cell = this.getCellContainer( + this.selection.groupKey, + this.selection.focus.rowIndex, + this.selection.focus.columnIndex + ); + if (!cell) { + return; + } + const row = cell.closest('data-view-table-row'); + const rows = Array.from( + row + ?.closest('.affine-database-table-container') + ?.querySelectorAll('data-view-table-row') ?? [] + ); + const cells = Array.from( + row?.querySelectorAll('affine-database-cell-container') ?? [] + ); + if (!row || !rows || !cells) { + return; + } + let rowIndex = rows.indexOf(row); + let columnIndex = cells.indexOf(cell); + if (position === 'left') { + if (columnIndex === 0) { + columnIndex = cells.length - 1; + rowIndex--; + } else { + columnIndex--; + } + } + if (position === 'right') { + if (columnIndex === cells.length - 1) { + columnIndex = 0; + rowIndex++; + } else { + columnIndex++; + } + } + if (position === 'up') { + if (rowIndex === 0) { + // + } else { + rowIndex--; + } + } + if (position === 'down') { + if (rowIndex === rows.length - 1) { + // + } else { + rowIndex++; + } + } + rows[rowIndex] + ?.querySelectorAll('affine-database-cell-container') + ?.item(columnIndex) + ?.selectCurrentCell(false); + } + + getCellContainer( + groupKey: string | undefined, + rowIndex: number, + columnIndex: number + ): DatabaseCellContainer | undefined { + const row = this.rows(groupKey)?.item(rowIndex); + return row + ?.querySelectorAll('affine-database-cell-container') + .item(columnIndex); + } + + getGroup(groupKey: string | undefined) { + const container = + groupKey != null + ? this.tableContainer?.querySelector( + `affine-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-database-cell-container'); + const leftCell = topCells.item(left); + const rightCell = topCells.item(right); + if (!leftCell || !rightCell) { + return; + } + const leftRect = leftCell.getBoundingClientRect(); + const scale = leftRect.width / leftCell.column.width$.value; + return { + top: leftRect.top / scale, + left: leftRect.left / scale, + width: (rightCell.getBoundingClientRect().right - leftRect.left) / scale, + height: (bottomRow.getBoundingClientRect().bottom - leftRect.top) / scale, + scale, + }; + } + + getRow(groupKey: string | undefined, rowId: string) { + return this.getGroup(groupKey)?.querySelector( + `data-view-table-row[data-row-id='${rowId}']` + ); + } + + getSelectionAreaBorder(position: 'left' | 'right' | 'top' | 'bottom') { + return this.__selectionElement.selectionRef.value?.querySelector( + `.area-border.area-${position}` + ); + } + + hostConnected() { + requestAnimationFrame(() => { + this.tableContainer?.append(this.__selectionElement); + this.tableContainer?.append(this.__dragToFillElement); + }); + this.handleDragEvent(); + this.handleSelectionChange(); + } + + insertRowAfter(groupKey: string | undefined, rowId: string) { + this.insertTo(groupKey, rowId, false); + } + + insertRowBefore(groupKey: string | undefined, rowId: string) { + this.insertTo(groupKey, rowId, true); + } + + isRowSelection() { + return this.selection?.selectionType === 'row'; + } + + isValidSelection(selection?: TableViewSelectionWithType): boolean { + if (!selection || selection.selectionType === 'row') { + return true; + } + if (selection.focus.rowIndex > this.view.rows$.value.length - 1) { + this.selection = undefined; + return false; + } + if (selection.focus.columnIndex > this.view.propertyIds$.value.length - 1) { + this.selection = undefined; + return false; + } + return true; + } + + navigateRowSelection(direction: 'up' | 'down', append = false) { + if (!TableRowSelection.is(this.selection)) return; + const rows = this.selection.rows; + const lastRow = rows[rows.length - 1]; + const lastRowIndex = + ( + this.getGroup(lastRow.groupKey)?.querySelector( + `data-view-table-row[data-row-id='${lastRow.id}']` + ) as TableRow | null + )?.rowIndex ?? 0; + const getRowByIndex = (index: number) => { + const tableRow = this.rows(lastRow.groupKey)?.item(index); + if (!tableRow) { + return; + } + return { + id: tableRow.rowId, + groupKey: lastRow.groupKey, + }; + }; + const prevRow = getRowByIndex(lastRowIndex - 1); + const nextRow = getRowByIndex(lastRowIndex + 1); + const includes = (row: RowWithGroup) => { + if (!row) { + return false; + } + return rows.some(r => RowWithGroup.equal(r, row)); + }; + if (append) { + const addList: RowWithGroup[] = []; + const removeList: RowWithGroup[] = []; + if (direction === 'up' && prevRow != null) { + if (includes(prevRow)) { + removeList.push(lastRow); + } else { + addList.push(prevRow); + } + } + if (direction === 'down' && nextRow != null) { + if (includes(nextRow)) { + removeList.push(lastRow); + } else { + addList.push(nextRow); + } + } + this.rowSelectionChange({ add: addList, remove: removeList }); + } else { + const target = direction === 'up' ? prevRow : nextRow; + if (target != null) { + this.selection = TableRowSelection.create({ + rows: [target], + }); + } + } + } + + rows(groupKey: string | undefined) { + const container = + groupKey != null + ? this.tableContainer?.querySelector( + `affine-data-view-table-group[data-group-key="${groupKey}"]` + ) + : this.tableContainer; + return container?.querySelectorAll('data-view-table-row'); + } + + rowSelectionChange({ + add, + remove, + }: { + add: RowWithGroup[]; + remove: RowWithGroup[]; + }) { + const key = (r: RowWithGroup) => `${r.id}.${r.groupKey ? r.groupKey : ''}`; + const rows = new Set( + TableRowSelection.rows(this.selection).map(r => key(r)) + ); + remove.forEach(row => rows.delete(key(row))); + add.forEach(row => rows.add(key(row))); + const result = [...rows] + .map(r => r.split('.')) + .map(([id, groupKey]) => ({ + id, + groupKey: groupKey ? groupKey : undefined, + })); + this.selection = TableRowSelection.create({ + rows: result, + }); + } + + rowsToArea(rows: string[]): + | { + start: number; + end: number; + groupKey?: string; + } + | undefined { + let groupKey: string | undefined = undefined; + let minIndex: number | undefined = undefined; + let maxIndex: number | undefined = undefined; + const set = new Set(rows); + if (!this.tableContainer) return; + for (const row of this.tableContainer + ?.querySelectorAll('data-view-table-row') + .values() ?? []) { + if (!set.has(row.rowId)) { + continue; + } + minIndex = + minIndex != null ? Math.min(minIndex, row.rowIndex) : row.rowIndex; + maxIndex = + maxIndex != null ? Math.max(maxIndex, row.rowIndex) : row.rowIndex; + if (groupKey == null) { + groupKey = row.groupKey; + } else if (groupKey !== row.groupKey) { + return; + } + } + if (minIndex == null || maxIndex == null) { + return; + } + return { + groupKey, + start: minIndex, + end: maxIndex, + }; + } + + selectionAreaDown() { + const selection = this.selection; + if (!selection || selection.selectionType !== 'area') { + return; + } + const newSelection = this.focusToArea(selection); + if (newSelection.rowsSelection.start === newSelection.focus.rowIndex) { + newSelection.rowsSelection.end = Math.min( + (this.rows(newSelection.groupKey)?.length ?? 0) - 1, + newSelection.rowsSelection.end + 1 + ); + requestAnimationFrame(() => { + this.getSelectionAreaBorder('bottom')?.scrollIntoView({ + block: 'nearest', + inline: 'nearest', + behavior: 'smooth', + }); + }); + } else { + newSelection.rowsSelection.start += 1; + requestAnimationFrame(() => { + this.getSelectionAreaBorder('top')?.scrollIntoView({ + block: 'nearest', + inline: 'nearest', + behavior: 'smooth', + }); + }); + } + this.selection = newSelection; + } + + selectionAreaLeft() { + const selection = this.selection; + if (!selection || selection.selectionType !== 'area') { + return; + } + const newSelection = this.focusToArea(selection); + if (newSelection.columnsSelection.end === newSelection.focus.columnIndex) { + newSelection.columnsSelection.start = Math.max( + 0, + newSelection.columnsSelection.start - 1 + ); + requestAnimationFrame(() => { + this.getSelectionAreaBorder('left')?.scrollIntoView({ + block: 'nearest', + inline: 'nearest', + behavior: 'smooth', + }); + }); + } else { + newSelection.columnsSelection.end -= 1; + requestAnimationFrame(() => { + this.getSelectionAreaBorder('right')?.scrollIntoView({ + block: 'nearest', + inline: 'nearest', + behavior: 'smooth', + }); + }); + } + this.selection = newSelection; + } + + selectionAreaRight() { + const selection = this.selection; + if (!selection || selection.selectionType !== 'area') { + return; + } + const newSelection = this.focusToArea(selection); + if ( + newSelection.columnsSelection.start === newSelection.focus.columnIndex + ) { + const max = + (this.rows(newSelection.groupKey) + ?.item(0) + .querySelectorAll('affine-database-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: DatabaseCellContainer, + fillValues?: boolean + ) { + const groupKey = cell.closest('affine-data-view-table-group')?.group?.key; + const table = this.tableContainer; + const scrollContainer = table?.parentElement; + if (!table || !scrollContainer) { + return; + } + const tableRect = table.getBoundingClientRect(); + const startOffsetX = evt.x - tableRect.left; + const startOffsetY = evt.y - tableRect.top; + const offsetToSelection = this.cellPosition(groupKey); + const select = (selection: { + row: MultiSelection; + column: MultiSelection; + }) => { + this.selection = TableAreaSelection.create({ + groupKey: groupKey, + rowsSelection: selection.row, + columnsSelection: selection.column, + focus: { + rowIndex: cell.rowIndex, + columnIndex: cell.columnIndex, + }, + isEditing: false, + }); + }; + const drag = startDrag< + | { + row: MultiSelection; + column: MultiSelection; + } + | undefined, + { + x: number; + y: number; + } + >(evt, { + transform: evt => ({ + x: evt.x, + y: evt.y, + }), + onDrag: () => { + if (fillValues) this.__dragToFillElement.dragging = true; + return undefined; + }, + onMove: ({ x, y }) => { + if (!table) return; + const tableRect = table.getBoundingClientRect(); + const startX = tableRect.left + startOffsetX; + const startY = tableRect.top + startOffsetY; + const selection = offsetToSelection(startX, x, startY, y); + + if (fillValues) + selection.column = { + start: cell.columnIndex, + end: cell.columnIndex, + }; + + select(selection); + return selection; + }, + onDrop: selection => { + if (!selection) { + return; + } + select(selection); + if (fillValues && this.selection) { + this.__dragToFillElement.dragging = false; + fillSelectionWithFocusCellData( + this.host, + TableAreaSelection.create({ + groupKey: groupKey, + rowsSelection: selection.row, + columnsSelection: selection.column, + focus: { + rowIndex: cell.rowIndex, + columnIndex: cell.columnIndex, + }, + isEditing: false, + }) + ); + } + }, + onClear: () => { + cancelScroll(); + }, + }); + const cancelScroll = autoScrollOnBoundary( + scrollContainer, + computed(() => { + return { + left: drag.mousePosition.value.x, + right: drag.mousePosition.value.x, + top: drag.mousePosition.value.y, + bottom: drag.mousePosition.value.y, + }; + }), + { + onScroll() { + drag.move({ x: drag.last.x, y: drag.last.y }); + }, + } + ); + } + + toggleRow(rowId: string, groupKey?: string) { + const row = { + id: rowId, + groupKey, + }; + const isSelected = TableRowSelection.includes(this.selection, row); + this.rowSelectionChange({ + add: isSelected ? [] : [row], + remove: isSelected ? [row] : [], + }); + } +} + +export class SelectionElement extends WithDisposable(ShadowlessElement) { + static override styles = css` + .database-selection { + position: absolute; + z-index: 2; + box-sizing: border-box; + background: var(--affine-primary-color-04); + pointer-events: none; + display: none; + } + + .database-focus { + position: absolute; + width: 100%; + z-index: 2; + box-sizing: border-box; + border: 1px solid var(--affine-primary-color); + border-radius: 2px; + pointer-events: none; + display: none; + outline: none; + } + + .area-border { + position: absolute; + pointer-events: none; + } + + .area-left { + left: 0; + height: 100%; + width: 1px; + } + + .area-right { + right: 0; + height: 100%; + width: 1px; + } + + .area-top { + top: 0; + width: 100%; + height: 1px; + } + + .area-bottom { + bottom: 0; + width: 100%; + height: 1px; + } + + @media print { + data-view-table-selection { + display: none; + } + } + `; + + focusRef: Ref = createRef(); + + preTask = 0; + + selectionRef: Ref = createRef(); + + get selection$() { + return this.controller.host.props.selection$; + } + + clearAreaStyle() { + const div = this.selectionRef.value; + if (!div) return; + div.style.display = 'none'; + } + + clearFocusStyle() { + const div = this.focusRef.value; + const dragToFill = this.controller.dragToFillDraggable; + if (!div || !dragToFill) return; + div.style.display = 'none'; + dragToFill.style.display = 'none'; + } + + override connectedCallback() { + super.connectedCallback(); + this.disposables.add( + effect(() => { + this.startUpdate(this.selection$.value); + }) + ); + } + + override render() { + return html` +
+
+
+
+
+
+
+ `; + } + + startUpdate(selection?: TableViewSelection) { + if (this.preTask) { + cancelAnimationFrame(this.preTask); + this.preTask = 0; + } + if ( + selection?.selectionType === 'area' && + !this.controller.host.props.view.readonly$.value + ) { + this.updateAreaSelectionStyle( + selection.groupKey, + selection.rowsSelection, + selection.columnsSelection + ); + + const columnSelection = selection.columnsSelection; + const rowSelection = selection.rowsSelection; + + const isSingleRowSelection = rowSelection.end - rowSelection.start === 0; + const isSingleColumnSelection = + columnSelection.end - columnSelection.start === 0; + + const isDragElemDragging = this.controller.__dragToFillElement.dragging; + const isEditing = selection.isEditing; + + const showDragToFillHandle = + !isEditing && + (isDragElemDragging || isSingleRowSelection) && + isSingleColumnSelection; + + this.updateFocusSelectionStyle( + selection.groupKey, + selection.focus, + isEditing, + showDragToFillHandle + ); + this.preTask = requestAnimationFrame(() => + this.startUpdate(this.selection$.value) + ); + } else { + this.clearFocusStyle(); + this.clearAreaStyle(); + } + } + + updateAreaSelectionStyle( + groupKey: string | undefined, + rowSelection: MultiSelection, + columnSelection: MultiSelection + ) { + const div = this.selectionRef.value; + if (!div) return; + const tableContainer = this.controller.tableContainer; + if (!tableContainer) return; + const tableRect = tableContainer.getBoundingClientRect(); + const rect = this.controller.getRect( + groupKey, + rowSelection?.start ?? 0, + rowSelection?.end ?? this.controller.view.rows$.value.length - 1, + columnSelection?.start ?? 0, + columnSelection?.end ?? this.controller.view.properties$.value.length - 1 + ); + if (!rect) { + this.clearAreaStyle(); + return; + } + const { left, top, width, height, scale } = rect; + div.style.left = `${left - tableRect.left / scale}px`; + div.style.top = `${top - tableRect.top / scale}px`; + div.style.width = `${width}px`; + div.style.height = `${height}px`; + div.style.display = 'block'; + } + + updateFocusSelectionStyle( + groupKey: string | undefined, + focus: CellFocus, + isEditing: boolean, + showDragToFillHandle = false + ) { + const div = this.focusRef.value; + const dragToFill = this.controller.dragToFillDraggable; + if (!div || !dragToFill) return; + // Check if row is removed. + const rows = this.controller.rows(groupKey) ?? []; + if (rows.length <= focus.rowIndex) return; + + const rect = this.controller.getRect( + groupKey, + focus.rowIndex, + focus.rowIndex, + focus.columnIndex, + focus.columnIndex + ); + if (!rect) { + this.clearFocusStyle(); + return; + } + const { left, top, width, height, scale } = rect; + const tableContainer = this.controller.tableContainer; + if (!tableContainer) return; + const tableRect = tableContainer?.getBoundingClientRect(); + if (!tableRect) { + this.clearFocusStyle(); + return; + } + + const x = left - tableRect.left / scale; + const y = top - 1 - tableRect.top / scale; + const w = width + 1; + const h = height + 1; + div.style.left = `${x}px`; + div.style.top = `${y}px`; + div.style.width = `${w}px`; + div.style.height = `${h}px`; + div.style.borderColor = 'var(--affine-primary-color)'; + div.style.borderStyle = this.controller.__dragToFillElement.dragging + ? 'dashed' + : 'solid'; + div.style.boxShadow = isEditing + ? '0px 0px 0px 2px rgba(30, 150, 235, 0.30)' + : 'unset'; + div.style.display = 'block'; + + dragToFill.style.left = `${x + w}px`; + dragToFill.style.top = `${y + h}px`; + dragToFill.style.display = showDragToFillHandle ? 'block' : 'none'; + } + + @property({ attribute: false }) + accessor controller!: TableSelectionController; +} diff --git a/blocksuite/affine/data-view/src/view-presets/table/pc/group.ts b/blocksuite/affine/data-view/src/view-presets/table/pc/group.ts new file mode 100644 index 0000000000..0474c20e87 --- /dev/null +++ b/blocksuite/affine/data-view/src/view-presets/table/pc/group.ts @@ -0,0 +1,305 @@ +import { + menu, + popFilterableSimpleMenu, + popupTargetFromElement, +} from '@blocksuite/affine-components/context-menu'; +import { ShadowlessElement } from '@blocksuite/block-std'; +import { SignalWatcher, WithDisposable } from '@blocksuite/global/utils'; +import { PlusIcon } from '@blocksuite/icons/lit'; +import { effect } from '@preact/signals-core'; +import { css, html } from 'lit'; +import { property, query } from 'lit/decorators.js'; +import { repeat } from 'lit/directives/repeat.js'; + +import type { DataViewRenderer } from '../../../core/data-view.js'; +import { GroupTitle } from '../../../core/group-by/group-title.js'; +import type { GroupData } from '../../../core/group-by/trait.js'; +import { createDndContext } from '../../../core/utils/wc-dnd/dnd-context.js'; +import { defaultActivators } from '../../../core/utils/wc-dnd/sensors/index.js'; +import { linearMove } from '../../../core/utils/wc-dnd/utils/linear-move.js'; +import { LEFT_TOOL_BAR_WIDTH } from '../consts.js'; +import type { TableSingleView } from '../table-view-manager.js'; +import { TableAreaSelection } from '../types.js'; +import { DataViewColumnPreview } from './header/column-renderer.js'; +import { getVerticalIndicator } from './header/vertical-indicator.js'; +import type { DataViewTable } from './table-view.js'; + +const styles = css` + affine-data-view-table-group:hover .group-header-op { + visibility: visible; + opacity: 1; + } + + .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 { + .data-view-table-group-add-row { + display: none; + } + } + + .data-view-table-group-add-row-button { + position: sticky; + left: ${8 + LEFT_TOOL_BAR_WIDTH}px; + display: flex; + align-items: center; + justify-content: center; + gap: 10px; + user-select: none; + font-size: 12px; + line-height: 20px; + color: var(--affine-text-secondary-color); + } +`; + +export class TableGroup extends SignalWatcher( + WithDisposable(ShadowlessElement) +) { + static override styles = styles; + + private clickAddRow = () => { + this.view.rowAdd('end', this.group?.key); + requestAnimationFrame(() => { + const selectionController = this.viewEle.selectionController; + const index = this.view.properties$.value.findIndex( + v => v.type$.value === 'title' + ); + selectionController.selection = TableAreaSelection.create({ + groupKey: this.group?.key, + focus: { + rowIndex: this.rows.length - 1, + columnIndex: index, + }, + isEditing: true, + }); + }); + }; + + private clickAddRowInStart = () => { + this.view.rowAdd('start', this.group?.key); + requestAnimationFrame(() => { + const selectionController = this.viewEle.selectionController; + const index = this.view.properties$.value.findIndex( + v => v.type$.value === 'title' + ); + selectionController.selection = TableAreaSelection.create({ + groupKey: this.group?.key, + focus: { + rowIndex: 0, + columnIndex: index, + }, + isEditing: true, + }); + }); + }; + + private clickGroupOptions = (e: MouseEvent) => { + const group = this.group; + if (!group) { + return; + } + const ele = e.currentTarget as HTMLElement; + popFilterableSimpleMenu(popupTargetFromElement(ele), [ + menu.action({ + name: 'Ungroup', + hide: () => group.value == null, + select: () => { + group.rows.forEach(id => { + group.manager.removeFromGroup(id, group.key); + }); + }, + }), + menu.action({ + name: 'Delete Cards', + select: () => { + this.view.rowDelete(group.rows); + }, + }), + ]); + }; + + private renderGroupHeader = () => { + if (!this.group) { + return null; + } + return html` +
+ ${GroupTitle(this.group, { + readonly: this.view.readonly$.value, + clickAdd: this.clickAddRowInStart, + clickOps: this.clickGroupOptions, + })} +
+ `; + }; + + @property({ attribute: false }) + accessor group: GroupData | undefined = undefined; + + @property({ attribute: false }) + accessor view!: TableSingleView; + + dndContext = createDndContext({ + activators: defaultActivators, + container: this, + modifiers: [ + ({ transform }) => { + return { + ...transform, + y: 0, + }; + }, + ], + onDragEnd: ({ over, active }) => { + if (over && over.id !== active.id) { + const activeIndex = this.view.properties$.value.findIndex( + data => data.id === active.id + ); + const overIndex = this.view.properties$.value.findIndex( + data => data.id === over.id + ); + this.view.propertyMove(active.id, { + before: activeIndex > overIndex, + id: over.id, + }); + } + }, + collisionDetection: linearMove(true), + createOverlay: active => { + const column = this.view.propertyGet(active.id); + const preview = new DataViewColumnPreview(); + preview.column = column; + preview.group = this.group; + preview.container = this; + preview.style.position = 'absolute'; + preview.style.zIndex = '999'; + const scale = this.dndContext.scale$.value; + const offsetParentRect = this.offsetParent?.getBoundingClientRect(); + if (!offsetParentRect) { + return; + } + preview.style.width = `${column.width$.value}px`; + preview.style.top = `${(active.rect.top - offsetParentRect.top - 1) / scale.y}px`; + preview.style.left = `${(active.rect.left - offsetParentRect.left) / scale.x}px`; + const cells = Array.from( + this.querySelectorAll(`[data-column-id="${active.id}"]`) + ) as HTMLElement[]; + cells.forEach(ele => { + ele.style.opacity = '0.1'; + }); + this.append(preview); + return { + overlay: preview, + cleanup: () => { + preview.remove(); + cells.forEach(ele => { + ele.style.opacity = '1'; + }); + }, + }; + }, + }); + + showIndicator = () => { + const columnMoveIndicator = getVerticalIndicator(); + this.disposables.add( + effect(() => { + const active = this.dndContext.active$.value; + const over = this.dndContext.over$.value; + if (!active || !over) { + columnMoveIndicator.remove(); + return; + } + const scrollX = this.dndContext.scrollOffset$.value.x; + const bottom = + this.rowsContainer?.getBoundingClientRect().bottom ?? + this.getBoundingClientRect().bottom; + const left = + over.rect.left < active.rect.left ? over.rect.left : over.rect.right; + const height = bottom - over.rect.top; + columnMoveIndicator.display(left - scrollX, over.rect.top, height); + }) + ); + }; + + get rows() { + return this.group?.rows ?? this.view.rows$.value; + } + + private renderRows(ids: string[]) { + return html` + +
+ ${repeat( + ids, + id => id, + (id, idx) => { + return html` `; + } + )} +
+ ${this.view.readonly$.value + ? null + : html`
+
+ ${PlusIcon()}New Record +
+
`} + + + `; + } + + override connectedCallback(): void { + super.connectedCallback(); + this.showIndicator(); + } + + override render() { + return this.renderRows(this.rows); + } + + @property({ attribute: false }) + accessor dataViewEle!: DataViewRenderer; + + @query('.affine-database-block-rows') + accessor rowsContainer: HTMLElement | null = null; + + @property({ attribute: false }) + accessor viewEle!: DataViewTable; +} + +declare global { + interface HTMLElementTagNameMap { + 'affine-data-view-table-group': TableGroup; + } +} diff --git a/blocksuite/affine/data-view/src/view-presets/table/pc/header/column-header.ts b/blocksuite/affine/data-view/src/view-presets/table/pc/header/column-header.ts new file mode 100644 index 0000000000..1a81a50d4b --- /dev/null +++ b/blocksuite/affine/data-view/src/view-presets/table/pc/header/column-header.ts @@ -0,0 +1,139 @@ +/* eslint-disable @typescript-eslint/no-non-null-assertion */ +import { getScrollContainer } from '@blocksuite/affine-shared/utils'; +import { ShadowlessElement } from '@blocksuite/block-std'; +import { SignalWatcher, WithDisposable } from '@blocksuite/global/utils'; +import { PlusIcon } from '@blocksuite/icons/lit'; +import { autoUpdate } from '@floating-ui/dom'; +import { nothing, type TemplateResult } from 'lit'; +import { property, query } from 'lit/decorators.js'; +import { repeat } from 'lit/directives/repeat.js'; +import { styleMap } from 'lit/directives/style-map.js'; +import { html } from 'lit/static-html.js'; + +import type { TableSingleView } from '../../table-view-manager.js'; +import type { TableGroup } from '../group.js'; +import { styles } from './styles.js'; + +export class DatabaseColumnHeader extends SignalWatcher( + WithDisposable(ShadowlessElement) +) { + static override styles = styles; + + private _onAddColumn = (e: MouseEvent) => { + if (this.readonly) return; + this.tableViewManager.propertyAdd('end'); + const ele = e.currentTarget as HTMLElement; + requestAnimationFrame(() => { + this.editLastColumnTitle(); + ele.scrollIntoView({ block: 'nearest', inline: 'nearest' }); + }); + }; + + editLastColumnTitle = () => { + const columns = this.querySelectorAll('affine-database-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-data-view-renderer')! + ); + const group = this.closest('affine-data-view-table-group'); + if (group) { + const cancel = autoUpdate(group, this, () => { + if (!scrollContainer) { + return; + } + this.autoSetHeaderPosition(group, scrollContainer); + }); + this.disposables.add(cancel); + } + } + + getScale() { + return this.scaleDiv?.getBoundingClientRect().width ?? 1; + } + + override render() { + return html` + ${this.renderGroupHeader?.()} +
+ ${this.readonly + ? nothing + : html`
`} + ${repeat( + this.tableViewManager.properties$.value, + column => column.id, + (column, index) => { + const style = styleMap({ + width: `${column.width$.value}px`, + border: index === 0 ? 'none' : undefined, + }); + return html` + +
+ `; + } + )} +
+ ${PlusIcon()} +
+
+
+ `; + } + + @property({ attribute: false }) + accessor renderGroupHeader: (() => TemplateResult) | undefined; + + @query('.scale-div') + accessor scaleDiv!: HTMLDivElement; + + @property({ attribute: false }) + accessor tableViewManager!: TableSingleView; +} + +declare global { + interface HTMLElementTagNameMap { + 'affine-database-column-header': DatabaseColumnHeader; + } +} diff --git a/blocksuite/affine/data-view/src/view-presets/table/pc/header/column-renderer.ts b/blocksuite/affine/data-view/src/view-presets/table/pc/header/column-renderer.ts new file mode 100644 index 0000000000..83e466f7c5 --- /dev/null +++ b/blocksuite/affine/data-view/src/view-presets/table/pc/header/column-renderer.ts @@ -0,0 +1,85 @@ +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 { GroupData } from '../../../../core/group-by/trait.js'; +import type { TableColumn, TableSingleView } from '../../table-view-manager.js'; + +export class DataViewColumnPreview extends SignalWatcher( + WithDisposable(ShadowlessElement) +) { + static override styles = css` + affine-data-view-column-preview { + pointer-events: none; + display: block; + position: fixed; + font-family: var(--affine-font-family); + } + `; + + get tableViewManager(): TableSingleView { + return this.column.view as TableSingleView; + } + + private renderGroup(rows: string[]) { + const columnIndex = this.tableViewManager.propertyIndexGet(this.column.id); + return html` +
+ + ${repeat(rows, (id, index) => { + const height = this.container.querySelector( + `affine-database-cell-container[data-row-id="${id}"]` + )?.clientHeight; + const style = styleMap({ + height: height + 'px', + }); + return html`
+
+ +
+
`; + })} +
+
+ `; + } + + override render() { + return this.renderGroup( + this.group?.rows ?? this.tableViewManager.rows$.value + ); + } + + @property({ attribute: false }) + accessor column!: TableColumn; + + @property({ attribute: false }) + accessor container!: HTMLElement; + + @property({ attribute: false }) + accessor group: GroupData | undefined = undefined; +} + +declare global { + interface HTMLElementTagNameMap { + 'affine-data-view-column-preview': DataViewColumnPreview; + } +} diff --git a/blocksuite/affine/data-view/src/view-presets/table/pc/header/database-header-column.ts b/blocksuite/affine/data-view/src/view-presets/table/pc/header/database-header-column.ts new file mode 100644 index 0000000000..bac236a2b3 --- /dev/null +++ b/blocksuite/affine/data-view/src/view-presets/table/pc/header/database-header-column.ts @@ -0,0 +1,506 @@ +import { + menu, + type MenuConfig, + popMenu, + popupTargetFromElement, +} from '@blocksuite/affine-components/context-menu'; +import { ShadowlessElement } from '@blocksuite/block-std'; +import { SignalWatcher, WithDisposable } from '@blocksuite/global/utils'; +import { + DeleteIcon, + DuplicateIcon, + FilterIcon, + InsertLeftIcon, + InsertRightIcon, + MoveLeftIcon, + MoveRightIcon, + SortIcon, + ViewIcon, +} from '@blocksuite/icons/lit'; +import { css } from 'lit'; +import { property } from 'lit/decorators.js'; +import { classMap } from 'lit/directives/class-map.js'; +import { createRef, ref } from 'lit/directives/ref.js'; +import { styleMap } from 'lit/directives/style-map.js'; +import { html } from 'lit/static-html.js'; + +import { + inputConfig, + typeConfig, +} from '../../../../core/common/property-menu.js'; +import { filterTraitKey } from '../../../../core/filter/trait.js'; +import { firstFilterByRef } from '../../../../core/filter/utils.js'; +import { renderUniLit } from '../../../../core/index.js'; +import { sortTraitKey } from '../../../../core/sort/manager.js'; +import { createSortUtils } from '../../../../core/sort/utils.js'; +import { + draggable, + dragHandler, + droppable, +} from '../../../../core/utils/wc-dnd/dnd-context.js'; +import type { Property } from '../../../../core/view-manager/property.js'; +import type { NumberPropertyDataType } from '../../../../property-presets/index.js'; +import { numberFormats } from '../../../../property-presets/number/utils/formats.js'; +import { ShowQuickSettingBarContextKey } from '../../../../widget-presets/quick-setting-bar/context.js'; +import { DEFAULT_COLUMN_TITLE_HEIGHT } from '../../consts.js'; +import type { TableColumn, TableSingleView } from '../../table-view-manager.js'; +import { + getTableGroupRect, + getVerticalIndicator, + startDragWidthAdjustmentBar, +} from './vertical-indicator.js'; + +export class DatabaseHeaderColumn extends SignalWatcher( + WithDisposable(ShadowlessElement) +) { + static override styles = css` + affine-database-header-column { + display: flex; + } + + .affine-database-header-column-grabbing * { + cursor: grabbing; + } + `; + + private _clickColumn = () => { + if (this.tableViewManager.readonly$.value) { + return; + } + this.popMenu(); + }; + + private _clickTypeIcon = (event: MouseEvent) => { + if (this.tableViewManager.readonly$.value) { + return; + } + if (this.column.type$.value === 'title') { + return; + } + event.stopPropagation(); + popMenu(popupTargetFromElement(this), { + options: { + items: this.tableViewManager.propertyMetas.map(config => { + return menu.action({ + name: config.config.name, + isSelected: config.type === this.column.type$.value, + prefix: renderUniLit( + this.tableViewManager.propertyIconGet(config.type) + ), + select: () => { + this.column.typeSet?.(config.type); + }, + }); + }), + }, + }); + }; + + 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 rect = getTableGroupRect(this); + if (!rect) { + return; + } + getVerticalIndicator().display( + this.getBoundingClientRect().right, + rect.top, + rect.bottom - rect.top + ); + this.drawWidthDragBarTask = requestAnimationFrame(this.drawWidthDragBar); + }; + + private drawWidthDragBarTask = 0; + + private widthDragBar = createRef(); + + editTitle = () => { + this._clickColumn(); + }; + + private get readonly() { + return this.tableViewManager.readonly$.value; + } + + private _addFilter() { + const filterTrait = this.tableViewManager.traitGet(filterTraitKey); + if (!filterTrait) return; + + const filter = firstFilterByRef(this.tableViewManager.vars$.value, { + type: 'ref', + name: this.column.id, + }); + + filterTrait.filterSet({ + type: 'group', + op: 'and', + conditions: [filter, ...filterTrait.filter$.value.conditions], + }); + + this._toggleQuickSettingBar(); + } + + private _addSort(desc: boolean) { + const sortTrait = this.tableViewManager.traitGet(sortTraitKey); + if (!sortTrait) return; + + const sortUtils = createSortUtils( + sortTrait, + this.closest('affine-data-view-renderer')?.view?.expose.eventTrace ?? + (() => {}) + ); + const sortList = sortUtils.sortList$.value; + const existingIndex = sortList.findIndex( + sort => sort.ref.name === this.column.id + ); + + if (existingIndex !== -1) { + sortUtils.change(existingIndex, { + ref: { type: 'ref', name: this.column.id }, + desc, + }); + } else { + sortUtils.add({ + ref: { type: 'ref', name: this.column.id }, + desc, + }); + } + + this._toggleQuickSettingBar(); + } + + private _toggleQuickSettingBar(show = true) { + const map = this.tableViewManager.contextGet(ShowQuickSettingBarContextKey); + map.value = { + ...map.value, + [this.tableViewManager.id]: show, + }; + } + + private popMenu(ele?: HTMLElement) { + const enableNumberFormatting = + this.tableViewManager.featureFlags$.value.enable_number_formatting; + + popMenu(popupTargetFromElement(ele ?? this), { + options: { + items: [ + inputConfig(this.column), + typeConfig(this.column), + // Number format begin + ...(enableNumberFormatting + ? [ + menu.subMenu({ + name: 'Number Format', + hide: () => + !this.column.dataUpdate || + this.column.type$.value !== 'number', + options: { + items: [ + numberFormatConfig(this.column), + ...numberFormats.map(format => { + const data = ( + this.column as Property< + number, + NumberPropertyDataType + > + ).data$.value; + return menu.action({ + isSelected: data.format === format.type, + prefix: html`${format.symbol}`, + name: format.label, + select: () => { + if (data.format === format.type) return; + this.column.dataUpdate(() => ({ + format: format.type, + })); + }, + }); + }), + ], + }, + }), + ] + : []), + // Number format end + menu.group({ + items: [ + menu.action({ + name: 'Hide In View', + prefix: ViewIcon(), + hide: () => + this.column.hide$.value || + this.column.type$.value === 'title', + select: () => { + this.column.hideSet(true); + }, + }), + ], + }), + menu.group({ + items: [ + menu.action({ + name: 'Filter', + prefix: FilterIcon(), + select: () => this._addFilter(), + }), + menu.action({ + name: 'Sort Ascending', + prefix: SortIcon(), + select: () => this._addSort(false), + }), + menu.action({ + name: 'Sort Descending', + prefix: SortIcon(), + select: () => this._addSort(true), + }), + ], + }), + menu.group({ + items: [ + menu.action({ + name: 'Insert Left Column', + prefix: InsertLeftIcon(), + select: () => { + this.tableViewManager.propertyAdd({ + id: this.column.id, + before: true, + }); + Promise.resolve() + .then(() => { + const pre = + this.previousElementSibling?.previousElementSibling; + if (pre instanceof DatabaseHeaderColumn) { + pre.editTitle(); + pre.scrollIntoView({ + inline: 'nearest', + block: 'nearest', + }); + } + }) + .catch(console.error); + }, + }), + menu.action({ + name: 'Insert Right Column', + prefix: InsertRightIcon(), + select: () => { + this.tableViewManager.propertyAdd({ + id: this.column.id, + before: false, + }); + Promise.resolve() + .then(() => { + const next = this.nextElementSibling?.nextElementSibling; + if (next instanceof DatabaseHeaderColumn) { + next.editTitle(); + next.scrollIntoView({ + inline: 'nearest', + block: 'nearest', + }); + } + }) + .catch(console.error); + }, + }), + menu.action({ + name: 'Move Left', + prefix: MoveLeftIcon(), + hide: () => this.column.isFirst, + select: () => { + const preId = this.tableViewManager.propertyPreGet( + this.column.id + )?.id; + if (!preId) { + return; + } + this.tableViewManager.propertyMove(this.column.id, { + id: preId, + before: true, + }); + }, + }), + menu.action({ + name: 'Move Right', + prefix: MoveRightIcon(), + hide: () => this.column.isLast, + select: () => { + const nextId = this.tableViewManager.propertyNextGet( + this.column.id + )?.id; + if (!nextId) { + return; + } + this.tableViewManager.propertyMove(this.column.id, { + id: nextId, + before: false, + }); + }, + }), + ], + }), + menu.group({ + items: [ + menu.action({ + name: 'Duplicate', + prefix: DuplicateIcon(), + hide: () => + !this.column.duplicate || this.column.type$.value === 'title', + select: () => { + this.column.duplicate?.(); + }, + }), + menu.action({ + name: 'Delete', + prefix: DeleteIcon(), + hide: () => + !this.column.delete || this.column.type$.value === 'title', + select: () => { + this.column.delete?.(); + }, + class: { + 'delete-item': true, + }, + }), + ], + }), + ], + }, + }); + } + + private widthDragStart(event: PointerEvent) { + startDragWidthAdjustmentBar( + event, + this, + this.getBoundingClientRect().width, + this.column + ); + } + + override connectedCallback() { + super.connectedCallback(); + const table = this.closest('affine-database-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 && + this.widthDragBar.value?.contains(target) + ) { + event.preventDefault(); + event.stopPropagation(); + this.widthDragStart(event); + return true; + } + return false; + }) + ); + } + } + + override render() { + const column = this.column; + const style = styleMap({ + height: DEFAULT_COLUMN_TITLE_HEIGHT + 'px', + }); + const classes = classMap({ + 'affine-database-column-move': true, + [this.grabStatus]: true, + }); + return html` +
+ ${this.readonly + ? null + : html` `} +
+
+ +
+
+
+ ${column.name$.value} +
+
+
+
+
+
+
+ `; + } + + @property({ attribute: false }) + accessor column!: TableColumn; + + @property({ attribute: false }) + accessor grabStatus: 'grabStart' | 'grabEnd' | 'grabbing' = 'grabEnd'; + + @property({ attribute: false }) + accessor tableViewManager!: TableSingleView; +} + +function numberFormatConfig(column: Property): MenuConfig { + return () => + html` `; +} + +declare global { + interface HTMLElementTagNameMap { + 'affine-database-header-column': DatabaseHeaderColumn; + } +} diff --git a/blocksuite/affine/data-view/src/view-presets/table/pc/header/number-format-bar.ts b/blocksuite/affine/data-view/src/view-presets/table/pc/header/number-format-bar.ts new file mode 100644 index 0000000000..fde8520079 --- /dev/null +++ b/blocksuite/affine/data-view/src/view-presets/table/pc/header/number-format-bar.ts @@ -0,0 +1,144 @@ +import { WithDisposable } from '@blocksuite/global/utils'; +import { css, html, LitElement } from 'lit'; +import { property } from 'lit/decorators.js'; + +import type { Property } from '../../../../core/view-manager/property.js'; +import { formatNumber } from '../../../../property-presets/number/utils/formatter.js'; + +const IncreaseDecimalPlacesIcon = html` + + + +`; + +const DecreaseDecimalPlacesIcon = html` + + + +`; + +export class DatabaseNumberFormatBar extends WithDisposable(LitElement) { + static override styles = css` + .number-format-toolbar-container { + padding: 4px 12px; + display: flex; + gap: 7px; + flex-direction: column; + } + + .number-format-decimal-places { + display: flex; + gap: 4px; + align-items: center; + justify-content: flex-start; + } + + .number-format-toolbar-button { + box-sizing: border-box; + background-color: transparent; + border: none; + border-radius: 4px; + color: var(--affine-icon-color); + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + padding: 4px; + position: relative; + + user-select: none; + } + + .number-format-toolbar-button svg { + width: 16px; + height: 16px; + } + + .number-formatting-sample { + font-size: var(--affine-font-xs); + color: var(--affine-icon-color); + margin-left: auto; + } + .number-format-toolbar-button:hover { + background-color: var(--affine-hover-color); + } + .divider { + width: 100%; + height: 1px; + background-color: var(--affine-border-color); + } + `; + + private _decrementDecimalPlaces = () => { + this.column.dataUpdate(data => ({ + decimal: Math.max(((data.decimal as number) ?? 0) - 1, 0), + })); + this.requestUpdate(); + }; + + private _incrementDecimalPlaces = () => { + this.column.dataUpdate(data => ({ + decimal: Math.min(((data.decimal as number) ?? 0) + 1, 8), + })); + this.requestUpdate(); + }; + + override render() { + return html` +
+
+ + + + + ( ${formatNumber( + 1, + 'number', + (this.column.data$.value.decimal as number) ?? 0 + )} ) + +
+
+
+ `; + } + + @property({ attribute: false }) + accessor column!: Property; +} + +declare global { + interface HTMLElementTagNameMap { + 'affine-database-number-format-bar': DatabaseNumberFormatBar; + } +} diff --git a/blocksuite/affine/data-view/src/view-presets/table/pc/header/styles.ts b/blocksuite/affine/data-view/src/view-presets/table/pc/header/styles.ts new file mode 100644 index 0000000000..7654b8a402 --- /dev/null +++ b/blocksuite/affine/data-view/src/view-presets/table/pc/header/styles.ts @@ -0,0 +1,348 @@ +import { unsafeCSSVarV2 } from '@blocksuite/affine-shared/theme'; +import { baseTheme } from '@toeverything/theme'; +import { css, unsafeCSS } from 'lit'; + +import { + DEFAULT_ADD_BUTTON_WIDTH, + DEFAULT_COLUMN_TITLE_HEIGHT, +} from '../../consts.js'; + +export const styles = css` + affine-database-column-header { + display: block; + background-color: var(--affine-background-primary-color); + position: relative; + z-index: 2; + } + + .affine-database-column-header { + position: relative; + display: flex; + flex-direction: row; + border-bottom: 1px solid var(--affine-border-color); + border-top: 1px solid var(--affine-border-color); + box-sizing: border-box; + user-select: none; + background-color: var(--affine-background-primary-color); + } + + .affine-database-column { + cursor: pointer; + } + + .database-cell { + user-select: none; + } + + .database-cell.add-column-button { + flex: 1; + min-width: ${DEFAULT_ADD_BUTTON_WIDTH}px; + min-height: 100%; + display: flex; + align-items: center; + } + + .affine-database-column-content { + display: flex; + align-items: center; + gap: 6px; + width: 100%; + height: 100%; + padding: 6px; + box-sizing: border-box; + position: relative; + } + + .affine-database-column-content:hover, + .affine-database-column-content.edit { + background: var(--affine-hover-color); + } + + .affine-database-column-content.edit .affine-database-column-text-icon { + opacity: 1; + } + + .affine-database-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-database-column-type-icon { + display: flex; + align-items: center; + border-radius: 4px; + padding: 2px; + font-size: 18px; + color: ${unsafeCSSVarV2('database/textSecondary')}; + } + + .affine-database-column-text-content { + flex: 1; + display: flex; + align-items: center; + overflow: hidden; + } + + .affine-database-column-content:hover .affine-database-column-text-icon { + opacity: 1; + } + + .affine-database-column-text-input { + flex: 1; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + font-weight: 500; + } + + .affine-database-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-database-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-database-column-text-save-icon:hover { + background: var(--affine-white); + border-color: var(--affine-border-color); + } + + .affine-database-column-text-icon svg { + fill: var(--affine-icon-color); + } + + .affine-database-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-database-column-input:focus { + outline: none; + } + + .affine-database-column-move { + display: flex; + align-items: center; + } + + .affine-database-column-move svg { + width: 10px; + height: 14px; + color: var(--affine-black-10); + cursor: grab; + opacity: 0; + } + + .affine-database-column-content:hover svg { + opacity: 1; + } + + .affine-database-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; + font-size: 18px; + color: ${unsafeCSSVarV2('icon/primary')}; + } + + @media print { + .header-add-column-button { + display: none; + } + } + + .affine-database-column-type-menu-icon { + border: 1px solid var(--affine-border-color); + border-radius: 4px; + padding: 5px; + background-color: var(--affine-background-secondary-color); + } + + .affine-database-column-type-menu-icon svg { + color: var(--affine-text-secondary-color); + width: 20px; + height: 20px; + + } + + .affine-database-column-move-preview { + position: fixed; + z-index: 100; + width: 100px; + height: 100px; + background: var(--affine-text-emphasis-color); + } + + .affine-database-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-database-column-move .control-l::before, + .affine-database-column-move .control-h::before, + .affine-database-column-move .control-l::after, + .affine-database-column-move .control-h::after, + .affine-database-column-move .control-r, + .affine-database-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-database-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-database-column-move .hover-trigger { + width: 12px; + height: 80%; + right: 3px; + top: 10%; + background: transparent + z-index: 1; + opacity: 1; + } + + .affine-database-column-move:hover .control-r { + opacity: 1; + } + + .affine-database-column-move .control-h::before, + .affine-database-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-database-column-move .control-h::before { + top: var(--bw2); + } + + .affine-database-column-move .control-h::after { + bottom: var(--bw2); + } + + .affine-database-column-move .control-l::before { + --delay: 0s; + width: var(--bw); + height: 100%; + opacity: 0; + background: var(--active); + left: var(--bw2); + } + + .affine-database-column-move .control-l::before { + top: 0; + } + + .affine-database-column-move .control-l::after { + bottom: 0; + } + + /* handle--active style */ + + .affine-database-column-move:hover .control-r { + --delay-opacity: 0s; + opacity: 1; + } + + .affine-database-column-move:active .control-r, + .hover-trigger:hover ~ .control-r, + .grabbing.affine-database-column-move .control-r { + opacity: 1; + --delay: 0s; + --delay-opacity: 0s; + right: var(--bw2); + width: var(--bw); + height: 100%; + background: var(--active); + } + + .affine-database-column-move:active .control-h::before, + .affine-database-column-move:active .control-h::after, + .hover-trigger:hover ~ .control-h::before, + .hover-trigger:hover ~ .control-h::after, + .grabbing.affine-database-column-move .control-h::before, + .grabbing.affine-database-column-move .control-h::after { + --delay: 0.2s; + width: calc(100% - var(--bw2) * 2); + opacity: 1; + } + + .affine-database-column-move:active .control-l::before, + .affine-database-column-move:active .control-l::after, + .hover-trigger:hover ~ .control-l::before, + .hover-trigger:hover ~ .control-l::after, + .grabbing.affine-database-column-move .control-l::before, + .grabbing.affine-database-column-move .control-l::after { + --delay: 0.4s; + opacity: 1; + } +`; diff --git a/blocksuite/affine/data-view/src/view-presets/table/pc/header/vertical-indicator.ts b/blocksuite/affine/data-view/src/view-presets/table/pc/header/vertical-indicator.ts new file mode 100644 index 0000000000..37c06f7e88 --- /dev/null +++ b/blocksuite/affine/data-view/src/view-presets/table/pc/header/vertical-indicator.ts @@ -0,0 +1,163 @@ +import { unsafeCSSVarV2 } from '@blocksuite/affine-shared/theme'; +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 { styleMap } from 'lit/directives/style-map.js'; + +import { startDrag } from '../../../../core/utils/drag.js'; +import { getResultInRange } from '../../../../core/utils/utils.js'; +import type { TableColumn } from '../../table-view-manager.js'; + +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 { + position: absolute; + pointer-events: none; + width: 1px; + background-color: ${unsafeCSSVarV2('layer/background/hoverOverlay')}; + } + + .vertical-indicator::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::after { + box-shadow: 0px 0px 8px 0px rgba(30, 150, 235, 0.35); + } + `; + + protected override render(): unknown { + const style = styleMap({ + top: `${this.top}px`, + left: `${this.left}px`, + height: `${this.height}px`, + width: `${this.width}px`, + }); + const className = classMap({ + 'with-shadow': this.shadow, + 'vertical-indicator': true, + }); + return html`
`; + } + + @property({ attribute: false }) + accessor height!: number; + + @property({ attribute: false }) + accessor left!: number; + + @property({ attribute: false }) + accessor shadow = false; + + @property({ attribute: false }) + accessor top!: number; + + @property({ attribute: false }) + accessor width!: number; +} + +export const getTableGroupRect = (ele: HTMLElement) => { + const group = ele.closest('affine-data-view-table-group'); + if (!group) { + return; + } + const groupRect = group?.getBoundingClientRect(); + const top = + group + .querySelector('.affine-database-column-header') + ?.getBoundingClientRect().top ?? groupRect.top; + const bottom = + group.querySelector('.affine-database-block-rows')?.getBoundingClientRect() + .bottom ?? groupRect.bottom; + return { + top: top, + bottom: bottom, + }; +}; +export const startDragWidthAdjustmentBar = ( + evt: PointerEvent, + ele: HTMLElement, + width: number, + column: TableColumn +) => { + const scale = width / column.width$.value; + const left = ele.getBoundingClientRect().left; + const rect = getTableGroupRect(ele); + if (!rect) { + return; + } + const preview = getVerticalIndicator(); + preview.display(left, rect.top, rect.bottom - rect.top, width * scale); + startDrag<{ width: number }>(evt, { + onDrag: () => ({ width: column.width$.value }), + onMove: ({ x }) => { + const width = Math.round( + getResultInRange((x - left) / scale, column.minWidth, Infinity) + ); + preview.display(left, rect.top, rect.bottom - rect.top, width * scale); + return { + width, + }; + }, + onDrop: ({ width }) => { + column.updateWidth(width); + }, + onClear: () => { + preview.remove(); + }, + }); +}; +let preview: VerticalIndicator | null = null; +type VerticalIndicator = { + display: ( + left: number, + top: number, + height: number, + width?: number, + shadow?: boolean + ) => void; + remove: () => void; +}; +export const getVerticalIndicator = (): VerticalIndicator => { + if (!preview) { + const dragBar = new TableVerticalIndicator(); + preview = { + display( + left: number, + top: number, + height: number, + width = 1, + shadow = false + ) { + document.body.append(dragBar); + dragBar.left = left; + dragBar.height = height; + dragBar.top = top; + dragBar.width = width; + dragBar.shadow = shadow; + }, + remove() { + dragBar.remove(); + }, + }; + } + + return preview; +}; diff --git a/blocksuite/affine/data-view/src/view-presets/table/pc/menu.ts b/blocksuite/affine/data-view/src/view-presets/table/pc/menu.ts new file mode 100644 index 0000000000..ef3e62bdd2 --- /dev/null +++ b/blocksuite/affine/data-view/src/view-presets/table/pc/menu.ts @@ -0,0 +1,130 @@ +import { + menu, + popFilterableSimpleMenu, + type PopupTarget, +} from '@blocksuite/affine-components/context-menu'; +import { + CopyIcon, + DeleteIcon, + ExpandFullIcon, + MoveLeftIcon, + MoveRightIcon, +} from '@blocksuite/icons/lit'; +import { html } from 'lit'; + +import type { DataViewRenderer } from '../../../core/data-view.js'; +import { TableRowSelection } from '../types.js'; +import type { TableSelectionController } from './controller/selection.js'; + +export const openDetail = ( + dataViewEle: DataViewRenderer, + rowId: string, + selection: TableSelectionController +) => { + const old = selection.selection; + selection.selection = undefined; + dataViewEle.openDetailPanel({ + view: selection.host.props.view, + rowId: rowId, + onClose: () => { + selection.selection = old; + }, + }); +}; + +export const popRowMenu = ( + dataViewEle: DataViewRenderer, + ele: PopupTarget, + selectionController: TableSelectionController +) => { + const selection = selectionController.selection; + if (!TableRowSelection.is(selection)) { + return; + } + if (selection.rows.length > 1) { + const rows = TableRowSelection.rowsIds(selection); + popFilterableSimpleMenu(ele, [ + menu.group({ + name: '', + items: [ + menu.action({ + name: 'Copy', + prefix: html`
+ ${CopyIcon()} +
`, + select: () => { + selectionController.host.clipboardController.copy(); + }, + }), + ], + }), + menu.group({ + name: '', + items: [ + menu.action({ + name: 'Delete Rows', + class: { + 'delete-item': true, + }, + prefix: DeleteIcon(), + select: () => { + selectionController.view.rowDelete(rows); + }, + }), + ], + }), + ]); + return; + } + const row = selection.rows[0]; + popFilterableSimpleMenu(ele, [ + menu.action({ + name: 'Expand Row', + prefix: ExpandFullIcon(), + select: () => { + openDetail(dataViewEle, row.id, selectionController); + }, + }), + menu.group({ + name: '', + items: [ + menu.action({ + name: 'Insert Before', + prefix: html`
+ ${MoveLeftIcon()} +
`, + select: () => { + selectionController.insertRowBefore(row.groupKey, row.id); + }, + }), + menu.action({ + name: 'Insert After', + prefix: html`
+ ${MoveRightIcon()} +
`, + select: () => { + selectionController.insertRowAfter(row.groupKey, row.id); + }, + }), + ], + }), + menu.group({ + items: [ + menu.action({ + name: 'Delete Row', + class: { 'delete-item': true }, + prefix: DeleteIcon(), + select: () => { + selectionController.deleteRow(row.id); + }, + }), + ], + }), + ]); +}; diff --git a/blocksuite/affine/data-view/src/view-presets/table/pc/row/row-select-checkbox.ts b/blocksuite/affine/data-view/src/view-presets/table/pc/row/row-select-checkbox.ts new file mode 100644 index 0000000000..fcaf2e7fd9 --- /dev/null +++ b/blocksuite/affine/data-view/src/view-presets/table/pc/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` + row-select-checkbox { + display: contents; + } + .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); + } + .row-select-checkbox:hover { + opacity: 1; + } + .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-database-table')?.selectionController.toggleRow( + this.rowId, + this.groupKey + ); + }); + } + + override render() { + const classString = classMap({ + 'row-selected-bg': true, + 'row-select-checkbox': true, + selected: this.isSelected$.value, + }); + return html` +
+ ${this.isSelected$.value + ? CheckBoxCkeckSolidIcon({ style: `color:#1E96EB` }) + : CheckBoxUnIcon()} +
+ `; + } +} diff --git a/blocksuite/affine/data-view/src/view-presets/table/pc/row/row.ts b/blocksuite/affine/data-view/src/view-presets/table/pc/row/row.ts new file mode 100644 index 0000000000..0bf5465bf6 --- /dev/null +++ b/blocksuite/affine/data-view/src/view-presets/table/pc/row/row.ts @@ -0,0 +1,294 @@ +import { popupTargetFromElement } from '@blocksuite/affine-components/context-menu'; +import { ShadowlessElement } from '@blocksuite/block-std'; +import { SignalWatcher, WithDisposable } from '@blocksuite/global/utils'; +import { CenterPeekIcon, MoreHorizontalIcon } from '@blocksuite/icons/lit'; +import { css, 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 { TableRowSelection, type TableViewSelection } from '../../types.js'; +import { openDetail, popRowMenu } from '../menu.js'; + +export class TableRow extends SignalWatcher(WithDisposable(ShadowlessElement)) { + static override styles = css` + .affine-database-block-row:has(.row-select-checkbox.selected) { + background: var(--affine-primary-color-04); + } + .affine-database-block-row:has(.row-select-checkbox.selected) + .row-selected-bg { + position: relative; + } + .affine-database-block-row:has(.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-database-block-row { + width: 100%; + display: flex; + flex-direction: row; + border-bottom: 1px solid var(--affine-border-color); + position: relative; + } + + .affine-database-block-row.selected > .database-cell { + background: transparent; + } + + .row-ops { + position: relative; + width: 0; + margin-top: 4px; + height: max-content; + visibility: hidden; + display: flex; + gap: 4px; + cursor: pointer; + justify-content: end; + } + + .row-op:last-child { + margin-right: 8px; + } + + .affine-database-block-row .show-on-hover-row { + visibility: hidden; + opacity: 0; + } + .affine-database-block-row:hover .show-on-hover-row { + visibility: visible; + opacity: 1; + } + .affine-database-block-row:has(.active) .show-on-hover-row { + visibility: visible; + opacity: 1; + } + .affine-database-block-row:has([data-editing='true']) .show-on-hover-row { + visibility: hidden; + opacity: 0; + } + + .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: 34px; + display: flex; + align-items: center; + justify-content: center; + cursor: grab; + background-color: var(--affine-background-primary-color); + } + `; + + private _clickDragHandler = () => { + if (this.view.readonly$.value) { + return; + } + this.selectionController?.toggleRow(this.rowId, this.groupKey); + }; + + contextMenu = (e: MouseEvent) => { + if (this.view.readonly$.value) { + return; + } + const selection = this.selectionController; + if (!selection) { + return; + } + e.preventDefault(); + const ele = e.target as HTMLElement; + const cell = ele.closest('affine-database-cell-container'); + const row = { id: this.rowId, groupKey: this.groupKey }; + if (!TableRowSelection.includes(selection.selection, row)) { + selection.selection = TableRowSelection.create({ + rows: [row], + }); + } + const target = + cell ?? + (e.target as HTMLElement).closest('.database-cell') ?? // for last add btn cell + (e.target as HTMLElement); + + popRowMenu(this.dataViewEle, popupTargetFromElement(target), selection); + }; + + setSelection = (selection?: TableViewSelection) => { + if (this.selectionController) { + this.selectionController.selection = selection; + } + }; + + get groupKey() { + return this.closest('affine-data-view-table-group')?.group?.key; + } + + get selectionController() { + return this.closest('affine-database-table')?.selectionController; + } + + override connectedCallback() { + super.connectedCallback(); + this.disposables.addFromEvent(this, 'contextmenu', this.contextMenu); + + this.classList.add('affine-database-block-row', 'database-row'); + } + + protected override render(): unknown { + const view = this.view; + return html` + ${view.readonly$.value + ? nothing + : html`
+
+
+
+
+ +
+
`} + ${repeat( + view.properties$.value, + v => v.id, + (column, i) => { + const clickDetail = () => { + if (!this.selectionController) { + return; + } + this.setSelection( + TableRowSelection.create({ + rows: [{ id: this.rowId, groupKey: this.groupKey }], + }) + ); + openDetail(this.dataViewEle, this.rowId, this.selectionController); + }; + const openMenu = (e: MouseEvent) => { + if (!this.selectionController) { + return; + } + const ele = e.currentTarget as HTMLElement; + const selection = this.selectionController.selection; + if ( + !TableRowSelection.is(selection) || + !selection.rows.some( + row => row.id === this.rowId && row.groupKey === this.groupKey + ) + ) { + const row = { id: this.rowId, groupKey: this.groupKey }; + this.setSelection( + TableRowSelection.create({ + rows: [row], + }) + ); + } + popRowMenu( + this.dataViewEle, + popupTargetFromElement(ele), + this.selectionController + ); + }; + return html` +
+ + +
+
+ ${!column.readonly$.value && + column.view.mainProperties$.value.titleColumn === column.id + ? html`
+
+ ${CenterPeekIcon()} +
+ ${!view.readonly$.value + ? html`
+ ${MoreHorizontalIcon()} +
` + : nothing} +
` + : nothing} + `; + } + )} +
+ `; + } + + @property({ attribute: false }) + accessor dataViewEle!: DataViewRenderer; + + @property({ attribute: false }) + accessor rowId!: string; + + @property({ attribute: false }) + accessor rowIndex!: number; + + @property({ attribute: false }) + accessor view!: TableSingleView; +} + +declare global { + interface HTMLElementTagNameMap { + 'data-view-table-row': TableRow; + } +} diff --git a/blocksuite/affine/data-view/src/view-presets/table/pc/table-view.ts b/blocksuite/affine/data-view/src/view-presets/table/pc/table-view.ts new file mode 100644 index 0000000000..be35b5bb38 --- /dev/null +++ b/blocksuite/affine/data-view/src/view-presets/table/pc/table-view.ts @@ -0,0 +1,320 @@ +import { + menu, + popMenu, + popupTargetFromElement, +} from '@blocksuite/affine-components/context-menu'; +import { AddCursorIcon } from '@blocksuite/icons/lit'; +import { css } from 'lit'; +import { repeat } from 'lit/directives/repeat.js'; +import { styleMap } from 'lit/directives/style-map.js'; +import { html } from 'lit/static-html.js'; + +import type { GroupTrait } from '../../../core/group-by/trait.js'; +import type { DataViewInstance } from '../../../core/index.js'; +import { renderUniLit } from '../../../core/utils/uni-component/uni-component.js'; +import { DataViewBase } from '../../../core/view/data-view-base.js'; +import { LEFT_TOOL_BAR_WIDTH } from '../consts.js'; +import type { TableSingleView } from '../table-view-manager.js'; +import type { TableViewSelectionWithType } from '../types.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'; + +const styles = css` + affine-database-table { + position: relative; + display: flex; + flex-direction: column; + } + + affine-database-table * { + box-sizing: border-box; + } + + .affine-database-table { + overflow-y: auto; + } + + .affine-database-block-title-container { + display: flex; + align-items: center; + justify-content: space-between; + height: 44px; + margin: 2px 0 2px; + } + + .affine-database-block-table { + position: relative; + width: 100%; + padding-bottom: 4px; + z-index: 1; + overflow-x: scroll; + overflow-y: hidden; + } + + /* Disable horizontal scrolling to prevent crashes on iOS Safari */ + affine-edgeless-root .affine-database-block-table { + @media (pointer: coarse) { + overflow: hidden; + } + @media (pointer: fine) { + overflow-x: scroll; + overflow-y: hidden; + } + } + + .affine-database-block-table:hover { + padding-bottom: 0px; + } + + .affine-database-block-table::-webkit-scrollbar { + -webkit-appearance: none; + display: block; + } + + .affine-database-block-table::-webkit-scrollbar:horizontal { + height: 4px; + } + + .affine-database-block-table::-webkit-scrollbar-thumb { + border-radius: 2px; + background-color: transparent; + } + + .affine-database-block-table:hover::-webkit-scrollbar:horizontal { + height: 8px; + } + + .affine-database-block-table:hover::-webkit-scrollbar-thumb { + border-radius: 16px; + background-color: var(--affine-black-30); + } + + .affine-database-block-table:hover::-webkit-scrollbar-track { + background-color: var(--affine-hover-color); + } + + .affine-database-table-container { + position: relative; + width: fit-content; + min-width: 100%; + } + + .affine-database-block-tag-circle { + width: 12px; + height: 12px; + border-radius: 50%; + display: inline-block; + } + + .affine-database-block-tag { + display: inline-flex; + border-radius: 11px; + align-items: center; + padding: 0 8px; + cursor: pointer; + } + + .cell-divider { + width: 1px; + height: 100%; + background-color: 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-database-block-rows { + display: flex; + flex-direction: column; + justify-content: center; + align-items: flex-start; + } +`; + +export class DataViewTable extends DataViewBase< + TableSingleView, + TableViewSelectionWithType +> { + static override styles = styles; + + clipboardController = new TableClipboardController(this); + + dragController = new TableDragController(this); + + 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: GroupTrait) => { + 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, + oldData: column.data$.value, + dataSource: this.props.view.manager.dataSource, + }) as never + ); + } + }, + }), + ], + }, + }); + }; + return html`
+
+
${AddCursorIcon()}
+
New Group
+
+
`; + }; + + selectionController = new TableSelectionController(this); + + get expose(): DataViewInstance { + return { + clearSelection: () => { + this.selectionController.clear(); + }, + addRow: position => { + if (this.readonly) return; + const rowId = this.props.view.rowAdd(position); + if (rowId) { + this.props.dataViewEle.openDetailPanel({ + view: this.props.view, + rowId, + }); + } + return rowId; + }, + 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; + }, + view: this.props.view, + eventTrace: this.props.eventTrace, + }; + } + + private get readonly() { + return this.props.view.readonly$.value; + } + + private renderTable() { + const groups = this.props.view.groupTrait.groupsDataList$.value; + if (groups) { + return html` +
+ ${repeat( + groups, + v => v.key, + group => { + return html` `; + } + )} + ${this.renderAddGroup(this.props.view.groupTrait)} +
+ `; + } + return html` `; + } + + override render() { + const vPadding = this.props.virtualPadding$.value; + const wrapperStyle = styleMap({ + marginLeft: `-${vPadding}px`, + marginRight: `-${vPadding}px`, + }); + const containerStyle = styleMap({ + paddingLeft: `${vPadding}px`, + paddingRight: `${vPadding}px`, + }); + return html` + ${renderUniLit(this.props.headerWidget, { + dataViewInstance: this.expose, + })} +
+
+
+ ${this.renderTable()} +
+
+
+ `; + } +} + +declare global { + interface HTMLElementTagNameMap { + 'affine-database-table': DataViewTable; + } +} diff --git a/blocksuite/affine/data-view/src/view-presets/table/renderer.ts b/blocksuite/affine/data-view/src/view-presets/table/renderer.ts new file mode 100644 index 0000000000..01fcd9d098 --- /dev/null +++ b/blocksuite/affine/data-view/src/view-presets/table/renderer.ts @@ -0,0 +1,11 @@ +import { createUniComponentFromWebComponent } from '../../core/utils/uni-component/uni-component.js'; +import { createIcon } from '../../core/utils/uni-icon.js'; +import { tableViewModel } from './define.js'; +import { MobileDataViewTable } from './mobile/table-view.js'; +import { DataViewTable } from './pc/table-view.js'; + +export const tableViewMeta = tableViewModel.createMeta({ + view: createUniComponentFromWebComponent(DataViewTable), + mobileView: createUniComponentFromWebComponent(MobileDataViewTable), + icon: createIcon('DatabaseTableViewIcon'), +}); diff --git a/blocksuite/affine/data-view/src/view-presets/table/stats/column-stats-bar.ts b/blocksuite/affine/data-view/src/view-presets/table/stats/column-stats-bar.ts new file mode 100644 index 0000000000..f936f5da27 --- /dev/null +++ b/blocksuite/affine/data-view/src/view-presets/table/stats/column-stats-bar.ts @@ -0,0 +1,55 @@ +import { ShadowlessElement } from '@blocksuite/block-std'; +import { SignalWatcher, WithDisposable } from '@blocksuite/global/utils'; +import { css, html } from 'lit'; +import { property } from 'lit/decorators.js'; +import { repeat } from 'lit/directives/repeat.js'; + +import type { GroupData } from '../../../core/group-by/trait.js'; +import { LEFT_TOOL_BAR_WIDTH, STATS_BAR_HEIGHT } from '../consts.js'; +import type { TableSingleView } from '../table-view-manager.js'; + +const styles = css` + .affine-database-column-stats { + width: 100%; + margin-left: ${LEFT_TOOL_BAR_WIDTH}px; + height: ${STATS_BAR_HEIGHT}px; + display: flex; + } +`; + +export class DataBaseColumnStats extends SignalWatcher( + WithDisposable(ShadowlessElement) +) { + static override styles = styles; + + protected override render() { + const cols = this.view.properties$.value; + + return html` +
+ ${repeat( + cols, + col => col.id, + col => { + return html``; + } + )} +
+ `; + } + + @property({ attribute: false }) + accessor group: GroupData | undefined = undefined; + + @property({ attribute: false }) + accessor view!: TableSingleView; +} + +declare global { + interface HTMLElementTagNameMap { + 'affine-database-column-stats': DataBaseColumnStats; + } +} diff --git a/blocksuite/affine/data-view/src/view-presets/table/stats/column-stats-column.ts b/blocksuite/affine/data-view/src/view-presets/table/stats/column-stats-column.ts new file mode 100644 index 0000000000..6b503d8e0c --- /dev/null +++ b/blocksuite/affine/data-view/src/view-presets/table/stats/column-stats-column.ts @@ -0,0 +1,242 @@ +import { + menu, + type MenuConfig, + popMenu, + popupTargetFromElement, +} from '@blocksuite/affine-components/context-menu'; +import { ShadowlessElement } from '@blocksuite/block-std'; +import { SignalWatcher, WithDisposable } from '@blocksuite/global/utils'; +import { ArrowDownSmallIcon } from '@blocksuite/icons/lit'; +import { Text } from '@blocksuite/store'; +import { autoPlacement, offset } from '@floating-ui/dom'; +import { computed, signal } from '@preact/signals-core'; +import { css, html } from 'lit'; +import { property } from 'lit/decorators.js'; +import { styleMap } from 'lit/directives/style-map.js'; + +import type { GroupData } from '../../../core/group-by/trait.js'; +import { typeSystem } from '../../../core/index.js'; +import { statsFunctions } from '../../../core/statistics/index.js'; +import type { StatisticsConfig } from '../../../core/statistics/types.js'; +import type { TableColumn } from '../table-view-manager.js'; + +const styles = css` + .stats-cell { + cursor: pointer; + transition: opacity 230ms ease; + font-size: 12px; + color: var(--affine-text-secondary-color); + display: flex; + opacity: 0; + justify-content: flex-end; + height: 100%; + align-items: center; + user-select: none; + } + + .affine-database-column-stats:hover .stats-cell { + opacity: 1; + } + + .stats-cell:hover, + affine-database-column-stats-cell.active .stats-cell { + opacity: 1; + background-color: var(--affine-hover-color); + cursor: pointer; + } + + .stats-cell[calculated='true'] { + opacity: 1; + } + + .stats-cell .content { + display: flex; + align-items: center; + justify-content: center; + gap: 0.2rem; + margin-inline: 5px; + } + + .label { + text-transform: uppercase; + color: var(--affine-text-secondary-color); + } + + .value { + color: var(--affine-text-primary-color); + } +`; + +export class DatabaseColumnStatsCell extends SignalWatcher( + WithDisposable(ShadowlessElement) +) { + static override styles = styles; + + @property({ attribute: false }) + accessor column!: TableColumn; + + cellValues$ = computed(() => { + if (this.group) { + return this.group.rows.map(id => { + return this.column.valueGet(id); + }); + } + return this.column.cells$.value.map(cell => cell.value$.value); + }); + + groups$ = computed(() => { + const groups: Record> = {}; + + statsFunctions.forEach(func => { + if (!typeSystem.unify(this.column.dataType$.value, func.dataType)) { + return; + } + if (!groups[func.group]) { + groups[func.group] = {}; + } + const oldFunc = groups[func.group][func.type]; + if (!oldFunc || typeSystem.unify(func.dataType, oldFunc.dataType)) { + if (!func.impl) { + delete groups[func.group][func.type]; + } else { + groups[func.group][func.type] = func; + } + } + }); + return groups; + }); + + openMenu = (ev: MouseEvent) => { + const menus: MenuConfig[] = Object.entries(this.groups$.value).map( + ([group, funcs]) => { + return menu.subMenu({ + name: group, + options: { + items: Object.values(funcs).map(func => { + return menu.action({ + isSelected: func.type === this.column.statCalcOp$.value, + name: func.menuName ?? func.type, + select: () => { + this.column.updateStatCalcOp(func.type); + }, + }); + }), + }, + }); + } + ); + popMenu(popupTargetFromElement(ev.currentTarget as HTMLElement), { + options: { + items: [ + menu.action({ + isSelected: !this.column.statCalcOp$.value, + name: 'None', + select: () => { + this.column.updateStatCalcOp(); + }, + }), + ...menus, + ], + }, + middleware: [ + autoPlacement({ allowedPlacements: ['top', 'bottom'] }), + offset(10), + ], + }); + }; + + statsFunc$ = computed(() => { + return Object.values(this.groups$.value) + .flatMap(group => Object.values(group)) + .find(func => func.type === this.column.statCalcOp$.value); + }); + + values$ = signal([]); + + statsResult$ = computed(() => { + const meta = this.column.view.propertyMetaGet(this.column.type$.value); + if (!meta) { + return null; + } + const func = this.statsFunc$.value; + if (!func) { + return null; + } + return { + name: func.displayName, + value: + func.impl?.(this.values$.value, { + meta, + dataSource: this.column.view.manager.dataSource, + }) ?? '', + }; + }); + + subscriptionMap = new Map void>(); + + override connectedCallback(): void { + super.connectedCallback(); + this.disposables.addFromEvent(this, 'click', this.openMenu); + this.disposables.add( + this.cellValues$.subscribe(values => { + const map = new Map void>(); + values.forEach(value => { + if (value instanceof Text) { + const unsub = this.subscriptionMap.get(value); + if (unsub) { + map.set(value, unsub); + this.subscriptionMap.delete(value); + } else { + const f = () => { + this.values$.value = [...this.cellValues$.value]; + }; + value.yText.observe(f); + map.set(value, () => { + value.yText.unobserve(f); + }); + } + } + }); + this.subscriptionMap.forEach(unsub => { + unsub(); + }); + this.subscriptionMap = map; + this.values$.value = this.cellValues$.value; + }) + ); + this.disposables.add(() => { + this.subscriptionMap.forEach(unsub => { + unsub(); + }); + }); + } + + protected override render() { + const style = { + width: `${this.column.width$.value}px`, + }; + return html`
+
+ ${!this.statsResult$.value + ? html`Calculate ${ArrowDownSmallIcon()}` + : html` + ${this.statsResult$.value.name} + ${this.statsResult$.value.value} + `} +
+
`; + } + + @property({ attribute: false }) + accessor group: GroupData | undefined = undefined; +} + +declare global { + interface HTMLElementTagNameMap { + 'affine-database-column-stats-cell': DatabaseColumnStatsCell; + } +} diff --git a/blocksuite/affine/data-view/src/view-presets/table/table-view-manager.ts b/blocksuite/affine/data-view/src/view-presets/table/table-view-manager.ts new file mode 100644 index 0000000000..6b34fdb89d --- /dev/null +++ b/blocksuite/affine/data-view/src/view-presets/table/table-view-manager.ts @@ -0,0 +1,401 @@ +import { + insertPositionToIndex, + type InsertToPosition, +} from '@blocksuite/affine-shared/utils'; +import { computed, type ReadonlySignal } from '@preact/signals-core'; + +import { evalFilter } from '../../core/filter/eval.js'; +import { generateDefaultValues } from '../../core/filter/generate-default-values.js'; +import { FilterTrait, filterTraitKey } from '../../core/filter/trait.js'; +import type { FilterGroup } from '../../core/filter/types.js'; +import { emptyFilterGroup } from '../../core/filter/utils.js'; +import { + GroupTrait, + groupTraitKey, + sortByManually, +} from '../../core/group-by/trait.js'; +import { SortManager, sortTraitKey } from '../../core/sort/manager.js'; +import { PropertyBase } from '../../core/view-manager/property.js'; +import { + type SingleView, + SingleViewBase, +} from '../../core/view-manager/single-view.js'; +import type { ViewManager } from '../../core/view-manager/view-manager.js'; +import { DEFAULT_COLUMN_MIN_WIDTH, DEFAULT_COLUMN_WIDTH } from './consts.js'; +import type { TableViewData } from './define.js'; +import type { StatCalcOpType } from './types.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, + }; + }); + }); + + private filter$ = computed(() => { + return this.data$.value?.filter ?? emptyFilterGroup; + }); + + private groupBy$ = computed(() => { + return this.data$.value?.groupBy; + }); + + private sortList$ = computed(() => { + return this.data$.value?.sort; + }); + + private sortManager = this.traitSet( + sortTraitKey, + new SortManager(this.sortList$, this, { + setSortList: sortList => { + this.dataUpdate(data => { + return { + sort: { + ...data.sort, + ...sortList, + }, + }; + }); + }, + }) + ); + + detailProperties$ = computed(() => { + return this.propertiesWithoutFilter$.value.filter( + id => this.propertyTypeGet(id) !== 'title' + ); + }); + + filterTrait = this.traitSet( + filterTraitKey, + new FilterTrait(this.filter$, this, { + filterSet: (filter: FilterGroup) => { + this.dataUpdate(() => { + return { + filter, + }; + }); + }, + }) + ); + + groupTrait = this.traitSet( + groupTraitKey, + new GroupTrait(this.groupBy$, this, { + groupBySet: groupBy => { + this.dataUpdate(() => { + return { + groupBy, + }; + }); + }, + sortGroup: ids => + sortByManually( + ids, + v => v, + this.groupProperties.map(v => v.key) + ), + sortRow: (key, ids) => { + const property = this.groupProperties.find(v => v.key === key); + return sortByManually(ids, v => v, property?.manuallyCardSort ?? []); + }, + changeGroupSort: keys => { + const map = new Map(this.groupProperties.map(v => [v.key, v])); + this.dataUpdate(() => { + return { + groupProperties: keys.map(key => { + const property = map.get(key); + if (property) { + return property; + } + return { + key, + hide: false, + manuallyCardSort: [], + }; + }), + }; + }); + }, + changeRowSort: (groupKeys, groupKey, keys) => { + const map = new Map(this.groupProperties.map(v => [v.key, v])); + this.dataUpdate(() => { + return { + groupProperties: groupKeys.map(key => { + if (key === groupKey) { + const group = map.get(key); + return group + ? { + ...group, + manuallyCardSort: keys, + } + : { + key, + hide: false, + manuallyCardSort: keys, + }; + } else { + return ( + map.get(key) ?? { + key, + hide: false, + manuallyCardSort: [], + } + ); + } + }), + }; + }); + }, + }) + ); + + mainProperties$ = computed(() => { + return ( + this.data$.value?.header ?? { + titleColumn: this.propertiesWithoutFilter$.value.find( + id => this.propertyTypeGet(id) === 'title' + ), + iconColumn: 'type', + } + ); + }); + + propertyIds$ = computed(() => { + return this.propertiesWithoutFilter$.value.filter( + id => !this.propertyHideGet(id) + ); + }); + + readonly$ = computed(() => { + return this.manager.readonly$.value; + }); + + get groupProperties() { + return this.data$.value?.groupProperties ?? []; + } + + get name(): string { + return this.data$.value?.name ?? ''; + } + + override get type(): string { + return this.data$.value?.mode ?? 'table'; + } + + constructor(viewManager: ViewManager, viewId: string) { + super(viewManager, viewId); + } + + 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 + ), + }; + }); + } + + isShow(rowId: string): boolean { + if (this.filter$.value?.conditions.length) { + const rowMap = Object.fromEntries( + this.properties$.value.map(column => [ + column.id, + column.cellGet(rowId).jsonValue$.value, + ]) + ); + return evalFilter(this.filter$.value, rowMap); + } + return true; + } + + minWidthGet(type: string): number { + return ( + this.propertyMetaGet(type)?.config.minWidth ?? DEFAULT_COLUMN_MIN_WIDTH + ); + } + + 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); + + const filter = this.filter$.value; + if (filter.conditions.length > 0) { + const defaultValues = generateDefaultValues(filter, this.vars$.value); + Object.entries(defaultValues).forEach(([propertyId, jsonValue]) => { + const property = this.propertyGet(propertyId); + const propertyMeta = this.propertyMetaGet(property.type$.value); + if (propertyMeta?.config.cellFromJson) { + const value = propertyMeta.config.cellFromJson({ + value: jsonValue, + data: property.data$.value, + dataSource: this.dataSource, + }); + this.cellValueSet(id, propertyId, value); + } + }); + } + + if (groupKey) { + this.groupTrait.addToGroup(id, groupKey); + } + return id; + } + + override rowMove( + rowId: string, + position: InsertToPosition, + fromGroup?: string, + toGroup?: string + ) { + if (toGroup == null) { + super.rowMove(rowId, position); + return; + } + this.groupTrait.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]; + } + + override rowsMapping(rows: string[]) { + return this.sortManager.sort(super.rowsMapping(rows)); + } +} + +export class TableColumn extends PropertyBase { + statCalcOp$ = computed(() => { + return this.tableView.columnGetStatCalcOp(this.id); + }); + + width$: ReadonlySignal = computed(() => { + return this.tableView.columnGetWidth(this.id); + }); + + get minWidth() { + return this.tableView.minWidthGet(this.type$.value); + } + + 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/blocksuite/affine/data-view/src/view-presets/table/types.ts b/blocksuite/affine/data-view/src/view-presets/table/types.ts new file mode 100644 index 0000000000..066ac257c3 --- /dev/null +++ b/blocksuite/affine/data-view/src/view-presets/table/types.ts @@ -0,0 +1,117 @@ +export type ColumnType = string; + +export interface Column< + Data extends Record = Record, +> { + id: string; + type: ColumnType; + name: string; + data: Data; +} + +export type StatCalcOpType = string | undefined; + +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/blocksuite/affine/data-view/src/widget-presets/index.ts b/blocksuite/affine/data-view/src/widget-presets/index.ts new file mode 100644 index 0000000000..4f082c1c52 --- /dev/null +++ b/blocksuite/affine/data-view/src/widget-presets/index.ts @@ -0,0 +1,10 @@ +import { widgetQuickSettingBar } from './quick-setting-bar/index.js'; +import { createWidgetTools, toolsWidgetPresets } from './tools/index.js'; +import { widgetViewsBar } from './views-bar/index.js'; + +export const widgetPresets = { + viewBar: widgetViewsBar, + quickSettingBar: widgetQuickSettingBar, + createTools: createWidgetTools, + tools: toolsWidgetPresets, +}; diff --git a/blocksuite/affine/data-view/src/widget-presets/quick-setting-bar/context.ts b/blocksuite/affine/data-view/src/widget-presets/quick-setting-bar/context.ts new file mode 100644 index 0000000000..af25383151 --- /dev/null +++ b/blocksuite/affine/data-view/src/widget-presets/quick-setting-bar/context.ts @@ -0,0 +1,7 @@ +import { type Signal, signal } from '@preact/signals-core'; + +import { createContextKey } from '../../core/index.js'; + +export const ShowQuickSettingBarContextKey = createContextKey< + Signal> +>('show-quick-setting-bar', signal({})); diff --git a/blocksuite/affine/data-view/src/widget-presets/quick-setting-bar/filter/condition-view.ts b/blocksuite/affine/data-view/src/widget-presets/quick-setting-bar/filter/condition-view.ts new file mode 100644 index 0000000000..d835c78d8a --- /dev/null +++ b/blocksuite/affine/data-view/src/widget-presets/quick-setting-bar/filter/condition-view.ts @@ -0,0 +1,308 @@ +import { + menu, + popFilterableSimpleMenu, + popMenu, + type PopupTarget, + popupTargetFromElement, + subMenuMiddleware, +} from '@blocksuite/affine-components/context-menu'; +import { ShadowlessElement } from '@blocksuite/block-std'; +import { SignalWatcher } from '@blocksuite/global/utils'; +import { + ArrowDownSmallIcon, + ArrowRightSmallIcon, + DeleteIcon, +} from '@blocksuite/icons/lit'; +import { computed, type ReadonlySignal } from '@preact/signals-core'; +import { css, html } from 'lit'; +import { property } from 'lit/decorators.js'; + +import { getRefType } from '../../../core/expression/ref/ref.js'; +import type { Variable } from '../../../core/expression/types.js'; +import { filterMatcher } from '../../../core/filter/filter-fn/matcher.js'; +import { literalItemsMatcher } from '../../../core/filter/literal/index.js'; +import type { Filter, SingleFilter } from '../../../core/filter/types.js'; +import { + renderUniLit, + t, + type TypeInstance, + typeSystem, +} from '../../../core/index.js'; + +export class FilterConditionView extends SignalWatcher(ShadowlessElement) { + static override styles = css` + filter-condition-view { + } + + .filter-condition-expression { + display: flex; + align-items: center; + gap: 4px; + } + + .filter-condition-delete { + border-radius: 4px; + display: flex; + align-items: center; + justify-content: center; + height: max-content; + cursor: pointer; + } + + .filter-condition-delete:hover { + background-color: var(--affine-hover-color); + } + + .filter-condition-delete svg { + width: 16px; + height: 16px; + } + + .filter-condition-function-name { + font-size: 12px; + line-height: 20px; + color: var(--affine-text-secondary-color); + padding: 2px 8px; + border-radius: 4px; + cursor: pointer; + } + + .filter-condition-function-name:hover { + background-color: var(--affine-hover-color); + } + + .filter-condition-arg { + font-size: 12px; + font-style: normal; + font-weight: 600; + padding: 0 4px; + height: 100%; + display: flex; + align-items: center; + } + `; + + private onClickButton = (evt: Event) => { + this.popConditionEdit( + popupTargetFromElement(evt.currentTarget as HTMLElement) + ); + }; + + private popConditionEdit = (target: PopupTarget) => { + const type = this.leftVar$.value?.type; + if (!type) { + return; + } + const fn = this.fnConfig$.value; + if (!fn) { + popFilterableSimpleMenu(target, this.getFunctionItems(target)); + return; + } + const handler = popMenu(target, { + options: { + items: [ + menu.group({ + items: [ + menu.action({ + name: fn.label, + postfix: ArrowRightSmallIcon(), + select: ele => { + popMenu(popupTargetFromElement(ele), { + options: { + items: [ + menu.group({ + items: this.getFunctionItems(target, () => { + handler.close(); + }), + }), + ], + }, + middleware: subMenuMiddleware, + }); + return false; + }, + }), + ], + }), + menu.dynamic(() => this.getArgsItems()), + menu.group({ + items: [ + menu.action({ + name: 'Delete', + class: { 'delete-item': true }, + prefix: DeleteIcon(), + select: () => { + const list = this.value.value.slice(); + list.splice(this.index, 1); + this.onChange(list); + }, + }), + ], + }), + ], + }, + }); + }; + + @property({ attribute: false }) + accessor value!: ReadonlySignal; + + filter$ = computed(() => { + const filter = this.value.value[this.index]; + if (!filter || filter.type !== 'filter') { + return; + } + return filter; + }); + + args$ = computed(() => { + return this.filter$.value?.args.map(v => v.value); + }); + + fnConfig$ = computed(() => { + return filterMatcher.getFilterByName(this.filter$.value?.function); + }); + + @property({ attribute: false }) + accessor vars!: ReadonlySignal; + + fnType$ = computed(() => { + const fnConfig = this.fnConfig$.value; + const filter = this.filter$.value; + if (!fnConfig || !filter) { + return; + } + const refType = getRefType(this.vars.value, filter.left); + if (!refType) { + return; + } + const fnTemplate = t.fn.instance( + [fnConfig.self, ...fnConfig.args], + t.boolean.instance(), + fnConfig.vars + ); + return typeSystem.instanceFn( + fnTemplate, + [refType], + t.boolean.instance(), + {} + ); + }); + + getFunctionItems = (target: PopupTarget, onSelect?: () => void) => { + const filter = this.filter$.value; + if (!filter) { + return []; + } + const type = getRefType(this.vars.value, filter?.left); + if (!type) { + return []; + } + return filterMatcher.filterListBySelfType(type).map(v => { + const selected = v.name === filter.function; + return menu.action({ + name: v.label, + isSelected: selected, + select: () => { + this.setFilter({ + ...filter, + function: v.name, + }); + onSelect?.(); + this.popConditionEdit(target); + }, + }); + }); + }; + + leftVar$ = computed(() => { + return this.vars.value.find(v => v.id === this.filter$.value?.left.name); + }); + + setFilter = (filter: SingleFilter) => { + const list = this.value.value.slice(); + list[this.index] = filter; + this.onChange(list); + }; + + text$ = computed(() => { + const name = this.leftVar$.value?.name ?? ''; + const data = this.fnConfig$.value; + const type = this.fnType$.value; + const argValues = this.args$.value; + if (!type || !argValues || !data) { + return; + } + const argDataList = argValues.map((v, i) => + v ? { value: v, type: type.args[i + 1] } : undefined + ); + const valueString = data.shortString?.(...argDataList) ?? ''; + if (valueString) { + return `${name}${valueString}`; + } + return name; + }); + + private getArgItems(argType: TypeInstance, index: number) { + return literalItemsMatcher.getItems( + argType, + computed(() => { + return this.filter$.value?.args[index]?.value; + }), + value => { + const filter = this.filter$.value; + if (!filter) { + return; + } + const args = filter.args.slice(); + args[index] = { type: 'literal', value }; + this.setFilter({ + ...filter, + args: args, + }); + } + ); + } + + private getArgsItems() { + return ( + this.fnType$.value?.args + .slice(1) + .flatMap((arg, i) => this.getArgItems(arg, i)) ?? [] + ); + } + + override render() { + const leftVar = this.leftVar$.value; + if (!leftVar) { + return html` `; + } + return html` + ${this.text$.value}`}" + .postfix="${ArrowDownSmallIcon()}" + > + `; + } + + @property({ attribute: false }) + accessor index!: number; + + @property({ attribute: false }) + accessor onChange!: (filters: Filter[]) => void; +} + +declare global { + interface HTMLElementTagNameMap { + 'filter-condition-view': FilterConditionView; + } +} diff --git a/blocksuite/affine/data-view/src/widget-presets/quick-setting-bar/filter/group-panel-view.ts b/blocksuite/affine/data-view/src/widget-presets/quick-setting-bar/filter/group-panel-view.ts new file mode 100644 index 0000000000..2b7312126d --- /dev/null +++ b/blocksuite/affine/data-view/src/widget-presets/quick-setting-bar/filter/group-panel-view.ts @@ -0,0 +1,476 @@ +import { + menu, + popFilterableSimpleMenu, + popMenu, + type PopupTarget, + popupTargetFromElement, +} from '@blocksuite/affine-components/context-menu'; +import { ShadowlessElement } from '@blocksuite/block-std'; +import { SignalWatcher } from '@blocksuite/global/utils'; +import { + ArrowDownSmallIcon, + ConvertIcon, + DeleteIcon, + DuplicateIcon, + MoreHorizontalIcon, + PlusIcon, +} from '@blocksuite/icons/lit'; +import { computed, type ReadonlySignal } from '@preact/signals-core'; +import { css, html, nothing, type TemplateResult } 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 { Variable } from '../../../core/expression/types.js'; +import type { Filter, FilterGroup } from '../../../core/filter/types.js'; +import { firstFilter, firstFilterInGroup } from '../../../core/filter/utils.js'; + +export const popAddNewFilter = ( + target: PopupTarget, + props: { + value: FilterGroup; + onChange: (value: FilterGroup) => void; + vars: Variable[]; + } +) => { + popFilterableSimpleMenu(target, [ + menu.action({ + name: 'Add filter', + select: () => { + props.onChange({ + ...props.value, + conditions: [...props.value.conditions, firstFilter(props.vars)], + }); + }, + }), + menu.action({ + name: 'Add filter group', + select: () => { + props.onChange({ + ...props.value, + conditions: [ + ...props.value.conditions, + firstFilterInGroup(props.vars), + ], + }); + }, + }), + ]); +}; + +export class FilterGroupView extends SignalWatcher(ShadowlessElement) { + static override styles = css` + filter-group-view { + border-radius: 4px; + display: flex; + flex-direction: column; + user-select: none; + } + + .filter-group-op { + width: 60px; + display: flex; + justify-content: end; + padding: 4px; + height: 34px; + align-items: center; + font-size: 14px; + font-style: normal; + font-weight: 400; + line-height: 22px; + color: var(--affine-text-primary-color); + } + + .filter-group-op-clickable { + border-radius: 4px; + cursor: pointer; + } + + .filter-group-op-clickable:hover { + background-color: var(--affine-hover-color); + } + + .filter-group-container { + display: flex; + flex-direction: column; + gap: 2px; + } + + .filter-group-button { + padding: 8px 12px; + display: flex; + align-items: center; + gap: 6px; + font-size: 14px; + line-height: 22px; + border-radius: 4px; + cursor: pointer; + color: var(--affine-text-secondary-color); + } + + .filter-group-button svg { + fill: var(--affine-text-secondary-color); + color: var(--affine-text-secondary-color); + width: 20px; + height: 20px; + } + + .filter-group-button:hover { + background-color: var(--affine-hover-color); + color: var(--affine-text-primary-color); + } + + .filter-group-button:hover svg { + fill: var(--affine-text-primary-color); + color: var(--affine-text-primary-color); + } + + .filter-group-item { + padding: 4px 0; + display: flex; + align-items: start; + gap: 8px; + } + + .filter-group-item-ops { + margin-top: 4px; + padding: 4px; + border-radius: 4px; + height: max-content; + display: flex; + cursor: pointer; + } + + .filter-group-item-ops:hover { + background-color: var(--affine-hover-color); + } + + .filter-group-item-ops svg { + fill: var(--affine-text-secondary-color); + color: var(--affine-text-secondary-color); + width: 18px; + height: 18px; + } + + .filter-group-item-ops:hover svg { + fill: var(--affine-text-primary-color); + color: var(--affine-text-primary-color); + } + + .delete-style { + background-color: var(--affine-background-error-color); + } + + .filter-group-border { + border: 1px dashed var(--affine-border-color); + } + + .filter-group-bg-1 { + background-color: var(--affine-background-secondary-color); + border: 1px solid var(--affine-border-color); + } + + .filter-group-bg-2 { + background-color: var(--affine-background-tertiary-color); + border: 1px solid var(--affine-border-color); + } + + .hover-style { + background-color: var(--affine-hover-color); + } + + .delete-style { + background-color: var(--affine-background-error-color); + } + `; + + private _addNew = (e: MouseEvent) => { + if (this.isMaxDepth) { + this.onChange({ + ...this.filterGroup.value, + conditions: [ + ...this.filterGroup.value.conditions, + firstFilter(this.vars.value), + ], + }); + return; + } + popAddNewFilter(popupTargetFromElement(e.currentTarget as HTMLElement), { + value: this.filterGroup.value, + onChange: this.onChange, + vars: this.vars.value, + }); + }; + + private _selectOp = (event: MouseEvent) => { + popFilterableSimpleMenu( + popupTargetFromElement(event.currentTarget as HTMLElement), + [ + menu.action({ + name: 'And', + select: () => { + this.onChange({ + ...this.filterGroup.value, + op: 'and', + }); + }, + }), + menu.action({ + name: 'Or', + select: () => { + this.onChange({ + ...this.filterGroup.value, + op: 'or', + }); + }, + }), + ] + ); + }; + + private _setFilter = (index: number, filter: Filter) => { + this.onChange({ + ...this.filterGroup.value, + conditions: this.filterGroup.value.conditions.map((v, i) => + index === i ? filter : v + ), + }); + }; + + private opMap = { + and: 'And', + or: 'Or', + }; + + @property({ attribute: false }) + accessor filterGroup!: ReadonlySignal; + + conditions$ = computed(() => { + return this.filterGroup.value.conditions; + }); + + setConditions = (conditions: Filter[]) => { + this.onChange({ + ...this.filterGroup.value, + conditions: conditions, + }); + }; + + private get isMaxDepth() { + return this.depth === 3; + } + + private _clickConditionOps(target: HTMLElement, i: number) { + const filter = this.filterGroup.value.conditions[i]; + popFilterableSimpleMenu(popupTargetFromElement(target), [ + menu.group({ + items: [ + menu.action({ + name: + filter.type === 'filter' ? 'Turn into group' : 'Wrap in group', + prefix: ConvertIcon(), + onHover: hover => { + this.containerClass = hover + ? { index: i, class: 'hover-style' } + : undefined; + }, + hide: () => this.depth + getDepth(filter) > 3, + select: () => { + this.onChange({ + type: 'group', + op: 'and', + conditions: [this.filterGroup.value], + }); + }, + }), + menu.action({ + name: 'Duplicate', + prefix: DuplicateIcon(), + onHover: hover => { + this.containerClass = hover + ? { index: i, class: 'hover-style' } + : undefined; + }, + select: () => { + const conditions = [...this.filterGroup.value.conditions]; + conditions.splice( + i + 1, + 0, + JSON.parse(JSON.stringify(conditions[i])) + ); + this.onChange({ + ...this.filterGroup.value, + conditions: conditions, + }); + }, + }), + ], + }), + menu.group({ + name: '', + items: [ + menu.action({ + name: 'Delete', + prefix: DeleteIcon(), + class: { 'delete-item': true }, + onHover: hover => { + this.containerClass = hover + ? { index: i, class: 'delete-style' } + : undefined; + }, + select: () => { + const conditions = [...this.filterGroup.value.conditions]; + conditions.splice(i, 1); + this.onChange({ + ...this.filterGroup.value, + conditions, + }); + }, + }), + ], + }), + ]); + } + + override render() { + const data = this.filterGroup.value; + return html` +
+ ${repeat(data.conditions, (filter, i) => { + const clickOps = (e: MouseEvent) => { + e.stopPropagation(); + e.preventDefault(); + this._clickConditionOps(e.target as HTMLElement, i); + }; + let op: TemplateResult; + if (i === 0) { + op = html`
Where
`; + } else { + op = html` +
+ ${this.opMap[data.op]} +
+ `; + } + const classList = classMap({ + 'filter-root-item': true, + 'filter-exactly-hover-container': true, + 'dv-pd-4 dv-round-4': true, + [this.containerClass?.class ?? '']: + this.containerClass?.index === i, + }); + const groupClassList = classMap({ + [`filter-group-bg-${this.depth}`]: filter.type !== 'filter', + }); + return html`
+ ${op} +
+ ${filter.type === 'filter' + ? html` + + ` + : html` + + `} +
+ ${MoreHorizontalIcon()} +
+
+
`; + })} +
+
+ ${PlusIcon()} Add ${this.isMaxDepth ? nothing : ArrowDownSmallIcon()} +
+ `; + } + + @state() + accessor containerClass: + | { + index: number; + class: string; + } + | undefined = undefined; + + @property({ attribute: false }) + accessor depth = 1; + + @property({ attribute: false }) + accessor onChange!: (filter: FilterGroup) => void; + + @property({ attribute: false }) + accessor vars!: ReadonlySignal; +} + +declare global { + interface HTMLElementTagNameMap { + 'filter-group-view': FilterGroupView; + } +} +export const getDepth = (filter: Filter): number => { + if (filter.type === 'filter') { + return 1; + } + return Math.max(...filter.conditions.map(getDepth)) + 1; +}; +export const popFilterGroup = ( + target: PopupTarget, + props: { + vars: ReadonlySignal; + value$: ReadonlySignal; + onChange: (value?: FilterGroup) => void; + onBack?: () => void; + } +) => { + popMenu(target, { + options: { + title: { + text: 'Filter group', + onBack: props.onBack, + }, + items: [ + menu.group({ + items: [ + () => { + return html` `; + }, + ], + }), + menu.group({ + items: [ + menu.action({ + name: 'Delete', + class: { 'delete-item': true }, + prefix: DeleteIcon(), + select: () => { + props.onChange(); + }, + }), + ], + }), + ], + }, + }); +}; diff --git a/blocksuite/affine/data-view/src/widget-presets/quick-setting-bar/filter/index.ts b/blocksuite/affine/data-view/src/widget-presets/quick-setting-bar/filter/index.ts new file mode 100644 index 0000000000..e109e8d453 --- /dev/null +++ b/blocksuite/affine/data-view/src/widget-presets/quick-setting-bar/filter/index.ts @@ -0,0 +1,20 @@ +import { IS_MOBILE } from '@blocksuite/global/env'; +import { html } from 'lit'; + +import { filterTraitKey } from '../../../core/filter/trait.js'; +import type { DataViewWidgetProps } from '../../../core/widget/types.js'; + +export const renderFilterBar = (props: DataViewWidgetProps) => { + const filterTrait = props.dataViewInstance.view.traitGet(filterTraitKey); + if (!filterTrait) { + return; + } + if (!IS_MOBILE && !filterTrait.hasFilter$.value) { + return; + } + return html` `; +}; diff --git a/blocksuite/affine/data-view/src/widget-presets/quick-setting-bar/filter/list-view.ts b/blocksuite/affine/data-view/src/widget-presets/quick-setting-bar/filter/list-view.ts new file mode 100644 index 0000000000..412fba0a0b --- /dev/null +++ b/blocksuite/affine/data-view/src/widget-presets/quick-setting-bar/filter/list-view.ts @@ -0,0 +1,215 @@ +import { + type PopupTarget, + popupTargetFromElement, +} from '@blocksuite/affine-components/context-menu'; +import { ShadowlessElement } from '@blocksuite/block-std'; +import { SignalWatcher } from '@blocksuite/global/utils'; +import { + ArrowDownSmallIcon, + FilterIcon, + PlusIcon, +} from '@blocksuite/icons/lit'; +import { computed, type ReadonlySignal } from '@preact/signals-core'; +import { css, html } from 'lit'; +import { property } from 'lit/decorators.js'; + +import type { Variable } from '../../../core/expression/types.js'; +import type { Filter, FilterGroup } from '../../../core/filter/types.js'; +import { popCreateFilter } from '../../../core/index.js'; +import { popFilterGroup } from './group-panel-view.js'; + +export class FilterBar extends SignalWatcher(ShadowlessElement) { + static override styles = css` + filter-bar { + display: flex; + gap: 8px; + overflow-x: scroll; + margin-bottom: -10px; + padding-bottom: 2px; + align-items: center; + } + + .filter-group-tag { + font-size: 12px; + font-style: normal; + font-weight: 600; + line-height: 20px; + display: flex; + align-items: center; + padding: 4px; + background-color: var(--affine-white); + } + + .filter-bar-add-filter { + white-space: nowrap; + color: var(--affine-text-secondary-color); + padding: 4px 8px; + display: flex; + align-items: center; + gap: 6px; + font-size: 14px; + font-style: normal; + font-weight: 400; + line-height: 22px; + } + + filter-bar::-webkit-scrollbar { + -webkit-appearance: none; + display: block; + } + + filter-bar::-webkit-scrollbar-thumb { + border-radius: 2px; + background-color: transparent; + } + + filter-bar::-webkit-scrollbar:horizontal { + height: 8px; + } + + filter-bar:hover::-webkit-scrollbar-thumb { + border-radius: 16px; + background-color: var(--affine-black-30); + } + + filter-bar:hover::-webkit-scrollbar-track { + //background-color: var(--affine-hover-color); + } + `; + + private _setFilter = (index: number, filter: Filter) => { + this.onChange({ + ...this.filterGroup.value, + conditions: this.filterGroup.value.conditions.map((v, i) => + index === i ? filter : v + ), + }); + }; + + private addFilter = (e: MouseEvent) => { + const element = popupTargetFromElement(e.target as HTMLElement); + popCreateFilter(element, { + vars: this.vars, + onSelect: filter => { + const index = this.filterGroup.value.conditions.length; + this.onChange({ + ...this.filterGroup.value, + conditions: [...this.filterGroup.value.conditions, filter], + }); + requestAnimationFrame(() => { + this.expandGroup(element, index); + }); + }, + }); + }; + + private expandGroup = (position: PopupTarget, i: number) => { + if (this.filterGroup.value.conditions[i]?.type !== 'group') { + return; + } + popFilterGroup(position, { + vars: this.vars, + value$: computed(() => { + return this.filterGroup.value.conditions[i] as FilterGroup; + }), + onChange: filter => { + if (filter) { + this._setFilter(i, filter); + } else { + this.deleteFilter(i); + } + }, + }); + }; + + @property({ attribute: false }) + accessor filterGroup!: ReadonlySignal; + + conditions$ = computed(() => { + return this.filterGroup.value.conditions; + }); + + renderAddFilter = () => { + return html`
+ ${PlusIcon()} Add filter +
`; + }; + + setConditions = (conditions: Filter[]) => { + this.onChange({ + ...this.filterGroup.value, + conditions: conditions, + }); + }; + + updateMoreFilterPanel?: () => void; + + private deleteFilter(i: number) { + this.onChange({ + ...this.filterGroup.value, + conditions: this.filterGroup.value.conditions.filter( + (_, index) => index !== i + ), + }); + } + + override render() { + return html` ${this.renderFilters()} ${this.renderAddFilter()} `; + } + + renderCondition(i: number) { + const condition = this.conditions$.value[i]; + if (!condition) { + return; + } + if (condition.type === 'filter') { + return html` `; + } + const expandGroup = (e: MouseEvent) => { + this.expandGroup( + popupTargetFromElement(e.currentTarget as HTMLElement), + i + ); + }; + const length = condition.conditions.length; + const text = length > 1 ? `${length} rules` : `${length} rule`; + return html` `; + } + + renderFilters() { + return this.filterGroup.value.conditions.map((_, i) => + this.renderCondition(i) + ); + } + + override updated() { + this.updateMoreFilterPanel?.(); + } + + @property({ attribute: false }) + accessor onChange!: (filter: FilterGroup) => void; + + @property({ attribute: false }) + accessor vars!: ReadonlySignal; +} + +declare global { + interface HTMLElementTagNameMap { + 'filter-bar': FilterBar; + } +} diff --git a/blocksuite/affine/data-view/src/widget-presets/quick-setting-bar/filter/root-panel-view.ts b/blocksuite/affine/data-view/src/widget-presets/quick-setting-bar/filter/root-panel-view.ts new file mode 100644 index 0000000000..5e89c10b73 --- /dev/null +++ b/blocksuite/affine/data-view/src/widget-presets/quick-setting-bar/filter/root-panel-view.ts @@ -0,0 +1,426 @@ +import { + menu, + popFilterableSimpleMenu, + popMenu, + type PopupTarget, + popupTargetFromElement, + subMenuMiddleware, +} from '@blocksuite/affine-components/context-menu'; +import { ShadowlessElement } from '@blocksuite/block-std'; +import { SignalWatcher } from '@blocksuite/global/utils'; +import { + ArrowDownSmallIcon, + ConvertIcon, + DeleteIcon, + DuplicateIcon, + FilterIcon, + MoreHorizontalIcon, + PlusIcon, +} from '@blocksuite/icons/lit'; +import { computed, type ReadonlySignal } from '@preact/signals-core'; +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 { Variable } from '../../../core/expression/types.js'; +import type { FilterTrait } from '../../../core/filter/trait.js'; +import type { Filter, FilterGroup } from '../../../core/filter/types.js'; +import { popCreateFilter } from '../../../core/index.js'; +import { + type FilterGroupView, + getDepth, + popFilterGroup, +} from './group-panel-view.js'; + +export class FilterRootView extends SignalWatcher(ShadowlessElement) { + static override styles = css` + .filter-root-title { + padding: 12px; + font-size: 14px; + font-weight: 600; + line-height: 22px; + color: var(--affine-text-primary-color); + } + + .filter-root-op { + width: 60px; + display: flex; + justify-content: end; + padding: 4px; + height: 34px; + align-items: center; + } + + .filter-root-op-clickable { + border-radius: 4px; + cursor: pointer; + } + + .filter-root-op-clickable:hover { + background-color: var(--affine-hover-color); + } + + .filter-root-container { + display: flex; + flex-direction: column; + gap: 4px; + max-height: 400px; + overflow: auto; + } + + .filter-root-button { + margin: 4px 8px 8px; + padding: 8px 12px; + display: flex; + align-items: center; + gap: 6px; + font-size: 14px; + line-height: 22px; + border-radius: 4px; + cursor: pointer; + color: var(--affine-text-secondary-color); + } + + .filter-root-button svg { + fill: var(--affine-text-secondary-color); + color: var(--affine-text-secondary-color); + width: 20px; + height: 20px; + } + + .filter-root-button:hover { + background-color: var(--affine-hover-color); + color: var(--affine-text-primary-color); + } + + .filter-root-button:hover svg { + fill: var(--affine-text-primary-color); + color: var(--affine-text-primary-color); + } + + .filter-root-item { + padding: 4px 0; + display: flex; + align-items: start; + gap: 8px; + } + + .filter-group-title { + font-size: 14px; + font-style: normal; + font-weight: 500; + line-height: 22px; + display: flex; + align-items: center; + color: var(--affine-text-primary-color); + gap: 6px; + } + + .filter-root-item-ops { + margin-top: 2px; + padding: 4px; + border-radius: 4px; + height: max-content; + display: flex; + cursor: pointer; + } + + .filter-root-item-ops:hover { + background-color: var(--affine-hover-color); + } + + .filter-root-item-ops svg { + fill: var(--affine-text-secondary-color); + color: var(--affine-text-secondary-color); + width: 18px; + height: 18px; + } + + .filter-root-item-ops:hover svg { + fill: var(--affine-text-primary-color); + color: var(--affine-text-primary-color); + } + + .filter-root-grabber { + cursor: grab; + width: 4px; + height: 12px; + background-color: var(--affine-placeholder-color); + border-radius: 1px; + } + + .divider { + height: 1px; + background-color: var(--affine-divider-color); + flex-shrink: 0; + margin: 8px 0; + } + `; + + private _setFilter = (index: number, filter: Filter) => { + this.onChange({ + ...this.filterGroup.value, + conditions: this.filterGroup.value.conditions.map((v, i) => + index === i ? filter : v + ), + }); + }; + + private expandGroup = (position: PopupTarget, i: number) => { + if (this.filterGroup.value.conditions[i]?.type !== 'group') { + return; + } + popFilterGroup(position, { + vars: this.vars, + value$: computed(() => { + return this.filterGroup.value.conditions[i] as FilterGroup; + }), + onChange: filter => { + if (filter) { + this._setFilter(i, filter); + } else { + this.deleteFilter(i); + } + }, + }); + }; + + @property({ attribute: false }) + accessor filterGroup!: ReadonlySignal; + + conditions$ = computed(() => { + return this.filterGroup.value.conditions; + }); + + setConditions = (conditions: Filter[]) => { + this.onChange({ + ...this.filterGroup.value, + conditions: conditions, + }); + }; + + private _clickConditionOps(target: HTMLElement, i: number) { + const filter = this.filterGroup.value.conditions[i]; + popFilterableSimpleMenu(popupTargetFromElement(target), [ + menu.action({ + name: filter.type === 'filter' ? 'Turn into group' : 'Wrap in group', + prefix: ConvertIcon(), + onHover: hover => { + this.containerClass = hover + ? { index: i, class: 'hover-style' } + : undefined; + }, + hide: () => getDepth(filter) > 3, + select: () => { + this.onChange({ + type: 'group', + op: 'and', + conditions: [this.filterGroup.value], + }); + }, + }), + menu.action({ + name: 'Duplicate', + prefix: DuplicateIcon(), + onHover: hover => { + this.containerClass = hover + ? { index: i, class: 'hover-style' } + : undefined; + }, + select: () => { + const conditions = [...this.filterGroup.value.conditions]; + conditions.splice( + i + 1, + 0, + JSON.parse(JSON.stringify(conditions[i])) + ); + this.onChange({ ...this.filterGroup.value, conditions: conditions }); + }, + }), + menu.group({ + name: '', + items: [ + menu.action({ + name: 'Delete', + prefix: DeleteIcon(), + class: { 'delete-item': true }, + onHover: hover => { + this.containerClass = hover + ? { index: i, class: 'delete-style' } + : undefined; + }, + select: () => { + const conditions = [...this.filterGroup.value.conditions]; + conditions.splice(i, 1); + this.onChange({ + ...this.filterGroup.value, + conditions, + }); + }, + }), + ], + }), + ]); + } + + private deleteFilter(i: number) { + this.onChange({ + ...this.filterGroup.value, + conditions: this.filterGroup.value.conditions.filter( + (_, index) => index !== i + ), + }); + } + + override render() { + const data = this.filterGroup.value; + return html` +
+ ${repeat(data.conditions, (_, i) => { + const clickOps = (e: MouseEvent) => { + e.stopPropagation(); + e.preventDefault(); + this._clickConditionOps(e.target as HTMLElement, i); + }; + const ops = html` +
+ ${MoreHorizontalIcon()} +
+ `; + const content = html` +
+
+
+ ${this.renderCondition(i)} +
+ ${ops} +
+ `; + const classList = classMap({ + 'filter-root-item': true, + 'filter-exactly-hover-container': true, + 'dv-pd-4 dv-round-4': true, + [this.containerClass?.class ?? '']: + this.containerClass?.index === i, + }); + return html`
+ ${content} +
`; + })} +
+ `; + } + + renderCondition(i: number) { + const condition = this.conditions$.value[i]; + if (!condition) { + return; + } + if (condition.type === 'filter') { + return html` `; + } + const expandGroup = (e: MouseEvent) => { + this.expandGroup( + popupTargetFromElement(e.currentTarget as HTMLElement), + i + ); + }; + const length = condition.conditions.length; + const text = length > 1 ? `${length} rules` : `${length} rule`; + return html` `; + } + + @state() + accessor containerClass: + | { + index: number; + class: string; + } + | undefined = undefined; + + @property({ attribute: false }) + accessor onBack!: () => void; + + @property({ attribute: false }) + accessor onChange!: (filter: FilterGroup) => void; + + @property({ attribute: false }) + accessor vars!: ReadonlySignal; +} + +declare global { + interface HTMLElementTagNameMap { + 'filter-root-view': FilterGroupView; + } +} +export const popFilterRoot = ( + target: PopupTarget, + props: { + filterTrait: FilterTrait; + onBack: () => void; + } +) => { + const filterTrait = props.filterTrait; + const view = filterTrait.view; + popMenu(target, { + options: { + title: { + text: 'Filters', + onBack: props.onBack, + }, + items: [ + menu.group({ + items: [ + () => { + return html` `; + }, + ], + }), + menu.group({ + items: [ + menu.action({ + name: 'Add', + prefix: PlusIcon(), + select: ele => { + const value = filterTrait.filter$.value; + popCreateFilter( + popupTargetFromElement(ele), + { + vars: view.vars$, + onSelect: filter => { + filterTrait.filterSet({ + ...value, + conditions: [...value.conditions, filter], + }); + }, + }, + { middleware: subMenuMiddleware } + ); + return false; + }, + }), + ], + }), + ], + }, + }); +}; diff --git a/blocksuite/affine/data-view/src/widget-presets/quick-setting-bar/index.ts b/blocksuite/affine/data-view/src/widget-presets/quick-setting-bar/index.ts new file mode 100644 index 0000000000..2cde6a1fef --- /dev/null +++ b/blocksuite/affine/data-view/src/widget-presets/quick-setting-bar/index.ts @@ -0,0 +1,44 @@ +import { unsafeCSSVarV2 } from '@blocksuite/affine-shared/theme'; +import { IS_MOBILE } from '@blocksuite/global/env'; +import { html, nothing } from 'lit'; + +import { + type DataViewWidgetProps, + defineUniComponent, +} from '../../core/index.js'; +import { ShowQuickSettingBarContextKey } from './context.js'; +import { renderFilterBar } from './filter/index.js'; +import { renderSortBar } from './sort/index.js'; + +export const widgetQuickSettingBar = defineUniComponent( + (props: DataViewWidgetProps) => { + const view = props.dataViewInstance.view; + const barList = [renderSortBar(props), renderFilterBar(props)].filter( + Boolean + ); + if (!IS_MOBILE) { + if (!view.contextGet(ShowQuickSettingBarContextKey).value[view.id]) { + return html``; + } + if (!barList.length) { + return html``; + } + } + return html`
+ ${barList.map((bar, index) => { + return html` + ${index !== 0 + ? html`
` + : nothing} + ${bar} + `; + })} +
`; + } +); diff --git a/blocksuite/affine/data-view/src/widget-presets/quick-setting-bar/sort/index.ts b/blocksuite/affine/data-view/src/widget-presets/quick-setting-bar/sort/index.ts new file mode 100644 index 0000000000..df2b1ff775 --- /dev/null +++ b/blocksuite/affine/data-view/src/widget-presets/quick-setting-bar/sort/index.ts @@ -0,0 +1,32 @@ +import { popupTargetFromElement } from '@blocksuite/affine-components/context-menu'; +import { SortIcon } from '@blocksuite/icons/lit'; +import { html } from 'lit'; + +import { sortTraitKey } from '../../../core/sort/manager.js'; +import { createSortUtils } from '../../../core/sort/utils.js'; +import type { DataViewWidgetProps } from '../../../core/widget/types.js'; +import { popSortRoot } from './root-panel.js'; + +export const renderSortBar = (props: DataViewWidgetProps) => { + const sortTrait = props.dataViewInstance.view.traitGet(sortTraitKey); + if (!sortTrait) { + return; + } + const count = sortTrait.sortList$.value.length; + if (count === 0) { + return; + } + const text = count === 1 ? html`1 Sort` : html`${count} Sorts`; + const click = (event: MouseEvent) => { + popSortRoot(popupTargetFromElement(event.currentTarget as HTMLElement), { + sortUtils: createSortUtils(sortTrait, props.dataViewInstance.eventTrace), + }); + }; + return html` `; +}; diff --git a/blocksuite/affine/data-view/src/widget-presets/quick-setting-bar/sort/root-panel.ts b/blocksuite/affine/data-view/src/widget-presets/quick-setting-bar/sort/root-panel.ts new file mode 100644 index 0000000000..edfc6a291a --- /dev/null +++ b/blocksuite/affine/data-view/src/widget-presets/quick-setting-bar/sort/root-panel.ts @@ -0,0 +1,240 @@ +import { + menu, + popMenu, + type PopupTarget, + popupTargetFromElement, +} from '@blocksuite/affine-components/context-menu'; +import { unsafeCSSVarV2 } from '@blocksuite/affine-shared/theme'; +import { ShadowlessElement } from '@blocksuite/block-std'; +import { SignalWatcher, WithDisposable } from '@blocksuite/global/utils'; +import { + ArrowDownSmallIcon, + CloseIcon, + DeleteIcon, + PlusIcon, +} from '@blocksuite/icons/lit'; +import { computed } from '@preact/signals-core'; +import { css, html } from 'lit'; +import { property } from 'lit/decorators.js'; +import { keyed } from 'lit/directives/keyed.js'; +import { repeat } from 'lit/directives/repeat.js'; + +import { renderUniLit } from '../../../core/index.js'; +import { popCreateSort } from '../../../core/sort/add-sort.js'; +import type { SortBy } from '../../../core/sort/types.js'; +import type { SortUtils } from '../../../core/sort/utils.js'; +import { dragHandler } from '../../../core/utils/wc-dnd/dnd-context.js'; +import { defaultActivators } from '../../../core/utils/wc-dnd/sensors/index.js'; +import { + createSortContext, + sortable, +} from '../../../core/utils/wc-dnd/sort/sort-context.js'; +import { verticalListSortingStrategy } from '../../../core/utils/wc-dnd/sort/strategies/index.js'; + +export class SortRootView extends SignalWatcher( + WithDisposable(ShadowlessElement) +) { + static override styles = css` + .sort-root-container { + margin-bottom: 8px; + gap: 8px; + display: flex; + flex-direction: column; + } + + .sort-item { + display: flex; + align-items: center; + } + `; + + @property({ attribute: false }) + accessor sortUtils!: SortUtils; + + items$ = computed(() => { + return this.sortUtils.sortList$.value.map(v => v.ref.name); + }); + + sortContext = createSortContext({ + activators: defaultActivators, + container: this, + onDragEnd: evt => { + const over = evt.over; + if (over) { + const list = this.sortUtils.sortList$.value; + this.sortUtils.move( + list.findIndex(v => v.ref.name === evt.active.id), + list.findIndex(v => v.ref.name === over.id) + ); + } + }, + modifiers: [ + ({ transform }) => { + return { + ...transform, + x: 0, + }; + }, + ], + items: this.items$, + strategy: verticalListSortingStrategy, + }); + + override render() { + const list = this.sortUtils.sortList$.value; + return html` +
+ ${repeat(list, (sort, index) => { + const id = sort.ref.name; + const variable = this.sortUtils.vars$.value.find(v => v.id === id); + let content; + const deleteRule = () => { + this.sortUtils.remove(index); + }; + const changeRule = (rule: SortBy) => { + this.sortUtils.change(index, rule); + }; + if (!variable) { + content = html` + + `; + } else { + const descName = sort.desc ? 'Descending' : 'Ascending'; + const clickField = (event: MouseEvent) => { + popMenu( + popupTargetFromElement(event.currentTarget as HTMLElement), + { + options: { + items: this.sortUtils.vars$.value.map(v => { + return menu.action({ + name: v.name, + prefix: renderUniLit(v.icon), + isSelected: v.id === id, + select: () => { + changeRule({ + ...sort, + ref: { type: 'ref', name: v.id }, + }); + }, + }); + }), + }, + } + ); + }; + const clickOrder = (event: MouseEvent) => { + popMenu( + popupTargetFromElement(event.currentTarget as HTMLElement), + { + options: { + items: [false, true].map(desc => { + return menu.action({ + name: desc ? 'Descending' : 'Ascending', + isSelected: desc === sort.desc, + select: () => { + changeRule({ ...sort, desc }); + }, + }); + }), + }, + } + ); + }; + content = html` + + ${descName}
`}" + .postfix="${ArrowDownSmallIcon()}" + > + `; + } + return keyed( + id, + html` +
+
+
+ ${content} +
+
${CloseIcon({ width: '16px', height: '16px' })} +
+
+ + ` + ); + })} + + `; + } +} + +declare global { + interface HTMLElementTagNameMap { + 'sort-root-view': SortRootView; + } +} + +export const popSortRoot = ( + target: PopupTarget, + props: { + sortUtils: SortUtils; + title?: { + text: string; + onBack?: () => void; + }; + } +) => { + const sortUtils = props.sortUtils; + popMenu(target, { + options: { + title: props.title, + items: [ + () => { + return html` `; + }, + menu.action({ + name: 'Add sort', + prefix: PlusIcon(), + select: ele => { + popCreateSort(popupTargetFromElement(ele), { + sortUtils: props.sortUtils, + }); + return false; + }, + }), + menu.action({ + name: 'Delete', + class: { 'delete-item': true }, + prefix: DeleteIcon(), + select: () => { + props.sortUtils.removeAll(); + }, + }), + ], + }, + }); +}; diff --git a/blocksuite/affine/data-view/src/widget-presets/tools/index.ts b/blocksuite/affine/data-view/src/widget-presets/tools/index.ts new file mode 100644 index 0000000000..2680447ee2 --- /dev/null +++ b/blocksuite/affine/data-view/src/widget-presets/tools/index.ts @@ -0,0 +1,33 @@ +import { createUniComponentFromWebComponent } from '../../core/index.js'; +import { uniMap } from '../../core/utils/uni-component/operation.js'; +import type { + DataViewWidget, + DataViewWidgetProps, +} from '../../core/widget/types.js'; +import { DataViewHeaderToolsFilter } from './presets/filter/filter.js'; +import { DataViewHeaderToolsSearch } from './presets/search/search.js'; +import { DataViewHeaderToolsSort } from './presets/sort/sort.js'; +import { DataViewHeaderToolsAddRow } from './presets/table-add-row/add-row.js'; +import { DataViewHeaderToolsViewOptions } from './presets/view-options/view-options.js'; +import { DataViewHeaderTools } from './tools-view.js'; + +export const toolsWidgetPresets = { + sort: createUniComponentFromWebComponent(DataViewHeaderToolsSort), + filter: createUniComponentFromWebComponent(DataViewHeaderToolsFilter), + search: createUniComponentFromWebComponent(DataViewHeaderToolsSearch), + viewOptions: createUniComponentFromWebComponent( + DataViewHeaderToolsViewOptions + ), + tableAddRow: createUniComponentFromWebComponent(DataViewHeaderToolsAddRow), +}; +export const createWidgetTools = ( + toolsMap: Record +) => { + return uniMap( + createUniComponentFromWebComponent(DataViewHeaderTools), + (props: DataViewWidgetProps) => ({ + ...props, + toolsMap, + }) + ); +}; diff --git a/blocksuite/affine/data-view/src/widget-presets/tools/presets/filter/filter.ts b/blocksuite/affine/data-view/src/widget-presets/tools/presets/filter/filter.ts new file mode 100644 index 0000000000..d61eb616af --- /dev/null +++ b/blocksuite/affine/data-view/src/widget-presets/tools/presets/filter/filter.ts @@ -0,0 +1,115 @@ +import { popupTargetFromElement } from '@blocksuite/affine-components/context-menu'; +import { IS_MOBILE } from '@blocksuite/global/env'; +import { FilterIcon } from '@blocksuite/icons/lit'; +import { computed } from '@preact/signals-core'; +import { cssVarV2 } from '@toeverything/theme/v2'; +import { css, html, nothing } from 'lit'; +import { styleMap } from 'lit/directives/style-map.js'; + +import { popCreateFilter } from '../../../../core/filter/add-filter.js'; +import { filterTraitKey } from '../../../../core/filter/trait.js'; +import type { FilterGroup } from '../../../../core/filter/types.js'; +import { emptyFilterGroup } from '../../../../core/filter/utils.js'; +import { WidgetBase } from '../../../../core/widget/widget-base.js'; +import { ShowQuickSettingBarContextKey } from '../../../quick-setting-bar/context.js'; + +const styles = css` + .affine-database-filter-button { + display: flex; + align-items: center; + gap: 6px; + line-height: 20px; + padding: 2px; + border-radius: 4px; + cursor: pointer; + font-size: 20px; + } + + .affine-database-filter-button:hover, + .affine-database-filter-button.active { + background-color: var(--affine-hover-color); + } + + .affine-database-filter-button { + } +`; + +export class DataViewHeaderToolsFilter extends WidgetBase { + static override styles = styles; + + hasFilter = computed(() => { + return this.filterTrait?.hasFilter$.value ?? false; + }); + + private get _filter(): FilterGroup { + return this.filterTrait?.filter$.value ?? emptyFilterGroup; + } + + private set _filter(filter: FilterGroup) { + this.filterTrait?.filterSet(filter); + } + + get filterTrait() { + return this.view.traitGet(filterTraitKey); + } + + private get readonly() { + return this.view.readonly$.value; + } + + private clickFilter(event: MouseEvent) { + if (this.hasFilter.value) { + this.toggleShowFilter(); + return; + } + popCreateFilter( + popupTargetFromElement(event.currentTarget as HTMLElement), + { + vars: this.view.vars$, + onSelect: filter => { + this._filter = { + ...this._filter, + conditions: [filter], + }; + this.toggleShowFilter(true); + }, + } + ); + return; + } + + override connectedCallback() { + super.connectedCallback(); + this.style.display = IS_MOBILE ? 'none' : 'flex'; + } + + override render() { + if (this.readonly) return nothing; + const style = styleMap({ + color: this.hasFilter.value + ? cssVarV2('text/emphasis') + : cssVarV2('icon/primary'), + }); + return html`
+ ${FilterIcon()} +
`; + } + + toggleShowFilter(show?: boolean) { + const map = this.view.contextGet(ShowQuickSettingBarContextKey); + map.value = { + ...map.value, + [this.view.id]: show ?? !map.value[this.view.id], + }; + } +} + +declare global { + interface HTMLElementTagNameMap { + 'data-view-header-tools-filter': DataViewHeaderToolsFilter; + } +} diff --git a/blocksuite/affine/data-view/src/widget-presets/tools/presets/search/search.ts b/blocksuite/affine/data-view/src/widget-presets/tools/presets/search/search.ts new file mode 100644 index 0000000000..e7b2d8991a --- /dev/null +++ b/blocksuite/affine/data-view/src/widget-presets/tools/presets/search/search.ts @@ -0,0 +1,191 @@ +import { unsafeCSSVarV2 } from '@blocksuite/affine-shared/theme'; +import { IS_MOBILE } from '@blocksuite/global/env'; +import { CloseIcon, SearchIcon } from '@blocksuite/icons/lit'; +import { baseTheme } from '@toeverything/theme'; +import { css, html, unsafeCSS } from 'lit'; +import { query, state } from 'lit/decorators.js'; +import { classMap } from 'lit/directives/class-map.js'; +import { styleMap } from 'lit/directives/style-map.js'; + +import { stopPropagation } from '../../../../core/utils/event.js'; +import { WidgetBase } from '../../../../core/widget/widget-base.js'; +import type { + KanbanSingleView, + TableSingleView, +} from '../../../../view-presets/index.js'; + +const styles = css` + .affine-database-search-container { + position: relative; + display: flex; + align-items: center; + gap: 8px; + width: 24px; + height: 24px; + border-radius: 4px; + transition: width 0.3s ease; + overflow: hidden; + } + + .search-container-expand { + overflow: visible; + width: 138px; + background-color: var(--affine-hover-color); + } + + .search-input-container { + display: flex; + align-items: center; + } + + .close-icon { + display: flex; + align-items: center; + padding-right: 8px; + height: 100%; + cursor: pointer; + } + + .affine-database-search-input-icon { + position: absolute; + left: 0; + font-size: 20px; + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + cursor: pointer; + padding: 2px; + border-radius: 4px; + color: ${unsafeCSSVarV2('icon/primary')}; + } + + .affine-database-search-input-icon:hover { + background: var(--affine-hover-color); + } + + .search-container-expand .affine-database-search-input-icon { + left: 4px; + pointer-events: none; + } + + .affine-database-search-input { + flex: 1; + width: 100%; + padding: 0 2px 0 30px; + border: none; + font-family: ${unsafeCSS(baseTheme.fontSansFamily)}; + font-size: var(--affine-font-sm); + box-sizing: border-box; + color: inherit; + background: transparent; + outline: none; + } + + .affine-database-search-input::placeholder { + color: var(--affine-placeholder-color); + font-size: var(--affine-font-sm); + } +`; + +export class DataViewHeaderToolsSearch extends WidgetBase< + TableSingleView | KanbanSingleView +> { + static override styles = styles; + + private _clearSearch = () => { + this._searchInput.value = ''; + this.view.setSearch(''); + this.preventBlur = true; + setTimeout(() => { + this.preventBlur = false; + }); + }; + + private _clickSearch = (e: MouseEvent) => { + e.stopPropagation(); + this.showSearch = true; + }; + + private _onSearch = (event: InputEvent) => { + const el = event.target as HTMLInputElement; + const inputValue = el.value.trim(); + this.view.setSearch(inputValue); + }; + + private _onSearchBlur = () => { + if (this._searchInput.value || this.preventBlur) { + return; + } + this.showSearch = false; + }; + + private _onSearchKeydown = (event: KeyboardEvent) => { + if (event.key === 'Escape') { + if (this._searchInput.value) { + this._searchInput.value = ''; + this.view.setSearch(''); + } else { + this.showSearch = false; + } + } + }; + + preventBlur = false; + + override connectedCallback() { + super.connectedCallback(); + this.style.display = IS_MOBILE ? 'none' : 'flex'; + } + + override render() { + const searchToolClassMap = classMap({ + 'affine-database-search-container': true, + 'search-container-expand': this.showSearch, + active: this.showSearch, + }); + return html` + + `; + } + + @query('.affine-database-search-input') + private accessor _searchInput!: HTMLInputElement; + + @state() + private accessor showSearch = false; +} + +declare global { + interface HTMLElementTagNameMap { + 'data-view-header-tools-search': DataViewHeaderToolsSearch; + } +} diff --git a/blocksuite/affine/data-view/src/widget-presets/tools/presets/sort/sort.ts b/blocksuite/affine/data-view/src/widget-presets/tools/presets/sort/sort.ts new file mode 100644 index 0000000000..7d4ca7e82f --- /dev/null +++ b/blocksuite/affine/data-view/src/widget-presets/tools/presets/sort/sort.ts @@ -0,0 +1,121 @@ +import { popupTargetFromElement } from '@blocksuite/affine-components/context-menu'; +import { IS_MOBILE } from '@blocksuite/global/env'; +import { SortIcon } from '@blocksuite/icons/lit'; +import { computed } from '@preact/signals-core'; +import { cssVarV2 } from '@toeverything/theme/v2'; +import { css, html, nothing } from 'lit'; +import { styleMap } from 'lit/directives/style-map.js'; + +import { popCreateSort } from '../../../../core/sort/add-sort.js'; +import { sortTraitKey } from '../../../../core/sort/manager.js'; +import { createSortUtils } from '../../../../core/sort/utils.js'; +import { WidgetBase } from '../../../../core/widget/widget-base.js'; +import { ShowQuickSettingBarContextKey } from '../../../quick-setting-bar/context.js'; +import { popSortRoot } from '../../../quick-setting-bar/sort/root-panel.js'; + +const styles = css` + .affine-database-sort-button { + display: flex; + align-items: center; + gap: 6px; + line-height: 20px; + padding: 2px; + border-radius: 4px; + cursor: pointer; + font-size: 20px; + } + + .affine-database-sort-button:hover, + .affine-database-sort-button.active { + background-color: var(--affine-hover-color); + } + + .affine-database-sort-button { + } +`; + +export class DataViewHeaderToolsSort extends WidgetBase { + static override styles = styles; + + sortUtils$ = computed(() => { + const sortTrait = this.view.traitGet(sortTraitKey); + if (sortTrait) { + return createSortUtils(sortTrait, this.dataViewInstance.eventTrace); + } + return; + }); + + hasSort = computed(() => { + return (this.sortUtils$.value?.sortList$?.value?.length ?? 0) > 0; + }); + + private get readonly() { + return this.view.readonly$.value; + } + + private clickSort(event: MouseEvent) { + const sortUtils = this.sortUtils$.value; + if (!sortUtils) { + return; + } + if (this.hasSort.value) { + this.toggleShowQuickSettingBar(); + return; + } + popCreateSort(popupTargetFromElement(event.currentTarget as HTMLElement), { + sortUtils: { + ...sortUtils, + add: sort => { + sortUtils.add(sort); + this.toggleShowQuickSettingBar(true); + requestAnimationFrame(() => { + const ele = this.closest( + 'affine-data-view-renderer' + )?.querySelector('.data-view-sort-button'); + if (ele) { + popSortRoot(popupTargetFromElement(ele as HTMLElement), { + sortUtils: sortUtils, + }); + } + }); + }, + }, + }); + return; + } + + override connectedCallback() { + super.connectedCallback(); + this.style.display = IS_MOBILE ? 'none' : 'flex'; + } + + override render() { + if (this.readonly) return nothing; + const style = styleMap({ + color: this.hasSort.value + ? cssVarV2('text/emphasis') + : cssVarV2('icon/primary'), + }); + return html`
+ ${SortIcon()} +
`; + } + + toggleShowQuickSettingBar(show?: boolean) { + const map = this.view.contextGet(ShowQuickSettingBarContextKey); + map.value = { + ...map.value, + [this.view.id]: show ?? !map.value[this.view.id], + }; + } +} + +declare global { + interface HTMLElementTagNameMap { + 'data-view-header-tools-sort': DataViewHeaderToolsSort; + } +} diff --git a/blocksuite/affine/data-view/src/widget-presets/tools/presets/table-add-row/add-row.ts b/blocksuite/affine/data-view/src/widget-presets/tools/presets/table-add-row/add-row.ts new file mode 100644 index 0000000000..159ad67252 --- /dev/null +++ b/blocksuite/affine/data-view/src/widget-presets/tools/presets/table-add-row/add-row.ts @@ -0,0 +1,51 @@ +import { unsafeCSSVarV2 } from '@blocksuite/affine-shared/theme'; +import { IS_MOBILE } from '@blocksuite/global/env'; +import { PlusIcon } from '@blocksuite/icons/lit'; +import { css, html } from 'lit'; + +import { WidgetBase } from '../../../../core/widget/widget-base.js'; + +const styles = css` + .new-record { + font-weight: 500; + } + + .new-record svg { + font-size: 20px; + color: ${unsafeCSSVarV2('icon/primary')}; + } +`; + +export class DataViewHeaderToolsAddRow extends WidgetBase { + static override styles = styles; + + private _onAddNewRecord = () => { + if (this.readonly) return; + this.viewMethods.addRow?.('start'); + }; + + private get readonly() { + return this.view.readonly$.value; + } + + override render() { + if (this.readonly) { + return; + } + return html` New` + : html`New Record`}" + > + `; + } +} + +declare global { + interface HTMLElementTagNameMap { + 'data-view-header-tools-add-row': DataViewHeaderToolsAddRow; + } +} diff --git a/blocksuite/affine/data-view/src/widget-presets/tools/presets/table-add-row/new-record-preview.ts b/blocksuite/affine/data-view/src/widget-presets/tools/presets/table-add-row/new-record-preview.ts new file mode 100644 index 0000000000..2eb32f2173 --- /dev/null +++ b/blocksuite/affine/data-view/src/widget-presets/tools/presets/table-add-row/new-record-preview.ts @@ -0,0 +1,43 @@ +import { ShadowlessElement } from '@blocksuite/block-std'; +import { PlusIcon } from '@blocksuite/icons/lit'; +import { html } from 'lit'; + +export class NewRecordPreview extends ShadowlessElement { + override render() { + return html` + + ${PlusIcon()} + `; + } +} diff --git a/blocksuite/affine/data-view/src/widget-presets/tools/presets/view-options/view-options.ts b/blocksuite/affine/data-view/src/widget-presets/tools/presets/view-options/view-options.ts new file mode 100644 index 0000000000..f2070c95d7 --- /dev/null +++ b/blocksuite/affine/data-view/src/widget-presets/tools/presets/view-options/view-options.ts @@ -0,0 +1,379 @@ +import { + menu, + type MenuButtonData, + type MenuConfig, + popMenu, + type PopupTarget, + popupTargetFromElement, +} from '@blocksuite/affine-components/context-menu'; +import { unsafeCSSVarV2 } from '@blocksuite/affine-shared/theme'; +import { + ArrowRightSmallIcon, + DeleteIcon, + DuplicateIcon, + FilterIcon, + GroupingIcon, + InfoIcon, + LayoutIcon, + MoreHorizontalIcon, + SortIcon, +} from '@blocksuite/icons/lit'; +import { css, html } from 'lit'; +import { styleMap } from 'lit/directives/style-map.js'; + +import { popPropertiesSetting } from '../../../../core/common/properties.js'; +import { filterTraitKey } from '../../../../core/filter/trait.js'; +import { + popGroupSetting, + popSelectGroupByProperty, +} from '../../../../core/group-by/setting.js'; +import { groupTraitKey } from '../../../../core/group-by/trait.js'; +import { + type DataViewInstance, + emptyFilterGroup, + popCreateFilter, + renderUniLit, +} from '../../../../core/index.js'; +import { popCreateSort } from '../../../../core/sort/add-sort.js'; +import { sortTraitKey } from '../../../../core/sort/manager.js'; +import { createSortUtils } from '../../../../core/sort/utils.js'; +import { WidgetBase } from '../../../../core/widget/widget-base.js'; +import { popFilterRoot } from '../../../quick-setting-bar/filter/root-panel-view.js'; +import { popSortRoot } from '../../../quick-setting-bar/sort/root-panel.js'; + +const styles = css` + .affine-database-toolbar-item.more-action { + padding: 2px; + border-radius: 4px; + display: flex; + align-items: center; + cursor: pointer; + } + + .affine-database-toolbar-item.more-action:hover { + background: var(--affine-hover-color); + } + + .affine-database-toolbar-item.more-action { + font-size: 20px; + color: ${unsafeCSSVarV2('icon/primary')}; + } + + .more-action.active { + background: var(--affine-hover-color); + } +`; + +export class DataViewHeaderToolsViewOptions extends WidgetBase { + static override styles = styles; + + clickMoreAction = (e: MouseEvent) => { + e.stopPropagation(); + this.openMoreAction(popupTargetFromElement(e.currentTarget as HTMLElement)); + }; + + openMoreAction = (target: PopupTarget) => { + popViewOptions(target, this.dataViewInstance); + }; + + override render() { + if (this.view.readonly$.value) { + return; + } + return html`
+ ${MoreHorizontalIcon()} +
`; + } +} + +declare global { + interface HTMLElementTagNameMap { + 'data-view-header-tools-view-options': DataViewHeaderToolsViewOptions; + } +} +const createSettingMenus = ( + target: PopupTarget, + dataViewInstance: DataViewInstance, + reopen: () => void +) => { + const view = dataViewInstance.view; + const settingItems: MenuConfig[] = []; + settingItems.push( + menu.action({ + name: 'Properties', + prefix: InfoIcon(), + postfix: html`
+ ${view.properties$.value.length} shown +
+ ${ArrowRightSmallIcon()}`, + select: () => { + popPropertiesSetting(target, { + view: view, + onBack: reopen, + }); + }, + }) + ); + const filterTrait = view.traitGet(filterTraitKey); + if (filterTrait) { + const filterCount = filterTrait.filter$.value.conditions.length; + settingItems.push( + menu.action({ + name: 'Filter', + prefix: FilterIcon(), + postfix: html`
+ ${filterCount === 0 + ? '' + : filterCount === 1 + ? '1 filter' + : `${filterCount} filters`} +
+ ${ArrowRightSmallIcon()}`, + select: () => { + if (!filterTrait.filter$.value.conditions.length) { + popCreateFilter(target, { + vars: view.vars$, + onBack: reopen, + onSelect: filter => { + filterTrait.filterSet({ + ...(filterTrait.filter$.value ?? emptyFilterGroup), + conditions: [...filterTrait.filter$.value.conditions, filter], + }); + popFilterRoot(target, { + filterTrait: filterTrait, + onBack: reopen, + }); + }, + }); + } else { + popFilterRoot(target, { + filterTrait: filterTrait, + onBack: reopen, + }); + } + }, + }) + ); + } + const sortTrait = view.traitGet(sortTraitKey); + if (sortTrait) { + const sortCount = sortTrait.sortList$.value.length; + settingItems.push( + menu.action({ + name: 'Sort', + prefix: SortIcon(), + postfix: html`
+ ${sortCount === 0 + ? '' + : sortCount === 1 + ? '1 sort' + : `${sortCount} sorts`} +
+ ${ArrowRightSmallIcon()}`, + select: () => { + const sortList = sortTrait.sortList$.value; + const sortUtils = createSortUtils( + sortTrait, + dataViewInstance.eventTrace + ); + if (!sortList.length) { + popCreateSort(target, { + sortUtils: sortUtils, + onBack: reopen, + }); + } else { + popSortRoot(target, { + sortUtils: sortUtils, + title: { + text: 'Sort', + onBack: reopen, + }, + }); + } + }, + }) + ); + } + const groupTrait = view.traitGet(groupTraitKey); + if (groupTrait) { + settingItems.push( + menu.action({ + name: 'Group', + prefix: GroupingIcon(), + postfix: html`
+ ${groupTrait.property$.value?.name$.value ?? ''} +
+ ${ArrowRightSmallIcon()}`, + select: () => { + const groupBy = groupTrait.property$.value; + if (!groupBy) { + popSelectGroupByProperty(target, groupTrait, { + onSelect: () => popGroupSetting(target, groupTrait, reopen), + onBack: reopen, + }); + } else { + popGroupSetting(target, groupTrait, reopen); + } + }, + }) + ); + } + return settingItems; +}; +export const popViewOptions = ( + target: PopupTarget, + dataViewInstance: DataViewInstance, + onClose?: () => void +) => { + const view = dataViewInstance.view; + const reopen = () => { + popViewOptions(target, dataViewInstance); + }; + const items: MenuConfig[] = []; + items.push( + menu.input({ + initialValue: view.name$.value, + onChange: text => { + view.nameSet(text); + }, + }) + ); + items.push( + menu.group({ + items: [ + menu.action({ + name: 'Layout', + postfix: html`
+ ${view.type} +
+ ${ArrowRightSmallIcon()}`, + select: () => { + const viewTypes = view.manager.viewMetas.map(meta => { + return menu => { + if (!menu.search(meta.model.defaultName)) { + return; + } + const isSelected = + meta.type === view.manager.currentView$.value.type; + const iconStyle = styleMap({ + fontSize: '24px', + color: isSelected + ? 'var(--affine-text-emphasis-color)' + : 'var(--affine-icon-secondary)', + }); + const textStyle = styleMap({ + fontSize: '14px', + lineHeight: '22px', + color: isSelected + ? 'var(--affine-text-emphasis-color)' + : 'var(--affine-text-secondary-color)', + }); + const data: MenuButtonData = { + content: () => html` +
+
+ ${renderUniLit(meta.renderer.icon)} +
+
${meta.model.defaultName}
+
+ `, + select: () => { + view.manager.viewChangeType( + view.manager.currentViewId$.value, + meta.type + ); + dataViewInstance.clearSelection(); + }, + class: {}, + }; + const containerStyle = styleMap({ + flex: '1', + }); + return html` `; + }; + }); + popMenu(target, { + options: { + title: { + onBack: reopen, + text: 'Layout', + }, + items: [ + menu => { + const result = menu.renderItems(viewTypes); + if (result.length) { + return html`
${result}
`; + } + return html``; + }, + // menu.toggleSwitch({ + // name: 'Show block icon', + // on: true, + // onChange: value => { + // console.log(value); + // }, + // }), + // menu.toggleSwitch({ + // name: 'Show Vertical lines', + // on: true, + // onChange: value => { + // console.log(value); + // }, + // }), + ], + }, + }); + }, + prefix: LayoutIcon(), + }), + ], + }) + ); + + items.push( + menu.group({ + items: createSettingMenus(target, dataViewInstance, reopen), + }) + ); + items.push( + menu.group({ + items: [ + menu.action({ + name: 'Duplicate', + prefix: DuplicateIcon(), + select: () => { + view.duplicate(); + }, + }), + menu.action({ + name: 'Delete', + prefix: DeleteIcon(), + select: () => { + view.delete(); + }, + class: { 'delete-item': true }, + }), + ], + }) + ); + popMenu(target, { + options: { + title: { + text: 'View settings', + }, + items, + onClose: onClose, + }, + }); +}; diff --git a/blocksuite/affine/data-view/src/widget-presets/tools/tools-view.ts b/blocksuite/affine/data-view/src/widget-presets/tools/tools-view.ts new file mode 100644 index 0000000000..235043a7c2 --- /dev/null +++ b/blocksuite/affine/data-view/src/widget-presets/tools/tools-view.ts @@ -0,0 +1,83 @@ +import { IS_MOBILE } from '@blocksuite/global/env'; +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 DataViewInstance, renderUniLit } from '../../core/index.js'; +import type { SingleView } from '../../core/view-manager/single-view.js'; +import type { ViewManager } from '../../core/view-manager/view-manager.js'; +import type { DataViewWidget } from '../../core/widget/types.js'; +import { WidgetBase } from '../../core/widget/widget-base.js'; + +const styles = css` + .affine-database-toolbar { + display: flex; + align-items: center; + gap: 6px; + opacity: 0; + transition: opacity 150ms cubic-bezier(0.42, 0, 1, 1); + } + + .toolbar-hover-container:hover .affine-database-toolbar { + visibility: visible; + opacity: 1; + } + .toolbar-hover-container:has(.active) .affine-database-toolbar { + visibility: visible; + opacity: 1; + } + + .show-toolbar { + visibility: visible; + opacity: 1; + } + + @media print { + .affine-database-toolbar { + display: none; + } + } +`; + +export class DataViewHeaderTools extends WidgetBase { + static override styles = styles; + + override render() { + const classList = classMap({ + 'show-toolbar': IS_MOBILE, + 'affine-database-toolbar': true, + }); + const tools = this.toolsMap[this.view.type]; + return html`
+ ${repeat(tools ?? [], uni => { + return renderUniLit(uni, { + dataViewInstance: this.dataViewInstance, + }); + })} +
`; + } + + @state() + accessor showToolBar = false; + + @property({ attribute: false }) + accessor toolsMap!: Record; +} + +declare global { + interface HTMLElementTagNameMap { + 'data-view-header-tools': DataViewHeaderTools; + } +} +export const renderTools = ( + view: SingleView, + viewMethods: DataViewInstance, + viewSource: ViewManager +) => { + return html` `; +}; diff --git a/blocksuite/affine/data-view/src/widget-presets/views-bar/index.ts b/blocksuite/affine/data-view/src/widget-presets/views-bar/index.ts new file mode 100644 index 0000000000..0b80af948f --- /dev/null +++ b/blocksuite/affine/data-view/src/widget-presets/views-bar/index.ts @@ -0,0 +1,11 @@ +import { + createUniComponentFromWebComponent, + type DataViewWidgetProps, +} from '../../core/index.js'; +import { DataViewHeaderViews } from './views-view.js'; + +export const widgetViewsBar = createUniComponentFromWebComponent< + DataViewWidgetProps & { + onChangeView?: (viewId: string) => void; + } +>(DataViewHeaderViews); diff --git a/blocksuite/affine/data-view/src/widget-presets/views-bar/views-view.ts b/blocksuite/affine/data-view/src/widget-presets/views-bar/views-view.ts new file mode 100644 index 0000000000..dbcf8a1fbb --- /dev/null +++ b/blocksuite/affine/data-view/src/widget-presets/views-bar/views-view.ts @@ -0,0 +1,305 @@ +import { + menu, + popFilterableSimpleMenu, + popMenu, + type PopupTarget, + popupTargetFromElement, +} from '@blocksuite/affine-components/context-menu'; +import { + DeleteIcon, + DuplicateIcon, + InfoIcon, + MoreHorizontalIcon, + MoveLeftIcon, + MoveRightIcon, + PlusIcon, +} from '@blocksuite/icons/lit'; +import { css, html } from 'lit'; +import { property } from 'lit/decorators.js'; +import { classMap } from 'lit/directives/class-map.js'; + +import { WidgetBase } from '../../core/widget/widget-base.js'; + +export class DataViewHeaderViews extends WidgetBase { + static override styles = css` + data-view-header-views { + height: 28px; + display: flex; + user-select: none; + gap: 4px; + } + data-view-header-views::-webkit-scrollbar-thumb { + width: 1px; + } + + .database-view-button { + height: 100%; + cursor: pointer; + padding: 2px 4px; + border-radius: 4px; + font-size: 14px; + display: flex; + align-items: center; + justify-content: center; + color: var(--affine-text-secondary-color); + white-space: nowrap; + max-width: 200px; + min-width: 28px; + } + + .database-view-button .name { + align-items: center; + font-size: 15px; + line-height: 24px; + overflow: hidden; + text-overflow: ellipsis; + font-weight: 500; + padding-right: 2px; + } + + .database-view-button .icon { + margin-right: 6px; + display: block; + flex-shrink: 0; + } + + .database-view-button .icon svg { + width: 16px; + height: 16px; + } + + .database-view-button.selected { + color: var(--affine-text-primary-color); + background-color: var(--affine-hover-color-filled); + } + `; + + _addViewMenu = (event: MouseEvent) => { + popFilterableSimpleMenu( + popupTargetFromElement(event.currentTarget as HTMLElement), + this.dataSource.viewMetas.map(v => { + return menu.action({ + name: v.model.defaultName, + prefix: html``, + select: () => { + const id = this.viewManager.viewAdd(v.type); + this.viewManager.setCurrentView(id); + }, + }); + }) + ); + }; + + _showMore = (event: MouseEvent) => { + const views = this.viewManager.views$.value; + popFilterableSimpleMenu( + popupTargetFromElement(event.currentTarget as HTMLElement), + [ + menu.group({ + items: views.map(id => { + const openViewOption = (event: MouseEvent) => { + event.stopPropagation(); + this.openViewOption( + popupTargetFromElement(event.currentTarget as HTMLElement), + id + ); + }; + const view = this.viewManager.viewGet(id); + return menu.action({ + prefix: html``, + name: view.name$.value ?? '', + label: () => html`${view.name$.value}`, + isSelected: this.viewManager.currentViewId$.value === id, + select: () => { + this.viewManager.setCurrentView(id); + }, + postfix: html`
+ ${MoreHorizontalIcon()} +
`, + }); + }), + }), + menu.group({ + items: this.dataSource.viewMetas.map(v => { + return menu.action({ + name: `Create ${v.model.defaultName}`, + hide: () => this.readonly, + prefix: PlusIcon(), + select: () => { + const id = this.viewManager.viewAdd(v.type); + this.viewManager.setCurrentView(id); + }, + }); + }), + }), + ] + ); + }; + + openViewOption = (target: PopupTarget, id: string) => { + if (this.readonly) { + return; + } + const views = this.viewManager.views$.value; + const index = views.findIndex(v => v === id); + const view = this.viewManager.viewGet(views[index]); + if (!view) { + return; + } + popMenu(target, { + options: { + items: [ + menu.input({ + initialValue: view.name$.value, + onChange: text => { + view.nameSet(text); + }, + }), + menu.group({ + items: [ + menu.action({ + name: 'Edit View', + prefix: InfoIcon(), + select: () => { + this.closest('affine-data-view-renderer') + ?.querySelector('data-view-header-tools-view-options') + ?.openMoreAction(target); + }, + }), + menu.action({ + name: 'Move Left', + hide: () => index === 0, + prefix: MoveLeftIcon(), + select: () => { + const targetId = views[index - 1]; + this.viewManager.moveTo( + id, + targetId ? { before: true, id: targetId } : 'start' + ); + }, + }), + menu.action({ + name: 'Move Right', + prefix: MoveRightIcon(), + hide: () => index === views.length - 1, + select: () => { + const targetId = views[index + 1]; + this.viewManager.moveTo( + id, + targetId ? { before: false, id: targetId } : 'end' + ); + }, + }), + ], + }), + menu.group({ + items: [ + menu.action({ + name: 'Duplicate', + prefix: DuplicateIcon(), + select: () => { + this.viewManager.viewDuplicate(id); + }, + }), + menu.action({ + name: 'Delete', + prefix: DeleteIcon(), + select: () => { + view.delete(); + }, + class: { 'delete-item': true }, + }), + ], + }), + ], + }, + }); + }; + + renderMore = (count: number) => { + const views = this.viewManager.views$.value; + if (count === views.length) { + if (this.readonly) { + return; + } + return html`
+ ${PlusIcon()} +
`; + } + return html` +
+ ${views.length - count} More +
+ `; + }; + + renderViews = () => { + const views = this.viewManager.views$.value; + return views.map(id => () => { + const classList = classMap({ + 'database-view-button': true, + 'dv-hover': true, + selected: this.viewManager.currentViewId$.value === id, + }); + const view = this.viewManager.viewDataGet(id); + return html` +
+ +
${view?.name}
+
+ `; + }); + }; + + get readonly() { + return this.viewManager.readonly$.value; + } + + private getRenderer(viewId: string) { + return this.dataSource.viewMetaGetById(viewId).renderer; + } + + _clickView(event: MouseEvent, id: string) { + if (this.viewManager.currentViewId$.value !== id) { + this.viewManager.setCurrentView(id); + this.onChangeView?.(id); + return; + } + this.openViewOption( + popupTargetFromElement(event.currentTarget as HTMLElement), + id + ); + } + + override render() { + return html` + + `; + } + + @property({ attribute: false }) + accessor onChangeView: ((id: string) => void) | undefined; +} + +declare global { + interface HTMLElementTagNameMap { + 'data-view-header-views': DataViewHeaderViews; + } +} diff --git a/blocksuite/affine/data-view/tsconfig.json b/blocksuite/affine/data-view/tsconfig.json new file mode 100644 index 0000000000..fb83679c1d --- /dev/null +++ b/blocksuite/affine/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/blocksuite/affine/data-view/typedoc.json b/blocksuite/affine/data-view/typedoc.json new file mode 100644 index 0000000000..101e923dba --- /dev/null +++ b/blocksuite/affine/data-view/typedoc.json @@ -0,0 +1,4 @@ +{ + "extends": ["../../../typedoc.base.json"], + "entryPoints": ["src/index.ts"] +} diff --git a/blocksuite/affine/data-view/vitest.config.ts b/blocksuite/affine/data-view/vitest.config.ts new file mode 100644 index 0000000000..1e76565bf5 --- /dev/null +++ b/blocksuite/affine/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/blocksuite/affine/model/package.json b/blocksuite/affine/model/package.json new file mode 100644 index 0000000000..8bbaabbe7d --- /dev/null +++ b/blocksuite/affine/model/package.json @@ -0,0 +1,32 @@ +{ + "name": "@blocksuite/affine-model", + "description": "Models for BlockSuite in Affine.", + "type": "module", + "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": "MIT", + "dependencies": { + "@blocksuite/block-std": "workspace:*", + "@blocksuite/global": "workspace:*", + "@blocksuite/inline": "workspace:*", + "@blocksuite/store": "workspace:*", + "fractional-indexing": "^3.2.0", + "zod": "^3.23.8" + }, + "exports": { + ".": "./src/index.ts" + }, + "files": [ + "src", + "dist", + "!src/__tests__", + "!dist/__tests__" + ] +} diff --git a/blocksuite/affine/model/src/blocks/attachment/attachment-model.ts b/blocksuite/affine/model/src/blocks/attachment/attachment-model.ts new file mode 100644 index 0000000000..f80c52805f --- /dev/null +++ b/blocksuite/affine/model/src/blocks/attachment/attachment-model.ts @@ -0,0 +1,101 @@ +import type { + GfxCommonBlockProps, + GfxElementGeometry, +} from '@blocksuite/block-std/gfx'; +import { GfxCompatible } from '@blocksuite/block-std/gfx'; +import { BlockModel, defineBlockSchema } from '@blocksuite/store'; + +import type { EmbedCardStyle } from '../../utils/index.js'; +import { AttachmentBlockTransformer } from './attachment-transformer.js'; + +/** + * When the attachment is uploading, the `sourceId` is `undefined`. + * And we can query the upload status by the `isAttachmentLoading` function. + * + * Other collaborators will see an error attachment block when the blob has not finished uploading. + * This issue can be resolve by sync the upload status through the awareness system in the future. + * + * When the attachment is uploaded, the `sourceId` is the id of the blob. + * + * If there are no `sourceId` and the `isAttachmentLoading` function returns `false`, + * it means that the attachment is failed to upload. + */ + +/** + * @deprecated + */ +type BackwardCompatibleUndefined = undefined; + +export const AttachmentBlockStyles: EmbedCardStyle[] = [ + 'cubeThick', + 'horizontalThin', + 'pdf', +] as const; + +export type AttachmentBlockProps = { + name: string; + size: number; + /** + * MIME type + */ + type: string; + caption?: string; + // `loadingKey` was used to indicate whether the attachment is loading, + // which is currently unused but no breaking change is needed. + // The `loadingKey` and `sourceId` should not be existed at the same time. + // loadingKey?: string | null; + sourceId?: string; + /** + * Whether to show the attachment as an embed view. + */ + embed: boolean | BackwardCompatibleUndefined; + + style?: (typeof AttachmentBlockStyles)[number]; +} & Omit; + +export const defaultAttachmentProps: AttachmentBlockProps = { + name: '', + size: 0, + type: 'application/octet-stream', + sourceId: undefined, + caption: undefined, + embed: false, + style: AttachmentBlockStyles[1], + index: 'a0', + xywh: '[0,0,0,0]', + lockedBySelf: false, + rotate: 0, +}; + +export const AttachmentBlockSchema = defineBlockSchema({ + flavour: 'affine:attachment', + props: (): AttachmentBlockProps => defaultAttachmentProps, + metadata: { + version: 1, + role: 'content', + parent: [ + 'affine:note', + 'affine:surface', + 'affine:edgeless-text', + 'affine:paragraph', + 'affine:list', + ], + }, + transformer: () => new AttachmentBlockTransformer(), + toModel: () => new AttachmentBlockModel(), +}); + +export class AttachmentBlockModel + extends GfxCompatible(BlockModel) + implements GfxElementGeometry {} + +declare global { + namespace BlockSuite { + interface EdgelessBlockModelMap { + 'affine:attachment': AttachmentBlockModel; + } + interface BlockModels { + 'affine:attachment': AttachmentBlockModel; + } + } +} diff --git a/blocksuite/affine/model/src/blocks/attachment/attachment-transformer.ts b/blocksuite/affine/model/src/blocks/attachment/attachment-transformer.ts new file mode 100644 index 0000000000..f6bd06a77d --- /dev/null +++ b/blocksuite/affine/model/src/blocks/attachment/attachment-transformer.ts @@ -0,0 +1,17 @@ +import type { FromSnapshotPayload, SnapshotNode } from '@blocksuite/store'; +import { BaseBlockTransformer } from '@blocksuite/store'; + +import type { AttachmentBlockProps } from './attachment-model.js'; + +export class AttachmentBlockTransformer extends BaseBlockTransformer { + override async fromSnapshot( + payload: FromSnapshotPayload + ): Promise> { + const snapshotRet = await super.fromSnapshot(payload); + const sourceId = snapshotRet.props.sourceId; + if (!payload.assets.isEmpty() && sourceId) + await payload.assets.writeToBlob(sourceId); + + return snapshotRet; + } +} diff --git a/blocksuite/affine/model/src/blocks/attachment/index.ts b/blocksuite/affine/model/src/blocks/attachment/index.ts new file mode 100644 index 0000000000..35bb161bc4 --- /dev/null +++ b/blocksuite/affine/model/src/blocks/attachment/index.ts @@ -0,0 +1,2 @@ +export * from './attachment-model.js'; +export * from './attachment-transformer.js'; diff --git a/blocksuite/affine/model/src/blocks/bookmark/bookmark-model.ts b/blocksuite/affine/model/src/blocks/bookmark/bookmark-model.ts new file mode 100644 index 0000000000..243dabaa6d --- /dev/null +++ b/blocksuite/affine/model/src/blocks/bookmark/bookmark-model.ts @@ -0,0 +1,70 @@ +import type { + GfxCommonBlockProps, + GfxElementGeometry, +} from '@blocksuite/block-std/gfx'; +import { GfxCompatible } from '@blocksuite/block-std/gfx'; +import { BlockModel, defineBlockSchema } from '@blocksuite/store'; + +import type { EmbedCardStyle, LinkPreviewData } from '../../utils/index.js'; + +export const BookmarkStyles: EmbedCardStyle[] = [ + 'vertical', + 'horizontal', + 'list', + 'cube', +] as const; + +export type BookmarkBlockProps = { + style: (typeof BookmarkStyles)[number]; + url: string; + caption: string | null; +} & LinkPreviewData & + Omit; + +const defaultBookmarkProps: BookmarkBlockProps = { + style: BookmarkStyles[1], + url: '', + caption: null, + + description: null, + icon: null, + image: null, + title: null, + + index: 'a0', + xywh: '[0,0,0,0]', + lockedBySelf: false, + rotate: 0, +}; + +export const BookmarkBlockSchema = defineBlockSchema({ + flavour: 'affine:bookmark', + props: (): BookmarkBlockProps => defaultBookmarkProps, + metadata: { + version: 1, + role: 'content', + parent: [ + 'affine:note', + 'affine:surface', + 'affine:edgeless-text', + 'affine:paragraph', + 'affine:list', + ], + }, + toModel: () => new BookmarkBlockModel(), +}); + +export class BookmarkBlockModel + extends GfxCompatible(BlockModel) + implements GfxElementGeometry {} + +declare global { + namespace BlockSuite { + interface EdgelessBlockModelMap { + 'affine:bookmark': BookmarkBlockModel; + } + interface BlockModels { + 'affine:bookmark': BookmarkBlockModel; + } + } +} diff --git a/blocksuite/affine/model/src/blocks/bookmark/index.ts b/blocksuite/affine/model/src/blocks/bookmark/index.ts new file mode 100644 index 0000000000..a6ebdb1f8e --- /dev/null +++ b/blocksuite/affine/model/src/blocks/bookmark/index.ts @@ -0,0 +1 @@ +export * from './bookmark-model.js'; diff --git a/blocksuite/affine/model/src/blocks/code/code-model.ts b/blocksuite/affine/model/src/blocks/code/code-model.ts new file mode 100644 index 0000000000..74fbb8e332 --- /dev/null +++ b/blocksuite/affine/model/src/blocks/code/code-model.ts @@ -0,0 +1,44 @@ +import { + defineBlockSchema, + type SchemaToModel, + type Text, +} from '@blocksuite/store'; + +interface CodeBlockProps { + text: Text; + language: string | null; + wrap: boolean; + caption: string; +} + +export const CodeBlockSchema = defineBlockSchema({ + flavour: 'affine:code', + props: internal => + ({ + text: internal.Text(), + language: null, + wrap: false, + caption: '', + }) as CodeBlockProps, + metadata: { + version: 1, + role: 'content', + parent: [ + 'affine:note', + 'affine:paragraph', + 'affine:list', + 'affine:edgeless-text', + ], + children: [], + }, +}); + +export type CodeBlockModel = SchemaToModel; + +declare global { + namespace BlockSuite { + interface BlockModels { + 'affine:code': CodeBlockModel; + } + } +} diff --git a/blocksuite/affine/model/src/blocks/code/index.ts b/blocksuite/affine/model/src/blocks/code/index.ts new file mode 100644 index 0000000000..bcd5db3e08 --- /dev/null +++ b/blocksuite/affine/model/src/blocks/code/index.ts @@ -0,0 +1 @@ +export * from './code-model.js'; diff --git a/blocksuite/affine/model/src/blocks/database/database-model.ts b/blocksuite/affine/model/src/blocks/database/database-model.ts new file mode 100644 index 0000000000..eda9934652 --- /dev/null +++ b/blocksuite/affine/model/src/blocks/database/database-model.ts @@ -0,0 +1,30 @@ +import type { Text } from '@blocksuite/store'; +import { BlockModel, defineBlockSchema } from '@blocksuite/store'; + +import type { Column, SerializedCells, ViewBasicDataType } from './types.js'; + +export type DatabaseBlockProps = { + views: ViewBasicDataType[]; + title: Text; + cells: SerializedCells; + columns: Array; +}; + +export class DatabaseBlockModel extends BlockModel {} + +export const DatabaseBlockSchema = defineBlockSchema({ + flavour: 'affine:database', + props: (internal): DatabaseBlockProps => ({ + views: [], + title: internal.Text(), + cells: Object.create(null), + columns: [], + }), + metadata: { + role: 'hub', + version: 3, + parent: ['affine:note'], + children: ['affine:paragraph', 'affine:list'], + }, + toModel: () => new DatabaseBlockModel(), +}); diff --git a/blocksuite/affine/model/src/blocks/database/index.ts b/blocksuite/affine/model/src/blocks/database/index.ts new file mode 100644 index 0000000000..384ec3c9d1 --- /dev/null +++ b/blocksuite/affine/model/src/blocks/database/index.ts @@ -0,0 +1,2 @@ +export * from './database-model.js'; +export * from './types.js'; diff --git a/blocksuite/affine/model/src/blocks/database/types.ts b/blocksuite/affine/model/src/blocks/database/types.ts new file mode 100644 index 0000000000..b0e5aa4c40 --- /dev/null +++ b/blocksuite/affine/model/src/blocks/database/types.ts @@ -0,0 +1,21 @@ +export interface Column< + Data extends Record = Record, +> { + id: string; + type: string; + name: string; + data: Data; +} + +export type ColumnUpdater = (data: T) => Partial; +export type Cell = { + columnId: Column['id']; + value: ValueType; +}; + +export type SerializedCells = Record>; +export type ViewBasicDataType = { + id: string; + name: string; + mode: string; +}; diff --git a/blocksuite/affine/model/src/blocks/divider/divider-model.ts b/blocksuite/affine/model/src/blocks/divider/divider-model.ts new file mode 100644 index 0000000000..bc824f9694 --- /dev/null +++ b/blocksuite/affine/model/src/blocks/divider/divider-model.ts @@ -0,0 +1,20 @@ +import { defineBlockSchema, type SchemaToModel } from '@blocksuite/store'; + +export const DividerBlockSchema = defineBlockSchema({ + flavour: 'affine:divider', + metadata: { + version: 1, + role: 'content', + children: [], + }, +}); + +export type DividerBlockModel = SchemaToModel; + +declare global { + namespace BlockSuite { + interface BlockModels { + 'affine:divider': DividerBlockModel; + } + } +} diff --git a/blocksuite/affine/model/src/blocks/divider/index.ts b/blocksuite/affine/model/src/blocks/divider/index.ts new file mode 100644 index 0000000000..4311170030 --- /dev/null +++ b/blocksuite/affine/model/src/blocks/divider/index.ts @@ -0,0 +1 @@ +export * from './divider-model.js'; diff --git a/blocksuite/affine/model/src/blocks/edgeless-text/edgeless-text-model.ts b/blocksuite/affine/model/src/blocks/edgeless-text/edgeless-text-model.ts new file mode 100644 index 0000000000..4fadf8295b --- /dev/null +++ b/blocksuite/affine/model/src/blocks/edgeless-text/edgeless-text-model.ts @@ -0,0 +1,74 @@ +import type { + GfxCommonBlockProps, + GfxElementGeometry, +} from '@blocksuite/block-std/gfx'; +import { GfxCompatible } from '@blocksuite/block-std/gfx'; +import { BlockModel, defineBlockSchema } from '@blocksuite/store'; + +import { + FontFamily, + FontStyle, + FontWeight, + TextAlign, + type TextStyleProps, +} from '../../consts/index.js'; + +type EdgelessTextProps = { + hasMaxWidth: boolean; +} & Omit & + GfxCommonBlockProps; + +export const EdgelessTextBlockSchema = defineBlockSchema({ + flavour: 'affine:edgeless-text', + props: (): EdgelessTextProps => ({ + xywh: '[0,0,16,16]', + index: 'a0', + lockedBySelf: false, + color: '#000000', + fontFamily: FontFamily.Inter, + fontStyle: FontStyle.Normal, + fontWeight: FontWeight.Regular, + textAlign: TextAlign.Left, + scale: 1, + rotate: 0, + hasMaxWidth: false, + }), + metadata: { + version: 1, + role: 'hub', + parent: ['affine:surface'], + children: [ + 'affine:paragraph', + 'affine:list', + 'affine:code', + 'affine:image', + 'affine:bookmark', + 'affine:attachment', + 'affine:embed-!(synced-doc)', + 'affine:latex', + ], + }, + toModel: () => { + return new EdgelessTextBlockModel(); + }, +}); + +export class EdgelessTextBlockModel + extends GfxCompatible(BlockModel) + implements GfxElementGeometry {} + +declare global { + namespace BlockSuite { + interface BlockModels { + 'affine:edgeless-text': EdgelessTextBlockModel; + } + + interface EdgelessBlockModelMap { + 'affine:edgeless-text': EdgelessTextBlockModel; + } + + interface EdgelessTextModelMap { + 'edgeless-text': EdgelessTextBlockModel; + } + } +} diff --git a/blocksuite/affine/model/src/blocks/edgeless-text/index.ts b/blocksuite/affine/model/src/blocks/edgeless-text/index.ts new file mode 100644 index 0000000000..4a68caeef8 --- /dev/null +++ b/blocksuite/affine/model/src/blocks/edgeless-text/index.ts @@ -0,0 +1 @@ +export * from './edgeless-text-model.js'; diff --git a/blocksuite/affine/model/src/blocks/embed/figma/figma-model.ts b/blocksuite/affine/model/src/blocks/embed/figma/figma-model.ts new file mode 100644 index 0000000000..77e74fb084 --- /dev/null +++ b/blocksuite/affine/model/src/blocks/embed/figma/figma-model.ts @@ -0,0 +1,32 @@ +import { BlockModel } from '@blocksuite/store'; + +import type { EmbedCardStyle } from '../../../utils/index.js'; +import { defineEmbedModel } from '../../../utils/index.js'; + +export type EmbedFigmaBlockUrlData = { + title: string | null; + description: string | null; +}; + +export const EmbedFigmaStyles: EmbedCardStyle[] = ['figma'] as const; + +export type EmbedFigmaBlockProps = { + style: (typeof EmbedFigmaStyles)[number]; + url: string; + caption: string | null; +} & EmbedFigmaBlockUrlData; + +export class EmbedFigmaModel extends defineEmbedModel( + BlockModel +) {} + +declare global { + namespace BlockSuite { + interface EdgelessBlockModelMap { + 'affine:embed-figma': EmbedFigmaModel; + } + interface BlockModels { + 'affine:embed-figma': EmbedFigmaModel; + } + } +} diff --git a/blocksuite/affine/model/src/blocks/embed/figma/figma-schema.ts b/blocksuite/affine/model/src/blocks/embed/figma/figma-schema.ts new file mode 100644 index 0000000000..9c56dcd550 --- /dev/null +++ b/blocksuite/affine/model/src/blocks/embed/figma/figma-schema.ts @@ -0,0 +1,22 @@ +import { createEmbedBlockSchema } from '../../../utils/index.js'; +import { + type EmbedFigmaBlockProps, + EmbedFigmaModel, + EmbedFigmaStyles, +} from './figma-model.js'; + +const defaultEmbedFigmaProps: EmbedFigmaBlockProps = { + style: EmbedFigmaStyles[0], + url: '', + caption: null, + + title: null, + description: null, +}; + +export const EmbedFigmaBlockSchema = createEmbedBlockSchema({ + name: 'figma', + version: 1, + toModel: () => new EmbedFigmaModel(), + props: (): EmbedFigmaBlockProps => defaultEmbedFigmaProps, +}); diff --git a/blocksuite/affine/model/src/blocks/embed/figma/index.ts b/blocksuite/affine/model/src/blocks/embed/figma/index.ts new file mode 100644 index 0000000000..98d85034ee --- /dev/null +++ b/blocksuite/affine/model/src/blocks/embed/figma/index.ts @@ -0,0 +1,2 @@ +export * from './figma-model.js'; +export * from './figma-schema.js'; diff --git a/blocksuite/affine/model/src/blocks/embed/github/github-model.ts b/blocksuite/affine/model/src/blocks/embed/github/github-model.ts new file mode 100644 index 0000000000..2644aa1ef5 --- /dev/null +++ b/blocksuite/affine/model/src/blocks/embed/github/github-model.ts @@ -0,0 +1,46 @@ +import { BlockModel } from '@blocksuite/store'; + +import type { EmbedCardStyle } from '../../../utils/index.js'; +import { defineEmbedModel } from '../../../utils/index.js'; + +export type EmbedGithubBlockUrlData = { + image: string | null; + status: string | null; + statusReason: string | null; + title: string | null; + description: string | null; + createdAt: string | null; + assignees: string[] | null; +}; + +export const EmbedGithubStyles: EmbedCardStyle[] = [ + 'vertical', + 'horizontal', + 'list', + 'cube', +] as const; + +export type EmbedGithubBlockProps = { + style: (typeof EmbedGithubStyles)[number]; + owner: string; + repo: string; + githubType: 'issue' | 'pr'; + githubId: string; + url: string; + caption: string | null; +} & EmbedGithubBlockUrlData; + +export class EmbedGithubModel extends defineEmbedModel( + BlockModel +) {} + +declare global { + namespace BlockSuite { + interface EdgelessBlockModelMap { + 'affine:embed-github': EmbedGithubModel; + } + interface BlockModels { + 'affine:embed-github': EmbedGithubModel; + } + } +} diff --git a/blocksuite/affine/model/src/blocks/embed/github/github-schema.ts b/blocksuite/affine/model/src/blocks/embed/github/github-schema.ts new file mode 100644 index 0000000000..e84f52eaf3 --- /dev/null +++ b/blocksuite/affine/model/src/blocks/embed/github/github-schema.ts @@ -0,0 +1,31 @@ +import { createEmbedBlockSchema } from '../../../utils/index.js'; +import { + type EmbedGithubBlockProps, + EmbedGithubModel, + EmbedGithubStyles, +} from './github-model.js'; + +const defaultEmbedGithubProps: EmbedGithubBlockProps = { + style: EmbedGithubStyles[1], + owner: '', + repo: '', + githubType: 'issue', + githubId: '', + url: '', + caption: null, + + image: null, + status: null, + statusReason: null, + title: null, + description: null, + createdAt: null, + assignees: null, +}; + +export const EmbedGithubBlockSchema = createEmbedBlockSchema({ + name: 'github', + version: 1, + toModel: () => new EmbedGithubModel(), + props: (): EmbedGithubBlockProps => defaultEmbedGithubProps, +}); diff --git a/blocksuite/affine/model/src/blocks/embed/github/index.ts b/blocksuite/affine/model/src/blocks/embed/github/index.ts new file mode 100644 index 0000000000..5c9115f5de --- /dev/null +++ b/blocksuite/affine/model/src/blocks/embed/github/index.ts @@ -0,0 +1,2 @@ +export * from './github-model.js'; +export * from './github-schema.js'; diff --git a/blocksuite/affine/model/src/blocks/embed/html/html-model.ts b/blocksuite/affine/model/src/blocks/embed/html/html-model.ts new file mode 100644 index 0000000000..e518927de8 --- /dev/null +++ b/blocksuite/affine/model/src/blocks/embed/html/html-model.ts @@ -0,0 +1,28 @@ +import { BlockModel } from '@blocksuite/store'; + +import type { EmbedCardStyle } from '../../../utils/index.js'; +import { defineEmbedModel } from '../../../utils/index.js'; + +export const EmbedHtmlStyles: EmbedCardStyle[] = ['html'] as const; + +export type EmbedHtmlBlockProps = { + style: (typeof EmbedHtmlStyles)[number]; + caption: string | null; + html?: string; + design?: string; +}; + +export class EmbedHtmlModel extends defineEmbedModel( + BlockModel +) {} + +declare global { + namespace BlockSuite { + interface EdgelessBlockModelMap { + 'affine:embed-html': EmbedHtmlModel; + } + interface BlockModels { + 'affine:embed-html': EmbedHtmlModel; + } + } +} diff --git a/blocksuite/affine/model/src/blocks/embed/html/html-schema.ts b/blocksuite/affine/model/src/blocks/embed/html/html-schema.ts new file mode 100644 index 0000000000..6d55c74e5f --- /dev/null +++ b/blocksuite/affine/model/src/blocks/embed/html/html-schema.ts @@ -0,0 +1,20 @@ +import { createEmbedBlockSchema } from '../../../utils/index.js'; +import { + type EmbedHtmlBlockProps, + EmbedHtmlModel, + EmbedHtmlStyles, +} from './html-model.js'; + +const defaultEmbedHtmlProps: EmbedHtmlBlockProps = { + style: EmbedHtmlStyles[0], + caption: null, + html: undefined, + design: undefined, +}; + +export const EmbedHtmlBlockSchema = createEmbedBlockSchema({ + name: 'html', + version: 1, + toModel: () => new EmbedHtmlModel(), + props: (): EmbedHtmlBlockProps => defaultEmbedHtmlProps, +}); diff --git a/blocksuite/affine/model/src/blocks/embed/html/index.ts b/blocksuite/affine/model/src/blocks/embed/html/index.ts new file mode 100644 index 0000000000..96075945ac --- /dev/null +++ b/blocksuite/affine/model/src/blocks/embed/html/index.ts @@ -0,0 +1,2 @@ +export * from './html-model.js'; +export * from './html-schema.js'; diff --git a/blocksuite/affine/model/src/blocks/embed/index.ts b/blocksuite/affine/model/src/blocks/embed/index.ts new file mode 100644 index 0000000000..9f25b0e727 --- /dev/null +++ b/blocksuite/affine/model/src/blocks/embed/index.ts @@ -0,0 +1,7 @@ +export * from './figma/index.js'; +export * from './github/index.js'; +export * from './html/index.js'; +export * from './linked-doc/index.js'; +export * from './loom/index.js'; +export * from './synced-doc/index.js'; +export * from './youtube/index.js'; diff --git a/blocksuite/affine/model/src/blocks/embed/linked-doc/index.ts b/blocksuite/affine/model/src/blocks/embed/linked-doc/index.ts new file mode 100644 index 0000000000..93ed3e5d82 --- /dev/null +++ b/blocksuite/affine/model/src/blocks/embed/linked-doc/index.ts @@ -0,0 +1,2 @@ +export * from './linked-doc-model.js'; +export * from './linked-doc-schema.js'; diff --git a/blocksuite/affine/model/src/blocks/embed/linked-doc/linked-doc-model.ts b/blocksuite/affine/model/src/blocks/embed/linked-doc/linked-doc-model.ts new file mode 100644 index 0000000000..2cff7fdabd --- /dev/null +++ b/blocksuite/affine/model/src/blocks/embed/linked-doc/linked-doc-model.ts @@ -0,0 +1,33 @@ +import { BlockModel } from '@blocksuite/store'; + +import type { ReferenceInfo } from '../../../consts/doc.js'; +import type { EmbedCardStyle } from '../../../utils/index.js'; +import { defineEmbedModel } from '../../../utils/index.js'; + +export const EmbedLinkedDocStyles: EmbedCardStyle[] = [ + 'vertical', + 'horizontal', + 'list', + 'cube', + 'horizontalThin', +]; + +export type EmbedLinkedDocBlockProps = { + style: EmbedCardStyle; + caption: string | null; +} & ReferenceInfo; + +export class EmbedLinkedDocModel extends defineEmbedModel( + BlockModel +) {} + +declare global { + namespace BlockSuite { + interface EdgelessBlockModelMap { + 'affine:embed-linked-doc': EmbedLinkedDocModel; + } + interface BlockModels { + 'affine:embed-linked-doc': EmbedLinkedDocModel; + } + } +} diff --git a/blocksuite/affine/model/src/blocks/embed/linked-doc/linked-doc-schema.ts b/blocksuite/affine/model/src/blocks/embed/linked-doc/linked-doc-schema.ts new file mode 100644 index 0000000000..f500023382 --- /dev/null +++ b/blocksuite/affine/model/src/blocks/embed/linked-doc/linked-doc-schema.ts @@ -0,0 +1,22 @@ +import { createEmbedBlockSchema } from '../../../utils/index.js'; +import { + type EmbedLinkedDocBlockProps, + EmbedLinkedDocModel, + EmbedLinkedDocStyles, +} from './linked-doc-model.js'; + +const defaultEmbedLinkedDocBlockProps: EmbedLinkedDocBlockProps = { + pageId: '', + style: EmbedLinkedDocStyles[1], + caption: null, + // title & description aliases + title: undefined, + description: undefined, +}; + +export const EmbedLinkedDocBlockSchema = createEmbedBlockSchema({ + name: 'linked-doc', + version: 1, + toModel: () => new EmbedLinkedDocModel(), + props: (): EmbedLinkedDocBlockProps => defaultEmbedLinkedDocBlockProps, +}); diff --git a/blocksuite/affine/model/src/blocks/embed/loom/index.ts b/blocksuite/affine/model/src/blocks/embed/loom/index.ts new file mode 100644 index 0000000000..a4da11bad4 --- /dev/null +++ b/blocksuite/affine/model/src/blocks/embed/loom/index.ts @@ -0,0 +1,2 @@ +export * from './loom-model.js'; +export * from './loom-schema.js'; diff --git a/blocksuite/affine/model/src/blocks/embed/loom/loom-model.ts b/blocksuite/affine/model/src/blocks/embed/loom/loom-model.ts new file mode 100644 index 0000000000..e11cb30a81 --- /dev/null +++ b/blocksuite/affine/model/src/blocks/embed/loom/loom-model.ts @@ -0,0 +1,34 @@ +import { BlockModel } from '@blocksuite/store'; + +import type { EmbedCardStyle } from '../../../utils/index.js'; +import { defineEmbedModel } from '../../../utils/index.js'; + +export type EmbedLoomBlockUrlData = { + videoId: string | null; + image: string | null; + title: string | null; + description: string | null; +}; + +export const EmbedLoomStyles: EmbedCardStyle[] = ['video'] as const; + +export type EmbedLoomBlockProps = { + style: (typeof EmbedLoomStyles)[number]; + url: string; + caption: string | null; +} & EmbedLoomBlockUrlData; + +export class EmbedLoomModel extends defineEmbedModel( + BlockModel +) {} + +declare global { + namespace BlockSuite { + interface EdgelessBlockModelMap { + 'affine:embed-loom': EmbedLoomModel; + } + interface BlockModels { + 'affine:embed-loom': EmbedLoomModel; + } + } +} diff --git a/blocksuite/affine/model/src/blocks/embed/loom/loom-schema.ts b/blocksuite/affine/model/src/blocks/embed/loom/loom-schema.ts new file mode 100644 index 0000000000..9035d915e7 --- /dev/null +++ b/blocksuite/affine/model/src/blocks/embed/loom/loom-schema.ts @@ -0,0 +1,24 @@ +import { createEmbedBlockSchema } from '../../../utils/index.js'; +import { + type EmbedLoomBlockProps, + EmbedLoomModel, + EmbedLoomStyles, +} from './loom-model.js'; + +const defaultEmbedLoomProps: EmbedLoomBlockProps = { + style: EmbedLoomStyles[0], + url: '', + caption: null, + + image: null, + title: null, + description: null, + videoId: null, +}; + +export const EmbedLoomBlockSchema = createEmbedBlockSchema({ + name: 'loom', + version: 1, + toModel: () => new EmbedLoomModel(), + props: (): EmbedLoomBlockProps => defaultEmbedLoomProps, +}); diff --git a/blocksuite/affine/model/src/blocks/embed/synced-doc/index.ts b/blocksuite/affine/model/src/blocks/embed/synced-doc/index.ts new file mode 100644 index 0000000000..16541371ee --- /dev/null +++ b/blocksuite/affine/model/src/blocks/embed/synced-doc/index.ts @@ -0,0 +1,2 @@ +export * from './synced-doc-model.js'; +export * from './synced-doc-schema.js'; diff --git a/blocksuite/affine/model/src/blocks/embed/synced-doc/synced-doc-model.ts b/blocksuite/affine/model/src/blocks/embed/synced-doc/synced-doc-model.ts new file mode 100644 index 0000000000..0833c786bf --- /dev/null +++ b/blocksuite/affine/model/src/blocks/embed/synced-doc/synced-doc-model.ts @@ -0,0 +1,28 @@ +import { BlockModel } from '@blocksuite/store'; + +import type { ReferenceInfo } from '../../../consts/doc.js'; +import type { EmbedCardStyle } from '../../../utils/index.js'; +import { defineEmbedModel } from '../../../utils/index.js'; + +export const EmbedSyncedDocStyles: EmbedCardStyle[] = ['syncedDoc']; + +export type EmbedSyncedDocBlockProps = { + style: EmbedCardStyle; + caption?: string | null; + scale?: number; +} & ReferenceInfo; + +export class EmbedSyncedDocModel extends defineEmbedModel( + BlockModel +) {} + +declare global { + namespace BlockSuite { + interface EdgelessBlockModelMap { + 'affine:embed-synced-doc': EmbedSyncedDocModel; + } + interface BlockModels { + 'affine:embed-synced-doc': EmbedSyncedDocModel; + } + } +} diff --git a/blocksuite/affine/model/src/blocks/embed/synced-doc/synced-doc-schema.ts b/blocksuite/affine/model/src/blocks/embed/synced-doc/synced-doc-schema.ts new file mode 100644 index 0000000000..135603e155 --- /dev/null +++ b/blocksuite/affine/model/src/blocks/embed/synced-doc/synced-doc-schema.ts @@ -0,0 +1,23 @@ +import { createEmbedBlockSchema } from '../../../utils/index.js'; +import { + type EmbedSyncedDocBlockProps, + EmbedSyncedDocModel, + EmbedSyncedDocStyles, +} from './synced-doc-model.js'; + +export const defaultEmbedSyncedDocBlockProps: EmbedSyncedDocBlockProps = { + pageId: '', + style: EmbedSyncedDocStyles[0], + caption: undefined, + scale: undefined, + // title & description aliases + title: undefined, + description: undefined, +}; + +export const EmbedSyncedDocBlockSchema = createEmbedBlockSchema({ + name: 'synced-doc', + version: 1, + toModel: () => new EmbedSyncedDocModel(), + props: (): EmbedSyncedDocBlockProps => defaultEmbedSyncedDocBlockProps, +}); diff --git a/blocksuite/affine/model/src/blocks/embed/youtube/index.ts b/blocksuite/affine/model/src/blocks/embed/youtube/index.ts new file mode 100644 index 0000000000..c338f0e265 --- /dev/null +++ b/blocksuite/affine/model/src/blocks/embed/youtube/index.ts @@ -0,0 +1,2 @@ +export * from './youtube-model.js'; +export * from './youtube-schema.js'; diff --git a/blocksuite/affine/model/src/blocks/embed/youtube/youtube-model.ts b/blocksuite/affine/model/src/blocks/embed/youtube/youtube-model.ts new file mode 100644 index 0000000000..c208a1e330 --- /dev/null +++ b/blocksuite/affine/model/src/blocks/embed/youtube/youtube-model.ts @@ -0,0 +1,37 @@ +import { BlockModel } from '@blocksuite/store'; + +import type { EmbedCardStyle } from '../../../utils/index.js'; +import { defineEmbedModel } from '../../../utils/index.js'; + +export type EmbedYoutubeBlockUrlData = { + videoId: string | null; + image: string | null; + title: string | null; + description: string | null; + creator: string | null; + creatorUrl: string | null; + creatorImage: string | null; +}; + +export const EmbedYoutubeStyles: EmbedCardStyle[] = ['video'] as const; + +export type EmbedYoutubeBlockProps = { + style: (typeof EmbedYoutubeStyles)[number]; + url: string; + caption: string | null; +} & EmbedYoutubeBlockUrlData; + +export class EmbedYoutubeModel extends defineEmbedModel( + BlockModel +) {} + +declare global { + namespace BlockSuite { + interface EdgelessBlockModelMap { + 'affine:embed-youtube': EmbedYoutubeModel; + } + interface BlockModels { + 'affine:embed-youtube': EmbedYoutubeModel; + } + } +} diff --git a/blocksuite/affine/model/src/blocks/embed/youtube/youtube-schema.ts b/blocksuite/affine/model/src/blocks/embed/youtube/youtube-schema.ts new file mode 100644 index 0000000000..a26dbdcc46 --- /dev/null +++ b/blocksuite/affine/model/src/blocks/embed/youtube/youtube-schema.ts @@ -0,0 +1,27 @@ +import { createEmbedBlockSchema } from '../../../utils/index.js'; +import { + type EmbedYoutubeBlockProps, + EmbedYoutubeModel, + EmbedYoutubeStyles, +} from './youtube-model.js'; + +const defaultEmbedYoutubeProps: EmbedYoutubeBlockProps = { + style: EmbedYoutubeStyles[0], + url: '', + caption: null, + + image: null, + title: null, + description: null, + creator: null, + creatorUrl: null, + creatorImage: null, + videoId: null, +}; + +export const EmbedYoutubeBlockSchema = createEmbedBlockSchema({ + name: 'youtube', + version: 1, + toModel: () => new EmbedYoutubeModel(), + props: (): EmbedYoutubeBlockProps => defaultEmbedYoutubeProps, +}); diff --git a/blocksuite/affine/model/src/blocks/frame/frame-model.ts b/blocksuite/affine/model/src/blocks/frame/frame-model.ts new file mode 100644 index 0000000000..9a53be18aa --- /dev/null +++ b/blocksuite/affine/model/src/blocks/frame/frame-model.ts @@ -0,0 +1,148 @@ +import type { + GfxBlockElementModel, + GfxCompatibleProps, + GfxElementGeometry, + GfxGroupCompatibleInterface, + GfxModel, + PointTestOptions, +} from '@blocksuite/block-std/gfx'; +import { + canSafeAddToContainer, + descendantElementsImpl, + generateKeyBetweenV2, + GfxCompatible, + gfxGroupCompatibleSymbol, + hasDescendantElementImpl, +} from '@blocksuite/block-std/gfx'; +import { Bound } from '@blocksuite/global/utils'; +import { BlockModel, defineBlockSchema, type Text } from '@blocksuite/store'; + +import type { Color } from '../../consts/index.js'; + +export type FrameBlockProps = { + title: Text; + background: Color; + childElementIds?: Record; + presentationIndex?: string; +} & GfxCompatibleProps; + +export const FrameBlockSchema = defineBlockSchema({ + flavour: 'affine:frame', + props: (internal): FrameBlockProps => ({ + title: internal.Text(), + background: '--affine-palette-transparent', + xywh: `[0,0,100,100]`, + index: 'a0', + childElementIds: Object.create(null), + presentationIndex: generateKeyBetweenV2(null, null), + lockedBySelf: false, + }), + metadata: { + version: 1, + role: 'content', + parent: ['affine:surface'], + children: [], + }, + toModel: () => { + return new FrameBlockModel(); + }, +}); + +export class FrameBlockModel + extends GfxCompatible(BlockModel) + implements GfxElementGeometry, GfxGroupCompatibleInterface +{ + [gfxGroupCompatibleSymbol] = true as const; + + get childElements() { + if (!this.surface) return []; + + const elements: GfxModel[] = []; + + for (const key of this.childIds) { + const element = + this.surface.getElementById(key) || + (this.surface.doc.getBlockById(key) as GfxBlockElementModel); + + element && elements.push(element); + } + + return elements; + } + + get childIds() { + return this.childElementIds ? Object.keys(this.childElementIds) : []; + } + + get descendantElements(): GfxModel[] { + return descendantElementsImpl(this); + } + + addChild(element: GfxModel) { + if (!canSafeAddToContainer(this, element)) return; + + this.doc.transact(() => { + this.childElementIds = { ...this.childElementIds, [element.id]: true }; + }); + } + + addChildren(elements: GfxModel[]): void { + elements = [...new Set(elements)].filter(element => + canSafeAddToContainer(this, element) + ); + + const newChildren: Record = {}; + for (const element of elements) { + const id = typeof element === 'string' ? element : element.id; + newChildren[id] = true; + } + + this.doc.transact(() => { + this.childElementIds = { + ...this.childElementIds, + ...newChildren, + }; + }); + } + + override containsBound(bound: Bound): boolean { + return this.elementBound.contains(bound); + } + + hasChild(element: GfxModel): boolean { + return this.childElementIds ? element.id in this.childElementIds : false; + } + + hasDescendant(element: GfxModel): boolean { + return hasDescendantElementImpl(this, element); + } + + override includesPoint(x: number, y: number, _: PointTestOptions): boolean { + const bound = Bound.deserialize(this.xywh); + return bound.isPointInBound([x, y]); + } + + override intersectsBound(selectedBound: Bound): boolean { + const bound = Bound.deserialize(this.xywh); + return ( + bound.isIntersectWithBound(selectedBound) || selectedBound.contains(bound) + ); + } + + removeChild(element: GfxModel): void { + this.doc.transact(() => { + this.childElementIds && delete this.childElementIds[element.id]; + }); + } +} + +declare global { + namespace BlockSuite { + interface EdgelessBlockModelMap { + 'affine:frame': FrameBlockModel; + } + interface BlockModels { + 'affine:frame': FrameBlockModel; + } + } +} diff --git a/blocksuite/affine/model/src/blocks/frame/index.ts b/blocksuite/affine/model/src/blocks/frame/index.ts new file mode 100644 index 0000000000..97afd9b296 --- /dev/null +++ b/blocksuite/affine/model/src/blocks/frame/index.ts @@ -0,0 +1 @@ +export * from './frame-model.js'; diff --git a/blocksuite/affine/model/src/blocks/image/image-model.ts b/blocksuite/affine/model/src/blocks/image/image-model.ts new file mode 100644 index 0000000000..6bcd97898f --- /dev/null +++ b/blocksuite/affine/model/src/blocks/image/image-model.ts @@ -0,0 +1,55 @@ +import type { + GfxCommonBlockProps, + GfxElementGeometry, +} from '@blocksuite/block-std/gfx'; +import { GfxCompatible } from '@blocksuite/block-std/gfx'; +import { BlockModel, defineBlockSchema } from '@blocksuite/store'; + +import { ImageBlockTransformer } from './image-transformer.js'; + +export type ImageBlockProps = { + caption?: string; + sourceId?: string; + width?: number; + height?: number; + rotate: number; + size?: number; +} & Omit; + +const defaultImageProps: ImageBlockProps = { + caption: '', + sourceId: '', + width: 0, + height: 0, + index: 'a0', + xywh: '[0,0,0,0]', + lockedBySelf: false, + rotate: 0, + size: -1, +}; + +export const ImageBlockSchema = defineBlockSchema({ + flavour: 'affine:image', + props: () => defaultImageProps, + metadata: { + version: 1, + role: 'content', + }, + transformer: () => new ImageBlockTransformer(), + toModel: () => new ImageBlockModel(), +}); + +export class ImageBlockModel + extends GfxCompatible(BlockModel) + implements GfxElementGeometry {} + +declare global { + namespace BlockSuite { + interface BlockModels { + 'affine:image': ImageBlockModel; + } + interface EdgelessBlockModelMap { + 'affine:image': ImageBlockModel; + } + } +} diff --git a/blocksuite/affine/model/src/blocks/image/image-transformer.ts b/blocksuite/affine/model/src/blocks/image/image-transformer.ts new file mode 100644 index 0000000000..1135659392 --- /dev/null +++ b/blocksuite/affine/model/src/blocks/image/image-transformer.ts @@ -0,0 +1,17 @@ +import type { FromSnapshotPayload, SnapshotNode } from '@blocksuite/store'; +import { BaseBlockTransformer } from '@blocksuite/store'; + +import type { ImageBlockProps } from './image-model.js'; + +export class ImageBlockTransformer extends BaseBlockTransformer { + override async fromSnapshot( + payload: FromSnapshotPayload + ): Promise> { + const snapshotRet = await super.fromSnapshot(payload); + const sourceId = snapshotRet.props.sourceId; + if (!payload.assets.isEmpty() && sourceId && !sourceId.startsWith('/')) + await payload.assets.writeToBlob(sourceId); + + return snapshotRet; + } +} diff --git a/blocksuite/affine/model/src/blocks/image/index.ts b/blocksuite/affine/model/src/blocks/image/index.ts new file mode 100644 index 0000000000..89cbd0ea93 --- /dev/null +++ b/blocksuite/affine/model/src/blocks/image/index.ts @@ -0,0 +1,2 @@ +export * from './image-model.js'; +export * from './image-transformer.js'; diff --git a/blocksuite/affine/model/src/blocks/index.ts b/blocksuite/affine/model/src/blocks/index.ts new file mode 100644 index 0000000000..67d6f1e4bb --- /dev/null +++ b/blocksuite/affine/model/src/blocks/index.ts @@ -0,0 +1,15 @@ +export * from './attachment/index.js'; +export * from './bookmark/index.js'; +export * from './code/index.js'; +export * from './database/index.js'; +export * from './divider/index.js'; +export * from './edgeless-text/index.js'; +export * from './embed/index.js'; +export * from './frame/index.js'; +export * from './image/index.js'; +export * from './latex/index.js'; +export * from './list/index.js'; +export * from './note/index.js'; +export * from './paragraph/index.js'; +export * from './root/index.js'; +export * from './surface-ref/index.js'; diff --git a/blocksuite/affine/model/src/blocks/latex/index.ts b/blocksuite/affine/model/src/blocks/latex/index.ts new file mode 100644 index 0000000000..b0d3001324 --- /dev/null +++ b/blocksuite/affine/model/src/blocks/latex/index.ts @@ -0,0 +1 @@ +export * from './latex-model.js'; diff --git a/blocksuite/affine/model/src/blocks/latex/latex-model.ts b/blocksuite/affine/model/src/blocks/latex/latex-model.ts new file mode 100644 index 0000000000..31f1a6b23d --- /dev/null +++ b/blocksuite/affine/model/src/blocks/latex/latex-model.ts @@ -0,0 +1,51 @@ +import { + type GfxCommonBlockProps, + GfxCompatible, + type GfxElementGeometry, +} from '@blocksuite/block-std/gfx'; +import { BlockModel, defineBlockSchema } from '@blocksuite/store'; + +export type LatexProps = { + latex: string; +} & GfxCommonBlockProps; + +export const LatexBlockSchema = defineBlockSchema({ + flavour: 'affine:latex', + props: (): LatexProps => ({ + xywh: '[0,0,16,16]', + index: 'a0', + lockedBySelf: false, + scale: 1, + rotate: 0, + latex: '', + }), + metadata: { + version: 1, + role: 'content', + parent: [ + 'affine:note', + 'affine:edgeless-text', + 'affine:paragraph', + 'affine:list', + ], + }, + toModel: () => { + return new LatexBlockModel(); + }, +}); + +export class LatexBlockModel + extends GfxCompatible(BlockModel) + implements GfxElementGeometry {} + +declare global { + namespace BlockSuite { + interface BlockModels { + 'affine:latex': LatexBlockModel; + } + + interface EdgelessBlockModelMap { + 'affine:latex': LatexBlockModel; + } + } +} diff --git a/blocksuite/affine/model/src/blocks/list/index.ts b/blocksuite/affine/model/src/blocks/list/index.ts new file mode 100644 index 0000000000..cc8792d6ab --- /dev/null +++ b/blocksuite/affine/model/src/blocks/list/index.ts @@ -0,0 +1 @@ +export * from './list-model.js'; diff --git a/blocksuite/affine/model/src/blocks/list/list-model.ts b/blocksuite/affine/model/src/blocks/list/list-model.ts new file mode 100644 index 0000000000..b694906cad --- /dev/null +++ b/blocksuite/affine/model/src/blocks/list/list-model.ts @@ -0,0 +1,48 @@ +import type { SchemaToModel, Text } from '@blocksuite/store'; +import { defineBlockSchema } from '@blocksuite/store'; + +// `toggle` type has been deprecated, do not use it +export type ListType = 'bulleted' | 'numbered' | 'todo' | 'toggle'; + +export interface ListProps { + type: ListType; + text: Text; + checked: boolean; + collapsed: boolean; + order: number | null; +} + +export const ListBlockSchema = defineBlockSchema({ + flavour: 'affine:list', + props: internal => + ({ + type: 'bulleted', + text: internal.Text(), + checked: false, + collapsed: false, + + // number type only for numbered list + order: null, + }) as ListProps, + metadata: { + version: 1, + role: 'content', + parent: [ + 'affine:note', + 'affine:database', + 'affine:list', + 'affine:paragraph', + 'affine:edgeless-text', + ], + }, +}); + +export type ListBlockModel = SchemaToModel; + +declare global { + namespace BlockSuite { + interface BlockModels { + 'affine:list': ListBlockModel; + } + } +} diff --git a/blocksuite/affine/model/src/blocks/note/index.ts b/blocksuite/affine/model/src/blocks/note/index.ts new file mode 100644 index 0000000000..b98f6db6fc --- /dev/null +++ b/blocksuite/affine/model/src/blocks/note/index.ts @@ -0,0 +1 @@ +export * from './note-model.js'; diff --git a/blocksuite/affine/model/src/blocks/note/note-model.ts b/blocksuite/affine/model/src/blocks/note/note-model.ts new file mode 100644 index 0000000000..24915a2e77 --- /dev/null +++ b/blocksuite/affine/model/src/blocks/note/note-model.ts @@ -0,0 +1,126 @@ +import type { + GfxCompatibleProps, + GfxElementGeometry, +} from '@blocksuite/block-std/gfx'; +import { GfxCompatible } from '@blocksuite/block-std/gfx'; +import { Bound } from '@blocksuite/global/utils'; +import { BlockModel, defineBlockSchema } from '@blocksuite/store'; + +import { + type Color, + DEFAULT_NOTE_BACKGROUND_COLOR, + DEFAULT_NOTE_BORDER_SIZE, + DEFAULT_NOTE_BORDER_STYLE, + DEFAULT_NOTE_CORNER, + DEFAULT_NOTE_HEIGHT, + DEFAULT_NOTE_SHADOW, + DEFAULT_NOTE_WIDTH, + NoteDisplayMode, + type StrokeStyle, +} from '../../consts/index.js'; + +export const NoteBlockSchema = defineBlockSchema({ + flavour: 'affine:note', + props: (): NoteProps => ({ + xywh: `[0,0,${DEFAULT_NOTE_WIDTH},${DEFAULT_NOTE_HEIGHT}]`, + background: DEFAULT_NOTE_BACKGROUND_COLOR, + index: 'a0', + lockedBySelf: false, + hidden: false, + displayMode: NoteDisplayMode.DocAndEdgeless, + edgeless: { + style: { + borderRadius: DEFAULT_NOTE_CORNER, + borderSize: DEFAULT_NOTE_BORDER_SIZE, + borderStyle: DEFAULT_NOTE_BORDER_STYLE, + shadowType: DEFAULT_NOTE_SHADOW, + }, + }, + }), + metadata: { + version: 1, + role: 'hub', + parent: ['affine:page'], + children: [ + 'affine:paragraph', + 'affine:list', + 'affine:code', + 'affine:divider', + 'affine:database', + 'affine:data-view', + 'affine:image', + 'affine:bookmark', + 'affine:attachment', + 'affine:surface-ref', + 'affine:embed-*', + 'affine:latex', + ], + }, + toModel: () => { + return new NoteBlockModel(); + }, +}); + +export type NoteProps = { + background: Color; + displayMode: NoteDisplayMode; + edgeless: NoteEdgelessProps; + /** + * @deprecated + * use `displayMode` instead + * hidden:true -> displayMode:NoteDisplayMode.EdgelessOnly: + * means the note is visible only in the edgeless mode + * hidden:false -> displayMode:NoteDisplayMode.DocAndEdgeless: + * means the note is visible in the doc and edgeless mode + */ + hidden: boolean; +} & GfxCompatibleProps; + +export type NoteEdgelessProps = { + style: { + borderRadius: number; + borderSize: number; + borderStyle: StrokeStyle; + shadowType: string; + }; + collapse?: boolean; + collapsedHeight?: number; + scale?: number; +}; + +export class NoteBlockModel + extends GfxCompatible(BlockModel) + implements GfxElementGeometry +{ + private _isSelectable(): boolean { + return this.displayMode !== NoteDisplayMode.DocOnly; + } + + override containsBound(bounds: Bound): boolean { + if (!this._isSelectable()) return false; + return super.containsBound(bounds); + } + + override includesPoint(x: number, y: number): boolean { + if (!this._isSelectable()) return false; + + const bound = Bound.deserialize(this.xywh); + return bound.isPointInBound([x, y], 0); + } + + override intersectsBound(bound: Bound): boolean { + if (!this._isSelectable()) return false; + return super.intersectsBound(bound); + } +} + +declare global { + namespace BlockSuite { + interface BlockModels { + 'affine:note': NoteBlockModel; + } + interface EdgelessBlockModelMap { + 'affine:note': NoteBlockModel; + } + } +} diff --git a/blocksuite/affine/model/src/blocks/paragraph/index.ts b/blocksuite/affine/model/src/blocks/paragraph/index.ts new file mode 100644 index 0000000000..9c5c11592a --- /dev/null +++ b/blocksuite/affine/model/src/blocks/paragraph/index.ts @@ -0,0 +1 @@ +export * from './paragraph-model.js'; diff --git a/blocksuite/affine/model/src/blocks/paragraph/paragraph-model.ts b/blocksuite/affine/model/src/blocks/paragraph/paragraph-model.ts new file mode 100644 index 0000000000..c467c48bca --- /dev/null +++ b/blocksuite/affine/model/src/blocks/paragraph/paragraph-model.ts @@ -0,0 +1,52 @@ +import { BlockModel, defineBlockSchema, type Text } from '@blocksuite/store'; + +export type ParagraphType = + | 'text' + | 'quote' + | 'h1' + | 'h2' + | 'h3' + | 'h4' + | 'h5' + | 'h6'; + +export type ParagraphProps = { + type: ParagraphType; + text: Text; + collapsed: boolean; +}; + +export const ParagraphBlockSchema = defineBlockSchema({ + flavour: 'affine:paragraph', + props: (internal): ParagraphProps => ({ + type: 'text', + text: internal.Text(), + collapsed: false, + }), + metadata: { + version: 1, + role: 'content', + parent: [ + 'affine:note', + 'affine:database', + 'affine:paragraph', + 'affine:list', + 'affine:edgeless-text', + ], + }, + toModel: () => new ParagraphBlockModel(), +}); + +export class ParagraphBlockModel extends BlockModel { + override flavour!: 'affine:paragraph'; + + override text!: Text; +} + +declare global { + namespace BlockSuite { + interface BlockModels { + 'affine:paragraph': ParagraphBlockModel; + } + } +} diff --git a/blocksuite/affine/model/src/blocks/root/index.ts b/blocksuite/affine/model/src/blocks/root/index.ts new file mode 100644 index 0000000000..d78d9e4ec0 --- /dev/null +++ b/blocksuite/affine/model/src/blocks/root/index.ts @@ -0,0 +1 @@ +export * from './root-block-model.js'; diff --git a/blocksuite/affine/model/src/blocks/root/root-block-model.ts b/blocksuite/affine/model/src/blocks/root/root-block-model.ts new file mode 100644 index 0000000000..ab52d9c55c --- /dev/null +++ b/blocksuite/affine/model/src/blocks/root/root-block-model.ts @@ -0,0 +1,45 @@ +import type { Text } from '@blocksuite/store'; +import { BlockModel, defineBlockSchema } from '@blocksuite/store'; + +export type RootBlockProps = { + title: Text; +}; + +export class RootBlockModel extends BlockModel { + constructor() { + super(); + this.created.once(() => { + this.doc.slots.rootAdded.on(id => { + const model = this.doc.getBlockById(id); + if (model instanceof RootBlockModel) { + const newDocMeta = this.doc.collection.meta.getDocMeta(model.doc.id); + if (!newDocMeta || newDocMeta.title !== model.title.toString()) { + this.doc.collection.setDocMeta(model.doc.id, { + title: model.title.toString(), + }); + } + } + }); + }); + } +} + +export const RootBlockSchema = defineBlockSchema({ + flavour: 'affine:page', + props: (internal): RootBlockProps => ({ + title: internal.Text(), + }), + metadata: { + version: 2, + role: 'root', + }, + toModel: () => new RootBlockModel(), +}); + +declare global { + namespace BlockSuite { + interface BlockModels { + 'affine:page': RootBlockModel; + } + } +} diff --git a/blocksuite/affine/model/src/blocks/surface-ref/index.ts b/blocksuite/affine/model/src/blocks/surface-ref/index.ts new file mode 100644 index 0000000000..0788c97f1f --- /dev/null +++ b/blocksuite/affine/model/src/blocks/surface-ref/index.ts @@ -0,0 +1 @@ +export * from './surface-ref-model.js'; diff --git a/blocksuite/affine/model/src/blocks/surface-ref/surface-ref-model.ts b/blocksuite/affine/model/src/blocks/surface-ref/surface-ref-model.ts new file mode 100644 index 0000000000..636b161b8e --- /dev/null +++ b/blocksuite/affine/model/src/blocks/surface-ref/surface-ref-model.ts @@ -0,0 +1,31 @@ +import { defineBlockSchema, type SchemaToModel } from '@blocksuite/store'; + +export type SurfaceRefProps = { + reference: string; + caption: string; + refFlavour: string; +}; + +export const SurfaceRefBlockSchema = defineBlockSchema({ + flavour: 'affine:surface-ref', + props: () => + ({ + reference: '', + caption: '', + }) as SurfaceRefProps, + metadata: { + version: 1, + role: 'content', + parent: ['affine:note', 'affine:paragraph', 'affine:list'], + }, +}); + +export type SurfaceRefBlockModel = SchemaToModel; + +declare global { + namespace BlockSuite { + interface BlockModels { + 'affine:surface-ref': SurfaceRefBlockModel; + } + } +} diff --git a/blocksuite/affine/model/src/consts/brush.ts b/blocksuite/affine/model/src/consts/brush.ts new file mode 100644 index 0000000000..cdbfc9a505 --- /dev/null +++ b/blocksuite/affine/model/src/consts/brush.ts @@ -0,0 +1,3 @@ +import { LineColor } from './line.js'; + +export const DEFAULT_BRUSH_COLOR = LineColor.Blue; diff --git a/blocksuite/affine/model/src/consts/connector.ts b/blocksuite/affine/model/src/consts/connector.ts new file mode 100644 index 0000000000..4222f98574 --- /dev/null +++ b/blocksuite/affine/model/src/consts/connector.ts @@ -0,0 +1,39 @@ +import { createEnumMap } from '../utils/enum.js'; +import { LineColor } from './line.js'; + +export enum ConnectorEndpoint { + Front = 'Front', + Rear = 'Rear', +} + +export enum PointStyle { + Arrow = 'Arrow', + Circle = 'Circle', + Diamond = 'Diamond', + None = 'None', + Triangle = 'Triangle', +} + +export const PointStyleMap = createEnumMap(PointStyle); + +export const DEFAULT_CONNECTOR_COLOR = LineColor.Grey; + +export const DEFAULT_CONNECTOR_TEXT_COLOR = LineColor.Black; + +export const DEFAULT_FRONT_END_POINT_STYLE = PointStyle.None; + +export const DEFAULT_REAR_END_POINT_STYLE = PointStyle.Arrow; + +export const CONNECTOR_LABEL_MAX_WIDTH = 280; + +export enum ConnectorLabelOffsetAnchor { + Bottom = 'bottom', + Center = 'center', + Top = 'top', +} + +export enum ConnectorMode { + Straight, + Orthogonal, + Curve, +} diff --git a/blocksuite/affine/model/src/consts/doc.ts b/blocksuite/affine/model/src/consts/doc.ts new file mode 100644 index 0000000000..22a5c11d89 --- /dev/null +++ b/blocksuite/affine/model/src/consts/doc.ts @@ -0,0 +1,44 @@ +import { z } from 'zod'; + +export type DocMode = 'edgeless' | 'page'; + +export const DocModes = ['edgeless', 'page'] as const; + +/** + * Custom title and description information. + * + * Supports the following blocks: + * + * 1. Inline View: `AffineReference` - title + * 2. Card View: `EmbedLinkedDocBlock` - title & description + * 3. Embed View: `EmbedSyncedDocBlock` - title + */ +export const AliasInfoSchema = z + .object({ + title: z.string(), + description: z.string(), + }) + .partial(); + +export type AliasInfo = z.infer; + +export const ReferenceParamsSchema = z + .object({ + mode: z.enum(DocModes), + blockIds: z.string().array(), + elementIds: z.string().array(), + databaseId: z.string().optional(), + databaseRowId: z.string().optional(), + }) + .partial(); + +export type ReferenceParams = z.infer; + +export const ReferenceInfoSchema = z + .object({ + pageId: z.string(), + params: ReferenceParamsSchema.optional(), + }) + .merge(AliasInfoSchema); + +export type ReferenceInfo = z.infer; diff --git a/blocksuite/affine/model/src/consts/frame.ts b/blocksuite/affine/model/src/consts/frame.ts new file mode 100644 index 0000000000..15c98574c3 --- /dev/null +++ b/blocksuite/affine/model/src/consts/frame.ts @@ -0,0 +1,27 @@ +import { z } from 'zod'; + +export enum FrameBackgroundColor { + Blue = '--affine-tag-blue', + Gray = '--affine-tag-gray', + Green = '--affine-tag-green', + Orange = '--affine-tag-orange', + Pink = '--affine-tag-pink', + Purple = '--affine-tag-purple', + Red = '--affine-tag-red', + Teal = '--affine-tag-teal', + Yellow = '--affine-tag-yellow', +} + +export const FRAME_BACKGROUND_COLORS = [ + FrameBackgroundColor.Gray, + FrameBackgroundColor.Red, + FrameBackgroundColor.Orange, + FrameBackgroundColor.Yellow, + FrameBackgroundColor.Green, + FrameBackgroundColor.Teal, + FrameBackgroundColor.Blue, + FrameBackgroundColor.Purple, + FrameBackgroundColor.Pink, +]; + +export const FrameBackgroundColorsSchema = z.nativeEnum(FrameBackgroundColor); diff --git a/blocksuite/affine/model/src/consts/index.ts b/blocksuite/affine/model/src/consts/index.ts new file mode 100644 index 0000000000..f871216c5c --- /dev/null +++ b/blocksuite/affine/model/src/consts/index.ts @@ -0,0 +1,9 @@ +export * from './brush.js'; +export * from './connector.js'; +export * from './doc.js'; +export * from './frame.js'; +export * from './line.js'; +export * from './mindmap.js'; +export * from './note.js'; +export * from './shape.js'; +export * from './text.js'; diff --git a/blocksuite/affine/model/src/consts/line.ts b/blocksuite/affine/model/src/consts/line.ts new file mode 100644 index 0000000000..83ff57c902 --- /dev/null +++ b/blocksuite/affine/model/src/consts/line.ts @@ -0,0 +1,46 @@ +import { z } from 'zod'; + +import { createEnumMap } from '../utils/enum.js'; + +export enum LineWidth { + Eight = 8, + // Thin + Four = 4, + Six = 6, + // Thick + Ten = 10, + Twelve = 12, + Two = 2, +} + +export enum LineColor { + Black = '--affine-palette-line-black', + Blue = '--affine-palette-line-blue', + Green = '--affine-palette-line-green', + Grey = '--affine-palette-line-grey', + Magenta = '--affine-palette-line-magenta', + Orange = '--affine-palette-line-orange', + Purple = '--affine-palette-line-purple', + Red = '--affine-palette-line-red', + Teal = '--affine-palette-line-teal', + White = '--affine-palette-line-white', + Yellow = '--affine-palette-line-yellow', +} + +export const LineColorMap = createEnumMap(LineColor); + +export const LINE_COLORS = [ + LineColor.Yellow, + LineColor.Orange, + LineColor.Red, + LineColor.Magenta, + LineColor.Purple, + LineColor.Blue, + LineColor.Teal, + LineColor.Green, + LineColor.Black, + LineColor.Grey, + LineColor.White, +] as const; + +export const LineColorsSchema = z.nativeEnum(LineColor); diff --git a/blocksuite/affine/model/src/consts/mindmap.ts b/blocksuite/affine/model/src/consts/mindmap.ts new file mode 100644 index 0000000000..5a6857006a --- /dev/null +++ b/blocksuite/affine/model/src/consts/mindmap.ts @@ -0,0 +1,12 @@ +export enum LayoutType { + BALANCE = 2, + LEFT = 1, + RIGHT = 0, +} + +export enum MindmapStyle { + FOUR = 4, + ONE = 1, + THREE = 3, + TWO = 2, +} diff --git a/blocksuite/affine/model/src/consts/note.ts b/blocksuite/affine/model/src/consts/note.ts new file mode 100644 index 0000000000..7046999f99 --- /dev/null +++ b/blocksuite/affine/model/src/consts/note.ts @@ -0,0 +1,107 @@ +import { z } from 'zod'; + +import { createEnumMap } from '../utils/enum.js'; + +export const NOTE_MIN_WIDTH = 450 + 24 * 2; +export const NOTE_MIN_HEIGHT = 92; + +export const DEFAULT_NOTE_WIDTH = NOTE_MIN_WIDTH; +export const DEFAULT_NOTE_HEIGHT = NOTE_MIN_HEIGHT; + +export enum NoteBackgroundColor { + Black = '--affine-note-background-black', + Blue = '--affine-note-background-blue', + Green = '--affine-note-background-green', + Grey = '--affine-note-background-grey', + Magenta = '--affine-note-background-magenta', + Orange = '--affine-note-background-orange', + Purple = '--affine-note-background-purple', + Red = '--affine-note-background-red', + Teal = '--affine-note-background-teal', + White = '--affine-note-background-white', + Yellow = '--affine-note-background-yellow', +} + +export const NoteBackgroundColorMap = createEnumMap(NoteBackgroundColor); + +export const NOTE_BACKGROUND_COLORS = [ + NoteBackgroundColor.Yellow, + NoteBackgroundColor.Orange, + NoteBackgroundColor.Red, + NoteBackgroundColor.Magenta, + NoteBackgroundColor.Purple, + NoteBackgroundColor.Blue, + NoteBackgroundColor.Teal, + NoteBackgroundColor.Green, + NoteBackgroundColor.Black, + NoteBackgroundColor.Grey, + NoteBackgroundColor.White, +] as const; + +export const DEFAULT_NOTE_BACKGROUND_COLOR = NoteBackgroundColor.White; + +export const NoteBackgroundColorsSchema = z.nativeEnum(NoteBackgroundColor); + +export enum NoteShadow { + Box = '--affine-note-shadow-box', + Film = '--affine-note-shadow-film', + Float = '--affine-note-shadow-float', + None = '', + Paper = '--affine-note-shadow-paper', + Sticker = '--affine-note-shadow-sticker', +} + +export const NoteShadowMap = createEnumMap(NoteShadow); + +export const NOTE_SHADOWS = [ + NoteShadow.None, + NoteShadow.Box, + NoteShadow.Sticker, + NoteShadow.Paper, + NoteShadow.Float, + NoteShadow.Film, +] as const; + +export const DEFAULT_NOTE_SHADOW = NoteShadow.Box; + +export const NoteShadowsSchema = z.nativeEnum(NoteShadow); + +export enum NoteDisplayMode { + DocAndEdgeless = 'both', + DocOnly = 'doc', + EdgelessOnly = 'edgeless', +} + +export enum StrokeStyle { + Dash = 'dash', + None = 'none', + Solid = 'solid', +} + +export const DEFAULT_NOTE_BORDER_STYLE = StrokeStyle.None; + +export const StrokeStyleMap = createEnumMap(StrokeStyle); + +export enum NoteCorners { + Huge = 32, + Large = 24, + Medium = 16, + None = 0, + Small = 8, +} + +export const NoteCornersMap = createEnumMap(NoteCorners); + +export const NOTE_CORNERS = [ + NoteCorners.None, + NoteCorners.Small, + NoteCorners.Medium, + NoteCorners.Large, + NoteCorners.Huge, +] as const; + +export const DEFAULT_NOTE_CORNER = NoteCorners.Small; + +export const NoteCornersSchema = z.nativeEnum(NoteCorners); + +export const DEFAULT_NOTE_BORDER_SIZE = 4; diff --git a/blocksuite/affine/model/src/consts/shape.ts b/blocksuite/affine/model/src/consts/shape.ts new file mode 100644 index 0000000000..5ffb459671 --- /dev/null +++ b/blocksuite/affine/model/src/consts/shape.ts @@ -0,0 +1,90 @@ +import { z } from 'zod'; + +import { LINE_COLORS, LineColor } from './line.js'; + +export const DEFAULT_ROUGHNESS = 1.4; + +// TODO: need to check the default central area ratio +export const DEFAULT_CENTRAL_AREA_RATIO = 0.3; + +export enum ShapeTextFontSize { + LARGE = 28, + MEDIUM = 20, + SMALL = 12, + XLARGE = 36, +} + +export enum ShapeType { + Diamond = 'diamond', + Ellipse = 'ellipse', + Rect = 'rect', + Triangle = 'triangle', +} + +export type ShapeName = ShapeType | 'roundedRect'; + +export function getShapeName(type: ShapeType, radius: number): ShapeName { + if (type === ShapeType.Rect && radius > 0) { + return 'roundedRect'; + } + return type; +} + +export function getShapeType(name: ShapeName): ShapeType { + if (name === 'roundedRect') { + return ShapeType.Rect; + } + return name; +} + +export function getShapeRadius(name: ShapeName): number { + if (name === 'roundedRect') { + return 0.1; + } + return 0; +} + +export enum ShapeStyle { + General = 'General', + Scribbled = 'Scribbled', +} + +export enum ShapeFillColor { + Black = '--affine-palette-shape-black', + Blue = '--affine-palette-shape-blue', + Green = '--affine-palette-shape-green', + Grey = '--affine-palette-shape-grey', + Magenta = '--affine-palette-shape-magenta', + Orange = '--affine-palette-shape-orange', + Purple = '--affine-palette-shape-purple', + Red = '--affine-palette-shape-red', + Teal = '--affine-palette-shape-teal', + White = '--affine-palette-shape-white', + Yellow = '--affine-palette-shape-yellow', +} + +export const SHAPE_FILL_COLORS = [ + ShapeFillColor.Yellow, + ShapeFillColor.Orange, + ShapeFillColor.Red, + ShapeFillColor.Magenta, + ShapeFillColor.Purple, + ShapeFillColor.Blue, + ShapeFillColor.Teal, + ShapeFillColor.Green, + ShapeFillColor.Black, + ShapeFillColor.Grey, + ShapeFillColor.White, +] as const; + +export const DEFAULT_SHAPE_FILL_COLOR = ShapeFillColor.Yellow; + +export const FillColorsSchema = z.nativeEnum(ShapeFillColor); + +export const SHAPE_STROKE_COLORS = LINE_COLORS; + +export const DEFAULT_SHAPE_STROKE_COLOR = LineColor.Yellow; + +export const DEFAULT_SHAPE_TEXT_COLOR = LineColor.Black; + +export const StrokeColorsSchema = z.nativeEnum(LineColor); diff --git a/blocksuite/affine/model/src/consts/text.ts b/blocksuite/affine/model/src/consts/text.ts new file mode 100644 index 0000000000..83ca28c327 --- /dev/null +++ b/blocksuite/affine/model/src/consts/text.ts @@ -0,0 +1,70 @@ +import { createEnumMap } from '../utils/enum.js'; +import { LineColor } from './line.js'; + +export enum ColorScheme { + Dark = 'dark', + Light = 'light', +} + +export type Color = string | Partial>; + +export enum TextAlign { + Center = 'center', + Left = 'left', + Right = 'right', +} + +export const TextAlignMap = createEnumMap(TextAlign); + +export enum TextVerticalAlign { + Bottom = 'bottom', + Center = 'center', + Top = 'top', +} + +export type TextStyleProps = { + color: Color; + fontFamily: FontFamily; + fontSize: number; + fontStyle: FontStyle; + fontWeight: FontWeight; + textAlign: TextAlign; +}; + +export enum FontWeight { + Bold = '700', + Light = '300', + Medium = '500', + Regular = '400', + SemiBold = '600', +} + +export const FontWeightMap = createEnumMap(FontWeight); + +export enum FontStyle { + Italic = 'italic', + Normal = 'normal', +} + +export enum FontFamily { + BebasNeue = 'blocksuite:surface:BebasNeue', + Inter = 'blocksuite:surface:Inter', + Kalam = 'blocksuite:surface:Kalam', + Lora = 'blocksuite:surface:Lora', + OrelegaOne = 'blocksuite:surface:OrelegaOne', + Poppins = 'blocksuite:surface:Poppins', + Satoshi = 'blocksuite:surface:Satoshi', +} + +export const FontFamilyMap = createEnumMap(FontFamily); + +export const FontFamilyList = Object.entries(FontFamilyMap) as { + [K in FontFamily]: [K, (typeof FontFamilyMap)[K]]; +}[FontFamily][]; + +export enum TextResizing { + AUTO_WIDTH_AND_HEIGHT, + AUTO_HEIGHT, +} + +export const DEFAULT_TEXT_COLOR = LineColor.Blue; diff --git a/blocksuite/affine/model/src/elements/brush/brush.ts b/blocksuite/affine/model/src/elements/brush/brush.ts new file mode 100644 index 0000000000..3d05abcf90 --- /dev/null +++ b/blocksuite/affine/model/src/elements/brush/brush.ts @@ -0,0 +1,234 @@ +import type { + BaseElementProps, + PointTestOptions, +} from '@blocksuite/block-std/gfx'; +import { + convert, + derive, + field, + GfxPrimitiveElementModel, + watch, +} from '@blocksuite/block-std/gfx'; +import { + Bound, + getBoundFromPoints, + getPointsFromBoundWithRotation, + getQuadBoundWithRotation, + getSolidStrokePoints, + getSvgPathFromStroke, + inflateBound, + isPointOnlines, + type IVec, + type IVec3, + lineIntersects, + PointLocation, + polyLineNearestPoint, + type SerializedXYWH, + transformPointsToNewBound, + Vec, +} from '@blocksuite/global/utils'; + +import type { Color } from '../../consts/index.js'; + +export type BrushProps = BaseElementProps & { + /** + * [[x0,y0,pressure0?],[x1,y1,pressure1?]...] + * pressure is optional and exsits when pressure sensitivity is supported, otherwise not. + */ + points: number[][]; + color: Color; + lineWidth: number; +}; + +export class BrushElementModel extends GfxPrimitiveElementModel { + /** + * The SVG path commands for the brush. + */ + get commands() { + if (!this._local.has('commands')) { + const stroke = getSolidStrokePoints(this.points, this.lineWidth); + const commands = getSvgPathFromStroke(stroke); + + this._local.set('commands', commands); + } + + return this._local.get('commands') as string; + } + + override get connectable() { + return false; + } + + override get type() { + return 'brush'; + } + + static override propsToY(props: BrushProps) { + return props; + } + + override containsBound(bounds: Bound) { + const points = getPointsFromBoundWithRotation(this); + return points.some(point => bounds.containsPoint(point)); + } + + override getLineIntersections(start: IVec, end: IVec) { + const tl = [this.x, this.y]; + const points = getPointsFromBoundWithRotation(this, _ => + this.points.map(point => Vec.add(point, tl)) + ); + + const box = Bound.fromDOMRect(getQuadBoundWithRotation(this)); + + if (box.w < 8 && box.h < 8) { + return Vec.distanceToLineSegment(start, end, box.center) < 5 ? [] : null; + } + + if (box.intersectLine(start, end, true)) { + const len = points.length; + for (let i = 1; i < len; i++) { + const result = lineIntersects(start, end, points[i - 1], points[i]); + if (result) { + return [ + new PointLocation( + result, + Vec.normalize(Vec.sub(points[i], points[i - 1])) + ), + ]; + } + } + } + return null; + } + + override getNearestPoint(point: IVec): IVec { + const { x, y } = this; + + return polyLineNearestPoint( + this.points.map(p => Vec.add(p, [x, y])), + point + ) as IVec; + } + + override getRelativePointLocation(position: IVec): PointLocation { + const point = Bound.deserialize(this.xywh).getRelativePoint(position); + return new PointLocation(point); + } + + override includesPoint( + px: number, + py: number, + options?: PointTestOptions + ): boolean { + const hit = isPointOnlines( + Bound.deserialize(this.xywh), + this.points as [number, number][], + this.rotate, + [px, py], + (options?.hitThreshold ?? 10) / Math.min(options?.zoom ?? 1, 1) + ); + return hit; + } + + @field() + accessor color: Color = '#000000'; + + @watch((_, instance) => { + instance['_local'].delete('commands'); + }) + @derive((lineWidth: number, instance: Instance) => { + const oldBound = instance.elementBound; + + if ( + lineWidth === instance.lineWidth || + oldBound.w === 0 || + oldBound.h === 0 + ) + return {}; + + const points = instance.points; + const transformed = transformPointsToNewBound( + points.map(([x, y]) => ({ x, y })), + oldBound, + instance.lineWidth / 2, + inflateBound(oldBound, lineWidth - instance.lineWidth), + lineWidth / 2 + ); + + return { + points: transformed.points.map((p, i) => [ + p.x, + p.y, + ...(points[i][2] !== undefined ? [points[i][2]] : []), + ]), + xywh: transformed.bound.serialize(), + }; + }) + @field() + accessor lineWidth: number = 4; + + @watch((_, instance) => { + instance['_local'].delete('commands'); + }) + @derive((points: IVec[], instance: Instance) => { + const lineWidth = instance.lineWidth; + const bound = getBoundFromPoints(points); + const boundWidthLineWidth = inflateBound(bound, lineWidth); + + return { + xywh: boundWidthLineWidth.serialize(), + }; + }) + @convert((points: (IVec | IVec3)[], instance) => { + const lineWidth = instance.lineWidth; + const bound = getBoundFromPoints(points as IVec[]); + const boundWidthLineWidth = inflateBound(bound, lineWidth); + const relativePoints = points.map(([x, y, pressure]) => [ + x - boundWidthLineWidth.x, + y - boundWidthLineWidth.y, + ...(pressure !== undefined ? [pressure] : []), + ]); + + return relativePoints; + }) + @field() + accessor points: (IVec | IVec3)[] = []; + + @field(0) + accessor rotate: number = 0; + + @derive((xywh: SerializedXYWH, instance: Instance) => { + const bound = Bound.deserialize(xywh); + + if (bound.w === instance.w && bound.h === instance.h) return {}; + + const { lineWidth } = instance; + const transformed = transformPointsToNewBound( + instance.points.map(([x, y]) => ({ x, y })), + instance, + instance.lineWidth / 2, + bound, + lineWidth / 2 + ); + + return { + points: transformed.points.map((p, i) => [ + p.x, + p.y, + ...(instance.points[i][2] !== undefined ? [instance.points[i][2]] : []), + ]), + }; + }) + @field() + accessor xywh: SerializedXYWH = '[0,0,0,0]'; +} + +type Instance = GfxPrimitiveElementModel & BrushProps; + +declare global { + namespace BlockSuite { + interface SurfaceElementModelMap { + brush: BrushElementModel; + } + } +} diff --git a/blocksuite/affine/model/src/elements/brush/index.ts b/blocksuite/affine/model/src/elements/brush/index.ts new file mode 100644 index 0000000000..80194782d8 --- /dev/null +++ b/blocksuite/affine/model/src/elements/brush/index.ts @@ -0,0 +1 @@ +export * from './brush.js'; diff --git a/blocksuite/affine/model/src/elements/connector/connector.ts b/blocksuite/affine/model/src/elements/connector/connector.ts new file mode 100644 index 0000000000..524fbe8da3 --- /dev/null +++ b/blocksuite/affine/model/src/elements/connector/connector.ts @@ -0,0 +1,521 @@ +import type { + BaseElementProps, + PointTestOptions, + SerializedElement, +} from '@blocksuite/block-std/gfx'; +import { + derive, + field, + GfxPrimitiveElementModel, + local, +} from '@blocksuite/block-std/gfx'; +import type { IVec, SerializedXYWH, XYWH } from '@blocksuite/global/utils'; +import { + Bound, + curveIntersects, + getBezierNearestPoint, + getBezierNearestTime, + getBezierParameters, + getBezierPoint, + linePolylineIntersects, + PointLocation, + Polyline, + polyLineNearestPoint, + Vec, +} from '@blocksuite/global/utils'; +import { DocCollection, type Y } from '@blocksuite/store'; + +import { + type Color, + CONNECTOR_LABEL_MAX_WIDTH, + ConnectorLabelOffsetAnchor, + ConnectorMode, + DEFAULT_ROUGHNESS, + FontFamily, + FontStyle, + FontWeight, + type PointStyle, + StrokeStyle, + TextAlign, + type TextStyleProps, +} from '../../consts/index.js'; + +export type SerializedConnection = { + id?: string; + position?: `[${number},${number}]` | PointLocation; +}; + +// at least one of id and position is not null +// both exists means the position is relative to the element +export type Connection = { + id?: string; + position?: [number, number]; +}; + +export const getConnectorModeName = (mode: ConnectorMode) => { + return { + [ConnectorMode.Straight]: 'Straight', + [ConnectorMode.Orthogonal]: 'Elbowed', + [ConnectorMode.Curve]: 'Curve', + }[mode]; +}; + +export type ConnectorLabelOffsetProps = { + // [0, 1], `0.5` by default + distance: number; + // `center` by default + anchor?: ConnectorLabelOffsetAnchor; +}; + +export type ConnectorLabelConstraintsProps = { + hasMaxWidth: boolean; + maxWidth: number; +}; + +export type ConnectorLabelProps = { + // Label's content + text?: Y.Text; + labelEditing?: boolean; + labelDisplay?: boolean; + labelXYWH?: XYWH; + labelOffset?: ConnectorLabelOffsetProps; + labelStyle?: TextStyleProps; + labelConstraints?: ConnectorLabelConstraintsProps; +}; + +export type SerializedConnectorElement = SerializedElement & { + source: SerializedConnection; + target: SerializedConnection; +}; + +export type ConnectorElementProps = BaseElementProps & { + mode: ConnectorMode; + stroke: Color; + strokeWidth: number; + strokeStyle: StrokeStyle; + roughness?: number; + rough?: boolean; + source: Connection; + target: Connection; + + frontEndpointStyle?: PointStyle; + rearEndpointStyle?: PointStyle; +} & ConnectorLabelProps; + +export class ConnectorElementModel extends GfxPrimitiveElementModel { + updatingPath = false; + + override get connectable() { + return false as const; + } + + get connected() { + return !!(this.source.id || this.target.id); + } + + override get elementBound() { + let bounds = super.elementBound; + if (this.hasLabel()) { + bounds = bounds.unite(Bound.fromXYWH(this.labelXYWH!)); + } + return bounds; + } + + get type() { + return 'connector'; + } + + static override propsToY(props: ConnectorElementProps) { + if (props.text && !(props.text instanceof DocCollection.Y.Text)) { + props.text = new DocCollection.Y.Text(props.text); + } + + return props; + } + + override containsBound(bounds: Bound) { + return ( + this.absolutePath.some(point => bounds.containsPoint(point)) || + (this.hasLabel() && + Bound.fromXYWH(this.labelXYWH!).points.some(p => + bounds.containsPoint(p) + )) + ); + } + + override getLineIntersections(start: IVec, end: IVec) { + const { mode, absolutePath: path } = this; + + let intersected = null; + + if (mode === ConnectorMode.Curve && path.length > 1) { + intersected = curveIntersects(path, [start, end]); + } else { + intersected = linePolylineIntersects(start, end, path); + } + + if (!intersected && this.hasLabel()) { + intersected = linePolylineIntersects( + start, + end, + Bound.fromXYWH(this.labelXYWH!).points + ); + } + + return intersected; + } + + /** + * Calculate the closest point on the curve via a point. + */ + override getNearestPoint(point: IVec): IVec { + const { mode, absolutePath: path } = this; + + if (mode === ConnectorMode.Straight) { + const first = path[0]; + const last = path[path.length - 1]; + return Vec.nearestPointOnLineSegment(first, last, point, true); + } + + if (mode === ConnectorMode.Orthogonal) { + const points = path.map(p => [p[0], p[1]]); + return Polyline.nearestPoint(points, point); + } + + const b = getBezierParameters(path); + const t = getBezierNearestTime(b, point); + const p = getBezierPoint(b, t); + if (p) return p; + + const { x, y } = this; + return [x, y]; + } + + /** + * Calculating the computed distance along a path via a point. + * + * The point is relative to the viewport. + */ + getOffsetDistanceByPoint(point: IVec, bounds?: Bound) { + const { mode, absolutePath: path } = this; + + let { x, y, w, h } = this; + if (bounds) { + x = bounds.x; + y = bounds.y; + w = bounds.w; + h = bounds.h; + } + + point[0] = Vec.clamp(point[0], x, x + w); + point[1] = Vec.clamp(point[1], y, y + h); + + if (mode === ConnectorMode.Straight) { + const s = path[0]; + const e = path[path.length - 1]; + const pl = Vec.dist(s, point); + const fl = Vec.dist(s, e); + return pl / fl; + } + + if (mode === ConnectorMode.Orthogonal) { + const points = path.map(p => [p[0], p[1]]); + const p = Polyline.nearestPoint(points, point); + const pl = Polyline.lenAtPoint(points, p); + const fl = Polyline.len(points); + return pl / fl; + } + + const b = getBezierParameters(path); + return getBezierNearestTime(b, point); + } + + /** + * Calculating the computed point along a path via a offset distance. + * + * Returns a point relative to the viewport. + */ + getPointByOffsetDistance(offsetDistance = 0.5, bounds?: Bound): IVec { + const { mode, absolutePath: path } = this; + + if (mode === ConnectorMode.Straight) { + const first = path[0]; + const last = path[path.length - 1]; + return Vec.lrp(first, last, offsetDistance); + } + + let { x, y, w, h } = this; + if (bounds) { + x = bounds.x; + y = bounds.y; + w = bounds.w; + h = bounds.h; + } + + if (mode === ConnectorMode.Orthogonal) { + const points = path.map(p => [p[0], p[1]]); + const point = Polyline.pointAt(points, offsetDistance); + if (point) return point; + return [x + w / 2, y + h / 2]; + } + + const b = getBezierParameters(path); + const point = getBezierPoint(b, offsetDistance); + if (point) return point; + return [x + w / 2, y + h / 2]; + } + + override getRelativePointLocation(point: IVec): PointLocation { + return new PointLocation( + Bound.deserialize(this.xywh).getRelativePoint(point) + ); + } + + hasLabel() { + return Boolean(!this.lableEditing && this.labelDisplay && this.labelXYWH); + } + + override includesPoint( + x: number, + y: number, + options?: PointTestOptions | undefined + ): boolean { + const currentPoint: IVec = [x, y]; + + if (this.labelIncludesPoint(currentPoint as IVec)) { + return true; + } + + const { mode, strokeWidth, absolutePath: path } = this; + + const point = + mode === ConnectorMode.Curve + ? getBezierNearestPoint(getBezierParameters(path), currentPoint) + : polyLineNearestPoint(path, currentPoint); + + return ( + Vec.dist(point, currentPoint) < + (options?.hitThreshold ? strokeWidth / 2 : 0) + 8 + ); + } + + labelIncludesPoint(point: IVec) { + return ( + this.hasLabel() && Bound.fromXYWH(this.labelXYWH!).isPointInBound(point) + ); + } + + moveTo(bound: Bound) { + const oldBound = Bound.deserialize(this.xywh); + const offset = Vec.sub([bound.x, bound.y], [oldBound.x, oldBound.y]); + const { source, target } = this; + + if (!source.id && source.position) { + this.source = { + position: Vec.add(source.position, offset) as [number, number], + }; + } + + if (!target.id && target.position) { + this.target = { + position: Vec.add(target.position, offset) as [number, number], + }; + } + + // Updates Connector's Label position. + if (this.hasLabel()) { + const [x, y, w, h] = this.labelXYWH!; + this.labelXYWH = [x + offset[0], y + offset[1], w, h]; + } + } + + resize(bounds: Bound, originalPath: PointLocation[], matrix: DOMMatrix) { + this.updatingPath = false; + + const path = this.resizePath(originalPath, matrix); + + // the property assignment order matters + this.xywh = bounds.serialize(); + this.path = path.map(p => p.clone().setVec(Vec.sub(p, bounds.tl))); + + const props: { + labelXYWH?: XYWH; + source?: Connection; + target?: Connection; + } = {}; + + // Updates Connector's Label position. + if (this.hasLabel()) { + const [cx, cy] = this.getPointByOffsetDistance(this.labelOffset.distance); + const [, , w, h] = this.labelXYWH!; + props.labelXYWH = [cx - w / 2, cy - h / 2, w, h]; + } + + if (!this.source.id) { + props.source = { + ...this.source, + position: path[0].toVec() as [number, number], + }; + } + if (!this.target.id) { + props.target = { + ...this.target, + position: path[path.length - 1].toVec() as [number, number], + }; + } + + return props; + } + + resizePath(originalPath: PointLocation[], matrix: DOMMatrix) { + if (this.mode === ConnectorMode.Curve) { + return originalPath.map(point => { + const [p, t, absIn, absOut] = [ + point, + point.tangent, + point.absIn, + point.absOut, + ] + .map(p => new DOMPoint(...p).matrixTransform(matrix)) + .map(p => [p.x, p.y] as IVec); + const ip = Vec.sub(absIn, p); + const op = Vec.sub(absOut, p); + return new PointLocation(p, t, ip, op); + }); + } + + return originalPath.map(point => { + const { x, y } = new DOMPoint(...point).matrixTransform(matrix); + const p: IVec = [x, y]; + return PointLocation.fromVec(p); + }); + } + + override serialize() { + const result = super.serialize(); + result.xywh = this.xywh; + result.source = structuredClone(this.source); + result.target = structuredClone(this.target); + return result as SerializedConnectorElement; + } + + @local() + accessor absolutePath: PointLocation[] = []; + + @field('None' as PointStyle) + accessor frontEndpointStyle!: PointStyle; + + /** + * Defines the size constraints of the label. + */ + @field({ + hasMaxWidth: true, + maxWidth: CONNECTOR_LABEL_MAX_WIDTH, + } as ConnectorLabelConstraintsProps) + accessor labelConstraints!: ConnectorLabelConstraintsProps; + + /** + * Control display and hide. + */ + @field(true) + accessor labelDisplay!: boolean; + + /** + * The offset property specifies the label along the connector path. + */ + @field({ + distance: 0.5, + anchor: ConnectorLabelOffsetAnchor.Center, + } as ConnectorLabelOffsetProps) + accessor labelOffset!: ConnectorLabelOffsetProps; + + /** + * Defines the style of the label. + */ + @field({ + color: '#000000', + fontFamily: FontFamily.Inter, + fontSize: 16, + fontStyle: FontStyle.Normal, + fontWeight: FontWeight.Regular, + textAlign: TextAlign.Center, + } as TextStyleProps) + accessor labelStyle!: TextStyleProps; + + /** + * Returns a `XYWH` array providing information about the size of a label + * and its position relative to the viewport. + */ + @field() + accessor labelXYWH: XYWH | undefined = undefined; + + /** + * Local control display and hide, mainly used in editing scenarios. + */ + @local() + accessor lableEditing: boolean = false; + + @field() + accessor mode: ConnectorMode = ConnectorMode.Orthogonal; + + @derive((path: PointLocation[], instance) => { + const { x, y } = instance; + + return { + absolutePath: path.map(p => p.clone().setVec(Vec.add(p, [x, y]))), + }; + }) + @local() + accessor path: PointLocation[] = []; + + @field('Arrow' as PointStyle) + accessor rearEndpointStyle!: PointStyle; + + @local() + accessor rotate: number = 0; + + @field() + accessor rough: boolean | undefined = undefined; + + @field() + accessor roughness: number = DEFAULT_ROUGHNESS; + + @field() + accessor source: Connection = { + position: [0, 0], + }; + + @field() + accessor stroke: Color = '#000000'; + + @field() + accessor strokeStyle: StrokeStyle = StrokeStyle.Solid; + + @field() + accessor strokeWidth: number = 4; + + @field() + accessor target: Connection = { + position: [0, 0], + }; + + /** + * The content of the label. + */ + @field() + accessor text: Y.Text | undefined = undefined; + + @local() + accessor xywh: SerializedXYWH = '[0,0,0,0]'; +} + +declare global { + namespace BlockSuite { + interface SurfaceElementModelMap { + connector: ConnectorElementModel; + } + interface EdgelessTextModelMap { + connector: ConnectorElementModel; + } + } +} diff --git a/blocksuite/affine/model/src/elements/connector/index.ts b/blocksuite/affine/model/src/elements/connector/index.ts new file mode 100644 index 0000000000..0eafb86ae6 --- /dev/null +++ b/blocksuite/affine/model/src/elements/connector/index.ts @@ -0,0 +1,2 @@ +export * from './connector.js'; +export * from './local-connector.js'; diff --git a/blocksuite/affine/model/src/elements/connector/local-connector.ts b/blocksuite/affine/model/src/elements/connector/local-connector.ts new file mode 100644 index 0000000000..4d23a2142f --- /dev/null +++ b/blocksuite/affine/model/src/elements/connector/local-connector.ts @@ -0,0 +1,66 @@ +import { GfxLocalElementModel } from '@blocksuite/block-std/gfx'; +import type { PointLocation } from '@blocksuite/global/utils'; + +import { + type Color, + ConnectorMode, + DEFAULT_ROUGHNESS, + type PointStyle, + StrokeStyle, +} from '../../consts/index.js'; +import type { Connection } from './connector.js'; + +export class LocalConnectorElementModel extends GfxLocalElementModel { + private _path: PointLocation[] = []; + + absolutePath: PointLocation[] = []; + + frontEndpointStyle!: PointStyle; + + mode: ConnectorMode = ConnectorMode.Orthogonal; + + rearEndpointStyle!: PointStyle; + + rough?: boolean; + + roughness: number = DEFAULT_ROUGHNESS; + + source: Connection = { + position: [0, 0], + }; + + stroke: Color = '#000000'; + + strokeStyle: StrokeStyle = StrokeStyle.Solid; + + strokeWidth: number = 4; + + target: Connection = { + position: [0, 0], + }; + + updatingPath = false; + + get path(): PointLocation[] { + return this._path; + } + + set path(value: PointLocation[]) { + const { x, y } = this; + + this._path = value; + this.absolutePath = value.map(p => p.clone().setVec([p[0] + x, p[1] + y])); + } + + get type() { + return 'connector'; + } +} + +declare global { + namespace BlockSuite { + interface SurfaceLocalModelMap { + connector: LocalConnectorElementModel; + } + } +} diff --git a/blocksuite/affine/model/src/elements/group/group.ts b/blocksuite/affine/model/src/elements/group/group.ts new file mode 100644 index 0000000000..1081758a11 --- /dev/null +++ b/blocksuite/affine/model/src/elements/group/group.ts @@ -0,0 +1,124 @@ +import type { + BaseElementProps, + GfxModel, + SerializedElement, +} from '@blocksuite/block-std/gfx'; +import { + canSafeAddToContainer, + field, + GfxGroupLikeElementModel, + local, + observe, +} from '@blocksuite/block-std/gfx'; +import type { IVec, PointLocation } from '@blocksuite/global/utils'; +import { Bound, keys, linePolygonIntersects } from '@blocksuite/global/utils'; +import type { Y } from '@blocksuite/store'; +import { DocCollection } from '@blocksuite/store'; + +type GroupElementProps = BaseElementProps & { + children: Y.Map; + title: Y.Text; +}; + +export type SerializedGroupElement = SerializedElement & { + title: string; + children: Record; +}; + +export class GroupElementModel extends GfxGroupLikeElementModel { + get rotate() { + return 0; + } + + set rotate(_: number) {} + + get type() { + return 'group'; + } + + static override propsToY(props: Record) { + if ('title' in props && !(props.title instanceof DocCollection.Y.Text)) { + props.title = new DocCollection.Y.Text(props.title as string); + } + + if (props.children && !(props.children instanceof DocCollection.Y.Map)) { + const children = new DocCollection.Y.Map() as Y.Map; + + keys(props.children).forEach(key => { + children.set(key as string, true); + }); + + props.children = children; + } + + return props as GroupElementProps; + } + + override addChild(element: GfxModel) { + if (!canSafeAddToContainer(this, element)) { + return; + } + + this.surface.doc.transact(() => { + this.children.set(element.id, true); + }); + } + + override containsBound(bound: Bound): boolean { + return bound.contains(Bound.deserialize(this.xywh)); + } + + override getLineIntersections( + start: IVec, + end: IVec + ): PointLocation[] | null { + const bound = Bound.deserialize(this.xywh); + return linePolygonIntersects(start, end, bound.points); + } + + removeChild(element: GfxModel) { + if (!this.children) { + return; + } + this.surface.doc.transact(() => { + this.children.delete(element.id); + }); + } + + override serialize() { + const result = super.serialize(); + return result as SerializedGroupElement; + } + + @observe( + // use `GroupElementModel` type in decorator will cause playwright error + (_, instance: GfxGroupLikeElementModel, transaction) => { + if (instance.children.doc) { + instance.setChildIds( + Array.from(instance.children.keys()), + transaction?.local ?? false + ); + } + } + ) + @field() + accessor children: Y.Map = new DocCollection.Y.Map(); + + @local() + accessor showTitle: boolean = true; + + @field() + accessor title: Y.Text = new DocCollection.Y.Text(); +} + +declare global { + namespace BlockSuite { + interface SurfaceGroupLikeModelMap { + group: GroupElementModel; + } + + interface SurfaceElementModelMap { + group: GroupElementModel; + } + } +} diff --git a/blocksuite/affine/model/src/elements/group/index.ts b/blocksuite/affine/model/src/elements/group/index.ts new file mode 100644 index 0000000000..bc63caa42f --- /dev/null +++ b/blocksuite/affine/model/src/elements/group/index.ts @@ -0,0 +1 @@ +export * from './group.js'; diff --git a/blocksuite/affine/model/src/elements/index.ts b/blocksuite/affine/model/src/elements/index.ts new file mode 100644 index 0000000000..65cd2f58cf --- /dev/null +++ b/blocksuite/affine/model/src/elements/index.ts @@ -0,0 +1,6 @@ +export * from './brush/index.js'; +export * from './connector/index.js'; +export * from './group/index.js'; +export * from './mindmap/index.js'; +export * from './shape/index.js'; +export * from './text/index.js'; diff --git a/blocksuite/affine/model/src/elements/mindmap/index.ts b/blocksuite/affine/model/src/elements/mindmap/index.ts new file mode 100644 index 0000000000..9ea6aac781 --- /dev/null +++ b/blocksuite/affine/model/src/elements/mindmap/index.ts @@ -0,0 +1,2 @@ +export * from './mindmap.js'; +export * from './style.js'; diff --git a/blocksuite/affine/model/src/elements/mindmap/mindmap.ts b/blocksuite/affine/model/src/elements/mindmap/mindmap.ts new file mode 100644 index 0000000000..8316b0abf5 --- /dev/null +++ b/blocksuite/affine/model/src/elements/mindmap/mindmap.ts @@ -0,0 +1,975 @@ +import type { + BaseElementProps, + GfxModel, + PointTestOptions, + SerializedElement, +} from '@blocksuite/block-std/gfx'; +import { + convert, + field, + GfxGroupLikeElementModel, + observe, + watch, +} from '@blocksuite/block-std/gfx'; +import type { Bound, SerializedXYWH, XYWH } from '@blocksuite/global/utils'; +import { + assertType, + deserializeXYWH, + keys, + last, + noop, + pick, +} from '@blocksuite/global/utils'; +import { DocCollection, type Y } from '@blocksuite/store'; +import { generateKeyBetween } from 'fractional-indexing'; +import { z } from 'zod'; + +import { ConnectorMode } from '../../consts/connector.js'; +import { LayoutType, MindmapStyle } from '../../consts/mindmap.js'; +import { LocalConnectorElementModel } from '../connector/local-connector.js'; +import type { MindmapStyleGetter } from './style.js'; +import { mindmapStyleGetters } from './style.js'; +import { findInfiniteLoop } from './utils.js'; + +export type NodeDetail = { + /** + * The index of the node, it decides the layout order of the node + */ + index: string; + parent?: string; + collapsed?: boolean; +}; + +export type MindmapNode = { + id: string; + detail: NodeDetail; + + element: BlockSuite.SurfaceElementModel; + children: MindmapNode[]; + + parent: MindmapNode | null; + + /** + * This area is used to determine where to place the dragged node. + * + * When dragging another node into this area, it will become a sibling of the this node. + * But if it is dragged into the small area located right after the this node, it will become a child of the this node. + */ + responseArea?: Bound; + + /** + * This property override the preferredDir or default layout direction. + * It is used during dragging that would temporary change the layout direction + */ + overriddenDir?: LayoutType; +}; + +export type MindmapRoot = MindmapNode & { + left: MindmapNode[]; + right: MindmapNode[]; +}; + +const baseNodeSchema = z.object({ + text: z.string(), + xywh: z.optional(z.string()), +}); + +type Node = z.infer & { + children?: Node[]; +}; + +const nodeSchema: z.ZodType = baseNodeSchema.extend({ + children: z.lazy(() => nodeSchema.array()).optional(), +}); + +export type NodeType = z.infer; + +function isNodeType(node: Record): node is NodeType { + return typeof node.text === 'string' && Array.isArray(node.children); +} + +export type SerializedMindmapElement = SerializedElement & { + children: Record; +}; + +type MindmapElementProps = BaseElementProps & { + children: Y.Map; +}; + +function observeChildren( + _: unknown, + instance: MindmapElementModel, + transaction: Y.Transaction | null +) { + if (instance.children.doc) { + instance.setChildIds( + Array.from(instance.children.keys()), + transaction?.local ?? true + ); + + instance.buildTree(); + instance.connectors.clear(); + } +} + +function watchLayoutType( + _: unknown, + instance: MindmapElementModel, + local: boolean +) { + if (!local) { + return; + } + + instance.surface.doc.transact(() => { + instance['_tree']?.children.forEach(child => { + if (!instance.children.has(child.id)) { + return; + } + + instance.children.set(child.id, { + index: child.detail.index, + parent: child.detail.parent, + }); + }); + }); + + instance.buildTree(); +} + +function watchStyle(_: unknown, instance: MindmapElementModel, local: boolean) { + if (!local) return; + instance.layout(); +} + +export class MindmapElementModel extends GfxGroupLikeElementModel { + private _layout: MindmapElementModel['layout'] | null = null; + + private _nodeMap = new Map(); + + private _queueBuildTree = false; + + private _queuedLayout = false; + + private _stashedNode = new Set(); + + private _tree!: MindmapRoot; + + connectors = new Map(); + + get nodeMap() { + return this._nodeMap; + } + + override get rotate() { + return 0; + } + + override set rotate(_: number) {} + + get styleGetter(): MindmapStyleGetter { + return mindmapStyleGetters[this.style]; + } + + get tree() { + return this._tree; + } + + get type() { + return 'mindmap'; + } + + static override propsToY(props: Record) { + if ( + props.children && + !isNodeType(props.children as Record) && + !(props.children instanceof DocCollection.Y.Map) + ) { + const children: Y.Map = new DocCollection.Y.Map(); + + keys(props.children).forEach(key => { + const detail = pick, keyof NodeDetail>( + props.children![key], + ['index', 'parent'] + ); + children.set(key as string, detail as NodeDetail); + }); + + props.children = children; + } + + return props as MindmapElementProps; + } + + private _cfgBalanceLayoutDir() { + if (this.layoutType !== LayoutType.BALANCE) { + return; + } + + const tree = this._tree; + const splitPoint = Math.ceil(tree.children.length / 2); + + tree.right.push(...tree.children.slice(0, splitPoint)); + tree.left.push(...tree.children.slice(splitPoint)); + tree.left.reverse(); + } + + private _isConnectorOutdated( + options: + | { + connector: LocalConnectorElementModel; + from: MindmapNode; + to: MindmapNode; + layout: LayoutType; + } + | { + connector: LocalConnectorElementModel; + from: MindmapNode; + layout: LayoutType; + collapsed: boolean; + }, + updateKey: boolean = true + ) { + const collapsed = 'collapsed' in options; + const { connector, from, layout } = options; + + if (!from.element || (!collapsed && !options.to.element)) { + return { outdated: true, cacheKey: '' }; + } + + const cacheKey = collapsed + ? `${from.element.xywh}-collapsed-${layout}-${this.style}` + : `${from.element.xywh}-${options.to.element.xywh}-${layout}-${this.style}`; + + if (connector.cache.get('MINDMAP_CONNECTOR') === cacheKey) { + return false; + } else if (updateKey) { + connector.cache.set('MINDMAP_CONNECTOR', cacheKey); + } + + return true; + } + + protected override _getXYWH(): Bound { + return super._getXYWH(); + } + + /** + * @deprecated + * you should not call this method directly + */ + addChild(_element: GfxModel) { + noop(); + } + + addNode( + /** + * The parent node id of the new node. If it's null, the node will be the root node + */ + parent: string | MindmapNode | null, + sibling?: string | number, + position: 'before' | 'after' = 'after', + props: Record = {} + ) { + if (parent && typeof parent !== 'string') { + parent = parent.id; + } + + assertType(parent); + + if (parent && !this._nodeMap.has(parent)) { + throw new Error(`Parent node ${parent} not found`); + } + + props['text'] = new DocCollection.Y.Text( + (props['text'] as string) ?? 'New node' + ); + + const type = (props.type as string) ?? 'shape'; + let id: string; + this.surface.doc.transact(() => { + const parentNode = parent ? this._nodeMap.get(parent)! : null; + + if (parentNode) { + let index = last(parentNode.children) + ? generateKeyBetween(last(parentNode.children)!.detail.index, null) + : 'a0'; + + sibling = sibling ?? last(parentNode.children)?.id; + const siblingNode = + typeof sibling === 'number' + ? parentNode.children[sibling] + : sibling + ? this._nodeMap.get(sibling) + : undefined; + const path = siblingNode + ? this.getPath(siblingNode) + : this.getPath(parentNode).concat([0]); + const style = this.styleGetter.getNodeStyle( + siblingNode ?? parentNode, + path + ); + + id = this.surface.addElement({ + type, + xywh: '[0,0,100,30]', + maxWidth: false, + ...props, + ...style.node, + }); + + if (siblingNode) { + const siblingIndex = parentNode.children.findIndex( + val => val.id === sibling + ); + + index = + position === 'after' + ? generateKeyBetween( + siblingNode.detail.index, + parentNode.children[siblingIndex + 1]?.detail.index ?? null + ) + : generateKeyBetween( + parentNode.children[siblingIndex - 1]?.detail.index ?? null, + siblingNode.detail.index + ); + } + + const nodeDetail: NodeDetail = { + index, + parent: parent!, + }; + + this.children.set(id, nodeDetail); + } else { + const rootStyle = this.styleGetter.root; + + id = this.surface.addElement({ + type, + xywh: '[0,0,113,41]', + maxWidth: false, + ...props, + ...rootStyle, + }); + + this.children.clear(); + this.children.set(id, { + index: 'a0', + }); + } + }); + this.layout(); + + return id!; + } + + buildTree() { + const mindmapNodeMap = new Map(); + const nodesMap = this.children; + + // The element may be removed + if (!nodesMap || nodesMap.size === 0) { + this._nodeMap = mindmapNodeMap; + // @ts-expect-error FIXME: ts error + this._tree = null; + return; + } + + let rootNode: MindmapRoot | undefined; + + nodesMap.forEach((val, id) => { + if (!mindmapNodeMap.has(id)) { + mindmapNodeMap.set(id, { + id, + index: val.index, + detail: val, + element: this.surface.getElementById(id)!, + children: [], + parent: null, + } as MindmapNode); + } + + const node = mindmapNodeMap.get(id)!; + + // some node may be already created during + // iterating its children + if (!node.detail) { + node.detail = val; + } + + if (!val.parent) { + rootNode = node as MindmapRoot; + rootNode.left = []; + rootNode.right = []; + } else { + if (!mindmapNodeMap.has(val.parent)) { + mindmapNodeMap.set(val.parent, { + id: val.parent, + detail: nodesMap.get(val.parent)!, + parent: null, + children: [], + element: this.surface.getElementById(val.parent)!, + } as MindmapNode); + } + + const parent = mindmapNodeMap.get(val.parent)!; + parent.children.push(node); + node.parent = parent; + } + }); + + mindmapNodeMap.forEach(node => { + node.children.sort((a, b) => + a.detail.index === b.detail.index + ? 0 + : a.detail.index > b.detail.index + ? 1 + : -1 + ); + }); + + if (!rootNode) { + return; + } + + const loops = findInfiniteLoop(rootNode, mindmapNodeMap); + + if (loops.length) { + this.surface.doc.withoutTransact(() => { + loops.forEach(loop => { + if (loop.detached) { + loop.chain.forEach(node => { + this.children.delete(node.id); + }); + } else { + const child = last(loop.chain); + + if (child) { + this.children.set(child.id, { + index: child.detail.index, + }); + } + } + }); + }); + return; + } + + this._nodeMap = mindmapNodeMap; + this._tree = rootNode; + + if (this.layoutType === LayoutType.BALANCE) { + this._cfgBalanceLayoutDir(); + } else { + this._tree[this.layoutType === LayoutType.RIGHT ? 'right' : 'left'] = + this._tree.children; + } + } + + /** + * + * @param subtree The subtree of root, this only take effects when the layout type is BALANCED. + * @returns + */ + getChildNodes(id: string, subtree?: 'left' | 'right') { + const node = this._nodeMap.get(id); + + if (!node) { + return []; + } + + if (subtree && id === this._tree.id) { + return this._tree[subtree]; + } + + return node.children; + } + + /** + * Get all the connectors start from the given node + * @param node + * @returns + */ + getConnectors(node: MindmapNode) { + if (!this._nodeMap.has(node.id)) { + return null; + } + + if (node.detail.collapsed) { + const id = `#${node.id}-collapsed`; + const layout = this.getLayoutDir(node)!; + const connector = + this.connectors.get(id) ?? new LocalConnectorElementModel(this.surface); + const connectorExist = this.connectors.has(id); + const connectorStyle = this.styleGetter.getNodeStyle( + node, + this.getPath(node).concat([0]) + ).connector; + const outdated = this._isConnectorOutdated({ + connector, + from: node, + collapsed: true, + layout, + }); + + if (!connectorExist) { + connector.id = id; + this.connectors.set(id, connector); + } + + if (outdated) { + const nodeBound = node.element.elementBound; + connector.id = id; + connector.source = { + id: node.id, + position: layout === LayoutType.LEFT ? [0, 0.5] : [1, 0.5], + }; + connector.target = { + position: + layout === LayoutType.LEFT + ? [nodeBound.x - 6, nodeBound.y + nodeBound.h / 2] + : [nodeBound.x + nodeBound.w + 6, nodeBound.y + nodeBound.h / 2], + }; + + Object.entries(connectorStyle).forEach(([key, value]) => { + // @ts-expect-error FIXME: ts error + connector[key as unknown] = value; + }); + + connector.mode = ConnectorMode.Straight; + } + + return [{ outdated, connector }]; + } else { + const from = node; + return from.children.map(to => { + const layout = this.getLayoutDir(to)!; + const id = `#${from.id}-${to.id}`; + const connectorExist = this.connectors.has(id); + const connectorStyle = this.styleGetter.getNodeStyle( + to, + this.getPath(to) + ).connector; + const connector = + this.connectors.get(id) ?? + new LocalConnectorElementModel(this.surface); + const outdated = this._isConnectorOutdated({ + connector, + from, + to, + layout, + }); + + if (!connectorExist) { + connector.id = id; + this.connectors.set(id, connector); + } + + if (outdated) { + connector.source = { + id: from.id, + position: layout === LayoutType.RIGHT ? [1, 0.5] : [0, 0.5], + }; + connector.target = { + id: to.id, + position: layout === LayoutType.RIGHT ? [0, 0.5] : [1, 0.5], + }; + + Object.entries(connectorStyle).forEach(([key, value]) => { + // @ts-expect-error FIXME: ts error + connector[key as unknown] = value; + }); + } + + return { outdated, connector }; + }); + } + } + + getLayoutDir(node: string | MindmapNode): LayoutType { + node = typeof node === 'string' ? this._nodeMap.get(node)! : node; + + assertType(node); + + let current: MindmapNode | null = node; + const root = this._tree; + + while (current) { + if (current.overriddenDir !== undefined) { + return current.overriddenDir; + } + + const parent: MindmapNode | null = current.detail.parent + ? (this._nodeMap.get(current.detail.parent) ?? null) + : null; + + if (parent === root) { + return ( + parent.overriddenDir ?? + (root.left.includes(current) + ? LayoutType.LEFT + : root.right.includes(current) + ? LayoutType.RIGHT + : this.layoutType) + ); + } + + current = parent; + } + + return this.layoutType; + } + + getNode(id: string) { + return this._nodeMap.get(id) ?? null; + } + + getParentNode(id: string) { + const node = this.children.get(id); + + return node?.parent ? (this._nodeMap.get(node.parent) ?? null) : null; + } + + /** + * Path is an array of indexes that represent the path from the root node to the target node. + * The first element of the array is always 0, which represents the root node. + * @param element + * @returns + * + * @example + * ```ts + * const path = mindmap.getPath('nodeId'); + * // [0, 1, 2] + * ``` + */ + getPath(element: string | MindmapNode) { + let node = this._nodeMap.get( + typeof element === 'string' ? element : element.id + ); + + if (!node) { + throw new Error('Node not found'); + } + + const path: number[] = []; + + while (node && node !== this._tree) { + const parent = this._nodeMap.get(node!.detail.parent!); + + path.unshift(parent!.children.indexOf(node!)); + node = parent; + } + + path.unshift(0); + + return path; + } + + getSiblingNode( + id: string, + direction: 'prev' | 'next' = 'next', + /** + * The subtree of which that the sibling node belongs to, + * this is used when the layout type is BALANCED. + */ + subtree?: 'left' | 'right' + ) { + const node = this._nodeMap.get(id); + + if (!node) { + return null; + } + + const parent = this._nodeMap.get(node.detail.parent!); + + if (!parent) { + return null; + } + + const childrenTree = + subtree && parent.id === this._tree.id + ? this._tree[subtree] + : parent.children; + const idx = childrenTree.indexOf(node); + if (idx === -1) { + return null; + } + const siblingIndex = direction === 'next' ? idx + 1 : idx - 1; + const sibling = childrenTree[siblingIndex] ?? null; + + return sibling; + } + + override includesPoint(x: number, y: number, options: PointTestOptions) { + const bound = this.elementBound; + + bound.x -= options.responsePadding?.[0] ?? 0; + bound.w += (options.responsePadding?.[0] ?? 0) * 2; + bound.y -= options.responsePadding?.[1] ?? 0; + bound.h += (options.responsePadding?.[1] ?? 0) * 2; + + return bound.containsPoint([x, y]); + } + + layout( + _tree: MindmapNode | MindmapRoot = this.tree, + _options: { + applyStyle?: boolean; + layoutType?: LayoutType; + calculateTreeBound?: boolean; + stashed?: boolean; + } = { + applyStyle: true, + calculateTreeBound: true, + stashed: true, + } + ) { + // should be implemented by the view + // otherwise, it would be just an empty function + if (this._layout) { + this._layout(_tree, _options); + } + } + + moveTo(targetXYWH: SerializedXYWH | XYWH) { + const { x, y } = this; + const targetPos = + typeof targetXYWH === 'string' ? deserializeXYWH(targetXYWH) : targetXYWH; + const offsetX = targetPos[0] - x; + const offsetY = targetPos[1] - y + targetPos[3]; + + this.surface.doc.transact(() => { + this.childElements.forEach(el => { + const deserializedXYWH = deserializeXYWH(el.xywh); + + el.xywh = + `[${deserializedXYWH[0] + offsetX},${deserializedXYWH[1] + offsetY},${deserializedXYWH[2]},${deserializedXYWH[3]}]` as SerializedXYWH; + }); + }); + } + + override onCreated(): void { + this.buildTree(); + } + + removeChild(element: GfxModel) { + if (!this._nodeMap.has(element.id)) { + return; + } + + const surface = this.surface; + const removedDescendants: string[] = []; + const remove = (node: MindmapNode) => { + node.children?.forEach(child => { + remove(child); + }); + + this.children?.delete(node.id); + removedDescendants.push(node.id); + }; + + surface.doc.transact(() => { + remove(this._nodeMap.get(element.id)!); + }); + + queueMicrotask(() => { + removedDescendants.forEach(id => surface.deleteElement(id)); + }); + + // This transaction may not end + // force to build the elements + this.buildTree(); + this.requestLayout(); + } + + protected requestBuildTree() { + if (this._queueBuildTree) { + return; + } + + this._queueBuildTree = true; + queueMicrotask(() => { + this.buildTree(); + this._queueBuildTree = false; + }); + } + + requestLayout() { + if (!this._queuedLayout) { + this._queuedLayout = true; + + queueMicrotask(() => { + this.layout(); + this._queuedLayout = false; + }); + } + } + + override serialize() { + const result = super.serialize(); + return result as SerializedMindmapElement; + } + + setLayoutMethod(layoutMethod: MindmapElementModel['layout']) { + this._layout = layoutMethod; + } + + /** + * Stash mind map node and its children's xywh property + * @param node + * @returns a function that write back the stashed xywh into yjs + */ + stashTree(node: MindmapNode | string) { + const mindNode = typeof node === 'string' ? this.getNode(node) : node; + + if (!mindNode || this._stashedNode.has(mindNode.id)) { + return; + } + + const stashed = new Set(); + const traverse = (node: MindmapNode) => { + node.element.stash('xywh'); + stashed.add(node.element); + + if (node.children.length) { + node.children.forEach(child => traverse(child)); + } + }; + + traverse(mindNode); + + return () => { + this._stashedNode.delete(mindNode.id); + stashed.forEach(el => { + el.pop('xywh'); + }); + }; + } + + toggleCollapse(node: MindmapNode, options: { layout?: boolean } = {}) { + if (!this._nodeMap.has(node.id)) { + return; + } + + const { layout = false } = options; + + if (node && node.children.length > 0) { + const collapsed = node.detail.collapsed ? false : true; + const isExpand = !collapsed; + + const changeNodesVisibility = (node: MindmapNode) => { + node.element.hidden = collapsed; + + if (isExpand && node.detail.collapsed) { + return; + } + + node.children.forEach(child => { + changeNodesVisibility(child); + }); + }; + + node.children.forEach(changeNodesVisibility); + this.surface.doc.transact(() => { + this.children.set(node.id, { + ...node.detail, + collapsed, + }); + }); + } + + if (layout) { + this.requestLayout(); + } + } + + traverse( + callback: (node: MindmapNode, parent: MindmapNode | null) => void, + root: MindmapNode = this._tree, + options: { stopOnCollapse?: boolean } = {} + ) { + const { stopOnCollapse = false } = options; + const traverse = (node: MindmapNode, parent: MindmapNode | null) => { + callback(node, parent); + + if (stopOnCollapse && node.detail.collapsed) { + return; + } + + node?.children.forEach(child => { + traverse(child, node); + }); + }; + + if (root) { + traverse(root, null); + } + } + + @convert((initialValue, instance) => { + if (!(initialValue instanceof DocCollection.Y.Map)) { + nodeSchema.parse(initialValue); + + assertType(initialValue); + + const map: Y.Map = new DocCollection.Y.Map(); + const surface = instance.surface; + const doc = surface.doc; + const recursive = ( + node: NodeType, + parent: string | null = null, + index: string = 'a0' + ) => { + const id = surface.addElement({ + type: 'shape', + text: node.text, + xywh: node.xywh ? node.xywh : `[0, 0, 100, 30]`, + }); + + map.set(id, { + index, + parent: parent ?? undefined, + }); + + let curIdx = 'a0'; + node.children?.forEach(childNode => { + recursive(childNode, id, curIdx); + curIdx = generateKeyBetween(curIdx, null); + }); + }; + + doc.transact(() => { + recursive(initialValue); + }); + + instance.requestBuildTree(); + instance.requestLayout(); + return map; + } else { + instance.requestBuildTree(); + instance.requestLayout(); + return initialValue; + } + }) + // Use extracted function to avoid playwright test failure + // since this model package is imported by playwright + @observe(observeChildren) + @field() + accessor children: Y.Map = new DocCollection.Y.Map(); + + @watch(watchLayoutType) + @field() + accessor layoutType: LayoutType = LayoutType.RIGHT; + + @watch(watchStyle) + @field() + accessor style: MindmapStyle = MindmapStyle.ONE; +} + +declare global { + namespace BlockSuite { + interface SurfaceGroupLikeModelMap { + mindmap: MindmapElementModel; + } + } +} diff --git a/blocksuite/affine/model/src/elements/mindmap/style.ts b/blocksuite/affine/model/src/elements/mindmap/style.ts new file mode 100644 index 0000000000..3c72c03c8a --- /dev/null +++ b/blocksuite/affine/model/src/elements/mindmap/style.ts @@ -0,0 +1,504 @@ +import { isEqual, last } from '@blocksuite/global/utils'; + +import { ConnectorMode } from '../../consts/connector.js'; +import { LineColor } from '../../consts/line.js'; +import { MindmapStyle } from '../../consts/mindmap.js'; +import { StrokeStyle } from '../../consts/note.js'; +import { ShapeFillColor } from '../../consts/shape.js'; +import { FontFamily, FontWeight, TextResizing } from '../../consts/text.js'; +import type { MindmapNode } from './mindmap.js'; + +export type CollapseButton = { + width: number; + height: number; + radius: number; + + filled: boolean; + fillColor: string; + + strokeColor: string; + strokeWidth: number; +}; + +export type ExpandButton = CollapseButton & { + fontFamily: FontFamily; + fontSize: number; + fontWeight: FontWeight; + + color: string; +}; + +export type NodeStyle = { + radius: number; + + strokeWidth: number; + strokeColor: string; + + textResizing: TextResizing; + + fontSize: number; + fontFamily: string; + fontWeight: FontWeight; + color: string; + + filled: boolean; + fillColor: string; + + padding: [number, number]; + + shadow?: { + blur: number; + offsetX: number; + offsetY: number; + color: string; + }; +}; + +export type ConnectorStyle = { + strokeStyle: StrokeStyle; + stroke: string; + strokeWidth: number; + + mode: ConnectorMode; +}; + +export abstract class MindmapStyleGetter { + abstract readonly root: NodeStyle; + + abstract getNodeStyle( + node: MindmapNode, + path: number[] + ): { + connector: ConnectorStyle; + collapseButton: CollapseButton; + expandButton: ExpandButton; + node: NodeStyle; + }; +} + +export class StyleOne extends MindmapStyleGetter { + private _colorOrders = [ + LineColor.Purple, + LineColor.Magenta, + LineColor.Orange, + LineColor.Yellow, + LineColor.Green, + '#7ae2d5', + ]; + + readonly root = { + radius: 8, + + textResizing: TextResizing.AUTO_WIDTH_AND_HEIGHT, + + strokeWidth: 4, + strokeColor: '#84CFFF', + + fontFamily: FontFamily.Poppins, + fontSize: 20, + fontWeight: FontWeight.SemiBold, + color: '--affine-black', + + filled: true, + fillColor: '--affine-white', + + padding: [11, 22] as [number, number], + + shadow: { + offsetX: 0, + offsetY: 6, + blur: 12, + color: 'rgba(0, 0, 0, 0.14)', + }, + }; + + private _getColor(number: number) { + return this._colorOrders[number % this._colorOrders.length]; + } + + getNodeStyle(_: MindmapNode, path: number[]) { + const color = this._getColor(path[1] ?? 0); + + return { + connector: { + strokeStyle: StrokeStyle.Solid, + stroke: color, + strokeWidth: 3, + + mode: ConnectorMode.Curve, + }, + collapseButton: { + width: 16, + height: 16, + radius: 0.5, + + filled: true, + fillColor: '--affine-white', + + strokeColor: color, + strokeWidth: 3, + }, + expandButton: { + width: 24, + height: 24, + radius: 8, + + filled: true, + fillColor: color, + + strokeColor: color, + strokeWidth: 0, + + padding: [4, 0], + + color: '--affine-white', + + fontFamily: FontFamily.Inter, + fontWeight: FontWeight.Bold, + fontSize: 15, + }, + node: { + radius: 8, + + textResizing: TextResizing.AUTO_WIDTH_AND_HEIGHT, + + strokeWidth: 3, + strokeColor: color, + + fontFamily: FontFamily.Poppins, + fontSize: 16, + fontWeight: FontWeight.Medium, + color: '--affine-black', + + filled: true, + fillColor: '--affine-white', + + padding: [6, 22] as [number, number], + + shadow: { + offsetX: 0, + offsetY: 6, + blur: 12, + color: 'rgba(0, 0, 0, 0.14)', + }, + }, + }; + } +} +export const styleOne = new StyleOne(); + +export class StyleTwo extends MindmapStyleGetter { + private _colorOrders = [ + ShapeFillColor.Blue, + '#7ae2d5', + ShapeFillColor.Yellow, + ]; + + readonly root = { + radius: 3, + + textResizing: TextResizing.AUTO_WIDTH_AND_HEIGHT, + + strokeWidth: 3, + strokeColor: '--affine-black', + + fontFamily: FontFamily.Poppins, + fontSize: 18, + fontWeight: FontWeight.SemiBold, + color: ShapeFillColor.Black, + + filled: true, + fillColor: ShapeFillColor.Orange, + + padding: [11, 22] as [number, number], + + shadow: { + blur: 0, + offsetX: 3, + offsetY: 3, + color: '--affine-black', + }, + }; + + private _getColor(number: number) { + return number >= this._colorOrders.length + ? last(this._colorOrders)! + : this._colorOrders[number]; + } + + getNodeStyle(_: MindmapNode, path: number[]) { + const color = this._getColor(path.length - 2); + + return { + connector: { + strokeStyle: StrokeStyle.Solid, + stroke: '--affine-black', + strokeWidth: 3, + + mode: ConnectorMode.Orthogonal, + }, + collapseButton: { + width: 16, + height: 16, + radius: 0.5, + + filled: true, + fillColor: '--affine-white', + + strokeColor: '--affine-black', + strokeWidth: 3, + }, + expandButton: { + width: 24, + height: 24, + radius: 2, + + filled: true, + fillColor: '--affine-black', + + padding: [4, 0], + + strokeColor: '--affine-black', + strokeWidth: 0, + + color: '--affine-white', + + fontFamily: FontFamily.Inter, + fontWeight: FontWeight.Bold, + fontSize: 15, + }, + node: { + radius: 3, + + textResizing: TextResizing.AUTO_WIDTH_AND_HEIGHT, + + strokeWidth: 3, + strokeColor: '--affine-black', + + fontFamily: FontFamily.Poppins, + fontSize: 16, + fontWeight: FontWeight.SemiBold, + color: ShapeFillColor.Black, + + filled: true, + fillColor: color, + + padding: [6, 22] as [number, number], + + shadow: { + blur: 0, + offsetX: 3, + offsetY: 3, + color: '--affine-black', + }, + }, + }; + } +} +export const styleTwo = new StyleTwo(); + +export class StyleThree extends MindmapStyleGetter { + private _strokeColor = [LineColor.Yellow, LineColor.Green, LineColor.Teal]; + + readonly root = { + radius: 10, + + textResizing: TextResizing.AUTO_WIDTH_AND_HEIGHT, + + strokeWidth: 0, + strokeColor: 'transparent', + + fontFamily: FontFamily.Poppins, + fontSize: 16, + fontWeight: FontWeight.Medium, + color: ShapeFillColor.Black, + + filled: true, + fillColor: ShapeFillColor.Yellow, + + padding: [10, 22] as [number, number], + + shadow: { + blur: 12, + offsetX: 0, + offsetY: 0, + color: 'rgba(66, 65, 73, 0.18)', + }, + }; + + private _getColor(number: number) { + return this._strokeColor[number % this._strokeColor.length]; + } + + override getNodeStyle(_: MindmapNode, path: number[]) { + const strokeColor = this._getColor(path.length - 2); + + return { + node: { + radius: 10, + + textResizing: TextResizing.AUTO_WIDTH_AND_HEIGHT, + + strokeWidth: 2, + strokeColor: strokeColor, + + fontFamily: FontFamily.Poppins, + fontSize: 16, + fontWeight: FontWeight.Medium, + color: ShapeFillColor.Black, + + filled: true, + fillColor: ShapeFillColor.White, + + padding: [6, 22] as [number, number], + + shadow: { + blur: 12, + offsetX: 0, + offsetY: 0, + color: 'rgba(66, 65, 73, 0.18)', + }, + }, + collapseButton: { + width: 16, + height: 16, + radius: 0.5, + + filled: true, + fillColor: '--affine-white', + + strokeColor: '#3CBC36', + strokeWidth: 3, + }, + expandButton: { + width: 24, + height: 24, + radius: 8, + + filled: true, + fillColor: '#3CBC36', + + padding: [4, 0], + + strokeColor: '#3CBC36', + strokeWidth: 0, + + color: '#fff', + + fontFamily: FontFamily.Inter, + fontWeight: FontWeight.Bold, + fontSize: 15, + }, + connector: { + strokeStyle: StrokeStyle.Solid, + stroke: strokeColor, + strokeWidth: 2, + + mode: ConnectorMode.Curve, + }, + }; + } +} +export const styleThree = new StyleThree(); + +export class StyleFour extends MindmapStyleGetter { + private _colors = [ + ShapeFillColor.Purple, + ShapeFillColor.Magenta, + ShapeFillColor.Orange, + ShapeFillColor.Yellow, + ShapeFillColor.Green, + ShapeFillColor.Blue, + ]; + + readonly root = { + radius: 0, + + textResizing: TextResizing.AUTO_WIDTH_AND_HEIGHT, + + strokeWidth: 0, + strokeColor: 'transparent', + + fontFamily: FontFamily.Kalam, + fontSize: 22, + fontWeight: FontWeight.Bold, + color: '--affine-black', + + filled: true, + fillColor: 'transparent', + + padding: [0, 10] as [number, number], + }; + + private _getColor(order: number) { + return this._colors[order % this._colors.length]; + } + + getNodeStyle(_: MindmapNode, path: number[]) { + const stroke = this._getColor(path[1] ?? 0); + + return { + connector: { + strokeStyle: StrokeStyle.Solid, + stroke, + strokeWidth: 3, + + mode: ConnectorMode.Curve, + }, + collapseButton: { + width: 16, + height: 16, + radius: 0.5, + + filled: true, + fillColor: '--affine-white', + + strokeColor: stroke, + strokeWidth: 3, + }, + expandButton: { + width: 24, + height: 24, + radius: 8, + + filled: true, + fillColor: stroke, + + padding: [4, 0], + + strokeColor: stroke, + strokeWidth: 0, + + color: '--affine-white', + + fontFamily: FontFamily.Inter, + fontWeight: FontWeight.Bold, + fontSize: 15, + }, + node: { + ...this.root, + + fontSize: 18, + padding: [1.5, 10] as [number, number], + }, + }; + } +} +export const styleFour = new StyleFour(); + +export const mindmapStyleGetters: Record = { + [MindmapStyle.ONE]: styleOne, + [MindmapStyle.TWO]: styleTwo, + [MindmapStyle.THREE]: styleThree, + [MindmapStyle.FOUR]: styleFour, +}; + +export const applyNodeStyle = (node: MindmapNode, nodeStyle: NodeStyle) => { + Object.entries(nodeStyle).forEach(([key, value]) => { + // @ts-expect-error FIXME: ts error + if (!isEqual(node.element[key], value)) { + // @ts-expect-error FIXME: ts error + node.element[key] = value; + } + }); +}; diff --git a/blocksuite/affine/model/src/elements/mindmap/utils.ts b/blocksuite/affine/model/src/elements/mindmap/utils.ts new file mode 100644 index 0000000000..65ae14ee4a --- /dev/null +++ b/blocksuite/affine/model/src/elements/mindmap/utils.ts @@ -0,0 +1,46 @@ +import type { MindmapNode } from './mindmap.js'; + +export function findInfiniteLoop( + root: MindmapNode, + nodeMap: Map +) { + const visited = new Set(); + const loop: { + detached: boolean; + chain: MindmapNode[]; + }[] = []; + + const traverse = ( + node: MindmapNode, + traverseChain: MindmapNode[] = [], + detached = false + ) => { + if (visited.has(node.id)) { + loop.push({ + detached, + chain: traverseChain, + }); + return; + } + + visited.add(node.id); + + traverseChain.push(node); + + node.children.forEach(child => + traverse(child, traverseChain.slice(), detached) + ); + }; + + traverse(root); + + nodeMap.forEach(node => { + if (visited.has(node.id)) { + return; + } + + traverse(node, [], true); + }); + + return loop; +} diff --git a/blocksuite/affine/model/src/elements/shape/api/diamond.ts b/blocksuite/affine/model/src/elements/shape/api/diamond.ts new file mode 100644 index 0000000000..2c76487217 --- /dev/null +++ b/blocksuite/affine/model/src/elements/shape/api/diamond.ts @@ -0,0 +1,120 @@ +import type { PointTestOptions } from '@blocksuite/block-std/gfx'; +import type { IBound, IVec } from '@blocksuite/global/utils'; +import { + Bound, + getCenterAreaBounds, + getPointsFromBoundWithRotation, + linePolygonIntersects, + pointInPolygon, + PointLocation, + pointOnPolygonStoke, + polygonGetPointTangent, + polygonNearestPoint, + rotatePoints, +} from '@blocksuite/global/utils'; + +import { DEFAULT_CENTRAL_AREA_RATIO } from '../../../consts/index.js'; +import type { ShapeElementModel } from '../shape.js'; + +export const diamond = { + points({ x, y, w, h }: IBound): IVec[] { + return [ + [x, y + h / 2], + [x + w / 2, y], + [x + w, y + h / 2], + [x + w / 2, y + h], + ]; + }, + draw(ctx: CanvasRenderingContext2D, { x, y, w, h, rotate = 0 }: IBound) { + const cx = x + w / 2; + const cy = y + h / 2; + + ctx.save(); + ctx.translate(cx, cy); + ctx.rotate((rotate * Math.PI) / 180); + ctx.translate(-cx, -cy); + + ctx.beginPath(); + ctx.moveTo(x, y + h / 2); + ctx.lineTo(x + w / 2, y); + ctx.lineTo(x + w, y + h / 2); + ctx.lineTo(x + w / 2, y + h); + ctx.closePath(); + + ctx.restore(); + }, + + includesPoint( + this: ShapeElementModel, + x: number, + y: number, + options: PointTestOptions + ) { + const point: IVec = [x, y]; + const points = getPointsFromBoundWithRotation(this, diamond.points); + + let hit = pointOnPolygonStoke( + point, + points, + (options?.hitThreshold ?? 1) / (options.zoom ?? 1) + ); + + if (!hit) { + if (!options.ignoreTransparent || this.filled) { + hit = pointInPolygon([x, y], points); + } else { + // If shape is not filled or transparent + const text = this.text; + if (!text || !text.length) { + // Check the center area of the shape + const centralBounds = getCenterAreaBounds( + this, + DEFAULT_CENTRAL_AREA_RATIO + ); + const centralPoints = getPointsFromBoundWithRotation( + centralBounds, + diamond.points + ); + hit = pointInPolygon(point, centralPoints); + } else if (this.textBound) { + hit = pointInPolygon( + point, + getPointsFromBoundWithRotation( + this, + () => Bound.from(this.textBound!).points + ) + ); + } + } + } + + return hit; + }, + + containsBound(bounds: Bound, element: ShapeElementModel) { + const points = getPointsFromBoundWithRotation(element, diamond.points); + return points.some(point => bounds.containsPoint(point)); + }, + + getNearestPoint(point: IVec, element: ShapeElementModel) { + const points = getPointsFromBoundWithRotation(element, diamond.points); + return polygonNearestPoint(points, point); + }, + + getLineIntersections(start: IVec, end: IVec, element: ShapeElementModel) { + const points = getPointsFromBoundWithRotation(element, diamond.points); + return linePolygonIntersects(start, end, points); + }, + + getRelativePointLocation(position: IVec, element: ShapeElementModel) { + const bound = Bound.deserialize(element.xywh); + const point = bound.getRelativePoint(position); + let points = diamond.points(bound); + points.push(point); + + points = rotatePoints(points, bound.center, element.rotate); + const rotatePoint = points.pop() as IVec; + const tangent = polygonGetPointTangent(points, rotatePoint); + return new PointLocation(rotatePoint, tangent); + }, +}; diff --git a/blocksuite/affine/model/src/elements/shape/api/ellipse.ts b/blocksuite/affine/model/src/elements/shape/api/ellipse.ts new file mode 100644 index 0000000000..203338b9c1 --- /dev/null +++ b/blocksuite/affine/model/src/elements/shape/api/ellipse.ts @@ -0,0 +1,202 @@ +import type { PointTestOptions } from '@blocksuite/block-std/gfx'; +import { + Bound, + clamp, + getPointsFromBoundWithRotation, + type IBound, + type IVec, + lineEllipseIntersects, + pointInEllipse, + pointInPolygon, + PointLocation, + rotatePoints, + toRadian, + Vec, +} from '@blocksuite/global/utils'; + +import { DEFAULT_CENTRAL_AREA_RATIO } from '../../../consts/index.js'; +import type { ShapeElementModel } from '../shape.js'; + +export const ellipse = { + points({ x, y, w, h }: IBound): IVec[] { + return [ + [x, y + h / 2], + [x + w / 2, y], + [x + w, y + h / 2], + [x + w / 2, y + h], + ]; + }, + draw(ctx: CanvasRenderingContext2D, { x, y, w, h, rotate = 0 }: IBound) { + const cx = x + w / 2; + const cy = y + h / 2; + + ctx.save(); + ctx.translate(cx, cy); + ctx.rotate((rotate * Math.PI) / 180); + ctx.translate(-cx, -cy); + + ctx.beginPath(); + ctx.ellipse(cx, cy, w / 2, h / 2, 0, 0, 2 * Math.PI); + + ctx.restore(); + }, + includesPoint( + this: ShapeElementModel, + x: number, + y: number, + options: PointTestOptions + ) { + const point: IVec = [x, y]; + const expand = (options?.hitThreshold ?? 1) / (options?.zoom ?? 1); + const rx = this.w / 2; + const ry = this.h / 2; + const center: IVec = [this.x + rx, this.y + ry]; + const rad = (this.rotate * Math.PI) / 180; + + let hit = + pointInEllipse(point, center, rx + expand, ry + expand, rad) && + !pointInEllipse(point, center, rx - expand, ry - expand, rad); + + if (!hit) { + if (!options.ignoreTransparent || this.filled) { + hit = pointInEllipse(point, center, rx, ry, rad); + } else { + // If shape is not filled or transparent + const text = this.text; + if (!text || !text.length) { + // Check the center area of the shape + const centralRx = rx * DEFAULT_CENTRAL_AREA_RATIO; + const centralRy = ry * DEFAULT_CENTRAL_AREA_RATIO; + hit = pointInEllipse(point, center, centralRx, centralRy, rad); + } else if (this.textBound) { + hit = pointInPolygon( + point, + getPointsFromBoundWithRotation( + this, + () => Bound.from(this.textBound!).points + ) + ); + } + } + } + + return hit; + }, + containsBound(bounds: Bound, element: ShapeElementModel): boolean { + const points = getPointsFromBoundWithRotation(element, ellipse.points); + return points.some(point => bounds.containsPoint(point)); + }, + + // See links: + // * https://github.com/0xfaded/ellipse_demo/issues/1 + // * https://blog.chatfield.io/simple-method-for-distance-to-ellipse/ + // * https://gist.github.com/fundon/11331322d3ca223c42e216df48c339e1 + // * https://github.com/excalidraw/excalidraw/blob/master/packages/utils/geometry/geometry.ts#L888 (MIT) + getNearestPoint(point: IVec, { rotate, xywh }: ShapeElementModel) { + const { center, w, h } = Bound.deserialize(xywh); + const rad = toRadian(rotate); + const a = w / 2; + const b = h / 2; + + // Use the center of the ellipse as the origin + const [rotatedPointX, rotatedPointY] = Vec.rot( + Vec.sub(point, center), + -rad + ); + + const px = Math.abs(rotatedPointX); + const py = Math.abs(rotatedPointY); + + let tx = Math.SQRT1_2; // 0.707 + let ty = Math.SQRT1_2; // 0.707 + let i = 0; + + for (; i < 3; i++) { + const x = a * tx; + const y = b * ty; + + const ex = ((a * a - b * b) * tx ** 3) / a; + const ey = ((b * b - a * a) * ty ** 3) / b; + + const rx = x - ex; + const ry = y - ey; + + const qx = px - ex; + const qy = py - ey; + + const r = Math.hypot(ry, rx); + const q = Math.hypot(qy, qx); + + tx = clamp(((qx * r) / q + ex) / a, 0, 1); + ty = clamp(((qy * r) / q + ey) / b, 0, 1); + const t = Math.hypot(ty, tx); + tx /= t; + ty /= t; + } + + return Vec.add( + Vec.rot( + [a * tx * Math.sign(rotatedPointX), b * ty * Math.sign(rotatedPointY)], + rad + ), + center + ); + }, + + getLineIntersections( + start: IVec, + end: IVec, + { rotate, xywh }: ShapeElementModel + ) { + const rad = toRadian(rotate); + const bound = Bound.deserialize(xywh); + return lineEllipseIntersects( + start, + end, + bound.center, + bound.w / 2, + bound.h / 2, + rad + ); + }, + + getRelativePointLocation( + relativePoint: IVec, + { rotate, xywh }: ShapeElementModel + ) { + const bounds = Bound.deserialize(xywh); + const point = bounds.getRelativePoint(relativePoint); + const { x, y, w, h, center } = bounds; + const points = rotatePoints( + [ + [x, y], + [x + w / 2, y], + [x + w, y], + [x + w, y + h / 2], + [x + w, y + h], + [x + w / 2, y + h], + [x, y + h], + [x, y + h / 2], + point, + ], + center, + rotate + ); + const rotatedPoint = points.pop() as IVec; + const len = points.length; + let tangent: IVec = [0, 0.5]; + let i = 0; + + for (; i < len; i++) { + const p0 = points[i]; + const p1 = points[(i + 1) % len]; + const bounds = Bound.fromPoints([p0, p1, center]); + if (bounds.containsPoint(rotatedPoint)) { + tangent = Vec.normalize(Vec.sub(p1, p0)); + break; + } + } + + return new PointLocation(rotatedPoint, tangent); + }, +}; diff --git a/blocksuite/affine/model/src/elements/shape/api/index.ts b/blocksuite/affine/model/src/elements/shape/api/index.ts new file mode 100644 index 0000000000..7368d0b8b6 --- /dev/null +++ b/blocksuite/affine/model/src/elements/shape/api/index.ts @@ -0,0 +1,12 @@ +import type { ShapeType } from '../../../consts/shape.js'; +import { diamond } from './diamond.js'; +import { ellipse } from './ellipse.js'; +import { rect } from './rect.js'; +import { triangle } from './triangle.js'; + +export const shapeMethods: Record = { + rect, + triangle, + ellipse, + diamond, +}; diff --git a/blocksuite/affine/model/src/elements/shape/api/rect.ts b/blocksuite/affine/model/src/elements/shape/api/rect.ts new file mode 100644 index 0000000000..1092d965cb --- /dev/null +++ b/blocksuite/affine/model/src/elements/shape/api/rect.ts @@ -0,0 +1,119 @@ +import type { PointTestOptions } from '@blocksuite/block-std/gfx'; +import type { IBound, IVec } from '@blocksuite/global/utils'; +import { + Bound, + getCenterAreaBounds, + getPointsFromBoundWithRotation, + linePolygonIntersects, + pointInPolygon, + PointLocation, + pointOnPolygonStoke, + polygonGetPointTangent, + polygonNearestPoint, + rotatePoints, +} from '@blocksuite/global/utils'; + +import { DEFAULT_CENTRAL_AREA_RATIO } from '../../../consts/index.js'; +import type { ShapeElementModel } from '../shape.js'; + +export const rect = { + points({ x, y, w, h }: IBound) { + return [ + [x, y], + [x + w, y], + [x + w, y + h], + [x, y + h], + ]; + }, + draw(ctx: CanvasRenderingContext2D, { x, y, w, h, rotate = 0 }: IBound) { + ctx.save(); + ctx.translate(x + w / 2, y + h / 2); + ctx.rotate((rotate * Math.PI) / 180); + ctx.translate(-x - w / 2, -y - h / 2); + ctx.rect(x, y, w, h); + ctx.restore(); + }, + includesPoint( + this: ShapeElementModel, + x: number, + y: number, + options: PointTestOptions + ) { + const point: IVec = [x, y]; + const points = getPointsFromBoundWithRotation( + this, + undefined, + options.responsePadding + ); + + let hit = pointOnPolygonStoke( + point, + points, + (options?.hitThreshold ?? 1) / (options.zoom ?? 1) + ); + + if (!hit) { + // If the point is not on the stroke, check if it is in the shape + // When the shape is filled and transparent is not ignored + if (!options.ignoreTransparent || this.filled) { + hit = pointInPolygon([x, y], points); + } else { + // If shape is not filled or transparent + // Check if hit the text area + const text = this.text; + if (!text || !text.length) { + // if not, check the default center area of the shape + const centralBounds = getCenterAreaBounds( + this, + DEFAULT_CENTRAL_AREA_RATIO + ); + const centralPoints = getPointsFromBoundWithRotation(centralBounds); + // Check if the point is in the center area + hit = pointInPolygon([x, y], centralPoints); + } else if (this.textBound) { + hit = pointInPolygon( + point, + getPointsFromBoundWithRotation( + this, + () => Bound.from(this.textBound!).points + ) + ); + } + } + } + + return hit; + }, + + containsBound(bounds: Bound, element: ShapeElementModel): boolean { + const points = getPointsFromBoundWithRotation(element); + return points.some(point => bounds.containsPoint(point)); + }, + + getNearestPoint(point: IVec, element: ShapeElementModel) { + const points = getPointsFromBoundWithRotation(element); + return polygonNearestPoint(points, point); + }, + + getLineIntersections(start: IVec, end: IVec, element: ShapeElementModel) { + const points = getPointsFromBoundWithRotation(element); + return linePolygonIntersects(start, end, points); + }, + + getRelativePointLocation(relativePoint: IVec, element: ShapeElementModel) { + const bound = Bound.deserialize(element.xywh); + const point = bound.getRelativePoint(relativePoint); + const rotatePoint = rotatePoints( + [point], + bound.center, + element.rotate ?? 0 + )[0]; + const points = rotatePoints( + bound.points, + bound.center, + element.rotate ?? 0 + ); + const tangent = polygonGetPointTangent(points, rotatePoint); + return new PointLocation(rotatePoint, tangent); + }, +}; diff --git a/blocksuite/affine/model/src/elements/shape/api/triangle.ts b/blocksuite/affine/model/src/elements/shape/api/triangle.ts new file mode 100644 index 0000000000..e4cc04d9b6 --- /dev/null +++ b/blocksuite/affine/model/src/elements/shape/api/triangle.ts @@ -0,0 +1,116 @@ +import type { PointTestOptions } from '@blocksuite/block-std/gfx'; +import type { IBound, IVec } from '@blocksuite/global/utils'; +import { + Bound, + getCenterAreaBounds, + getPointsFromBoundWithRotation, + linePolygonIntersects, + pointInPolygon, + PointLocation, + pointOnPolygonStoke, + polygonGetPointTangent, + polygonNearestPoint, + rotatePoints, +} from '@blocksuite/global/utils'; + +import { DEFAULT_CENTRAL_AREA_RATIO } from '../../../consts/index.js'; +import type { ShapeElementModel } from '../shape.js'; + +export const triangle = { + points({ x, y, w, h }: IBound): IVec[] { + return [ + [x, y + h], + [x + w / 2, y], + [x + w, y + h], + ]; + }, + draw(ctx: CanvasRenderingContext2D, { x, y, w, h, rotate = 0 }: IBound) { + const cx = x + w / 2; + const cy = y + h / 2; + + ctx.save(); + ctx.translate(cx, cy); + ctx.rotate((rotate * Math.PI) / 180); + ctx.translate(-cx, -cy); + + ctx.beginPath(); + ctx.moveTo(x, y + h); + ctx.lineTo(x + w / 2, y); + ctx.lineTo(x + w, y + h); + ctx.closePath(); + + ctx.restore(); + }, + includesPoint( + this: ShapeElementModel, + x: number, + y: number, + options: PointTestOptions + ) { + const point: IVec = [x, y]; + const points = getPointsFromBoundWithRotation(this, triangle.points); + + let hit = pointOnPolygonStoke( + point, + points, + (options?.hitThreshold ?? 1) / (options?.zoom ?? 1) + ); + + if (!hit) { + if (!options.ignoreTransparent || this.filled) { + hit = pointInPolygon([x, y], points); + } else { + // If shape is not filled or transparent + const text = this.text; + if (!text || !text.length) { + // Check the center area of the shape + const centralBounds = getCenterAreaBounds( + this, + DEFAULT_CENTRAL_AREA_RATIO + ); + const centralPoints = getPointsFromBoundWithRotation( + centralBounds, + triangle.points + ); + hit = pointInPolygon([x, y], centralPoints); + } else if (this.textBound) { + hit = pointInPolygon( + point, + getPointsFromBoundWithRotation( + this, + () => Bound.from(this.textBound!).points + ) + ); + } + } + } + + return hit; + }, + containsBound(bounds: Bound, element: ShapeElementModel): boolean { + const points = getPointsFromBoundWithRotation(element, triangle.points); + return points.some(point => bounds.containsPoint(point)); + }, + + getNearestPoint(point: IVec, element: ShapeElementModel) { + const points = getPointsFromBoundWithRotation(element, triangle.points); + return polygonNearestPoint(points, point); + }, + + getLineIntersections(start: IVec, end: IVec, element: ShapeElementModel) { + const points = getPointsFromBoundWithRotation(element, triangle.points); + return linePolygonIntersects(start, end, points); + }, + + getRelativePointLocation(position: IVec, element: ShapeElementModel) { + const bound = Bound.deserialize(element.xywh); + const point = bound.getRelativePoint(position); + let points = triangle.points(bound); + points.push(point); + + points = rotatePoints(points, bound.center, element.rotate); + const rotatePoint = points.pop() as IVec; + const tangent = polygonGetPointTangent(points, rotatePoint); + return new PointLocation(rotatePoint, tangent); + }, +}; diff --git a/blocksuite/affine/model/src/elements/shape/index.ts b/blocksuite/affine/model/src/elements/shape/index.ts new file mode 100644 index 0000000000..ac6b7c18b1 --- /dev/null +++ b/blocksuite/affine/model/src/elements/shape/index.ts @@ -0,0 +1,2 @@ +export * from './api/index.js'; +export * from './shape.js'; diff --git a/blocksuite/affine/model/src/elements/shape/shape.ts b/blocksuite/affine/model/src/elements/shape/shape.ts new file mode 100644 index 0000000000..d40b87cd8c --- /dev/null +++ b/blocksuite/affine/model/src/elements/shape/shape.ts @@ -0,0 +1,277 @@ +import type { + BaseElementProps, + PointTestOptions, +} from '@blocksuite/block-std/gfx'; +import { + field, + GfxLocalElementModel, + GfxPrimitiveElementModel, + local, + prop, +} from '@blocksuite/block-std/gfx'; +import type { + Bound, + IBound, + IVec, + PointLocation, + SerializedXYWH, +} from '@blocksuite/global/utils'; +import { DocCollection, type Y } from '@blocksuite/store'; + +import { + type Color, + DEFAULT_ROUGHNESS, + FontFamily, + FontStyle, + FontWeight, + LineColor, + ShapeFillColor, + ShapeStyle, + ShapeTextFontSize, + ShapeType, + StrokeStyle, + TextAlign, + TextResizing, + type TextStyleProps, + TextVerticalAlign, +} from '../../consts/index.js'; +import { shapeMethods } from './api/index.js'; + +export type ShapeProps = BaseElementProps & { + shapeType: ShapeType; + radius: number; + filled: boolean; + fillColor: Color; + strokeWidth: number; + strokeColor: Color; + strokeStyle: StrokeStyle; + shapeStyle: ShapeStyle; + // https://github.com/rough-stuff/rough/wiki#roughness + roughness?: number; + + text?: Y.Text; + textHorizontalAlign?: TextAlign; + textVerticalAlign?: TextVerticalAlign; + textResizing?: TextResizing; + maxWidth?: false | number; +} & Partial; + +export const SHAPE_TEXT_PADDING = 20; +export const SHAPE_TEXT_VERTICAL_PADDING = 10; + +export class ShapeElementModel extends GfxPrimitiveElementModel { + /** + * The bound of the text content. + */ + textBound: IBound | null = null; + + get type() { + return 'shape'; + } + + static override propsToY(props: ShapeProps) { + if (props.text && !(props.text instanceof DocCollection.Y.Text)) { + props.text = new DocCollection.Y.Text(props.text); + } + + return props; + } + + override containsBound(bounds: Bound) { + return shapeMethods[this.shapeType].containsBound(bounds, this); + } + + override getLineIntersections(start: IVec, end: IVec) { + return shapeMethods[this.shapeType].getLineIntersections(start, end, this); + } + + override getNearestPoint(point: IVec): IVec { + return shapeMethods[this.shapeType].getNearestPoint(point, this) as IVec; + } + + override getRelativePointLocation(point: IVec): PointLocation { + return shapeMethods[this.shapeType].getRelativePointLocation(point, this); + } + + override includesPoint(x: number, y: number, options: PointTestOptions) { + return shapeMethods[this.shapeType].includesPoint.call(this, x, y, { + ...options, + ignoreTransparent: options.ignoreTransparent ?? true, + }); + } + + @field('#000000' as Color) + accessor color!: Color; + + @field() + accessor fillColor: Color = ShapeFillColor.Yellow; + + @field() + accessor filled: boolean = false; + + @field(FontFamily.Inter as string) + accessor fontFamily!: string; + + @field(ShapeTextFontSize.MEDIUM) + accessor fontSize!: number; + + @field(FontStyle.Normal as FontStyle) + accessor fontStyle!: FontStyle; + + @field(FontWeight.Regular as FontWeight) + accessor fontWeight!: FontWeight; + + @field(false as false | number) + accessor maxWidth: false | number = false; + + @field([SHAPE_TEXT_VERTICAL_PADDING, SHAPE_TEXT_PADDING]) + accessor padding: [number, number] = [ + SHAPE_TEXT_VERTICAL_PADDING, + SHAPE_TEXT_PADDING, + ]; + + @field() + accessor radius: number = 0; + + @field(0) + accessor rotate: number = 0; + + @field(DEFAULT_ROUGHNESS) + accessor roughness: number = DEFAULT_ROUGHNESS; + + @field() + accessor shadow: { + /** + * @deprecated Since the shadow blur will reduce the performance of canvas rendering, + * we already disable the shadow blur rendering by default, so set this field will not take effect. + * You can enable it by setting the flag `enable_shape_shadow_blur` in the awareness store. + * https://web.dev/articles/canvas-performance#avoid_shadowblur + */ + blur: number; + offsetX: number; + offsetY: number; + color: string; + } | null = null; + + @field() + accessor shapeStyle: ShapeStyle = ShapeStyle.General; + + @field() + accessor shapeType: ShapeType = ShapeType.Rect; + + @field() + accessor strokeColor: Color = LineColor.Yellow; + + @field() + accessor strokeStyle: StrokeStyle = StrokeStyle.Solid; + + @field() + accessor strokeWidth: number = 4; + + @field() + accessor text: Y.Text | undefined = undefined; + + @field(TextAlign.Center as TextAlign) + accessor textAlign!: TextAlign; + + @local() + accessor textDisplay: boolean = true; + + @field(TextAlign.Center as TextAlign) + accessor textHorizontalAlign!: TextAlign; + + @field(TextResizing.AUTO_HEIGHT as TextResizing) + accessor textResizing: TextResizing = TextResizing.AUTO_HEIGHT; + + @field(TextVerticalAlign.Center as TextVerticalAlign) + accessor textVerticalAlign!: TextVerticalAlign; + + @field() + accessor xywh: SerializedXYWH = '[0,0,100,100]'; +} + +export class LocalShapeElementModel extends GfxLocalElementModel { + roughness: number = DEFAULT_ROUGHNESS; + + textBound: Bound | null = null; + + textDisplay: boolean = true; + + get type() { + return 'shape'; + } + + @prop() + accessor color: Color = '#000000'; + + @prop() + accessor fillColor: Color = ShapeFillColor.Yellow; + + @prop() + accessor filled: boolean = false; + + @prop() + accessor fontFamily: string = FontFamily.Inter; + + @prop() + accessor fontSize: number = 16; + + @prop() + accessor fontStyle: FontStyle = FontStyle.Normal; + + @prop() + accessor fontWeight: FontWeight = FontWeight.Regular; + + @prop() + accessor padding: [number, number] = [ + SHAPE_TEXT_VERTICAL_PADDING, + SHAPE_TEXT_PADDING, + ]; + + @prop() + accessor radius: number = 0; + + @prop() + accessor shadow: { + blur: number; + offsetX: number; + offsetY: number; + color: string; + } | null = null; + + @prop() + accessor shapeStyle: ShapeStyle = ShapeStyle.General; + + @prop() + accessor shapeType: ShapeType = ShapeType.Rect; + + @prop() + accessor strokeColor: Color = LineColor.Yellow; + + @prop() + accessor strokeStyle: StrokeStyle = StrokeStyle.Solid; + + @prop() + accessor strokeWidth: number = 4; + + @prop() + accessor text: string = ''; + + @prop() + accessor textAlign: TextAlign = TextAlign.Center; + + @prop() + accessor textVerticalAlign: TextVerticalAlign = TextVerticalAlign.Center; +} + +declare global { + namespace BlockSuite { + interface SurfaceElementModelMap { + shape: ShapeElementModel; + } + + interface EdgelessTextModelMap { + shape: ShapeElementModel; + } + } +} diff --git a/blocksuite/affine/model/src/elements/text/index.ts b/blocksuite/affine/model/src/elements/text/index.ts new file mode 100644 index 0000000000..9a89020a79 --- /dev/null +++ b/blocksuite/affine/model/src/elements/text/index.ts @@ -0,0 +1 @@ +export * from './text.js'; diff --git a/blocksuite/affine/model/src/elements/text/text.ts b/blocksuite/affine/model/src/elements/text/text.ts new file mode 100644 index 0000000000..06e724a3a5 --- /dev/null +++ b/blocksuite/affine/model/src/elements/text/text.ts @@ -0,0 +1,104 @@ +import type { BaseElementProps } from '@blocksuite/block-std/gfx'; +import { field, GfxPrimitiveElementModel } from '@blocksuite/block-std/gfx'; +import type { IVec, SerializedXYWH } from '@blocksuite/global/utils'; +import { + Bound, + getPointsFromBoundWithRotation, + linePolygonIntersects, + pointInPolygon, + polygonNearestPoint, +} from '@blocksuite/global/utils'; +import { DocCollection, type Y } from '@blocksuite/store'; + +import { + type Color, + FontFamily, + FontStyle, + FontWeight, + TextAlign, + type TextStyleProps, +} from '../../consts/index.js'; + +export type TextElementProps = BaseElementProps & { + text: Y.Text; + hasMaxWidth?: boolean; +} & Omit & + Partial>; + +export class TextElementModel extends GfxPrimitiveElementModel { + get type() { + return 'text'; + } + + static override propsToY(props: Record) { + if (props.text && !(props.text instanceof DocCollection.Y.Text)) { + props.text = new DocCollection.Y.Text(props.text as string); + } + + return props; + } + + override containsBound(bounds: Bound): boolean { + const points = getPointsFromBoundWithRotation(this); + return points.some(point => bounds.containsPoint(point)); + } + + override getLineIntersections(start: IVec, end: IVec) { + const points = getPointsFromBoundWithRotation(this); + return linePolygonIntersects(start, end, points); + } + + override getNearestPoint(point: IVec): IVec { + return polygonNearestPoint( + Bound.deserialize(this.xywh).points, + point + ) as IVec; + } + + override includesPoint(x: number, y: number): boolean { + const points = getPointsFromBoundWithRotation(this); + return pointInPolygon([x, y], points); + } + + @field() + accessor color: Color = '#000000'; + + @field() + accessor fontFamily: FontFamily = FontFamily.Inter; + + @field() + accessor fontSize: number = 16; + + @field(FontStyle.Normal as FontStyle) + accessor fontStyle: FontStyle = FontStyle.Normal; + + @field(FontWeight.Regular as FontWeight) + accessor fontWeight: FontWeight = FontWeight.Regular; + + @field(false) + accessor hasMaxWidth: boolean = false; + + @field(0) + accessor rotate: number = 0; + + @field() + accessor text: Y.Text = new DocCollection.Y.Text(); + + @field() + accessor textAlign: TextAlign = TextAlign.Center; + + @field() + accessor xywh: SerializedXYWH = '[0,0,16,16]'; +} + +declare global { + namespace BlockSuite { + interface SurfaceElementModelMap { + text: TextElementModel; + } + + interface EdgelessTextModelMap { + text: TextElementModel; + } + } +} diff --git a/blocksuite/affine/model/src/index.ts b/blocksuite/affine/model/src/index.ts new file mode 100644 index 0000000000..2ef5fe41e1 --- /dev/null +++ b/blocksuite/affine/model/src/index.ts @@ -0,0 +1,4 @@ +export * from './blocks/index.js'; +export * from './consts/index.js'; +export * from './elements/index.js'; +export * from './utils/index.js'; diff --git a/blocksuite/affine/model/src/utils/enum.ts b/blocksuite/affine/model/src/utils/enum.ts new file mode 100644 index 0000000000..fe0a6d0549 --- /dev/null +++ b/blocksuite/affine/model/src/utils/enum.ts @@ -0,0 +1,11 @@ +export function createEnumMap>( + EnumObject: T +) { + return Object.entries(EnumObject).reduce( + (acc, [key, value]) => { + acc[value as T[keyof T]] = key as keyof T; + return acc; + }, + {} as Record + ); +} diff --git a/blocksuite/affine/model/src/utils/global.ts b/blocksuite/affine/model/src/utils/global.ts new file mode 100644 index 0000000000..0f332fb613 --- /dev/null +++ b/blocksuite/affine/model/src/utils/global.ts @@ -0,0 +1,49 @@ +import type { + GfxBlockElementModel, + GfxGroupLikeElementModel, + GfxLocalElementModel, + GfxModel, + GfxPrimitiveElementModel, +} from '@blocksuite/block-std/gfx'; + +declare global { + namespace BlockSuite { + interface EdgelessBlockModelMap {} + type EdgelessBlockModelKeyType = keyof EdgelessBlockModelMap; + type EdgelessBlockModelType = + | EdgelessBlockModelMap[EdgelessBlockModelKeyType] + | GfxBlockElementModel; + + type EdgelessModel = GfxModel; + + interface EdgelessTextModelMap {} + type EdgelessTextModelKeyType = keyof EdgelessTextModelMap; + type EdgelessTextModelType = EdgelessTextModelMap[EdgelessTextModelKeyType]; + + interface SurfaceElementModelMap {} + type SurfaceElementModelKeys = keyof SurfaceElementModelMap; + type SurfaceElementModel = + | SurfaceElementModelMap[SurfaceElementModelKeys] + | GfxPrimitiveElementModel; + + interface SurfaceGroupLikeModelMap {} + type SurfaceGroupLikeModelKeys = keyof SurfaceGroupLikeModelMap; + type SurfaceGroupLikeModel = + | SurfaceGroupLikeModelMap[SurfaceGroupLikeModelKeys] + | GfxGroupLikeElementModel; + + interface SurfaceLocalModelMap {} + type SurfaceLocalModelKeys = keyof SurfaceLocalModelMap; + type SurfaceLocalModel = + | SurfaceLocalModelMap[SurfaceLocalModelKeys] + | GfxLocalElementModel; + + // not include local model + type SurfaceModel = SurfaceElementModel | SurfaceGroupLikeModel; + type SurfaceModelKeyType = + | SurfaceElementModelKeys + | SurfaceGroupLikeModelKeys; + + type EdgelessModelKeys = EdgelessBlockModelKeyType | SurfaceModelKeyType; + } +} diff --git a/blocksuite/affine/model/src/utils/helper.ts b/blocksuite/affine/model/src/utils/helper.ts new file mode 100644 index 0000000000..2e69a14fa3 --- /dev/null +++ b/blocksuite/affine/model/src/utils/helper.ts @@ -0,0 +1,63 @@ +import type { GfxCompatibleProps } from '@blocksuite/block-std/gfx'; +import { GfxCompatible } from '@blocksuite/block-std/gfx'; +import type { Constructor } from '@blocksuite/global/utils'; +import { + type BaseBlockTransformer, + type BlockModel, + defineBlockSchema, + type InternalPrimitives, +} from '@blocksuite/store'; + +export function defineEmbedModel< + Props extends object, + T extends Constructor> = Constructor>, +>(BlockModelSuperClass: T) { + return GfxCompatible( + BlockModelSuperClass as Constructor> + ); +} + +export type EmbedProps = Props & GfxCompatibleProps; + +export type EmbedBlockModel = BlockModel>; + +export function createEmbedBlockSchema< + Props extends object, + Model extends EmbedBlockModel, + Transformer extends BaseBlockTransformer< + EmbedProps + > = BaseBlockTransformer>, +>({ + name, + version, + toModel, + props, + transformer, +}: { + name: string; + version: number; + toModel: () => Model; + props?: (internalPrimitives: InternalPrimitives) => Props; + transformer?: () => Transformer; +}) { + return defineBlockSchema({ + flavour: `affine:embed-${name}`, + props: internalPrimitives => { + const userProps = props?.(internalPrimitives); + + return { + index: 'a0', + xywh: '[0,0,0,0]', + lockedBySelf: false, + rotate: 0, + ...userProps, + } as unknown as EmbedProps; + }, + metadata: { + version, + role: 'content', + }, + toModel, + transformer, + }); +} diff --git a/blocksuite/affine/model/src/utils/index.ts b/blocksuite/affine/model/src/utils/index.ts new file mode 100644 index 0000000000..b316f75db1 --- /dev/null +++ b/blocksuite/affine/model/src/utils/index.ts @@ -0,0 +1,4 @@ +export * from './enum.js'; +export * from './global.js'; +export * from './helper.js'; +export * from './types.js'; diff --git a/blocksuite/affine/model/src/utils/types.ts b/blocksuite/affine/model/src/utils/types.ts new file mode 100644 index 0000000000..ef8fe53e95 --- /dev/null +++ b/blocksuite/affine/model/src/utils/types.ts @@ -0,0 +1,19 @@ +export type EmbedCardStyle = + | 'horizontal' + | 'horizontalThin' + | 'list' + | 'vertical' + | 'cube' + | 'cubeThick' + | 'video' + | 'figma' + | 'html' + | 'syncedDoc' + | 'pdf'; + +export type LinkPreviewData = { + description: string | null; + icon: string | null; + image: string | null; + title: string | null; +}; diff --git a/blocksuite/affine/model/tsconfig.json b/blocksuite/affine/model/tsconfig.json new file mode 100644 index 0000000000..e2574d0ea5 --- /dev/null +++ b/blocksuite/affine/model/tsconfig.json @@ -0,0 +1,23 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "rootDir": "./src/", + "outDir": "./dist/", + "noEmit": false + }, + "include": ["./src"], + "references": [ + { + "path": "../../framework/global" + }, + { + "path": "../../framework/store" + }, + { + "path": "../../framework/block-std" + }, + { + "path": "../../framework/inline" + } + ] +} diff --git a/blocksuite/affine/model/typedoc.json b/blocksuite/affine/model/typedoc.json new file mode 100644 index 0000000000..101e923dba --- /dev/null +++ b/blocksuite/affine/model/typedoc.json @@ -0,0 +1,4 @@ +{ + "extends": ["../../../typedoc.base.json"], + "entryPoints": ["src/index.ts"] +} diff --git a/blocksuite/affine/model/vitest.config.ts b/blocksuite/affine/model/vitest.config.ts new file mode 100644 index 0000000000..a1bcb95c66 --- /dev/null +++ b/blocksuite/affine/model/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/affine-model', + }, + /** + * 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/blocksuite/affine/shared/package.json b/blocksuite/affine/shared/package.json new file mode 100644 index 0000000000..9d7183f457 --- /dev/null +++ b/blocksuite/affine/shared/package.json @@ -0,0 +1,57 @@ +{ + "name": "@blocksuite/affine-shared", + "description": "Default BlockSuite editable blocks.", + "type": "module", + "scripts": { + "build": "tsc", + "test:unit": "nx vite:test --run", + "test:unit:coverage": "nx vite:test --run --coverage", + "test:e2e": "playwright test" + }, + "sideEffects": false, + "keywords": [], + "author": "toeverything", + "license": "MIT", + "dependencies": { + "@blocksuite/affine-model": "workspace:*", + "@blocksuite/block-std": "workspace:*", + "@blocksuite/global": "workspace:*", + "@blocksuite/icons": "^2.1.75", + "@blocksuite/inline": "workspace:*", + "@blocksuite/store": "workspace:*", + "@floating-ui/dom": "^1.6.10", + "@lit/context": "^1.1.2", + "@preact/signals-core": "^1.8.0", + "@toeverything/theme": "^1.1.1", + "@types/hast": "^3.0.4", + "@types/mdast": "^4.0.4", + "lit": "^3.2.0", + "lodash.clonedeep": "^4.5.0", + "lodash.mergewith": "^4.6.2", + "minimatch": "^10.0.1", + "zod": "^3.23.8" + }, + "exports": { + ".": "./src/index.ts", + "./selection": "./src/selection/index.ts", + "./utils": "./src/utils/index.ts", + "./consts": "./src/consts/index.ts", + "./types": "./src/types/index.ts", + "./commands": "./src/commands/index.ts", + "./mixins": "./src/mixins/index.ts", + "./theme": "./src/theme/index.ts", + "./styles": "./src/styles/index.ts", + "./services": "./src/services/index.ts", + "./adapters": "./src/adapters/index.ts" + }, + "files": [ + "src", + "dist", + "!src/__tests__", + "!dist/__tests__" + ], + "devDependencies": { + "@types/lodash.clonedeep": "^4.5.9", + "@types/lodash.mergewith": "^4" + } +} diff --git a/blocksuite/affine/shared/src/__tests__/utils/iterable.unit.spec.ts b/blocksuite/affine/shared/src/__tests__/utils/iterable.unit.spec.ts new file mode 100644 index 0000000000..ab5d7101ce --- /dev/null +++ b/blocksuite/affine/shared/src/__tests__/utils/iterable.unit.spec.ts @@ -0,0 +1,86 @@ +import { + atLeastNMatches, + countBy, + groupBy, + maxBy, +} from '@blocksuite/global/utils'; +import { describe, expect, it } from 'vitest'; + +describe('countBy', () => { + it('basic', () => { + const items = [ + { name: 'a', classroom: 'c1' }, + { name: 'b', classroom: 'c2' }, + { name: 'a', classroom: 'c2' }, + ]; + const counted = countBy(items, i => i.name); + expect(counted).toEqual({ a: 2, b: 1 }); + }); + + it('empty items', () => { + const counted = countBy([], i => i); + expect(Object.keys(counted).length).toBe(0); + }); +}); + +describe('maxBy', () => { + it('basic', () => { + const items = [{ n: 1 }, { n: 2 }]; + const max = maxBy(items, i => i.n); + expect(max).toBe(items[1]); + }); + + it('empty items', () => { + expect(maxBy([], i => i)).toBeNull(); + }); +}); + +describe('atLeastNMatches', () => { + it('basic', () => { + const arr = [1, 2, 3, 4, 5, 6, 7, 8, 9]; + const isEven = (num: number): boolean => num % 2 === 0; + const isGreaterThan5 = (num: number): boolean => num > 5; + const isNegative = (num: number): boolean => num < 0; + + expect(atLeastNMatches(arr, isEven, 3)).toBe(true); + expect(atLeastNMatches(arr, isGreaterThan5, 5)).toBe(false); + expect(atLeastNMatches(arr, isNegative, 1)).toBe(false); + + const strArr = ['apple', 'banana', 'orange', 'kiwi', 'mango']; + const startsWithA = (str: string): boolean => str[0].toLowerCase() === 'a'; + const longerThan5 = (str: string): boolean => str.length > 5; + + expect(atLeastNMatches(strArr, startsWithA, 1)).toBe(true); + expect(atLeastNMatches(strArr, longerThan5, 3)).toBe(false); + }); +}); + +describe('groupBy', () => { + it('basic', () => { + const students = [ + { name: 'Alice', age: 25 }, + { name: 'Bob', age: 23 }, + { name: 'Cathy', age: 25 }, + { name: 'David', age: 23 }, + ]; + + const groupedByAge = groupBy(students, student => student.age.toString()); + const expectedGroupedByAge = { + '23': [ + { name: 'Bob', age: 23 }, + { name: 'David', age: 23 }, + ], + '25': [ + { name: 'Alice', age: 25 }, + { name: 'Cathy', age: 25 }, + ], + }; + expect(groupedByAge).toMatchObject(expectedGroupedByAge); + }); + + it('empty', () => { + const emptyArray: string[] = []; + const groupedEmptyArray = groupBy(emptyArray, item => item); + expect(Object.keys(groupedEmptyArray).length).toBe(0); + }); +}); diff --git a/blocksuite/affine/shared/src/__tests__/utils/rect.unit.spec.ts b/blocksuite/affine/shared/src/__tests__/utils/rect.unit.spec.ts new file mode 100644 index 0000000000..cf1a41aefe --- /dev/null +++ b/blocksuite/affine/shared/src/__tests__/utils/rect.unit.spec.ts @@ -0,0 +1,57 @@ +import { Point } from '@blocksuite/global/utils'; +import { describe, expect, it } from 'vitest'; + +describe('Point', () => { + it('should return a min point', () => { + const a = new Point(0, 0); + const b = new Point(-1, 1); + expect(Point.min(a, b)).toEqual(new Point(-1, 0)); + }); + + it('should return a max point', () => { + const a = new Point(0, 0); + const b = new Point(-1, 1); + expect(Point.max(a, b)).toEqual(new Point(0, 1)); + }); + + it('should return a clamp point', () => { + const min = new Point(0, 0); + const max = new Point(1, 1); + const a = new Point(-1, 2); + expect(Point.clamp(a, min, max)).toEqual(new Point(0, 1)); + + const b = new Point(2, 2); + expect(Point.clamp(b, min, max)).toEqual(new Point(1, 1)); + + const c = new Point(0.5, 0.5); + expect(Point.clamp(c, min, max)).toEqual(new Point(0.5, 0.5)); + }); + + it('should return a copy of point', () => { + const a = new Point(0, 0); + expect(a.clone()).toEqual(new Point(0, 0)); + }); + + it('#set method should set x and y', () => { + const p = new Point(0, 0); + p.set(1, 2); + expect(p).toEqual(new Point(1, 2)); + }); + + it('#add', () => { + const a = new Point(1, 2); + const b = new Point(3, 4); + expect(a.add(b)).toEqual(new Point(4, 6)); + }); + + it('#subtract', () => { + const a = new Point(1, 2); + const b = new Point(3, 4); + expect(a.subtract(b)).toEqual(new Point(-2, -2)); + }); + + it('#scale', () => { + const a = new Point(1, 2); + expect(a.scale(2)).toEqual(new Point(2, 4)); + }); +}); diff --git a/blocksuite/affine/shared/src/__tests__/utils/string.unit.spec.ts b/blocksuite/affine/shared/src/__tests__/utils/string.unit.spec.ts new file mode 100644 index 0000000000..2cffd111e2 --- /dev/null +++ b/blocksuite/affine/shared/src/__tests__/utils/string.unit.spec.ts @@ -0,0 +1,69 @@ +import { describe, expect, it } from 'vitest'; + +import { isFuzzyMatch, substringMatchScore } from '../../utils/string.js'; + +describe('fuzzyMatch', () => { + it('basic case', () => { + expect(isFuzzyMatch('John Smith', 'j')).toEqual(true); + expect(isFuzzyMatch('John Smith', 'js')).toEqual(true); + expect(isFuzzyMatch('John Smith', 'jsa')).toEqual(false); + }); + + it('should works with CJK', () => { + expect(isFuzzyMatch('中', '中')).toEqual(true); + expect(isFuzzyMatch('中文', '中')).toEqual(true); + expect(isFuzzyMatch('中文字符', '中字')).toEqual(true); + expect(isFuzzyMatch('中文字符', '字中')).toEqual(false); + }); + + it('should works with IME', () => { + // IME will generate a space between 'da' and 't' + expect(isFuzzyMatch('database', 'da t')).toEqual(true); + }); +}); + +describe('substringMatchScore', () => { + it('should return a fraction if there exists a common maximal length substring. ', () => { + const result = substringMatchScore('testing the function', 'tet'); + expect(result).toBeLessThan(1); + expect(result).toBeGreaterThan(0); + }); + + it('should return bigger score for longer match', () => { + const result = substringMatchScore('testing the function', 'functin'); + const result2 = substringMatchScore('testing the function', 'tet'); + // because th length of common substring of 'functin' is bigger than 'tet' + expect(result).toBeGreaterThan(result2); + }); + + it('should return bigger score when using same query to search a shorter string', () => { + const result = substringMatchScore('test', 'test'); + const result2 = substringMatchScore('testing the function', 'test'); + expect(result).toBeGreaterThan(result2); + }); + + it('should return 0 when there is no match', () => { + const result = substringMatchScore('abc', 'defghijk'); + expect(result).toBe(0); + }); + + it('should handle cases where the query is longer than the string', () => { + const result = substringMatchScore('short', 'longer substring'); + expect(result).toBe(0); + }); + + it('should handle empty strings correctly', () => { + const result = substringMatchScore('any string', ''); + expect(result).toBe(0); + }); + + it('should handle both strings being empty', () => { + const result = substringMatchScore('', ''); + expect(result).toBe(0); + }); + + it('should handle cases where both strings are identical', () => { + const result = substringMatchScore('identical', 'identical'); + expect(result).toBe(1); + }); +}); diff --git a/blocksuite/affine/shared/src/__tests__/utils/url.unit.spec.ts b/blocksuite/affine/shared/src/__tests__/utils/url.unit.spec.ts new file mode 100644 index 0000000000..6331b8bdfe --- /dev/null +++ b/blocksuite/affine/shared/src/__tests__/utils/url.unit.spec.ts @@ -0,0 +1,83 @@ +import { describe, expect, test } from 'vitest'; + +import { isValidUrl } from '../../utils/url.js'; + +describe('isValidUrl: determining whether a URL is valid is very complicated', () => { + test('basic case', () => { + expect(isValidUrl('')).toEqual(false); + expect(isValidUrl('1.co')).toEqual(true); + expect(isValidUrl('https://www.example.com')).toEqual(true); + expect(isValidUrl('www.example.com')).toEqual(true); + expect(isValidUrl('http://www.github.com/toeverything/blocksuite')).toEqual( + true + ); + }); + + test('CAUTION: any link include allowed schema is a valid url!', () => { + expect(isValidUrl('http://www.example.cm')).toEqual(true); + expect(isValidUrl('https://x ')).toEqual(true); + expect(isValidUrl('mailto://w:80')).toEqual(true); + }); + + test('link include a unknown schema is not a valid url', () => { + expect(isValidUrl('xxx://www.example.com')).toEqual(false); + expect(isValidUrl('https://')).toEqual(false); + expect(isValidUrl('http://w.... !@#*(!!!!')).toEqual(false); + }); + + test('URL without protocol is a valid URL', () => { + expect(isValidUrl('www.example.com')).toEqual(true); + expect(isValidUrl('example.co')).toEqual(true); + expect(isValidUrl('example.cm')).toEqual(true); + expect(isValidUrl('1.1.1.1')).toEqual(true); + + expect(isValidUrl('example.c')).toEqual(false); + }); + + test('special cases', () => { + expect(isValidUrl('example.com.')).toEqual(true); + + // I don't know why + // private & local networks is excluded + expect(isValidUrl('127.0.0.1')).toEqual(false); + expect(isValidUrl('10.0.0.1')).toEqual(false); + expect(isValidUrl('localhost')).toEqual(false); + expect(isValidUrl('0.0.0.0')).toEqual(false); + + expect(isValidUrl('128.0.0.1')).toEqual(true); + expect(isValidUrl('1.0.0.1')).toEqual(true); + }); + + test('email link is a valid URL', () => { + // See https://www.rapidtables.com/web/html/mailto.html + expect(isValidUrl('mailto:name@email.com')).toEqual(true); + expect( + isValidUrl( + 'mailto:name@rapidtables.com?subject=The%20subject%20of%20the%20mail' + ) + ).toEqual(true); + expect( + isValidUrl( + 'mailto:name1@rapidtables.com?cc=name2@rapidtables.com&bcc=name3@rapidtables.com&subject=The%20subject%20of%20the%20email&body=The%20body%20of%20the%20email' + ) + ).toEqual(true); + // multiple email recipients + expect(isValidUrl('mailto:name1@mail.com,name2@mail.com')).toEqual(true); + }); + + test('misc case', () => { + // Emoji domain + expect(isValidUrl('xn--i-7iq.ws')).toEqual(true); + expect( + isValidUrl('https://username:password@www.example.com:80/?q_a=1234567') + ).toEqual(true); + + expect(isValidUrl('新华网.cn')).toEqual(true); + expect(isValidUrl('example.com/中文/にほんご')).toEqual(true); + + // It's a valid url, but we don't want to support it + // Longest TLD up to date is `.xn--vermgensberatung-pwb`, at 24 characters in Punycode and 17 when decoded [vermögensberatung]. + // See also https://stackoverflow.com/questions/9238640/how-long-can-a-tld-possibly-be#:~:text=Longest%20TLD%20up%20to%20date,17%20when%20decoded%20%5Bverm%C3%B6gensberatung%5D. + expect(isValidUrl('example.xn--vermgensberatung-pwb')).toEqual(false); + }); +}); diff --git a/blocksuite/affine/shared/src/adapters/html-adapter/block-adapter.ts b/blocksuite/affine/shared/src/adapters/html-adapter/block-adapter.ts new file mode 100644 index 0000000000..090c2d5c63 --- /dev/null +++ b/blocksuite/affine/shared/src/adapters/html-adapter/block-adapter.ts @@ -0,0 +1,31 @@ +import type { ExtensionType } from '@blocksuite/block-std'; +import { + createIdentifier, + type ServiceIdentifier, +} from '@blocksuite/global/di'; + +import type { BlockAdapterMatcher } from '../types/adapter.js'; +import type { HtmlAST } from '../types/hast.js'; +import type { HtmlDeltaConverter } from './delta-converter.js'; + +export type BlockHtmlAdapterMatcher = BlockAdapterMatcher< + HtmlAST, + HtmlDeltaConverter +>; + +export const BlockHtmlAdapterMatcherIdentifier = + createIdentifier('BlockHtmlAdapterMatcher'); + +export function BlockHtmlAdapterExtension( + matcher: BlockHtmlAdapterMatcher +): ExtensionType & { + identifier: ServiceIdentifier; +} { + const identifier = BlockHtmlAdapterMatcherIdentifier(matcher.flavour); + return { + setup: di => { + di.addImpl(identifier, () => matcher); + }, + identifier, + }; +} diff --git a/blocksuite/affine/shared/src/adapters/html-adapter/delta-converter.ts b/blocksuite/affine/shared/src/adapters/html-adapter/delta-converter.ts new file mode 100644 index 0000000000..f4ee823208 --- /dev/null +++ b/blocksuite/affine/shared/src/adapters/html-adapter/delta-converter.ts @@ -0,0 +1,134 @@ +import type { ExtensionType } from '@blocksuite/block-std'; +import { + createIdentifier, + type ServiceIdentifier, +} from '@blocksuite/global/di'; +import type { DeltaInsert } from '@blocksuite/inline'; + +import type { AffineTextAttributes } from '../../types/index.js'; +import { + type ASTToDeltaMatcher, + DeltaASTConverter, + type DeltaASTConverterOptions, + type InlineDeltaMatcher, +} from '../types/adapter.js'; +import type { HtmlAST, InlineHtmlAST } from '../types/hast.js'; +import { TextUtils } from '../utils/text.js'; + +export type InlineDeltaToHtmlAdapterMatcher = InlineDeltaMatcher; + +export const InlineDeltaToHtmlAdapterMatcherIdentifier = + createIdentifier( + 'InlineDeltaToHtmlAdapterMatcher' + ); + +export function InlineDeltaToHtmlAdapterExtension( + matcher: InlineDeltaToHtmlAdapterMatcher +): ExtensionType & { + identifier: ServiceIdentifier; +} { + const identifier = InlineDeltaToHtmlAdapterMatcherIdentifier(matcher.name); + return { + setup: di => { + di.addImpl(identifier, () => matcher); + }, + identifier, + }; +} + +export type HtmlASTToDeltaMatcher = ASTToDeltaMatcher; + +export const HtmlASTToDeltaMatcherIdentifier = + createIdentifier('HtmlASTToDeltaMatcher'); + +export function HtmlASTToDeltaExtension( + matcher: HtmlASTToDeltaMatcher +): ExtensionType & { + identifier: ServiceIdentifier; +} { + const identifier = HtmlASTToDeltaMatcherIdentifier(matcher.name); + return { + setup: di => { + di.addImpl(identifier, () => matcher); + }, + identifier, + }; +} + +export class HtmlDeltaConverter extends DeltaASTConverter< + AffineTextAttributes, + HtmlAST +> { + constructor( + readonly configs: Map, + readonly inlineDeltaMatchers: InlineDeltaToHtmlAdapterMatcher[], + readonly htmlASTToDeltaMatchers: HtmlASTToDeltaMatcher[] + ) { + super(); + } + + private _applyTextFormatting( + delta: DeltaInsert + ): InlineHtmlAST { + let hast: InlineHtmlAST = { + type: 'text', + value: delta.insert, + }; + + const context: { + configs: Map; + current: InlineHtmlAST; + } = { + configs: this.configs, + current: hast, + }; + for (const matcher of this.inlineDeltaMatchers) { + if (matcher.match(delta)) { + hast = matcher.toAST(delta, context); + context.current = hast; + } + } + + return hast; + } + + private _spreadAstToDelta( + ast: HtmlAST, + options: DeltaASTConverterOptions = Object.create(null) + ): DeltaInsert[] { + const context = { + configs: this.configs, + options, + toDelta: (ast: HtmlAST, options?: DeltaASTConverterOptions) => + this._spreadAstToDelta(ast, options), + }; + for (const matcher of this.htmlASTToDeltaMatchers) { + if (matcher.match(ast)) { + return matcher.toDelta(ast, context); + } + } + return 'children' in ast + ? ast.children.flatMap(child => this._spreadAstToDelta(child, options)) + : []; + } + + astToDelta( + ast: HtmlAST, + options: DeltaASTConverterOptions = Object.create(null) + ): DeltaInsert[] { + return this._spreadAstToDelta(ast, options).reduce((acc, cur) => { + return TextUtils.mergeDeltas(acc, cur); + }, [] as DeltaInsert[]); + } + + deltaToAST( + deltas: DeltaInsert[], + depth = 0 + ): InlineHtmlAST[] { + if (depth > 0) { + deltas.unshift({ insert: ' '.repeat(4).repeat(depth) }); + } + + return deltas.map(delta => this._applyTextFormatting(delta)); + } +} diff --git a/blocksuite/affine/shared/src/adapters/html-adapter/index.ts b/blocksuite/affine/shared/src/adapters/html-adapter/index.ts new file mode 100644 index 0000000000..e45efaf7c7 --- /dev/null +++ b/blocksuite/affine/shared/src/adapters/html-adapter/index.ts @@ -0,0 +1,3 @@ +export * from './block-adapter.js'; +export * from './delta-converter.js'; +export * from './type.js'; diff --git a/blocksuite/affine/shared/src/adapters/html-adapter/type.ts b/blocksuite/affine/shared/src/adapters/html-adapter/type.ts new file mode 100644 index 0000000000..6388211f2b --- /dev/null +++ b/blocksuite/affine/shared/src/adapters/html-adapter/type.ts @@ -0,0 +1 @@ +export type Html = string; diff --git a/blocksuite/affine/shared/src/adapters/index.ts b/blocksuite/affine/shared/src/adapters/index.ts new file mode 100644 index 0000000000..fb261cd31a --- /dev/null +++ b/blocksuite/affine/shared/src/adapters/index.ts @@ -0,0 +1,53 @@ +export { + BlockHtmlAdapterExtension, + type BlockHtmlAdapterMatcher, + BlockHtmlAdapterMatcherIdentifier, + type Html, + type HtmlASTToDeltaMatcher, + HtmlASTToDeltaMatcherIdentifier, + HtmlDeltaConverter, + type InlineDeltaToHtmlAdapterMatcher, + InlineDeltaToHtmlAdapterMatcherIdentifier, +} from './html-adapter/index.js'; +export { + BlockMarkdownAdapterExtension, + type BlockMarkdownAdapterMatcher, + BlockMarkdownAdapterMatcherIdentifier, + type InlineDeltaToMarkdownAdapterMatcher, + InlineDeltaToMarkdownAdapterMatcherIdentifier, + isMarkdownAST, + type Markdown, + type MarkdownAST, + type MarkdownASTToDeltaMatcher, + MarkdownASTToDeltaMatcherIdentifier, + MarkdownDeltaConverter, +} from './markdown/index.js'; +export { + BlockNotionHtmlAdapterExtension, + type BlockNotionHtmlAdapterMatcher, + BlockNotionHtmlAdapterMatcherIdentifier, + type InlineDeltaToNotionHtmlAdapterMatcher, + type NotionHtml, + type NotionHtmlASTToDeltaMatcher, + NotionHtmlASTToDeltaMatcherIdentifier, + NotionHtmlDeltaConverter, +} from './notion-html/index.js'; +export { + BlockPlainTextAdapterExtension, + type BlockPlainTextAdapterMatcher, + BlockPlainTextAdapterMatcherIdentifier, + type InlineDeltaToPlainTextAdapterMatcher, + InlineDeltaToPlainTextAdapterMatcherIdentifier, + type PlainText, + PlainTextDeltaConverter, +} from './plain-text/index.js'; +export { + type AdapterContext, + type BlockAdapterMatcher, + DeltaASTConverter, + type HtmlAST, + type InlineHtmlAST, + isBlockSnapshotNode, + type TextBuffer, +} from './types/index.js'; +export * from './utils/index.js'; diff --git a/blocksuite/affine/shared/src/adapters/markdown/block-adapter.ts b/blocksuite/affine/shared/src/adapters/markdown/block-adapter.ts new file mode 100644 index 0000000000..b34cee48ce --- /dev/null +++ b/blocksuite/affine/shared/src/adapters/markdown/block-adapter.ts @@ -0,0 +1,31 @@ +import type { ExtensionType } from '@blocksuite/block-std'; +import { + createIdentifier, + type ServiceIdentifier, +} from '@blocksuite/global/di'; + +import type { BlockAdapterMatcher } from '../types/adapter.js'; +import type { MarkdownDeltaConverter } from './delta-converter.js'; +import type { MarkdownAST } from './type.js'; + +export type BlockMarkdownAdapterMatcher = BlockAdapterMatcher< + MarkdownAST, + MarkdownDeltaConverter +>; + +export const BlockMarkdownAdapterMatcherIdentifier = + createIdentifier('BlockMarkdownAdapterMatcher'); + +export function BlockMarkdownAdapterExtension( + matcher: BlockMarkdownAdapterMatcher +): ExtensionType & { + identifier: ServiceIdentifier; +} { + const identifier = BlockMarkdownAdapterMatcherIdentifier(matcher.flavour); + return { + setup: di => { + di.addImpl(identifier, () => matcher); + }, + identifier, + }; +} diff --git a/blocksuite/affine/shared/src/adapters/markdown/delta-converter.ts b/blocksuite/affine/shared/src/adapters/markdown/delta-converter.ts new file mode 100644 index 0000000000..e972903538 --- /dev/null +++ b/blocksuite/affine/shared/src/adapters/markdown/delta-converter.ts @@ -0,0 +1,125 @@ +import type { ExtensionType } from '@blocksuite/block-std'; +import { + createIdentifier, + type ServiceIdentifier, +} from '@blocksuite/global/di'; +import type { DeltaInsert } from '@blocksuite/inline/types'; +import type { PhrasingContent } from 'mdast'; + +import type { AffineTextAttributes } from '../../types/index.js'; +import { + type ASTToDeltaMatcher, + DeltaASTConverter, + type InlineDeltaMatcher, +} from '../types/adapter.js'; +import type { MarkdownAST } from './type.js'; + +export type InlineDeltaToMarkdownAdapterMatcher = + InlineDeltaMatcher; + +export const InlineDeltaToMarkdownAdapterMatcherIdentifier = + createIdentifier( + 'InlineDeltaToMarkdownAdapterMatcher' + ); + +export function InlineDeltaToMarkdownAdapterExtension( + matcher: InlineDeltaToMarkdownAdapterMatcher +): ExtensionType & { + identifier: ServiceIdentifier; +} { + const identifier = InlineDeltaToMarkdownAdapterMatcherIdentifier( + matcher.name + ); + return { + setup: di => { + di.addImpl(identifier, () => matcher); + }, + identifier, + }; +} + +export type MarkdownASTToDeltaMatcher = ASTToDeltaMatcher; + +export const MarkdownASTToDeltaMatcherIdentifier = + createIdentifier('MarkdownASTToDeltaMatcher'); + +export function MarkdownASTToDeltaExtension( + matcher: MarkdownASTToDeltaMatcher +): ExtensionType & { + identifier: ServiceIdentifier; +} { + const identifier = MarkdownASTToDeltaMatcherIdentifier(matcher.name); + return { + setup: di => { + di.addImpl(identifier, () => matcher); + }, + identifier, + }; +} + +export class MarkdownDeltaConverter extends DeltaASTConverter< + AffineTextAttributes, + MarkdownAST +> { + constructor( + readonly configs: Map, + readonly inlineDeltaMatchers: InlineDeltaToMarkdownAdapterMatcher[], + readonly markdownASTToDeltaMatchers: MarkdownASTToDeltaMatcher[] + ) { + super(); + } + + applyTextFormatting( + delta: DeltaInsert + ): PhrasingContent { + let mdast: PhrasingContent = { + type: 'text', + value: delta.attributes?.underline + ? `${delta.insert}` + : delta.insert, + }; + + const context: { + configs: Map; + current: PhrasingContent; + } = { + configs: this.configs, + current: mdast, + }; + for (const matcher of this.inlineDeltaMatchers) { + if (matcher.match(delta)) { + mdast = matcher.toAST(delta, context); + context.current = mdast; + } + } + + return mdast; + } + + astToDelta(ast: MarkdownAST): DeltaInsert[] { + const context = { + configs: this.configs, + options: Object.create(null), + toDelta: (ast: MarkdownAST) => this.astToDelta(ast), + }; + for (const matcher of this.markdownASTToDeltaMatchers) { + if (matcher.match(ast)) { + return matcher.toDelta(ast, context); + } + } + return 'children' in ast + ? ast.children.flatMap(child => this.astToDelta(child)) + : []; + } + + deltaToAST( + deltas: DeltaInsert[], + depth = 0 + ): PhrasingContent[] { + if (depth > 0) { + deltas.unshift({ insert: ' '.repeat(4).repeat(depth) }); + } + + return deltas.map(delta => this.applyTextFormatting(delta)); + } +} diff --git a/blocksuite/affine/shared/src/adapters/markdown/index.ts b/blocksuite/affine/shared/src/adapters/markdown/index.ts new file mode 100644 index 0000000000..e45efaf7c7 --- /dev/null +++ b/blocksuite/affine/shared/src/adapters/markdown/index.ts @@ -0,0 +1,3 @@ +export * from './block-adapter.js'; +export * from './delta-converter.js'; +export * from './type.js'; diff --git a/blocksuite/affine/shared/src/adapters/markdown/type.ts b/blocksuite/affine/shared/src/adapters/markdown/type.ts new file mode 100644 index 0000000000..f3200215a2 --- /dev/null +++ b/blocksuite/affine/shared/src/adapters/markdown/type.ts @@ -0,0 +1,17 @@ +import type { Root, RootContentMap } from 'mdast'; + +export type Markdown = string; + +type MdastUnionType< + K extends keyof RootContentMap, + V extends RootContentMap[K], +> = V; + +export type MarkdownAST = + | MdastUnionType + | Root; + +export const isMarkdownAST = (node: unknown): node is MarkdownAST => + !Array.isArray(node) && + 'type' in (node as object) && + (node as MarkdownAST).type !== undefined; diff --git a/blocksuite/affine/shared/src/adapters/notion-html/block-adapter.ts b/blocksuite/affine/shared/src/adapters/notion-html/block-adapter.ts new file mode 100644 index 0000000000..a11b06eb45 --- /dev/null +++ b/blocksuite/affine/shared/src/adapters/notion-html/block-adapter.ts @@ -0,0 +1,33 @@ +import type { ExtensionType } from '@blocksuite/block-std'; +import { + createIdentifier, + type ServiceIdentifier, +} from '@blocksuite/global/di'; + +import type { BlockAdapterMatcher } from '../types/adapter.js'; +import type { HtmlAST } from '../types/hast.js'; +import type { NotionHtmlDeltaConverter } from './delta-converter.js'; + +export type BlockNotionHtmlAdapterMatcher = BlockAdapterMatcher< + HtmlAST, + NotionHtmlDeltaConverter +>; + +export const BlockNotionHtmlAdapterMatcherIdentifier = + createIdentifier( + 'BlockNotionHtmlAdapterMatcher' + ); + +export function BlockNotionHtmlAdapterExtension( + matcher: BlockNotionHtmlAdapterMatcher +): ExtensionType & { + identifier: ServiceIdentifier; +} { + const identifier = BlockNotionHtmlAdapterMatcherIdentifier(matcher.flavour); + return { + setup: di => { + di.addImpl(identifier, () => matcher); + }, + identifier, + }; +} diff --git a/blocksuite/affine/shared/src/adapters/notion-html/delta-converter.ts b/blocksuite/affine/shared/src/adapters/notion-html/delta-converter.ts new file mode 100644 index 0000000000..490b53787d --- /dev/null +++ b/blocksuite/affine/shared/src/adapters/notion-html/delta-converter.ts @@ -0,0 +1,106 @@ +import type { ExtensionType } from '@blocksuite/block-std'; +import { + createIdentifier, + type ServiceIdentifier, +} from '@blocksuite/global/di'; +import { isEqual } from '@blocksuite/global/utils'; +import type { DeltaInsert } from '@blocksuite/inline'; + +import type { AffineTextAttributes } from '../../types/index.js'; +import { + type ASTToDeltaMatcher, + DeltaASTConverter, + type DeltaASTConverterOptions, + type InlineDeltaMatcher, +} from '../types/adapter.js'; +import type { HtmlAST, InlineHtmlAST } from '../types/hast.js'; + +export type InlineDeltaToNotionHtmlAdapterMatcher = + InlineDeltaMatcher; + +export type NotionHtmlASTToDeltaMatcher = ASTToDeltaMatcher; + +export const NotionHtmlASTToDeltaMatcherIdentifier = + createIdentifier('NotionHtmlASTToDeltaMatcher'); + +export function NotionHtmlASTToDeltaExtension( + matcher: NotionHtmlASTToDeltaMatcher +): ExtensionType & { + identifier: ServiceIdentifier; +} { + const identifier = NotionHtmlASTToDeltaMatcherIdentifier(matcher.name); + return { + setup: di => { + di.addImpl(identifier, () => matcher); + }, + identifier, + }; +} + +export class NotionHtmlDeltaConverter extends DeltaASTConverter< + AffineTextAttributes, + HtmlAST +> { + constructor( + readonly configs: Map, + readonly inlineDeltaMatchers: InlineDeltaToNotionHtmlAdapterMatcher[], + readonly htmlASTToDeltaMatchers: NotionHtmlASTToDeltaMatcher[] + ) { + super(); + } + + private _spreadAstToDelta( + ast: HtmlAST, + options: DeltaASTConverterOptions = Object.create(null) + ): DeltaInsert[] { + const context = { + configs: this.configs, + options, + toDelta: (ast: HtmlAST, options?: DeltaASTConverterOptions) => + this._spreadAstToDelta(ast, options), + }; + for (const matcher of this.htmlASTToDeltaMatchers) { + if (matcher.match(ast)) { + return matcher.toDelta(ast, context); + } + } + + const result = + 'children' in ast + ? ast.children.flatMap(child => this._spreadAstToDelta(child, options)) + : []; + + if (options.removeLastBr && result.length > 0) { + const lastItem = result[result.length - 1]; + if (lastItem.insert === '\n') { + result.pop(); + } + } + return result; + } + + astToDelta( + ast: HtmlAST, + options: DeltaASTConverterOptions = Object.create(null) + ): DeltaInsert[] { + return this._spreadAstToDelta(ast, options).reduce((acc, cur) => { + if (acc.length === 0) { + return [cur]; + } + const last = acc[acc.length - 1]; + if ( + typeof last.insert === 'string' && + typeof cur.insert === 'string' && + isEqual(last.attributes, cur.attributes) + ) { + last.insert += cur.insert; + return acc; + } + return [...acc, cur]; + }, [] as DeltaInsert[]); + } + + deltaToAST(_: DeltaInsert[]): InlineHtmlAST[] { + return []; + } +} diff --git a/blocksuite/affine/shared/src/adapters/notion-html/index.ts b/blocksuite/affine/shared/src/adapters/notion-html/index.ts new file mode 100644 index 0000000000..e45efaf7c7 --- /dev/null +++ b/blocksuite/affine/shared/src/adapters/notion-html/index.ts @@ -0,0 +1,3 @@ +export * from './block-adapter.js'; +export * from './delta-converter.js'; +export * from './type.js'; diff --git a/blocksuite/affine/shared/src/adapters/notion-html/type.ts b/blocksuite/affine/shared/src/adapters/notion-html/type.ts new file mode 100644 index 0000000000..c2d00590b0 --- /dev/null +++ b/blocksuite/affine/shared/src/adapters/notion-html/type.ts @@ -0,0 +1 @@ +export type NotionHtml = string; diff --git a/blocksuite/affine/shared/src/adapters/plain-text/block-adapter.ts b/blocksuite/affine/shared/src/adapters/plain-text/block-adapter.ts new file mode 100644 index 0000000000..8ea22ef8cd --- /dev/null +++ b/blocksuite/affine/shared/src/adapters/plain-text/block-adapter.ts @@ -0,0 +1,28 @@ +import type { ExtensionType } from '@blocksuite/block-std'; +import { + createIdentifier, + type ServiceIdentifier, +} from '@blocksuite/global/di'; + +import type { BlockAdapterMatcher, TextBuffer } from '../types/adapter.js'; + +export type BlockPlainTextAdapterMatcher = BlockAdapterMatcher; + +export const BlockPlainTextAdapterMatcherIdentifier = + createIdentifier( + 'BlockPlainTextAdapterMatcher' + ); + +export function BlockPlainTextAdapterExtension( + matcher: BlockPlainTextAdapterMatcher +): ExtensionType & { + identifier: ServiceIdentifier; +} { + const identifier = BlockPlainTextAdapterMatcherIdentifier(matcher.flavour); + return { + setup: di => { + di.addImpl(identifier, () => matcher); + }, + identifier, + }; +} diff --git a/blocksuite/affine/shared/src/adapters/plain-text/delta-converter.ts b/blocksuite/affine/shared/src/adapters/plain-text/delta-converter.ts new file mode 100644 index 0000000000..18be18a8d5 --- /dev/null +++ b/blocksuite/affine/shared/src/adapters/plain-text/delta-converter.ts @@ -0,0 +1,62 @@ +import { createIdentifier } from '@blocksuite/global/di'; +import type { DeltaInsert } from '@blocksuite/inline'; + +import type { AffineTextAttributes } from '../../types/index.js'; +import { + type ASTToDeltaMatcher, + DeltaASTConverter, + type InlineDeltaMatcher, + type TextBuffer, +} from '../types/adapter.js'; + +export type InlineDeltaToPlainTextAdapterMatcher = + InlineDeltaMatcher; + +export const InlineDeltaToPlainTextAdapterMatcherIdentifier = + createIdentifier( + 'InlineDeltaToPlainTextAdapterMatcher' + ); + +export type PlainTextASTToDeltaMatcher = ASTToDeltaMatcher; + +export class PlainTextDeltaConverter extends DeltaASTConverter< + AffineTextAttributes, + string +> { + constructor( + readonly configs: Map, + readonly inlineDeltaMatchers: InlineDeltaToPlainTextAdapterMatcher[], + readonly plainTextASTToDeltaMatchers: PlainTextASTToDeltaMatcher[] + ) { + super(); + } + + astToDelta(ast: string) { + const context = { + configs: this.configs, + options: Object.create(null), + toDelta: (ast: string) => this.astToDelta(ast), + }; + for (const matcher of this.plainTextASTToDeltaMatchers) { + if (matcher.match(ast)) { + return matcher.toDelta(ast, context); + } + } + return []; + } + + deltaToAST(deltas: DeltaInsert[]): string[] { + return deltas.map(delta => { + const context = { + configs: this.configs, + current: { content: delta.insert }, + }; + for (const matcher of this.inlineDeltaMatchers) { + if (matcher.match(delta)) { + context.current = matcher.toAST(delta, context); + } + } + return context.current.content; + }); + } +} diff --git a/blocksuite/affine/shared/src/adapters/plain-text/index.ts b/blocksuite/affine/shared/src/adapters/plain-text/index.ts new file mode 100644 index 0000000000..e45efaf7c7 --- /dev/null +++ b/blocksuite/affine/shared/src/adapters/plain-text/index.ts @@ -0,0 +1,3 @@ +export * from './block-adapter.js'; +export * from './delta-converter.js'; +export * from './type.js'; diff --git a/blocksuite/affine/shared/src/adapters/plain-text/type.ts b/blocksuite/affine/shared/src/adapters/plain-text/type.ts new file mode 100644 index 0000000000..d8cb42f07c --- /dev/null +++ b/blocksuite/affine/shared/src/adapters/plain-text/type.ts @@ -0,0 +1 @@ +export type PlainText = string; diff --git a/blocksuite/affine/shared/src/adapters/types/adapter.ts b/blocksuite/affine/shared/src/adapters/types/adapter.ts new file mode 100644 index 0000000000..aa26170b28 --- /dev/null +++ b/blocksuite/affine/shared/src/adapters/types/adapter.ts @@ -0,0 +1,170 @@ +import type { BaseTextAttributes, DeltaInsert } from '@blocksuite/inline'; +import { + type AssetsManager, + type ASTWalker, + type ASTWalkerContext, + type BlockSnapshot, + BlockSnapshotSchema, + type Job, + type NodeProps, +} from '@blocksuite/store'; + +import type { AffineTextAttributes } from '../../types/index.js'; + +export const isBlockSnapshotNode = (node: unknown): node is BlockSnapshot => + BlockSnapshotSchema.safeParse(node).success; + +export type TextBuffer = { + content: string; +}; + +export type DeltaASTConverterOptions = { + trim?: boolean; + pre?: boolean; + pageMap?: Map; + removeLastBr?: boolean; +}; + +export type AdapterContext< + ONode extends object, + TNode extends object = never, + TConverter extends DeltaASTConverter = DeltaASTConverter, +> = { + walker: ASTWalker; + walkerContext: ASTWalkerContext; + configs: Map; + job: Job; + deltaConverter: TConverter; + textBuffer: TextBuffer; + assets?: AssetsManager; + pageMap?: Map; + updateAssetIds?: (assetsId: string) => void; +}; + +/** + * Defines the interface for adapting between different blocks and target formats. + * Used to convert blocks between a source format (TNode) and BlockSnapshot format. + * + * @template TNode - The source/target node type to convert from/to + * @template TConverter - The converter used for handling delta format conversions + */ +export type BlockAdapterMatcher< + TNode extends object = never, + TConverter extends DeltaASTConverter = DeltaASTConverter, +> = { + /** The block flavour identifier */ + flavour: string; + + /** + * Function to check if a target node matches this adapter + * @param o - The target node properties to check + * @returns true if this adapter can handle the node + */ + toMatch: (o: NodeProps) => boolean; + + /** + * Function to check if a BlockSnapshot matches this adapter + * @param o - The BlockSnapshot properties to check + * @returns true if this adapter can handle the snapshot + */ + fromMatch: (o: NodeProps) => boolean; + + /** + * Handlers for converting from target format to BlockSnapshot + */ + toBlockSnapshot: { + /** + * Called when entering a target walker node during traversal + * @param o - The target node properties + * @param context - The adapter context + */ + enter?: ( + o: NodeProps, + context: AdapterContext + ) => void | Promise; + + /** + * Called when leaving a target walker node during traversal + * @param o - The target node properties + * @param context - The adapter context + */ + leave?: ( + o: NodeProps, + context: AdapterContext + ) => void | Promise; + }; + + /** + * Handlers for converting from BlockSnapshot to target format + */ + fromBlockSnapshot: { + /** + * Called when entering a BlockSnapshot walker node during traversal + * @param o - The BlockSnapshot properties + * @param context - The adapter context + */ + enter?: ( + o: NodeProps, + context: AdapterContext + ) => void | Promise; + + /** + * Called when leaving a BlockSnapshot walker node during traversal + * @param o - The BlockSnapshot properties + * @param context - The adapter context + */ + leave?: ( + o: NodeProps, + context: AdapterContext + ) => void | Promise; + }; +}; + +export abstract class DeltaASTConverter< + TextAttributes extends BaseTextAttributes = BaseTextAttributes, + AST = unknown, +> { + /** + * Convert AST format to delta format + */ + abstract astToDelta( + ast: AST, + options?: unknown + ): DeltaInsert[]; + + /** + * Convert delta format to AST format + */ + abstract deltaToAST( + deltas: DeltaInsert[], + options?: unknown + ): AST[]; +} + +export type InlineDeltaMatcher = { + name: keyof AffineTextAttributes | string; + match: (delta: DeltaInsert) => boolean; + toAST: ( + delta: DeltaInsert, + context: { + configs: Map; + current: TNode; + } + ) => TNode; +}; + +export type ASTToDeltaMatcher = { + name: string; + match: (ast: AST) => boolean; + toDelta: ( + ast: AST, + context: { + configs: Map; + options: DeltaASTConverterOptions; + toDelta: ( + ast: AST, + options?: DeltaASTConverterOptions + ) => DeltaInsert[]; + } + ) => DeltaInsert[]; +}; diff --git a/blocksuite/affine/shared/src/adapters/types/hast.ts b/blocksuite/affine/shared/src/adapters/types/hast.ts new file mode 100644 index 0000000000..553521a348 --- /dev/null +++ b/blocksuite/affine/shared/src/adapters/types/hast.ts @@ -0,0 +1,12 @@ +import type { Element, Root, RootContentMap, Text } from 'hast'; + +export type HastUnionType< + K extends keyof RootContentMap, + V extends RootContentMap[K], +> = V; + +export type HtmlAST = + | HastUnionType + | Root; + +export type InlineHtmlAST = Element | Text; diff --git a/blocksuite/affine/shared/src/adapters/types/index.ts b/blocksuite/affine/shared/src/adapters/types/index.ts new file mode 100644 index 0000000000..8d2beb1f23 --- /dev/null +++ b/blocksuite/affine/shared/src/adapters/types/index.ts @@ -0,0 +1,2 @@ +export * from './adapter.js'; +export * from './hast.js'; diff --git a/blocksuite/affine/shared/src/adapters/utils/fetch.ts b/blocksuite/affine/shared/src/adapters/utils/fetch.ts new file mode 100644 index 0000000000..fc98820bd1 --- /dev/null +++ b/blocksuite/affine/shared/src/adapters/utils/fetch.ts @@ -0,0 +1,37 @@ +const fetchImage = async (url: string, init?: RequestInit, proxy?: string) => { + try { + if (!proxy) { + return await fetch(url, init); + } + if (url.startsWith('blob:')) { + return await fetch(url, init); + } + if (url.startsWith('data:')) { + return await fetch(url, init); + } + if (url.startsWith(window.location.origin)) { + return await fetch(url, init); + } + return await fetch(proxy + '?url=' + encodeURIComponent(url), init) + .then(res => { + if (!res.ok) { + throw new Error('Network response was not ok'); + } + return res; + }) + .catch(() => fetch(url, init)); + } catch (error) { + console.warn('Error fetching image:', error); + return null; + } +}; + +const fetchable = (url: string) => + url.startsWith('http:') || + url.startsWith('https:') || + url.startsWith('data:'); + +export const FetchUtils = { + fetchImage, + fetchable, +}; diff --git a/blocksuite/affine/shared/src/adapters/utils/hast.ts b/blocksuite/affine/shared/src/adapters/utils/hast.ts new file mode 100644 index 0000000000..c34e7a6cfb --- /dev/null +++ b/blocksuite/affine/shared/src/adapters/utils/hast.ts @@ -0,0 +1,266 @@ +import type { Element, ElementContent, Text } from 'hast'; + +import type { HtmlAST } from '../types/hast.js'; + +const isElement = (ast: HtmlAST): ast is Element => { + return ast.type === 'element'; +}; + +const getTextContent = (ast: HtmlAST | undefined, defaultStr = ''): string => { + if (!ast) { + return defaultStr; + } + switch (ast.type) { + case 'text': { + return ast.value.replace(/\s+/g, ' '); + } + case 'element': { + switch (ast.tagName) { + case 'br': { + return '\n'; + } + } + return ast.children.map(child => getTextContent(child)).join(''); + } + } + return defaultStr; +}; + +const getElementChildren = (ast: HtmlAST | undefined): Element[] => { + if (!ast) { + return []; + } + if (ast.type === 'element') { + return ast.children.filter(child => child.type === 'element') as Element[]; + } + return []; +}; + +const getTextChildren = (ast: HtmlAST | undefined): Text[] => { + if (!ast) { + return []; + } + if (ast.type === 'element') { + return ast.children.filter(child => child.type === 'text') as Text[]; + } + return []; +}; + +const getTextChildrenOnlyAst = (ast: Element): Element => { + return { + ...ast, + children: getTextChildren(ast), + }; +}; + +const isTagInline = (tagName: string): boolean => { + // Phrasing content + const inlineElements = [ + 'a', + 'abbr', + 'audio', + 'b', + 'bdi', + 'bdo', + 'br', + 'button', + 'canvas', + 'cite', + 'code', + 'data', + 'datalist', + 'del', + 'dfn', + 'em', + 'embed', + 'i', + // 'iframe' is not included because it needs special handling + // 'img' is not included because it needs special handling + 'input', + 'ins', + 'kbd', + 'label', + 'link', + 'map', + 'mark', + 'math', + 'meta', + 'meter', + 'noscript', + 'object', + 'output', + 'picture', + 'progress', + 'q', + 'ruby', + 's', + 'samp', + 'script', + 'select', + 'slot', + 'small', + 'span', + 'strong', + 'sub', + 'sup', + 'svg', + 'template', + 'textarea', + 'time', + 'u', + 'var', + 'video', + 'wbr', + ]; + return inlineElements.includes(tagName); +}; + +const isElementInline = (element: Element): boolean => { + return ( + isTagInline(element.tagName) || + // Inline elements + !!( + typeof element.properties?.style === 'string' && + element.properties.style.match(/display:\s*inline/) + ) + ); +}; + +const getInlineElementsAndText = (ast: Element): (Element | Text)[] => { + if (!ast || !ast.children) { + return []; + } + + return ast.children.filter((child): child is Element | Text => { + if (child.type === 'text') { + return true; + } + if (child.type === 'element' && child.tagName && isElementInline(child)) { + return true; + } + return false; + }); +}; + +const getInlineOnlyElementAST = (ast: Element): Element => { + return { + ...ast, + children: getInlineElementsAndText(ast), + }; +}; + +const querySelectorTag = ( + ast: HtmlAST, + tagName: string +): Element | undefined => { + if (ast.type === 'element') { + if (ast.tagName === tagName) { + return ast; + } + for (const child of ast.children) { + const result = querySelectorTag(child, tagName); + if (result) { + return result; + } + } + } + return undefined; +}; + +const querySelectorClass = ( + ast: HtmlAST, + className: string +): Element | undefined => { + if (ast.type === 'element') { + if ( + Array.isArray(ast.properties?.className) && + ast.properties.className.includes(className) + ) { + return ast; + } + for (const child of ast.children) { + const result = querySelectorClass(child, className); + if (result) { + return result; + } + } + } + return undefined; +}; + +const querySelectorId = (ast: HtmlAST, id: string): Element | undefined => { + if (ast.type === 'element') { + if (ast.properties.id === id) { + return ast; + } + for (const child of ast.children) { + const result = querySelectorId(child, id); + if (result) { + return result; + } + } + } + return undefined; +}; + +const querySelector = (ast: HtmlAST, selector: string): Element | undefined => { + if (ast.type === 'root') { + for (const child of ast.children) { + const result = querySelector(child, selector); + if (result) { + return result; + } + } + } else if (ast.type === 'element') { + if (selector.startsWith('.')) { + return querySelectorClass(ast, selector.slice(1)); + } else if (selector.startsWith('#')) { + return querySelectorId(ast, selector.slice(1)); + } else { + return querySelectorTag(ast, selector); + } + } + return undefined; +}; + +const flatNodes = ( + ast: HtmlAST, + expression: (tagName: string) => boolean +): HtmlAST => { + if (ast.type === 'element') { + const children = ast.children.map(child => flatNodes(child, expression)); + return { + ...ast, + children: children.flatMap(child => { + if (child.type === 'element' && expression(child.tagName)) { + return child.children; + } + return child; + }) as ElementContent[], + }; + } + return ast; +}; + +// Check if it is a paragraph like element +// https://html.spec.whatwg.org/#paragraph +const isParagraphLike = (node: Element): boolean => { + // Flex container + return ( + (typeof node.properties?.style === 'string' && + node.properties.style.match(/display:\s*flex/) !== null) || + getElementChildren(node).every(child => isElementInline(child)) + ); +}; + +export const HastUtils = { + isElement, + getTextContent, + getElementChildren, + getTextChildren, + getTextChildrenOnlyAst, + getInlineOnlyElementAST, + querySelector, + flatNodes, + isParagraphLike, +}; diff --git a/blocksuite/affine/shared/src/adapters/utils/index.ts b/blocksuite/affine/shared/src/adapters/utils/index.ts new file mode 100644 index 0000000000..fba7ec3562 --- /dev/null +++ b/blocksuite/affine/shared/src/adapters/utils/index.ts @@ -0,0 +1,3 @@ +export * from './fetch.js'; +export * from './hast.js'; +export * from './text.js'; diff --git a/blocksuite/affine/shared/src/adapters/utils/text.ts b/blocksuite/affine/shared/src/adapters/utils/text.ts new file mode 100644 index 0000000000..f642f4098a --- /dev/null +++ b/blocksuite/affine/shared/src/adapters/utils/text.ts @@ -0,0 +1,83 @@ +import { isEqual } from '@blocksuite/global/utils'; +import type { DeltaInsert } from '@blocksuite/inline'; + +const mergeDeltas = ( + acc: DeltaInsert[], + cur: DeltaInsert, + options: { force?: boolean } = { force: false } +) => { + if (acc.length === 0) { + return [cur]; + } + const last = acc[acc.length - 1]; + if (options?.force) { + last.insert = last.insert + cur.insert; + last.attributes = Object.create(null); + return acc; + } else if ( + typeof last.insert === 'string' && + typeof cur.insert === 'string' && + (isEqual(last.attributes, cur.attributes) || + (last.attributes === undefined && cur.attributes === undefined)) + ) { + last.insert += cur.insert; + return acc; + } + return [...acc, cur]; +}; + +const isNullish = (value: unknown) => value === null || value === undefined; + +const createText = (s: string) => { + return { + '$blocksuite:internal:text$': true, + delta: s.length === 0 ? [] : [{ insert: s }], + }; +}; + +const isText = (o: unknown) => { + if ( + typeof o === 'object' && + o !== null && + '$blocksuite:internal:text$' in o + ) { + return o['$blocksuite:internal:text$'] === true; + } + return false; +}; + +function toURLSearchParams( + params?: Partial> +) { + if (!params) return; + + const items = Object.entries(params) + .filter(([_, v]) => !isNullish(v)) + .filter(([_, v]) => { + if (typeof v === 'string') { + return v.length > 0; + } + if (Array.isArray(v)) { + return v.length > 0; + } + return false; + }) + .map(([k, v]) => [k, Array.isArray(v) ? v.filter(v => v.length) : v]) as [ + string, + string | string[], + ][]; + + return new URLSearchParams( + items + .filter(([_, v]) => v.length) + .map(([k, v]) => [k, Array.isArray(v) ? v.join(',') : v]) + ); +} + +export const TextUtils = { + mergeDeltas, + isNullish, + createText, + isText, + toURLSearchParams, +}; diff --git a/blocksuite/affine/shared/src/commands/README.md b/blocksuite/affine/shared/src/commands/README.md new file mode 100644 index 0000000000..9512389635 --- /dev/null +++ b/blocksuite/affine/shared/src/commands/README.md @@ -0,0 +1,4 @@ +# @blocksuite/affine-shared/commands + +This package contains the common commands used in the affine blocks. +Keep in mind that you should not put commands that are specific to a single kind of block here. diff --git a/blocksuite/affine/shared/src/commands/block-crud/get-block-index.ts b/blocksuite/affine/shared/src/commands/block-crud/get-block-index.ts new file mode 100644 index 0000000000..45e961405c --- /dev/null +++ b/blocksuite/affine/shared/src/commands/block-crud/get-block-index.ts @@ -0,0 +1,44 @@ +import type { BlockComponent, Command } from '@blocksuite/block-std'; +import { assertExists } from '@blocksuite/global/utils'; + +export const getBlockIndexCommand: Command< + 'currentSelectionPath', + 'blockIndex' | 'parentBlock', + { + path?: string; + } +> = (ctx, next) => { + const path = ctx.path ?? ctx.currentSelectionPath; + assertExists( + path, + '`path` is required, you need to pass it in args or ctx before adding this command to the pipeline.' + ); + + const parentModel = ctx.std.doc.getParent(path); + if (!parentModel) return; + + const parent = ctx.std.view.getBlock(parentModel.id); + if (!parent) return; + + const index = parent.childBlocks.findIndex(x => { + return x.blockId === path; + }); + + next({ + blockIndex: index, + parentBlock: parent as BlockComponent, + }); +}; + +declare global { + namespace BlockSuite { + interface CommandContext { + blockIndex?: number; + parentBlock?: BlockComponent; + } + + interface Commands { + getBlockIndex: typeof getBlockIndexCommand; + } + } +} diff --git a/blocksuite/affine/shared/src/commands/block-crud/get-next-block.ts b/blocksuite/affine/shared/src/commands/block-crud/get-next-block.ts new file mode 100644 index 0000000000..9028627254 --- /dev/null +++ b/blocksuite/affine/shared/src/commands/block-crud/get-next-block.ts @@ -0,0 +1,45 @@ +import type { BlockComponent, Command } from '@blocksuite/block-std'; +import { assertExists } from '@blocksuite/global/utils'; + +import { getNextContentBlock } from '../../utils/index.js'; + +function getNextBlock(std: BlockSuite.Std, path: string) { + const view = std.view; + const model = std.doc.getBlock(path)?.model; + if (!model) return null; + const nextModel = getNextContentBlock(std.host, model); + if (!nextModel) return null; + return view.getBlock(nextModel.id); +} + +export const getNextBlockCommand: Command< + 'currentSelectionPath', + 'nextBlock', + { + path?: string; + } +> = (ctx, next) => { + const path = ctx.path ?? ctx.currentSelectionPath; + assertExists( + path, + '`path` is required, you need to pass it in args or ctx before adding this command to the pipeline.' + ); + + const nextBlock = getNextBlock(ctx.std, path); + + if (nextBlock) { + next({ nextBlock }); + } +}; + +declare global { + namespace BlockSuite { + interface CommandContext { + nextBlock?: BlockComponent; + } + + interface Commands { + getNextBlock: typeof getNextBlockCommand; + } + } +} diff --git a/blocksuite/affine/shared/src/commands/block-crud/get-prev-block.ts b/blocksuite/affine/shared/src/commands/block-crud/get-prev-block.ts new file mode 100644 index 0000000000..206d659e2e --- /dev/null +++ b/blocksuite/affine/shared/src/commands/block-crud/get-prev-block.ts @@ -0,0 +1,46 @@ +import type { BlockComponent, Command } from '@blocksuite/block-std'; +import { assertExists } from '@blocksuite/global/utils'; + +import { getPrevContentBlock } from '../../utils/index.js'; + +function getPrevBlock(std: BlockSuite.Std, path: string) { + const view = std.view; + + const model = std.doc.getBlock(path)?.model; + if (!model) return null; + const prevModel = getPrevContentBlock(std.host, model); + if (!prevModel) return null; + return view.getBlock(prevModel.id); +} + +export const getPrevBlockCommand: Command< + 'currentSelectionPath', + 'prevBlock', + { + path?: string; + } +> = (ctx, next) => { + const path = ctx.path ?? ctx.currentSelectionPath; + assertExists( + path, + '`path` is required, you need to pass it in args or ctx before adding this command to the pipeline.' + ); + + const prevBlock = getPrevBlock(ctx.std, path); + + if (prevBlock) { + next({ prevBlock }); + } +}; + +declare global { + namespace BlockSuite { + interface CommandContext { + prevBlock?: BlockComponent; + } + + interface Commands { + getPrevBlock: typeof getPrevBlockCommand; + } + } +} diff --git a/blocksuite/affine/shared/src/commands/block-crud/get-selected-blocks.ts b/blocksuite/affine/shared/src/commands/block-crud/get-selected-blocks.ts new file mode 100644 index 0000000000..4522223fd0 --- /dev/null +++ b/blocksuite/affine/shared/src/commands/block-crud/get-selected-blocks.ts @@ -0,0 +1,160 @@ +import type { + BlockSelection, + Command, + TextSelection, +} from '@blocksuite/block-std'; +import { BlockComponent } from '@blocksuite/block-std'; +import type { RoleType } from '@blocksuite/store'; + +import type { ImageSelection } from '../../selection/index.js'; + +export const getSelectedBlocksCommand: Command< + 'currentTextSelection' | 'currentBlockSelections' | 'currentImageSelections', + 'selectedBlocks', + { + textSelection?: TextSelection; + blockSelections?: BlockSelection[]; + imageSelections?: ImageSelection[]; + filter?: (el: BlockComponent) => boolean; + types?: Extract[]; + roles?: RoleType[]; + mode?: 'all' | 'flat' | 'highest'; + } +> = (ctx, next) => { + const { + types = ['block', 'text', 'image'], + roles = ['content'], + mode = 'flat', + } = ctx; + + let dirtyResult: BlockComponent[] = []; + + const textSelection = ctx.textSelection ?? ctx.currentTextSelection; + if (types.includes('text') && textSelection) { + try { + const range = ctx.std.range.textSelectionToRange(textSelection); + if (!range) return; + + const selectedBlocks = ctx.std.range.getSelectedBlockComponentsByRange( + range, + { + match: (el: BlockComponent) => roles.includes(el.model.role), + mode, + } + ); + dirtyResult.push(...selectedBlocks); + } catch { + return; + } + } + + const blockSelections = ctx.blockSelections ?? ctx.currentBlockSelections; + if (types.includes('block') && blockSelections) { + const viewStore = ctx.std.view; + const doc = ctx.std.doc; + const selectedBlockComponents = blockSelections.flatMap(selection => { + const el = viewStore.getBlock(selection.blockId); + if (!el) { + return []; + } + const blocks: BlockComponent[] = [el]; + let selectionPath = selection.blockId; + if (mode === 'all') { + let parent = null; + do { + parent = doc.getParent(selectionPath); + if (!parent) { + break; + } + const view = parent; + if ( + view instanceof BlockComponent && + !roles.includes(view.model.role) + ) { + break; + } + selectionPath = parent.id; + } while (parent); + parent = viewStore.getBlock(selectionPath); + if (parent) { + blocks.push(parent); + } + } + if (['all', 'flat'].includes(mode)) { + viewStore.walkThrough(node => { + const view = node; + if (!(view instanceof BlockComponent)) { + return true; + } + if (roles.includes(view.model.role)) { + blocks.push(view); + } + return; + }, selectionPath); + } + return blocks; + }); + dirtyResult.push(...selectedBlockComponents); + } + + const imageSelections = ctx.imageSelections ?? ctx.currentImageSelections; + if (types.includes('image') && imageSelections) { + const viewStore = ctx.std.view; + const selectedBlocks = imageSelections + .map(selection => { + const el = viewStore.getBlock(selection.blockId); + return el; + }) + .filter((el): el is BlockComponent => Boolean(el)); + dirtyResult.push(...selectedBlocks); + } + + if (ctx.filter) { + dirtyResult = dirtyResult.filter(ctx.filter); + } + + // remove duplicate elements + const result: BlockComponent[] = dirtyResult + .filter((el, index) => dirtyResult.indexOf(el) === index) + // sort by document position + .sort((a, b) => { + if (a === b) { + return 0; + } + + const position = a.compareDocumentPosition(b); + if ( + position & Node.DOCUMENT_POSITION_FOLLOWING || + position & Node.DOCUMENT_POSITION_CONTAINED_BY + ) { + return -1; + } + + if ( + position & Node.DOCUMENT_POSITION_PRECEDING || + position & Node.DOCUMENT_POSITION_CONTAINS + ) { + return 1; + } + + return 0; + }); + + if (result.length === 0) return; + + next({ + selectedBlocks: result, + }); +}; + +declare global { + namespace BlockSuite { + interface CommandContext { + selectedBlocks?: BlockComponent[]; + } + + interface Commands { + getSelectedBlocks: typeof getSelectedBlocksCommand; + } + } +} diff --git a/blocksuite/affine/shared/src/commands/block-crud/index.ts b/blocksuite/affine/shared/src/commands/block-crud/index.ts new file mode 100644 index 0000000000..73c7b2a5cd --- /dev/null +++ b/blocksuite/affine/shared/src/commands/block-crud/index.ts @@ -0,0 +1,4 @@ +export { getBlockIndexCommand } from './get-block-index.js'; +export { getNextBlockCommand } from './get-next-block.js'; +export { getPrevBlockCommand } from './get-prev-block.js'; +export { getSelectedBlocksCommand } from './get-selected-blocks.js'; diff --git a/blocksuite/affine/shared/src/commands/index.d.ts b/blocksuite/affine/shared/src/commands/index.d.ts new file mode 100644 index 0000000000..c5cf0ab2af --- /dev/null +++ b/blocksuite/affine/shared/src/commands/index.d.ts @@ -0,0 +1,5 @@ +// This is a dev-only file to make other packages use commands from this package. + +export type * from './block-crud/index.js'; +export type * from './model-crud/index.js'; +export type * from './selection/index.js'; diff --git a/blocksuite/affine/shared/src/commands/index.ts b/blocksuite/affine/shared/src/commands/index.ts new file mode 100644 index 0000000000..a3dd37fa0f --- /dev/null +++ b/blocksuite/affine/shared/src/commands/index.ts @@ -0,0 +1,32 @@ +export { + getBlockIndexCommand, + getNextBlockCommand, + getPrevBlockCommand, + getSelectedBlocksCommand, +} from './block-crud/index.js'; +export { + clearAndSelectFirstModelCommand, + copySelectedModelsCommand, + deleteSelectedModelsCommand, + draftSelectedModelsCommand, + duplicateSelectedModelsCommand, + getSelectedModelsCommand, + retainFirstModelCommand, +} from './model-crud/index.js'; +export { + getBlockSelectionsCommand, + getImageSelectionsCommand, + getSelectionRectsCommand, + getTextSelectionCommand, + type SelectionRect, +} from './selection/index.js'; + +declare global { + namespace BlockSuite { + // if we use `with` or `inline` to add command data either then use a command we + // need to update this interface + interface CommandContext { + currentSelectionPath?: string; + } + } +} diff --git a/blocksuite/affine/shared/src/commands/model-crud/clear-and-select-first-model.ts b/blocksuite/affine/shared/src/commands/model-crud/clear-and-select-first-model.ts new file mode 100644 index 0000000000..317c8678c8 --- /dev/null +++ b/blocksuite/affine/shared/src/commands/model-crud/clear-and-select-first-model.ts @@ -0,0 +1,41 @@ +import type { Command } from '@blocksuite/block-std'; + +export const clearAndSelectFirstModelCommand: Command<'selectedModels'> = ( + ctx, + next +) => { + const models = ctx.selectedModels; + + if (!models) { + console.error( + '`selectedModels` is required, you need to use `getSelectedModels` command before adding this command to the pipeline.' + ); + return; + } + + if (models.length > 0) { + const firstModel = models[0]; + if (firstModel.text) { + firstModel.text.clear(); + const selection = ctx.std.selection.create('text', { + from: { + blockId: firstModel.id, + index: 0, + length: 0, + }, + to: null, + }); + ctx.std.selection.setGroup('note', [selection]); + } + } + + return next(); +}; + +declare global { + namespace BlockSuite { + interface Commands { + clearAndSelectFirstModel: typeof clearAndSelectFirstModelCommand; + } + } +} diff --git a/blocksuite/affine/shared/src/commands/model-crud/copy-selected-models.ts b/blocksuite/affine/shared/src/commands/model-crud/copy-selected-models.ts new file mode 100644 index 0000000000..aa334488d0 --- /dev/null +++ b/blocksuite/affine/shared/src/commands/model-crud/copy-selected-models.ts @@ -0,0 +1,36 @@ +import type { Command } from '@blocksuite/block-std'; +import { Slice } from '@blocksuite/store'; + +export const copySelectedModelsCommand: Command<'draftedModels' | 'onCopy'> = ( + ctx, + next +) => { + const models = ctx.draftedModels; + if (!models) { + console.error( + '`draftedModels` is required, you need to use `draftSelectedModels` command before adding this command to the pipeline.' + ); + return; + } + + models + .then(models => { + const slice = Slice.fromModels(ctx.std.doc, models); + + return ctx.std.clipboard.copy(slice); + }) + .then(() => ctx.onCopy?.()) + .catch(console.error); + return next(); +}; + +declare global { + namespace BlockSuite { + interface CommandContext { + onCopy?: () => void; + } + interface Commands { + copySelectedModels: typeof copySelectedModelsCommand; + } + } +} diff --git a/blocksuite/affine/shared/src/commands/model-crud/delete-selected-models.ts b/blocksuite/affine/shared/src/commands/model-crud/delete-selected-models.ts new file mode 100644 index 0000000000..a085ef5fa4 --- /dev/null +++ b/blocksuite/affine/shared/src/commands/model-crud/delete-selected-models.ts @@ -0,0 +1,29 @@ +import type { Command } from '@blocksuite/block-std'; + +export const deleteSelectedModelsCommand: Command<'selectedModels'> = ( + ctx, + next +) => { + const models = ctx.selectedModels; + + if (!models) { + console.error( + '`selectedModels` is required, you need to use `getSelectedModels` command before adding this command to the pipeline.' + ); + return; + } + + models.forEach(model => { + ctx.std.doc.deleteBlock(model); + }); + + return next(); +}; + +declare global { + namespace BlockSuite { + interface Commands { + deleteSelectedModels: typeof deleteSelectedModelsCommand; + } + } +} diff --git a/blocksuite/affine/shared/src/commands/model-crud/draft-selected-models.ts b/blocksuite/affine/shared/src/commands/model-crud/draft-selected-models.ts new file mode 100644 index 0000000000..fcf603bcb6 --- /dev/null +++ b/blocksuite/affine/shared/src/commands/model-crud/draft-selected-models.ts @@ -0,0 +1,58 @@ +import type { Command } from '@blocksuite/block-std'; +import { + type BlockModel, + type DraftModel, + toDraftModel, +} from '@blocksuite/store'; + +export const draftSelectedModelsCommand: Command< + 'selectedModels', + 'draftedModels' +> = (ctx, next) => { + const models = ctx.selectedModels; + if (!models) { + console.error( + '`selectedModels` is required, you need to use `getSelectedModels` command before adding this command to the pipeline.' + ); + return; + } + + const draftedModelsPromise = new Promise(resolve => { + const draftedModels = models.map(toDraftModel); + + const modelMap = new Map(draftedModels.map(model => [model.id, model])); + + const traverse = (model: DraftModel) => { + const isDatabase = model.flavour === 'affine:database'; + const children = isDatabase + ? model.children + : model.children.filter(child => modelMap.has(child.id)); + + children.forEach(child => { + modelMap.delete(child.id); + traverse(child); + }); + model.children = children; + }; + + draftedModels.forEach(traverse); + + const remainingDraftedModels = Array.from(modelMap.values()); + + resolve(remainingDraftedModels); + }); + + return next({ draftedModels: draftedModelsPromise }); +}; + +declare global { + namespace BlockSuite { + interface CommandContext { + draftedModels?: Promise>[]>; + } + + interface Commands { + draftSelectedModels: typeof draftSelectedModelsCommand; + } + } +} diff --git a/blocksuite/affine/shared/src/commands/model-crud/duplicate-selected-model.ts b/blocksuite/affine/shared/src/commands/model-crud/duplicate-selected-model.ts new file mode 100644 index 0000000000..ec7d719862 --- /dev/null +++ b/blocksuite/affine/shared/src/commands/model-crud/duplicate-selected-model.ts @@ -0,0 +1,38 @@ +import type { Command } from '@blocksuite/block-std'; +import { Slice } from '@blocksuite/store'; + +export const duplicateSelectedModelsCommand: Command< + 'draftedModels' | 'selectedModels' +> = (ctx, next) => { + const { std, draftedModels, selectedModels } = ctx; + if (!draftedModels || !selectedModels) return; + + const model = selectedModels[selectedModels.length - 1]; + + const parentModel = std.doc.getParent(model.id); + if (!parentModel) return; + + const index = parentModel.children.findIndex(x => x.id === model.id); + + draftedModels + .then(models => { + const slice = Slice.fromModels(std.doc, models); + return std.clipboard.duplicateSlice( + slice, + std.doc, + parentModel.id, + index + 1 + ); + }) + .catch(console.error); + + return next(); +}; + +declare global { + namespace BlockSuite { + interface Commands { + duplicateSelectedModels: typeof duplicateSelectedModelsCommand; + } + } +} diff --git a/blocksuite/affine/shared/src/commands/model-crud/get-selected-models.ts b/blocksuite/affine/shared/src/commands/model-crud/get-selected-models.ts new file mode 100644 index 0000000000..c7bb5c068a --- /dev/null +++ b/blocksuite/affine/shared/src/commands/model-crud/get-selected-models.ts @@ -0,0 +1,72 @@ +import type { Command } from '@blocksuite/block-std'; +import type { BlockModel } from '@blocksuite/store'; + +/** + * Retrieves the selected models based on the provided selection types and mode. + * + * @param ctx - The command context, which includes the types of selections to be retrieved and the mode of the selection. + * @param ctx.types - The selection types to be retrieved. Can be an array of 'block', 'text', or 'image'. + * @param ctx.mode - The mode of the selection. Can be 'all', 'flat', or 'highest'. + * @example + * // Assuming `commandContext` is an instance of the command context + * getSelectedModelsCommand(commandContext, (result) => { + * console.log(result.selectedModels); + * }); + * + * // Example selection: + * // aaa + * // b[bb + * // ccc + * // ddd + * // ee]e + * + * // all mode: [aaa, bbb, ccc, ddd, eee] + * // flat mode: [bbb, ccc, ddd, eee] + * // highest mode: [bbb, ddd] + * + * // The match function will be evaluated before filtering using mode + * @param next - The next function to be called. + * @returns An object containing the selected models as an array of BlockModel instances. + */ +export const getSelectedModelsCommand: Command< + never, + 'selectedModels', + { + types?: Extract[]; + mode?: 'all' | 'flat' | 'highest'; + } +> = (ctx, next) => { + const types = ctx.types ?? ['block', 'text', 'image']; + const mode = ctx.mode ?? 'flat'; + const selectedModels: BlockModel[] = []; + ctx.std.command + .chain() + .tryAll(chain => [ + chain.getTextSelection(), + chain.getBlockSelections(), + chain.getImageSelections(), + ]) + .getSelectedBlocks({ + types, + mode, + }) + .inline(ctx => { + const { selectedBlocks = [] } = ctx; + selectedModels.push(...selectedBlocks.map(el => el.model)); + }) + .run(); + + next({ selectedModels }); +}; + +declare global { + namespace BlockSuite { + interface CommandContext { + selectedModels?: BlockModel[]; + } + + interface Commands { + getSelectedModels: typeof getSelectedModelsCommand; + } + } +} diff --git a/blocksuite/affine/shared/src/commands/model-crud/index.ts b/blocksuite/affine/shared/src/commands/model-crud/index.ts new file mode 100644 index 0000000000..85fe888958 --- /dev/null +++ b/blocksuite/affine/shared/src/commands/model-crud/index.ts @@ -0,0 +1,7 @@ +export { clearAndSelectFirstModelCommand } from './clear-and-select-first-model.js'; +export { copySelectedModelsCommand } from './copy-selected-models.js'; +export { deleteSelectedModelsCommand } from './delete-selected-models.js'; +export { draftSelectedModelsCommand } from './draft-selected-models.js'; +export { duplicateSelectedModelsCommand } from './duplicate-selected-model.js'; +export { getSelectedModelsCommand } from './get-selected-models.js'; +export { retainFirstModelCommand } from './retain-first-model.js'; diff --git a/blocksuite/affine/shared/src/commands/model-crud/retain-first-model.ts b/blocksuite/affine/shared/src/commands/model-crud/retain-first-model.ts new file mode 100644 index 0000000000..12571d88b0 --- /dev/null +++ b/blocksuite/affine/shared/src/commands/model-crud/retain-first-model.ts @@ -0,0 +1,27 @@ +import type { Command } from '@blocksuite/block-std'; + +export const retainFirstModelCommand: Command<'selectedModels'> = ( + ctx, + next +) => { + if (!ctx.selectedModels) { + console.error( + '`selectedModels` is required, you need to use `getSelectedModels` command before adding this command to the pipeline.' + ); + return; + } + + if (ctx.selectedModels.length > 0) { + ctx.selectedModels.shift(); + } + + return next(); +}; + +declare global { + namespace BlockSuite { + interface Commands { + retainFirstModel: typeof retainFirstModelCommand; + } + } +} diff --git a/blocksuite/affine/shared/src/commands/selection/get-block-selections.ts b/blocksuite/affine/shared/src/commands/selection/get-block-selections.ts new file mode 100644 index 0000000000..63d7ebdcb7 --- /dev/null +++ b/blocksuite/affine/shared/src/commands/selection/get-block-selections.ts @@ -0,0 +1,23 @@ +import type { BlockSelection, Command } from '@blocksuite/block-std'; + +export const getBlockSelectionsCommand: Command< + never, + 'currentBlockSelections' +> = (ctx, next) => { + const currentBlockSelections = ctx.std.selection.filter('block'); + if (currentBlockSelections.length === 0) return; + + next({ currentBlockSelections }); +}; + +declare global { + namespace BlockSuite { + interface CommandContext { + currentBlockSelections?: BlockSelection[]; + } + + interface Commands { + getBlockSelections: typeof getBlockSelectionsCommand; + } + } +} diff --git a/blocksuite/affine/shared/src/commands/selection/get-image-selections.ts b/blocksuite/affine/shared/src/commands/selection/get-image-selections.ts new file mode 100644 index 0000000000..28b80e60ca --- /dev/null +++ b/blocksuite/affine/shared/src/commands/selection/get-image-selections.ts @@ -0,0 +1,25 @@ +import type { Command } from '@blocksuite/block-std'; + +import type { ImageSelection } from '../../selection/index.js'; + +export const getImageSelectionsCommand: Command< + never, + 'currentImageSelections' +> = (ctx, next) => { + const currentImageSelections = ctx.std.selection.filter('image'); + if (currentImageSelections.length === 0) return; + + next({ currentImageSelections }); +}; + +declare global { + namespace BlockSuite { + interface CommandContext { + currentImageSelections?: ImageSelection[]; + } + + interface Commands { + getImageSelections: typeof getImageSelectionsCommand; + } + } +} diff --git a/blocksuite/affine/shared/src/commands/selection/get-selection-rects.ts b/blocksuite/affine/shared/src/commands/selection/get-selection-rects.ts new file mode 100644 index 0000000000..ff9515935d --- /dev/null +++ b/blocksuite/affine/shared/src/commands/selection/get-selection-rects.ts @@ -0,0 +1,200 @@ +import type { + BlockSelection, + Command, + TextSelection, +} from '@blocksuite/block-std'; + +import { getViewportElement } from '../../utils/index.js'; + +export interface SelectionRect { + width: number; + height: number; + top: number; + left: number; + + /** + * The block id that the rect is in. Only available for block selections. + */ + blockId?: string; +} + +export const getSelectionRectsCommand: Command< + 'currentTextSelection' | 'currentBlockSelections', + 'selectionRects', + { + textSelection?: TextSelection; + blockSelections?: BlockSelection[]; + } +> = (ctx, next) => { + let textSelection; + let blockSelections; + + // priority parameters + if (ctx.textSelection) { + textSelection = ctx.textSelection; + } else if (ctx.blockSelections) { + blockSelections = ctx.blockSelections; + } else if (ctx.currentTextSelection) { + textSelection = ctx.currentTextSelection; + } else if (ctx.currentBlockSelections) { + blockSelections = ctx.currentBlockSelections; + } else { + console.error( + 'No selection provided, may forgot to call getTextSelection or getBlockSelections or provide the selection directly.' + ); + return; + } + + const { std } = ctx; + + const container = getViewportElement(std.host); + const containerRect = container?.getBoundingClientRect(); + + if (textSelection) { + const range = std.range.textSelectionToRange(textSelection); + + if (range) { + const nativeRects = Array.from(range.getClientRects()); + const rectsWithoutFiltered = nativeRects + .map(rect => ({ + width: rect.right - rect.left, + height: rect.bottom - rect.top, + top: + rect.top - (containerRect?.top ?? 0) + (container?.scrollTop ?? 0), + left: + rect.left - + (containerRect?.left ?? 0) + + (container?.scrollLeft ?? 0), + })) + .filter(rect => rect.width > 0 && rect.height > 0); + + return next({ + selectionRects: filterCoveringRects(rectsWithoutFiltered), + }); + } + } else if (blockSelections && blockSelections.length > 0) { + const result = blockSelections + .map(blockSelection => { + const block = std.view.getBlock(blockSelection.blockId); + if (!block) return null; + + const rect = block.getBoundingClientRect(); + + return { + width: rect.width, + height: rect.height, + top: + rect.top - (containerRect?.top ?? 0) + (container?.scrollTop ?? 0), + left: + rect.left - + (containerRect?.left ?? 0) + + (container?.scrollLeft ?? 0), + blockId: blockSelection.blockId, + }; + }) + .filter(rect => !!rect); + + return next({ selectionRects: result }); + } + + return; +}; + +declare global { + namespace BlockSuite { + interface CommandContext { + selectionRects?: SelectionRect[]; + } + + interface Commands { + /** + * Get the selection rects of the current selection or given selections. + * + * @chain may be `getTextSelection`, `getBlockSelections`, or nothing. + * @param textSelection The provided text selection. + * @param blockSelections The provided block selections. If `textSelection` is provided, this will be ignored. + * @returns The selection rects. + */ + getSelectionRects: typeof getSelectionRectsCommand; + } + } +} + +function covers(rect1: SelectionRect, rect2: SelectionRect): boolean { + return ( + rect1.left <= rect2.left && + rect1.top <= rect2.top && + rect1.left + rect1.width >= rect2.left + rect2.width && + rect1.top + rect1.height >= rect2.top + rect2.height + ); +} + +function intersects(rect1: SelectionRect, rect2: SelectionRect): boolean { + return ( + rect1.left <= rect2.left + rect2.width && + rect1.left + rect1.width >= rect2.left && + rect1.top <= rect2.top + rect2.height && + rect1.top + rect1.height >= rect2.top + ); +} + +function merge(rect1: SelectionRect, rect2: SelectionRect): SelectionRect { + const left = Math.min(rect1.left, rect2.left); + const top = Math.min(rect1.top, rect2.top); + const right = Math.max(rect1.left + rect1.width, rect2.left + rect2.width); + const bottom = Math.max(rect1.top + rect1.height, rect2.top + rect2.height); + + return { + width: right - left, + height: bottom - top, + top, + left, + }; +} + +export function filterCoveringRects(rects: SelectionRect[]): SelectionRect[] { + let mergedRects: SelectionRect[] = []; + let hasChanges: boolean; + + do { + hasChanges = false; + const newMergedRects: SelectionRect[] = [...mergedRects]; + + for (const rect of rects) { + let merged = false; + + for (let i = 0; i < newMergedRects.length; i++) { + if (covers(newMergedRects[i], rect)) { + merged = true; + break; + } else if (intersects(newMergedRects[i], rect)) { + newMergedRects[i] = merge(newMergedRects[i], rect); + merged = true; + hasChanges = true; + break; + } + } + + if (!merged) { + newMergedRects.push(rect); + } + } + + if (!hasChanges) { + for (let i = 0; i < newMergedRects.length; i++) { + for (let j = i + 1; j < newMergedRects.length; j++) { + if (intersects(newMergedRects[i], newMergedRects[j])) { + newMergedRects[i] = merge(newMergedRects[i], newMergedRects[j]); + newMergedRects.splice(j, 1); + hasChanges = true; + break; + } + } + } + } + + mergedRects = newMergedRects; + } while (hasChanges); + + return mergedRects; +} diff --git a/blocksuite/affine/shared/src/commands/selection/get-text-selection.ts b/blocksuite/affine/shared/src/commands/selection/get-text-selection.ts new file mode 100644 index 0000000000..a68e2f305b --- /dev/null +++ b/blocksuite/affine/shared/src/commands/selection/get-text-selection.ts @@ -0,0 +1,23 @@ +import type { Command, TextSelection } from '@blocksuite/block-std'; + +export const getTextSelectionCommand: Command = ( + ctx, + next +) => { + const currentTextSelection = ctx.std.selection.find('text'); + if (!currentTextSelection) return; + + next({ currentTextSelection }); +}; + +declare global { + namespace BlockSuite { + interface CommandContext { + currentTextSelection?: TextSelection; + } + + interface Commands { + getTextSelection: typeof getTextSelectionCommand; + } + } +} diff --git a/blocksuite/affine/shared/src/commands/selection/index.ts b/blocksuite/affine/shared/src/commands/selection/index.ts new file mode 100644 index 0000000000..308e683ba5 --- /dev/null +++ b/blocksuite/affine/shared/src/commands/selection/index.ts @@ -0,0 +1,7 @@ +export { getBlockSelectionsCommand } from './get-block-selections.js'; +export { getImageSelectionsCommand } from './get-image-selections.js'; +export { + getSelectionRectsCommand, + type SelectionRect, +} from './get-selection-rects.js'; +export { getTextSelectionCommand } from './get-text-selection.js'; diff --git a/blocksuite/affine/shared/src/consts/bracket-pairs.ts b/blocksuite/affine/shared/src/consts/bracket-pairs.ts new file mode 100644 index 0000000000..1ff9bde2f5 --- /dev/null +++ b/blocksuite/affine/shared/src/consts/bracket-pairs.ts @@ -0,0 +1,73 @@ +interface BracketPair { + name: string; + left: string; + right: string; +} + +export const BRACKET_PAIRS: BracketPair[] = [ + { + name: 'parenthesis', + left: '(', + right: ')', + }, + { + name: 'square bracket', + left: '[', + right: ']', + }, + { + name: 'curly bracket', + left: '{', + right: '}', + }, + { + name: 'single quote', + left: "'", + right: "'", + }, + { + name: 'double quote', + left: '"', + right: '"', + }, + { + name: 'angle bracket', + left: '<', + right: '>', + }, + { + name: 'fullwidth single quote', + left: '‘', + right: '’', + }, + { + name: 'fullwidth double quote', + left: '“', + right: '”', + }, + { + name: 'fullwidth parenthesis', + left: '(', + right: ')', + }, + { + name: 'fullwidth square bracket', + left: '【', + right: '】', + }, + { + name: 'fullwidth angle bracket', + left: '《', + right: '》', + }, + { + name: 'corner bracket', + left: '「', + right: '」', + }, + { + name: 'white corner bracket', + left: '『', + right: '』', + }, +]; diff --git a/blocksuite/affine/shared/src/consts/index.ts b/blocksuite/affine/shared/src/consts/index.ts new file mode 100644 index 0000000000..bd323e6672 --- /dev/null +++ b/blocksuite/affine/shared/src/consts/index.ts @@ -0,0 +1,67 @@ +import type { EmbedCardStyle } from '@blocksuite/affine-model'; + +export const BLOCK_CHILDREN_CONTAINER_PADDING_LEFT = 24; +export const EDGELESS_BLOCK_CHILD_PADDING = 24; +export const EDGELESS_BLOCK_CHILD_BORDER_WIDTH = 2; + +// The height of the header, which is used to calculate the scroll offset +// In AFFiNE, to avoid the option element to be covered by the header, we need to reserve the space for the header +export const PAGE_HEADER_HEIGHT = 53; + +export const EMBED_CARD_MIN_WIDTH = 450; + +export const EMBED_CARD_WIDTH: Record = { + horizontal: 752, + horizontalThin: 752, + list: 752, + vertical: 364, + cube: 170, + cubeThick: 170, + video: 752, + figma: 752, + html: 752, + syncedDoc: 752, + pdf: 537 + 24 + 2, +}; + +export const EMBED_CARD_HEIGHT: Record = { + horizontal: 116, + horizontalThin: 80, + list: 46, + vertical: 390, + cube: 114, + cubeThick: 132, + video: 544, + figma: 544, + html: 544, + syncedDoc: 455, + pdf: 759 + 46 + 24 + 2, +}; + +export const EMBED_BLOCK_FLAVOUR_LIST = [ + 'affine:embed-github', + 'affine:embed-youtube', + 'affine:embed-figma', + 'affine:embed-linked-doc', + 'affine:embed-synced-doc', + 'affine:embed-html', + 'affine:embed-loom', +] as const; + +export const DEFAULT_IMAGE_PROXY_ENDPOINT = + 'https://affine-worker.toeverything.workers.dev/api/worker/image-proxy'; + +// https://github.com/toeverything/affine-workers/tree/main/packages/link-preview +export const DEFAULT_LINK_PREVIEW_ENDPOINT = + 'https://affine-worker.toeverything.workers.dev/api/worker/link-preview'; + +// This constant is used to ignore tags when exporting using html2canvas +export const CANVAS_EXPORT_IGNORE_TAGS = [ + 'EDGELESS-TOOLBAR-WIDGET', + 'AFFINE-DRAG-HANDLE-WIDGET', + 'AFFINE-FORMAT-BAR-WIDGET', + 'AFFINE-BLOCK-SELECTION', +]; + +export * from './bracket-pairs.js'; +export * from './note.js'; diff --git a/blocksuite/affine/shared/src/consts/note.ts b/blocksuite/affine/shared/src/consts/note.ts new file mode 100644 index 0000000000..9b1a928e94 --- /dev/null +++ b/blocksuite/affine/shared/src/consts/note.ts @@ -0,0 +1,2 @@ +export const NOTE_SELECTOR = + 'affine-note, affine-edgeless-note .edgeless-note-page-content, affine-edgeless-text'; diff --git a/blocksuite/affine/shared/src/index.ts b/blocksuite/affine/shared/src/index.ts new file mode 100644 index 0000000000..cb0ff5c3b5 --- /dev/null +++ b/blocksuite/affine/shared/src/index.ts @@ -0,0 +1 @@ +export {}; diff --git a/blocksuite/affine/shared/src/mixins/index.ts b/blocksuite/affine/shared/src/mixins/index.ts new file mode 100644 index 0000000000..cb0ff5c3b5 --- /dev/null +++ b/blocksuite/affine/shared/src/mixins/index.ts @@ -0,0 +1 @@ +export {}; diff --git a/blocksuite/affine/shared/src/selection/hightlight.ts b/blocksuite/affine/shared/src/selection/hightlight.ts new file mode 100644 index 0000000000..a4577c7276 --- /dev/null +++ b/blocksuite/affine/shared/src/selection/hightlight.ts @@ -0,0 +1,62 @@ +import { + type ReferenceParams, + ReferenceParamsSchema, +} from '@blocksuite/affine-model'; +import { BaseSelection, SelectionExtension } from '@blocksuite/block-std'; + +export class HighlightSelection extends BaseSelection { + static override group = 'scene'; + + static override type = 'highlight'; + + readonly blockIds: string[] = []; + + readonly elementIds: string[] = []; + + readonly mode: 'page' | 'edgeless' = 'page'; + + constructor({ mode, blockIds, elementIds }: ReferenceParams) { + super({ blockId: '[scene-highlight]' }); + + this.mode = mode ?? 'page'; + this.blockIds = blockIds ?? []; + this.elementIds = elementIds ?? []; + } + + static override fromJSON(json: Record): HighlightSelection { + const result = ReferenceParamsSchema.parse(json); + return new HighlightSelection(result); + } + + override equals(other: HighlightSelection): boolean { + return ( + this.mode === other.mode && + this.blockId === other.blockId && + this.blockIds.length === other.blockIds.length && + this.elementIds.length === other.elementIds.length && + this.blockIds.every((id, n) => id === other.blockIds[n]) && + this.elementIds.every((id, n) => id === other.elementIds[n]) + ); + } + + override toJSON(): Record { + return { + type: 'highlight', + mode: this.mode, + blockId: this.blockId, + blockIds: this.blockIds, + elementIds: this.elementIds, + }; + } +} + +declare global { + namespace BlockSuite { + interface Selection { + highlight: typeof HighlightSelection; + } + } +} + +export const HighlightSelectionExtension = + SelectionExtension(HighlightSelection); diff --git a/blocksuite/affine/shared/src/selection/image.ts b/blocksuite/affine/shared/src/selection/image.ts new file mode 100644 index 0000000000..098c696f44 --- /dev/null +++ b/blocksuite/affine/shared/src/selection/image.ts @@ -0,0 +1,41 @@ +import { BaseSelection, SelectionExtension } from '@blocksuite/block-std'; +import z from 'zod'; + +const ImageSelectionSchema = z.object({ + blockId: z.string(), +}); + +export class ImageSelection extends BaseSelection { + static override group = 'note'; + + static override type = 'image'; + + static override fromJSON(json: Record): ImageSelection { + const result = ImageSelectionSchema.parse(json); + return new ImageSelection(result); + } + + override equals(other: BaseSelection): boolean { + if (other instanceof ImageSelection) { + return this.blockId === other.blockId; + } + return false; + } + + override toJSON(): Record { + return { + type: this.type, + blockId: this.blockId, + }; + } +} + +declare global { + namespace BlockSuite { + interface Selection { + image: typeof ImageSelection; + } + } +} + +export const ImageSelectionExtension = SelectionExtension(ImageSelection); diff --git a/blocksuite/affine/shared/src/selection/index.ts b/blocksuite/affine/shared/src/selection/index.ts new file mode 100644 index 0000000000..995bf3809e --- /dev/null +++ b/blocksuite/affine/shared/src/selection/index.ts @@ -0,0 +1,5 @@ +export { + HighlightSelection, + HighlightSelectionExtension, +} from './hightlight.js'; +export { ImageSelection, ImageSelectionExtension } from './image.js'; diff --git a/blocksuite/affine/shared/src/services/doc-display-meta-service.ts b/blocksuite/affine/shared/src/services/doc-display-meta-service.ts new file mode 100644 index 0000000000..df3bf2751b --- /dev/null +++ b/blocksuite/affine/shared/src/services/doc-display-meta-service.ts @@ -0,0 +1,201 @@ +import type { AliasInfo, ReferenceParams } from '@blocksuite/affine-model'; +import { LifeCycleWatcher, StdIdentifier } from '@blocksuite/block-std'; +import { type Container, createIdentifier } from '@blocksuite/global/di'; +import type { Disposable } from '@blocksuite/global/utils'; +import { + AliasIcon, + BlockLinkIcon, + DeleteIcon, + EdgelessIcon, + LinkedEdgelessIcon, + LinkedPageIcon, + PageIcon, +} from '@blocksuite/icons/lit'; +import type { Doc } from '@blocksuite/store'; +import { computed, type Signal, signal } from '@preact/signals-core'; +import type { TemplateResult } from 'lit'; + +import { referenceToNode } from '../utils/reference.js'; +import { DocModeProvider } from './doc-mode-service.js'; + +export type DocDisplayMetaParams = { + referenced?: boolean; + params?: ReferenceParams; +} & AliasInfo; + +/** + * Customize document display title and icon. + * + * Supports the following blocks: + * + * * Inline View: + * `AffineReference` + * * Card View: + * `EmbedLinkedDocBlockComponent` + * `EmbedEdgelessLinkedDocBlockComponent` + * * Embed View: + * `EmbedSyncedDocBlockComponent` + * `EmbedEdgelessSyncedDocBlockComponent` + */ +export interface DocDisplayMetaExtension { + icon: ( + docId: string, + referenceInfo?: DocDisplayMetaParams + ) => Signal; + title: ( + docId: string, + referenceInfo?: DocDisplayMetaParams + ) => Signal; +} + +export const DocDisplayMetaProvider = createIdentifier( + 'DocDisplayMetaService' +); + +export class DocDisplayMetaService + extends LifeCycleWatcher + implements DocDisplayMetaExtension +{ + static icons = { + deleted: iconBuilder(DeleteIcon), + aliased: iconBuilder(AliasIcon), + page: iconBuilder(PageIcon), + edgeless: iconBuilder(EdgelessIcon), + linkedBlock: iconBuilder(BlockLinkIcon), + linkedPage: iconBuilder(LinkedPageIcon), + linkedEdgeless: iconBuilder(LinkedEdgelessIcon), + } as const; + + static override key = 'doc-display-meta'; + + readonly disposables: Disposable[] = []; + + readonly iconMap = new WeakMap>(); + + readonly titleMap = new WeakMap>(); + + static override setup(di: Container) { + di.addImpl(DocDisplayMetaProvider, this, [StdIdentifier]); + } + + dispose() { + while (this.disposables.length > 0) { + this.disposables.pop()?.dispose(); + } + } + + icon( + pageId: string, + { params, title, referenced }: DocDisplayMetaParams = {} + ): Signal { + const doc = this.std.collection.getDoc(pageId); + + if (!doc) { + return signal(DocDisplayMetaService.icons.deleted); + } + + let icon$ = this.iconMap.get(doc); + + if (!icon$) { + icon$ = signal( + this.std.get(DocModeProvider).getPrimaryMode(pageId) === 'edgeless' + ? DocDisplayMetaService.icons.edgeless + : DocDisplayMetaService.icons.page + ); + + const disposable = this.std + .get(DocModeProvider) + .onPrimaryModeChange(mode => { + icon$!.value = + mode === 'edgeless' + ? DocDisplayMetaService.icons.edgeless + : DocDisplayMetaService.icons.page; + }, pageId); + + this.disposables.push(disposable); + this.disposables.push( + this.std.collection.slots.docRemoved + .filter(docId => docId === doc.id) + .once(() => { + const index = this.disposables.findIndex(d => d === disposable); + if (index !== -1) { + this.disposables.splice(index, 1); + disposable.dispose(); + } + this.iconMap.delete(doc); + }) + ); + this.iconMap.set(doc, icon$); + } + + return computed(() => { + if (title) { + return DocDisplayMetaService.icons.aliased; + } + + if (referenceToNode({ pageId, params })) { + return DocDisplayMetaService.icons.linkedBlock; + } + + if (referenced) { + const mode = + params?.mode ?? + this.std.get(DocModeProvider).getPrimaryMode(pageId) ?? + 'page'; + return mode === 'edgeless' + ? DocDisplayMetaService.icons.linkedEdgeless + : DocDisplayMetaService.icons.linkedPage; + } + + return icon$.value; + }); + } + + title(pageId: string, { title }: DocDisplayMetaParams = {}): Signal { + const doc = this.std.collection.getDoc(pageId); + + if (!doc) { + return signal(title || 'Deleted doc'); + } + + let title$ = this.titleMap.get(doc); + if (!title$) { + title$ = signal(doc.meta?.title || 'Untitled'); + + const disposable = this.std.collection.meta.docMetaUpdated.on(() => { + title$!.value = doc.meta?.title || 'Untitled'; + }); + + this.disposables.push(disposable); + this.disposables.push( + this.std.collection.slots.docRemoved + .filter(docId => docId === doc.id) + .once(() => { + const index = this.disposables.findIndex(d => d === disposable); + if (index !== -1) { + this.disposables.splice(index, 1); + disposable.dispose(); + } + this.titleMap.delete(doc); + }) + ); + this.titleMap.set(doc, title$); + } + + return computed(() => { + return title || title$.value; + }); + } + + override unmounted() { + this.dispose(); + } +} + +function iconBuilder( + icon: typeof PageIcon, + size = '1.25em', + style = 'user-select:none;flex-shrink:0;vertical-align:middle;font-size:inherit;margin-bottom:0.1em;' +) { + return icon({ width: size, height: size, style }); +} diff --git a/blocksuite/affine/shared/src/services/doc-mode-service.ts b/blocksuite/affine/shared/src/services/doc-mode-service.ts new file mode 100644 index 0000000000..cd3f1e0bea --- /dev/null +++ b/blocksuite/affine/shared/src/services/doc-mode-service.ts @@ -0,0 +1,108 @@ +import type { DocMode } from '@blocksuite/affine-model'; +import type { ExtensionType } from '@blocksuite/block-std'; +import { Extension } from '@blocksuite/block-std'; +import type { Container } from '@blocksuite/global/di'; +import { createIdentifier } from '@blocksuite/global/di'; +import { type Disposable, noop, Slot } from '@blocksuite/global/utils'; + +const DEFAULT_MODE: DocMode = 'page'; + +export interface DocModeProvider { + /** + * Set the primary mode of the doc. + * This would not affect the current editor mode. + * If you want to switch the editor mode, use `setEditorMode` instead. + * @param mode - The mode to set. + * @param docId - The id of the doc. + */ + setPrimaryMode: (mode: DocMode, docId: string) => void; + /** + * Get the primary mode of the doc. + * Normally, it would be used to query the mode of other doc. + * @param docId - The id of the doc. + * @returns The primary mode of the document. + */ + getPrimaryMode: (docId: string) => DocMode; + /** + * Toggle the primary mode of the doc. + * @param docId - The id of the doc. + * @returns The new primary mode of the doc. + */ + togglePrimaryMode: (docId: string) => DocMode; + /** + * Subscribe to changes in the primary mode of the doc. + * For example: + * Embed-linked-doc-block will subscribe to the primary mode of the linked doc, + * and will display different UI according to the primary mode of the linked doc. + * @param handler - The handler to call when the primary mode of certain doc changes. + * @param docId - The id of the doc. + * @returns A disposable to stop the subscription. + */ + onPrimaryModeChange: ( + handler: (mode: DocMode) => void, + docId: string + ) => Disposable; + /** + * Set the editor mode. Normally, it would be used to set the mode of the current editor. + * When patch or override the doc mode service, can pass a callback to set the editor mode. + * @param mode - The mode to set. + */ + setEditorMode: (mode: DocMode) => void; + /** + * Get current editor mode. + * @returns The editor mode. + */ + getEditorMode: () => DocMode | null; +} + +export const DocModeProvider = createIdentifier( + 'AffineDocModeService' +); + +const modeMap = new Map(); +const slotMap = new Map>(); + +export class DocModeService extends Extension implements DocModeProvider { + static override setup(di: Container) { + di.addImpl(DocModeProvider, DocModeService); + } + + getEditorMode(): DocMode | null { + return null; + } + + getPrimaryMode(id: string) { + return modeMap.get(id) ?? DEFAULT_MODE; + } + + onPrimaryModeChange(handler: (mode: DocMode) => void, id: string) { + if (!slotMap.get(id)) { + slotMap.set(id, new Slot()); + } + return slotMap.get(id)!.on(handler); + } + + setEditorMode(mode: DocMode) { + noop(mode); + } + + setPrimaryMode(mode: DocMode, id: string) { + modeMap.set(id, mode); + slotMap.get(id)?.emit(mode); + } + + togglePrimaryMode(id: string) { + const mode = this.getPrimaryMode(id) === 'page' ? 'edgeless' : 'page'; + this.setPrimaryMode(mode, id); + + return mode; + } +} + +export function DocModeExtension(service: DocModeProvider): ExtensionType { + return { + setup: di => { + di.override(DocModeProvider, () => service); + }, + }; +} diff --git a/blocksuite/affine/shared/src/services/drag-handle-config.ts b/blocksuite/affine/shared/src/services/drag-handle-config.ts new file mode 100644 index 0000000000..d186a325c4 --- /dev/null +++ b/blocksuite/affine/shared/src/services/drag-handle-config.ts @@ -0,0 +1,125 @@ +import { + type BlockComponent, + type BlockStdScope, + type DndEventState, + type EditorHost, + Extension, + type ExtensionType, + StdIdentifier, +} from '@blocksuite/block-std'; +import { type Container, createIdentifier } from '@blocksuite/global/di'; +import type { Point } from '@blocksuite/global/utils'; +import { Job, Slice, type SliceSnapshot } from '@blocksuite/store'; + +export type DropType = 'before' | 'after' | 'in'; +export type OnDragStartProps = { + state: DndEventState; + startDragging: ( + blocks: BlockComponent[], + state: DndEventState, + dragPreview?: HTMLElement, + dragPreviewOffset?: Point + ) => void; + anchorBlockId: string | null; + editorHost: EditorHost; +}; + +export type OnDragEndProps = { + state: DndEventState; + draggingElements: BlockComponent[]; + dropBlockId: string; + dropType: DropType | null; + dragPreview: HTMLElement; + noteScale: number; + editorHost: EditorHost; +}; + +export type OnDragMoveProps = { + state: DndEventState; + draggingElements?: BlockComponent[]; +}; + +export type DragHandleOption = { + flavour: string | RegExp; + edgeless?: boolean; + onDragStart?: (props: OnDragStartProps) => boolean; + onDragMove?: (props: OnDragMoveProps) => boolean; + onDragEnd?: (props: OnDragEndProps) => boolean; +}; + +export const DragHandleConfigIdentifier = createIdentifier( + 'AffineDragHandleIdentifier' +); + +export function DragHandleConfigExtension( + option: DragHandleOption +): ExtensionType { + return { + setup: di => { + const key = + typeof option.flavour === 'string' + ? option.flavour + : option.flavour.source; + di.addImpl(DragHandleConfigIdentifier(key), () => option); + }, + }; +} + +export const DndApiExtensionIdentifier = createIdentifier( + 'AffineDndApiIdentifier' +); + +export class DNDAPIExtension extends Extension { + mimeType = 'application/x-blocksuite-dnd'; + + constructor(readonly std: BlockStdScope) { + super(); + } + + static override setup(di: Container) { + di.add(this, [StdIdentifier]); + + di.addImpl(DndApiExtensionIdentifier, provider => provider.get(this)); + } + + decodeSnapshot(data: string): SliceSnapshot { + return JSON.parse(decodeURIComponent(data)); + } + + encodeSnapshot(json: SliceSnapshot) { + const snapshot = JSON.stringify(json); + return encodeURIComponent(snapshot); + } + + fromEntity(options: { + docId: string; + flavour?: string; + blockId?: string; + }): SliceSnapshot | null { + const { docId, flavour = 'affine:embed-linked-doc', blockId } = options; + + const slice = Slice.fromModels(this.std.doc, []); + const job = new Job({ collection: this.std.collection }); + const snapshot = job.sliceToSnapshot(slice); + if (!snapshot) { + console.error('Failed to convert slice to snapshot'); + return null; + } + const props = { + ...(blockId ? { blockId } : {}), + pageId: docId, + }; + return { + ...snapshot, + content: [ + { + id: this.std.collection.idGenerator(), + type: 'block', + flavour, + props, + children: [], + }, + ], + }; + } +} diff --git a/blocksuite/affine/shared/src/services/edit-props-store.ts b/blocksuite/affine/shared/src/services/edit-props-store.ts new file mode 100644 index 0000000000..d535372340 --- /dev/null +++ b/blocksuite/affine/shared/src/services/edit-props-store.ts @@ -0,0 +1,211 @@ +import { type BlockStdScope, LifeCycleWatcher } from '@blocksuite/block-std'; +import { BlockSuiteError, ErrorCode } from '@blocksuite/global/exceptions'; +import { + type DeepPartial, + DisposableGroup, + Slot, +} from '@blocksuite/global/utils'; +import { DocCollection } from '@blocksuite/store'; +import { computed, type Signal, signal } from '@preact/signals-core'; +import clonedeep from 'lodash.clonedeep'; +import mergeWith from 'lodash.mergewith'; +import { z } from 'zod'; + +import { + ColorSchema, + makeDeepOptional, + NodePropsSchema, +} from '../utils/index.js'; +import { EditorSettingProvider } from './editor-setting-service.js'; + +const LastPropsSchema = NodePropsSchema; +const OptionalPropsSchema = makeDeepOptional(NodePropsSchema); +export type LastProps = z.infer; +export type LastPropsKey = keyof LastProps; + +const SessionPropsSchema = z.object({ + viewport: z.union([ + z.object({ + centerX: z.number(), + centerY: z.number(), + zoom: z.number(), + }), + z.object({ + xywh: z.string(), + padding: z + .tuple([z.number(), z.number(), z.number(), z.number()]) + .optional(), + }), + ]), + templateCache: z.string(), + remoteColor: z.string(), + showBidirectional: z.boolean(), +}); + +const LocalPropsSchema = z.object({ + presentBlackBackground: z.boolean(), + presentFillScreen: z.boolean(), + presentHideToolbar: z.boolean(), + + autoHideEmbedHTMLFullScreenToolbar: z.boolean(), +}); + +type SessionProps = z.infer; +type LocalProps = z.infer; +type StorageProps = SessionProps & LocalProps; +type StoragePropsKey = keyof StorageProps; + +function isLocalProp(key: string): key is keyof LocalProps { + return key in LocalPropsSchema.shape; +} + +function isSessionProp(key: string): key is keyof SessionProps { + return key in SessionPropsSchema.shape; +} + +function customizer(_target: unknown, source: unknown) { + if ( + ColorSchema.safeParse(source).success || + source instanceof DocCollection.Y.Text || + source instanceof DocCollection.Y.Array || + source instanceof DocCollection.Y.Map + ) { + return source; + } + return; +} + +export class EditPropsStore extends LifeCycleWatcher { + static override key = 'EditPropsStore'; + + private _disposables = new DisposableGroup(); + + private innerProps$: Signal> = signal({}); + + lastProps$: Signal; + + slots = { + storageUpdated: new Slot<{ + key: StoragePropsKey; + value: StorageProps[StoragePropsKey]; + }>(), + }; + + constructor(std: BlockStdScope) { + super(std); + const initProps: LastProps = LastPropsSchema.parse( + Object.entries(LastPropsSchema.shape).reduce((value, [key, schema]) => { + return { + ...value, + [key]: schema.parse(undefined), + }; + }, {}) + ); + + this.lastProps$ = computed(() => { + const editorSetting$ = this.std.getOptional(EditorSettingProvider); + const nextProps = mergeWith( + clonedeep(initProps), + editorSetting$?.value, + this.innerProps$.value, + customizer + ); + return LastPropsSchema.parse(nextProps); + }); + } + + private _getStorage(key: T) { + return isSessionProp(key) ? sessionStorage : localStorage; + } + + private _getStorageKey(key: T) { + const id = this.std.doc.id; + switch (key) { + case 'viewport': + return 'blocksuite:' + id + ':edgelessViewport'; + case 'presentBlackBackground': + return 'blocksuite:presentation:blackBackground'; + case 'presentFillScreen': + return 'blocksuite:presentation:fillScreen'; + case 'presentHideToolbar': + return 'blocksuite:presentation:hideToolbar'; + case 'templateCache': + return 'blocksuite:' + id + ':templateTool'; + case 'remoteColor': + return 'blocksuite:remote-color'; + case 'showBidirectional': + return 'blocksuite:' + id + ':showBidirectional'; + case 'autoHideEmbedHTMLFullScreenToolbar': + return 'blocksuite:embedHTML:autoHideFullScreenToolbar'; + default: + return key; + } + } + + applyLastProps(key: LastPropsKey, props: Record) { + if (['__proto__', 'constructor', 'prototype'].includes(key)) { + throw new BlockSuiteError( + ErrorCode.DefaultRuntimeError, + `Invalid key: ${key}` + ); + } + const lastProps = this.lastProps$.value[key]; + return mergeWith(clonedeep(lastProps), props, customizer); + } + + dispose() { + this._disposables.dispose(); + } + + getStorage(key: T) { + try { + const storage = this._getStorage(key); + const value = storage.getItem(this._getStorageKey(key)); + if (!value) return null; + if (isLocalProp(key)) { + return LocalPropsSchema.shape[key].parse( + JSON.parse(value) + ) as StorageProps[T]; + } else if (isSessionProp(key)) { + return SessionPropsSchema.shape[key].parse( + JSON.parse(value) + ) as StorageProps[T]; + } else { + return null; + } + } catch { + return null; + } + } + + recordLastProps(key: LastPropsKey, props: Partial) { + const schema = OptionalPropsSchema._def.innerType.shape[key]; + if (!schema) return; + + const overrideProps = schema.parse(props); + if (Object.keys(overrideProps).length === 0) return; + + const innerProps = this.innerProps$.value; + const nextProps = mergeWith( + clonedeep(innerProps), + { [key]: overrideProps }, + customizer + ); + this.innerProps$.value = OptionalPropsSchema.parse(nextProps); + } + + setStorage(key: T, value: StorageProps[T]) { + const oldValue = this.getStorage(key); + this._getStorage(key).setItem( + this._getStorageKey(key), + JSON.stringify(value) + ); + if (oldValue === value) return; + this.slots.storageUpdated.emit({ key, value }); + } + + override unmounted() { + super.unmounted(); + this.dispose(); + } +} diff --git a/blocksuite/affine/shared/src/services/editor-setting-service.ts b/blocksuite/affine/shared/src/services/editor-setting-service.ts new file mode 100644 index 0000000000..854e634941 --- /dev/null +++ b/blocksuite/affine/shared/src/services/editor-setting-service.ts @@ -0,0 +1,25 @@ +import type { ExtensionType } from '@blocksuite/block-std'; +import { createIdentifier } from '@blocksuite/global/di'; +import type { DeepPartial } from '@blocksuite/global/utils'; +import type { Signal } from '@preact/signals-core'; +import type { z } from 'zod'; + +import { NodePropsSchema } from '../utils/index.js'; + +export const EditorSettingSchema = NodePropsSchema; + +export type EditorSetting = z.infer; + +export const EditorSettingProvider = createIdentifier< + Signal> +>('AffineEditorSettingProvider'); + +export function EditorSettingExtension( + signal: Signal> +): ExtensionType { + return { + setup: di => { + di.addImpl(EditorSettingProvider, () => signal); + }, + }; +} diff --git a/blocksuite/affine/shared/src/services/embed-option-service.ts b/blocksuite/affine/shared/src/services/embed-option-service.ts new file mode 100644 index 0000000000..356a5659c2 --- /dev/null +++ b/blocksuite/affine/shared/src/services/embed-option-service.ts @@ -0,0 +1,44 @@ +import type { EmbedCardStyle } from '@blocksuite/affine-model'; +import { Extension } from '@blocksuite/block-std'; +import type { Container } from '@blocksuite/global/di'; +import { createIdentifier } from '@blocksuite/global/di'; + +export type EmbedOptions = { + flavour: string; + urlRegex: RegExp; + styles: EmbedCardStyle[]; + viewType: 'card' | 'embed'; +}; + +export interface EmbedOptionProvider { + getEmbedBlockOptions(url: string): EmbedOptions | null; + registerEmbedBlockOptions(options: EmbedOptions): void; +} + +export const EmbedOptionProvider = createIdentifier( + 'AffineEmbedOptionProvider' +); + +export class EmbedOptionService + extends Extension + implements EmbedOptionProvider +{ + private _embedBlockRegistry = new Set(); + + getEmbedBlockOptions = (url: string): EmbedOptions | null => { + const entries = this._embedBlockRegistry.entries(); + for (const [options] of entries) { + const regex = options.urlRegex; + if (regex.test(url)) return options; + } + return null; + }; + + registerEmbedBlockOptions = (options: EmbedOptions): void => { + this._embedBlockRegistry.add(options); + }; + + static override setup(di: Container) { + di.addImpl(EmbedOptionProvider, EmbedOptionService); + } +} diff --git a/blocksuite/affine/shared/src/services/font-loader/config.ts b/blocksuite/affine/shared/src/services/font-loader/config.ts new file mode 100644 index 0000000000..281b261209 --- /dev/null +++ b/blocksuite/affine/shared/src/services/font-loader/config.ts @@ -0,0 +1,376 @@ +import { FontFamily, FontStyle, FontWeight } from '@blocksuite/affine-model'; + +export interface FontConfig { + font: string; + weight: string; + url: string; + style: string; +} + +export const AffineCanvasTextFonts: FontConfig[] = [ + // Inter, https://fonts.cdnfonts.com/css/inter?styles=29139,29134,29135,29136,29140,29141 + { + font: FontFamily.Inter, + url: 'https://cdn.affine.pro/fonts/Inter-Light-BETA.woff', + weight: FontWeight.Light, + style: FontStyle.Normal, + }, + { + font: FontFamily.Inter, + url: 'https://cdn.affine.pro/fonts/Inter-Regular.woff', + weight: FontWeight.Regular, + style: FontStyle.Normal, + }, + { + font: FontFamily.Inter, + url: 'https://cdn.affine.pro/fonts/Inter-SemiBold.woff', + weight: FontWeight.SemiBold, + style: FontStyle.Normal, + }, + { + font: FontFamily.Inter, + url: 'https://cdn.affine.pro/fonts/Inter-LightItalic-BETA.woff', + weight: FontWeight.Light, + style: FontStyle.Italic, + }, + { + font: FontFamily.Inter, + url: 'https://cdn.affine.pro/fonts/Inter-Italic.woff', + weight: FontWeight.Regular, + style: FontStyle.Italic, + }, + { + font: FontFamily.Inter, + url: 'https://cdn.affine.pro/fonts/Inter-SemiBoldItalic.woff', + weight: FontWeight.SemiBold, + style: FontStyle.Italic, + }, + // Kalam, https://fonts.cdnfonts.com/css/kalam?styles=15166,170689,170687 + { + font: FontFamily.Kalam, + url: 'https://cdn.affine.pro/fonts/Kalam-Light.woff', + weight: FontWeight.Light, + style: FontStyle.Normal, + }, + { + font: FontFamily.Kalam, + url: 'https://cdn.affine.pro/fonts/Kalam-Regular.woff', + weight: FontWeight.Regular, + style: FontStyle.Normal, + }, + { + font: FontFamily.Kalam, + url: 'https://cdn.affine.pro/fonts/Kalam-Bold.woff', + weight: FontWeight.SemiBold, + style: FontStyle.Normal, + }, + // Satoshi, https://fonts.cdnfonts.com/css/satoshi?styles=135009,135004,135005,135006,135002,135003 + { + font: FontFamily.Satoshi, + url: 'https://cdn.affine.pro/fonts/Satoshi-Light.woff', + weight: FontWeight.Light, + style: FontStyle.Normal, + }, + { + font: FontFamily.Satoshi, + url: 'https://cdn.affine.pro/fonts/Satoshi-Regular.woff', + weight: FontWeight.Regular, + style: FontStyle.Normal, + }, + { + font: FontFamily.Satoshi, + url: 'https://cdn.affine.pro/fonts/Satoshi-Bold.woff', + weight: FontWeight.SemiBold, + style: FontStyle.Normal, + }, + { + font: FontFamily.Satoshi, + url: 'https://cdn.affine.pro/fonts/Satoshi-LightItalic.woff', + weight: FontWeight.Light, + style: FontStyle.Italic, + }, + { + font: FontFamily.Satoshi, + url: 'https://cdn.affine.pro/fonts/Satoshi-Italic.woff', + weight: FontWeight.Regular, + style: FontStyle.Italic, + }, + { + font: FontFamily.Satoshi, + url: 'https://cdn.affine.pro/fonts/Satoshi-BoldItalic.woff', + weight: FontWeight.SemiBold, + style: FontStyle.Italic, + }, + // Poppins, https://fonts.cdnfonts.com/css/poppins?styles=20394,20389,20390,20391,20395,20396 + { + font: FontFamily.Poppins, + url: 'https://cdn.affine.pro/fonts/Poppins-Light.woff', + weight: FontWeight.Light, + style: FontStyle.Normal, + }, + { + font: FontFamily.Poppins, + url: 'https://cdn.affine.pro/fonts/Poppins-Regular.woff', + weight: FontWeight.Regular, + style: FontStyle.Normal, + }, + { + font: FontFamily.Poppins, + url: 'https://cdn.affine.pro/fonts/Poppins-Medium.woff', + weight: FontWeight.Medium, + style: FontStyle.Normal, + }, + { + font: FontFamily.Poppins, + url: 'https://cdn.affine.pro/fonts/Poppins-SemiBold.woff', + weight: FontWeight.SemiBold, + style: FontStyle.Normal, + }, + { + font: FontFamily.Poppins, + url: 'https://cdn.affine.pro/fonts/Poppins-LightItalic.woff', + weight: FontWeight.Light, + style: FontStyle.Italic, + }, + { + font: FontFamily.Poppins, + url: 'https://cdn.affine.pro/fonts/Poppins-Italic.woff', + weight: FontWeight.Regular, + style: FontStyle.Italic, + }, + { + font: FontFamily.Poppins, + url: 'https://cdn.affine.pro/fonts/Poppins-SemiBoldItalic.woff', + weight: FontWeight.SemiBold, + style: FontStyle.Italic, + }, + // Lora, https://fonts.cdnfonts.com/css/lora-4?styles=50357,50356,50354,50355 + { + font: FontFamily.Lora, + url: 'https://cdn.affine.pro/fonts/Lora-Regular.woff', + weight: FontWeight.Regular, + style: FontStyle.Normal, + }, + { + font: FontFamily.Lora, + url: 'https://cdn.affine.pro/fonts/Lora-Bold.woff', + weight: FontWeight.SemiBold, + style: FontStyle.Normal, + }, + { + font: FontFamily.Lora, + url: 'https://cdn.affine.pro/fonts/Lora-Italic.woff', + weight: FontWeight.Regular, + style: FontStyle.Italic, + }, + { + font: FontFamily.Lora, + url: 'https://cdn.affine.pro/fonts/Lora-BoldItalic.woff', + weight: FontWeight.SemiBold, + style: FontStyle.Italic, + }, + // BebasNeue, https://fonts.cdnfonts.com/css/bebas-neue?styles=169713,17622,17620 + { + font: FontFamily.BebasNeue, + url: 'https://cdn.affine.pro/fonts/BebasNeue-Light.woff', + weight: FontWeight.Light, + style: FontStyle.Normal, + }, + { + font: FontFamily.BebasNeue, + url: 'https://cdn.affine.pro/fonts/BebasNeue-Regular.woff', + weight: FontWeight.Regular, + style: FontStyle.Normal, + }, + // OrelegaOne, https://fonts.cdnfonts.com/css/orelega-one?styles=148618 + { + font: FontFamily.OrelegaOne, + url: 'https://cdn.affine.pro/fonts/OrelegaOne-Regular.woff', + weight: FontWeight.Regular, + style: FontStyle.Normal, + }, +]; + +export const CommunityCanvasTextFonts: FontConfig[] = [ + // Inter, https://fonts.cdnfonts.com/css/inter?styles=29139,29134,29135,29136,29140,29141 + { + font: FontFamily.Inter, + url: 'https://fonts.cdnfonts.com/s/19795/Inter-Light-BETA.woff', + weight: FontWeight.Light, + style: FontStyle.Normal, + }, + { + font: FontFamily.Inter, + url: 'https://fonts.cdnfonts.com/s/19795/Inter-Regular.woff', + weight: FontWeight.Regular, + style: FontStyle.Normal, + }, + { + font: FontFamily.Inter, + url: 'https://fonts.cdnfonts.com/s/19795/Inter-SemiBold.woff', + weight: FontWeight.SemiBold, + style: FontStyle.Normal, + }, + { + font: FontFamily.Inter, + url: 'https://fonts.cdnfonts.com/s/19795/Inter-LightItalic-BETA.woff', + weight: FontWeight.Light, + style: FontStyle.Italic, + }, + { + font: FontFamily.Inter, + url: 'https://fonts.cdnfonts.com/s/19795/Inter-Italic.woff', + weight: FontWeight.Regular, + style: FontStyle.Italic, + }, + { + font: FontFamily.Inter, + url: 'https://fonts.cdnfonts.com/s/19795/Inter-SemiBoldItalic.woff', + weight: FontWeight.SemiBold, + style: FontStyle.Italic, + }, + // Kalam, https://fonts.cdnfonts.com/css/kalam?styles=15166,170689,170687 + { + font: FontFamily.Kalam, + url: 'https://fonts.cdnfonts.com/s/13130/Kalam-Light.woff', + weight: FontWeight.Light, + style: FontStyle.Normal, + }, + { + font: FontFamily.Kalam, + url: 'https://fonts.cdnfonts.com/s/13130/Kalam-Regular.woff', + weight: FontWeight.Regular, + style: FontStyle.Normal, + }, + { + font: FontFamily.Kalam, + url: 'https://fonts.cdnfonts.com/s/13130/Kalam-Bold.woff', + weight: FontWeight.SemiBold, + style: FontStyle.Normal, + }, + // Satoshi, https://fonts.cdnfonts.com/css/satoshi?styles=135009,135004,135005,135006,135002,135003 + { + font: FontFamily.Satoshi, + url: 'https://fonts.cdnfonts.com/s/85546/Satoshi-Light.woff', + weight: FontWeight.Light, + style: FontStyle.Normal, + }, + { + font: FontFamily.Satoshi, + url: 'https://fonts.cdnfonts.com/s/85546/Satoshi-Regular.woff', + weight: FontWeight.Regular, + style: FontStyle.Normal, + }, + { + font: FontFamily.Satoshi, + url: 'https://fonts.cdnfonts.com/s/85546/Satoshi-Bold.woff', + weight: FontWeight.SemiBold, + style: FontStyle.Normal, + }, + { + font: FontFamily.Satoshi, + url: 'https://fonts.cdnfonts.com/s/85546/Satoshi-LightItalic.woff', + weight: FontWeight.Light, + style: FontStyle.Italic, + }, + { + font: FontFamily.Satoshi, + url: 'https://fonts.cdnfonts.com/s/85546/Satoshi-Italic.woff', + weight: FontWeight.Regular, + style: FontStyle.Italic, + }, + { + font: FontFamily.Satoshi, + url: 'https://fonts.cdnfonts.com/s/85546/Satoshi-BoldItalic.woff', + weight: FontWeight.SemiBold, + style: FontStyle.Italic, + }, + // Poppins, https://fonts.cdnfonts.com/css/poppins?styles=20394,20389,20390,20391,20395,20396 + { + font: FontFamily.Poppins, + url: 'https://fonts.cdnfonts.com/s/16009/Poppins-Light.woff', + weight: FontWeight.Light, + style: FontStyle.Normal, + }, + { + font: FontFamily.Poppins, + url: 'https://fonts.cdnfonts.com/s/16009/Poppins-Regular.woff', + weight: FontWeight.Regular, + style: FontStyle.Normal, + }, + { + font: FontFamily.Poppins, + url: 'https://fonts.cdnfonts.com/s/16009/Poppins-Medium.woff', + weight: FontWeight.Medium, + style: FontStyle.Normal, + }, + { + font: FontFamily.Poppins, + url: 'https://fonts.cdnfonts.com/s/16009/Poppins-SemiBold.woff', + weight: FontWeight.SemiBold, + style: FontStyle.Normal, + }, + { + font: FontFamily.Poppins, + url: 'https://fonts.cdnfonts.com/s/16009/Poppins-LightItalic.woff', + weight: FontWeight.Light, + style: FontStyle.Italic, + }, + { + font: FontFamily.Poppins, + url: 'https://fonts.cdnfonts.com/s/16009/Poppins-Italic.woff', + weight: FontWeight.Regular, + style: FontStyle.Italic, + }, + { + font: FontFamily.Poppins, + url: 'https://fonts.cdnfonts.com/s/16009/Poppins-SemiBoldItalic.woff', + weight: FontWeight.SemiBold, + style: FontStyle.Italic, + }, + // Lora, https://fonts.cdnfonts.com/css/lora-4?styles=50357,50356,50354,50355 + { + font: FontFamily.Lora, + url: 'https://fonts.cdnfonts.com/s/29883/Lora-Regular.woff', + weight: FontWeight.Regular, + style: FontStyle.Normal, + }, + { + font: FontFamily.Lora, + url: 'https://fonts.cdnfonts.com/s/29883/Lora-Bold.woff', + weight: FontWeight.SemiBold, + style: FontStyle.Normal, + }, + { + font: FontFamily.Lora, + url: 'https://fonts.cdnfonts.com/s/29883/Lora-Italic.woff', + weight: FontWeight.Regular, + style: FontStyle.Italic, + }, + { + font: FontFamily.Lora, + url: 'https://fonts.cdnfonts.com/s/29883/Lora-BoldItalic.woff', + weight: FontWeight.SemiBold, + style: FontStyle.Italic, + }, + // BebasNeue, https://fonts.cdnfonts.com/css/bebas-neue?styles=169713,17622,17620 + { + font: FontFamily.BebasNeue, + url: 'https://fonts.cdnfonts.com/s/14902/BebasNeue%20Light.woff', + weight: FontWeight.Light, + style: FontStyle.Normal, + }, + { + font: FontFamily.BebasNeue, + url: 'https://fonts.cdnfonts.com/s/14902/BebasNeue-Regular.woff', + weight: FontWeight.Regular, + style: FontStyle.Normal, + }, + // OrelegaOne, https://fonts.cdnfonts.com/css/orelega-one?styles=148618 + { + font: FontFamily.OrelegaOne, + url: 'https://fonts.cdnfonts.com/s/93179/OrelegaOne-Regular.woff', + weight: FontWeight.Regular, + style: FontStyle.Normal, + }, +]; diff --git a/blocksuite/affine/shared/src/services/font-loader/font-loader-service.ts b/blocksuite/affine/shared/src/services/font-loader/font-loader-service.ts new file mode 100644 index 0000000000..01384c57c1 --- /dev/null +++ b/blocksuite/affine/shared/src/services/font-loader/font-loader-service.ts @@ -0,0 +1,61 @@ +import { type ExtensionType, LifeCycleWatcher } from '@blocksuite/block-std'; +import { createIdentifier } from '@blocksuite/global/di'; +import { IS_FIREFOX } from '@blocksuite/global/env'; + +import type { FontConfig } from './config.js'; + +const initFontFace = IS_FIREFOX + ? ({ font, weight, url, style }: FontConfig) => + new FontFace(`"${font}"`, `url(${url})`, { + weight, + style, + }) + : ({ font, weight, url, style }: FontConfig) => + new FontFace(font, `url(${url})`, { + weight, + style, + }); + +export class FontLoaderService extends LifeCycleWatcher { + static override readonly key = 'font-loader'; + + readonly fontFaces: FontFace[] = []; + + get ready() { + return Promise.all(this.fontFaces.map(fontFace => fontFace.loaded)); + } + + load(fonts: FontConfig[]) { + this.fontFaces.push( + ...fonts.map(font => { + const fontFace = initFontFace(font); + document.fonts.add(fontFace); + fontFace.load().catch(console.error); + return fontFace; + }) + ); + } + + override mounted() { + const config = this.std.getOptional(FontConfigIdentifier); + if (config) { + this.load(config); + } + } + + override unmounted() { + this.fontFaces.forEach(fontFace => document.fonts.delete(fontFace)); + this.fontFaces.splice(0, this.fontFaces.length); + } +} + +export const FontConfigIdentifier = + createIdentifier('AffineFontConfig'); + +export const FontConfigExtension = ( + fontConfig: FontConfig[] +): ExtensionType => ({ + setup: di => { + di.addImpl(FontConfigIdentifier, () => fontConfig); + }, +}); diff --git a/blocksuite/affine/shared/src/services/font-loader/index.ts b/blocksuite/affine/shared/src/services/font-loader/index.ts new file mode 100644 index 0000000000..4dbf3939b1 --- /dev/null +++ b/blocksuite/affine/shared/src/services/font-loader/index.ts @@ -0,0 +1,2 @@ +export * from './config.js'; +export * from './font-loader-service.js'; diff --git a/blocksuite/affine/shared/src/services/generate-url-service.ts b/blocksuite/affine/shared/src/services/generate-url-service.ts new file mode 100644 index 0000000000..b07d3298b4 --- /dev/null +++ b/blocksuite/affine/shared/src/services/generate-url-service.ts @@ -0,0 +1,21 @@ +import type { ReferenceParams } from '@blocksuite/affine-model'; +import type { ExtensionType } from '@blocksuite/block-std'; +import { createIdentifier } from '@blocksuite/global/di'; + +export interface GenerateDocUrlService { + generateDocUrl: (docId: string, params?: ReferenceParams) => string | void; +} + +export const GenerateDocUrlProvider = createIdentifier( + 'GenerateDocUrlService' +); + +export function GenerateDocUrlExtension( + generateDocUrlProvider: GenerateDocUrlService +): ExtensionType { + return { + setup: di => { + di.addImpl(GenerateDocUrlProvider, generateDocUrlProvider); + }, + }; +} diff --git a/blocksuite/affine/shared/src/services/index.ts b/blocksuite/affine/shared/src/services/index.ts new file mode 100644 index 0000000000..d6919eaad3 --- /dev/null +++ b/blocksuite/affine/shared/src/services/index.ts @@ -0,0 +1,13 @@ +export * from './doc-display-meta-service.js'; +export * from './doc-mode-service.js'; +export * from './drag-handle-config.js'; +export * from './edit-props-store.js'; +export * from './editor-setting-service.js'; +export * from './embed-option-service.js'; +export * from './font-loader/index.js'; +export * from './generate-url-service.js'; +export * from './notification-service.js'; +export * from './parse-url-service.js'; +export * from './quick-search-service.js'; +export * from './telemetry-service/index.js'; +export * from './theme-service.js'; diff --git a/blocksuite/affine/shared/src/services/notification-service.ts b/blocksuite/affine/shared/src/services/notification-service.ts new file mode 100644 index 0000000000..f491c8936b --- /dev/null +++ b/blocksuite/affine/shared/src/services/notification-service.ts @@ -0,0 +1,55 @@ +import type { ExtensionType } from '@blocksuite/block-std'; +import { createIdentifier } from '@blocksuite/global/di'; +import type { TemplateResult } from 'lit'; + +export interface NotificationService { + toast( + message: string, + options?: { + duration?: number; + portal?: HTMLElement; + } + ): void; + confirm(options: { + title: string | TemplateResult; + message: string | TemplateResult; + confirmText?: string; + cancelText?: string; + abort?: AbortSignal; + }): Promise; + prompt(options: { + title: string | TemplateResult; + message: string | TemplateResult; + autofill?: string; + placeholder?: string; + confirmText?: string; + cancelText?: string; + abort?: AbortSignal; + }): Promise; // when cancel, return null + notify(options: { + title: string | TemplateResult; + message?: string | TemplateResult; + accent?: 'info' | 'success' | 'warning' | 'error'; + duration?: number; // unit ms, give 0 to disable auto dismiss + abort?: AbortSignal; + action?: { + label: string | TemplateResult; + onClick: () => void; + }; + onClose: () => void; + }): void; +} + +export const NotificationProvider = createIdentifier( + 'AffineNotificationService' +); + +export function NotificationExtension( + notificationService: NotificationService +): ExtensionType { + return { + setup: di => { + di.addImpl(NotificationProvider, notificationService); + }, + }; +} diff --git a/blocksuite/affine/shared/src/services/parse-url-service.ts b/blocksuite/affine/shared/src/services/parse-url-service.ts new file mode 100644 index 0000000000..848b4f1d60 --- /dev/null +++ b/blocksuite/affine/shared/src/services/parse-url-service.ts @@ -0,0 +1,22 @@ +import type { ReferenceParams } from '@blocksuite/affine-model'; +import type { ExtensionType } from '@blocksuite/block-std'; +import { createIdentifier } from '@blocksuite/global/di'; + +export interface ParseDocUrlService { + parseDocUrl: ( + url: string + ) => ({ docId: string } & ReferenceParams) | undefined; +} + +export const ParseDocUrlProvider = + createIdentifier('ParseDocUrlService'); + +export function ParseDocUrlExtension( + parseDocUrlService: ParseDocUrlService +): ExtensionType { + return { + setup: di => { + di.addImpl(ParseDocUrlProvider, parseDocUrlService); + }, + }; +} diff --git a/blocksuite/affine/shared/src/services/quick-search-service.ts b/blocksuite/affine/shared/src/services/quick-search-service.ts new file mode 100644 index 0000000000..49e67cbb53 --- /dev/null +++ b/blocksuite/affine/shared/src/services/quick-search-service.ts @@ -0,0 +1,31 @@ +import type { ReferenceParams } from '@blocksuite/affine-model'; +import type { ExtensionType } from '@blocksuite/block-std'; +import { createIdentifier } from '@blocksuite/global/di'; + +export interface QuickSearchService { + openQuickSearch: () => Promise; +} + +export type QuickSearchResult = + | { + docId: string; + params?: ReferenceParams; + } + | { + externalUrl: string; + } + | null; + +export const QuickSearchProvider = createIdentifier( + 'AffineQuickSearchService' +); + +export function QuickSearchExtension( + quickSearchService: QuickSearchService +): ExtensionType { + return { + setup: di => { + di.addImpl(QuickSearchProvider, quickSearchService); + }, + }; +} diff --git a/blocksuite/affine/shared/src/services/telemetry-service/database.ts b/blocksuite/affine/shared/src/services/telemetry-service/database.ts new file mode 100644 index 0000000000..a859230807 --- /dev/null +++ b/blocksuite/affine/shared/src/services/telemetry-service/database.ts @@ -0,0 +1,56 @@ +type OrderType = 'desc' | 'asc'; +export type WithParams = { [K in keyof Map]: Map[K] & T }; +export type SortParams = { + fieldId: string; + fieldType: string; + orderType: OrderType; + orderIndex: number; +}; +export type ViewParams = { + viewId: string; + viewType: string; +}; +export type DatabaseParams = { + blockId: string; +}; + +export type DatabaseViewEvents = { + DatabaseSortClear: { + rulesCount: number; + }; +}; + +export type DatabaseEvents = { + AddDatabase: {}; +}; + +export interface DatabaseAllSortEvents { + DatabaseSortAdd: {}; + DatabaseSortRemove: {}; + DatabaseSortModify: { + oldOrderType: OrderType; + oldFieldType: string; + oldFieldId: string; + }; + DatabaseSortReorder: { + prevFieldType: string; + nextFieldType: string; + newOrderIndex: number; + }; +} + +export type DatabaseAllViewEvents = DatabaseViewEvents & + WithParams; + +export type DatabaseAllEvents = DatabaseEvents & + WithParams; + +export type OutDatabaseAllEvents = WithParams< + DatabaseAllEvents, + DatabaseParams +>; + +export type EventTraceFn = ( + key: K, + params: Events[K] +) => void; diff --git a/blocksuite/affine/shared/src/services/telemetry-service/index.ts b/blocksuite/affine/shared/src/services/telemetry-service/index.ts new file mode 100644 index 0000000000..1c6241ff04 --- /dev/null +++ b/blocksuite/affine/shared/src/services/telemetry-service/index.ts @@ -0,0 +1,4 @@ +export * from './database.js'; +export * from './link.js'; +export * from './telemetry-service.js'; +export * from './types.js'; diff --git a/blocksuite/affine/shared/src/services/telemetry-service/link.ts b/blocksuite/affine/shared/src/services/telemetry-service/link.ts new file mode 100644 index 0000000000..7eb14d532d --- /dev/null +++ b/blocksuite/affine/shared/src/services/telemetry-service/link.ts @@ -0,0 +1,16 @@ +import type { TelemetryEvent } from './types.js'; + +export type LinkEventType = + | 'CopiedLink' + | 'OpenedAliasPopup' + | 'SavedAlias' + | 'ResetedAlias' + | 'OpenedViewSelector' + | 'SelectedView' + | 'OpenedCaptionEditor' + | 'OpenedCardStyleSelector' + | 'SelectedCardStyle' + | 'OpenedCardScaleSelector' + | 'SelectedCardScale'; + +export type LinkToolbarEvents = Record; diff --git a/blocksuite/affine/shared/src/services/telemetry-service/telemetry-service.ts b/blocksuite/affine/shared/src/services/telemetry-service/telemetry-service.ts new file mode 100644 index 0000000000..fd1d4d0122 --- /dev/null +++ b/blocksuite/affine/shared/src/services/telemetry-service/telemetry-service.ts @@ -0,0 +1,35 @@ +import { createIdentifier } from '@blocksuite/global/di'; + +import type { OutDatabaseAllEvents } from './database.js'; +import type { LinkToolbarEvents } from './link.js'; +import type { + AttachmentUploadedEvent, + DocCreatedEvent, + ElementCreationEvent, + ElementLockEvent, + MindMapCollapseEvent, + TelemetryEvent, +} from './types.js'; + +export type TelemetryEventMap = OutDatabaseAllEvents & + LinkToolbarEvents & { + DocCreated: DocCreatedEvent; + Link: TelemetryEvent; + LinkedDocCreated: TelemetryEvent; + SplitNote: TelemetryEvent; + CanvasElementAdded: ElementCreationEvent; + EdgelessElementLocked: ElementLockEvent; + ExpandedAndCollapsed: MindMapCollapseEvent; + AttachmentUploadedEvent: AttachmentUploadedEvent; + }; + +export interface TelemetryService { + track( + eventName: T, + props: TelemetryEventMap[T] + ): void; +} + +export const TelemetryProvider = createIdentifier( + 'AffineTelemetryService' +); diff --git a/blocksuite/affine/shared/src/services/telemetry-service/types.ts b/blocksuite/affine/shared/src/services/telemetry-service/types.ts new file mode 100644 index 0000000000..553dfa351c --- /dev/null +++ b/blocksuite/affine/shared/src/services/telemetry-service/types.ts @@ -0,0 +1,63 @@ +export type ElementCreationSource = + | 'shortcut' + | 'toolbar:general' + | 'toolbar:dnd' + | 'canvas:drop' + | 'canvas:draw' + | 'canvas:dbclick' + | 'canvas:paste' + | 'context-menu' + | 'ai' + | 'internal' + | 'conversation' + | 'manually save'; + +export interface TelemetryEvent { + page?: string; + segment?: string; + module?: string; + control?: string; + type?: string; + category?: string; + other?: unknown; +} + +export interface DocCreatedEvent extends TelemetryEvent { + page?: 'doc editor' | 'whiteboard editor'; + segment?: 'whiteboard' | 'note' | 'doc'; + module?: + | 'slash commands' + | 'format toolbar' + | 'edgeless toolbar' + | 'inline @'; + category?: 'page' | 'whiteboard'; +} + +export interface ElementCreationEvent extends TelemetryEvent { + segment?: 'toolbar' | 'whiteboard' | 'right sidebar'; + page?: 'doc editor' | 'whiteboard editor'; + module?: 'toolbar' | 'canvas' | 'ai chat panel'; + control?: ElementCreationSource; +} + +export interface ElementLockEvent extends TelemetryEvent { + page: 'whiteboard editor'; + segment: 'element toolbar'; + module: 'element toolbar'; + control: 'lock' | 'unlock' | 'group-lock'; +} + +export interface MindMapCollapseEvent extends TelemetryEvent { + page: 'whiteboard editor'; + segment: 'mind map'; + type: 'expand' | 'collapse'; +} + +export interface AttachmentUploadedEvent extends TelemetryEvent { + page: 'doc editor' | 'whiteboard editor'; + segment: 'attachment'; + module: 'attachment'; + control: 'uploader'; + type: string; // file type + category: 'success' | 'failure'; +} diff --git a/blocksuite/affine/shared/src/services/theme-service.ts b/blocksuite/affine/shared/src/services/theme-service.ts new file mode 100644 index 0000000000..734ea3164b --- /dev/null +++ b/blocksuite/affine/shared/src/services/theme-service.ts @@ -0,0 +1,207 @@ +import { type Color, ColorScheme } from '@blocksuite/affine-model'; +import { + type BlockStdScope, + Extension, + type ExtensionType, + StdIdentifier, +} from '@blocksuite/block-std'; +import { type Container, createIdentifier } from '@blocksuite/global/di'; +import { type Signal, signal } from '@preact/signals-core'; +import { + type AffineCssVariables, + combinedDarkCssVariables, + combinedLightCssVariables, +} from '@toeverything/theme'; + +import { isInsideEdgelessEditor } from '../utils/index.js'; + +const TRANSPARENT = 'transparent'; + +export const ThemeExtensionIdentifier = createIdentifier( + 'AffineThemeExtension' +); + +export interface ThemeExtension { + getAppTheme?: () => Signal; + getEdgelessTheme?: (docId?: string) => Signal; +} + +export function OverrideThemeExtension(service: ThemeExtension): ExtensionType { + return { + setup: di => { + di.override(ThemeExtensionIdentifier, () => service); + }, + }; +} + +export const ThemeProvider = createIdentifier( + 'AffineThemeProvider' +); + +export class ThemeService extends Extension { + app$: Signal; + + edgeless$: Signal; + + get appTheme() { + return this.app$.peek(); + } + + get edgelessTheme() { + return this.edgeless$.peek(); + } + + get theme() { + return isInsideEdgelessEditor(this.std.host) + ? this.edgelessTheme + : this.appTheme; + } + + get theme$() { + return isInsideEdgelessEditor(this.std.host) ? this.edgeless$ : this.app$; + } + + constructor(private std: BlockStdScope) { + super(); + const extension = this.std.getOptional(ThemeExtensionIdentifier); + this.app$ = extension?.getAppTheme?.() || getThemeObserver().theme$; + this.edgeless$ = + extension?.getEdgelessTheme?.(this.std.doc.id) || + getThemeObserver().theme$; + } + + static override setup(di: Container) { + di.addImpl(ThemeProvider, ThemeService, [StdIdentifier]); + } + + /** + * Generates a CSS's color property with `var` or `light-dark` functions. + * + * Sometimes used to set the frame/note background. + * + * @param color - A color value. + * @param fallback - If color value processing fails, it will be used as a fallback. + * @returns - A color property string. + * + * @example + * + * ``` + * `rgba(255,0,0)` + * `#fff` + * `light-dark(#fff, #000)` + * `var(--affine-palette-shape-blue)` + * ``` + */ + generateColorProperty( + color: Color, + fallback = 'transparent', + theme = this.theme + ) { + let result: string | undefined = undefined; + + if (typeof color === 'object') { + result = color[theme] ?? color.normal; + } else { + result = color; + } + if (!result) { + result = fallback; + } + if (result.startsWith('--')) { + return result.endsWith(TRANSPARENT) ? TRANSPARENT : `var(${result})`; + } + + return result ?? TRANSPARENT; + } + + /** + * Gets a color with the current theme. + * + * @param color - A color value. + * @param fallback - If color value processing fails, it will be used as a fallback. + * @param real - If true, it returns the computed style. + * @returns - A color property string. + * + * @example + * + * ``` + * `rgba(255,0,0)` + * `#fff` + * `--affine-palette-shape-blue` + * ``` + */ + getColorValue( + color: Color, + fallback = TRANSPARENT, + real = false, + theme = this.theme + ) { + let result: string | undefined = undefined; + + if (typeof color === 'object') { + result = color[theme] ?? color.normal; + } else { + result = color; + } + if (!result) { + result = fallback; + } + if (real && result.startsWith('--')) { + result = result.endsWith(TRANSPARENT) + ? TRANSPARENT + : this.getCssVariableColor(result, theme); + } + + return result ?? TRANSPARENT; + } + + getCssVariableColor(property: string, theme = this.theme) { + if (property.startsWith('--')) { + if (property.endsWith(TRANSPARENT)) { + return TRANSPARENT; + } + const key = property as keyof AffineCssVariables; + const color = + theme === ColorScheme.Dark + ? combinedDarkCssVariables[key] + : combinedLightCssVariables[key]; + return color; + } + return property; + } +} + +export class ThemeObserver { + private observer: MutationObserver; + + theme$ = signal(ColorScheme.Light); + + constructor() { + const COLOR_SCHEMES: string[] = Object.values(ColorScheme); + this.observer = new MutationObserver(() => { + const mode = document.documentElement.dataset.theme; + if (!mode) return; + if (!COLOR_SCHEMES.includes(mode)) return; + if (mode === this.theme$.value) return; + + this.theme$.value = mode as ColorScheme; + }); + this.observer.observe(document.documentElement, { + attributes: true, + attributeFilter: ['data-theme'], + }); + } + + destroy() { + this.observer.disconnect(); + } +} + +export const getThemeObserver = (function () { + let observer: ThemeObserver; + return function () { + if (observer) return observer; + observer = new ThemeObserver(); + return observer; + }; +})(); diff --git a/blocksuite/affine/shared/src/styles/font.ts b/blocksuite/affine/shared/src/styles/font.ts new file mode 100644 index 0000000000..2d6d86d63a --- /dev/null +++ b/blocksuite/affine/shared/src/styles/font.ts @@ -0,0 +1,24 @@ +import { baseTheme } from '@toeverything/theme'; +import { unsafeCSS } from 'lit'; + +export const FONT_BASE = unsafeCSS(` + font-family: ${baseTheme.fontSansFamily}; + font-feature-settings: + 'clig' off, + 'liga' off; + font-style: normal; +`); + +export const FONT_SM = unsafeCSS(` + ${FONT_BASE}; + font-size: var(--affine-font-sm); + font-weight: 500; + line-height: 22px; +`); + +export const FONT_XS = unsafeCSS(` + ${FONT_BASE}; + font-size: var(--affine-font-xs); + font-weight: 500; + line-height: 20px; +`); diff --git a/blocksuite/affine/shared/src/styles/index.ts b/blocksuite/affine/shared/src/styles/index.ts new file mode 100644 index 0000000000..0a3a2e8721 --- /dev/null +++ b/blocksuite/affine/shared/src/styles/index.ts @@ -0,0 +1,2 @@ +export { FONT_BASE, FONT_SM, FONT_XS } from './font.js'; +export { PANEL_BASE, PANEL_BASE_COLORS } from './panel.js'; diff --git a/blocksuite/affine/shared/src/styles/panel.ts b/blocksuite/affine/shared/src/styles/panel.ts new file mode 100644 index 0000000000..9c17627632 --- /dev/null +++ b/blocksuite/affine/shared/src/styles/panel.ts @@ -0,0 +1,23 @@ +import { cssVarV2 } from '@toeverything/theme/v2'; +import { unsafeCSS } from 'lit'; + +import { FONT_SM } from './font.js'; + +export const PANEL_BASE_COLORS = unsafeCSS(` + color: var(--affine-icon-color); + box-shadow: var(--affine-overlay-shadow); + background: ${cssVarV2('layer/background/overlayPanel')}; +`); + +export const PANEL_BASE = unsafeCSS(` + display: flex; + align-items: center; + gap: 8px; + width: max-content; + padding: 0 6px; + border-radius: 4px; + border: 0.5px solid ${cssVarV2('layer/insideBorder/border')}; + + ${PANEL_BASE_COLORS}; + ${FONT_SM}; +`); diff --git a/blocksuite/affine/shared/src/theme/css-variables.ts b/blocksuite/affine/shared/src/theme/css-variables.ts new file mode 100644 index 0000000000..c8f417fc41 --- /dev/null +++ b/blocksuite/affine/shared/src/theme/css-variables.ts @@ -0,0 +1,132 @@ +/* CSS variables. You need to handle all places where `CSS variables` are marked. */ + +import { LINE_COLORS, SHAPE_FILL_COLORS } from '@blocksuite/affine-model'; +import { + type AffineCssVariables, + type AffineTheme, + cssVar, +} from '@toeverything/theme'; +import { type AffineThemeKeyV2, cssVarV2 } from '@toeverything/theme/v2'; +import { unsafeCSS } from 'lit'; + +export const ColorVariables = [ + '--affine-brand-color', + '--affine-primary-color', + '--affine-secondary-color', + '--affine-tertiary-color', + '--affine-hover-color', + '--affine-icon-color', + '--affine-icon-secondary', + '--affine-border-color', + '--affine-divider-color', + '--affine-placeholder-color', + '--affine-quote-color', + '--affine-link-color', + '--affine-edgeless-grid-color', + '--affine-success-color', + '--affine-warning-color', + '--affine-error-color', + '--affine-processing-color', + '--affine-text-emphasis-color', + '--affine-text-primary-color', + '--affine-text-secondary-color', + '--affine-text-disable-color', + '--affine-black-10', + '--affine-black-30', + '--affine-black-50', + '--affine-black-60', + '--affine-black-80', + '--affine-black-90', + '--affine-black', + '--affine-white-10', + '--affine-white-30', + '--affine-white-50', + '--affine-white-60', + '--affine-white-80', + '--affine-white-90', + '--affine-white', + '--affine-background-code-block', + '--affine-background-tertiary-color', + '--affine-background-processing-color', + '--affine-background-error-color', + '--affine-background-warning-color', + '--affine-background-success-color', + '--affine-background-primary-color', + '--affine-background-secondary-color', + '--affine-background-modal-color', + '--affine-background-overlay-panel-color', + '--affine-tag-blue', + '--affine-tag-green', + '--affine-tag-teal', + '--affine-tag-white', + '--affine-tag-purple', + '--affine-tag-red', + '--affine-tag-pink', + '--affine-tag-yellow', + '--affine-tag-orange', + '--affine-tag-gray', + ...LINE_COLORS, + ...SHAPE_FILL_COLORS, + '--affine-tooltip', + '--affine-blue', +]; + +export const SizeVariables = [ + '--affine-font-h-1', + '--affine-font-h-2', + '--affine-font-h-3', + '--affine-font-h-4', + '--affine-font-h-5', + '--affine-font-h-6', + '--affine-font-base', + '--affine-font-sm', + '--affine-font-xs', + '--affine-line-height', + '--affine-z-index-modal', + '--affine-z-index-popover', +]; + +export const FontFamilyVariables = [ + '--affine-font-family', + '--affine-font-number-family', + '--affine-font-code-family', +]; + +export const StyleVariables = [ + '--affine-editor-width', + + '--affine-theme-mode', + '--affine-editor-mode', + /* --affine-palette-transparent: special values added for the sake of logical consistency. */ + '--affine-palette-transparent', + + '--affine-popover-shadow', + '--affine-menu-shadow', + '--affine-float-button-shadow', + '--affine-shadow-1', + '--affine-shadow-2', + '--affine-shadow-3', + + '--affine-paragraph-space', + '--affine-popover-radius', + '--affine-scale', + ...SizeVariables, + ...ColorVariables, + ...FontFamilyVariables, +] as const; + +type VariablesType = typeof StyleVariables; +export type CssVariableName = Extract< + VariablesType[keyof VariablesType], + string +>; + +export type CssVariablesMap = Record; + +export const unsafeCSSVar = ( + key: keyof AffineCssVariables | keyof AffineTheme, + fallback?: string +) => unsafeCSS(cssVar(key, fallback)); + +export const unsafeCSSVarV2 = (key: AffineThemeKeyV2, fallback?: string) => + unsafeCSS(cssVarV2(key, fallback)); diff --git a/blocksuite/affine/shared/src/theme/index.ts b/blocksuite/affine/shared/src/theme/index.ts new file mode 100644 index 0000000000..48ddb6d474 --- /dev/null +++ b/blocksuite/affine/shared/src/theme/index.ts @@ -0,0 +1 @@ +export * from './css-variables.js'; diff --git a/blocksuite/affine/shared/src/types/index.ts b/blocksuite/affine/shared/src/types/index.ts new file mode 100644 index 0000000000..aa33d3d815 --- /dev/null +++ b/blocksuite/affine/shared/src/types/index.ts @@ -0,0 +1,73 @@ +import type { EmbedCardStyle, ReferenceInfo } from '@blocksuite/affine-model'; +import type { BlockComponent } from '@blocksuite/block-std'; +import type { BlockModel } from '@blocksuite/store'; + +export interface EditingState { + element: BlockComponent; + model: BlockModel; + rect: DOMRect; +} + +export enum LassoMode { + FreeHand, + Polygonal, +} + +export type NoteChildrenFlavour = + | 'affine:paragraph' + | 'affine:list' + | 'affine:code' + | 'affine:divider' + | 'affine:database' + | 'affine:data-view' + | 'affine:image' + | 'affine:bookmark' + | 'affine:attachment' + | 'affine:surface-ref'; + +export interface Viewport { + left: number; + top: number; + scrollLeft: number; + scrollTop: number; + scrollWidth: number; + scrollHeight: number; + clientWidth: number; + clientHeight: number; +} + +export type ExtendedModel = BlockModel & Record; + +export type EmbedOptions = { + flavour: string; + urlRegex: RegExp; + styles: EmbedCardStyle[]; + viewType: 'card' | 'embed'; +}; + +export type IndentContext = { + blockId: string; + inlineIndex: number; + flavour: Extract< + keyof BlockSuite.BlockModels, + 'affine:paragraph' | 'affine:list' + >; + type: 'indent' | 'dedent'; +}; + +export interface AffineTextAttributes { + bold?: true | null; + italic?: true | null; + underline?: true | null; + strike?: true | null; + code?: true | null; + link?: string | null; + reference?: + | ({ + type: 'Subpage' | 'LinkedPage'; + } & ReferenceInfo) + | null; + background?: string | null; + color?: string | null; + latex?: string | null; +} diff --git a/blocksuite/affine/shared/src/utils/button-popper.ts b/blocksuite/affine/shared/src/utils/button-popper.ts new file mode 100644 index 0000000000..2d24e95316 --- /dev/null +++ b/blocksuite/affine/shared/src/utils/button-popper.ts @@ -0,0 +1,159 @@ +import type { Disposable } from '@blocksuite/global/utils'; +import { + autoPlacement, + autoUpdate, + computePosition, + offset, + type Rect, + shift, + size, +} from '@floating-ui/dom'; + +export function listenClickAway( + element: HTMLElement, + onClickAway: () => void +): Disposable { + const callback = (event: MouseEvent) => { + const inside = event.composedPath().includes(element); + if (!inside) { + onClickAway(); + } + }; + + document.addEventListener('click', callback); + + return { + dispose: () => { + document.removeEventListener('click', callback); + }, + }; +} + +type Display = 'show' | 'hidden'; + +const ATTR_SHOW = 'data-show'; +/** + * Using attribute 'data-show' to control popper visibility. + * + * ```css + * selector { + * display: none; + * } + * selector[data-show] { + * display: block; + * } + * ``` + */ +export function createButtonPopper( + reference: HTMLElement, + popperElement: HTMLElement, + stateUpdated: (state: { display: Display }) => void = () => { + /** DEFAULT EMPTY FUNCTION */ + }, + { + mainAxis, + crossAxis, + rootBoundary, + ignoreShift, + }: { + mainAxis?: number; + crossAxis?: number; + rootBoundary?: Rect | (() => Rect | undefined); + ignoreShift?: boolean; + } = {} +) { + let display: Display = 'hidden'; + let cleanup: (() => void) | void; + + const originMaxHeight = window.getComputedStyle(popperElement).maxHeight; + + function compute() { + const overflowOptions = { + rootBoundary: + typeof rootBoundary === 'function' ? rootBoundary() : rootBoundary, + }; + + computePosition(reference, popperElement, { + middleware: [ + offset({ + mainAxis: mainAxis ?? 14, + crossAxis: crossAxis ?? 0, + }), + autoPlacement({ + allowedPlacements: ['top', 'bottom'], + ...overflowOptions, + }), + shift(overflowOptions), + size({ + ...overflowOptions, + apply({ availableHeight }) { + popperElement.style.maxHeight = originMaxHeight + ? `min(${originMaxHeight}, ${availableHeight}px)` + : `${availableHeight}px`; + }, + }), + ], + }) + .then(({ x, y, middlewareData: data }) => { + if (!ignoreShift) { + x += data.shift?.x ?? 0; + y += data.shift?.y ?? 0; + } + Object.assign(popperElement.style, { + position: 'absolute', + zIndex: 1, + left: `${x}px`, + top: `${y}px`, + }); + }) + .catch(console.error); + } + + const show = (force = false) => { + const displayed = display === 'show'; + + if (displayed && !force) return; + + if (!displayed) { + popperElement.setAttribute(ATTR_SHOW, ''); + display = 'show'; + stateUpdated({ display }); + } + + cleanup?.(); + cleanup = autoUpdate(reference, popperElement, compute, { + animationFrame: true, + }); + }; + + const hide = () => { + if (display === 'hidden') return; + popperElement.removeAttribute(ATTR_SHOW); + display = 'hidden'; + stateUpdated({ display }); + cleanup?.(); + }; + + const toggle = () => { + if (popperElement.hasAttribute(ATTR_SHOW)) { + hide(); + } else { + show(); + } + }; + + const clickAway = listenClickAway(reference, () => hide()); + + return { + get state() { + return display; + }, + show, + hide, + toggle, + dispose: () => { + cleanup?.(); + clickAway.dispose(); + }, + }; +} diff --git a/blocksuite/affine/shared/src/utils/collapsed/index.ts b/blocksuite/affine/shared/src/utils/collapsed/index.ts new file mode 100644 index 0000000000..bd9fb8b5de --- /dev/null +++ b/blocksuite/affine/shared/src/utils/collapsed/index.ts @@ -0,0 +1 @@ +export * from './paragraph.js'; diff --git a/blocksuite/affine/shared/src/utils/collapsed/paragraph.ts b/blocksuite/affine/shared/src/utils/collapsed/paragraph.ts new file mode 100644 index 0000000000..37caec3686 --- /dev/null +++ b/blocksuite/affine/shared/src/utils/collapsed/paragraph.ts @@ -0,0 +1,57 @@ +import type { ParagraphBlockModel } from '@blocksuite/affine-model'; +import type { BlockModel } from '@blocksuite/store'; + +import { matchFlavours } from '../model/checker.js'; + +export function calculateCollapsedSiblings( + model: ParagraphBlockModel +): BlockModel[] { + const parent = model.parent; + if (!parent) return []; + const children = parent.children; + const index = children.indexOf(model); + if (index === -1) return []; + + const collapsedEdgeIndex = children.findIndex((child, i) => { + if ( + i > index && + matchFlavours(child, ['affine:paragraph']) && + child.type.startsWith('h') + ) { + const modelLevel = parseInt(model.type.slice(1)); + const childLevel = parseInt(child.type.slice(1)); + return childLevel <= modelLevel; + } + return false; + }); + + let collapsedSiblings: BlockModel[]; + if (collapsedEdgeIndex === -1) { + collapsedSiblings = children.slice(index + 1); + } else { + collapsedSiblings = children.slice(index + 1, collapsedEdgeIndex); + } + + return collapsedSiblings; +} + +export function getNearestHeadingBefore( + model: BlockModel +): ParagraphBlockModel | null { + const parent = model.parent; + if (!parent) return null; + const index = parent.children.indexOf(model); + if (index === -1) return null; + + for (let i = index - 1; i >= 0; i--) { + const sibling = parent.children[i]; + if ( + matchFlavours(sibling, ['affine:paragraph']) && + sibling.type.startsWith('h') + ) { + return sibling; + } + } + + return null; +} diff --git a/blocksuite/affine/shared/src/utils/dnd/index.ts b/blocksuite/affine/shared/src/utils/dnd/index.ts new file mode 100644 index 0000000000..d8f8db3616 --- /dev/null +++ b/blocksuite/affine/shared/src/utils/dnd/index.ts @@ -0,0 +1 @@ +export * from './legacy.js'; diff --git a/blocksuite/affine/shared/src/utils/dnd/legacy.ts b/blocksuite/affine/shared/src/utils/dnd/legacy.ts new file mode 100644 index 0000000000..e77a5f6641 --- /dev/null +++ b/blocksuite/affine/shared/src/utils/dnd/legacy.ts @@ -0,0 +1,175 @@ +import type { + EmbedCardStyle, + EmbedSyncedDocModel, +} from '@blocksuite/affine-model'; +import type { BlockComponent } from '@blocksuite/block-std'; +import { GfxControllerIdentifier } from '@blocksuite/block-std/gfx'; +import { assertExists, Bound } from '@blocksuite/global/utils'; +import type { BlockModel } from '@blocksuite/store'; + +import type { OnDragEndProps } from '../../services/index.js'; +import { getBlockProps } from '../model/index.js'; + +function isEmbedSyncedDocBlock( + element: BlockModel | BlockSuite.EdgelessModel | null +): element is EmbedSyncedDocModel { + return ( + !!element && + 'flavour' in element && + element.flavour === 'affine:embed-synced-doc' + ); +} + +/** + * @deprecated + * This is a terrible hack to apply the drag preview, + * do not use it. + * We're migrating to a standard drag and drop API. + */ +export function convertDragPreviewDocToEdgeless({ + blockComponent, + dragPreview, + cssSelector, + width, + height, + noteScale, + state, +}: OnDragEndProps & { + blockComponent: BlockComponent; + cssSelector: string; + width?: number; + height?: number; + style?: EmbedCardStyle; +}): boolean { + const edgelessRoot = blockComponent.closest('affine-edgeless-root'); + if (!edgelessRoot) { + return false; + } + + const previewEl = dragPreview.querySelector(cssSelector); + if (!previewEl) { + return false; + } + const rect = previewEl.getBoundingClientRect(); + const border = 2; + const controller = blockComponent.std.get(GfxControllerIdentifier); + const { viewport } = controller; + const { left: viewportLeft, top: viewportTop } = viewport; + const currentViewBound = new Bound( + rect.x - viewportLeft, + rect.y - viewportTop, + rect.width + border / noteScale, + rect.height + border / noteScale + ); + const currentModelBound = viewport.toModelBound(currentViewBound); + + // Except for embed synced doc block + // The width and height of other card style should be fixed + const newBound = isEmbedSyncedDocBlock(blockComponent.model) + ? new Bound( + currentModelBound.x, + currentModelBound.y, + (currentModelBound.w ?? width) * noteScale, + (currentModelBound.h ?? height) * noteScale + ) + : new Bound( + currentModelBound.x, + currentModelBound.y, + (width ?? currentModelBound.w) * noteScale, + (height ?? currentModelBound.h) * noteScale + ); + + const blockModel = blockComponent.model; + const blockProps = getBlockProps(blockModel); + + // @ts-expect-error TODO: fix after edgeless refactor + const blockId = edgelessRoot.service.addBlock( + blockComponent.flavour, + { + ...blockProps, + xywh: newBound.serialize(), + }, + // @ts-expect-error TODO: fix after edgeless refactor + edgelessRoot.surfaceBlockModel + ); + + // Embed synced doc block should extend the note scale + // @ts-expect-error TODO: fix after edgeless refactor + const newBlock = edgelessRoot.service.getElementById(blockId); + if (isEmbedSyncedDocBlock(newBlock)) { + // @ts-expect-error TODO: fix after edgeless refactor + edgelessRoot.service.updateElement(newBlock.id, { + scale: noteScale, + }); + } + + const doc = blockComponent.doc; + const host = blockComponent.host; + const altKey = state.raw.altKey; + if (!altKey) { + doc.deleteBlock(blockModel); + host.selection.setGroup('note', []); + } + + // @ts-expect-error TODO: fix after edgeless refactor + edgelessRoot.service.selection.set({ + elements: [blockId], + editing: false, + }); + + return true; +} + +/** + * @deprecated + * This is a terrible hack to apply the drag preview, + * do not use it. + * We're migrating to a standard drag and drop API. + */ +export function convertDragPreviewEdgelessToDoc({ + blockComponent, + dropBlockId, + dropType, + state, + style, +}: OnDragEndProps & { + blockComponent: BlockComponent; + style?: EmbedCardStyle; +}): boolean { + const doc = blockComponent.doc; + const host = blockComponent.host; + const targetBlock = doc.getBlockById(dropBlockId); + if (!targetBlock) return false; + + const shouldInsertIn = dropType === 'in'; + const parentBlock = shouldInsertIn ? targetBlock : doc.getParent(targetBlock); + assertExists(parentBlock); + const parentIndex = shouldInsertIn + ? 0 + : parentBlock.children.indexOf(targetBlock) + + (dropType === 'after' ? 1 : 0); + + const blockModel = blockComponent.model; + + // eslint-disable-next-line no-unused-vars + const { width, height, xywh, rotate, zIndex, ...blockProps } = + getBlockProps(blockModel); + if (style) { + blockProps.style = style; + } + + doc.addBlock( + blockModel.flavour as never, + blockProps, + parentBlock, + parentIndex + ); + + const altKey = state.raw.altKey; + if (!altKey) { + doc.deleteBlock(blockModel); + host.selection.setGroup('gfx', []); + } + + return true; +} diff --git a/blocksuite/affine/shared/src/utils/dom/checker.ts b/blocksuite/affine/shared/src/utils/dom/checker.ts new file mode 100644 index 0000000000..c06576fb61 --- /dev/null +++ b/blocksuite/affine/shared/src/utils/dom/checker.ts @@ -0,0 +1,15 @@ +import type { EditorHost } from '@blocksuite/block-std'; + +export function isInsidePageEditor(host: EditorHost) { + return Array.from(host.children).some( + v => v.tagName.toLowerCase() === 'affine-page-root' + ); +} + +export function isInsideEdgelessEditor(host: EditorHost) { + return Array.from(host.children).some( + v => + v.tagName.toLowerCase() === 'affine-edgeless-root' || + v.tagName.toLowerCase() === 'affine-edgeless-root-preview' + ); +} diff --git a/blocksuite/affine/shared/src/utils/dom/index.ts b/blocksuite/affine/shared/src/utils/dom/index.ts new file mode 100644 index 0000000000..2c2e1c47ff --- /dev/null +++ b/blocksuite/affine/shared/src/utils/dom/index.ts @@ -0,0 +1,6 @@ +export * from './checker.js'; +export * from './point-to-block.js'; +export * from './point-to-range.js'; +export * from './query.js'; +export * from './scroll-container.js'; +export * from './viewport.js'; diff --git a/blocksuite/affine/shared/src/utils/dom/point-to-block.ts b/blocksuite/affine/shared/src/utils/dom/point-to-block.ts new file mode 100644 index 0000000000..cea1e80411 --- /dev/null +++ b/blocksuite/affine/shared/src/utils/dom/point-to-block.ts @@ -0,0 +1,320 @@ +import { BLOCK_ID_ATTR, type BlockComponent } from '@blocksuite/block-std'; +import type { Point, Rect } from '@blocksuite/global/utils'; + +import { BLOCK_CHILDREN_CONTAINER_PADDING_LEFT } from '../../consts/index.js'; +import { clamp } from '../math.js'; +import { matchFlavours } from '../model/checker.js'; + +const ATTR_SELECTOR = `[${BLOCK_ID_ATTR}]`; + +// margin-top: calc(var(--affine-paragraph-space) + 24px); +// h1.margin-top = 8px + 24px = 32px; +const MAX_SPACE = 32; +const STEPS = MAX_SPACE / 2 / 2; + +/** + * Returns `16` if node is contained in the parent. + * Otherwise return `0`. + */ +function contains(parent: Element, node: Element) { + return ( + parent.compareDocumentPosition(node) & Node.DOCUMENT_POSITION_CONTAINED_BY + ); +} + +/** + * Returns `true` if element has `data-block-id` attribute. + */ +function hasBlockId(element: Element): element is BlockComponent { + return element.hasAttribute(BLOCK_ID_ATTR); +} + +/** + * Returns `true` if element is default/edgeless page or note. + */ +function isRootOrNoteOrSurface(element: BlockComponent) { + return matchFlavours(element.model, [ + 'affine:page', + 'affine:note', + // @ts-expect-error TODO: migrate surface model to @blocksuite/affine-model + 'affine:surface', + ]); +} + +function isBlock(element: BlockComponent) { + return !isRootOrNoteOrSurface(element); +} + +function isImage({ tagName }: Element) { + return tagName === 'AFFINE-IMAGE'; +} + +function isDatabase({ tagName }: Element) { + return tagName === 'AFFINE-DATABASE-TABLE' || tagName === 'AFFINE-DATABASE'; +} + +/** + * Returns the closest block element by a point in the rect. + * + * ``` + * ############### block + * ||############# block + * ||||########### block + * |||| ... + * |||| y - 2 * n + * |||| ... + * ||||----------- cursor + * |||| ... + * |||| y + 2 * n + * |||| ... + * ||||########### block + * ||############# block + * ############### block + * ``` + */ +export function getClosestBlockComponentByPoint( + point: Point, + state: { + rect?: Rect; + container?: Element; + snapToEdge?: { + x: boolean; + y: boolean; + }; + } | null = null, + scale = 1 +): BlockComponent | null { + const { y } = point; + + let container; + let element = null; + let bounds = null; + let childBounds = null; + let diff = 0; + let n = 1; + + if (state) { + const { + snapToEdge = { + x: true, + y: false, + }, + } = state; + container = state.container; + const rect = state.rect || container?.getBoundingClientRect(); + if (rect) { + if (snapToEdge.x) { + point.x = Math.min( + Math.max(point.x, rect.left) + + BLOCK_CHILDREN_CONTAINER_PADDING_LEFT * scale - + 1, + rect.right - BLOCK_CHILDREN_CONTAINER_PADDING_LEFT * scale - 1 + ); + } + if (snapToEdge.y) { + // TODO handle scale + if (scale !== 1) { + console.warn('scale is not supported yet'); + } + point.y = clamp(point.y, rect.top + 1, rect.bottom - 1); + } + } + } + + // find block element + element = findBlockComponent( + document.elementsFromPoint(point.x, point.y), + container + ); + + // Horizontal direction: for nested structures + if (element) { + // Database + if (isDatabase(element)) { + bounds = element.getBoundingClientRect(); + const rows = getDatabaseBlockRowsElement(element); + if (rows) { + childBounds = rows.getBoundingClientRect(); + + if (childBounds.height) { + if (point.y < childBounds.top || point.y > childBounds.bottom) { + return element as BlockComponent; + } + childBounds = null; + } else { + return element as BlockComponent; + } + } + } else { + // Indented paragraphs or list + bounds = getRectByBlockComponent(element); + childBounds = element + .querySelector('.affine-block-children-container') + ?.firstElementChild?.getBoundingClientRect(); + + if (childBounds && childBounds.height) { + if (bounds.x < point.x && point.x <= childBounds.x) { + return element as BlockComponent; + } + childBounds = null; + } else { + return element as BlockComponent; + } + } + + bounds = null; + element = null; + } + + // Vertical direction + do { + point.y = y - n * 2; + + if (n < 0) n--; + n *= -1; + + // find block element + element = findBlockComponent( + document.elementsFromPoint(point.x, point.y), + container + ); + + if (element) { + bounds = getRectByBlockComponent(element); + diff = bounds.bottom - point.y; + if (diff >= 0 && diff <= STEPS * 2) { + return element as BlockComponent; + } + diff = point.y - bounds.top; + if (diff >= 0 && diff <= STEPS * 2) { + return element as BlockComponent; + } + bounds = null; + element = null; + } + } while (n <= STEPS); + + return element; +} + +/** + * Find the most close block on the given position + * @param container container which the blocks can be found inside + * @param point position + * @param selector selector to find the block + */ +export function findClosestBlockComponent( + container: BlockComponent, + point: Point, + selector: string +): BlockComponent | null { + const children = ( + Array.from(container.querySelectorAll(selector)) as BlockComponent[] + ) + .filter(child => child.host === container.host) + .filter(child => child !== container); + + let lastDistance = Number.POSITIVE_INFINITY; + let lastChild = null; + + if (!children.length) return null; + + for (const child of children) { + const rect = child.getBoundingClientRect(); + if (rect.height === 0 || point.y > rect.bottom || point.y < rect.top) + continue; + const distance = + Math.pow(point.y - (rect.y + rect.height / 2), 2) + + Math.pow(point.x - rect.x, 2); + + if (distance <= lastDistance) { + lastDistance = distance; + lastChild = child; + } else { + return lastChild; + } + } + + return lastChild; +} + +/** + * Returns the closest block element by element that does not contain the page element and note element. + */ +export function getClosestBlockComponentByElement( + element: Element | null +): BlockComponent | null { + if (!element) return null; + if (hasBlockId(element) && isBlock(element)) { + return element; + } + const blockComponent = element.closest(ATTR_SELECTOR); + if (blockComponent && isBlock(blockComponent)) { + return blockComponent; + } + return null; +} + +/** + * Returns rect of the block element. + * + * Compatible with Safari! + * https://github.com/toeverything/blocksuite/issues/902 + * https://github.com/toeverything/blocksuite/pull/1121 + */ +export function getRectByBlockComponent(element: Element | BlockComponent) { + if (isDatabase(element)) return element.getBoundingClientRect(); + return (element.firstElementChild ?? element).getBoundingClientRect(); +} + +/** + * Returns block elements excluding their subtrees. + * Only keep block elements of same level. + */ +export function getBlockComponentsExcludeSubtrees( + elements: Element[] | BlockComponent[] +): BlockComponent[] { + if (elements.length <= 1) return elements as BlockComponent[]; + let parent = elements[0]; + return elements.filter((node, index) => { + if (index === 0) return true; + if (contains(parent, node)) { + return false; + } else { + parent = node; + return true; + } + }) as BlockComponent[]; +} + +/** + * Find block element from an `Element[]`. + * In Chrome/Safari, `document.elementsFromPoint` does not include `affine-image`. + */ +function findBlockComponent(elements: Element[], parent?: Element) { + const len = elements.length; + let element = null; + let i = 0; + while (i < len) { + element = elements[i]; + i++; + // if parent does not contain element, it's ignored + if (parent && !contains(parent, element)) continue; + if (hasBlockId(element) && isBlock(element)) return element; + if (isImage(element)) { + const element = elements[i]; + if (i < len && hasBlockId(element) && isBlock(element)) { + return elements[i]; + } + return getClosestBlockComponentByElement(element); + } + } + return null; +} + +/** + * Gets the rows of the database. + */ +function getDatabaseBlockRowsElement(element: Element) { + return element.querySelector('.affine-database-block-rows'); +} diff --git a/blocksuite/affine/shared/src/utils/dom/point-to-range.ts b/blocksuite/affine/shared/src/utils/dom/point-to-range.ts new file mode 100644 index 0000000000..98ea0ecc57 --- /dev/null +++ b/blocksuite/affine/shared/src/utils/dom/point-to-range.ts @@ -0,0 +1,86 @@ +import { IS_FIREFOX } from '@blocksuite/global/env'; + +declare global { + interface Document { + // firefox API + caretPositionFromPoint( + x: number, + y: number + ): { + offsetNode: Node; + offset: number; + }; + } +} + +/** + * A wrapper for the browser's `caretPositionFromPoint` and `caretRangeFromPoint`, + * but adapted for different browsers. + */ +export function caretRangeFromPoint( + clientX: number, + clientY: number +): Range | null { + if (IS_FIREFOX) { + const caret = document.caretPositionFromPoint(clientX, clientY); + // TODO handle caret is covered by popup + const range = document.createRange(); + range.setStart(caret.offsetNode, caret.offset); + return range; + } + + const range = document.caretRangeFromPoint(clientX, clientY); + + if (!range) { + return null; + } + + // See https://github.com/toeverything/blocksuite/issues/1382 + const rangeRects = range?.getClientRects(); + if ( + rangeRects && + rangeRects.length === 2 && + range.startOffset === range.endOffset && + clientY < rangeRects[0].y + rangeRects[0].height + ) { + const deltaX = (rangeRects[0].x | 0) - (rangeRects[1].x | 0); + + if (deltaX > 0) { + range.setStart(range.startContainer, range.startOffset - 1); + range.setEnd(range.endContainer, range.endOffset - 1); + } + } + return range; +} + +export function resetNativeSelection(range: Range | null) { + const selection = window.getSelection(); + if (!selection) return; + selection.removeAllRanges(); + range && selection.addRange(range); +} + +export function getCurrentNativeRange(selection = window.getSelection()) { + // When called on an ` + ); + + const blockSnapshot: BlockSnapshot = { + type: 'block', + id: 'matchesReplaceMap[0]', + flavour: 'affine:note', + props: { + xywh: '[0,0,800,95]', + background: DEFAULT_NOTE_BACKGROUND_COLOR, + index: 'a0', + hidden: false, + displayMode: NoteDisplayMode.DocAndEdgeless, + }, + children: [ + { + type: 'block', + id: 'matchesReplaceMap[1]', + flavour: 'affine:embed-youtube', + props: { + url: 'https://www.youtube.com/watch?v=QDsd0nyzwz0', + }, + children: [], + }, + ], + }; + + const htmlAdapter = new HtmlAdapter(createJob()); + const rawBlockSnapshot = await htmlAdapter.toBlockSnapshot({ + file: html, + }); + expect(nanoidReplacement(rawBlockSnapshot)).toEqual(blockSnapshot); + }); + + test('wrap', async () => { + const html = template( + `

a\n aa

b\t bb

c cc

ddd

eee

` + ); + + const blockSnapshot: BlockSnapshot = { + type: 'block', + id: 'matchesReplaceMap[0]', + flavour: 'affine:note', + props: { + xywh: '[0,0,800,95]', + background: DEFAULT_NOTE_BACKGROUND_COLOR, + index: 'a0', + hidden: false, + displayMode: NoteDisplayMode.DocAndEdgeless, + }, + children: [ + { + type: 'block', + id: 'matchesReplaceMap[1]', + flavour: 'affine:paragraph', + props: { + type: 'text', + text: { + '$blocksuite:internal:text$': true, + delta: [ + { + insert: 'a aa', + }, + ], + }, + }, + children: [], + }, + { + type: 'block', + id: 'matchesReplaceMap[2]', + flavour: 'affine:paragraph', + props: { + type: 'text', + text: { + '$blocksuite:internal:text$': true, + delta: [ + { + insert: 'b bb', + }, + ], + }, + }, + children: [], + }, + { + type: 'block', + id: 'matchesReplaceMap[3]', + flavour: 'affine:paragraph', + props: { + type: 'text', + text: { + '$blocksuite:internal:text$': true, + delta: [ + { + insert: 'c cc', + }, + ], + }, + }, + children: [], + }, + { + type: 'block', + id: 'matchesReplaceMap[4]', + flavour: 'affine:paragraph', + props: { + type: 'text', + text: { + '$blocksuite:internal:text$': true, + delta: [ + { + insert: 'ddd', + }, + ], + }, + }, + children: [], + }, + { + type: 'block', + id: 'matchesReplaceMap[5]', + flavour: 'affine:paragraph', + props: { + type: 'text', + text: { + '$blocksuite:internal:text$': true, + delta: [ + { + insert: 'eee', + }, + ], + }, + }, + children: [], + }, + ], + }; + + const htmlAdapter = new HtmlAdapter(createJob()); + const rawBlockSnapshot = await htmlAdapter.toBlockSnapshot({ + file: html, + }); + expect(nanoidReplacement(rawBlockSnapshot)).toEqual(blockSnapshot); + }); + + test('span nested in p', async () => { + const html = template( + `

aaabbbccc

` + ); + + const blockSnapshot: BlockSnapshot = { + type: 'block', + id: 'matchesReplaceMap[0]', + flavour: 'affine:note', + props: { + xywh: '[0,0,800,95]', + background: DEFAULT_NOTE_BACKGROUND_COLOR, + index: 'a0', + hidden: false, + displayMode: NoteDisplayMode.DocAndEdgeless, + }, + children: [ + { + type: 'block', + id: 'matchesReplaceMap[1]', + flavour: 'affine:paragraph', + props: { + type: 'text', + text: { + '$blocksuite:internal:text$': true, + delta: [ + { + insert: 'aaabbbccc', + }, + ], + }, + }, + children: [], + }, + ], + }; + + const htmlAdapter = new HtmlAdapter(createJob()); + const rawBlockSnapshot = await htmlAdapter.toBlockSnapshot({ + file: html, + }); + expect(nanoidReplacement(rawBlockSnapshot)).toEqual(blockSnapshot); + }); + + test('span nested in div', async () => { + const html = template( + `
aaabbbccc
` + ); + + const blockSnapshot: BlockSnapshot = { + type: 'block', + id: 'matchesReplaceMap[0]', + flavour: 'affine:note', + props: { + xywh: '[0,0,800,95]', + background: DEFAULT_NOTE_BACKGROUND_COLOR, + index: 'a0', + hidden: false, + displayMode: NoteDisplayMode.DocAndEdgeless, + }, + children: [ + { + type: 'block', + id: 'matchesReplaceMap[1]', + flavour: 'affine:paragraph', + props: { + type: 'text', + text: { + '$blocksuite:internal:text$': true, + delta: [ + { + insert: 'aaabbbccc', + }, + ], + }, + }, + children: [], + }, + ], + }; + + const htmlAdapter = new HtmlAdapter(createJob()); + const rawBlockSnapshot = await htmlAdapter.toBlockSnapshot({ + file: html, + }); + expect(nanoidReplacement(rawBlockSnapshot)).toEqual(blockSnapshot); + }); + + test('inline div', async () => { + const html = template( + `aaabbb
ccc
` + ); + + const blockSnapshot: BlockSnapshot = { + type: 'block', + id: 'matchesReplaceMap[0]', + flavour: 'affine:note', + props: { + xywh: '[0,0,800,95]', + background: DEFAULT_NOTE_BACKGROUND_COLOR, + index: 'a0', + hidden: false, + displayMode: NoteDisplayMode.DocAndEdgeless, + }, + children: [ + { + type: 'block', + id: 'matchesReplaceMap[1]', + flavour: 'affine:paragraph', + props: { + type: 'text', + text: { + '$blocksuite:internal:text$': true, + delta: [ + { + insert: 'aaa', + }, + { + insert: 'bbb', + attributes: { + link: 'https://www.google.com/', + }, + }, + { + insert: 'ccc', + }, + ], + }, + }, + children: [], + }, + ], + }; + + const htmlAdapter = new HtmlAdapter(createJob()); + const rawBlockSnapshot = await htmlAdapter.toBlockSnapshot({ + file: html, + }); + expect(nanoidReplacement(rawBlockSnapshot)).toEqual(blockSnapshot); + }); + + test('flex div', async () => { + const html = template( + `
aaabbb
ccc
` + ); + + const blockSnapshot: BlockSnapshot = { + type: 'block', + id: 'matchesReplaceMap[0]', + flavour: 'affine:note', + props: { + xywh: '[0,0,800,95]', + background: DEFAULT_NOTE_BACKGROUND_COLOR, + index: 'a0', + hidden: false, + displayMode: NoteDisplayMode.DocAndEdgeless, + }, + children: [ + { + type: 'block', + id: 'matchesReplaceMap[1]', + flavour: 'affine:paragraph', + props: { + type: 'text', + text: { + '$blocksuite:internal:text$': true, + delta: [ + { + insert: 'aaa', + }, + { + insert: 'bbb', + attributes: { + link: 'https://www.google.com/', + }, + }, + { + insert: 'ccc', + }, + ], + }, + }, + children: [], + }, + ], + }; + + const htmlAdapter = new HtmlAdapter(createJob()); + const rawBlockSnapshot = await htmlAdapter.toBlockSnapshot({ + file: html, + }); + expect(nanoidReplacement(rawBlockSnapshot)).toEqual(blockSnapshot); + }); + + test('span inside h1', async () => { + const html = template(`

aaa

`); + const blockSnapshot: BlockSnapshot = { + type: 'block', + id: 'matchesReplaceMap[0]', + flavour: 'affine:note', + props: { + xywh: '[0,0,800,95]', + background: DEFAULT_NOTE_BACKGROUND_COLOR, + index: 'a0', + hidden: false, + displayMode: NoteDisplayMode.DocAndEdgeless, + }, + children: [ + { + type: 'block', + id: 'matchesReplaceMap[1]', + flavour: 'affine:paragraph', + props: { + type: 'h1', + text: { + '$blocksuite:internal:text$': true, + delta: [ + { + insert: 'aaa', + }, + ], + }, + }, + children: [], + }, + ], + }; + + const htmlAdapter = new HtmlAdapter(createJob()); + const rawBlockSnapshot = await htmlAdapter.toBlockSnapshot({ + file: html, + }); + expect(nanoidReplacement(rawBlockSnapshot)).toEqual(blockSnapshot); + }); +}); diff --git a/blocksuite/blocks/src/__tests__/adapters/markdown.unit.spec.ts b/blocksuite/blocks/src/__tests__/adapters/markdown.unit.spec.ts new file mode 100644 index 0000000000..c44fa37ae9 --- /dev/null +++ b/blocksuite/blocks/src/__tests__/adapters/markdown.unit.spec.ts @@ -0,0 +1,3847 @@ +import { + DEFAULT_NOTE_BACKGROUND_COLOR, + NoteDisplayMode, +} from '@blocksuite/affine-model'; +import type { + BlockSnapshot, + DocSnapshot, + JobMiddleware, + SliceSnapshot, +} from '@blocksuite/store'; +import { AssetsManager, MemoryBlobCRUD } from '@blocksuite/store'; +import { describe, expect, test } from 'vitest'; + +import { MarkdownAdapter } from '../../_common/adapters/markdown/index.js'; +import { nanoidReplacement } from '../../_common/test-utils/test-utils.js'; +import { embedSyncedDocMiddleware } from '../../_common/transformers/middlewares.js'; +import { createJob } from '../utils/create-job.js'; + +describe('snapshot to markdown', () => { + test('code', async () => { + const blockSnapshot: BlockSnapshot = { + type: 'block', + id: 'block:vu6SK6WJpW', + flavour: 'affine:page', + props: { + title: { + '$blocksuite:internal:text$': true, + delta: [], + }, + }, + children: [ + { + type: 'block', + id: 'block:Tk4gSPocAt', + flavour: 'affine:surface', + props: { + elements: {}, + }, + children: [], + }, + { + type: 'block', + id: 'block:WfnS5ZDCJT', + flavour: 'affine:note', + props: { + xywh: '[0,0,800,95]', + background: DEFAULT_NOTE_BACKGROUND_COLOR, + index: 'a0', + hidden: false, + displayMode: NoteDisplayMode.DocAndEdgeless, + }, + children: [ + { + type: 'block', + id: 'block:8hOLxad5Fv', + flavour: 'affine:code', + props: { + language: 'python', + text: { + '$blocksuite:internal:text$': true, + delta: [ + { + insert: 'import this', + }, + ], + }, + }, + children: [], + }, + ], + }, + ], + }; + + const markdown = '```python\nimport this\n```\n'; + + const mdAdapter = new MarkdownAdapter(createJob()); + const target = await mdAdapter.fromBlockSnapshot({ + snapshot: blockSnapshot, + }); + expect(target.file).toBe(markdown); + }); + + test('paragraph', async () => { + const blockSnapshot: BlockSnapshot = { + type: 'block', + id: 'block:vu6SK6WJpW', + flavour: 'affine:page', + props: { + title: { + '$blocksuite:internal:text$': true, + delta: [], + }, + }, + children: [ + { + type: 'block', + id: 'block:Tk4gSPocAt', + flavour: 'affine:surface', + props: { + elements: {}, + }, + children: [], + }, + { + type: 'block', + id: 'block:WfnS5ZDCJT', + flavour: 'affine:note', + props: { + xywh: '[0,0,800,95]', + background: DEFAULT_NOTE_BACKGROUND_COLOR, + index: 'a0', + hidden: false, + displayMode: NoteDisplayMode.DocAndEdgeless, + }, + children: [ + { + type: 'block', + id: 'block:Bdn8Yvqcny', + flavour: 'affine:paragraph', + props: { + type: 'text', + text: { + '$blocksuite:internal:text$': true, + delta: [ + { + insert: 'aaa', + }, + ], + }, + }, + children: [ + { + type: 'block', + id: 'block:72SMa5mdLy', + flavour: 'affine:paragraph', + props: { + type: 'text', + text: { + '$blocksuite:internal:text$': true, + delta: [ + { + insert: 'bbb', + }, + ], + }, + }, + children: [], + }, + { + type: 'block', + id: 'block:f-Z6nRrGK_', + flavour: 'affine:paragraph', + props: { + type: 'text', + text: { + '$blocksuite:internal:text$': true, + delta: [ + { + insert: 'ccc', + }, + ], + }, + }, + children: [ + { + type: 'block', + id: 'block:sP3bU52el7', + flavour: 'affine:paragraph', + props: { + type: 'text', + text: { + '$blocksuite:internal:text$': true, + delta: [ + { + insert: 'ddd', + }, + ], + }, + }, + children: [], + }, + { + type: 'block', + id: 'block:X_HMxP4wxC', + flavour: 'affine:paragraph', + props: { + type: 'text', + text: { + '$blocksuite:internal:text$': true, + delta: [ + { + insert: 'eee', + }, + ], + }, + }, + children: [], + }, + { + type: 'block', + id: 'block:iA34Rb-RvV', + flavour: 'affine:paragraph', + props: { + text: { + '$blocksuite:internal:text$': true, + delta: [ + { + insert: 'fff', + }, + ], + }, + type: 'text', + }, + children: [], + }, + ], + }, + { + type: 'block', + id: 'block:I0Fmz5Nv02', + flavour: 'affine:paragraph', + props: { + type: 'text', + text: { + '$blocksuite:internal:text$': true, + delta: [ + { + insert: 'ggg', + }, + ], + }, + }, + children: [], + }, + ], + }, + { + type: 'block', + id: 'block:12lDwMD7ec', + flavour: 'affine:paragraph', + props: { + type: 'text', + text: { + '$blocksuite:internal:text$': true, + delta: [ + { + insert: 'hhh', + }, + ], + }, + }, + children: [], + }, + ], + }, + ], + }; + const markdown = `aaa + + bbb + + ccc + + ddd + + eee + + fff + + ggg + +hhh +`; + + const mdAdapter = new MarkdownAdapter(createJob()); + const target = await mdAdapter.fromBlockSnapshot({ + snapshot: blockSnapshot, + }); + expect(target.file).toBe(markdown); + }); + + test('bulleted list', async () => { + const blockSnapshot: BlockSnapshot = { + type: 'block', + id: 'block:vu6SK6WJpW', + flavour: 'affine:page', + props: { + title: { + '$blocksuite:internal:text$': true, + delta: [], + }, + }, + children: [ + { + type: 'block', + id: 'block:Tk4gSPocAt', + flavour: 'affine:surface', + props: { + elements: {}, + }, + children: [], + }, + { + type: 'block', + id: 'block:WfnS5ZDCJT', + flavour: 'affine:note', + props: { + xywh: '[0,0,800,95]', + background: DEFAULT_NOTE_BACKGROUND_COLOR, + index: 'a0', + hidden: false, + displayMode: NoteDisplayMode.DocAndEdgeless, + }, + children: [ + { + type: 'block', + id: 'block:imiLDMKSkx', + flavour: 'affine:list', + props: { + type: 'bulleted', + text: { + '$blocksuite:internal:text$': true, + delta: [ + { + insert: 'aaa', + }, + ], + }, + checked: false, + collapsed: false, + }, + children: [ + { + type: 'block', + id: 'block:kYliRIovvL', + flavour: 'affine:list', + props: { + type: 'bulleted', + text: { + '$blocksuite:internal:text$': true, + delta: [ + { + insert: 'bbb', + }, + ], + }, + checked: false, + collapsed: false, + }, + children: [ + { + type: 'block', + id: 'block:UyvxA_gqCJ', + flavour: 'affine:list', + props: { + type: 'bulleted', + text: { + '$blocksuite:internal:text$': true, + delta: [ + { + insert: 'ccc', + }, + ], + }, + checked: false, + collapsed: false, + }, + children: [], + }, + ], + }, + { + type: 'block', + id: 'block:-guNZRm5u1', + flavour: 'affine:list', + props: { + type: 'bulleted', + text: { + '$blocksuite:internal:text$': true, + delta: [ + { + insert: 'ddd', + }, + ], + }, + checked: false, + collapsed: false, + }, + children: [], + }, + ], + }, + { + type: 'block', + id: 'block:B9CaZzQ2CO', + flavour: 'affine:list', + props: { + type: 'bulleted', + text: { + '$blocksuite:internal:text$': true, + delta: [ + { + insert: 'eee', + }, + ], + }, + checked: false, + collapsed: false, + }, + children: [], + }, + ], + }, + ], + }; + const markdown = `* aaa + * bbb + * ccc + * ddd +* eee +`; + + const mdAdapter = new MarkdownAdapter(createJob()); + const target = await mdAdapter.fromBlockSnapshot({ + snapshot: blockSnapshot, + }); + expect(target.file).toBe(markdown); + }); + + test('todo list', async () => { + const blockSnapshot: BlockSnapshot = { + type: 'block', + id: 'block:vu6SK6WJpW', + flavour: 'affine:page', + props: { + title: { + '$blocksuite:internal:text$': true, + delta: [], + }, + }, + children: [ + { + type: 'block', + id: 'block:Tk4gSPocAt', + flavour: 'affine:surface', + props: { + elements: {}, + }, + children: [], + }, + { + type: 'block', + id: 'block:WfnS5ZDCJT', + flavour: 'affine:note', + props: { + xywh: '[0,0,800,95]', + background: DEFAULT_NOTE_BACKGROUND_COLOR, + index: 'a0', + hidden: false, + displayMode: NoteDisplayMode.DocAndEdgeless, + }, + children: [ + { + type: 'block', + id: 'block:imiLDMKSkx', + flavour: 'affine:list', + props: { + type: 'todo', + text: { + '$blocksuite:internal:text$': true, + delta: [ + { + insert: 'aaa', + }, + ], + }, + checked: false, + collapsed: false, + }, + children: [ + { + type: 'block', + id: 'block:kYliRIovvL', + flavour: 'affine:list', + props: { + type: 'todo', + text: { + '$blocksuite:internal:text$': true, + delta: [ + { + insert: 'bbb', + }, + ], + }, + checked: true, + collapsed: false, + }, + children: [ + { + type: 'block', + id: 'block:UyvxA_gqCJ', + flavour: 'affine:list', + props: { + type: 'todo', + text: { + '$blocksuite:internal:text$': true, + delta: [ + { + insert: 'ccc', + }, + ], + }, + checked: false, + collapsed: false, + }, + children: [], + }, + ], + }, + { + type: 'block', + id: 'block:-guNZRm5u1', + flavour: 'affine:list', + props: { + type: 'todo', + text: { + '$blocksuite:internal:text$': true, + delta: [ + { + insert: 'ddd', + }, + ], + }, + checked: true, + collapsed: false, + }, + children: [], + }, + ], + }, + { + type: 'block', + id: 'block:B9CaZzQ2CO', + flavour: 'affine:list', + props: { + type: 'todo', + text: { + '$blocksuite:internal:text$': true, + delta: [ + { + insert: 'eee', + }, + ], + }, + checked: false, + collapsed: false, + }, + children: [], + }, + ], + }, + ], + }; + const markdown = `\ +* [ ] aaa + * [x] bbb + * [ ] ccc + * [x] ddd +* [ ] eee +`; + + const mdAdapter = new MarkdownAdapter(createJob()); + const target = await mdAdapter.fromBlockSnapshot({ + snapshot: blockSnapshot, + }); + expect(target.file).toBe(markdown); + }); + + test('numbered list', async () => { + const blockSnapshot: BlockSnapshot = { + type: 'block', + id: 'block:m5hvdXHXS2', + flavour: 'affine:page', + version: 2, + props: { + title: { + '$blocksuite:internal:text$': true, + delta: [], + }, + }, + children: [ + { + type: 'block', + id: 'block:Y4J-oO9h9d', + flavour: 'affine:surface', + version: 5, + props: { + elements: {}, + }, + children: [], + }, + { + type: 'block', + id: 'block:1Ll22zT992', + flavour: 'affine:note', + version: 1, + props: { + xywh: '[0,0,800,95]', + background: DEFAULT_NOTE_BACKGROUND_COLOR, + index: 'a0', + hidden: false, + displayMode: 'both', + edgeless: { + style: { + borderRadius: 8, + borderSize: 4, + borderStyle: 'solid', + shadowType: '--affine-note-shadow-box', + }, + }, + }, + children: [ + { + type: 'block', + id: 'block:Fd0ZCYB7a4', + flavour: 'affine:list', + version: 1, + props: { + type: 'numbered', + text: { + '$blocksuite:internal:text$': true, + delta: [ + { + insert: 'aaa', + }, + ], + }, + checked: false, + collapsed: false, + }, + children: [ + { + type: 'block', + id: 'block:8-GeKDc06x', + flavour: 'affine:list', + version: 1, + props: { + type: 'numbered', + text: { + '$blocksuite:internal:text$': true, + delta: [ + { + insert: 'bbb', + }, + ], + }, + checked: false, + collapsed: false, + }, + children: [], + }, + { + type: 'block', + id: 'block:f0c-9xKaEL', + flavour: 'affine:list', + version: 1, + props: { + type: 'numbered', + text: { + '$blocksuite:internal:text$': true, + delta: [ + { + insert: 'ccc', + }, + ], + }, + checked: false, + collapsed: false, + }, + children: [], + }, + ], + }, + { + type: 'block', + id: 'block:Fd0ZCYB7a5', + flavour: 'affine:list', + version: 1, + props: { + type: 'numbered', + text: { + '$blocksuite:internal:text$': true, + delta: [ + { + insert: 'ddd', + }, + ], + }, + checked: false, + collapsed: false, + }, + children: [], + }, + ], + }, + ], + }; + + const markdown = `1. aaa + 1. bbb + 2. ccc +2. ddd +`; + + const mdAdapter = new MarkdownAdapter(createJob()); + const target = await mdAdapter.fromBlockSnapshot({ + snapshot: blockSnapshot, + }); + expect(target.file).toEqual(markdown); + }); + + test('different list', async () => { + const blockSnapshot: BlockSnapshot = { + type: 'block', + id: 'block:m5hvdXHXS2', + flavour: 'affine:page', + version: 2, + props: { + title: { + '$blocksuite:internal:text$': true, + delta: [], + }, + }, + children: [ + { + type: 'block', + id: 'block:Y4J-oO9h9d', + flavour: 'affine:surface', + version: 5, + props: { + elements: {}, + }, + children: [], + }, + { + type: 'block', + id: 'block:1Ll22zT992', + flavour: 'affine:note', + version: 1, + props: { + xywh: '[0,0,800,95]', + background: DEFAULT_NOTE_BACKGROUND_COLOR, + index: 'a0', + hidden: false, + displayMode: 'both', + edgeless: { + style: { + borderRadius: 8, + borderSize: 4, + borderStyle: 'solid', + shadowType: '--affine-note-shadow-box', + }, + }, + }, + children: [ + { + type: 'block', + id: 'block:Fd0ZCYB7a4', + flavour: 'affine:list', + version: 1, + props: { + type: 'numbered', + text: { + '$blocksuite:internal:text$': true, + delta: [ + { + insert: 'aaa', + }, + ], + }, + checked: false, + collapsed: false, + }, + children: [ + { + type: 'block', + id: 'block:8-GeKDc06x', + flavour: 'affine:list', + version: 1, + props: { + type: 'numbered', + text: { + '$blocksuite:internal:text$': true, + delta: [ + { + insert: 'bbb', + }, + ], + }, + checked: false, + collapsed: false, + }, + children: [], + }, + { + type: 'block', + id: 'block:f0c-9xKaEL', + flavour: 'affine:list', + version: 1, + props: { + type: 'bulleted', + text: { + '$blocksuite:internal:text$': true, + delta: [ + { + insert: 'ccc', + }, + ], + }, + checked: false, + collapsed: false, + }, + children: [], + }, + { + type: 'block', + id: 'block:f0c-9xKaEL', + flavour: 'affine:list', + version: 1, + props: { + type: 'numbered', + text: { + '$blocksuite:internal:text$': true, + delta: [ + { + insert: 'ddd', + }, + ], + }, + checked: false, + collapsed: false, + }, + children: [], + }, + ], + }, + { + type: 'block', + id: 'block:Fd0ZCYB7a5', + flavour: 'affine:list', + version: 1, + props: { + type: 'numbered', + text: { + '$blocksuite:internal:text$': true, + delta: [ + { + insert: 'eee', + }, + ], + }, + checked: false, + collapsed: false, + }, + children: [], + }, + ], + }, + ], + }; + + const markdown = `1. aaa + 1. bbb + * ccc + 1. ddd +2. eee +`; + + const mdAdapter = new MarkdownAdapter(createJob()); + const target = await mdAdapter.fromBlockSnapshot({ + snapshot: blockSnapshot, + }); + expect(target.file).toEqual(markdown); + }); + + test('code inline', async () => { + const blockSnapshot: BlockSnapshot = { + type: 'block', + id: 'block:vu6SK6WJpW', + flavour: 'affine:page', + props: { + title: { + '$blocksuite:internal:text$': true, + delta: [], + }, + }, + children: [ + { + type: 'block', + id: 'block:Tk4gSPocAt', + flavour: 'affine:surface', + props: { + elements: {}, + }, + children: [], + }, + { + type: 'block', + id: 'block:WfnS5ZDCJT', + flavour: 'affine:note', + props: { + xywh: '[0,0,800,95]', + background: DEFAULT_NOTE_BACKGROUND_COLOR, + index: 'a0', + hidden: false, + displayMode: NoteDisplayMode.DocAndEdgeless, + }, + children: [ + { + type: 'block', + id: 'block:qhpbuss-KN', + flavour: 'affine:paragraph', + props: { + type: 'text', + text: { + '$blocksuite:internal:text$': true, + delta: [ + { + insert: 'aaa ', + }, + { + insert: 'bbb', + attributes: { + code: true, + }, + }, + { + insert: ' ccc', + }, + ], + }, + }, + children: [], + }, + ], + }, + ], + }; + const markdown = 'aaa `bbb` ccc\n'; + + const mdAdapter = new MarkdownAdapter(createJob()); + const target = await mdAdapter.fromBlockSnapshot({ + snapshot: blockSnapshot, + }); + expect(target.file).toBe(markdown); + }); + + test('inline latex', async () => { + const blockSnapshot: BlockSnapshot = { + type: 'block', + id: 'block:vu6SK6WJpW', + flavour: 'affine:page', + props: { + title: { + '$blocksuite:internal:text$': true, + delta: [], + }, + }, + children: [ + { + type: 'block', + id: 'block:Tk4gSPocAt', + flavour: 'affine:surface', + props: { + elements: {}, + }, + children: [], + }, + { + type: 'block', + id: 'block:WfnS5ZDCJT', + flavour: 'affine:note', + props: { + xywh: '[0,0,800,95]', + background: DEFAULT_NOTE_BACKGROUND_COLOR, + index: 'a0', + hidden: false, + displayMode: NoteDisplayMode.DocAndEdgeless, + }, + children: [ + { + type: 'block', + id: 'block:qhpbuss-KN', + flavour: 'affine:paragraph', + props: { + type: 'text', + text: { + '$blocksuite:internal:text$': true, + delta: [ + { + insert: 'inline ', + }, + { + insert: ' ', + attributes: { + latex: 'E=mc^2', + }, + }, + { + insert: ' latex', + }, + ], + }, + }, + children: [], + }, + ], + }, + ], + }; + const markdown = 'inline $E=mc^2$ latex\n'; + + const mdAdapter = new MarkdownAdapter(createJob()); + const target = await mdAdapter.fromBlockSnapshot({ + snapshot: blockSnapshot, + }); + expect(target.file).toBe(markdown); + }); + + test('latex block', async () => { + const blockSnapshot: BlockSnapshot = { + type: 'block', + id: 'block:vu6SK6WJpW', + flavour: 'affine:page', + props: { + title: { + '$blocksuite:internal:text$': true, + delta: [], + }, + }, + children: [ + { + type: 'block', + id: 'block:Tk4gSPocAt', + flavour: 'affine:surface', + props: { + elements: {}, + }, + children: [], + }, + { + type: 'block', + id: 'block:WfnS5ZDCJT', + flavour: 'affine:note', + props: { + xywh: '[0,0,800,95]', + background: DEFAULT_NOTE_BACKGROUND_COLOR, + index: 'a0', + hidden: false, + displayMode: NoteDisplayMode.DocAndEdgeless, + }, + children: [ + { + type: 'block', + id: 'block:8hOLxad5Fv', + flavour: 'affine:latex', + props: { + latex: 'E=mc^2', + }, + children: [], + }, + ], + }, + ], + }; + + const markdown = '$$\nE=mc^2\n$$\n'; + + const mdAdapter = new MarkdownAdapter(createJob()); + const target = await mdAdapter.fromBlockSnapshot({ + snapshot: blockSnapshot, + }); + expect(target.file).toBe(markdown); + }); + + test('link', async () => { + const blockSnapshot: BlockSnapshot = { + type: 'block', + id: 'block:vu6SK6WJpW', + flavour: 'affine:page', + props: { + title: { + '$blocksuite:internal:text$': true, + delta: [], + }, + }, + children: [ + { + type: 'block', + id: 'block:Tk4gSPocAt', + flavour: 'affine:surface', + props: { + elements: {}, + }, + children: [], + }, + { + type: 'block', + id: 'block:WfnS5ZDCJT', + flavour: 'affine:note', + props: { + xywh: '[0,0,800,95]', + background: DEFAULT_NOTE_BACKGROUND_COLOR, + index: 'a0', + hidden: false, + displayMode: NoteDisplayMode.DocAndEdgeless, + }, + children: [ + { + type: 'block', + id: 'block:Bdn8Yvqcny', + flavour: 'affine:paragraph', + props: { + type: 'text', + text: { + '$blocksuite:internal:text$': true, + delta: [ + { + insert: 'aaa ', + }, + { + insert: 'bbb', + attributes: { + link: 'https://affine.pro/', + }, + }, + { + insert: ' ccc', + }, + ], + }, + }, + children: [], + }, + ], + }, + ], + }; + const markdown = 'aaa [bbb](https://affine.pro/) ccc\n'; + + const mdAdapter = new MarkdownAdapter(createJob()); + const target = await mdAdapter.fromBlockSnapshot({ + snapshot: blockSnapshot, + }); + expect(target.file).toBe(markdown); + }); + + test('inline link', async () => { + const blockSnapshot: BlockSnapshot = { + type: 'block', + id: 'block:vu6SK6WJpW', + flavour: 'affine:page', + props: { + title: { + '$blocksuite:internal:text$': true, + delta: [], + }, + }, + children: [ + { + type: 'block', + id: 'block:Tk4gSPocAt', + flavour: 'affine:surface', + props: { + elements: {}, + }, + children: [], + }, + { + type: 'block', + id: 'block:WfnS5ZDCJT', + flavour: 'affine:note', + props: { + xywh: '[0,0,800,95]', + background: DEFAULT_NOTE_BACKGROUND_COLOR, + index: 'a0', + hidden: false, + displayMode: NoteDisplayMode.DocAndEdgeless, + }, + children: [ + { + type: 'block', + id: 'block:Bdn8Yvqcny', + flavour: 'affine:paragraph', + props: { + type: 'text', + text: { + '$blocksuite:internal:text$': true, + delta: [ + { + insert: 'aaa ', + }, + { + insert: 'https://affine.pro/ ', + attributes: { + link: 'https://affine.pro/ ', + }, + }, + ], + }, + }, + children: [], + }, + ], + }, + ], + }; + const markdown = 'aaa https://affine.pro/ \n'; + + const mdAdapter = new MarkdownAdapter(createJob()); + const target = await mdAdapter.fromBlockSnapshot({ + snapshot: blockSnapshot, + }); + expect(target.file).toBe(markdown); + }); + + test('bold', async () => { + const blockSnapshot: BlockSnapshot = { + type: 'block', + id: 'block:vu6SK6WJpW', + flavour: 'affine:page', + props: { + title: { + '$blocksuite:internal:text$': true, + delta: [], + }, + }, + children: [ + { + type: 'block', + id: 'block:Tk4gSPocAt', + flavour: 'affine:surface', + props: { + elements: {}, + }, + children: [], + }, + { + type: 'block', + id: 'block:WfnS5ZDCJT', + flavour: 'affine:note', + props: { + xywh: '[0,0,800,95]', + background: DEFAULT_NOTE_BACKGROUND_COLOR, + index: 'a0', + hidden: false, + displayMode: NoteDisplayMode.DocAndEdgeless, + }, + children: [ + { + type: 'block', + id: 'block:zxDyvrg1Mh', + flavour: 'affine:paragraph', + props: { + type: 'text', + text: { + '$blocksuite:internal:text$': true, + delta: [ + { + insert: 'aaa', + }, + { + insert: 'bbb', + attributes: { + bold: true, + }, + }, + { + insert: 'ccc', + }, + ], + }, + }, + children: [], + }, + ], + }, + ], + }; + + const markdown = 'aaa**bbb**ccc\n'; + + const mdAdapter = new MarkdownAdapter(createJob()); + const target = await mdAdapter.fromBlockSnapshot({ + snapshot: blockSnapshot, + }); + expect(target.file).toBe(markdown); + }); + + test('italic', async () => { + const blockSnapshot: BlockSnapshot = { + type: 'block', + id: 'block:vu6SK6WJpW', + flavour: 'affine:page', + props: { + title: { + '$blocksuite:internal:text$': true, + delta: [], + }, + }, + children: [ + { + type: 'block', + id: 'block:Tk4gSPocAt', + flavour: 'affine:surface', + props: { + elements: {}, + }, + children: [], + }, + { + type: 'block', + id: 'block:WfnS5ZDCJT', + flavour: 'affine:note', + props: { + xywh: '[0,0,800,95]', + background: DEFAULT_NOTE_BACKGROUND_COLOR, + index: 'a0', + hidden: false, + displayMode: NoteDisplayMode.DocAndEdgeless, + }, + children: [ + { + type: 'block', + id: 'block:zxDyvrg1Mh', + flavour: 'affine:paragraph', + props: { + type: 'text', + text: { + '$blocksuite:internal:text$': true, + delta: [ + { + insert: 'aaa', + }, + { + insert: 'bbb', + attributes: { + italic: true, + }, + }, + { + insert: 'ccc', + }, + ], + }, + }, + children: [], + }, + ], + }, + ], + }; + + const markdown = 'aaa*bbb*ccc\n'; + + const mdAdapter = new MarkdownAdapter(createJob()); + const target = await mdAdapter.fromBlockSnapshot({ + snapshot: blockSnapshot, + }); + expect(target.file).toBe(markdown); + }); + + test('image', async () => { + const blockSnapshot: BlockSnapshot = { + type: 'block', + id: 'block:WcYcyv-oZY', + flavour: 'affine:page', + props: { + title: { + '$blocksuite:internal:text$': true, + delta: [], + }, + }, + children: [ + { + type: 'block', + id: 'block:zqtuv999Ww', + flavour: 'affine:surface', + props: { + elements: {}, + }, + children: [], + }, + { + type: 'block', + id: 'block:UTUZojv22c', + flavour: 'affine:note', + props: { + xywh: '[0,0,800,95]', + background: DEFAULT_NOTE_BACKGROUND_COLOR, + index: 'a0', + hidden: false, + displayMode: NoteDisplayMode.DocAndEdgeless, + }, + children: [ + { + type: 'block', + id: 'block:Gan31s-dYK', + flavour: 'affine:image', + props: { + sourceId: 'YXXTjRmLlNyiOUnHb8nAIvUP6V7PAXhwW9F5_tc2LGs=', + caption: 'aaa', + width: 0, + height: 0, + index: 'a0', + xywh: '[0,0,0,0]', + rotate: 0, + }, + children: [], + }, + { + type: 'block', + id: 'block:If92CIQiOl', + flavour: 'affine:paragraph', + props: { + type: 'text', + text: { + '$blocksuite:internal:text$': true, + delta: [], + }, + }, + children: [], + }, + ], + }, + ], + }; + + const markdown = + '![](assets/YXXTjRmLlNyiOUnHb8nAIvUP6V7PAXhwW9F5_tc2LGs=.blob "aaa")\n\n'; + + const mdAdapter = new MarkdownAdapter(createJob()); + const blobCRUD = new MemoryBlobCRUD(); + await blobCRUD.set( + 'YXXTjRmLlNyiOUnHb8nAIvUP6V7PAXhwW9F5_tc2LGs=', + new Blob() + ); + const assets = new AssetsManager({ blob: blobCRUD }); + + const target = await mdAdapter.fromBlockSnapshot({ + snapshot: blockSnapshot, + assets, + }); + expect(target.file).toBe(markdown); + }); + + test('table', async () => { + const blockSnapshot: BlockSnapshot = { + type: 'block', + id: 'block:8Wb7CSJ9Qe', + flavour: 'affine:database', + props: { + cells: { + 'block:P_-Wg7Rg9O': { + 'block:qyo8q9VPWU': { + columnId: 'block:qyo8q9VPWU', + value: 'TKip9uc7Yx', + }, + 'block:5cglrBmAr3': { + columnId: 'block:5cglrBmAr3', + value: 1702598400000, + }, + 'block:8Fa0JQe7WY': { + columnId: 'block:8Fa0JQe7WY', + value: 1, + }, + 'block:5ej6StPuF_': { + columnId: 'block:5ej6StPuF_', + value: 65, + }, + 'block:DPhZ6JBziD': { + columnId: 'block:DPhZ6JBziD', + value: ['-2_QD3GZT1', '73UrEZWaKk'], + }, + 'block:O8dpIDiP7-': { + columnId: 'block:O8dpIDiP7-', + value: { + '$blocksuite:internal:text$': true, + delta: [ + { + insert: 'test2', + attributes: { + link: 'https://google.com', + }, + }, + ], + }, + }, + 'block:U8lPD59MkF': { + columnId: 'block:U8lPD59MkF', + value: 'https://google.com', + }, + 'block:-DT7B0TafG': { + columnId: 'block:-DT7B0TafG', + value: true, + }, + }, + 'block:0vhfgcHtPF': { + 'block:qyo8q9VPWU': { + columnId: 'block:qyo8q9VPWU', + value: 'F2bgsaE3X2', + }, + 'block:O8dpIDiP7-': { + columnId: 'block:O8dpIDiP7-', + value: { + '$blocksuite:internal:text$': true, + delta: [ + { + insert: 'test1', + }, + ], + }, + }, + 'block:5cglrBmAr3': { + columnId: 'block:5cglrBmAr3', + value: 1703030400000, + }, + }, + 'block:b4_02QXMAM': { + 'block:qyo8q9VPWU': { + columnId: 'block:qyo8q9VPWU', + value: 'y3O1A2IHHu', + }, + }, + 'block:W_eirvg7EJ': { + 'block:qyo8q9VPWU': { + columnId: 'block:qyo8q9VPWU', + }, + }, + }, + columns: [ + { + type: 'title', + name: 'Title', + data: {}, + id: 'block:2VfUaitjf9', + }, + { + type: 'select', + name: 'Status', + data: { + options: [ + { + id: 'TKip9uc7Yx', + color: 'var(--affine-tag-white)', + value: 'TODO', + }, + { + id: 'F2bgsaE3X2', + color: 'var(--affine-tag-green)', + value: 'In Progress', + }, + { + id: 'y3O1A2IHHu', + color: 'var(--affine-tag-gray)', + value: 'Done', + }, + ], + }, + id: 'block:qyo8q9VPWU', + }, + { + type: 'date', + name: 'Date', + data: {}, + id: 'block:5cglrBmAr3', + }, + { + type: 'number', + name: 'Number', + data: { + decimal: 0, + }, + id: 'block:8Fa0JQe7WY', + }, + { + type: 'progress', + name: 'Progress', + data: {}, + id: 'block:5ej6StPuF_', + }, + { + type: 'multi-select', + name: 'MultiSelect', + data: { + options: [ + { + id: '73UrEZWaKk', + value: 'test2', + color: 'var(--affine-tag-purple)', + }, + { + id: '-2_QD3GZT1', + value: 'test1', + color: 'var(--affine-tag-teal)', + }, + ], + }, + id: 'block:DPhZ6JBziD', + }, + { + type: 'rich-text', + name: 'RichText', + data: {}, + id: 'block:O8dpIDiP7-', + }, + { + type: 'link', + name: 'Link', + data: {}, + id: 'block:U8lPD59MkF', + }, + { + type: 'checkbox', + name: 'Checkbox', + data: {}, + id: 'block:-DT7B0TafG', + }, + ], + }, + children: [ + { + type: 'block', + id: 'block:P_-Wg7Rg9O', + flavour: 'affine:paragraph', + props: { + type: 'text', + text: { + '$blocksuite:internal:text$': true, + delta: [ + { + insert: 'Task 1', + }, + ], + }, + }, + children: [], + }, + { + type: 'block', + id: 'block:0vhfgcHtPF', + flavour: 'affine:paragraph', + props: { + type: 'text', + text: { + '$blocksuite:internal:text$': true, + delta: [ + { + insert: 'Task 2', + }, + ], + }, + }, + children: [], + }, + ], + }; + + const md = `\ +| Title | Status | Date | Number | Progress | MultiSelect | RichText | Link | Checkbox | +| ------ | ----------- | ---------- | ------ | -------- | ----------- | --------------------------- | ------------------ | -------- | +| Task 1 | TODO | 2023-12-15 | 1 | 65 | test1,test2 | [test2](https://google.com) | https://google.com | true | +| Task 2 | In Progress | 2023-12-20 | | | | test1 | | | +`; + const mdAdapter = new MarkdownAdapter(createJob()); + const target = await mdAdapter.fromBlockSnapshot({ + snapshot: blockSnapshot, + }); + expect(target.file).toBe(md); + }); + + test('reference', async () => { + const blockSnapshot: BlockSnapshot = { + type: 'block', + id: 'block:vu6SK6WJpW', + flavour: 'affine:page', + props: { + title: { + '$blocksuite:internal:text$': true, + delta: [], + }, + }, + children: [ + { + type: 'block', + id: 'block:Tk4gSPocAt', + flavour: 'affine:surface', + props: { + elements: {}, + }, + children: [], + }, + { + type: 'block', + id: 'block:WfnS5ZDCJT', + flavour: 'affine:note', + props: { + xywh: '[0,0,800,95]', + background: DEFAULT_NOTE_BACKGROUND_COLOR, + index: 'a0', + hidden: false, + displayMode: NoteDisplayMode.DocAndEdgeless, + }, + children: [ + { + type: 'block', + id: 'block:Bdn8Yvqcny', + flavour: 'affine:paragraph', + props: { + type: 'text', + text: { + '$blocksuite:internal:text$': true, + delta: [ + { + insert: 'aaa', + }, + ], + }, + }, + children: [ + { + type: 'block', + id: 'block:72SMa5mdLy', + flavour: 'affine:paragraph', + props: { + type: 'text', + text: { + '$blocksuite:internal:text$': true, + delta: [ + { + insert: 'bbb', + }, + ], + }, + }, + children: [], + }, + { + type: 'block', + id: 'C0sH2Ee6cz-MysVNLNrBt', + flavour: 'affine:embed-linked-doc', + props: { + index: 'a0', + xywh: '[0,0,0,0]', + rotate: 0, + pageId: '4T5ObMgEIMII-4Bexyta1', + style: 'horizontal', + caption: null, + params: { + mode: 'page', + blockIds: ['abc', '123'], + elementIds: ['def', '456'], + databaseId: 'deadbeef', + databaseRowId: '123', + }, + }, + children: [], + }, + { + type: 'block', + id: 'block:f-Z6nRrGK_', + flavour: 'affine:paragraph', + props: { + type: 'text', + text: { + '$blocksuite:internal:text$': true, + delta: [ + { + insert: 'ccc', + }, + ], + }, + }, + children: [ + { + type: 'block', + id: 'block:sP3bU52el7', + flavour: 'affine:paragraph', + props: { + type: 'text', + text: { + '$blocksuite:internal:text$': true, + delta: [ + { + insert: 'ddd', + }, + ], + }, + }, + children: [], + }, + { + type: 'block', + id: 'block:X_HMxP4wxC', + flavour: 'affine:paragraph', + props: { + type: 'text', + text: { + '$blocksuite:internal:text$': true, + delta: [ + { + insert: 'eee', + }, + { + insert: '', + attributes: { + reference: { + type: 'LinkedPage', + pageId: 'deadbeef', + params: { + mode: 'page', + blockIds: ['abc', '123'], + elementIds: ['def', '456'], + databaseId: 'deadbeef', + databaseRowId: '123', + }, + }, + }, + }, + { + insert: ' ', + attributes: { + reference: { + type: 'LinkedPage', + pageId: 'foobar', + }, + }, + }, + ], + }, + }, + children: [], + }, + { + type: 'block', + id: 'block:iA34Rb-RvV', + flavour: 'affine:paragraph', + props: { + text: { + '$blocksuite:internal:text$': true, + delta: [ + { + insert: 'fff', + }, + ], + }, + type: 'text', + }, + children: [], + }, + ], + }, + { + type: 'block', + id: 'block:I0Fmz5Nv02', + flavour: 'affine:paragraph', + props: { + type: 'text', + text: { + '$blocksuite:internal:text$': true, + delta: [ + { + insert: 'ggg', + }, + ], + }, + }, + children: [], + }, + ], + }, + { + type: 'block', + id: 'block:12lDwMD7ec', + flavour: 'affine:paragraph', + props: { + type: 'text', + text: { + '$blocksuite:internal:text$': true, + delta: [ + { + insert: 'hhh', + }, + ], + }, + }, + children: [], + }, + ], + }, + ], + }; + const markdown = `aaa + + bbb + +[untitled](https://example.com/4T5ObMgEIMII-4Bexyta1?mode=page\\&blockIds=abc%2C123\\&elementIds=def%2C456\\&databaseId=deadbeef\\&databaseRowId=123) + + ccc + + ddd + + eee[test](https://example.com/deadbeef?mode=page\\&blockIds=abc%2C123\\&elementIds=def%2C456\\&databaseId=deadbeef\\&databaseRowId=123)[](https://example.com/foobar) + + fff + + ggg + +hhh +`; + const middleware: JobMiddleware = ({ adapterConfigs }) => { + adapterConfigs.set('title:deadbeef', 'test'); + adapterConfigs.set('docLinkBaseUrl', 'https://example.com'); + }; + const mdAdapter = new MarkdownAdapter(createJob([middleware])); + const target = await mdAdapter.fromBlockSnapshot({ + snapshot: blockSnapshot, + }); + expect(target.file).toBe(markdown); + }); + + test('synced-doc', async () => { + // doc -> synced doc block -> deepest synced doc block + // The deepest synced doc block only export it's title + + const deepestSyncedDocSnapshot: DocSnapshot = { + type: 'page', + meta: { + id: 'deepestSyncedDoc', + title: 'Deepest Doc', + createDate: 1715762171116, + tags: [], + }, + blocks: { + type: 'block', + id: '8WdJmN5FTT', + flavour: 'affine:page', + version: 2, + props: { + title: { + '$blocksuite:internal:text$': true, + delta: [ + { + insert: 'Deepest Doc', + }, + ], + }, + }, + children: [ + { + type: 'block', + id: 'zVN1EZFuZe', + flavour: 'affine:surface', + version: 5, + props: { + elements: {}, + }, + children: [], + }, + { + type: 'block', + id: '2s9sJlphLH', + flavour: 'affine:note', + version: 1, + props: { + xywh: '[0,0,800,95]', + background: DEFAULT_NOTE_BACKGROUND_COLOR, + index: 'a0', + hidden: false, + displayMode: 'both', + edgeless: { + style: { + borderRadius: 8, + borderSize: 4, + borderStyle: 'solid', + shadowType: '--affine-note-shadow-box', + }, + }, + }, + children: [ + { + type: 'block', + id: 'vNp5XrR5yw', + flavour: 'affine:paragraph', + version: 1, + props: { + type: 'text', + text: { + '$blocksuite:internal:text$': true, + delta: [], + }, + }, + children: [], + }, + { + type: 'block', + id: 'JTdfSl1ygZ', + flavour: 'affine:paragraph', + version: 1, + props: { + type: 'text', + text: { + '$blocksuite:internal:text$': true, + delta: [ + { + insert: 'Hello, This is deepest doc.', + }, + ], + }, + }, + children: [], + }, + ], + }, + ], + }, + }; + + const syncedDocSnapshot: DocSnapshot = { + type: 'page', + meta: { + id: 'syncedDoc', + title: 'Synced Doc', + createDate: 1719212435051, + tags: [], + }, + blocks: { + type: 'block', + id: 'AGOahFisBN', + flavour: 'affine:page', + version: 2, + props: { + title: { + '$blocksuite:internal:text$': true, + delta: [ + { + insert: 'Synced Doc', + }, + ], + }, + }, + children: [ + { + type: 'block', + id: 'gfVzx5tGpB', + flavour: 'affine:surface', + version: 5, + props: { + elements: {}, + }, + children: [], + }, + { + type: 'block', + id: 'CzEfaUret4', + flavour: 'affine:note', + version: 1, + props: { + xywh: '[0,0,800,95]', + background: '--affine-note-background-blue', + index: 'a0', + hidden: false, + displayMode: 'both', + edgeless: { + style: { + borderRadius: 0, + borderSize: 4, + borderStyle: 'none', + shadowType: '--affine-note-shadow-sticker', + }, + }, + }, + children: [ + { + type: 'block', + id: 'yFlNufsgke', + flavour: 'affine:paragraph', + version: 1, + props: { + type: 'h1', + text: { + '$blocksuite:internal:text$': true, + delta: [ + { + insert: 'Heading 1', + }, + ], + }, + }, + children: [], + }, + { + type: 'block', + id: 'oMuLcD6XS3', + flavour: 'affine:paragraph', + version: 1, + props: { + type: 'h2', + text: { + '$blocksuite:internal:text$': true, + delta: [ + { + insert: 'heading 2', + }, + ], + }, + }, + children: [], + }, + { + type: 'block', + id: 'PQ8FhGV6VM', + flavour: 'affine:paragraph', + version: 1, + props: { + type: 'text', + text: { + '$blocksuite:internal:text$': true, + delta: [ + { + insert: 'paragraph', + }, + ], + }, + }, + children: [], + }, + { + type: 'block', + id: 'sA9paSrdEN', + flavour: 'affine:paragraph', + version: 1, + props: { + type: 'text', + text: { + '$blocksuite:internal:text$': true, + delta: [ + { + insert: 'strike', + attributes: { + strike: true, + }, + }, + ], + }, + }, + children: [], + }, + { + type: 'block', + id: 'DF26giFpKX', + flavour: 'affine:code', + version: 1, + props: { + text: { + '$blocksuite:internal:text$': true, + delta: [ + { + insert: 'Hello world!', + }, + ], + }, + language: 'cpp', + wrap: false, + caption: '', + }, + children: [], + }, + { + type: 'block', + id: '-3bbVQTvI2', + flavour: 'affine:embed-synced-doc', + version: 1, + props: { + index: 'a0', + xywh: '[0,0,0,0]', + rotate: 0, + pageId: 'deepestSyncedDoc', + style: 'syncedDoc', + }, + children: [], + }, + ], + }, + ], + }, + }; + + const syncedDocMd = + '# Synced Doc\n\n# Heading 1\n\n## heading 2\n\nparagraph\n\n~~strike~~\n\n```cpp\nHello world!\n```'; + + const docSnapShot: DocSnapshot = { + type: 'page', + meta: { + id: 'y5nsrywQtr', + title: 'Test Doc', + createDate: 1719222172042, + tags: [], + }, + blocks: { + type: 'block', + id: 'VChAZIX7DM', + flavour: 'affine:page', + version: 2, + props: { + title: { + '$blocksuite:internal:text$': true, + delta: [ + { + insert: 'Test Doc', + }, + ], + }, + }, + children: [ + { + type: 'block', + id: 'uRj8gejH4d', + flavour: 'affine:surface', + version: 5, + props: { + elements: {}, + }, + children: [], + }, + { + type: 'block', + id: 'AqFoVDUoW9', + flavour: 'affine:note', + version: 1, + props: { + xywh: '[0,0,800,95]', + background: '--affine-note-background-blue', + index: 'a0', + hidden: false, + displayMode: 'both', + edgeless: { + style: { + borderRadius: 0, + borderSize: 4, + borderStyle: 'none', + shadowType: '--affine-note-shadow-sticker', + }, + }, + }, + children: [ + { + type: 'block', + id: 'cWBI4UGTqh', + flavour: 'affine:paragraph', + version: 1, + props: { + type: 'text', + text: { + '$blocksuite:internal:text$': true, + delta: [ + { + insert: 'Hello', + }, + ], + }, + }, + children: [], + }, + { + type: 'block', + id: 'AqFoVxas19', + flavour: 'affine:embed-synced-doc', + version: 1, + props: { + index: 'a0', + xywh: '[0,0,0,0]', + rotate: 0, + pageId: 'syncedDoc', + style: 'syncedDoc', + }, + children: [], + }, + { + type: 'block', + id: 'Db976U9v18', + flavour: 'affine:paragraph', + version: 1, + props: { + type: 'text', + text: { + '$blocksuite:internal:text$': true, + delta: [ + { + insert: 'World!', + }, + ], + }, + }, + children: [], + }, + ], + }, + ], + }, + }; + + const docMd = `\ +# Test Doc + +Hello + +${syncedDocMd} + +Deepest Doc + +World! +`; + + const job = createJob([embedSyncedDocMiddleware('content')]); + + // workaround for adding docs to collection + await job.snapshotToDoc(deepestSyncedDocSnapshot); + await job.snapshotToDoc(syncedDocSnapshot); + await job.snapshotToDoc(docSnapShot); + + const mdAdapter = new MarkdownAdapter(job); + const target = await mdAdapter.fromDocSnapshot({ + snapshot: docSnapShot, + }); + expect(target.file).toBe(docMd); + }); +}); + +describe('markdown to snapshot', () => { + test('code', async () => { + const markdown = '```python\nimport this\n```\n'; + + const blockSnapshot: BlockSnapshot = { + type: 'block', + id: 'matchesReplaceMap[0]', + flavour: 'affine:note', + props: { + xywh: '[0,0,800,95]', + background: DEFAULT_NOTE_BACKGROUND_COLOR, + index: 'a0', + hidden: false, + displayMode: NoteDisplayMode.DocAndEdgeless, + }, + children: [ + { + type: 'block', + id: 'matchesReplaceMap[1]', + flavour: 'affine:code', + props: { + language: 'python', + text: { + '$blocksuite:internal:text$': true, + delta: [ + { + insert: 'import this', + }, + ], + }, + }, + children: [], + }, + ], + }; + + const mdAdapter = new MarkdownAdapter(createJob()); + const rawBlockSnapshot = await mdAdapter.toBlockSnapshot({ + file: markdown, + }); + expect(nanoidReplacement(rawBlockSnapshot)).toEqual(blockSnapshot); + }); + + test('code with indentation 1 - slice', async () => { + const markdown = '```python\n import this\n```'; + + const sliceSnapshot: SliceSnapshot = { + type: 'slice', + content: [ + { + type: 'block', + id: 'matchesReplaceMap[0]', + flavour: 'affine:note', + props: { + xywh: '[0,0,800,95]', + background: DEFAULT_NOTE_BACKGROUND_COLOR, + index: 'a0', + hidden: false, + displayMode: 'both', + }, + children: [ + { + type: 'block', + id: 'matchesReplaceMap[1]', + flavour: 'affine:code', + props: { + language: 'python', + text: { + '$blocksuite:internal:text$': true, + delta: [ + { + insert: ' import this', + }, + ], + }, + }, + children: [], + }, + ], + }, + ], + workspaceId: '', + pageId: '', + }; + + const mdAdapter = new MarkdownAdapter(createJob()); + const rawSliceSnapshot = await mdAdapter.toSliceSnapshot({ + file: markdown, + workspaceId: '', + pageId: '', + }); + expect(nanoidReplacement(rawSliceSnapshot!)).toEqual(sliceSnapshot); + }); + + test('code with indentation 2 - slice', async () => { + const markdown = '````python\n```python\n import this\n```\n````'; + + const sliceSnapshot: SliceSnapshot = { + type: 'slice', + content: [ + { + type: 'block', + id: 'matchesReplaceMap[0]', + flavour: 'affine:note', + props: { + xywh: '[0,0,800,95]', + background: DEFAULT_NOTE_BACKGROUND_COLOR, + index: 'a0', + hidden: false, + displayMode: 'both', + }, + children: [ + { + type: 'block', + id: 'matchesReplaceMap[1]', + flavour: 'affine:code', + props: { + language: 'python', + text: { + '$blocksuite:internal:text$': true, + delta: [ + { + insert: '```python\n import this\n```', + }, + ], + }, + }, + children: [], + }, + ], + }, + ], + workspaceId: '', + pageId: '', + }; + + const mdAdapter = new MarkdownAdapter(createJob()); + const rawSliceSnapshot = await mdAdapter.toSliceSnapshot({ + file: markdown, + workspaceId: '', + pageId: '', + }); + expect(nanoidReplacement(rawSliceSnapshot!)).toEqual(sliceSnapshot); + }); + + test('code with indentation 3 - slice', async () => { + const markdown = '~~~~python\n````python\n import this\n````\n~~~~'; + + const sliceSnapshot: SliceSnapshot = { + type: 'slice', + content: [ + { + type: 'block', + id: 'matchesReplaceMap[0]', + flavour: 'affine:note', + props: { + xywh: '[0,0,800,95]', + background: DEFAULT_NOTE_BACKGROUND_COLOR, + index: 'a0', + hidden: false, + displayMode: 'both', + }, + children: [ + { + type: 'block', + id: 'matchesReplaceMap[1]', + flavour: 'affine:code', + props: { + language: 'python', + text: { + '$blocksuite:internal:text$': true, + delta: [ + { + insert: '````python\n import this\n````', + }, + ], + }, + }, + children: [], + }, + ], + }, + ], + workspaceId: '', + pageId: '', + }; + + const mdAdapter = new MarkdownAdapter(createJob()); + const rawSliceSnapshot = await mdAdapter.toSliceSnapshot({ + file: markdown, + workspaceId: '', + pageId: '', + }); + expect(nanoidReplacement(rawSliceSnapshot!)).toEqual(sliceSnapshot); + }); + + test('paragraph', async () => { + const markdown = `aaa + + bbb + + ccc + + ddd + + eee + + fff + + ggg + +hhh +`; + + const blockSnapshot: BlockSnapshot = { + type: 'block', + id: 'matchesReplaceMap[0]', + flavour: 'affine:note', + props: { + xywh: '[0,0,800,95]', + background: DEFAULT_NOTE_BACKGROUND_COLOR, + index: 'a0', + hidden: false, + displayMode: NoteDisplayMode.DocAndEdgeless, + }, + children: [ + { + type: 'block', + id: 'matchesReplaceMap[1]', + flavour: 'affine:paragraph', + props: { + type: 'text', + text: { + '$blocksuite:internal:text$': true, + delta: [ + { + insert: 'aaa', + }, + ], + }, + }, + children: [], + }, + { + type: 'block', + id: 'matchesReplaceMap[2]', + flavour: 'affine:paragraph', + props: { + type: 'text', + text: { + '$blocksuite:internal:text$': true, + delta: [ + { + insert: ' bbb', + }, + ], + }, + }, + children: [], + }, + { + type: 'block', + id: 'matchesReplaceMap[3]', + flavour: 'affine:paragraph', + props: { + type: 'text', + text: { + '$blocksuite:internal:text$': true, + delta: [ + { + insert: ' ccc', + }, + ], + }, + }, + children: [], + }, + { + type: 'block', + id: 'matchesReplaceMap[4]', + flavour: 'affine:paragraph', + props: { + type: 'text', + text: { + '$blocksuite:internal:text$': true, + delta: [ + { + insert: ' ddd', + }, + ], + }, + }, + children: [], + }, + { + type: 'block', + id: 'matchesReplaceMap[5]', + flavour: 'affine:paragraph', + props: { + type: 'text', + text: { + '$blocksuite:internal:text$': true, + delta: [ + { + insert: ' eee', + }, + ], + }, + }, + children: [], + }, + { + type: 'block', + id: 'matchesReplaceMap[6]', + flavour: 'affine:paragraph', + props: { + text: { + '$blocksuite:internal:text$': true, + delta: [ + { + insert: ' fff', + }, + ], + }, + type: 'text', + }, + children: [], + }, + { + type: 'block', + id: 'matchesReplaceMap[7]', + flavour: 'affine:paragraph', + props: { + type: 'text', + text: { + '$blocksuite:internal:text$': true, + delta: [ + { + insert: ' ggg', + }, + ], + }, + }, + children: [], + }, + { + type: 'block', + id: 'matchesReplaceMap[8]', + flavour: 'affine:paragraph', + props: { + type: 'text', + text: { + '$blocksuite:internal:text$': true, + delta: [ + { + insert: 'hhh', + }, + ], + }, + }, + children: [], + }, + ], + }; + + const mdAdapter = new MarkdownAdapter(createJob()); + const rawBlockSnapshot = await mdAdapter.toBlockSnapshot({ + file: markdown, + }); + expect(nanoidReplacement(rawBlockSnapshot)).toEqual(blockSnapshot); + }); + + test('bulleted list', async () => { + const markdown = `* aaa + + * bbb + + * ccc + + - ddd + +- eee +`; + + const blockSnapshot: BlockSnapshot = { + type: 'block', + id: 'matchesReplaceMap[0]', + flavour: 'affine:note', + props: { + xywh: '[0,0,800,95]', + background: DEFAULT_NOTE_BACKGROUND_COLOR, + index: 'a0', + hidden: false, + displayMode: NoteDisplayMode.DocAndEdgeless, + }, + children: [ + { + type: 'block', + id: 'matchesReplaceMap[1]', + flavour: 'affine:list', + props: { + type: 'bulleted', + text: { + '$blocksuite:internal:text$': true, + delta: [ + { + insert: 'aaa', + }, + ], + }, + checked: false, + collapsed: false, + order: null, + }, + children: [ + { + type: 'block', + id: 'matchesReplaceMap[2]', + flavour: 'affine:list', + props: { + type: 'bulleted', + text: { + '$blocksuite:internal:text$': true, + delta: [ + { + insert: 'bbb', + }, + ], + }, + checked: false, + collapsed: false, + order: null, + }, + children: [ + { + type: 'block', + id: 'matchesReplaceMap[3]', + flavour: 'affine:list', + props: { + type: 'bulleted', + text: { + '$blocksuite:internal:text$': true, + delta: [ + { + insert: 'ccc', + }, + ], + }, + checked: false, + collapsed: false, + order: null, + }, + children: [], + }, + ], + }, + { + type: 'block', + id: 'matchesReplaceMap[4]', + flavour: 'affine:list', + props: { + type: 'bulleted', + text: { + '$blocksuite:internal:text$': true, + delta: [ + { + insert: 'ddd', + }, + ], + }, + checked: false, + collapsed: false, + order: null, + }, + children: [], + }, + ], + }, + { + type: 'block', + id: 'matchesReplaceMap[5]', + flavour: 'affine:list', + props: { + type: 'bulleted', + text: { + '$blocksuite:internal:text$': true, + delta: [ + { + insert: 'eee', + }, + ], + }, + checked: false, + collapsed: false, + order: null, + }, + children: [], + }, + ], + }; + + const mdAdapter = new MarkdownAdapter(createJob()); + const rawBlockSnapshot = await mdAdapter.toBlockSnapshot({ + file: markdown, + }); + expect(nanoidReplacement(rawBlockSnapshot)).toEqual(blockSnapshot); + }); + + test('todo list', async () => { + const markdown = `- [ ] aaa + + - [x] bbb + + - [ ] ccc + + - [x] ddd + +- [ ] eee +`; + + const blockSnapshot: BlockSnapshot = { + type: 'block', + id: 'matchesReplaceMap[0]', + flavour: 'affine:note', + props: { + xywh: '[0,0,800,95]', + background: DEFAULT_NOTE_BACKGROUND_COLOR, + index: 'a0', + hidden: false, + displayMode: NoteDisplayMode.DocAndEdgeless, + }, + children: [ + { + type: 'block', + id: 'matchesReplaceMap[1]', + flavour: 'affine:list', + props: { + type: 'todo', + text: { + '$blocksuite:internal:text$': true, + delta: [ + { + insert: 'aaa', + }, + ], + }, + checked: false, + collapsed: false, + order: null, + }, + children: [ + { + type: 'block', + id: 'matchesReplaceMap[2]', + flavour: 'affine:list', + props: { + type: 'todo', + text: { + '$blocksuite:internal:text$': true, + delta: [ + { + insert: 'bbb', + }, + ], + }, + checked: true, + collapsed: false, + order: null, + }, + children: [ + { + type: 'block', + id: 'matchesReplaceMap[3]', + flavour: 'affine:list', + props: { + type: 'todo', + text: { + '$blocksuite:internal:text$': true, + delta: [ + { + insert: 'ccc', + }, + ], + }, + checked: false, + collapsed: false, + order: null, + }, + children: [], + }, + ], + }, + { + type: 'block', + id: 'matchesReplaceMap[4]', + flavour: 'affine:list', + props: { + type: 'todo', + text: { + '$blocksuite:internal:text$': true, + delta: [ + { + insert: 'ddd', + }, + ], + }, + checked: true, + collapsed: false, + order: null, + }, + children: [], + }, + ], + }, + { + type: 'block', + id: 'matchesReplaceMap[5]', + flavour: 'affine:list', + props: { + type: 'todo', + text: { + '$blocksuite:internal:text$': true, + delta: [ + { + insert: 'eee', + }, + ], + }, + checked: false, + collapsed: false, + order: null, + }, + children: [], + }, + ], + }; + + const mdAdapter = new MarkdownAdapter(createJob()); + const rawBlockSnapshot = await mdAdapter.toBlockSnapshot({ + file: markdown, + }); + expect(nanoidReplacement(rawBlockSnapshot)).toEqual(blockSnapshot); + }); + + test('non consecutive numbered list', async () => { + const markdown = ` +1. aaa + +bbb + +3. ccc +4. ddd +`; + + const blockSnapshot: BlockSnapshot = { + type: 'block', + id: 'matchesReplaceMap[0]', + flavour: 'affine:note', + props: { + xywh: '[0,0,800,95]', + background: DEFAULT_NOTE_BACKGROUND_COLOR, + index: 'a0', + hidden: false, + displayMode: NoteDisplayMode.DocAndEdgeless, + }, + children: [ + { + type: 'block', + id: 'matchesReplaceMap[1]', + flavour: 'affine:list', + props: { + type: 'numbered', + text: { + '$blocksuite:internal:text$': true, + delta: [ + { + insert: 'aaa', + }, + ], + }, + checked: false, + collapsed: false, + order: 1, + }, + children: [], + }, + { + type: 'block', + id: 'matchesReplaceMap[2]', + flavour: 'affine:paragraph', + props: { + type: 'text', + text: { + '$blocksuite:internal:text$': true, + delta: [ + { + insert: 'bbb', + }, + ], + }, + }, + children: [], + }, + { + type: 'block', + id: 'matchesReplaceMap[3]', + flavour: 'affine:list', + props: { + type: 'numbered', + text: { + '$blocksuite:internal:text$': true, + delta: [ + { + insert: 'ccc', + }, + ], + }, + checked: false, + collapsed: false, + order: 3, + }, + children: [], + }, + { + type: 'block', + id: 'matchesReplaceMap[4]', + flavour: 'affine:list', + props: { + type: 'numbered', + text: { + '$blocksuite:internal:text$': true, + delta: [ + { + insert: 'ddd', + }, + ], + }, + checked: false, + collapsed: false, + order: 4, + }, + children: [], + }, + ], + }; + + const mdAdapter = new MarkdownAdapter(createJob()); + const rawBlockSnapshot = await mdAdapter.toBlockSnapshot({ + file: markdown, + }); + expect(nanoidReplacement(rawBlockSnapshot)).toEqual(blockSnapshot); + }); + + test('code inline', async () => { + const markdown = 'aaa `bbb` ccc\n'; + const blockSnapshot: BlockSnapshot = { + type: 'block', + id: 'matchesReplaceMap[0]', + flavour: 'affine:note', + props: { + xywh: '[0,0,800,95]', + background: DEFAULT_NOTE_BACKGROUND_COLOR, + index: 'a0', + hidden: false, + displayMode: NoteDisplayMode.DocAndEdgeless, + }, + children: [ + { + type: 'block', + id: 'matchesReplaceMap[1]', + flavour: 'affine:paragraph', + props: { + type: 'text', + text: { + '$blocksuite:internal:text$': true, + delta: [ + { + insert: 'aaa ', + }, + { + insert: 'bbb', + attributes: { + code: true, + }, + }, + { + insert: ' ccc', + }, + ], + }, + }, + children: [], + }, + ], + }; + + const mdAdapter = new MarkdownAdapter(createJob()); + const rawBlockSnapshot = await mdAdapter.toBlockSnapshot({ + file: markdown, + }); + expect(nanoidReplacement(rawBlockSnapshot)).toEqual(blockSnapshot); + }); + + test('code inline - slice', async () => { + const markdown = '``` ```\n aaa'; + + const sliceSnapshot: SliceSnapshot = { + type: 'slice', + content: [ + { + type: 'block', + id: 'matchesReplaceMap[0]', + flavour: 'affine:note', + props: { + xywh: '[0,0,800,95]', + background: DEFAULT_NOTE_BACKGROUND_COLOR, + index: 'a0', + hidden: false, + displayMode: 'both', + }, + children: [ + { + type: 'block', + id: 'matchesReplaceMap[1]', + flavour: 'affine:paragraph', + props: { + type: 'text', + text: { + '$blocksuite:internal:text$': true, + delta: [ + { + insert: ' ', + attributes: { + code: true, + }, + }, + { + insert: '\n aaa', + }, + ], + }, + }, + children: [], + }, + ], + }, + ], + workspaceId: '', + pageId: '', + }; + + const mdAdapter = new MarkdownAdapter(createJob()); + const rawSliceSnapshot = await mdAdapter.toSliceSnapshot({ + file: markdown, + workspaceId: '', + pageId: '', + }); + expect(nanoidReplacement(rawSliceSnapshot!)).toEqual(sliceSnapshot); + }); + + test('link', async () => { + const markdown = 'aaa [bbb](https://affine.pro/) ccc\n'; + const blockSnapshot: BlockSnapshot = { + type: 'block', + id: 'matchesReplaceMap[0]', + flavour: 'affine:note', + props: { + xywh: '[0,0,800,95]', + background: DEFAULT_NOTE_BACKGROUND_COLOR, + index: 'a0', + hidden: false, + displayMode: NoteDisplayMode.DocAndEdgeless, + }, + children: [ + { + type: 'block', + id: 'matchesReplaceMap[1]', + flavour: 'affine:paragraph', + props: { + type: 'text', + text: { + '$blocksuite:internal:text$': true, + delta: [ + { + insert: 'aaa ', + }, + { + insert: 'bbb', + attributes: { + link: 'https://affine.pro/', + }, + }, + { + insert: ' ccc', + }, + ], + }, + }, + children: [], + }, + ], + }; + + const mdAdapter = new MarkdownAdapter(createJob()); + const rawBlockSnapshot = await mdAdapter.toBlockSnapshot({ + file: markdown, + }); + expect(nanoidReplacement(rawBlockSnapshot)).toEqual(blockSnapshot); + }); + + test('inline link', async () => { + const markdown = 'aaa https://affine.pro/ ccc\n'; + const blockSnapshot: BlockSnapshot = { + type: 'block', + id: 'matchesReplaceMap[0]', + flavour: 'affine:note', + props: { + xywh: '[0,0,800,95]', + background: DEFAULT_NOTE_BACKGROUND_COLOR, + index: 'a0', + hidden: false, + displayMode: NoteDisplayMode.DocAndEdgeless, + }, + children: [ + { + type: 'block', + id: 'matchesReplaceMap[1]', + flavour: 'affine:paragraph', + props: { + type: 'text', + text: { + '$blocksuite:internal:text$': true, + delta: [ + { + insert: 'aaa ', + }, + { + insert: 'https://affine.pro/', + attributes: { + link: 'https://affine.pro/', + }, + }, + { + insert: ' ccc', + }, + ], + }, + }, + children: [], + }, + ], + }; + + const mdAdapter = new MarkdownAdapter(createJob()); + const rawBlockSnapshot = await mdAdapter.toBlockSnapshot({ + file: markdown, + }); + expect(nanoidReplacement(rawBlockSnapshot)).toEqual(blockSnapshot); + }); + + test('bold', async () => { + const markdown = 'aaa**bbb**ccc\n'; + + const blockSnapshot: BlockSnapshot = { + type: 'block', + id: 'matchesReplaceMap[0]', + flavour: 'affine:note', + props: { + xywh: '[0,0,800,95]', + background: DEFAULT_NOTE_BACKGROUND_COLOR, + index: 'a0', + hidden: false, + displayMode: NoteDisplayMode.DocAndEdgeless, + }, + children: [ + { + type: 'block', + id: 'matchesReplaceMap[1]', + flavour: 'affine:paragraph', + props: { + type: 'text', + text: { + '$blocksuite:internal:text$': true, + delta: [ + { + insert: 'aaa', + }, + { + insert: 'bbb', + attributes: { + bold: true, + }, + }, + { + insert: 'ccc', + }, + ], + }, + }, + children: [], + }, + ], + }; + + const mdAdapter = new MarkdownAdapter(createJob()); + const rawBlockSnapshot = await mdAdapter.toBlockSnapshot({ + file: markdown, + }); + expect(nanoidReplacement(rawBlockSnapshot)).toEqual(blockSnapshot); + }); + + test('italic', async () => { + const markdown = 'aaa*bbb*ccc\n'; + + const blockSnapshot: BlockSnapshot = { + type: 'block', + id: 'matchesReplaceMap[0]', + flavour: 'affine:note', + props: { + xywh: '[0,0,800,95]', + background: DEFAULT_NOTE_BACKGROUND_COLOR, + index: 'a0', + hidden: false, + displayMode: NoteDisplayMode.DocAndEdgeless, + }, + children: [ + { + type: 'block', + id: 'matchesReplaceMap[1]', + flavour: 'affine:paragraph', + props: { + type: 'text', + text: { + '$blocksuite:internal:text$': true, + delta: [ + { + insert: 'aaa', + }, + { + insert: 'bbb', + attributes: { + italic: true, + }, + }, + { + insert: 'ccc', + }, + ], + }, + }, + children: [], + }, + ], + }; + + const mdAdapter = new MarkdownAdapter(createJob()); + const rawBlockSnapshot = await mdAdapter.toBlockSnapshot({ + file: markdown, + }); + expect(nanoidReplacement(rawBlockSnapshot)).toEqual(blockSnapshot); + }); + + test('table', async () => { + const markdown = `| aaa | bbb | ccc | +| --- | --- | --- | +| ddd | eee | fff | +`; + + const blockSnapshot: BlockSnapshot = { + type: 'block', + id: 'matchesReplaceMap[0]', + flavour: 'affine:note', + props: { + xywh: '[0,0,800,95]', + background: DEFAULT_NOTE_BACKGROUND_COLOR, + index: 'a0', + hidden: false, + displayMode: NoteDisplayMode.DocAndEdgeless, + }, + children: [ + { + type: 'block', + id: 'matchesReplaceMap[1]', + flavour: 'affine:database', + props: { + views: [ + { + id: 'matchesReplaceMap[2]', + name: 'Table View', + mode: 'table', + columns: [], + filter: { + type: 'group', + op: 'and', + conditions: [], + }, + header: { + titleColumn: 'matchesReplaceMap[9]', + iconColumn: 'type', + }, + }, + ], + title: { + '$blocksuite:internal:text$': true, + delta: [], + }, + cells: { + 'matchesReplaceMap[12]': { + 'matchesReplaceMap[10]': { + columnId: 'matchesReplaceMap[10]', + value: { + '$blocksuite:internal:text$': true, + delta: [ + { + insert: 'eee', + }, + ], + }, + }, + 'matchesReplaceMap[11]': { + columnId: 'matchesReplaceMap[11]', + value: { + '$blocksuite:internal:text$': true, + delta: [ + { + insert: 'fff', + }, + ], + }, + }, + }, + }, + columns: [ + { + type: 'title', + name: 'aaa', + data: {}, + id: 'matchesReplaceMap[9]', + }, + { + type: 'rich-text', + name: 'bbb', + data: {}, + id: 'matchesReplaceMap[10]', + }, + { + type: 'rich-text', + name: 'ccc', + data: {}, + id: 'matchesReplaceMap[11]', + }, + ], + }, + children: [ + { + type: 'block', + id: 'matchesReplaceMap[12]', + flavour: 'affine:paragraph', + props: { + text: { + '$blocksuite:internal:text$': true, + delta: [ + { + insert: 'ddd', + }, + ], + }, + type: 'text', + }, + children: [], + }, + ], + }, + ], + }; + + const mdAdapter = new MarkdownAdapter(createJob()); + const rawBlockSnapshot = await mdAdapter.toBlockSnapshot({ + file: markdown, + }); + expect(nanoidReplacement(rawBlockSnapshot)).toEqual(blockSnapshot); + }); + + test('html tag', async () => { + const markdown = `\n`; + + const blockSnapshot: BlockSnapshot = { + type: 'block', + id: 'matchesReplaceMap[0]', + flavour: 'affine:note', + props: { + xywh: '[0,0,800,95]', + background: DEFAULT_NOTE_BACKGROUND_COLOR, + index: 'a0', + hidden: false, + displayMode: NoteDisplayMode.DocAndEdgeless, + }, + children: [ + { + type: 'block', + id: 'matchesReplaceMap[1]', + flavour: 'affine:paragraph', + props: { + text: { + '$blocksuite:internal:text$': true, + delta: [ + { + insert: '', + }, + ], + }, + type: 'text', + }, + children: [], + }, + ], + }; + + const mdAdapter = new MarkdownAdapter(createJob()); + const rawBlockSnapshot = await mdAdapter.toBlockSnapshot({ + file: markdown, + }); + expect(nanoidReplacement(rawBlockSnapshot)).toEqual(blockSnapshot); + }); + + test('inline latex', async () => { + const markdown = 'inline $E=mc^2$ latex\n'; + const blockSnapshot: BlockSnapshot = { + type: 'block', + id: 'matchesReplaceMap[0]', + flavour: 'affine:note', + props: { + xywh: '[0,0,800,95]', + background: DEFAULT_NOTE_BACKGROUND_COLOR, + index: 'a0', + hidden: false, + displayMode: NoteDisplayMode.DocAndEdgeless, + }, + children: [ + { + type: 'block', + id: 'matchesReplaceMap[1]', + flavour: 'affine:paragraph', + props: { + type: 'text', + text: { + '$blocksuite:internal:text$': true, + delta: [ + { + insert: 'inline ', + }, + { + insert: ' ', + attributes: { + latex: 'E=mc^2', + }, + }, + { + insert: ' latex', + }, + ], + }, + }, + children: [], + }, + ], + }; + + const mdAdapter = new MarkdownAdapter(createJob()); + const rawBlockSnapshot = await mdAdapter.toBlockSnapshot({ + file: markdown, + }); + expect(nanoidReplacement(rawBlockSnapshot)).toEqual(blockSnapshot); + }); + + test('latex block', async () => { + const markdown = '$$\nE=mc^2\n$$\n'; + + const blockSnapshot: BlockSnapshot = { + type: 'block', + id: 'matchesReplaceMap[0]', + flavour: 'affine:note', + props: { + xywh: '[0,0,800,95]', + background: DEFAULT_NOTE_BACKGROUND_COLOR, + index: 'a0', + hidden: false, + displayMode: NoteDisplayMode.DocAndEdgeless, + }, + children: [ + { + type: 'block', + id: 'matchesReplaceMap[1]', + flavour: 'affine:latex', + props: { + latex: 'E=mc^2', + }, + children: [], + }, + ], + }; + + const mdAdapter = new MarkdownAdapter(createJob()); + const rawBlockSnapshot = await mdAdapter.toBlockSnapshot({ + file: markdown, + }); + expect(nanoidReplacement(rawBlockSnapshot)).toEqual(blockSnapshot); + }); + + test('reference', async () => { + const markdown = ` +aaa + + bbb + +[untitled](https://example.com/4T5ObMgEIMII-4Bexyta1) + + ccc + + ddd + + eee[test](https://example.com/deadbeef?mode=page\\&blockIds=abc%2C123\\&elementIds=def%2C456)[](https://example.com/foobar) + + fff + + ggg + +hhh +`; + const blockSnapshot: BlockSnapshot = { + type: 'block', + id: 'matchesReplaceMap[0]', + flavour: 'affine:note', + props: { + xywh: '[0,0,800,95]', + background: '--affine-note-background-white', + index: 'a0', + hidden: false, + displayMode: 'both', + }, + children: [ + { + type: 'block', + id: 'matchesReplaceMap[1]', + flavour: 'affine:paragraph', + props: { + type: 'text', + text: { + '$blocksuite:internal:text$': true, + delta: [{ insert: 'aaa' }], + }, + }, + children: [], + }, + { + type: 'block', + id: 'matchesReplaceMap[2]', + flavour: 'affine:paragraph', + props: { + type: 'text', + text: { + '$blocksuite:internal:text$': true, + delta: [{ insert: ' bbb' }], + }, + }, + children: [], + }, + { + type: 'block', + id: 'matchesReplaceMap[3]', + flavour: 'affine:paragraph', + props: { + type: 'text', + text: { + '$blocksuite:internal:text$': true, + delta: [ + { + insert: ' ', + attributes: { + reference: { + type: 'LinkedPage', + pageId: '4T5ObMgEIMII-4Bexyta1', + params: {}, + }, + }, + }, + ], + }, + }, + children: [], + }, + { + type: 'block', + id: 'matchesReplaceMap[4]', + flavour: 'affine:paragraph', + props: { + type: 'text', + text: { + '$blocksuite:internal:text$': true, + delta: [{ insert: ' ccc' }], + }, + }, + children: [], + }, + { + type: 'block', + id: 'matchesReplaceMap[5]', + flavour: 'affine:paragraph', + props: { + type: 'text', + text: { + '$blocksuite:internal:text$': true, + delta: [{ insert: ' ddd' }], + }, + }, + children: [], + }, + { + type: 'block', + id: 'matchesReplaceMap[6]', + flavour: 'affine:paragraph', + props: { + type: 'text', + text: { + '$blocksuite:internal:text$': true, + delta: [ + { insert: ' eee' }, + { + insert: ' ', + attributes: { + reference: { + type: 'LinkedPage', + pageId: 'deadbeef', + params: { + mode: 'page', + blockIds: ['abc', '123'], + elementIds: ['def', '456'], + }, + }, + }, + }, + { + insert: ' ', + attributes: { + reference: { + type: 'LinkedPage', + pageId: 'foobar', + params: {}, + }, + }, + }, + ], + }, + }, + children: [], + }, + { + type: 'block', + id: 'matchesReplaceMap[7]', + flavour: 'affine:paragraph', + props: { + type: 'text', + text: { + '$blocksuite:internal:text$': true, + delta: [{ insert: ' fff' }], + }, + }, + children: [], + }, + { + type: 'block', + id: 'matchesReplaceMap[8]', + flavour: 'affine:paragraph', + props: { + type: 'text', + text: { + '$blocksuite:internal:text$': true, + delta: [{ insert: ' ggg' }], + }, + }, + children: [], + }, + { + type: 'block', + id: 'matchesReplaceMap[9]', + flavour: 'affine:paragraph', + props: { + type: 'text', + text: { + '$blocksuite:internal:text$': true, + delta: [{ insert: 'hhh' }], + }, + }, + children: [], + }, + ], + }; + const middleware: JobMiddleware = ({ adapterConfigs }) => { + adapterConfigs.set('docLinkBaseUrl', 'https://example.com'); + }; + const mdAdapter = new MarkdownAdapter(createJob([middleware])); + const rawBlockSnapshot = await mdAdapter.toBlockSnapshot({ + file: markdown, + }); + expect(nanoidReplacement(rawBlockSnapshot)).toEqual(blockSnapshot); + }); +}); diff --git a/blocksuite/blocks/src/__tests__/adapters/notion-html.unit.spec.ts b/blocksuite/blocks/src/__tests__/adapters/notion-html.unit.spec.ts new file mode 100644 index 0000000000..e2a0409025 --- /dev/null +++ b/blocksuite/blocks/src/__tests__/adapters/notion-html.unit.spec.ts @@ -0,0 +1,2061 @@ +import { + DEFAULT_NOTE_BACKGROUND_COLOR, + NoteDisplayMode, +} from '@blocksuite/affine-model'; +import { + AssetsManager, + type BlockSnapshot, + MemoryBlobCRUD, +} from '@blocksuite/store'; +import { describe, expect, test } from 'vitest'; + +import { NotionHtmlAdapter } from '../../_common/adapters/notion-html/notion-html.js'; +import { nanoidReplacement } from '../../_common/test-utils/test-utils.js'; +import { createJob } from '../utils/create-job.js'; + +describe('notion html to snapshot', () => { + test('code', async () => { + const html = `
+
def fib(n):
+  a,b = 1,1
+  for i in range(n-1):
+      a,b = b,a+b
+  return a
+
`; + + const blockSnapshot: BlockSnapshot = { + type: 'block', + id: 'matchesReplaceMap[0]', + flavour: 'affine:note', + props: { + xywh: '[0,0,800,95]', + background: DEFAULT_NOTE_BACKGROUND_COLOR, + index: 'a0', + hidden: false, + displayMode: NoteDisplayMode.DocAndEdgeless, + }, + children: [ + { + type: 'block', + id: 'matchesReplaceMap[1]', + flavour: 'affine:code', + props: { + language: 'Plain Text', + text: { + '$blocksuite:internal:text$': true, + delta: [ + { + insert: + 'def fib(n):\n a,b = 1,1\n for i in range(n-1):\n a,b = b,a+b\n return a', + }, + ], + }, + }, + children: [], + }, + ], + }; + + const adapter = new NotionHtmlAdapter(createJob()); + const rawBlockSnapshot = await adapter.toBlockSnapshot({ + file: html, + }); + expect(nanoidReplacement(rawBlockSnapshot)).toEqual(blockSnapshot); + }); + + test('paragraph', async () => { + const html = `
+

aaa +

+

bbb +

+

ccc

+
+

+

ddd

+
+

+

eee

+
`; + + const blockSnapshot: BlockSnapshot = { + type: 'block', + id: 'matchesReplaceMap[0]', + flavour: 'affine:note', + props: { + xywh: '[0,0,800,95]', + background: DEFAULT_NOTE_BACKGROUND_COLOR, + index: 'a0', + hidden: false, + displayMode: NoteDisplayMode.DocAndEdgeless, + }, + children: [ + { + type: 'block', + id: 'matchesReplaceMap[1]', + flavour: 'affine:paragraph', + props: { + type: 'text', + text: { + '$blocksuite:internal:text$': true, + delta: [ + { + insert: 'aaa', + }, + ], + }, + }, + children: [ + { + type: 'block', + id: 'matchesReplaceMap[2]', + flavour: 'affine:paragraph', + props: { + type: 'text', + text: { + '$blocksuite:internal:text$': true, + delta: [ + { + insert: 'bbb', + }, + ], + }, + }, + children: [ + { + type: 'block', + id: 'matchesReplaceMap[3]', + flavour: 'affine:paragraph', + props: { + type: 'text', + text: { + '$blocksuite:internal:text$': true, + delta: [ + { + insert: 'ccc', + }, + ], + }, + }, + children: [], + }, + ], + }, + { + type: 'block', + id: 'matchesReplaceMap[4]', + flavour: 'affine:paragraph', + props: { + type: 'text', + text: { + '$blocksuite:internal:text$': true, + delta: [ + { + insert: 'ddd', + }, + ], + }, + }, + children: [], + }, + ], + }, + { + type: 'block', + id: 'matchesReplaceMap[5]', + flavour: 'affine:paragraph', + props: { + type: 'text', + text: { + '$blocksuite:internal:text$': true, + delta: [ + { + insert: 'eee', + }, + ], + }, + }, + children: [], + }, + ], + }; + const adapter = new NotionHtmlAdapter(createJob()); + const rawBlockSnapshot = await adapter.toBlockSnapshot({ + file: html, + }); + expect(nanoidReplacement(rawBlockSnapshot)).toEqual(blockSnapshot); + }); + + test('heading', async () => { + const html = `
+

1

+

2

+

3

+
`; + + const blockSnapshot: BlockSnapshot = { + type: 'block', + id: 'matchesReplaceMap[0]', + flavour: 'affine:note', + props: { + xywh: '[0,0,800,95]', + background: DEFAULT_NOTE_BACKGROUND_COLOR, + index: 'a0', + hidden: false, + displayMode: NoteDisplayMode.DocAndEdgeless, + }, + children: [ + { + type: 'block', + id: 'matchesReplaceMap[1]', + flavour: 'affine:paragraph', + props: { + type: 'h1', + text: { + '$blocksuite:internal:text$': true, + delta: [ + { + insert: '1', + }, + ], + }, + }, + children: [], + }, + { + type: 'block', + id: 'matchesReplaceMap[2]', + flavour: 'affine:paragraph', + props: { + type: 'h2', + text: { + '$blocksuite:internal:text$': true, + delta: [ + { + insert: '2', + }, + ], + }, + }, + children: [], + }, + { + type: 'block', + id: 'matchesReplaceMap[3]', + flavour: 'affine:paragraph', + props: { + type: 'h3', + text: { + '$blocksuite:internal:text$': true, + delta: [ + { + insert: '3', + }, + ], + }, + }, + children: [], + }, + ], + }; + + const adapter = new NotionHtmlAdapter(createJob()); + const rawBlockSnapshot = await adapter.toBlockSnapshot({ + file: html, + }); + expect(nanoidReplacement(rawBlockSnapshot)).toEqual(blockSnapshot); + }); + + test('list', async () => { + const html = `
+
    +
  • aaa +
      +
    • bbb +
        +
      • ccc
      • +
      +
    • +
    +
      +
    • ddd
    • +
    +
  • +
+
    +
  • eee
  • +
+
    +
  • +
    aaa +
    +
      +
    • +
      bbb +
      +
        +
      • +
        ccc +
        +
      • +
      +
      +
    • +
    +
      +
    • +
      ddd +
      +
    • +
    +
    +
  • +
+
    +
  • +
    eee +
    +
  • +
+
    +
  1. aaa
      +
    1. bbb
        +
      1. ccc
      2. +
      +
    2. +
    +
      +
    1. ddd
    2. +
    +
  2. +
+
    +
  1. eee
  2. +
+
    +
  • +
    + aaa +
      +
    • +
      + bbb +
        +
      • +
        + ccc +

        ddd

        +
        +
      • +
      +

      eee

      +
      +
    • +
    +

    fff

    +
    +
  • +
+
`; + + const blockSnapshot: BlockSnapshot = { + type: 'block', + id: 'matchesReplaceMap[0]', + flavour: 'affine:note', + props: { + xywh: '[0,0,800,95]', + background: DEFAULT_NOTE_BACKGROUND_COLOR, + index: 'a0', + hidden: false, + displayMode: NoteDisplayMode.DocAndEdgeless, + }, + children: [ + { + type: 'block', + id: 'matchesReplaceMap[1]', + flavour: 'affine:list', + props: { + type: 'bulleted', + text: { + '$blocksuite:internal:text$': true, + delta: [ + { + insert: 'aaa', + }, + ], + }, + checked: false, + collapsed: false, + }, + children: [ + { + type: 'block', + id: 'matchesReplaceMap[2]', + flavour: 'affine:list', + props: { + type: 'bulleted', + text: { + '$blocksuite:internal:text$': true, + delta: [ + { + insert: 'bbb', + }, + ], + }, + checked: false, + collapsed: false, + }, + children: [ + { + type: 'block', + id: 'matchesReplaceMap[3]', + flavour: 'affine:list', + props: { + type: 'bulleted', + text: { + '$blocksuite:internal:text$': true, + delta: [ + { + insert: 'ccc', + }, + ], + }, + checked: false, + collapsed: false, + }, + children: [], + }, + ], + }, + { + type: 'block', + id: 'matchesReplaceMap[4]', + flavour: 'affine:list', + props: { + type: 'bulleted', + text: { + '$blocksuite:internal:text$': true, + delta: [ + { + insert: 'ddd', + }, + ], + }, + checked: false, + collapsed: false, + }, + children: [], + }, + ], + }, + { + type: 'block', + id: 'matchesReplaceMap[5]', + flavour: 'affine:list', + props: { + type: 'bulleted', + text: { + '$blocksuite:internal:text$': true, + delta: [ + { + insert: 'eee', + }, + ], + }, + checked: false, + collapsed: false, + }, + children: [], + }, + { + type: 'block', + id: 'matchesReplaceMap[6]', + flavour: 'affine:list', + props: { + type: 'todo', + text: { + '$blocksuite:internal:text$': true, + delta: [ + { + insert: 'aaa', + }, + ], + }, + checked: false, + collapsed: false, + }, + children: [ + { + type: 'block', + id: 'matchesReplaceMap[7]', + flavour: 'affine:list', + props: { + type: 'todo', + text: { + '$blocksuite:internal:text$': true, + delta: [ + { + insert: 'bbb', + }, + ], + }, + checked: true, + collapsed: false, + }, + children: [ + { + type: 'block', + id: 'matchesReplaceMap[8]', + flavour: 'affine:list', + props: { + type: 'todo', + text: { + '$blocksuite:internal:text$': true, + delta: [ + { + insert: 'ccc', + }, + ], + }, + checked: false, + collapsed: false, + }, + children: [], + }, + ], + }, + { + type: 'block', + id: 'matchesReplaceMap[9]', + flavour: 'affine:list', + props: { + type: 'todo', + text: { + '$blocksuite:internal:text$': true, + delta: [ + { + insert: 'ddd', + }, + ], + }, + checked: false, + collapsed: false, + }, + children: [], + }, + ], + }, + { + type: 'block', + id: 'matchesReplaceMap[10]', + flavour: 'affine:list', + props: { + type: 'todo', + text: { + '$blocksuite:internal:text$': true, + delta: [ + { + insert: 'eee', + }, + ], + }, + checked: true, + collapsed: false, + }, + children: [], + }, + { + type: 'block', + id: 'matchesReplaceMap[11]', + flavour: 'affine:list', + props: { + type: 'numbered', + text: { + '$blocksuite:internal:text$': true, + delta: [ + { + insert: 'aaa', + }, + ], + }, + checked: false, + collapsed: false, + }, + children: [ + { + type: 'block', + id: 'matchesReplaceMap[12]', + flavour: 'affine:list', + props: { + type: 'numbered', + text: { + '$blocksuite:internal:text$': true, + delta: [ + { + insert: 'bbb', + }, + ], + }, + checked: false, + collapsed: false, + }, + children: [ + { + type: 'block', + id: 'matchesReplaceMap[13]', + flavour: 'affine:list', + props: { + type: 'numbered', + text: { + '$blocksuite:internal:text$': true, + delta: [ + { + insert: 'ccc', + }, + ], + }, + checked: false, + collapsed: false, + }, + children: [], + }, + ], + }, + { + type: 'block', + id: 'matchesReplaceMap[14]', + flavour: 'affine:list', + props: { + type: 'numbered', + text: { + '$blocksuite:internal:text$': true, + delta: [ + { + insert: 'ddd', + }, + ], + }, + checked: false, + collapsed: false, + }, + children: [], + }, + ], + }, + { + type: 'block', + id: 'matchesReplaceMap[15]', + flavour: 'affine:list', + props: { + type: 'numbered', + text: { + '$blocksuite:internal:text$': true, + delta: [ + { + insert: 'eee', + }, + ], + }, + checked: false, + collapsed: false, + }, + children: [], + }, + { + type: 'block', + id: 'matchesReplaceMap[16]', + flavour: 'affine:list', + props: { + type: 'bulleted', + text: { + '$blocksuite:internal:text$': true, + delta: [ + { + insert: 'aaa', + }, + ], + }, + checked: false, + collapsed: false, + }, + children: [ + { + type: 'block', + id: 'matchesReplaceMap[17]', + flavour: 'affine:list', + props: { + type: 'bulleted', + text: { + '$blocksuite:internal:text$': true, + delta: [ + { + insert: 'bbb', + }, + ], + }, + checked: false, + collapsed: false, + }, + children: [ + { + type: 'block', + id: 'matchesReplaceMap[18]', + flavour: 'affine:list', + props: { + type: 'bulleted', + text: { + '$blocksuite:internal:text$': true, + delta: [ + { + insert: 'ccc', + }, + ], + }, + checked: false, + collapsed: false, + }, + children: [ + { + type: 'block', + id: 'matchesReplaceMap[19]', + flavour: 'affine:paragraph', + props: { + type: 'text', + text: { + '$blocksuite:internal:text$': true, + delta: [ + { + insert: 'ddd', + }, + ], + }, + }, + children: [], + }, + ], + }, + { + type: 'block', + id: 'matchesReplaceMap[20]', + flavour: 'affine:paragraph', + props: { + type: 'text', + text: { + '$blocksuite:internal:text$': true, + delta: [ + { + insert: 'eee', + }, + ], + }, + }, + children: [], + }, + ], + }, + { + type: 'block', + id: 'matchesReplaceMap[21]', + flavour: 'affine:paragraph', + props: { + type: 'text', + text: { + '$blocksuite:internal:text$': true, + delta: [ + { + insert: 'fff', + }, + ], + }, + }, + children: [], + }, + ], + }, + ], + }; + + const adapter = new NotionHtmlAdapter(createJob()); + const rawBlockSnapshot = await adapter.toBlockSnapshot({ + file: html, + }); + expect(nanoidReplacement(rawBlockSnapshot)).toEqual(blockSnapshot); + }); + + test('nested list with nested paragraph', async () => { + const html = `
+
    +
  • list 1 +
      +
    • list 2 +
        +
      • list 3 +

        paragraph 1 +

        +

        paragraph 2

        +
        +

        +
      • +
      +
    • +
    +
  • +
+
`; + + const blockSnapshot: BlockSnapshot = { + type: 'block', + id: 'matchesReplaceMap[0]', + flavour: 'affine:note', + props: { + xywh: '[0,0,800,95]', + background: DEFAULT_NOTE_BACKGROUND_COLOR, + index: 'a0', + hidden: false, + displayMode: NoteDisplayMode.DocAndEdgeless, + }, + children: [ + { + type: 'block', + id: 'matchesReplaceMap[1]', + flavour: 'affine:list', + props: { + type: 'bulleted', + text: { + '$blocksuite:internal:text$': true, + delta: [ + { + insert: 'list 1', + }, + ], + }, + checked: false, + collapsed: false, + }, + children: [ + { + type: 'block', + id: 'matchesReplaceMap[2]', + flavour: 'affine:list', + props: { + type: 'bulleted', + text: { + '$blocksuite:internal:text$': true, + delta: [ + { + insert: 'list 2', + }, + ], + }, + checked: false, + collapsed: false, + }, + children: [ + { + type: 'block', + id: 'matchesReplaceMap[3]', + flavour: 'affine:list', + props: { + type: 'bulleted', + text: { + '$blocksuite:internal:text$': true, + delta: [ + { + insert: 'list 3', + }, + ], + }, + checked: false, + collapsed: false, + }, + children: [ + { + type: 'block', + id: 'matchesReplaceMap[4]', + flavour: 'affine:paragraph', + props: { + type: 'text', + text: { + '$blocksuite:internal:text$': true, + delta: [ + { + insert: 'paragraph 1', + }, + ], + }, + }, + children: [ + { + type: 'block', + id: 'matchesReplaceMap[5]', + flavour: 'affine:paragraph', + props: { + type: 'text', + text: { + '$blocksuite:internal:text$': true, + delta: [ + { + insert: 'paragraph 2', + }, + ], + }, + }, + children: [], + }, + ], + }, + ], + }, + ], + }, + ], + }, + ], + }; + + const adapter = new NotionHtmlAdapter(createJob()); + const rawBlockSnapshot = await adapter.toBlockSnapshot({ + file: html, + }); + expect(nanoidReplacement(rawBlockSnapshot)).toEqual(blockSnapshot); + }); + + test('quote', async () => { + const html = `
+
aaa
+
bbb

ccc +

+

ddd

+
+

+
+
`; + + const blockSnapshot: BlockSnapshot = { + type: 'block', + id: 'matchesReplaceMap[0]', + flavour: 'affine:note', + props: { + xywh: '[0,0,800,95]', + background: DEFAULT_NOTE_BACKGROUND_COLOR, + index: 'a0', + hidden: false, + displayMode: NoteDisplayMode.DocAndEdgeless, + }, + children: [ + { + type: 'block', + id: 'matchesReplaceMap[1]', + flavour: 'affine:paragraph', + props: { + type: 'quote', + text: { + '$blocksuite:internal:text$': true, + delta: [ + { + insert: 'aaa', + }, + ], + }, + }, + children: [], + }, + { + type: 'block', + id: 'matchesReplaceMap[2]', + flavour: 'affine:paragraph', + props: { + type: 'quote', + text: { + '$blocksuite:internal:text$': true, + delta: [ + { + insert: 'bbb', + }, + ], + }, + }, + children: [ + { + type: 'block', + id: 'matchesReplaceMap[3]', + flavour: 'affine:paragraph', + props: { + type: 'quote', + text: { + '$blocksuite:internal:text$': true, + delta: [ + { + insert: 'ccc', + }, + ], + }, + }, + children: [ + { + type: 'block', + id: 'matchesReplaceMap[4]', + flavour: 'affine:paragraph', + props: { + type: 'quote', + text: { + '$blocksuite:internal:text$': true, + delta: [ + { + insert: 'ddd', + }, + ], + }, + }, + children: [], + }, + ], + }, + ], + }, + ], + }; + + const adapter = new NotionHtmlAdapter(createJob()); + const rawBlockSnapshot = await adapter.toBlockSnapshot({ + file: html, + }); + expect(nanoidReplacement(rawBlockSnapshot)).toEqual(blockSnapshot); + }); + + test('callout', async () => { + const html = `
+
+
💡
+
aaa
+
+
`; + + const blockSnapshot: BlockSnapshot = { + type: 'block', + id: 'matchesReplaceMap[0]', + flavour: 'affine:note', + props: { + xywh: '[0,0,800,95]', + background: DEFAULT_NOTE_BACKGROUND_COLOR, + index: 'a0', + hidden: false, + displayMode: NoteDisplayMode.DocAndEdgeless, + }, + children: [ + { + type: 'block', + id: 'matchesReplaceMap[1]', + flavour: 'affine:paragraph', + props: { + type: 'quote', + text: { + '$blocksuite:internal:text$': true, + delta: [ + { + insert: '💡\n', + }, + { + insert: 'aaa', + }, + ], + }, + }, + children: [], + }, + ], + }; + + const adapter = new NotionHtmlAdapter(createJob()); + const rawBlockSnapshot = await adapter.toBlockSnapshot({ + file: html, + }); + expect(nanoidReplacement(rawBlockSnapshot)).toEqual(blockSnapshot); + }); + + test('divider', async () => { + const html = `
+

aaa

+
+

bbb

+
`; + + const blockSnapshot: BlockSnapshot = { + type: 'block', + id: 'matchesReplaceMap[0]', + flavour: 'affine:note', + props: { + xywh: '[0,0,800,95]', + background: DEFAULT_NOTE_BACKGROUND_COLOR, + index: 'a0', + hidden: false, + displayMode: NoteDisplayMode.DocAndEdgeless, + }, + children: [ + { + type: 'block', + id: 'matchesReplaceMap[1]', + flavour: 'affine:paragraph', + props: { + type: 'text', + text: { + '$blocksuite:internal:text$': true, + delta: [ + { + insert: 'aaa', + }, + ], + }, + }, + children: [], + }, + { + type: 'block', + id: 'matchesReplaceMap[2]', + flavour: 'affine:divider', + props: {}, + children: [], + }, + { + type: 'block', + id: 'matchesReplaceMap[3]', + flavour: 'affine:paragraph', + props: { + type: 'text', + text: { + '$blocksuite:internal:text$': true, + delta: [ + { + insert: 'bbb', + }, + ], + }, + }, + children: [], + }, + ], + }; + + const adapter = new NotionHtmlAdapter(createJob()); + const rawBlockSnapshot = await adapter.toBlockSnapshot({ + file: html, + }); + expect(nanoidReplacement(rawBlockSnapshot)).toEqual(blockSnapshot); + }); + + test('page-link', async () => { + const html = `
+ +
`; + + const blockSnapshot: BlockSnapshot = { + type: 'block', + id: 'matchesReplaceMap[0]', + flavour: 'affine:note', + props: { + xywh: '[0,0,800,95]', + background: DEFAULT_NOTE_BACKGROUND_COLOR, + index: 'a0', + hidden: false, + displayMode: NoteDisplayMode.DocAndEdgeless, + }, + children: [ + { + type: 'block', + id: 'matchesReplaceMap[1]', + flavour: 'affine:paragraph', + props: { + type: 'text', + text: { + '$blocksuite:internal:text$': true, + delta: [ + { + insert: 'Untitled', + attributes: { + link: 'https://www.notion.so/ed3d2ae962f5433a90499ddbd1c81ac5?pvs=21', + }, + }, + ], + }, + }, + children: [], + }, + ], + }; + + const adapter = new NotionHtmlAdapter(createJob()); + const rawBlockSnapshot = await adapter.toBlockSnapshot({ + file: html, + }); + expect(nanoidReplacement(rawBlockSnapshot)).toEqual(blockSnapshot); + }); + + test('image', async () => { + const html = `
+
+
+
`; + + const blockSnapshot: BlockSnapshot = { + type: 'block', + id: 'matchesReplaceMap[0]', + flavour: 'affine:note', + props: { + xywh: '[0,0,800,95]', + background: DEFAULT_NOTE_BACKGROUND_COLOR, + index: 'a0', + hidden: false, + displayMode: NoteDisplayMode.DocAndEdgeless, + }, + children: [ + { + type: 'block', + id: 'matchesReplaceMap[1]', + flavour: 'affine:image', + props: { + sourceId: 'matchesReplaceMap[2]', + }, + children: [], + }, + ], + }; + + const adapter = new NotionHtmlAdapter(createJob()); + const rawBlockSnapshot = await adapter.toBlockSnapshot({ + file: html, + assets: new AssetsManager({ blob: new MemoryBlobCRUD() }), + }); + expect(nanoidReplacement(rawBlockSnapshot)).toEqual(blockSnapshot); + }); + + test('bookmark', async () => { + const html = ``; + + const blockSnapshot: BlockSnapshot = { + type: 'block', + id: 'matchesReplaceMap[0]', + flavour: 'affine:note', + props: { + xywh: '[0,0,800,95]', + background: DEFAULT_NOTE_BACKGROUND_COLOR, + index: 'a0', + hidden: false, + displayMode: NoteDisplayMode.DocAndEdgeless, + }, + children: [ + { + type: 'block', + id: 'matchesReplaceMap[1]', + flavour: 'affine:bookmark', + props: { + type: 'card', + url: 'https://affine.pro/', + title: 'AFFiNE - All In One KnowledgeOS', + description: + 'The universal editor that lets you work, play, present or create just about anything.', + icon: 'https://affine.pro/favicon-96.png', + }, + children: [], + }, + ], + }; + + const adapter = new NotionHtmlAdapter(createJob()); + const rawBlockSnapshot = await adapter.toBlockSnapshot({ + file: html, + }); + expect(nanoidReplacement(rawBlockSnapshot)).toEqual(blockSnapshot); + }); + + test('embeded', async () => { + const html = `
+
+ +
+
`; + + const blockSnapshot: BlockSnapshot = { + type: 'block', + id: 'matchesReplaceMap[0]', + flavour: 'affine:note', + props: { + xywh: '[0,0,800,95]', + background: DEFAULT_NOTE_BACKGROUND_COLOR, + index: 'a0', + hidden: false, + displayMode: NoteDisplayMode.DocAndEdgeless, + }, + children: [ + { + type: 'block', + id: 'matchesReplaceMap[1]', + flavour: 'affine:attachment', + props: { + name: 'README.pdf', + size: 0, + type: '', + sourceId: 'matchesReplaceMap[2]', + }, + children: [], + }, + ], + }; + + const adapter = new NotionHtmlAdapter(createJob()); + const blobCRUD = new MemoryBlobCRUD(); + const key = await blobCRUD.set(new File([], 'README.pdf')); + const assestsManager = new AssetsManager({ blob: blobCRUD }); + assestsManager + .getPathBlobIdMap() + .set('Untitled 3d2ae962f5433a90499ddbd1c81ac507/README.pdf', key); + await assestsManager.readFromBlob(key); + const rawBlockSnapshot = await adapter.toBlockSnapshot({ + file: html, + assets: assestsManager, + }); + expect(nanoidReplacement(rawBlockSnapshot)).toEqual(blockSnapshot); + }); + + test('table', async () => { + const html = `
+
+

Table View

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ Name + Tags + Multi-select + Number + Status + Checkbox
https://affine.pro + aaaaaabbb5 +
Not started +
+
+
Untitledbbbaaaccc7 +
Not started +
+
+
Untitledaaabbb9 +
Not started +
+
+
+
+

+
`; + + const blockSnapshot: BlockSnapshot = { + type: 'block', + id: 'matchesReplaceMap[0]', + flavour: 'affine:note', + props: { + xywh: '[0,0,800,95]', + background: DEFAULT_NOTE_BACKGROUND_COLOR, + index: 'a0', + hidden: false, + displayMode: NoteDisplayMode.DocAndEdgeless, + }, + children: [ + { + type: 'block', + id: 'matchesReplaceMap[1]', + flavour: 'affine:database', + props: { + views: [ + { + id: 'matchesReplaceMap[2]', + name: 'Table View', + mode: 'table', + columns: [], + filter: { + type: 'group', + op: 'and', + conditions: [], + }, + header: { + titleColumn: 'matchesReplaceMap[4]', + iconColumn: 'type', + }, + }, + ], + title: { + '$blocksuite:internal:text$': true, + delta: [ + { + insert: 'Table View', + }, + ], + }, + columns: [ + { + type: 'title', + name: 'Name', + data: {}, + id: 'matchesReplaceMap[4]', + }, + { + type: 'multi-select', + name: 'Tags', + data: { + options: [ + { + id: 'matchesReplaceMap[51]', + value: 'aaa', + color: 'matchesReplaceMap[6]', + }, + { + id: 'matchesReplaceMap[37]', + value: 'bbb', + color: 'matchesReplaceMap[8]', + }, + ], + }, + id: 'matchesReplaceMap[50]', + }, + { + type: 'multi-select', + name: 'Multi-select', + data: { + options: [ + { + id: 'matchesReplaceMap[40]', + value: 'aaa', + color: 'matchesReplaceMap[11]', + }, + { + id: 'matchesReplaceMap[54]', + value: 'bbb', + color: 'matchesReplaceMap[13]', + }, + { + id: 'matchesReplaceMap[41]', + value: 'ccc', + color: 'matchesReplaceMap[15]', + }, + ], + }, + id: 'matchesReplaceMap[53]', + }, + { + type: 'number', + name: 'Number', + data: {}, + id: 'matchesReplaceMap[56]', + }, + { + type: 'rich-text', + name: 'Status', + data: {}, + id: 'matchesReplaceMap[58]', + }, + { + type: 'checkbox', + name: 'Checkbox', + data: {}, + id: 'matchesReplaceMap[60]', + }, + ], + cells: { + 'matchesReplaceMap[61]': { + 'matchesReplaceMap[50]': { + columnId: 'matchesReplaceMap[50]', + value: ['matchesReplaceMap[51]'], + }, + 'matchesReplaceMap[53]': { + columnId: 'matchesReplaceMap[53]', + value: ['matchesReplaceMap[40]', 'matchesReplaceMap[54]'], + }, + 'matchesReplaceMap[56]': { + columnId: 'matchesReplaceMap[56]', + value: 5, + }, + 'matchesReplaceMap[58]': { + columnId: 'matchesReplaceMap[58]', + value: { + '$blocksuite:internal:text$': true, + delta: [ + { + insert: ' Not started ', + }, + ], + }, + }, + 'matchesReplaceMap[60]': { + columnId: 'matchesReplaceMap[60]', + value: false, + }, + }, + 'matchesReplaceMap[62]': { + 'matchesReplaceMap[50]': { + columnId: 'matchesReplaceMap[50]', + value: ['matchesReplaceMap[37]'], + }, + 'matchesReplaceMap[53]': { + columnId: 'matchesReplaceMap[53]', + value: ['matchesReplaceMap[40]', 'matchesReplaceMap[41]'], + }, + 'matchesReplaceMap[56]': { + columnId: 'matchesReplaceMap[56]', + value: 7, + }, + 'matchesReplaceMap[58]': { + columnId: 'matchesReplaceMap[58]', + value: { + '$blocksuite:internal:text$': true, + delta: [ + { + insert: ' Not started ', + }, + ], + }, + }, + 'matchesReplaceMap[60]': { + columnId: 'matchesReplaceMap[60]', + value: true, + }, + }, + 'matchesReplaceMap[63]': { + 'matchesReplaceMap[50]': { + columnId: 'matchesReplaceMap[50]', + value: ['matchesReplaceMap[51]'], + }, + 'matchesReplaceMap[53]': { + columnId: 'matchesReplaceMap[53]', + value: ['matchesReplaceMap[54]'], + }, + 'matchesReplaceMap[56]': { + columnId: 'matchesReplaceMap[56]', + value: 9, + }, + 'matchesReplaceMap[58]': { + columnId: 'matchesReplaceMap[58]', + value: { + '$blocksuite:internal:text$': true, + delta: [ + { + insert: ' Not started ', + }, + ], + }, + }, + 'matchesReplaceMap[60]': { + columnId: 'matchesReplaceMap[60]', + value: false, + }, + }, + }, + }, + children: [ + { + type: 'block', + id: 'matchesReplaceMap[61]', + flavour: 'affine:paragraph', + props: { + text: { + '$blocksuite:internal:text$': true, + delta: [ + { + insert: 'https://affine.pro', + attributes: { + link: 'https://www.notion.so/https-affine-pro-ed3d2ae962f5433a90499ddbd1c81ac5?pvs=21', + }, + }, + ], + }, + type: 'text', + }, + children: [], + }, + { + type: 'block', + id: 'matchesReplaceMap[62]', + flavour: 'affine:paragraph', + props: { + text: { + '$blocksuite:internal:text$': true, + delta: [ + { + insert: 'Untitled', + attributes: { + link: 'https://www.notion.so/ed3d2ae962f5433a90499ddbd1c81ac5?pvs=21', + }, + }, + ], + }, + type: 'text', + }, + children: [], + }, + { + type: 'block', + id: 'matchesReplaceMap[63]', + flavour: 'affine:paragraph', + props: { + text: { + '$blocksuite:internal:text$': true, + delta: [ + { + insert: 'Untitled', + attributes: { + link: 'https://www.notion.so/ed3d2ae962f5433a90499ddbd1c81ac5?pvs=21', + }, + }, + ], + }, + type: 'text', + }, + children: [], + }, + ], + }, + ], + }; + + const adapter = new NotionHtmlAdapter(createJob()); + const rawBlockSnapshot = await adapter.toBlockSnapshot({ + file: html, + }); + expect(nanoidReplacement(rawBlockSnapshot)).toEqual(blockSnapshot); + }); + + test('plain table', async () => { + const html = `
+
aa
1
+
`; + + const blockSnapshot: BlockSnapshot = { + type: 'block', + id: 'matchesReplaceMap[0]', + flavour: 'affine:note', + props: { + xywh: '[0,0,800,95]', + background: DEFAULT_NOTE_BACKGROUND_COLOR, + index: 'a0', + hidden: false, + displayMode: 'both', + }, + children: [ + { + type: 'block', + id: 'matchesReplaceMap[1]', + flavour: 'affine:database', + props: { + views: [ + { + id: 'matchesReplaceMap[2]', + name: 'Table View', + mode: 'table', + columns: [], + filter: { + type: 'group', + op: 'and', + conditions: [], + }, + header: { + titleColumn: '', + iconColumn: 'type', + }, + }, + ], + title: { + '$blocksuite:internal:text$': true, + delta: [], + }, + columns: [ + { + type: 'rich-text', + name: '', + data: {}, + id: 'matchesReplaceMap[17]', + }, + { + type: 'rich-text', + name: '', + data: {}, + id: 'matchesReplaceMap[19]', + }, + ], + cells: { + 'matchesReplaceMap[20]': { + 'matchesReplaceMap[17]': { + columnId: 'matchesReplaceMap[17]', + value: { + '$blocksuite:internal:text$': true, + delta: [ + { + insert: 'aa', + }, + ], + }, + }, + 'matchesReplaceMap[19]': { + columnId: 'matchesReplaceMap[19]', + value: { + '$blocksuite:internal:text$': true, + delta: [], + }, + }, + }, + 'matchesReplaceMap[21]': { + 'matchesReplaceMap[17]': { + columnId: 'matchesReplaceMap[17]', + value: { + '$blocksuite:internal:text$': true, + delta: [ + { + insert: '1', + }, + ], + }, + }, + 'matchesReplaceMap[19]': { + columnId: 'matchesReplaceMap[19]', + value: { + '$blocksuite:internal:text$': true, + delta: [], + }, + }, + }, + 'matchesReplaceMap[22]': { + 'matchesReplaceMap[17]': { + columnId: 'matchesReplaceMap[17]', + value: { + '$blocksuite:internal:text$': true, + delta: [], + }, + }, + 'matchesReplaceMap[19]': { + columnId: 'matchesReplaceMap[19]', + value: { + '$blocksuite:internal:text$': true, + delta: [], + }, + }, + }, + }, + }, + children: [ + { + type: 'block', + id: 'matchesReplaceMap[20]', + flavour: 'affine:paragraph', + props: { + text: { + '$blocksuite:internal:text$': true, + delta: [ + { + insert: 'aa', + }, + ], + }, + type: 'text', + }, + children: [], + }, + { + type: 'block', + id: 'matchesReplaceMap[21]', + flavour: 'affine:paragraph', + props: { + text: { + '$blocksuite:internal:text$': true, + delta: [ + { + insert: 'aa', + }, + ], + }, + type: 'text', + }, + children: [], + }, + { + type: 'block', + id: 'matchesReplaceMap[22]', + flavour: 'affine:paragraph', + props: { + text: { + '$blocksuite:internal:text$': true, + delta: [], + }, + type: 'text', + }, + children: [], + }, + { + type: 'block', + id: 'matchesReplaceMap[23]', + flavour: 'affine:paragraph', + props: { + text: { + '$blocksuite:internal:text$': true, + delta: [], + }, + type: 'text', + }, + children: [], + }, + ], + }, + ], + }; + + const adapter = new NotionHtmlAdapter(createJob()); + const rawBlockSnapshot = await adapter.toBlockSnapshot({ + file: html, + }); + expect(nanoidReplacement(rawBlockSnapshot)).toEqual(blockSnapshot); + }); + + test('block equation', async () => { + const html = `
+
+
+ E = mc^2 +
+
+
`; + + const blockSnapshot: BlockSnapshot = { + type: 'block', + id: 'matchesReplaceMap[0]', + flavour: 'affine:note', + props: { + xywh: '[0,0,800,95]', + background: DEFAULT_NOTE_BACKGROUND_COLOR, + index: 'a0', + hidden: false, + displayMode: NoteDisplayMode.DocAndEdgeless, + }, + children: [ + { + type: 'block', + id: 'matchesReplaceMap[1]', + flavour: 'affine:latex', + props: { + latex: 'E = mc^2', + }, + children: [], + }, + ], + }; + + const adapter = new NotionHtmlAdapter(createJob()); + const rawBlockSnapshot = await adapter.toBlockSnapshot({ + file: html, + }); + expect(nanoidReplacement(rawBlockSnapshot)).toEqual(blockSnapshot); + }); + + test('inline equation', async () => { + const html = `
+

inline equation + + + E = mc^2 + +

+
`; + + const blockSnapshot: BlockSnapshot = { + type: 'block', + id: 'matchesReplaceMap[0]', + flavour: 'affine:note', + props: { + xywh: '[0,0,800,95]', + background: DEFAULT_NOTE_BACKGROUND_COLOR, + index: 'a0', + hidden: false, + displayMode: NoteDisplayMode.DocAndEdgeless, + }, + children: [ + { + type: 'block', + id: 'matchesReplaceMap[1]', + flavour: 'affine:paragraph', + props: { + text: { + '$blocksuite:internal:text$': true, + delta: [ + { + insert: 'inline equation', + }, + { + insert: ' ', + attributes: { + latex: 'E = mc^2', + }, + }, + ], + }, + type: 'text', + }, + children: [], + }, + ], + }; + + const adapter = new NotionHtmlAdapter(createJob()); + const rawBlockSnapshot = await adapter.toBlockSnapshot({ + file: html, + }); + expect(nanoidReplacement(rawBlockSnapshot)).toEqual(blockSnapshot); + }); + + test('inline style', async () => { + const html = `
+

+ strong italic underline strikethrough code +

+
`; + + const blockSnapshot: BlockSnapshot = { + type: 'block', + id: 'matchesReplaceMap[0]', + flavour: 'affine:note', + props: { + xywh: '[0,0,800,95]', + background: DEFAULT_NOTE_BACKGROUND_COLOR, + index: 'a0', + hidden: false, + displayMode: NoteDisplayMode.DocAndEdgeless, + }, + children: [ + { + type: 'block', + id: 'matchesReplaceMap[1]', + flavour: 'affine:paragraph', + props: { + text: { + '$blocksuite:internal:text$': true, + delta: [ + { + insert: 'strong', + attributes: { + bold: true, + }, + }, + { + insert: ' ', + }, + { + insert: 'italic', + attributes: { + italic: true, + }, + }, + { + insert: ' ', + }, + { + insert: 'underline', + attributes: { + underline: true, + }, + }, + { + insert: ' ', + }, + { + insert: 'strikethrough', + attributes: { + strike: true, + }, + }, + { + insert: ' ', + }, + { + insert: 'code', + attributes: { + code: true, + }, + }, + ], + }, + type: 'text', + }, + children: [], + }, + ], + }; + + const adapter = new NotionHtmlAdapter(createJob()); + const rawBlockSnapshot = await adapter.toBlockSnapshot({ + file: html, + }); + expect(nanoidReplacement(rawBlockSnapshot)).toEqual(blockSnapshot); + }); +}); diff --git a/blocksuite/blocks/src/__tests__/adapters/notion-text.unit.spec.ts b/blocksuite/blocks/src/__tests__/adapters/notion-text.unit.spec.ts new file mode 100644 index 0000000000..1dd7ca1043 --- /dev/null +++ b/blocksuite/blocks/src/__tests__/adapters/notion-text.unit.spec.ts @@ -0,0 +1,106 @@ +import { DEFAULT_NOTE_BACKGROUND_COLOR } from '@blocksuite/affine-model'; +import type { SliceSnapshot } from '@blocksuite/store'; +import { describe, expect, test } from 'vitest'; + +import { NotionTextAdapter } from '../../_common/adapters/notion-text.js'; +import { nanoidReplacement } from '../../_common/test-utils/test-utils.js'; +import { createJob } from '../utils/create-job.js'; + +describe('notion-text to snapshot', () => { + test('basic', () => { + const notionText = + '{"blockType":"text","editing":[["aaa ",[["_"],["b"],["i"]]],["nbbbb ",[["_"],["i"]]],["hjhj ",[["_"]]],["a",[["_"],["c"]]],[" ",[["_"]]],["ccc d",[["_"],["s"]]],["dd",[["_"],["s"]]]]}'; + + const sliceSnapshot: SliceSnapshot = { + type: 'slice', + content: [ + { + type: 'block', + id: 'matchesReplaceMap[0]', + flavour: 'affine:note', + props: { + xywh: '[0,0,800,95]', + background: DEFAULT_NOTE_BACKGROUND_COLOR, + index: 'a0', + hidden: false, + displayMode: 'both', + }, + children: [ + { + type: 'block', + id: 'matchesReplaceMap[1]', + flavour: 'affine:paragraph', + props: { + type: 'text', + text: { + '$blocksuite:internal:text$': true, + delta: [ + { + insert: 'aaa ', + attributes: { + underline: true, + bold: true, + italic: true, + }, + }, + { + insert: 'nbbbb ', + attributes: { + underline: true, + italic: true, + }, + }, + { + insert: 'hjhj ', + attributes: { + underline: true, + }, + }, + { + insert: 'a', + attributes: { + underline: true, + code: true, + }, + }, + { + insert: ' ', + attributes: { + underline: true, + }, + }, + { + insert: 'ccc d', + attributes: { + underline: true, + strike: true, + }, + }, + { + insert: 'dd', + attributes: { + underline: true, + strike: true, + }, + }, + ], + }, + }, + children: [], + }, + ], + }, + ], + workspaceId: '', + pageId: '', + }; + + const ntAdapter = new NotionTextAdapter(createJob()); + const target = ntAdapter.toSliceSnapshot({ + file: notionText, + workspaceId: '', + pageId: '', + }); + expect(nanoidReplacement(target!)).toEqual(sliceSnapshot); + }); +}); diff --git a/blocksuite/blocks/src/__tests__/adapters/plain-text.unit.spec.ts b/blocksuite/blocks/src/__tests__/adapters/plain-text.unit.spec.ts new file mode 100644 index 0000000000..705f40d3c7 --- /dev/null +++ b/blocksuite/blocks/src/__tests__/adapters/plain-text.unit.spec.ts @@ -0,0 +1,1189 @@ +import { + DEFAULT_NOTE_BACKGROUND_COLOR, + NoteDisplayMode, +} from '@blocksuite/affine-model'; +import type { + BlockSnapshot, + DocSnapshot, + JobMiddleware, +} from '@blocksuite/store'; +import { describe, expect, test } from 'vitest'; + +import { PlainTextAdapter } from '../../_common/adapters/plain-text/plain-text.js'; +import { embedSyncedDocMiddleware } from '../../_common/transformers/middlewares.js'; +import { createJob } from '../utils/create-job.js'; + +describe('snapshot to plain text', () => { + test('paragraph', async () => { + const blockSnapshot: BlockSnapshot = { + type: 'block', + id: 'block:vu6SK6WJpW', + flavour: 'affine:page', + props: { + title: { + '$blocksuite:internal:text$': true, + delta: [], + }, + }, + children: [ + { + type: 'block', + id: 'block:Tk4gSPocAt', + flavour: 'affine:surface', + props: { + elements: {}, + }, + children: [], + }, + { + type: 'block', + id: 'block:WfnS5ZDCJT', + flavour: 'affine:note', + props: { + xywh: '[0,0,800,95]', + background: DEFAULT_NOTE_BACKGROUND_COLOR, + index: 'a0', + hidden: false, + displayMode: NoteDisplayMode.DocAndEdgeless, + }, + children: [ + { + type: 'block', + id: 'block:Bdn8Yvqcny', + flavour: 'affine:paragraph', + props: { + type: 'text', + text: { + '$blocksuite:internal:text$': true, + delta: [ + { + insert: 'aaa', + }, + { + insert: 'bbb', + attributes: { + italic: true, + }, + }, + { + insert: 'ccc', + attributes: { + bold: true, + }, + }, + ], + }, + }, + children: [ + { + type: 'block', + id: 'block:72SMa5mdLy', + flavour: 'affine:paragraph', + props: { + type: 'text', + text: { + '$blocksuite:internal:text$': true, + delta: [ + { + insert: 'ddd', + attributes: { + italic: true, + }, + }, + ], + }, + }, + children: [], + }, + { + type: 'block', + id: 'block:f-Z6nRrGK_', + flavour: 'affine:paragraph', + props: { + type: 'text', + text: { + '$blocksuite:internal:text$': true, + delta: [ + { + insert: 'eee', + attributes: { + bold: true, + }, + }, + ], + }, + }, + children: [], + }, + { + type: 'block', + id: 'block:I0Fmz5Nv02', + flavour: 'affine:paragraph', + props: { + type: 'text', + text: { + '$blocksuite:internal:text$': true, + delta: [ + { + insert: 'fff', + }, + ], + }, + }, + children: [], + }, + ], + }, + { + type: 'block', + id: 'block:12lDwMD7ec', + flavour: 'affine:paragraph', + props: { + type: 'text', + text: { + '$blocksuite:internal:text$': true, + delta: [ + { + insert: 'ggg', + }, + ], + }, + }, + children: [], + }, + ], + }, + ], + }; + + const plainText = 'aaabbbccc\nddd\neee\nfff\nggg\n'; + const plainTextAdapter = new PlainTextAdapter(createJob()); + const target = await plainTextAdapter.fromBlockSnapshot({ + snapshot: blockSnapshot, + }); + expect(target.file).toBe(plainText); + }); + + test('list', async () => { + const blockSnapshot: BlockSnapshot = { + type: 'block', + id: 'block:m5hvdXHXS2', + flavour: 'affine:page', + version: 2, + props: { + title: { + '$blocksuite:internal:text$': true, + delta: [], + }, + }, + children: [ + { + type: 'block', + id: 'block:Y4J-oO9h9d', + flavour: 'affine:surface', + version: 5, + props: { + elements: {}, + }, + children: [], + }, + { + type: 'block', + id: 'block:1Ll22zT992', + flavour: 'affine:note', + version: 1, + props: { + xywh: '[0,0,800,95]', + background: DEFAULT_NOTE_BACKGROUND_COLOR, + index: 'a0', + hidden: false, + displayMode: NoteDisplayMode.DocAndEdgeless, + edgeless: { + style: { + borderRadius: 8, + borderSize: 4, + borderStyle: 'solid', + shadowType: '--affine-note-shadow-box', + }, + }, + }, + children: [ + { + type: 'block', + id: 'block:Fd0ZCYB7a4', + flavour: 'affine:list', + version: 1, + props: { + type: 'numbered', + text: { + '$blocksuite:internal:text$': true, + delta: [ + { + insert: 'aaa', + }, + ], + }, + checked: false, + collapsed: false, + }, + children: [ + { + type: 'block', + id: 'block:8-GeKDc06x', + flavour: 'affine:list', + version: 1, + props: { + type: 'numbered', + text: { + '$blocksuite:internal:text$': true, + delta: [ + { + insert: 'bbb', + }, + ], + }, + checked: false, + collapsed: false, + }, + children: [], + }, + { + type: 'block', + id: 'block:f0c-9xKaEL', + flavour: 'affine:list', + version: 1, + props: { + type: 'bulleted', + text: { + '$blocksuite:internal:text$': true, + delta: [ + { + insert: 'ccc', + }, + ], + }, + checked: false, + collapsed: false, + }, + children: [], + }, + { + type: 'block', + id: 'block:f0c-9xKaEL', + flavour: 'affine:list', + version: 1, + props: { + type: 'numbered', + text: { + '$blocksuite:internal:text$': true, + delta: [ + { + insert: 'ddd', + }, + ], + }, + checked: false, + collapsed: false, + }, + children: [], + }, + ], + }, + { + type: 'block', + id: 'block:Fd0ZCYB7a5', + flavour: 'affine:list', + version: 1, + props: { + type: 'numbered', + text: { + '$blocksuite:internal:text$': true, + delta: [ + { + insert: 'eee', + }, + ], + }, + checked: false, + collapsed: false, + }, + children: [], + }, + ], + }, + ], + }; + + const plainText = 'aaa\nbbb\nccc\nddd\neee\n'; + + const plainTextAdapter = new PlainTextAdapter(createJob()); + const target = await plainTextAdapter.fromBlockSnapshot({ + snapshot: blockSnapshot, + }); + expect(target.file).toEqual(plainText); + }); + + test('divider', async () => { + const blockSnapshot: BlockSnapshot = { + type: 'block', + id: 'block:vu6SK6WJpW', + flavour: 'affine:page', + props: { + title: { + '$blocksuite:internal:text$': true, + delta: [], + }, + }, + children: [ + { + type: 'block', + id: 'block:Tk4gSPocAt', + flavour: 'affine:surface', + props: { + elements: {}, + }, + children: [], + }, + { + type: 'block', + id: 'block:WfnS5ZDCJT', + flavour: 'affine:note', + props: { + xywh: '[0,0,800,95]', + background: DEFAULT_NOTE_BACKGROUND_COLOR, + index: 'a0', + hidden: false, + displayMode: NoteDisplayMode.DocAndEdgeless, + }, + children: [ + { + type: 'block', + id: 'block:Bdn8Yvqcny', + flavour: 'affine:paragraph', + props: { + type: 'text', + text: { + '$blocksuite:internal:text$': true, + delta: [ + { + insert: 'aaa', + }, + ], + }, + }, + children: [], + }, + { + type: 'block', + id: 'block:12lDwMD7ec', + flavour: 'affine:divider', + props: {}, + children: [], + }, + ], + }, + ], + }; + + const plainText = 'aaa\n---\n'; + const plainTextAdapter = new PlainTextAdapter(createJob()); + const target = await plainTextAdapter.fromBlockSnapshot({ + snapshot: blockSnapshot, + }); + expect(target.file).toBe(plainText); + }); + + test('code', async () => { + const blockSnapshot: BlockSnapshot = { + type: 'block', + id: 'block:vu6SK6WJpW', + flavour: 'affine:page', + props: { + title: { + '$blocksuite:internal:text$': true, + delta: [], + }, + }, + children: [ + { + type: 'block', + id: 'block:Tk4gSPocAt', + flavour: 'affine:surface', + props: { + elements: {}, + }, + children: [], + }, + { + type: 'block', + id: 'block:WfnS5ZDCJT', + flavour: 'affine:note', + props: { + xywh: '[0,0,800,95]', + background: DEFAULT_NOTE_BACKGROUND_COLOR, + index: 'a0', + hidden: false, + displayMode: NoteDisplayMode.DocAndEdgeless, + }, + children: [ + { + type: 'block', + id: 'block:8hOLxad5Fv', + flavour: 'affine:code', + props: { + language: 'python', + text: { + '$blocksuite:internal:text$': true, + delta: [ + { + insert: 'import this', + }, + ], + }, + }, + children: [], + }, + ], + }, + ], + }; + + const plainText = 'import this\n'; + const plainTextAdapter = new PlainTextAdapter(createJob()); + const target = await plainTextAdapter.fromBlockSnapshot({ + snapshot: blockSnapshot, + }); + expect(target.file).toBe(plainText); + }); + + test('special inline delta', async () => { + const blockSnapshot: BlockSnapshot = { + type: 'block', + id: 'block:vu6SK6WJpW', + flavour: 'affine:page', + props: { + title: { + '$blocksuite:internal:text$': true, + delta: [], + }, + }, + children: [ + { + type: 'block', + id: 'block:Tk4gSPocAt', + flavour: 'affine:surface', + props: { + elements: {}, + }, + children: [], + }, + { + type: 'block', + id: 'block:WfnS5ZDCJT', + flavour: 'affine:note', + props: { + xywh: '[0,0,800,95]', + background: DEFAULT_NOTE_BACKGROUND_COLOR, + index: 'a0', + hidden: false, + displayMode: NoteDisplayMode.DocAndEdgeless, + }, + children: [ + { + type: 'block', + id: 'block:Bdn8Yvqcny', + flavour: 'affine:paragraph', + props: { + type: 'text', + text: { + '$blocksuite:internal:text$': true, + delta: [ + { + insert: 'aaa', + attributes: { + link: 'https://affine.pro/', + }, + }, + ], + }, + }, + children: [ + { + type: 'block', + id: 'block:72SMa5mdLy', + flavour: 'affine:paragraph', + props: { + type: 'text', + text: { + '$blocksuite:internal:text$': true, + delta: [ + { + insert: '', + attributes: { + reference: { + type: 'LinkedPage', + pageId: 'deadbeef', + params: { + mode: 'page', + blockIds: ['abc', '123'], + elementIds: ['def', '456'], + databaseId: 'deadbeef', + databaseRowId: '123', + }, + }, + }, + }, + ], + }, + }, + children: [], + }, + { + type: 'block', + id: 'block:f-Z6nRrGK_', + flavour: 'affine:paragraph', + props: { + type: 'text', + text: { + '$blocksuite:internal:text$': true, + delta: [ + { + insert: ' ', + attributes: { + latex: 'E=mc^2', + }, + }, + ], + }, + }, + children: [], + }, + ], + }, + ], + }, + ], + }; + const middleware: JobMiddleware = ({ adapterConfigs }) => { + adapterConfigs.set('title:deadbeef', 'test'); + adapterConfigs.set('docLinkBaseUrl', 'https://example.com'); + }; + const plainTextAdapter = new PlainTextAdapter(createJob([middleware])); + + const plainText = + 'aaa: https://affine.pro/\ntest: https://example.com/deadbeef?mode=page&blockIds=abc%2C123&elementIds=def%2C456&databaseId=deadbeef&databaseRowId=123\nE=mc^2\n'; + const target = await plainTextAdapter.fromBlockSnapshot({ + snapshot: blockSnapshot, + }); + expect(target.file).toBe(plainText); + }); + + describe('embed link block', () => { + const embedTestCases = [ + { + name: 'bookmark', + flavour: 'affine:bookmark', + url: 'https://example.com', + title: 'example', + plainText: '[example](https://example.com)\n', + }, + { + name: 'embed github', + flavour: 'affine:embed-github', + url: 'https://github.com/toeverything/blocksuite/pull/66666', + title: 'example github pr title', + plainText: + '[example github pr title](https://github.com/toeverything/blocksuite/pull/66666)\n', + }, + { + name: 'embed figma', + flavour: 'affine:embed-figma', + url: 'https://www.figma.com/file/1234567890', + title: 'example figma title', + plainText: + '[example figma title](https://www.figma.com/file/1234567890)\n', + }, + { + name: 'embed youtube', + flavour: 'affine:embed-youtube', + url: 'https://www.youtube.com/watch?v=1234567890', + title: 'example youtube title', + plainText: + '[example youtube title](https://www.youtube.com/watch?v=1234567890)\n', + }, + { + name: 'embed loom', + flavour: 'affine:embed-loom', + url: 'https://www.loom.com/share/1234567890', + title: 'example loom title', + plainText: + '[example loom title](https://www.loom.com/share/1234567890)\n', + }, + ]; + + for (const testCase of embedTestCases) { + test(testCase.name, async () => { + const blockSnapshot: BlockSnapshot = { + type: 'block', + id: 'block:vu6SK6WJpW', + flavour: 'affine:page', + props: { + title: { + '$blocksuite:internal:text$': true, + delta: [], + }, + }, + children: [ + { + type: 'block', + id: 'block:Tk4gSPocAt', + flavour: 'affine:surface', + props: { + elements: {}, + }, + children: [], + }, + { + type: 'block', + id: 'block:WfnS5ZDCJT', + flavour: 'affine:note', + props: { + xywh: '[0,0,800,95]', + background: DEFAULT_NOTE_BACKGROUND_COLOR, + index: 'a0', + hidden: false, + displayMode: NoteDisplayMode.DocAndEdgeless, + }, + children: [ + { + type: 'block', + id: 'block:Bdn8Yvqcny', + flavour: testCase.flavour, + props: { + url: testCase.url, + title: testCase.title, + }, + children: [], + }, + ], + }, + ], + }; + + const plainTextAdapter = new PlainTextAdapter(createJob()); + const target = await plainTextAdapter.fromBlockSnapshot({ + snapshot: blockSnapshot, + }); + expect(target.file).toBe(testCase.plainText); + }); + } + + test('linked doc block', async () => { + const blockSnapShot: BlockSnapshot = { + type: 'block', + id: 'VChAZIX7DM', + flavour: 'affine:page', + version: 2, + props: { + title: { + '$blocksuite:internal:text$': true, + delta: [ + { + insert: 'Test Doc', + }, + ], + }, + }, + children: [ + { + type: 'block', + id: 'uRj8gejH4d', + flavour: 'affine:surface', + version: 5, + props: { + elements: {}, + }, + children: [], + }, + { + type: 'block', + id: 'AqFoVDUoW9', + flavour: 'affine:note', + version: 1, + props: { + xywh: '[0,0,800,95]', + background: DEFAULT_NOTE_BACKGROUND_COLOR, + index: 'a0', + hidden: false, + displayMode: NoteDisplayMode.DocAndEdgeless, + }, + children: [ + { + type: 'block', + id: 'C0sH2Ee6cz-MysVNLNrBt', + flavour: 'affine:embed-linked-doc', + props: { + index: 'a0', + xywh: '[0,0,0,0]', + rotate: 0, + pageId: '4T5ObMgEIMII-4Bexyta1', + style: 'horizontal', + caption: null, + params: { + mode: 'page', + blockIds: ['abc', '123'], + elementIds: ['def', '456'], + databaseId: 'deadbeef', + databaseRowId: '123', + }, + }, + children: [], + }, + ], + }, + ], + }; + + const middleware: JobMiddleware = ({ adapterConfigs }) => { + adapterConfigs.set('title:4T5ObMgEIMII-4Bexyta1', 'test'); + adapterConfigs.set('docLinkBaseUrl', 'https://example.com'); + }; + const plainText = + 'test: https://example.com/4T5ObMgEIMII-4Bexyta1?mode=page&blockIds=abc%2C123&elementIds=def%2C456&databaseId=deadbeef&databaseRowId=123\n'; + const plainTextAdapter = new PlainTextAdapter(createJob([middleware])); + const target = await plainTextAdapter.fromBlockSnapshot({ + snapshot: blockSnapShot, + }); + expect(target.file).toBe(plainText); + }); + + test('synced doc block', async () => { + // doc -> synced doc block -> deepest synced doc block + // The deepest synced doc block only export it's title + + const deepestSyncedDocSnapshot: DocSnapshot = { + type: 'page', + meta: { + id: 'deepestSyncedDoc', + title: 'Deepest Doc', + createDate: 1715762171116, + tags: [], + }, + blocks: { + type: 'block', + id: '8WdJmN5FTT', + flavour: 'affine:page', + version: 2, + props: { + title: { + '$blocksuite:internal:text$': true, + delta: [ + { + insert: 'Deepest Doc', + }, + ], + }, + }, + children: [ + { + type: 'block', + id: 'zVN1EZFuZe', + flavour: 'affine:surface', + version: 5, + props: { + elements: {}, + }, + children: [], + }, + { + type: 'block', + id: '2s9sJlphLH', + flavour: 'affine:note', + version: 1, + props: { + xywh: '[0,0,800,95]', + background: DEFAULT_NOTE_BACKGROUND_COLOR, + index: 'a0', + hidden: false, + displayMode: 'both', + edgeless: { + style: { + borderRadius: 8, + borderSize: 4, + borderStyle: 'solid', + shadowType: '--affine-note-shadow-box', + }, + }, + }, + children: [ + { + type: 'block', + id: 'vNp5XrR5yw', + flavour: 'affine:paragraph', + version: 1, + props: { + type: 'text', + text: { + '$blocksuite:internal:text$': true, + delta: [], + }, + }, + children: [], + }, + { + type: 'block', + id: 'JTdfSl1ygZ', + flavour: 'affine:paragraph', + version: 1, + props: { + type: 'text', + text: { + '$blocksuite:internal:text$': true, + delta: [ + { + insert: 'Hello, This is deepest doc.', + }, + ], + }, + }, + children: [], + }, + ], + }, + ], + }, + }; + + const syncedDocSnapshot: DocSnapshot = { + type: 'page', + meta: { + id: 'syncedDoc', + title: 'Synced Doc', + createDate: 1719212435051, + tags: [], + }, + blocks: { + type: 'block', + id: 'AGOahFisBN', + flavour: 'affine:page', + version: 2, + props: { + title: { + '$blocksuite:internal:text$': true, + delta: [ + { + insert: 'Synced Doc', + }, + ], + }, + }, + children: [ + { + type: 'block', + id: 'gfVzx5tGpB', + flavour: 'affine:surface', + version: 5, + props: { + elements: {}, + }, + children: [], + }, + { + type: 'block', + id: 'CzEfaUret4', + flavour: 'affine:note', + version: 1, + props: { + xywh: '[0,0,800,95]', + background: '--affine-note-background-blue', + index: 'a0', + hidden: false, + displayMode: 'both', + edgeless: { + style: { + borderRadius: 0, + borderSize: 4, + borderStyle: 'none', + shadowType: '--affine-note-shadow-sticker', + }, + }, + }, + children: [ + { + type: 'block', + id: 'yFlNufsgke', + flavour: 'affine:paragraph', + version: 1, + props: { + type: 'h1', + text: { + '$blocksuite:internal:text$': true, + delta: [ + { + insert: 'Heading 1', + }, + ], + }, + }, + children: [], + }, + { + type: 'block', + id: 'oMuLcD6XS3', + flavour: 'affine:paragraph', + version: 1, + props: { + type: 'h2', + text: { + '$blocksuite:internal:text$': true, + delta: [ + { + insert: 'heading 2', + }, + ], + }, + }, + children: [], + }, + { + type: 'block', + id: 'PQ8FhGV6VM', + flavour: 'affine:paragraph', + version: 1, + props: { + type: 'text', + text: { + '$blocksuite:internal:text$': true, + delta: [ + { + insert: 'paragraph', + }, + ], + }, + }, + children: [], + }, + { + type: 'block', + id: 'sA9paSrdEN', + flavour: 'affine:paragraph', + version: 1, + props: { + type: 'text', + text: { + '$blocksuite:internal:text$': true, + delta: [ + { + insert: 'strike', + attributes: { + strike: true, + }, + }, + ], + }, + }, + children: [], + }, + { + type: 'block', + id: 'DF26giFpKX', + flavour: 'affine:code', + version: 1, + props: { + text: { + '$blocksuite:internal:text$': true, + delta: [ + { + insert: 'Hello world!', + }, + ], + }, + language: 'cpp', + wrap: false, + caption: '', + }, + children: [], + }, + { + type: 'block', + id: '-3bbVQTvI2', + flavour: 'affine:embed-synced-doc', + version: 1, + props: { + index: 'a0', + xywh: '[0,0,0,0]', + rotate: 0, + pageId: 'deepestSyncedDoc', + style: 'syncedDoc', + }, + children: [], + }, + ], + }, + ], + }, + }; + + const syncedDocPlainText = + 'Heading 1\nheading 2\nparagraph\nstrike\nHello world!\n'; + + const docSnapShot: DocSnapshot = { + type: 'page', + meta: { + id: 'y5nsrywQtr', + title: 'Test Doc', + createDate: 1719222172042, + tags: [], + }, + blocks: { + type: 'block', + id: 'VChAZIX7DM', + flavour: 'affine:page', + version: 2, + props: { + title: { + '$blocksuite:internal:text$': true, + delta: [ + { + insert: 'Test Doc', + }, + ], + }, + }, + children: [ + { + type: 'block', + id: 'uRj8gejH4d', + flavour: 'affine:surface', + version: 5, + props: { + elements: {}, + }, + children: [], + }, + { + type: 'block', + id: 'AqFoVDUoW9', + flavour: 'affine:note', + version: 1, + props: { + xywh: '[0,0,800,95]', + background: '--affine-note-background-blue', + index: 'a0', + hidden: false, + displayMode: 'both', + edgeless: { + style: { + borderRadius: 0, + borderSize: 4, + borderStyle: 'none', + shadowType: '--affine-note-shadow-sticker', + }, + }, + }, + children: [ + { + type: 'block', + id: 'cWBI4UGTqh', + flavour: 'affine:paragraph', + version: 1, + props: { + type: 'text', + text: { + '$blocksuite:internal:text$': true, + delta: [ + { + insert: 'Hello', + }, + ], + }, + }, + children: [], + }, + { + type: 'block', + id: 'AqFoVxas19', + flavour: 'affine:embed-synced-doc', + version: 1, + props: { + index: 'a0', + xywh: '[0,0,0,0]', + rotate: 0, + pageId: 'syncedDoc', + style: 'syncedDoc', + }, + children: [], + }, + { + type: 'block', + id: 'Db976U9v18', + flavour: 'affine:paragraph', + version: 1, + props: { + type: 'text', + text: { + '$blocksuite:internal:text$': true, + delta: [ + { + insert: 'World!', + }, + ], + }, + }, + children: [], + }, + ], + }, + ], + }, + }; + + const docPlainText = `Test Doc\n\nHello\n${syncedDocPlainText}Deepest Doc\nWorld!\n`; + const job = createJob([embedSyncedDocMiddleware('content')]); + + // workaround for adding docs to collection + await job.snapshotToDoc(deepestSyncedDocSnapshot); + await job.snapshotToDoc(syncedDocSnapshot); + await job.snapshotToDoc(docSnapShot); + + const mdAdapter = new PlainTextAdapter(job); + const target = await mdAdapter.fromDocSnapshot({ + snapshot: docSnapShot, + }); + expect(target.file).toBe(docPlainText); + }); + }); + + test('latex block', async () => { + const blockSnapshot: BlockSnapshot = { + type: 'block', + id: 'matchesReplaceMap[0]', + flavour: 'affine:note', + props: { + xywh: '[0,0,800,95]', + background: DEFAULT_NOTE_BACKGROUND_COLOR, + index: 'a0', + hidden: false, + displayMode: NoteDisplayMode.DocAndEdgeless, + }, + children: [ + { + type: 'block', + id: 'matchesReplaceMap[1]', + flavour: 'affine:latex', + props: { + latex: 'E=mc^2', + }, + children: [], + }, + ], + }; + + const plainText = 'LaTex, with value: E=mc^2\n'; + const plainTextAdapter = new PlainTextAdapter(createJob()); + const target = await plainTextAdapter.fromBlockSnapshot({ + snapshot: blockSnapshot, + }); + expect(target.file).toBe(plainText); + }); +}); diff --git a/blocksuite/blocks/src/__tests__/database/database.unit.spec.ts b/blocksuite/blocks/src/__tests__/database/database.unit.spec.ts new file mode 100644 index 0000000000..c56fe92aff --- /dev/null +++ b/blocksuite/blocks/src/__tests__/database/database.unit.spec.ts @@ -0,0 +1,235 @@ +import { + type Cell, + type Column, + type DatabaseBlockModel, + DatabaseBlockSchema, + NoteBlockSchema, + ParagraphBlockSchema, + RootBlockSchema, +} from '@blocksuite/affine-model'; +import { propertyModelPresets } from '@blocksuite/data-view/property-pure-presets'; +import type { BlockModel, Doc } from '@blocksuite/store'; +import { DocCollection, IdGeneratorType, Schema } from '@blocksuite/store'; +import { beforeEach, describe, expect, test } from 'vitest'; + +import { databaseBlockColumns } from '../../database-block/index.js'; +import { + addProperty, + copyCellsByProperty, + deleteColumn, + getCell, + getProperty, + updateCell, +} from '../../database-block/utils/block-utils.js'; + +const AffineSchemas = [ + RootBlockSchema, + NoteBlockSchema, + ParagraphBlockSchema, + DatabaseBlockSchema, +]; + +function createTestOptions() { + const idGenerator = IdGeneratorType.AutoIncrement; + const schema = new Schema(); + schema.register(AffineSchemas); + return { id: 'test-collection', idGenerator, schema }; +} + +function createTestDoc(docId = 'doc0') { + const options = createTestOptions(); + const collection = new DocCollection(options); + collection.meta.initialize(); + const doc = collection.createDoc({ id: docId }); + doc.load(); + return doc; +} + +describe('DatabaseManager', () => { + let doc: Doc; + let db: DatabaseBlockModel; + + let rootId: BlockModel['id']; + let noteBlockId: BlockModel['id']; + let databaseBlockId: BlockModel['id']; + let p1: BlockModel['id']; + let p2: BlockModel['id']; + let col1: Column['id']; + let col2: Column['id']; + let col3: Column['id']; + + const selection = [ + { id: '1', value: 'Done', color: 'var(--affine-tag-white)' }, + { id: '2', value: 'TODO', color: 'var(--affine-tag-pink)' }, + { id: '3', value: 'WIP', color: 'var(--affine-tag-blue)' }, + ]; + + beforeEach(() => { + doc = createTestDoc(); + + rootId = doc.addBlock('affine:page', { + title: new doc.Text('database test'), + }); + noteBlockId = doc.addBlock('affine:note', {}, rootId); + + databaseBlockId = doc.addBlock( + 'affine:database' as BlockSuite.Flavour, + { + columns: [], + titleColumn: 'Title', + }, + noteBlockId + ); + + const databaseModel = doc.getBlockById( + databaseBlockId + ) as DatabaseBlockModel; + db = databaseModel; + + col1 = addProperty( + db, + 'end', + databaseBlockColumns.numberColumnConfig.create('Number') + ); + col2 = addProperty( + db, + 'end', + propertyModelPresets.selectPropertyModelConfig.create('Single Select', { + options: selection, + }) + ); + col3 = addProperty( + db, + 'end', + databaseBlockColumns.richTextColumnConfig.create('Rich Text') + ); + + doc.updateBlock(databaseModel, { + columns: [col1, col2, col3], + }); + + p1 = doc.addBlock( + 'affine:paragraph', + { + text: new doc.Text('text1'), + }, + databaseBlockId + ); + p2 = doc.addBlock( + 'affine:paragraph', + { + text: new doc.Text('text2'), + }, + databaseBlockId + ); + + updateCell(db, p1, { + columnId: col1, + value: 0.1, + }); + updateCell(db, p2, { + columnId: col2, + value: [selection[1]], + }); + }); + + test('getColumn', () => { + const column = { + ...databaseBlockColumns.numberColumnConfig.create('testColumnId'), + id: 'testColumnId', + }; + addProperty(db, 'end', column); + + const result = getProperty(db, column.id); + expect(result).toEqual(column); + }); + + test('addColumn', () => { + const column = + databaseBlockColumns.numberColumnConfig.create('Test Column'); + const id = addProperty(db, 'end', column); + const result = getProperty(db, id); + + expect(result).toMatchObject(column); + expect(result).toHaveProperty('id'); + }); + + test('deleteColumn', () => { + const column = { + ...databaseBlockColumns.numberColumnConfig.create('Test Column'), + id: 'testColumnId', + }; + addProperty(db, 'end', column); + expect(getProperty(db, column.id)).toEqual(column); + + deleteColumn(db, column.id); + expect(getProperty(db, column.id)).toBeUndefined(); + }); + + test('getCell', () => { + const modelId = doc.addBlock( + 'affine:paragraph', + { + text: new doc.Text('paragraph'), + }, + noteBlockId + ); + const column = { + ...databaseBlockColumns.numberColumnConfig.create('Test Column'), + id: 'testColumnId', + }; + const cell: Cell = { + columnId: column.id, + value: 42, + }; + + addProperty(db, 'end', column); + updateCell(db, modelId, cell); + + const model = doc.getBlockById(modelId); + + expect(model).not.toBeNull(); + + const result = getCell(db, model!.id, column.id); + expect(result).toEqual(cell); + }); + + test('updateCell', () => { + const newRowId = doc.addBlock( + 'affine:paragraph', + { + text: new doc.Text('text3'), + }, + databaseBlockId + ); + + updateCell(db, newRowId, { + columnId: col2, + value: [selection[2]], + }); + + const cell = getCell(db, newRowId, col2); + expect(cell).toEqual({ + columnId: col2, + value: [selection[2]], + }); + }); + + test('copyCellsByColumn', () => { + const newColId = addProperty( + db, + 'end', + propertyModelPresets.selectPropertyModelConfig.create('Copied Select', { + options: selection, + }) + ); + + copyCellsByProperty(db, col2, newColId); + + const cell = getCell(db, p2, newColId); + expect(cell).toEqual({ + columnId: newColId, + value: [selection[1]], + }); + }); +}); diff --git a/blocksuite/blocks/src/__tests__/database/typesystem.unit.spec.ts b/blocksuite/blocks/src/__tests__/database/typesystem.unit.spec.ts new file mode 100644 index 0000000000..dcab847fb9 --- /dev/null +++ b/blocksuite/blocks/src/__tests__/database/typesystem.unit.spec.ts @@ -0,0 +1,79 @@ +import { type SelectTag, t, typeSystem } from '@blocksuite/data-view'; +import { describe, expect, test } from 'vitest'; + +describe('subtyping', () => { + test('all type is subtype of unknown', () => { + expect(typeSystem.unify(t.boolean.instance(), t.unknown.instance())).toBe( + true + ); + expect(typeSystem.unify(t.string.instance(), t.unknown.instance())).toBe( + true + ); + expect( + typeSystem.unify( + t.array.instance(t.string.instance()), + t.unknown.instance() + ) + ).toBe(true); + expect(typeSystem.unify(t.tag.instance(), t.unknown.instance())).toBe(true); + }); +}); +describe('function apply', () => { + test('generic type function', () => { + const fn = t.fn.instance( + [t.typeVarReference.create('A'), t.typeVarReference.create('A')], + t.boolean.instance(), + [t.typeVarDefine.create('A', t.unknown.instance())] + ); + const instancedFn = typeSystem.instanceFn( + fn, + [t.boolean.instance()], + t.boolean.instance(), + {} + ); + expect(instancedFn?.args[1]).toStrictEqual(t.boolean.instance()); + }); + test('tags infer', () => { + const fn = t.fn.instance( + [ + t.typeVarReference.create('A'), + t.array.instance(t.typeVarReference.create('A')), + ] as const, + t.boolean.instance(), + [t.typeVarDefine.create('A', t.tag.instance())] + ); + const fnArray = t.fn.instance( + [ + t.array.instance(t.typeVarReference.create('A')), + t.array.instance(t.typeVarReference.create('A')), + ] as const, + t.boolean.instance(), + [t.typeVarDefine.create('A', t.tag.instance())] + ); + const tags: SelectTag[] = [{ id: 'a', value: 'b', color: 'c' }]; + const instancedFn = typeSystem.instanceFn( + fn, + [t.tag.instance(tags)], + t.boolean.instance(), + {} + ); + const instancedFnArray = typeSystem.instanceFn( + fnArray, + [t.array.instance(t.tag.instance(tags))], + t.boolean.instance(), + {} + ); + expect( + typeSystem.unify( + instancedFn?.args[1], + t.array.instance(t.tag.instance(tags)) + ) + ).toBe(true); + expect( + typeSystem.unify( + instancedFnArray?.args[1], + t.array.instance(t.tag.instance(tags)) + ) + ).toBe(true); + }); +}); diff --git a/blocksuite/blocks/src/__tests__/surface/mindmap-preview.unit.spec.ts b/blocksuite/blocks/src/__tests__/surface/mindmap-preview.unit.spec.ts new file mode 100644 index 0000000000..cc12527843 --- /dev/null +++ b/blocksuite/blocks/src/__tests__/surface/mindmap-preview.unit.spec.ts @@ -0,0 +1,92 @@ +import { DocCollection, Schema } from '@blocksuite/store'; +import { describe, expect, test } from 'vitest'; + +import { markdownToMindmap } from '../../surface-block/mini-mindmap/mindmap-preview.js'; + +describe('markdownToMindmap: convert markdown list to a mind map tree', () => { + test('basic case', () => { + const markdown = ` +- Text A + - Text B + - Text C + - Text D + - Text E +`; + const collection = new DocCollection({ schema: new Schema() }); + collection.meta.initialize(); + const doc = collection.createDoc(); + const nodes = markdownToMindmap(markdown, doc); + + expect(nodes).toEqual({ + text: 'Text A', + children: [ + { + text: 'Text B', + children: [ + { + text: 'Text C', + children: [], + }, + ], + }, + { + text: 'Text D', + children: [ + { + text: 'Text E', + children: [], + }, + ], + }, + ], + }); + }); + + test('basic case with different indent', () => { + const markdown = ` +- Text A + - Text B + - Text C + - Text D + - Text E +`; + const collection = new DocCollection({ schema: new Schema() }); + collection.meta.initialize(); + const doc = collection.createDoc(); + const nodes = markdownToMindmap(markdown, doc); + + expect(nodes).toEqual({ + text: 'Text A', + children: [ + { + text: 'Text B', + children: [ + { + text: 'Text C', + children: [], + }, + ], + }, + { + text: 'Text D', + children: [ + { + text: 'Text E', + children: [], + }, + ], + }, + ], + }); + }); + + test('empty case', () => { + const markdown = ''; + const collection = new DocCollection({ schema: new Schema() }); + collection.meta.initialize(); + const doc = collection.createDoc(); + const nodes = markdownToMindmap(markdown, doc); + + expect(nodes).toEqual(null); + }); +}); diff --git a/blocksuite/blocks/src/__tests__/utils/create-job.ts b/blocksuite/blocks/src/__tests__/utils/create-job.ts new file mode 100644 index 0000000000..1a029d2e97 --- /dev/null +++ b/blocksuite/blocks/src/__tests__/utils/create-job.ts @@ -0,0 +1,31 @@ +import { + DocCollection, + Job, + type JobMiddleware, + Schema, +} from '@blocksuite/store'; + +import { defaultImageProxyMiddleware } from '../../_common/transformers/middlewares.js'; +import { AffineSchemas } from '../../schemas.js'; + +declare global { + interface Window { + happyDOM: { + settings: { + fetch: { + disableSameOriginPolicy: boolean; + }; + }; + }; + } +} + +export function createJob(middlewares?: JobMiddleware[]) { + window.happyDOM.settings.fetch.disableSameOriginPolicy = true; + const testMiddlewares = middlewares ?? []; + testMiddlewares.push(defaultImageProxyMiddleware); + const schema = new Schema().register(AffineSchemas); + const docCollection = new DocCollection({ schema }); + docCollection.meta.initialize(); + return new Job({ collection: docCollection, middlewares: testMiddlewares }); +} diff --git a/blocksuite/blocks/src/_common/adapters/attachment.ts b/blocksuite/blocks/src/_common/adapters/attachment.ts new file mode 100644 index 0000000000..9170553cf9 --- /dev/null +++ b/blocksuite/blocks/src/_common/adapters/attachment.ts @@ -0,0 +1,139 @@ +import type { ExtensionType } from '@blocksuite/block-std'; +import { BlockSuiteError, ErrorCode } from '@blocksuite/global/exceptions'; +import { sha } from '@blocksuite/global/utils'; +import { + type AssetsManager, + BaseAdapter, + type BlockSnapshot, + type DocSnapshot, + type FromBlockSnapshotPayload, + type FromBlockSnapshotResult, + type FromDocSnapshotPayload, + type FromDocSnapshotResult, + type FromSliceSnapshotPayload, + type FromSliceSnapshotResult, + type Job, + nanoid, + type SliceSnapshot, + type ToBlockSnapshotPayload, + type ToDocSnapshotPayload, +} from '@blocksuite/store'; + +import { AdapterFactoryIdentifier } from './type.js'; + +export type Attachment = File[]; + +type AttachmentToSliceSnapshotPayload = { + file: Attachment; + assets?: AssetsManager; + blockVersions: Record; + workspaceId: string; + pageId: string; +}; + +export class AttachmentAdapter extends BaseAdapter { + override fromBlockSnapshot( + _payload: FromBlockSnapshotPayload + ): Promise> { + throw new BlockSuiteError( + ErrorCode.TransformerNotImplementedError, + 'AttachmentAdapter.fromBlockSnapshot is not implemented.' + ); + } + + override fromDocSnapshot( + _payload: FromDocSnapshotPayload + ): Promise> { + throw new BlockSuiteError( + ErrorCode.TransformerNotImplementedError, + 'AttachmentAdapter.fromDocSnapshot is not implemented.' + ); + } + + override fromSliceSnapshot( + payload: FromSliceSnapshotPayload + ): Promise> { + const attachments: Attachment = []; + for (const contentSlice of payload.snapshot.content) { + if (contentSlice.type === 'block') { + const { flavour, props } = contentSlice; + if (flavour === 'affine:attachment') { + const { sourceId } = props; + const file = payload.assets?.getAssets().get(sourceId as string) as + | File + | undefined; + if (file) { + attachments.push(file); + } + } + } + } + return Promise.resolve({ file: attachments, assetsIds: [] }); + } + + override toBlockSnapshot( + _payload: ToBlockSnapshotPayload + ): Promise { + throw new BlockSuiteError( + ErrorCode.TransformerNotImplementedError, + 'AttachmentAdapter.toBlockSnapshot is not implemented.' + ); + } + + override toDocSnapshot( + _payload: ToDocSnapshotPayload + ): Promise { + throw new BlockSuiteError( + ErrorCode.TransformerNotImplementedError, + 'AttachmentAdapter.toDocSnapshot is not implemented.' + ); + } + + override async toSliceSnapshot( + payload: AttachmentToSliceSnapshotPayload + ): Promise { + const content: SliceSnapshot['content'] = []; + for (const item of payload.file) { + const blobId = await sha(await item.arrayBuffer()); + payload.assets?.getAssets().set(blobId, item); + await payload.assets?.writeToBlob(blobId); + content.push({ + type: 'block', + flavour: 'affine:attachment', + id: nanoid(), + props: { + name: item.name, + size: item.size, + type: item.type, + embed: false, + style: 'horizontalThin', + index: 'a0', + xywh: '[0,0,0,0]', + rotate: 0, + sourceId: blobId, + }, + children: [], + }); + } + if (content.length === 0) { + return null; + } + return { + type: 'slice', + content, + workspaceId: payload.workspaceId, + pageId: payload.pageId, + }; + } +} + +export const AttachmentAdapterFactoryIdentifier = + AdapterFactoryIdentifier('Attachment'); + +export const AttachmentAdapterFactoryExtension: ExtensionType = { + setup: di => { + di.addImpl(AttachmentAdapterFactoryIdentifier, () => ({ + get: (job: Job) => new AttachmentAdapter(job), + })); + }, +}; diff --git a/blocksuite/blocks/src/_common/adapters/extension.ts b/blocksuite/blocks/src/_common/adapters/extension.ts new file mode 100644 index 0000000000..c2fde47ffa --- /dev/null +++ b/blocksuite/blocks/src/_common/adapters/extension.ts @@ -0,0 +1,32 @@ +import type { ExtensionType } from '@blocksuite/block-std'; + +import { AttachmentAdapterFactoryExtension } from './attachment.js'; +import { BlockHtmlAdapterExtensions } from './html-adapter/block-matcher.js'; +import { HtmlAdapterFactoryExtension } from './html-adapter/html.js'; +import { ImageAdapterFactoryExtension } from './image.js'; +import { BlockMarkdownAdapterExtensions } from './markdown/block-matcher.js'; +import { MarkdownAdapterFactoryExtension } from './markdown/markdown.js'; +import { MixTextAdapterFactoryExtension } from './mix-text.js'; +import { BlockNotionHtmlAdapterExtensions } from './notion-html/block-matcher.js'; +import { NotionHtmlAdapterFactoryExtension } from './notion-html/notion-html.js'; +import { NotionTextAdapterFactoryExtension } from './notion-text.js'; +import { BlockPlainTextAdapterExtensions } from './plain-text/block-matcher.js'; +import { PlainTextAdapterFactoryExtension } from './plain-text/plain-text.js'; + +export const AdapterFactoryExtensions: ExtensionType[] = [ + AttachmentAdapterFactoryExtension, + ImageAdapterFactoryExtension, + MarkdownAdapterFactoryExtension, + PlainTextAdapterFactoryExtension, + HtmlAdapterFactoryExtension, + NotionTextAdapterFactoryExtension, + NotionHtmlAdapterFactoryExtension, + MixTextAdapterFactoryExtension, +]; + +export const BlockAdapterMatcherExtensions: ExtensionType[] = [ + BlockPlainTextAdapterExtensions, + BlockMarkdownAdapterExtensions, + BlockHtmlAdapterExtensions, + BlockNotionHtmlAdapterExtensions, +].flat(); diff --git a/blocksuite/blocks/src/_common/adapters/html-adapter/block-matcher.ts b/blocksuite/blocks/src/_common/adapters/html-adapter/block-matcher.ts new file mode 100644 index 0000000000..5a51a08a01 --- /dev/null +++ b/blocksuite/blocks/src/_common/adapters/html-adapter/block-matcher.ts @@ -0,0 +1,82 @@ +import { + EmbedFigmaBlockHtmlAdapterExtension, + embedFigmaBlockHtmlAdapterMatcher, + EmbedGithubBlockHtmlAdapterExtension, + embedGithubBlockHtmlAdapterMatcher, + embedLinkedDocBlockHtmlAdapterMatcher, + EmbedLinkedDocHtmlAdapterExtension, + EmbedLoomBlockHtmlAdapterExtension, + embedLoomBlockHtmlAdapterMatcher, + EmbedSyncedDocBlockHtmlAdapterExtension, + embedSyncedDocBlockHtmlAdapterMatcher, + EmbedYoutubeBlockHtmlAdapterExtension, + embedYoutubeBlockHtmlAdapterMatcher, +} from '@blocksuite/affine-block-embed'; +import { + ListBlockHtmlAdapterExtension, + listBlockHtmlAdapterMatcher, +} from '@blocksuite/affine-block-list'; +import { + ParagraphBlockHtmlAdapterExtension, + paragraphBlockHtmlAdapterMatcher, +} from '@blocksuite/affine-block-paragraph'; +import type { ExtensionType } from '@blocksuite/block-std'; + +import { + BookmarkBlockHtmlAdapterExtension, + bookmarkBlockHtmlAdapterMatcher, +} from '../../../bookmark-block/adapters/html.js'; +import { + CodeBlockHtmlAdapterExtension, + codeBlockHtmlAdapterMatcher, +} from '../../../code-block/adapters/html.js'; +import { + DatabaseBlockHtmlAdapterExtension, + databaseBlockHtmlAdapterMatcher, +} from '../../../database-block/adapters/html.js'; +import { + DividerBlockHtmlAdapterExtension, + dividerBlockHtmlAdapterMatcher, +} from '../../../divider-block/adapters/html.js'; +import { + ImageBlockHtmlAdapterExtension, + imageBlockHtmlAdapterMatcher, +} from '../../../image-block/adapters/html.js'; +import { + RootBlockHtmlAdapterExtension, + rootBlockHtmlAdapterMatcher, +} from '../../../root-block/adapters/html.js'; + +export const defaultBlockHtmlAdapterMatchers = [ + listBlockHtmlAdapterMatcher, + paragraphBlockHtmlAdapterMatcher, + codeBlockHtmlAdapterMatcher, + dividerBlockHtmlAdapterMatcher, + imageBlockHtmlAdapterMatcher, + rootBlockHtmlAdapterMatcher, + embedYoutubeBlockHtmlAdapterMatcher, + embedFigmaBlockHtmlAdapterMatcher, + embedLoomBlockHtmlAdapterMatcher, + embedGithubBlockHtmlAdapterMatcher, + bookmarkBlockHtmlAdapterMatcher, + databaseBlockHtmlAdapterMatcher, + embedLinkedDocBlockHtmlAdapterMatcher, + embedSyncedDocBlockHtmlAdapterMatcher, +]; + +export const BlockHtmlAdapterExtensions: ExtensionType[] = [ + ListBlockHtmlAdapterExtension, + ParagraphBlockHtmlAdapterExtension, + CodeBlockHtmlAdapterExtension, + DividerBlockHtmlAdapterExtension, + ImageBlockHtmlAdapterExtension, + RootBlockHtmlAdapterExtension, + EmbedYoutubeBlockHtmlAdapterExtension, + EmbedFigmaBlockHtmlAdapterExtension, + EmbedLoomBlockHtmlAdapterExtension, + EmbedGithubBlockHtmlAdapterExtension, + BookmarkBlockHtmlAdapterExtension, + DatabaseBlockHtmlAdapterExtension, + EmbedLinkedDocHtmlAdapterExtension, + EmbedSyncedDocBlockHtmlAdapterExtension, +]; diff --git a/blocksuite/blocks/src/_common/adapters/html-adapter/delta-converter/html-inline.ts b/blocksuite/blocks/src/_common/adapters/html-adapter/delta-converter/html-inline.ts new file mode 100644 index 0000000000..7a0d910322 --- /dev/null +++ b/blocksuite/blocks/src/_common/adapters/html-adapter/delta-converter/html-inline.ts @@ -0,0 +1,234 @@ +import type { + HtmlAST, + HtmlASTToDeltaMatcher, +} from '@blocksuite/affine-shared/adapters'; +import { collapseWhiteSpace } from 'collapse-white-space'; +import type { Element } from 'hast'; + +const isElement = (ast: HtmlAST): ast is Element => { + return ast.type === 'element'; +}; + +const textLikeElementTags = new Set(['span', 'bdi', 'bdo', 'ins']); +const listElementTags = new Set(['ol', 'ul']); +const strongElementTags = new Set(['strong', 'b']); +const italicElementTags = new Set(['i', 'em']); + +export const htmlTextToDeltaMatcher: HtmlASTToDeltaMatcher = { + name: 'text', + match: ast => ast.type === 'text', + toDelta: (ast, context) => { + if (!('value' in ast)) { + return []; + } + const { options } = context; + options.trim ??= true; + + if (options.pre) { + return [{ insert: ast.value }]; + } + + const value = options.trim + ? collapseWhiteSpace(ast.value, { trim: options.trim }) + : collapseWhiteSpace(ast.value); + return value ? [{ insert: value }] : []; + }, +}; + +export const htmlTextLikeElementToDeltaMatcher: HtmlASTToDeltaMatcher = { + name: 'text-like-element', + match: ast => isElement(ast) && textLikeElementTags.has(ast.tagName), + toDelta: (ast, context) => { + if (!isElement(ast)) { + return []; + } + return ast.children.flatMap(child => + context.toDelta(child, { trim: false }) + ); + }, +}; + +export const htmlListToDeltaMatcher: HtmlASTToDeltaMatcher = { + name: 'list-element', + match: ast => isElement(ast) && listElementTags.has(ast.tagName), + toDelta: () => { + return []; + }, +}; + +export const htmlStrongElementToDeltaMatcher: HtmlASTToDeltaMatcher = { + name: 'strong-element', + match: ast => isElement(ast) && strongElementTags.has(ast.tagName), + toDelta: (ast, context) => { + if (!isElement(ast)) { + return []; + } + return ast.children.flatMap(child => + context.toDelta(child, { trim: false }).map(delta => { + delta.attributes = { ...delta.attributes, bold: true }; + return delta; + }) + ); + }, +}; + +export const htmlItalicElementToDeltaMatcher: HtmlASTToDeltaMatcher = { + name: 'italic-element', + match: ast => isElement(ast) && italicElementTags.has(ast.tagName), + toDelta: (ast, context) => { + if (!isElement(ast)) { + return []; + } + return ast.children.flatMap(child => + context.toDelta(child, { trim: false }).map(delta => { + delta.attributes = { ...delta.attributes, italic: true }; + return delta; + }) + ); + }, +}; +export const htmlCodeElementToDeltaMatcher: HtmlASTToDeltaMatcher = { + name: 'code-element', + match: ast => isElement(ast) && ast.tagName === 'code', + toDelta: (ast, context) => { + if (!isElement(ast)) { + return []; + } + return ast.children.flatMap(child => + context.toDelta(child, { trim: false }).map(delta => { + delta.attributes = { ...delta.attributes, code: true }; + return delta; + }) + ); + }, +}; + +export const htmlDelElementToDeltaMatcher: HtmlASTToDeltaMatcher = { + name: 'del-element', + match: ast => isElement(ast) && ast.tagName === 'del', + toDelta: (ast, context) => { + if (!isElement(ast)) { + return []; + } + return ast.children.flatMap(child => + context.toDelta(child, { trim: false }).map(delta => { + delta.attributes = { ...delta.attributes, strike: true }; + return delta; + }) + ); + }, +}; + +export const htmlUnderlineElementToDeltaMatcher: HtmlASTToDeltaMatcher = { + name: 'underline-element', + match: ast => isElement(ast) && ast.tagName === 'u', + toDelta: (ast, context) => { + if (!isElement(ast)) { + return []; + } + return ast.children.flatMap(child => + context.toDelta(child, { trim: false }).map(delta => { + delta.attributes = { ...delta.attributes, underline: true }; + return delta; + }) + ); + }, +}; + +export const htmlLinkElementToDeltaMatcher: HtmlASTToDeltaMatcher = { + name: 'link-element', + match: ast => isElement(ast) && ast.tagName === 'a', + toDelta: (ast, context) => { + if (!isElement(ast)) { + return []; + } + const href = ast.properties?.href; + if (typeof href !== 'string') { + return []; + } + const { configs } = context; + const baseUrl = configs.get('docLinkBaseUrl') ?? ''; + if (baseUrl && href.startsWith(baseUrl)) { + const path = href.substring(baseUrl.length); + // ^ - /{pageId}?mode={mode}&blockIds={blockIds}&elementIds={elementIds} + const match = path.match(/^\/([^?]+)(\?.*)?$/); + if (match) { + const pageId = match?.[1]; + const search = match?.[2]; + const searchParams = search ? new URLSearchParams(search) : undefined; + const mode = searchParams?.get('mode'); + const blockIds = searchParams?.get('blockIds')?.split(','); + const elementIds = searchParams?.get('elementIds')?.split(','); + + return [ + { + insert: ' ', + attributes: { + reference: { + type: 'LinkedPage', + pageId, + params: { + mode: + mode && ['edgeless', 'page'].includes(mode) + ? (mode as 'edgeless' | 'page') + : undefined, + blockIds, + elementIds, + }, + }, + }, + }, + ]; + } + } + return ast.children.flatMap(child => + context.toDelta(child, { trim: false }).map(delta => { + if (href.startsWith('http')) { + delta.attributes = { + ...delta.attributes, + link: href, + }; + return delta; + } + return delta; + }) + ); + }, +}; + +export const htmlMarkElementToDeltaMatcher: HtmlASTToDeltaMatcher = { + name: 'mark-element', + match: ast => isElement(ast) && ast.tagName === 'mark', + toDelta: (ast, context) => { + if (!isElement(ast)) { + return []; + } + return ast.children.flatMap(child => + context.toDelta(child, { trim: false }).map(delta => { + delta.attributes = { ...delta.attributes }; + return delta; + }) + ); + }, +}; + +export const htmlBrElementToDeltaMatcher: HtmlASTToDeltaMatcher = { + name: 'br-element', + match: ast => isElement(ast) && ast.tagName === 'br', + toDelta: () => { + return [{ insert: '\n' }]; + }, +}; + +export const htmlInlineToDeltaMatchers: HtmlASTToDeltaMatcher[] = [ + htmlTextToDeltaMatcher, + htmlTextLikeElementToDeltaMatcher, + htmlStrongElementToDeltaMatcher, + htmlItalicElementToDeltaMatcher, + htmlCodeElementToDeltaMatcher, + htmlDelElementToDeltaMatcher, + htmlUnderlineElementToDeltaMatcher, + htmlLinkElementToDeltaMatcher, + htmlMarkElementToDeltaMatcher, + htmlBrElementToDeltaMatcher, +]; diff --git a/blocksuite/blocks/src/_common/adapters/html-adapter/delta-converter/inline-delta.ts b/blocksuite/blocks/src/_common/adapters/html-adapter/delta-converter/inline-delta.ts new file mode 100644 index 0000000000..577e530211 --- /dev/null +++ b/blocksuite/blocks/src/_common/adapters/html-adapter/delta-converter/inline-delta.ts @@ -0,0 +1,145 @@ +import { generateDocUrl } from '@blocksuite/affine-block-embed'; +import type { + InlineDeltaToHtmlAdapterMatcher, + InlineHtmlAST, +} from '@blocksuite/affine-shared/adapters'; + +export const boldDeltaToHtmlAdapterMatcher: InlineDeltaToHtmlAdapterMatcher = { + name: 'bold', + match: delta => !!delta.attributes?.bold, + toAST: (_, context) => { + return { + type: 'element', + tagName: 'strong', + properties: {}, + children: [context.current], + }; + }, +}; + +export const italicDeltaToHtmlAdapterMatcher: InlineDeltaToHtmlAdapterMatcher = + { + name: 'italic', + match: delta => !!delta.attributes?.italic, + toAST: (_, context) => { + return { + type: 'element', + tagName: 'em', + properties: {}, + children: [context.current], + }; + }, + }; + +export const strikeDeltaToHtmlAdapterMatcher: InlineDeltaToHtmlAdapterMatcher = + { + name: 'strike', + match: delta => !!delta.attributes?.strike, + toAST: (_, context) => { + return { + type: 'element', + tagName: 'del', + properties: {}, + children: [context.current], + }; + }, + }; + +export const inlineCodeDeltaToMarkdownAdapterMatcher: InlineDeltaToHtmlAdapterMatcher = + { + name: 'inlineCode', + match: delta => !!delta.attributes?.code, + toAST: (_, context) => { + return { + type: 'element', + tagName: 'code', + properties: {}, + children: [context.current], + }; + }, + }; + +export const underlineDeltaToHtmlAdapterMatcher: InlineDeltaToHtmlAdapterMatcher = + { + name: 'underline', + match: delta => !!delta.attributes?.underline, + toAST: (_, context) => { + return { + type: 'element', + tagName: 'u', + properties: {}, + children: [context.current], + }; + }, + }; + +export const referenceDeltaToHtmlAdapterMatcher: InlineDeltaToHtmlAdapterMatcher = + { + name: 'reference', + match: delta => !!delta.attributes?.reference, + toAST: (delta, context) => { + let hast: InlineHtmlAST = { + type: 'text', + value: delta.insert, + }; + const reference = delta.attributes?.reference; + if (!reference) { + return hast; + } + + const { configs } = context; + const title = configs.get(`title:${reference.pageId}`); + const url = generateDocUrl( + configs.get('docLinkBaseUrl') ?? '', + String(reference.pageId), + reference.params ?? Object.create(null) + ); + if (title) { + hast.value = title; + } + hast = { + type: 'element', + tagName: 'a', + properties: { + href: url, + }, + children: [hast], + }; + + return hast; + }, + }; + +export const linkDeltaToHtmlAdapterMatcher: InlineDeltaToHtmlAdapterMatcher = { + name: 'link', + match: delta => !!delta.attributes?.link, + toAST: (delta, _) => { + const hast: InlineHtmlAST = { + type: 'text', + value: delta.insert, + }; + const link = delta.attributes?.link; + if (!link) { + return hast; + } + return { + type: 'element', + tagName: 'a', + properties: { + href: link, + }, + children: [hast], + }; + }, +}; + +export const inlineDeltaToHtmlAdapterMatchers: InlineDeltaToHtmlAdapterMatcher[] = + [ + boldDeltaToHtmlAdapterMatcher, + italicDeltaToHtmlAdapterMatcher, + strikeDeltaToHtmlAdapterMatcher, + underlineDeltaToHtmlAdapterMatcher, + inlineCodeDeltaToMarkdownAdapterMatcher, + referenceDeltaToHtmlAdapterMatcher, + linkDeltaToHtmlAdapterMatcher, + ]; diff --git a/blocksuite/blocks/src/_common/adapters/html-adapter/html.ts b/blocksuite/blocks/src/_common/adapters/html-adapter/html.ts new file mode 100644 index 0000000000..373e5b2e00 --- /dev/null +++ b/blocksuite/blocks/src/_common/adapters/html-adapter/html.ts @@ -0,0 +1,385 @@ +import { + DEFAULT_NOTE_BACKGROUND_COLOR, + NoteDisplayMode, +} from '@blocksuite/affine-model'; +import { + type AdapterContext, + type BlockHtmlAdapterMatcher, + BlockHtmlAdapterMatcherIdentifier, + HastUtils, + type HtmlAST, + HtmlDeltaConverter, +} from '@blocksuite/affine-shared/adapters'; +import type { ExtensionType } from '@blocksuite/block-std'; +import { + type AssetsManager, + ASTWalker, + BaseAdapter, + type BlockSnapshot, + BlockSnapshotSchema, + type DocSnapshot, + type FromBlockSnapshotPayload, + type FromBlockSnapshotResult, + type FromDocSnapshotPayload, + type FromDocSnapshotResult, + type FromSliceSnapshotPayload, + type FromSliceSnapshotResult, + type Job, + nanoid, + type SliceSnapshot, + type ToBlockSnapshotPayload, + type ToDocSnapshotPayload, +} from '@blocksuite/store'; +import type { Root } from 'hast'; +import rehypeParse from 'rehype-parse'; +import rehypeStringify from 'rehype-stringify'; +import { unified } from 'unified'; + +import { AdapterFactoryIdentifier } from '../type.js'; +import { defaultBlockHtmlAdapterMatchers } from './block-matcher.js'; +import { htmlInlineToDeltaMatchers } from './delta-converter/html-inline.js'; +import { inlineDeltaToHtmlAdapterMatchers } from './delta-converter/inline-delta.js'; + +export type Html = string; + +type HtmlToSliceSnapshotPayload = { + file: Html; + assets?: AssetsManager; + blockVersions: Record; + workspaceId: string; + pageId: string; +}; + +export class HtmlAdapter extends BaseAdapter { + private _astToHtml = (ast: Root) => { + return unified().use(rehypeStringify).stringify(ast); + }; + + private _traverseHtml = async ( + html: HtmlAST, + snapshot: BlockSnapshot, + assets?: AssetsManager + ) => { + const walker = new ASTWalker(); + walker.setONodeTypeGuard( + (node): node is HtmlAST => + 'type' in (node as object) && (node as HtmlAST).type !== undefined + ); + walker.setEnter(async (o, context) => { + for (const matcher of this.blockMatchers) { + if (matcher.toMatch(o)) { + const adapterContext: AdapterContext< + HtmlAST, + BlockSnapshot, + HtmlDeltaConverter + > = { + walker, + walkerContext: context, + configs: this.configs, + job: this.job, + deltaConverter: this.deltaConverter, + textBuffer: { content: '' }, + assets, + }; + await matcher.toBlockSnapshot.enter?.(o, adapterContext); + } + } + }); + walker.setLeave(async (o, context) => { + for (const matcher of this.blockMatchers) { + if (matcher.toMatch(o)) { + const adapterContext: AdapterContext< + HtmlAST, + BlockSnapshot, + HtmlDeltaConverter + > = { + walker, + walkerContext: context, + configs: this.configs, + job: this.job, + deltaConverter: this.deltaConverter, + textBuffer: { content: '' }, + assets, + }; + await matcher.toBlockSnapshot.leave?.(o, adapterContext); + } + } + }); + return walker.walk(html, snapshot); + }; + + private _traverseSnapshot = async ( + snapshot: BlockSnapshot, + html: HtmlAST, + assets?: AssetsManager + ) => { + const assetsIds: string[] = []; + const walker = new ASTWalker(); + walker.setONodeTypeGuard( + (node): node is BlockSnapshot => + BlockSnapshotSchema.safeParse(node).success + ); + walker.setEnter(async (o, context) => { + for (const matcher of this.blockMatchers) { + if (matcher.fromMatch(o)) { + const adapterContext: AdapterContext< + BlockSnapshot, + HtmlAST, + HtmlDeltaConverter + > = { + walker, + walkerContext: context, + configs: this.configs, + job: this.job, + deltaConverter: this.deltaConverter, + textBuffer: { content: '' }, + assets, + updateAssetIds: (assetsId: string) => { + assetsIds.push(assetsId); + }, + }; + await matcher.fromBlockSnapshot.enter?.(o, adapterContext); + } + } + }); + walker.setLeave(async (o, context) => { + for (const matcher of this.blockMatchers) { + if (matcher.fromMatch(o)) { + const adapterContext: AdapterContext< + BlockSnapshot, + HtmlAST, + HtmlDeltaConverter + > = { + walker, + walkerContext: context, + configs: this.configs, + job: this.job, + deltaConverter: this.deltaConverter, + textBuffer: { content: '' }, + assets, + }; + await matcher.fromBlockSnapshot.leave?.(o, adapterContext); + } + } + }); + return { + ast: (await walker.walk(snapshot, html)) as Root, + assetsIds, + }; + }; + + deltaConverter: HtmlDeltaConverter; + + constructor( + job: Job, + readonly blockMatchers: BlockHtmlAdapterMatcher[] = defaultBlockHtmlAdapterMatchers + ) { + super(job); + this.deltaConverter = new HtmlDeltaConverter( + job.adapterConfigs, + inlineDeltaToHtmlAdapterMatchers, + htmlInlineToDeltaMatchers + ); + } + + private _htmlToAst(html: Html) { + return unified().use(rehypeParse).parse(html); + } + + override async fromBlockSnapshot( + payload: FromBlockSnapshotPayload + ): Promise> { + const root: Root = { + type: 'root', + children: [ + { + type: 'doctype', + }, + ], + }; + const { ast, assetsIds } = await this._traverseSnapshot( + payload.snapshot, + root, + payload.assets + ); + return { + file: this._astToHtml(ast), + assetsIds, + }; + } + + override async fromDocSnapshot( + payload: FromDocSnapshotPayload + ): Promise> { + const { file, assetsIds } = await this.fromBlockSnapshot({ + snapshot: payload.snapshot.blocks, + assets: payload.assets, + }); + return { + file: file.replace( + '', + `

${payload.snapshot.meta.title}

` + ), + assetsIds, + }; + } + + override async fromSliceSnapshot( + payload: FromSliceSnapshotPayload + ): Promise> { + let buffer = ''; + const sliceAssetsIds: string[] = []; + for (const contentSlice of payload.snapshot.content) { + const root: Root = { + type: 'root', + children: [], + }; + const { ast, assetsIds } = await this._traverseSnapshot( + contentSlice, + root, + payload.assets + ); + sliceAssetsIds.push(...assetsIds); + buffer += this._astToHtml(ast); + } + const html = buffer; + return { + file: html, + assetsIds: sliceAssetsIds, + }; + } + + override toBlockSnapshot( + payload: ToBlockSnapshotPayload + ): Promise { + const htmlAst = this._htmlToAst(payload.file); + const blockSnapshotRoot = { + type: 'block', + id: nanoid(), + flavour: 'affine:note', + props: { + xywh: '[0,0,800,95]', + background: DEFAULT_NOTE_BACKGROUND_COLOR, + index: 'a0', + hidden: false, + displayMode: NoteDisplayMode.DocAndEdgeless, + }, + children: [], + }; + return this._traverseHtml( + htmlAst, + blockSnapshotRoot as BlockSnapshot, + payload.assets + ); + } + + override async toDocSnapshot( + payload: ToDocSnapshotPayload + ): Promise { + const htmlAst = this._htmlToAst(payload.file); + const titleAst = HastUtils.querySelector(htmlAst, 'title'); + const blockSnapshotRoot = { + type: 'block', + id: nanoid(), + flavour: 'affine:note', + props: { + xywh: '[0,0,800,95]', + background: DEFAULT_NOTE_BACKGROUND_COLOR, + index: 'a0', + hidden: false, + displayMode: NoteDisplayMode.DocAndEdgeless, + }, + children: [], + }; + return { + type: 'page', + meta: { + id: nanoid(), + title: HastUtils.getTextContent(titleAst, 'Untitled'), + createDate: Date.now(), + tags: [], + }, + blocks: { + type: 'block', + id: nanoid(), + flavour: 'affine:page', + props: { + title: { + '$blocksuite:internal:text$': true, + delta: this.deltaConverter.astToDelta( + titleAst ?? { + type: 'text', + value: 'Untitled', + } + ), + }, + }, + children: [ + { + type: 'block', + id: nanoid(), + flavour: 'affine:surface', + props: { + elements: {}, + }, + children: [], + }, + await this._traverseHtml( + htmlAst, + blockSnapshotRoot as BlockSnapshot, + payload.assets + ), + ], + }, + }; + } + + override async toSliceSnapshot( + payload: HtmlToSliceSnapshotPayload + ): Promise { + const htmlAst = this._htmlToAst(payload.file); + const blockSnapshotRoot = { + type: 'block', + id: nanoid(), + flavour: 'affine:note', + props: { + xywh: '[0,0,800,95]', + background: DEFAULT_NOTE_BACKGROUND_COLOR, + index: 'a0', + hidden: false, + displayMode: NoteDisplayMode.DocAndEdgeless, + }, + children: [], + }; + const contentSlice = (await this._traverseHtml( + htmlAst, + blockSnapshotRoot as BlockSnapshot, + payload.assets + )) as BlockSnapshot; + if (contentSlice.children.length === 0) { + return null; + } + return { + type: 'slice', + content: [contentSlice], + workspaceId: payload.workspaceId, + pageId: payload.pageId, + }; + } +} + +export const HtmlAdapterFactoryIdentifier = AdapterFactoryIdentifier('Html'); + +export const HtmlAdapterFactoryExtension: ExtensionType = { + setup: di => { + di.addImpl(HtmlAdapterFactoryIdentifier, provider => ({ + get: (job: Job) => + new HtmlAdapter( + job, + Array.from( + provider.getAll(BlockHtmlAdapterMatcherIdentifier).values() + ) + ), + })); + }, +}; diff --git a/blocksuite/blocks/src/_common/adapters/html-adapter/index.ts b/blocksuite/blocks/src/_common/adapters/html-adapter/index.ts new file mode 100644 index 0000000000..cf75421237 --- /dev/null +++ b/blocksuite/blocks/src/_common/adapters/html-adapter/index.ts @@ -0,0 +1,9 @@ +export { + BlockHtmlAdapterExtensions, + defaultBlockHtmlAdapterMatchers, +} from './block-matcher.js'; +export { + HtmlAdapter, + HtmlAdapterFactoryExtension, + HtmlAdapterFactoryIdentifier, +} from './html.js'; diff --git a/blocksuite/blocks/src/_common/adapters/image.ts b/blocksuite/blocks/src/_common/adapters/image.ts new file mode 100644 index 0000000000..7d45fc006d --- /dev/null +++ b/blocksuite/blocks/src/_common/adapters/image.ts @@ -0,0 +1,130 @@ +import type { ExtensionType } from '@blocksuite/block-std'; +import { BlockSuiteError, ErrorCode } from '@blocksuite/global/exceptions'; +import { sha } from '@blocksuite/global/utils'; +import { + type AssetsManager, + BaseAdapter, + type BlockSnapshot, + type DocSnapshot, + type FromBlockSnapshotPayload, + type FromBlockSnapshotResult, + type FromDocSnapshotPayload, + type FromDocSnapshotResult, + type FromSliceSnapshotPayload, + type FromSliceSnapshotResult, + type Job, + nanoid, + type SliceSnapshot, + type ToBlockSnapshotPayload, + type ToDocSnapshotPayload, +} from '@blocksuite/store'; + +import { AdapterFactoryIdentifier } from './type.js'; + +export type Image = File[]; + +type ImageToSliceSnapshotPayload = { + file: Image; + assets?: AssetsManager; + blockVersions: Record; + workspaceId: string; + pageId: string; +}; + +export class ImageAdapter extends BaseAdapter { + override fromBlockSnapshot( + _payload: FromBlockSnapshotPayload + ): Promise> { + throw new BlockSuiteError( + ErrorCode.TransformerNotImplementedError, + 'ImageAdapter.fromBlockSnapshot is not implemented.' + ); + } + + override fromDocSnapshot( + _payload: FromDocSnapshotPayload + ): Promise> { + throw new BlockSuiteError( + ErrorCode.TransformerNotImplementedError, + 'ImageAdapter.fromDocSnapshot is not implemented.' + ); + } + + override fromSliceSnapshot( + payload: FromSliceSnapshotPayload + ): Promise> { + const images: Image = []; + for (const contentSlice of payload.snapshot.content) { + if (contentSlice.type === 'block') { + const { flavour, props } = contentSlice; + if (flavour === 'affine:image') { + const { sourceId } = props; + const file = payload.assets?.getAssets().get(sourceId as string) as + | File + | undefined; + if (file) { + images.push(file); + } + } + } + } + return Promise.resolve({ file: images, assetsIds: [] }); + } + + override toBlockSnapshot( + _payload: ToBlockSnapshotPayload + ): Promise { + throw new BlockSuiteError( + ErrorCode.TransformerNotImplementedError, + 'ImageAdapter.toBlockSnapshot is not implemented.' + ); + } + + override toDocSnapshot( + _payload: ToDocSnapshotPayload + ): Promise { + throw new BlockSuiteError( + ErrorCode.TransformerNotImplementedError, + 'ImageAdapter.toDocSnapshot is not implemented' + ); + } + + override async toSliceSnapshot( + payload: ImageToSliceSnapshotPayload + ): Promise { + const content: SliceSnapshot['content'] = []; + for (const item of payload.file) { + const blobId = await sha(await item.arrayBuffer()); + payload.assets?.getAssets().set(blobId, item); + await payload.assets?.writeToBlob(blobId); + content.push({ + type: 'block', + flavour: 'affine:image', + id: nanoid(), + props: { + sourceId: blobId, + }, + children: [], + }); + } + if (content.length === 0) { + return null; + } + return { + type: 'slice', + content, + workspaceId: payload.workspaceId, + pageId: payload.pageId, + }; + } +} + +export const ImageAdapterFactoryIdentifier = AdapterFactoryIdentifier('Image'); + +export const ImageAdapterFactoryExtension: ExtensionType = { + setup: di => { + di.addImpl(ImageAdapterFactoryIdentifier, () => ({ + get: (job: Job) => new ImageAdapter(job), + })); + }, +}; diff --git a/blocksuite/blocks/src/_common/adapters/index.ts b/blocksuite/blocks/src/_common/adapters/index.ts new file mode 100644 index 0000000000..a1766dd7de --- /dev/null +++ b/blocksuite/blocks/src/_common/adapters/index.ts @@ -0,0 +1,8 @@ +export * from './attachment.js'; +export * from './html-adapter/html.js'; +export * from './image.js'; +export * from './markdown/index.js'; +export * from './mix-text.js'; +export * from './notion-html/index.js'; +export * from './notion-text.js'; +export * from './plain-text/plain-text.js'; diff --git a/blocksuite/blocks/src/_common/adapters/markdown/block-matcher.ts b/blocksuite/blocks/src/_common/adapters/markdown/block-matcher.ts new file mode 100644 index 0000000000..28b1260389 --- /dev/null +++ b/blocksuite/blocks/src/_common/adapters/markdown/block-matcher.ts @@ -0,0 +1,88 @@ +import { + embedFigmaBlockMarkdownAdapterMatcher, + EmbedFigmaMarkdownAdapterExtension, + embedGithubBlockMarkdownAdapterMatcher, + EmbedGithubMarkdownAdapterExtension, + embedLinkedDocBlockMarkdownAdapterMatcher, + EmbedLinkedDocMarkdownAdapterExtension, + embedLoomBlockMarkdownAdapterMatcher, + EmbedLoomMarkdownAdapterExtension, + EmbedSyncedDocBlockMarkdownAdapterExtension, + embedSyncedDocBlockMarkdownAdapterMatcher, + embedYoutubeBlockMarkdownAdapterMatcher, + EmbedYoutubeMarkdownAdapterExtension, +} from '@blocksuite/affine-block-embed'; +import { + ListBlockMarkdownAdapterExtension, + listBlockMarkdownAdapterMatcher, +} from '@blocksuite/affine-block-list'; +import { + ParagraphBlockMarkdownAdapterExtension, + paragraphBlockMarkdownAdapterMatcher, +} from '@blocksuite/affine-block-paragraph'; +import type { ExtensionType } from '@blocksuite/block-std'; + +import { + BookmarkBlockMarkdownAdapterExtension, + bookmarkBlockMarkdownAdapterMatcher, +} from '../../../bookmark-block/adapters/markdown.js'; +import { + CodeBlockMarkdownAdapterExtension, + codeBlockMarkdownAdapterMatcher, +} from '../../../code-block/adapters/markdown.js'; +import { + DatabaseBlockMarkdownAdapterExtension, + databaseBlockMarkdownAdapterMatcher, +} from '../../../database-block/adapters/markdown.js'; +import { + DividerBlockMarkdownAdapterExtension, + dividerBlockMarkdownAdapterMatcher, +} from '../../../divider-block/adapters/markdown.js'; +import { + ImageBlockMarkdownAdapterExtension, + imageBlockMarkdownAdapterMatcher, +} from '../../../image-block/adapters/markdown.js'; +import { + LatexBlockMarkdownAdapterExtension, + latexBlockMarkdownAdapterMatcher, +} from '../../../latex-block/adapters/markdown.js'; +import { + RootBlockMarkdownAdapterExtension, + rootBlockMarkdownAdapterMatcher, +} from '../../../root-block/adapters/markdown.js'; + +export const defaultBlockMarkdownAdapterMatchers = [ + embedFigmaBlockMarkdownAdapterMatcher, + embedGithubBlockMarkdownAdapterMatcher, + embedLinkedDocBlockMarkdownAdapterMatcher, + embedLoomBlockMarkdownAdapterMatcher, + embedSyncedDocBlockMarkdownAdapterMatcher, + embedYoutubeBlockMarkdownAdapterMatcher, + listBlockMarkdownAdapterMatcher, + paragraphBlockMarkdownAdapterMatcher, + bookmarkBlockMarkdownAdapterMatcher, + codeBlockMarkdownAdapterMatcher, + databaseBlockMarkdownAdapterMatcher, + dividerBlockMarkdownAdapterMatcher, + imageBlockMarkdownAdapterMatcher, + latexBlockMarkdownAdapterMatcher, + rootBlockMarkdownAdapterMatcher, +]; + +export const BlockMarkdownAdapterExtensions: ExtensionType[] = [ + EmbedFigmaMarkdownAdapterExtension, + EmbedGithubMarkdownAdapterExtension, + EmbedLinkedDocMarkdownAdapterExtension, + EmbedLoomMarkdownAdapterExtension, + EmbedSyncedDocBlockMarkdownAdapterExtension, + EmbedYoutubeMarkdownAdapterExtension, + ListBlockMarkdownAdapterExtension, + ParagraphBlockMarkdownAdapterExtension, + BookmarkBlockMarkdownAdapterExtension, + CodeBlockMarkdownAdapterExtension, + DatabaseBlockMarkdownAdapterExtension, + DividerBlockMarkdownAdapterExtension, + ImageBlockMarkdownAdapterExtension, + LatexBlockMarkdownAdapterExtension, + RootBlockMarkdownAdapterExtension, +]; diff --git a/blocksuite/blocks/src/_common/adapters/markdown/delta-converter/inline-delta.ts b/blocksuite/blocks/src/_common/adapters/markdown/delta-converter/inline-delta.ts new file mode 100644 index 0000000000..a1b9461d12 --- /dev/null +++ b/blocksuite/blocks/src/_common/adapters/markdown/delta-converter/inline-delta.ts @@ -0,0 +1,153 @@ +import { generateDocUrl } from '@blocksuite/affine-block-embed'; +import type { InlineDeltaToMarkdownAdapterMatcher } from '@blocksuite/affine-shared/adapters'; +import type { PhrasingContent } from 'mdast'; + +export const boldDeltaToMarkdownAdapterMatcher: InlineDeltaToMarkdownAdapterMatcher = + { + name: 'bold', + match: delta => !!delta.attributes?.bold, + toAST: (_, context) => { + const { current: currentMdast } = context; + return { + type: 'strong', + children: [currentMdast], + }; + }, + }; + +export const italicDeltaToMarkdownAdapterMatcher: InlineDeltaToMarkdownAdapterMatcher = + { + name: 'italic', + match: delta => !!delta.attributes?.italic, + toAST: (_, context) => { + const { current: currentMdast } = context; + return { + type: 'emphasis', + children: [currentMdast], + }; + }, + }; + +export const strikeDeltaToMarkdownAdapterMatcher: InlineDeltaToMarkdownAdapterMatcher = + { + name: 'strike', + match: delta => !!delta.attributes?.strike, + toAST: (_, context) => { + const { current: currentMdast } = context; + return { + type: 'delete', + children: [currentMdast], + }; + }, + }; + +export const inlineCodeDeltaToMarkdownAdapterMatcher: InlineDeltaToMarkdownAdapterMatcher = + { + name: 'inlineCode', + match: delta => !!delta.attributes?.code, + toAST: delta => ({ + type: 'inlineCode', + value: delta.insert, + }), + }; + +export const referenceDeltaToMarkdownAdapterMatcher: InlineDeltaToMarkdownAdapterMatcher = + { + name: 'reference', + match: delta => !!delta.attributes?.reference, + toAST: (delta, context) => { + let mdast: PhrasingContent = { + type: 'text', + value: delta.insert, + }; + const reference = delta.attributes?.reference; + if (!reference) { + return mdast; + } + + const { configs } = context; + const title = configs.get(`title:${reference.pageId}`); + const params = reference.params ?? {}; + const url = generateDocUrl( + configs.get('docLinkBaseUrl') ?? '', + String(reference.pageId), + params + ); + mdast = { + type: 'link', + url, + children: [ + { + type: 'text', + value: title ?? '', + }, + ], + }; + + return mdast; + }, + }; + +export const linkDeltaToMarkdownAdapterMatcher: InlineDeltaToMarkdownAdapterMatcher = + { + name: 'link', + match: delta => !!delta.attributes?.link, + toAST: (delta, context) => { + const mdast: PhrasingContent = { + type: 'text', + value: delta.insert, + }; + const link = delta.attributes?.link; + if (!link) { + return mdast; + } + + const { current: currentMdast } = context; + if ('value' in currentMdast) { + if (currentMdast.value === '') { + return { + type: 'text', + value: link, + }; + } + if (mdast.value !== link) { + return { + type: 'link', + url: link, + children: [currentMdast], + }; + } + } + return mdast; + }, + }; + +export const latexDeltaToMarkdownAdapterMatcher: InlineDeltaToMarkdownAdapterMatcher = + { + name: 'inlineLatex', + match: delta => !!delta.attributes?.latex, + toAST: delta => { + const mdast: PhrasingContent = { + type: 'text', + value: delta.insert, + }; + if (delta.attributes?.latex) { + return { + type: 'inlineMath', + value: delta.attributes.latex, + }; + } + return mdast; + }, + }; + +export const inlineDeltaToMarkdownAdapterMatchers: InlineDeltaToMarkdownAdapterMatcher[] = + [ + referenceDeltaToMarkdownAdapterMatcher, + linkDeltaToMarkdownAdapterMatcher, + inlineCodeDeltaToMarkdownAdapterMatcher, + boldDeltaToMarkdownAdapterMatcher, + italicDeltaToMarkdownAdapterMatcher, + strikeDeltaToMarkdownAdapterMatcher, + latexDeltaToMarkdownAdapterMatcher, + ]; diff --git a/blocksuite/blocks/src/_common/adapters/markdown/delta-converter/markdown-inline.ts b/blocksuite/blocks/src/_common/adapters/markdown/delta-converter/markdown-inline.ts new file mode 100644 index 0000000000..672a40b3d4 --- /dev/null +++ b/blocksuite/blocks/src/_common/adapters/markdown/delta-converter/markdown-inline.ts @@ -0,0 +1,150 @@ +import type { MarkdownASTToDeltaMatcher } from '@blocksuite/affine-shared/adapters'; + +export const markdownTextToDeltaMatcher: MarkdownASTToDeltaMatcher = { + name: 'text', + match: ast => ast.type === 'text', + toDelta: ast => { + if (!('value' in ast)) { + return []; + } + return [{ insert: ast.value }]; + }, +}; + +export const markdownInlineCodeToDeltaMatcher: MarkdownASTToDeltaMatcher = { + name: 'inlineCode', + match: ast => ast.type === 'inlineCode', + toDelta: ast => { + if (!('value' in ast)) { + return []; + } + return [{ insert: ast.value, attributes: { code: true } }]; + }, +}; + +export const markdownStrongToDeltaMatcher: MarkdownASTToDeltaMatcher = { + name: 'strong', + match: ast => ast.type === 'strong', + toDelta: (ast, context) => { + if (!('children' in ast)) { + return []; + } + return ast.children.flatMap(child => + context.toDelta(child).map(delta => { + delta.attributes = { ...delta.attributes, bold: true }; + return delta; + }) + ); + }, +}; + +export const markdownEmphasisToDeltaMatcher: MarkdownASTToDeltaMatcher = { + name: 'emphasis', + match: ast => ast.type === 'emphasis', + toDelta: (ast, context) => { + if (!('children' in ast)) { + return []; + } + return ast.children.flatMap(child => + context.toDelta(child).map(delta => { + delta.attributes = { ...delta.attributes, italic: true }; + return delta; + }) + ); + }, +}; + +export const markdownDeleteToDeltaMatcher: MarkdownASTToDeltaMatcher = { + name: 'delete', + match: ast => ast.type === 'delete', + toDelta: (ast, context) => { + if (!('children' in ast)) { + return []; + } + return ast.children.flatMap(child => + context.toDelta(child).map(delta => { + delta.attributes = { ...delta.attributes, strike: true }; + return delta; + }) + ); + }, +}; + +export const markdownLinkToDeltaMatcher: MarkdownASTToDeltaMatcher = { + name: 'link', + match: ast => ast.type === 'link', + toDelta: (ast, context) => { + if (!('children' in ast) || !('url' in ast)) { + return []; + } + const { configs } = context; + const baseUrl = configs.get('docLinkBaseUrl') ?? ''; + if (baseUrl && ast.url.startsWith(baseUrl)) { + const path = ast.url.substring(baseUrl.length); + // ^ - /{pageId}?mode={mode}&blockIds={blockIds}&elementIds={elementIds} + const match = path.match(/^\/([^?]+)(\?.*)?$/); + if (match) { + const pageId = match?.[1]; + const search = match?.[2]; + const searchParams = search ? new URLSearchParams(search) : undefined; + const mode = searchParams?.get('mode'); + const blockIds = searchParams?.get('blockIds')?.split(','); + const elementIds = searchParams?.get('elementIds')?.split(','); + + return [ + { + insert: ' ', + attributes: { + reference: { + type: 'LinkedPage', + pageId, + params: { + mode: + mode && ['edgeless', 'page'].includes(mode) + ? (mode as 'edgeless' | 'page') + : undefined, + blockIds, + elementIds, + }, + }, + }, + }, + ]; + } + } + return ast.children.flatMap(child => + context.toDelta(child).map(delta => { + delta.attributes = { ...delta.attributes, link: ast.url }; + return delta; + }) + ); + }, +}; + +export const markdownListToDeltaMatcher: MarkdownASTToDeltaMatcher = { + name: 'list', + match: ast => ast.type === 'list', + toDelta: () => [], +}; + +export const markdownInlineMathToDeltaMatcher: MarkdownASTToDeltaMatcher = { + name: 'inlineMath', + match: ast => ast.type === 'inlineMath', + toDelta: ast => { + if (!('value' in ast)) { + return []; + } + return [{ insert: ' ', attributes: { latex: ast.value } }]; + }, +}; + +export const markdownInlineToDeltaMatchers: MarkdownASTToDeltaMatcher[] = [ + markdownTextToDeltaMatcher, + markdownInlineCodeToDeltaMatcher, + markdownStrongToDeltaMatcher, + markdownEmphasisToDeltaMatcher, + markdownDeleteToDeltaMatcher, + markdownLinkToDeltaMatcher, + markdownInlineMathToDeltaMatcher, + markdownListToDeltaMatcher, +]; diff --git a/blocksuite/blocks/src/_common/adapters/markdown/gfm.ts b/blocksuite/blocks/src/_common/adapters/markdown/gfm.ts new file mode 100644 index 0000000000..da11510f80 --- /dev/null +++ b/blocksuite/blocks/src/_common/adapters/markdown/gfm.ts @@ -0,0 +1,69 @@ +/* +MIT License + +Copyright (c) 2020 Titus Wormer + +mdast-util-gfm-autolink-literal is from markdown only. +mdast-util-gfm-footnote is not included. +*/ +import { gfmAutolinkLiteralFromMarkdown } from 'mdast-util-gfm-autolink-literal'; +import { + gfmStrikethroughFromMarkdown, + gfmStrikethroughToMarkdown, +} from 'mdast-util-gfm-strikethrough'; +import { gfmTableFromMarkdown, gfmTableToMarkdown } from 'mdast-util-gfm-table'; +import { + gfmTaskListItemFromMarkdown, + gfmTaskListItemToMarkdown, +} from 'mdast-util-gfm-task-list-item'; +import { gfmAutolinkLiteral } from 'micromark-extension-gfm-autolink-literal'; +import { gfmStrikethrough } from 'micromark-extension-gfm-strikethrough'; +import { gfmTable } from 'micromark-extension-gfm-table'; +import { gfmTaskListItem } from 'micromark-extension-gfm-task-list-item'; +import { combineExtensions } from 'micromark-util-combine-extensions'; +import type { Processor } from 'unified'; + +export function gfm() { + return combineExtensions([ + gfmAutolinkLiteral(), + gfmStrikethrough(), + gfmTable(), + gfmTaskListItem(), + ]); +} + +function gfmFromMarkdown() { + return [ + gfmStrikethroughFromMarkdown(), + gfmTableFromMarkdown(), + gfmTaskListItemFromMarkdown(), + gfmAutolinkLiteralFromMarkdown(), + ]; +} + +function gfmToMarkdown() { + return { + extensions: [ + gfmStrikethroughToMarkdown(), + gfmTableToMarkdown(), + gfmTaskListItemToMarkdown(), + ], + }; +} + +export function remarkGfm(this: Processor) { + // eslint-disable-next-line @typescript-eslint/no-this-alias + const self = this; + const data = self.data(); + + const micromarkExtensions = + data.micromarkExtensions || (data.micromarkExtensions = []); + const fromMarkdownExtensions = + data.fromMarkdownExtensions || (data.fromMarkdownExtensions = []); + const toMarkdownExtensions = + data.toMarkdownExtensions || (data.toMarkdownExtensions = []); + + micromarkExtensions.push(gfm()); + fromMarkdownExtensions.push(gfmFromMarkdown()); + toMarkdownExtensions.push(gfmToMarkdown()); +} diff --git a/blocksuite/blocks/src/_common/adapters/markdown/index.ts b/blocksuite/blocks/src/_common/adapters/markdown/index.ts new file mode 100644 index 0000000000..2e63d55602 --- /dev/null +++ b/blocksuite/blocks/src/_common/adapters/markdown/index.ts @@ -0,0 +1,9 @@ +export { + BlockMarkdownAdapterExtensions, + defaultBlockMarkdownAdapterMatchers, +} from './block-matcher.js'; +export { + MarkdownAdapter, + MarkdownAdapterFactoryExtension, + MarkdownAdapterFactoryIdentifier, +} from './markdown.js'; diff --git a/blocksuite/blocks/src/_common/adapters/markdown/markdown.ts b/blocksuite/blocks/src/_common/adapters/markdown/markdown.ts new file mode 100644 index 0000000000..1a0acba7bc --- /dev/null +++ b/blocksuite/blocks/src/_common/adapters/markdown/markdown.ts @@ -0,0 +1,455 @@ +import { + DEFAULT_NOTE_BACKGROUND_COLOR, + NoteDisplayMode, +} from '@blocksuite/affine-model'; +import { + type AdapterContext, + type BlockMarkdownAdapterMatcher, + BlockMarkdownAdapterMatcherIdentifier, + type Markdown, + type MarkdownAST, + MarkdownDeltaConverter, +} from '@blocksuite/affine-shared/adapters'; +import type { ExtensionType } from '@blocksuite/block-std'; +import { + type AssetsManager, + ASTWalker, + BaseAdapter, + type BlockSnapshot, + BlockSnapshotSchema, + type DocSnapshot, + type FromBlockSnapshotPayload, + type FromBlockSnapshotResult, + type FromDocSnapshotPayload, + type FromDocSnapshotResult, + type FromSliceSnapshotPayload, + type FromSliceSnapshotResult, + type Job, + nanoid, + type SliceSnapshot, + type ToBlockSnapshotPayload, + type ToDocSnapshotPayload, +} from '@blocksuite/store'; +import type { Root } from 'mdast'; +import remarkMath from 'remark-math'; +import remarkParse from 'remark-parse'; +import remarkStringify from 'remark-stringify'; +import { unified } from 'unified'; + +import { AdapterFactoryIdentifier } from '../type.js'; +import { defaultBlockMarkdownAdapterMatchers } from './block-matcher.js'; +import { inlineDeltaToMarkdownAdapterMatchers } from './delta-converter/inline-delta.js'; +import { markdownInlineToDeltaMatchers } from './delta-converter/markdown-inline.js'; +import { remarkGfm } from './gfm.js'; + +type MarkdownToSliceSnapshotPayload = { + file: Markdown; + assets?: AssetsManager; + workspaceId: string; + pageId: string; +}; + +export class MarkdownAdapter extends BaseAdapter { + private _traverseMarkdown = ( + markdown: MarkdownAST, + snapshot: BlockSnapshot, + assets?: AssetsManager + ) => { + const walker = new ASTWalker(); + walker.setONodeTypeGuard( + (node): node is MarkdownAST => + !Array.isArray(node) && + 'type' in (node as object) && + (node as MarkdownAST).type !== undefined + ); + walker.setEnter(async (o, context) => { + for (const matcher of this.blockMatchers) { + if (matcher.toMatch(o)) { + const adapterContext: AdapterContext< + MarkdownAST, + BlockSnapshot, + MarkdownDeltaConverter + > = { + walker, + walkerContext: context, + configs: this.configs, + job: this.job, + deltaConverter: this.deltaConverter, + textBuffer: { content: '' }, + assets, + }; + await matcher.toBlockSnapshot.enter?.(o, adapterContext); + } + } + }); + walker.setLeave(async (o, context) => { + for (const matcher of this.blockMatchers) { + if (matcher.toMatch(o)) { + const adapterContext: AdapterContext< + MarkdownAST, + BlockSnapshot, + MarkdownDeltaConverter + > = { + walker, + walkerContext: context, + configs: this.configs, + job: this.job, + deltaConverter: this.deltaConverter, + textBuffer: { content: '' }, + assets, + }; + await matcher.toBlockSnapshot.leave?.(o, adapterContext); + } + } + }); + return walker.walk(markdown, snapshot); + }; + + private _traverseSnapshot = async ( + snapshot: BlockSnapshot, + markdown: MarkdownAST, + assets?: AssetsManager + ) => { + const assetsIds: string[] = []; + const walker = new ASTWalker(); + walker.setONodeTypeGuard( + (node): node is BlockSnapshot => + BlockSnapshotSchema.safeParse(node).success + ); + walker.setEnter(async (o, context) => { + for (const matcher of this.blockMatchers) { + if (matcher.fromMatch(o)) { + const adapterContext: AdapterContext< + BlockSnapshot, + MarkdownAST, + MarkdownDeltaConverter + > = { + walker, + walkerContext: context, + configs: this.configs, + job: this.job, + deltaConverter: this.deltaConverter, + textBuffer: { content: '' }, + assets, + updateAssetIds: (assetsId: string) => { + assetsIds.push(assetsId); + }, + }; + await matcher.fromBlockSnapshot.enter?.(o, adapterContext); + } + } + }); + walker.setLeave(async (o, context) => { + for (const matcher of this.blockMatchers) { + if (matcher.fromMatch(o)) { + const adapterContext: AdapterContext< + BlockSnapshot, + MarkdownAST, + MarkdownDeltaConverter + > = { + walker, + walkerContext: context, + configs: this.configs, + job: this.job, + deltaConverter: this.deltaConverter, + textBuffer: { content: '' }, + assets, + }; + await matcher.fromBlockSnapshot.leave?.(o, adapterContext); + } + } + }); + return { + ast: (await walker.walk(snapshot, markdown)) as Root, + assetsIds, + }; + }; + + deltaConverter: MarkdownDeltaConverter; + + constructor( + job: Job, + readonly blockMatchers: BlockMarkdownAdapterMatcher[] = defaultBlockMarkdownAdapterMatchers + ) { + super(job); + this.deltaConverter = new MarkdownDeltaConverter( + job.adapterConfigs, + inlineDeltaToMarkdownAdapterMatchers, + markdownInlineToDeltaMatchers + ); + } + + private _astToMarkdown(ast: Root) { + return unified() + .use(remarkGfm) + .use(remarkStringify, { + resourceLink: true, + }) + .use(remarkMath) + .stringify(ast) + .replace(/ \n/g, ' \n'); + } + + private _markdownToAst(markdown: Markdown) { + return unified() + .use(remarkParse) + .use(remarkGfm) + .use(remarkMath) + .parse(markdown); + } + + async fromBlockSnapshot({ + snapshot, + assets, + }: FromBlockSnapshotPayload): Promise> { + const root: Root = { + type: 'root', + children: [], + }; + const { ast, assetsIds } = await this._traverseSnapshot( + snapshot, + root, + assets + ); + return { + file: this._astToMarkdown(ast), + assetsIds, + }; + } + + async fromDocSnapshot({ + snapshot, + assets, + }: FromDocSnapshotPayload): Promise> { + let buffer = ''; + const { file, assetsIds } = await this.fromBlockSnapshot({ + snapshot: snapshot.blocks, + assets, + }); + buffer += file; + return { + file: buffer, + assetsIds, + }; + } + + async fromSliceSnapshot({ + snapshot, + assets, + }: FromSliceSnapshotPayload): Promise> { + let buffer = ''; + const sliceAssetsIds: string[] = []; + for (const contentSlice of snapshot.content) { + const root: Root = { + type: 'root', + children: [], + }; + const { ast, assetsIds } = await this._traverseSnapshot( + contentSlice, + root, + assets + ); + sliceAssetsIds.push(...assetsIds); + buffer += this._astToMarkdown(ast); + } + const markdown = + buffer.match(/\n/g)?.length === 1 ? buffer.trimEnd() : buffer; + return { + file: markdown, + assetsIds: sliceAssetsIds, + }; + } + + async toBlockSnapshot( + payload: ToBlockSnapshotPayload + ): Promise { + const markdownAst = this._markdownToAst(payload.file); + const blockSnapshotRoot = { + type: 'block', + id: nanoid(), + flavour: 'affine:note', + props: { + xywh: '[0,0,800,95]', + background: DEFAULT_NOTE_BACKGROUND_COLOR, + index: 'a0', + hidden: false, + displayMode: NoteDisplayMode.DocAndEdgeless, + }, + children: [], + }; + return this._traverseMarkdown( + markdownAst, + blockSnapshotRoot as BlockSnapshot, + payload.assets + ); + } + + async toDocSnapshot( + payload: ToDocSnapshotPayload + ): Promise { + const markdownAst = this._markdownToAst(payload.file); + const blockSnapshotRoot = { + type: 'block', + id: nanoid(), + flavour: 'affine:note', + props: { + xywh: '[0,0,800,95]', + background: DEFAULT_NOTE_BACKGROUND_COLOR, + index: 'a0', + hidden: false, + displayMode: NoteDisplayMode.DocAndEdgeless, + }, + children: [], + }; + return { + type: 'page', + meta: { + id: nanoid(), + title: 'Untitled', + createDate: Date.now(), + tags: [], + }, + blocks: { + type: 'block', + id: nanoid(), + flavour: 'affine:page', + props: { + title: { + '$blocksuite:internal:text$': true, + delta: [ + { + insert: 'Untitled', + }, + ], + }, + }, + children: [ + { + type: 'block', + id: nanoid(), + flavour: 'affine:surface', + props: { + elements: {}, + }, + children: [], + }, + await this._traverseMarkdown( + markdownAst, + blockSnapshotRoot as BlockSnapshot, + payload.assets + ), + ], + }, + }; + } + + async toSliceSnapshot( + payload: MarkdownToSliceSnapshotPayload + ): Promise { + let codeFence = ''; + payload.file = payload.file + .split('\n') + .map(line => { + if (line.trimStart().startsWith('-')) { + return line; + } + let trimmedLine = line.trimStart(); + if (!codeFence && trimmedLine.startsWith('```')) { + codeFence = trimmedLine.substring( + 0, + trimmedLine.lastIndexOf('```') + 3 + ); + if (codeFence.split('').every(c => c === '`')) { + return line; + } + codeFence = ''; + } + if (!codeFence && trimmedLine.startsWith('~~~')) { + codeFence = trimmedLine.substring( + 0, + trimmedLine.lastIndexOf('~~~') + 3 + ); + if (codeFence.split('').every(c => c === '~')) { + return line; + } + codeFence = ''; + } + if ( + !!codeFence && + trimmedLine.startsWith(codeFence) && + trimmedLine.lastIndexOf(codeFence) === 0 + ) { + codeFence = ''; + } + if (codeFence) { + return line; + } + + trimmedLine = trimmedLine.trimEnd(); + if (!trimmedLine.startsWith('<') && !trimmedLine.endsWith('>')) { + // check if it is a url link and wrap it with the angle brackets + // sometimes the url includes emphasis `_` that will break URL parsing + // + // eg. /MuawcBMT1Mzvoar09-_66?mode=page&blockIds=rL2_GXbtLU2SsJVfCSmh_ + // https://www.markdownguide.org/basic-syntax/#urls-and-email-addresses + try { + const valid = + URL.canParse?.(trimmedLine) ?? Boolean(new URL(trimmedLine)); + if (valid) { + return `<${trimmedLine}>`; + } + } catch (err) { + console.log(err); + } + } + + return line.replace(/^ /, ' '); + }) + .join('\n'); + const markdownAst = this._markdownToAst(payload.file); + const blockSnapshotRoot = { + type: 'block', + id: nanoid(), + flavour: 'affine:note', + props: { + xywh: '[0,0,800,95]', + background: DEFAULT_NOTE_BACKGROUND_COLOR, + index: 'a0', + hidden: false, + displayMode: NoteDisplayMode.DocAndEdgeless, + }, + children: [], + } as BlockSnapshot; + const contentSlice = (await this._traverseMarkdown( + markdownAst, + blockSnapshotRoot, + payload.assets + )) as BlockSnapshot; + if (contentSlice.children.length === 0) { + return null; + } + return { + type: 'slice', + content: [contentSlice], + workspaceId: payload.workspaceId, + pageId: payload.pageId, + }; + } +} + +export const MarkdownAdapterFactoryIdentifier = + AdapterFactoryIdentifier('Markdown'); + +export const MarkdownAdapterFactoryExtension: ExtensionType = { + setup: di => { + di.addImpl(MarkdownAdapterFactoryIdentifier, provider => ({ + get: (job: Job) => + new MarkdownAdapter( + job, + Array.from( + provider.getAll(BlockMarkdownAdapterMatcherIdentifier).values() + ) + ), + })); + }, +}; diff --git a/blocksuite/blocks/src/_common/adapters/mix-text.ts b/blocksuite/blocks/src/_common/adapters/mix-text.ts new file mode 100644 index 0000000000..a1aa791acf --- /dev/null +++ b/blocksuite/blocks/src/_common/adapters/mix-text.ts @@ -0,0 +1,363 @@ +import { + DEFAULT_NOTE_BACKGROUND_COLOR, + NoteDisplayMode, +} from '@blocksuite/affine-model'; +import type { ExtensionType } from '@blocksuite/block-std'; +import type { DeltaInsert } from '@blocksuite/inline'; +import { + type AssetsManager, + ASTWalker, + BaseAdapter, + type BlockSnapshot, + BlockSnapshotSchema, + type DocSnapshot, + type FromBlockSnapshotPayload, + type FromBlockSnapshotResult, + type FromDocSnapshotPayload, + type FromDocSnapshotResult, + type FromSliceSnapshotPayload, + type FromSliceSnapshotResult, + type Job, + nanoid, + type SliceSnapshot, + type ToBlockSnapshotPayload, + type ToDocSnapshotPayload, +} from '@blocksuite/store'; + +import { MarkdownAdapter } from './markdown/index.js'; +import { AdapterFactoryIdentifier } from './type.js'; + +export type MixText = string; + +type MixTextToSliceSnapshotPayload = { + file: MixText; + assets?: AssetsManager; + blockVersions: Record; + workspaceId: string; + pageId: string; +}; + +export class MixTextAdapter extends BaseAdapter { + private _markdownAdapter: MarkdownAdapter; + + constructor(job: Job) { + super(job); + this._markdownAdapter = new MarkdownAdapter(job); + } + + private _splitDeltas(deltas: DeltaInsert[]): DeltaInsert[][] { + const result: DeltaInsert[][] = [[]]; + const pending: DeltaInsert[] = deltas; + while (pending.length > 0) { + const delta = pending.shift(); + if (!delta) { + break; + } + if (delta.insert.includes('\n')) { + const splitIndex = delta.insert.indexOf('\n'); + const line = delta.insert.slice(0, splitIndex); + const rest = delta.insert.slice(splitIndex + 1); + result[result.length - 1].push({ ...delta, insert: line }); + result.push([]); + if (rest) { + pending.unshift({ ...delta, insert: rest }); + } + } else { + result[result.length - 1].push(delta); + } + } + return result; + } + + private async _traverseSnapshot( + snapshot: BlockSnapshot + ): Promise<{ mixtext: string }> { + let buffer = ''; + const walker = new ASTWalker(); + walker.setONodeTypeGuard( + (node): node is BlockSnapshot => + BlockSnapshotSchema.safeParse(node).success + ); + walker.setEnter(o => { + const text = (o.node.props.text ?? { delta: [] }) as { + delta: DeltaInsert[]; + }; + if (buffer.length > 0) { + buffer += '\n'; + } + switch (o.node.flavour) { + case 'affine:code': { + buffer += text.delta.map(delta => delta.insert).join(''); + break; + } + case 'affine:paragraph': { + buffer += text.delta.map(delta => delta.insert).join(''); + break; + } + case 'affine:list': { + buffer += text.delta.map(delta => delta.insert).join(''); + break; + } + case 'affine:divider': { + buffer += '---'; + break; + } + } + }); + await walker.walkONode(snapshot); + return { + mixtext: buffer, + }; + } + + async fromBlockSnapshot({ + snapshot, + }: FromBlockSnapshotPayload): Promise> { + const { mixtext } = await this._traverseSnapshot(snapshot); + return { + file: mixtext, + assetsIds: [], + }; + } + + async fromDocSnapshot({ + snapshot, + assets, + }: FromDocSnapshotPayload): Promise> { + let buffer = ''; + if (snapshot.meta.title) { + buffer += `${snapshot.meta.title}\n\n`; + } + const { file, assetsIds } = await this.fromBlockSnapshot({ + snapshot: snapshot.blocks, + assets, + }); + buffer += file; + return { + file: buffer, + assetsIds, + }; + } + + async fromSliceSnapshot({ + snapshot, + }: FromSliceSnapshotPayload): Promise> { + let buffer = ''; + const sliceAssetsIds: string[] = []; + for (const contentSlice of snapshot.content) { + const { mixtext } = await this._traverseSnapshot(contentSlice); + buffer += mixtext; + } + const mixtext = + buffer.match(/\n/g)?.length === 1 ? buffer.trimEnd() : buffer; + return { + file: mixtext, + assetsIds: sliceAssetsIds, + }; + } + + toBlockSnapshot(payload: ToBlockSnapshotPayload): BlockSnapshot { + payload.file = payload.file.replaceAll('\r', ''); + return { + type: 'block', + id: nanoid(), + flavour: 'affine:note', + props: { + xywh: '[0,0,800,95]', + background: DEFAULT_NOTE_BACKGROUND_COLOR, + index: 'a0', + hidden: false, + displayMode: NoteDisplayMode.DocAndEdgeless, + }, + children: payload.file.split('\n').map((line): BlockSnapshot => { + return { + type: 'block', + id: nanoid(), + flavour: 'affine:paragraph', + props: { + type: 'text', + text: { + '$blocksuite:internal:text$': true, + delta: [ + { + insert: line, + }, + ], + }, + }, + children: [], + }; + }), + }; + } + + toDocSnapshot(payload: ToDocSnapshotPayload): DocSnapshot { + payload.file = payload.file.replaceAll('\r', ''); + return { + type: 'page', + meta: { + id: nanoid(), + title: 'Untitled', + createDate: Date.now(), + tags: [], + }, + blocks: { + type: 'block', + id: nanoid(), + flavour: 'affine:page', + props: { + title: { + '$blocksuite:internal:text$': true, + delta: [ + { + insert: 'Untitled', + }, + ], + }, + }, + children: [ + { + type: 'block', + id: nanoid(), + flavour: 'affine:surface', + props: { + elements: {}, + }, + children: [], + }, + { + type: 'block', + id: nanoid(), + flavour: 'affine:note', + props: { + xywh: '[0,0,800,95]', + background: DEFAULT_NOTE_BACKGROUND_COLOR, + index: 'a0', + hidden: false, + displayMode: NoteDisplayMode.DocAndEdgeless, + }, + children: payload.file.split('\n').map((line): BlockSnapshot => { + return { + type: 'block', + id: nanoid(), + flavour: 'affine:paragraph', + props: { + type: 'text', + text: { + '$blocksuite:internal:text$': true, + delta: [ + { + insert: line, + }, + ], + }, + }, + children: [], + }; + }), + }, + ], + }, + }; + } + + async toSliceSnapshot( + payload: MixTextToSliceSnapshotPayload + ): Promise { + if (payload.file.trim().length === 0) { + return null; + } + payload.file = payload.file.replaceAll('\r', ''); + const sliceSnapshot = await this._markdownAdapter.toSliceSnapshot({ + file: payload.file, + assets: payload.assets, + workspaceId: payload.workspaceId, + pageId: payload.pageId, + }); + if (!sliceSnapshot) { + return null; + } + for (const contentSlice of sliceSnapshot.content) { + const blockSnapshotRoot = { + type: 'block', + id: nanoid(), + flavour: 'affine:note', + props: { + xywh: '[0,0,800,95]', + background: DEFAULT_NOTE_BACKGROUND_COLOR, + index: 'a0', + hidden: false, + displayMode: NoteDisplayMode.DocAndEdgeless, + }, + children: [], + } as BlockSnapshot; + const walker = new ASTWalker(); + walker.setONodeTypeGuard( + (node): node is BlockSnapshot => + BlockSnapshotSchema.safeParse(node).success + ); + walker.setEnter((o, context) => { + switch (o.node.flavour) { + case 'affine:note': { + break; + } + case 'affine:paragraph': { + if (o.parent?.node.flavour !== 'affine:note') { + context.openNode({ ...o.node, children: [] }); + break; + } + const text = (o.node.props.text ?? { delta: [] }) as { + delta: DeltaInsert[]; + }; + const newDeltas = this._splitDeltas(text.delta); + for (const [i, delta] of newDeltas.entries()) { + context.openNode({ + ...o.node, + id: i === 0 ? o.node.id : nanoid(), + props: { + ...o.node.props, + text: { + '$blocksuite:internal:text$': true, + delta, + }, + }, + children: [], + }); + if (i < newDeltas.length - 1) { + context.closeNode(); + } + } + break; + } + default: { + context.openNode({ ...o.node, children: [] }); + } + } + }); + walker.setLeave((o, context) => { + switch (o.node.flavour) { + case 'affine:note': { + break; + } + default: { + context.closeNode(); + } + } + }); + await walker.walk(contentSlice, blockSnapshotRoot); + contentSlice.children = blockSnapshotRoot.children; + } + return sliceSnapshot; + } +} + +export const MixTextAdapterFactoryIdentifier = + AdapterFactoryIdentifier('MixText'); + +export const MixTextAdapterFactoryExtension: ExtensionType = { + setup: di => { + di.addImpl(MixTextAdapterFactoryIdentifier, () => ({ + get: (job: Job) => new MixTextAdapter(job), + })); + }, +}; diff --git a/blocksuite/blocks/src/_common/adapters/notion-html/block-matcher.ts b/blocksuite/blocks/src/_common/adapters/notion-html/block-matcher.ts new file mode 100644 index 0000000000..544479e36b --- /dev/null +++ b/blocksuite/blocks/src/_common/adapters/notion-html/block-matcher.ts @@ -0,0 +1,70 @@ +import { + ListBlockNotionHtmlAdapterExtension, + listBlockNotionHtmlAdapterMatcher, +} from '@blocksuite/affine-block-list'; +import { + ParagraphBlockNotionHtmlAdapterExtension, + paragraphBlockNotionHtmlAdapterMatcher, +} from '@blocksuite/affine-block-paragraph'; +import type { BlockNotionHtmlAdapterMatcher } from '@blocksuite/affine-shared/adapters'; +import type { ExtensionType } from '@blocksuite/block-std'; + +import { + AttachmentBlockNotionHtmlAdapterExtension, + attachmentBlockNotionHtmlAdapterMatcher, +} from '../../../attachment-block/adapters/notion-html.js'; +import { + BookmarkBlockNotionHtmlAdapterExtension, + bookmarkBlockNotionHtmlAdapterMatcher, +} from '../../../bookmark-block/adapters/notion-html.js'; +import { + CodeBlockNotionHtmlAdapterExtension, + codeBlockNotionHtmlAdapterMatcher, +} from '../../../code-block/adapters/notion-html.js'; +import { + DatabaseBlockNotionHtmlAdapterExtension, + databaseBlockNotionHtmlAdapterMatcher, +} from '../../../database-block/adapters/notion-html.js'; +import { + DividerBlockNotionHtmlAdapterExtension, + dividerBlockNotionHtmlAdapterMatcher, +} from '../../../divider-block/adapters/notion-html.js'; +import { + ImageBlockNotionHtmlAdapterExtension, + imageBlockNotionHtmlAdapterMatcher, +} from '../../../image-block/adapters/notion-html.js'; +import { + LatexBlockNotionHtmlAdapterExtension, + latexBlockNotionHtmlAdapterMatcher, +} from '../../../latex-block/adapters/notion-html.js'; +import { + RootBlockNotionHtmlAdapterExtension, + rootBlockNotionHtmlAdapterMatcher, +} from '../../../root-block/adapters/notion-html.js'; + +export const defaultBlockNotionHtmlAdapterMatchers: BlockNotionHtmlAdapterMatcher[] = + [ + listBlockNotionHtmlAdapterMatcher, + paragraphBlockNotionHtmlAdapterMatcher, + codeBlockNotionHtmlAdapterMatcher, + dividerBlockNotionHtmlAdapterMatcher, + imageBlockNotionHtmlAdapterMatcher, + rootBlockNotionHtmlAdapterMatcher, + bookmarkBlockNotionHtmlAdapterMatcher, + databaseBlockNotionHtmlAdapterMatcher, + attachmentBlockNotionHtmlAdapterMatcher, + latexBlockNotionHtmlAdapterMatcher, + ]; + +export const BlockNotionHtmlAdapterExtensions: ExtensionType[] = [ + ListBlockNotionHtmlAdapterExtension, + ParagraphBlockNotionHtmlAdapterExtension, + CodeBlockNotionHtmlAdapterExtension, + DividerBlockNotionHtmlAdapterExtension, + ImageBlockNotionHtmlAdapterExtension, + RootBlockNotionHtmlAdapterExtension, + BookmarkBlockNotionHtmlAdapterExtension, + DatabaseBlockNotionHtmlAdapterExtension, + AttachmentBlockNotionHtmlAdapterExtension, + LatexBlockNotionHtmlAdapterExtension, +]; diff --git a/blocksuite/blocks/src/_common/adapters/notion-html/delta-converter/html-inline.ts b/blocksuite/blocks/src/_common/adapters/notion-html/delta-converter/html-inline.ts new file mode 100644 index 0000000000..0b5c9afc61 --- /dev/null +++ b/blocksuite/blocks/src/_common/adapters/notion-html/delta-converter/html-inline.ts @@ -0,0 +1,296 @@ +import { + HastUtils, + type HtmlAST, + type NotionHtmlASTToDeltaMatcher, +} from '@blocksuite/affine-shared/adapters'; +import { collapseWhiteSpace } from 'collapse-white-space'; +import type { Element, Text } from 'hast'; + +const isElement = (ast: HtmlAST): ast is Element => { + return ast.type === 'element'; +}; + +const isText = (ast: HtmlAST): ast is Text => { + return ast.type === 'text'; +}; + +const listElementTags = new Set(['ol', 'ul']); +const strongElementTags = new Set(['strong', 'b']); +const italicElementTags = new Set(['i', 'em']); + +const NotionInlineEquationToken = 'notion-text-equation-token'; +const NotionUnderlineStyleToken = 'border-bottom:0.05em solid'; + +export const notionHtmlTextToDeltaMatcher: NotionHtmlASTToDeltaMatcher = { + name: 'text', + match: ast => isText(ast), + toDelta: (ast, context) => { + if (!isText(ast)) { + return []; + } + const { options } = context; + options.trim ??= true; + if (options.pre || ast.value === ' ') { + return [{ insert: ast.value }]; + } + if (options.trim) { + const value = collapseWhiteSpace(ast.value, { trim: options.trim }); + if (value) { + return [{ insert: value }]; + } + return []; + } + if (ast.value) { + return [{ insert: collapseWhiteSpace(ast.value) }]; + } + return []; + }, +}; + +export const notionHtmlSpanElementToDeltaMatcher: NotionHtmlASTToDeltaMatcher = + { + name: 'span-element', + match: ast => isElement(ast) && ast.tagName === 'span', + toDelta: (ast, context) => { + if (!isElement(ast)) { + return []; + } + + const { toDelta, options } = context; + if ( + Array.isArray(ast.properties?.className) && + ast.properties?.className.includes(NotionInlineEquationToken) + ) { + const latex = HastUtils.getTextContent( + HastUtils.querySelector(ast, 'annotation') + ); + return [{ insert: ' ', attributes: { latex } }]; + } + + // Add underline style detection + if ( + typeof ast.properties?.style === 'string' && + ast.properties?.style?.includes(NotionUnderlineStyleToken) + ) { + return ast.children.flatMap(child => + context.toDelta(child, options).map(delta => { + delta.attributes = { ...delta.attributes, underline: true }; + return delta; + }) + ); + } + + return ast.children.flatMap(child => toDelta(child, options)); + }, + }; + +export const notionHtmlListToDeltaMatcher: NotionHtmlASTToDeltaMatcher = { + name: 'list-element', + match: ast => isElement(ast) && listElementTags.has(ast.tagName), + toDelta: () => { + return []; + }, +}; + +export const notionHtmlStrongElementToDeltaMatcher: NotionHtmlASTToDeltaMatcher = + { + name: 'strong-element', + match: ast => isElement(ast) && strongElementTags.has(ast.tagName), + toDelta: (ast, context) => { + if (!isElement(ast)) { + return []; + } + + const { toDelta, options } = context; + return ast.children.flatMap(child => + toDelta(child, options).map(delta => { + delta.attributes = { ...delta.attributes, bold: true }; + return delta; + }) + ); + }, + }; + +export const notionHtmlItalicElementToDeltaMatcher: NotionHtmlASTToDeltaMatcher = + { + name: 'italic-element', + match: ast => isElement(ast) && italicElementTags.has(ast.tagName), + toDelta: (ast, context) => { + if (!isElement(ast)) { + return []; + } + const { toDelta, options } = context; + return ast.children.flatMap(child => + toDelta(child, options).map(delta => { + delta.attributes = { ...delta.attributes, italic: true }; + return delta; + }) + ); + }, + }; +export const notionHtmlCodeElementToDeltaMatcher: NotionHtmlASTToDeltaMatcher = + { + name: 'code-element', + match: ast => isElement(ast) && ast.tagName === 'code', + toDelta: (ast, context) => { + if (!isElement(ast)) { + return []; + } + const { toDelta, options } = context; + return ast.children.flatMap(child => + toDelta(child, options).map(delta => { + delta.attributes = { ...delta.attributes, code: true }; + return delta; + }) + ); + }, + }; + +export const notionHtmlDelElementToDeltaMatcher: NotionHtmlASTToDeltaMatcher = { + name: 'del-element', + match: ast => isElement(ast) && ast.tagName === 'del', + toDelta: (ast, context) => { + if (!isElement(ast)) { + return []; + } + const { toDelta, options } = context; + return ast.children.flatMap(child => + toDelta(child, options).map(delta => { + delta.attributes = { ...delta.attributes, strike: true }; + return delta; + }) + ); + }, +}; + +export const notionHtmlUnderlineElementToDeltaMatcher: NotionHtmlASTToDeltaMatcher = + { + name: 'underline-element', + match: ast => isElement(ast) && ast.tagName === 'u', + toDelta: (ast, context) => { + if (!isElement(ast)) { + return []; + } + const { toDelta, options } = context; + return ast.children.flatMap(child => + toDelta(child, options).map(delta => { + delta.attributes = { ...delta.attributes, underline: true }; + return delta; + }) + ); + }, + }; + +export const notionHtmlLinkElementToDeltaMatcher: NotionHtmlASTToDeltaMatcher = + { + name: 'link-element', + match: ast => isElement(ast) && ast.tagName === 'a', + toDelta: (ast, context) => { + if (!isElement(ast)) { + return []; + } + + const href = ast.properties?.href; + if (typeof href !== 'string') { + return []; + } + const { toDelta, options } = context; + return ast.children.flatMap(child => + toDelta(child, options).map(delta => { + if (options.pageMap) { + const pageId = options.pageMap.get(decodeURIComponent(href)); + if (pageId) { + delta.attributes = { + ...delta.attributes, + reference: { + type: 'LinkedPage', + pageId, + }, + }; + delta.insert = ' '; + return delta; + } + } + if (href.startsWith('http')) { + delta.attributes = { + ...delta.attributes, + link: href, + }; + return delta; + } + return delta; + }) + ); + }, + }; + +export const notionHtmlMarkElementToDeltaMatcher: NotionHtmlASTToDeltaMatcher = + { + name: 'mark-element', + match: ast => isElement(ast) && ast.tagName === 'mark', + toDelta: (ast, context) => { + if (!isElement(ast)) { + return []; + } + const { toDelta, options } = context; + return ast.children.flatMap(child => + toDelta(child, options).map(delta => { + delta.attributes = { ...delta.attributes }; + return delta; + }) + ); + }, + }; + +export const notionHtmlLiElementToDeltaMatcher: NotionHtmlASTToDeltaMatcher = { + name: 'li-element', + match: ast => + isElement(ast) && + ast.tagName === 'li' && + !!HastUtils.querySelector(ast, '.checkbox'), + toDelta: (ast, context) => { + if (!isElement(ast) || !HastUtils.querySelector(ast, '.checkbox')) { + return []; + } + const { toDelta, options } = context; + // Should ignore the children of to do list which is the checkbox and the space following it + const checkBox = HastUtils.querySelector(ast, '.checkbox'); + const checkBoxIndex = ast.children.findIndex(child => child === checkBox); + return ast.children + .slice(checkBoxIndex + 2) + .flatMap(child => toDelta(child, options)); + }, +}; + +export const notionHtmlBrElementToDeltaMatcher: NotionHtmlASTToDeltaMatcher = { + name: 'br-element', + match: ast => isElement(ast) && ast.tagName === 'br', + toDelta: () => { + return [{ insert: '\n' }]; + }, +}; + +export const notionHtmlStyleElementToDeltaMatcher: NotionHtmlASTToDeltaMatcher = + { + name: 'style-element', + match: ast => isElement(ast) && ast.tagName === 'style', + toDelta: () => { + return []; + }, + }; + +export const notionHtmlInlineToDeltaMatchers: NotionHtmlASTToDeltaMatcher[] = [ + notionHtmlTextToDeltaMatcher, + notionHtmlSpanElementToDeltaMatcher, + notionHtmlStrongElementToDeltaMatcher, + notionHtmlItalicElementToDeltaMatcher, + notionHtmlCodeElementToDeltaMatcher, + notionHtmlDelElementToDeltaMatcher, + notionHtmlUnderlineElementToDeltaMatcher, + notionHtmlLinkElementToDeltaMatcher, + notionHtmlMarkElementToDeltaMatcher, + notionHtmlListToDeltaMatcher, + notionHtmlLiElementToDeltaMatcher, + notionHtmlBrElementToDeltaMatcher, + notionHtmlStyleElementToDeltaMatcher, +]; diff --git a/blocksuite/blocks/src/_common/adapters/notion-html/index.ts b/blocksuite/blocks/src/_common/adapters/notion-html/index.ts new file mode 100644 index 0000000000..0282c785b1 --- /dev/null +++ b/blocksuite/blocks/src/_common/adapters/notion-html/index.ts @@ -0,0 +1,9 @@ +export { + BlockNotionHtmlAdapterExtensions, + defaultBlockNotionHtmlAdapterMatchers, +} from './block-matcher.js'; +export { + NotionHtmlAdapter, + NotionHtmlAdapterFactoryExtension, + NotionHtmlAdapterFactoryIdentifier, +} from './notion-html.js'; diff --git a/blocksuite/blocks/src/_common/adapters/notion-html/notion-html.ts b/blocksuite/blocks/src/_common/adapters/notion-html/notion-html.ts new file mode 100644 index 0000000000..6f34641b3e --- /dev/null +++ b/blocksuite/blocks/src/_common/adapters/notion-html/notion-html.ts @@ -0,0 +1,299 @@ +import { + DEFAULT_NOTE_BACKGROUND_COLOR, + NoteDisplayMode, +} from '@blocksuite/affine-model'; +import { + type AdapterContext, + type BlockNotionHtmlAdapterMatcher, + BlockNotionHtmlAdapterMatcherIdentifier, + HastUtils, + type HtmlAST, + type NotionHtml, + NotionHtmlDeltaConverter, +} from '@blocksuite/affine-shared/adapters'; +import type { ExtensionType } from '@blocksuite/block-std'; +import { BlockSuiteError, ErrorCode } from '@blocksuite/global/exceptions'; +import { + type AssetsManager, + ASTWalker, + BaseAdapter, + type BlockSnapshot, + type DocSnapshot, + type FromBlockSnapshotPayload, + type FromBlockSnapshotResult, + type FromDocSnapshotPayload, + type FromDocSnapshotResult, + type FromSliceSnapshotPayload, + type FromSliceSnapshotResult, + type Job, + nanoid, + type SliceSnapshot, +} from '@blocksuite/store'; +import rehypeParse from 'rehype-parse'; +import { unified } from 'unified'; + +import { AdapterFactoryIdentifier } from '../type.js'; +import { defaultBlockNotionHtmlAdapterMatchers } from './block-matcher.js'; +import { notionHtmlInlineToDeltaMatchers } from './delta-converter/html-inline.js'; + +type NotionHtmlToSliceSnapshotPayload = { + file: NotionHtml; + assets?: AssetsManager; + blockVersions: Record; + workspaceId: string; + pageId: string; +}; + +type NotionHtmlToDocSnapshotPayload = { + file: NotionHtml; + assets?: AssetsManager; + pageId?: string; + pageMap?: Map; +}; + +type NotionHtmlToBlockSnapshotPayload = NotionHtmlToDocSnapshotPayload; + +export class NotionHtmlAdapter extends BaseAdapter { + private _traverseNotionHtml = async ( + html: HtmlAST, + snapshot: BlockSnapshot, + assets?: AssetsManager, + pageMap?: Map + ) => { + const walker = new ASTWalker(); + walker.setONodeTypeGuard( + (node): node is HtmlAST => + 'type' in (node as object) && (node as HtmlAST).type !== undefined + ); + walker.setEnter(async (o, context) => { + for (const matcher of this.blockMatchers) { + if (matcher.toMatch(o)) { + const adapterContext: AdapterContext< + HtmlAST, + BlockSnapshot, + NotionHtmlDeltaConverter + > = { + walker, + walkerContext: context, + configs: this.configs, + job: this.job, + deltaConverter: this.deltaConverter, + textBuffer: { content: '' }, + assets, + pageMap, + }; + await matcher.toBlockSnapshot.enter?.(o, adapterContext); + } + } + }); + walker.setLeave(async (o, context) => { + for (const matcher of this.blockMatchers) { + if (matcher.toMatch(o)) { + const adapterContext: AdapterContext< + HtmlAST, + BlockSnapshot, + NotionHtmlDeltaConverter + > = { + walker, + walkerContext: context, + configs: this.configs, + job: this.job, + deltaConverter: this.deltaConverter, + textBuffer: { content: '' }, + assets, + pageMap, + }; + await matcher.toBlockSnapshot.leave?.(o, adapterContext); + } + } + }); + return walker.walk(html, snapshot); + }; + + deltaConverter: NotionHtmlDeltaConverter; + + constructor( + job: Job, + readonly blockMatchers: BlockNotionHtmlAdapterMatcher[] = defaultBlockNotionHtmlAdapterMatchers + ) { + super(job); + this.deltaConverter = new NotionHtmlDeltaConverter( + job.adapterConfigs, + [], + notionHtmlInlineToDeltaMatchers + ); + } + + private _htmlToAst(notionHtml: NotionHtml) { + return unified().use(rehypeParse).parse(notionHtml); + } + + override fromBlockSnapshot( + _payload: FromBlockSnapshotPayload + ): Promise> { + throw new BlockSuiteError( + ErrorCode.TransformerNotImplementedError, + 'NotionHtmlAdapter.fromBlockSnapshot is not implemented' + ); + } + + override fromDocSnapshot( + _payload: FromDocSnapshotPayload + ): Promise> { + throw new BlockSuiteError( + ErrorCode.TransformerNotImplementedError, + 'NotionHtmlAdapter.fromDocSnapshot is not implemented' + ); + } + + override fromSliceSnapshot( + _payload: FromSliceSnapshotPayload + ): Promise> { + throw new BlockSuiteError( + ErrorCode.TransformerNotImplementedError, + 'NotionHtmlAdapter.fromSliceSnapshot is not implemented' + ); + } + + override toBlockSnapshot( + payload: NotionHtmlToBlockSnapshotPayload + ): Promise { + const notionHtmlAst = this._htmlToAst(payload.file); + const blockSnapshotRoot = { + type: 'block', + id: nanoid(), + flavour: 'affine:note', + props: { + xywh: '[0,0,800,95]', + background: DEFAULT_NOTE_BACKGROUND_COLOR, + index: 'a0', + hidden: false, + displayMode: NoteDisplayMode.DocAndEdgeless, + }, + children: [], + }; + return this._traverseNotionHtml( + notionHtmlAst, + blockSnapshotRoot as BlockSnapshot, + payload.assets, + payload.pageMap + ); + } + + override async toDoc(payload: NotionHtmlToDocSnapshotPayload) { + const snapshot = await this.toDocSnapshot(payload); + return this.job.snapshotToDoc(snapshot); + } + + override async toDocSnapshot( + payload: NotionHtmlToDocSnapshotPayload + ): Promise { + const notionHtmlAst = this._htmlToAst(payload.file); + const titleAst = HastUtils.querySelector(notionHtmlAst, 'title'); + const blockSnapshotRoot = { + type: 'block', + id: nanoid(), + flavour: 'affine:note', + props: { + xywh: '[0,0,800,95]', + background: DEFAULT_NOTE_BACKGROUND_COLOR, + index: 'a0', + hidden: false, + displayMode: NoteDisplayMode.DocAndEdgeless, + }, + children: [], + }; + return { + type: 'page', + meta: { + id: payload.pageId ?? nanoid(), + title: HastUtils.getTextContent(titleAst, ''), + createDate: Date.now(), + tags: [], + }, + blocks: { + type: 'block', + id: nanoid(), + flavour: 'affine:page', + props: { + title: { + '$blocksuite:internal:text$': true, + delta: this.deltaConverter.astToDelta( + titleAst ?? { + type: 'text', + value: '', + } + ), + }, + }, + children: [ + { + type: 'block', + id: nanoid(), + flavour: 'affine:surface', + props: { + elements: {}, + }, + children: [], + }, + await this._traverseNotionHtml( + notionHtmlAst, + blockSnapshotRoot as BlockSnapshot, + payload.assets, + payload.pageMap + ), + ], + }, + }; + } + + override async toSliceSnapshot( + payload: NotionHtmlToSliceSnapshotPayload + ): Promise { + const notionHtmlAst = this._htmlToAst(payload.file); + const blockSnapshotRoot = { + type: 'block', + id: nanoid(), + flavour: 'affine:note', + props: { + xywh: '[0,0,800,95]', + background: DEFAULT_NOTE_BACKGROUND_COLOR, + index: 'a0', + hidden: false, + displayMode: NoteDisplayMode.DocAndEdgeless, + }, + children: [], + }; + const contentSlice = (await this._traverseNotionHtml( + notionHtmlAst, + blockSnapshotRoot as BlockSnapshot, + payload.assets + )) as BlockSnapshot; + if (contentSlice.children.length === 0) { + return null; + } + return { + type: 'slice', + content: [contentSlice], + workspaceId: payload.workspaceId, + pageId: payload.pageId, + }; + } +} + +export const NotionHtmlAdapterFactoryIdentifier = + AdapterFactoryIdentifier('NotionHtml'); + +export const NotionHtmlAdapterFactoryExtension: ExtensionType = { + setup: di => { + di.addImpl(NotionHtmlAdapterFactoryIdentifier, provider => ({ + get: (job: Job) => + new NotionHtmlAdapter( + job, + Array.from( + provider.getAll(BlockNotionHtmlAdapterMatcherIdentifier).values() + ) + ), + })); + }, +}; diff --git a/blocksuite/blocks/src/_common/adapters/notion-text.ts b/blocksuite/blocks/src/_common/adapters/notion-text.ts new file mode 100644 index 0000000000..fe681f0e0d --- /dev/null +++ b/blocksuite/blocks/src/_common/adapters/notion-text.ts @@ -0,0 +1,170 @@ +import { DEFAULT_NOTE_BACKGROUND_COLOR } from '@blocksuite/affine-model'; +import type { AffineTextAttributes } from '@blocksuite/affine-shared/types'; +import type { ExtensionType } from '@blocksuite/block-std'; +import { BlockSuiteError, ErrorCode } from '@blocksuite/global/exceptions'; +import type { DeltaInsert } from '@blocksuite/inline'; +import { + type AssetsManager, + BaseAdapter, + type BlockSnapshot, + type DocSnapshot, + type FromBlockSnapshotResult, + type FromDocSnapshotResult, + type FromSliceSnapshotResult, + type Job, + nanoid, + type SliceSnapshot, +} from '@blocksuite/store'; + +import { AdapterFactoryIdentifier } from './type.js'; + +type NotionEditingStyle = { + 0: string; +}; + +type NotionEditing = { + 0: string; + 1: Array; +}; + +export type NotionTextSerialized = { + blockType: string; + editing: Array; +}; + +export type NotionText = string; + +type NotionHtmlToSliceSnapshotPayload = { + file: NotionText; + assets?: AssetsManager; + workspaceId: string; + pageId: string; +}; + +export class NotionTextAdapter extends BaseAdapter { + override fromBlockSnapshot(): + | FromBlockSnapshotResult + | Promise> { + throw new BlockSuiteError( + ErrorCode.TransformerNotImplementedError, + 'NotionTextAdapter.fromBlockSnapshot is not implemented.' + ); + } + + override fromDocSnapshot(): + | FromDocSnapshotResult + | Promise> { + throw new BlockSuiteError( + ErrorCode.TransformerNotImplementedError, + 'NotionTextAdapter.fromDocSnapshot is not implemented.' + ); + } + + override fromSliceSnapshot(): + | FromSliceSnapshotResult + | Promise> { + return { + file: JSON.stringify({ + blockType: 'text', + editing: [ + ['Notion Text is not supported to be exported from BlockSuite', []], + ], + }), + assetsIds: [], + }; + } + + override toBlockSnapshot(): Promise | BlockSnapshot { + throw new BlockSuiteError( + ErrorCode.TransformerNotImplementedError, + 'NotionTextAdapter.toBlockSnapshot is not implemented.' + ); + } + + override toDocSnapshot(): Promise | DocSnapshot { + throw new BlockSuiteError( + ErrorCode.TransformerNotImplementedError, + 'NotionTextAdapter.toDocSnapshot is not implemented.' + ); + } + + override toSliceSnapshot( + payload: NotionHtmlToSliceSnapshotPayload + ): SliceSnapshot | null { + const notionText = JSON.parse(payload.file) as NotionTextSerialized; + const content: SliceSnapshot['content'] = []; + const deltas: DeltaInsert[] = []; + for (const editing of notionText.editing) { + const delta: DeltaInsert = { + insert: editing[0], + attributes: Object.create(null), + }; + for (const styleElement of editing[1]) { + switch (styleElement[0]) { + case 'b': + delta.attributes!.bold = true; + break; + case 'i': + delta.attributes!.italic = true; + break; + case '_': + delta.attributes!.underline = true; + break; + case 'c': + delta.attributes!.code = true; + break; + case 's': + delta.attributes!.strike = true; + break; + } + } + deltas.push(delta); + } + + content.push({ + type: 'block', + id: nanoid(), + flavour: 'affine:note', + props: { + xywh: '[0,0,800,95]', + background: DEFAULT_NOTE_BACKGROUND_COLOR, + index: 'a0', + hidden: false, + displayMode: 'both', + }, + children: [ + { + type: 'block', + id: nanoid(), + flavour: 'affine:paragraph', + props: { + type: 'text', + text: { + '$blocksuite:internal:text$': true, + delta: deltas, + }, + }, + children: [], + }, + ], + }); + + return { + type: 'slice', + content, + workspaceId: payload.workspaceId, + pageId: payload.pageId, + }; + } +} + +export const NotionTextAdapterFactoryIdentifier = + AdapterFactoryIdentifier('NotionText'); + +export const NotionTextAdapterFactoryExtension: ExtensionType = { + setup: di => { + di.addImpl(NotionTextAdapterFactoryIdentifier, () => ({ + get: (job: Job) => new NotionTextAdapter(job), + })); + }, +}; diff --git a/blocksuite/blocks/src/_common/adapters/plain-text/block-matcher.ts b/blocksuite/blocks/src/_common/adapters/plain-text/block-matcher.ts new file mode 100644 index 0000000000..dd89b6d990 --- /dev/null +++ b/blocksuite/blocks/src/_common/adapters/plain-text/block-matcher.ts @@ -0,0 +1,72 @@ +import { + EmbedFigmaBlockPlainTextAdapterExtension, + embedFigmaBlockPlainTextAdapterMatcher, + EmbedGithubBlockPlainTextAdapterExtension, + embedGithubBlockPlainTextAdapterMatcher, + EmbedLinkedDocBlockPlainTextAdapterExtension, + embedLinkedDocBlockPlainTextAdapterMatcher, + EmbedLoomBlockPlainTextAdapterExtension, + embedLoomBlockPlainTextAdapterMatcher, + EmbedSyncedDocBlockPlainTextAdapterExtension, + embedSyncedDocBlockPlainTextAdapterMatcher, + EmbedYoutubeBlockPlainTextAdapterExtension, + embedYoutubeBlockPlainTextAdapterMatcher, +} from '@blocksuite/affine-block-embed'; +import { + ListBlockPlainTextAdapterExtension, + listBlockPlainTextAdapterMatcher, +} from '@blocksuite/affine-block-list'; +import { + ParagraphBlockPlainTextAdapterExtension, + paragraphBlockPlainTextAdapterMatcher, +} from '@blocksuite/affine-block-paragraph'; +import type { BlockPlainTextAdapterMatcher } from '@blocksuite/affine-shared/adapters'; +import type { ExtensionType } from '@blocksuite/block-std'; + +import { + BookmarkBlockPlainTextAdapterExtension, + bookmarkBlockPlainTextAdapterMatcher, +} from '../../../bookmark-block/adapters/plain-text.js'; +import { + CodeBlockPlainTextAdapterExtension, + codeBlockPlainTextAdapterMatcher, +} from '../../../code-block/adapters/plain-text.js'; +import { + DividerBlockPlainTextAdapterExtension, + dividerBlockPlainTextAdapterMatcher, +} from '../../../divider-block/adapters/plain-text.js'; +import { + LatexBlockPlainTextAdapterExtension, + latexBlockPlainTextAdapterMatcher, +} from '../../../latex-block/adapters/plain-text.js'; + +export const defaultBlockPlainTextAdapterMatchers: BlockPlainTextAdapterMatcher[] = + [ + paragraphBlockPlainTextAdapterMatcher, + listBlockPlainTextAdapterMatcher, + dividerBlockPlainTextAdapterMatcher, + codeBlockPlainTextAdapterMatcher, + bookmarkBlockPlainTextAdapterMatcher, + embedFigmaBlockPlainTextAdapterMatcher, + embedGithubBlockPlainTextAdapterMatcher, + embedLoomBlockPlainTextAdapterMatcher, + embedYoutubeBlockPlainTextAdapterMatcher, + embedLinkedDocBlockPlainTextAdapterMatcher, + embedSyncedDocBlockPlainTextAdapterMatcher, + latexBlockPlainTextAdapterMatcher, + ]; + +export const BlockPlainTextAdapterExtensions: ExtensionType[] = [ + ParagraphBlockPlainTextAdapterExtension, + ListBlockPlainTextAdapterExtension, + DividerBlockPlainTextAdapterExtension, + CodeBlockPlainTextAdapterExtension, + BookmarkBlockPlainTextAdapterExtension, + EmbedFigmaBlockPlainTextAdapterExtension, + EmbedGithubBlockPlainTextAdapterExtension, + EmbedLoomBlockPlainTextAdapterExtension, + EmbedYoutubeBlockPlainTextAdapterExtension, + EmbedLinkedDocBlockPlainTextAdapterExtension, + EmbedSyncedDocBlockPlainTextAdapterExtension, + LatexBlockPlainTextAdapterExtension, +]; diff --git a/blocksuite/blocks/src/_common/adapters/plain-text/delta-converter/inline-delta.ts b/blocksuite/blocks/src/_common/adapters/plain-text/delta-converter/inline-delta.ts new file mode 100644 index 0000000000..9bb93626ed --- /dev/null +++ b/blocksuite/blocks/src/_common/adapters/plain-text/delta-converter/inline-delta.ts @@ -0,0 +1,78 @@ +import { generateDocUrl } from '@blocksuite/affine-block-embed'; +import type { + InlineDeltaToPlainTextAdapterMatcher, + TextBuffer, +} from '@blocksuite/affine-shared/adapters'; + +export const referenceDeltaMarkdownAdapterMatch: InlineDeltaToPlainTextAdapterMatcher = + { + name: 'reference', + match: delta => !!delta.attributes?.reference, + toAST: (delta, context) => { + const node: TextBuffer = { + content: delta.insert, + }; + const reference = delta.attributes?.reference; + if (!reference) { + return node; + } + + const { configs } = context; + const title = configs.get(`title:${reference.pageId}`) ?? ''; + const url = generateDocUrl( + configs.get('docLinkBaseUrl') ?? '', + String(reference.pageId), + reference.params ?? Object.create(null) + ); + const content = `${title ? `${title}: ` : ''}${url}`; + + return { + content, + }; + }, + }; + +export const linkDeltaMarkdownAdapterMatch: InlineDeltaToPlainTextAdapterMatcher = + { + name: 'link', + match: delta => !!delta.attributes?.link, + toAST: delta => { + const linkText = delta.insert; + const node: TextBuffer = { + content: linkText, + }; + const link = delta.attributes?.link; + if (!link) { + return node; + } + + const content = `${linkText ? `${linkText}: ` : ''}${link}`; + return { + content, + }; + }, + }; + +export const latexDeltaMarkdownAdapterMatch: InlineDeltaToPlainTextAdapterMatcher = + { + name: 'inlineLatex', + match: delta => !!delta.attributes?.latex, + toAST: delta => { + const node: TextBuffer = { + content: delta.insert, + }; + if (!delta.attributes?.latex) { + return node; + } + return { + content: delta.attributes?.latex, + }; + }, + }; + +export const inlineDeltaToPlainTextAdapterMatchers: InlineDeltaToPlainTextAdapterMatcher[] = + [ + referenceDeltaMarkdownAdapterMatch, + linkDeltaMarkdownAdapterMatch, + latexDeltaMarkdownAdapterMatch, + ]; diff --git a/blocksuite/blocks/src/_common/adapters/plain-text/plain-text.ts b/blocksuite/blocks/src/_common/adapters/plain-text/plain-text.ts new file mode 100644 index 0000000000..89728ccf83 --- /dev/null +++ b/blocksuite/blocks/src/_common/adapters/plain-text/plain-text.ts @@ -0,0 +1,321 @@ +import { + DEFAULT_NOTE_BACKGROUND_COLOR, + NoteDisplayMode, +} from '@blocksuite/affine-model'; +import { + type AdapterContext, + type BlockPlainTextAdapterMatcher, + BlockPlainTextAdapterMatcherIdentifier, + type PlainText, + PlainTextDeltaConverter, + type TextBuffer, +} from '@blocksuite/affine-shared/adapters'; +import type { ExtensionType } from '@blocksuite/block-std'; +import { + type AssetsManager, + ASTWalker, + BaseAdapter, + type BlockSnapshot, + BlockSnapshotSchema, + type DocSnapshot, + type FromBlockSnapshotPayload, + type FromBlockSnapshotResult, + type FromDocSnapshotPayload, + type FromDocSnapshotResult, + type FromSliceSnapshotPayload, + type FromSliceSnapshotResult, + type Job, + nanoid, + type SliceSnapshot, + type ToBlockSnapshotPayload, + type ToDocSnapshotPayload, +} from '@blocksuite/store'; + +import { AdapterFactoryIdentifier } from '../type.js'; +import { defaultBlockPlainTextAdapterMatchers } from './block-matcher.js'; +import { inlineDeltaToPlainTextAdapterMatchers } from './delta-converter/inline-delta.js'; + +type PlainTextToSliceSnapshotPayload = { + file: PlainText; + assets?: AssetsManager; + blockVersions: Record; + workspaceId: string; + pageId: string; +}; + +export class PlainTextAdapter extends BaseAdapter { + deltaConverter: PlainTextDeltaConverter; + + constructor( + job: Job, + readonly blockMatchers: BlockPlainTextAdapterMatcher[] = defaultBlockPlainTextAdapterMatchers + ) { + super(job); + this.deltaConverter = new PlainTextDeltaConverter( + job.adapterConfigs, + inlineDeltaToPlainTextAdapterMatchers, + [] + ); + } + + private async _traverseSnapshot( + snapshot: BlockSnapshot + ): Promise<{ plaintext: string }> { + const textBuffer: TextBuffer = { + content: '', + }; + const walker = new ASTWalker<BlockSnapshot, TextBuffer>(); + walker.setONodeTypeGuard( + (node): node is BlockSnapshot => + BlockSnapshotSchema.safeParse(node).success + ); + walker.setEnter(async (o, context) => { + for (const matcher of this.blockMatchers) { + if (matcher.fromMatch(o)) { + const adapterContext: AdapterContext<BlockSnapshot, TextBuffer> = { + walker, + walkerContext: context, + configs: this.configs, + job: this.job, + deltaConverter: this.deltaConverter, + textBuffer, + }; + await matcher.fromBlockSnapshot.enter?.(o, adapterContext); + } + } + }); + walker.setLeave(async (o, context) => { + for (const matcher of this.blockMatchers) { + if (matcher.fromMatch(o)) { + const adapterContext: AdapterContext<BlockSnapshot, TextBuffer> = { + walker, + walkerContext: context, + configs: this.configs, + job: this.job, + deltaConverter: this.deltaConverter, + textBuffer, + }; + await matcher.fromBlockSnapshot.leave?.(o, adapterContext); + } + } + }); + await walker.walkONode(snapshot); + return { + plaintext: textBuffer.content, + }; + } + + async fromBlockSnapshot({ + snapshot, + }: FromBlockSnapshotPayload): Promise<FromBlockSnapshotResult<PlainText>> { + const { plaintext } = await this._traverseSnapshot(snapshot); + return { + file: plaintext, + assetsIds: [], + }; + } + + async fromDocSnapshot({ + snapshot, + assets, + }: FromDocSnapshotPayload): Promise<FromDocSnapshotResult<PlainText>> { + let buffer = ''; + if (snapshot.meta.title) { + buffer += `${snapshot.meta.title}\n\n`; + } + const { file, assetsIds } = await this.fromBlockSnapshot({ + snapshot: snapshot.blocks, + assets, + }); + buffer += file; + return { + file: buffer, + assetsIds, + }; + } + + async fromSliceSnapshot({ + snapshot, + }: FromSliceSnapshotPayload): Promise<FromSliceSnapshotResult<PlainText>> { + let buffer = ''; + const sliceAssetsIds: string[] = []; + for (const contentSlice of snapshot.content) { + const { plaintext } = await this._traverseSnapshot(contentSlice); + buffer += plaintext; + } + const plaintext = + buffer.match(/\n/g)?.length === 1 ? buffer.trimEnd() : buffer; + return { + file: plaintext, + assetsIds: sliceAssetsIds, + }; + } + + toBlockSnapshot(payload: ToBlockSnapshotPayload<PlainText>): BlockSnapshot { + payload.file = payload.file.replaceAll('\r', ''); + return { + type: 'block', + id: nanoid(), + flavour: 'affine:note', + props: { + xywh: '[0,0,800,95]', + background: DEFAULT_NOTE_BACKGROUND_COLOR, + index: 'a0', + hidden: false, + displayMode: NoteDisplayMode.DocAndEdgeless, + }, + children: payload.file.split('\n').map((line): BlockSnapshot => { + return { + type: 'block', + id: nanoid(), + flavour: 'affine:paragraph', + props: { + type: 'text', + text: { + '$blocksuite:internal:text$': true, + delta: [ + { + insert: line, + }, + ], + }, + }, + children: [], + }; + }), + }; + } + + toDocSnapshot(payload: ToDocSnapshotPayload<PlainText>): DocSnapshot { + payload.file = payload.file.replaceAll('\r', ''); + return { + type: 'page', + meta: { + id: nanoid(), + title: 'Untitled', + createDate: Date.now(), + tags: [], + }, + blocks: { + type: 'block', + id: nanoid(), + flavour: 'affine:page', + props: { + title: { + '$blocksuite:internal:text$': true, + delta: [ + { + insert: 'Untitled', + }, + ], + }, + }, + children: [ + { + type: 'block', + id: nanoid(), + flavour: 'affine:surface', + props: { + elements: {}, + }, + children: [], + }, + { + type: 'block', + id: nanoid(), + flavour: 'affine:note', + props: { + xywh: '[0,0,800,95]', + background: DEFAULT_NOTE_BACKGROUND_COLOR, + index: 'a0', + hidden: false, + displayMode: NoteDisplayMode.DocAndEdgeless, + }, + children: payload.file.split('\n').map((line): BlockSnapshot => { + return { + type: 'block', + id: nanoid(), + flavour: 'affine:paragraph', + props: { + type: 'text', + text: { + '$blocksuite:internal:text$': true, + delta: [ + { + insert: line, + }, + ], + }, + }, + children: [], + }; + }), + }, + ], + }, + }; + } + + toSliceSnapshot( + payload: PlainTextToSliceSnapshotPayload + ): SliceSnapshot | null { + if (payload.file.trim().length === 0) { + return null; + } + payload.file = payload.file.replaceAll('\r', ''); + const contentSlice = { + type: 'block', + id: nanoid(), + flavour: 'affine:note', + props: { + xywh: '[0,0,800,95]', + background: DEFAULT_NOTE_BACKGROUND_COLOR, + index: 'a0', + hidden: false, + displayMode: NoteDisplayMode.DocAndEdgeless, + }, + children: payload.file.split('\n').map((line): BlockSnapshot => { + return { + type: 'block', + id: nanoid(), + flavour: 'affine:paragraph', + props: { + type: 'text', + text: { + '$blocksuite:internal:text$': true, + delta: [ + { + insert: line, + }, + ], + }, + }, + children: [], + }; + }), + } as BlockSnapshot; + return { + type: 'slice', + content: [contentSlice], + workspaceId: payload.workspaceId, + pageId: payload.pageId, + }; + } +} + +export const PlainTextAdapterFactoryIdentifier = + AdapterFactoryIdentifier('PlainText'); + +export const PlainTextAdapterFactoryExtension: ExtensionType = { + setup: di => { + di.addImpl(PlainTextAdapterFactoryIdentifier, provider => ({ + get: (job: Job) => + new PlainTextAdapter( + job, + Array.from( + provider.getAll(BlockPlainTextAdapterMatcherIdentifier).values() + ) + ), + })); + }, +}; diff --git a/blocksuite/blocks/src/_common/adapters/type.ts b/blocksuite/blocks/src/_common/adapters/type.ts new file mode 100644 index 0000000000..dc8311cfe1 --- /dev/null +++ b/blocksuite/blocks/src/_common/adapters/type.ts @@ -0,0 +1,10 @@ +import { createIdentifier } from '@blocksuite/global/di'; +import type { BaseAdapter, Job } from '@blocksuite/store'; + +export type AdapterFactory = { + // TODO(@chen): Make it return the specific adapter type + get: (job: Job) => BaseAdapter; +}; + +export const AdapterFactoryIdentifier = + createIdentifier<AdapterFactory>('AdapterFactory'); diff --git a/blocksuite/blocks/src/_common/components/ai-item/ai-item-list.ts b/blocksuite/blocks/src/_common/components/ai-item/ai-item-list.ts new file mode 100644 index 0000000000..75c3e717fe --- /dev/null +++ b/blocksuite/blocks/src/_common/components/ai-item/ai-item-list.ts @@ -0,0 +1,150 @@ +import { createLitPortal } from '@blocksuite/affine-components/portal'; +import { + EditorHost, + PropTypes, + requiredProperties, +} from '@blocksuite/block-std'; +import { WithDisposable } from '@blocksuite/global/utils'; +import { flip, offset } from '@floating-ui/dom'; +import { baseTheme } from '@toeverything/theme'; +import { css, html, LitElement, nothing, unsafeCSS } from 'lit'; +import { property } from 'lit/decorators.js'; +import { repeat } from 'lit/directives/repeat.js'; + +import type { AIItem } from './ai-item.js'; +import { + SUBMENU_OFFSET_CROSS_AXIS, + SUBMENU_OFFSET_MAIN_AXIS, +} from './const.js'; +import type { AIItemConfig, AIItemGroupConfig } from './types.js'; + +@requiredProperties({ host: PropTypes.instanceOf(EditorHost) }) +export class AIItemList extends WithDisposable(LitElement) { + static override styles = css` + :host { + display: flex; + flex-direction: column; + gap: 2px; + width: 100%; + font-family: ${unsafeCSS(baseTheme.fontSansFamily)}; + user-select: none; + } + .group-name { + display: flex; + padding: 4px calc(var(--item-padding, 8px) + 4px); + align-items: center; + color: var(--affine-text-secondary-color); + text-align: justify; + font-size: var(--affine-font-xs); + font-style: normal; + font-weight: 500; + line-height: 20px; + width: 100%; + box-sizing: border-box; + } + `; + + private _abortController: AbortController | null = null; + + private _activeSubMenuItem: AIItemConfig | null = null; + + private _closeSubMenu = () => { + if (this._abortController) { + this._abortController.abort(); + this._abortController = null; + } + this._activeSubMenuItem = null; + }; + + private _itemClassName = (item: AIItemConfig) => { + return 'ai-item-' + item.name.split(' ').join('-').toLocaleLowerCase(); + }; + + private _openSubMenu = (item: AIItemConfig) => { + if (!item.subItem || item.subItem.length === 0) { + this._closeSubMenu(); + return; + } + + if (item === this._activeSubMenuItem) { + return; + } + + const aiItem = this.shadowRoot?.querySelector( + `.${this._itemClassName(item)}` + ) as AIItem | null; + if (!aiItem || !aiItem.menuItem) return; + + this._closeSubMenu(); + this._activeSubMenuItem = item; + this._abortController = new AbortController(); + this._abortController.signal.addEventListener('abort', () => { + this._closeSubMenu(); + }); + + const aiItemContainer = aiItem.menuItem; + const subMenuOffset = { + mainAxis: item.subItemOffset?.[0] ?? SUBMENU_OFFSET_MAIN_AXIS, + crossAxis: item.subItemOffset?.[1] ?? SUBMENU_OFFSET_CROSS_AXIS, + }; + + createLitPortal({ + template: html`<ai-sub-item-list + .item=${item} + .host=${this.host} + .onClick=${this.onClick} + .abortController=${this._abortController} + ></ai-sub-item-list>`, + container: aiItemContainer, + positionStrategy: 'fixed', + computePosition: { + referenceElement: aiItemContainer, + placement: 'right-start', + middleware: [flip(), offset(subMenuOffset)], + autoUpdate: true, + }, + abortController: this._abortController, + closeOnClickAway: true, + }); + }; + + override render() { + return html`${repeat(this.groups, group => { + return html` + ${group.name + ? html`<div class="group-name"> + ${group.name.toLocaleUpperCase()} + </div>` + : nothing} + ${repeat( + group.items, + item => + html`<ai-item + .onClick=${this.onClick} + .item=${item} + .host=${this.host} + class=${this._itemClassName(item)} + @mouseover=${() => { + this._openSubMenu(item); + }} + ></ai-item>` + )} + `; + })}`; + } + + @property({ attribute: false }) + accessor groups: AIItemGroupConfig[] = []; + + @property({ attribute: false }) + accessor host!: EditorHost; + + @property({ attribute: false }) + accessor onClick: (() => void) | undefined = undefined; +} + +declare global { + interface HTMLElementTagNameMap { + 'ai-item-list': AIItemList; + } +} diff --git a/blocksuite/blocks/src/_common/components/ai-item/ai-item.ts b/blocksuite/blocks/src/_common/components/ai-item/ai-item.ts new file mode 100644 index 0000000000..8aa8a396e7 --- /dev/null +++ b/blocksuite/blocks/src/_common/components/ai-item/ai-item.ts @@ -0,0 +1,66 @@ +import { ArrowRightIcon, EnterIcon } from '@blocksuite/affine-components/icons'; +import { + EditorHost, + PropTypes, + requiredProperties, +} from '@blocksuite/block-std'; +import { WithDisposable } from '@blocksuite/global/utils'; +import { css, html, LitElement, nothing } from 'lit'; +import { property, query } from 'lit/decorators.js'; + +import { menuItemStyles } from './styles.js'; +import type { AIItemConfig } from './types.js'; + +@requiredProperties({ + host: PropTypes.instanceOf(EditorHost), + item: PropTypes.object, +}) +export class AIItem extends WithDisposable(LitElement) { + static override styles = css` + ${menuItemStyles} + `; + + override render() { + const { item } = this; + const className = item.name.split(' ').join('-').toLocaleLowerCase(); + + return html`<div + class="menu-item ${className}" + @pointerdown=${(e: MouseEvent) => e.stopPropagation()} + @click=${() => { + this.onClick?.(); + if (typeof item.handler === 'function') { + item.handler(this.host); + } + }} + > + <span class="item-icon">${item.icon}</span> + <div class="item-name"> + ${item.name}${item.beta + ? html`<div class="item-beta">(Beta)</div>` + : nothing} + </div> + ${item.subItem + ? html`<span class="arrow-right-icon">${ArrowRightIcon}</span>` + : html`<span class="enter-icon">${EnterIcon}</span>`} + </div>`; + } + + @property({ attribute: false }) + accessor host!: EditorHost; + + @property({ attribute: false }) + accessor item!: AIItemConfig; + + @query('.menu-item') + accessor menuItem: HTMLDivElement | null = null; + + @property({ attribute: false }) + accessor onClick: (() => void) | undefined; +} + +declare global { + interface HTMLElementTagNameMap { + 'ai-item': AIItem; + } +} diff --git a/blocksuite/blocks/src/_common/components/ai-item/ai-sub-item-list.ts b/blocksuite/blocks/src/_common/components/ai-item/ai-sub-item-list.ts new file mode 100644 index 0000000000..fc62784dcb --- /dev/null +++ b/blocksuite/blocks/src/_common/components/ai-item/ai-sub-item-list.ts @@ -0,0 +1,90 @@ +import { EnterIcon } from '@blocksuite/affine-components/icons'; +import { + EditorHost, + PropTypes, + requiredProperties, +} from '@blocksuite/block-std'; +import { WithDisposable } from '@blocksuite/global/utils'; +import { baseTheme } from '@toeverything/theme'; +import { css, html, LitElement, nothing, unsafeCSS } from 'lit'; +import { property } from 'lit/decorators.js'; + +import { menuItemStyles } from './styles.js'; +import type { AIItemConfig, AISubItemConfig } from './types.js'; + +@requiredProperties({ + host: PropTypes.instanceOf(EditorHost), + item: PropTypes.object, +}) +export class AISubItemList extends WithDisposable(LitElement) { + static override styles = css` + .ai-sub-menu { + display: flex; + flex-direction: column; + box-sizing: border-box; + padding: 8px; + min-width: 240px; + max-height: 320px; + overflow-y: auto; + background: var(--affine-background-overlay-panel-color); + box-shadow: var(--affine-shadow-2); + border-radius: 8px; + z-index: var(--affine-z-index-popover); + font-family: ${unsafeCSS(baseTheme.fontSansFamily)}; + color: var(--affine-text-primary-color); + text-align: justify; + font-feature-settings: + 'clig' off, + 'liga' off; + font-size: var(--affine-font-sm); + font-style: normal; + font-weight: 400; + line-height: 22px; + user-select: none; + } + ${menuItemStyles} + `; + + private _handleClick = (subItem: AISubItemConfig) => { + this.onClick?.(); + if (subItem.handler) { + // TODO: add parameters to ai handler + subItem.handler(this.host); + } + this.abortController.abort(); + }; + + override render() { + if (!this.item.subItem || this.item.subItem.length <= 0) return nothing; + return html`<div class="ai-sub-menu"> + ${this.item.subItem?.map( + subItem => + html`<div + class="menu-item" + @click=${() => this._handleClick(subItem)} + > + <div class="item-name">${subItem.type}</div> + <span class="enter-icon">${EnterIcon}</span> + </div>` + )} + </div>`; + } + + @property({ attribute: false }) + accessor abortController: AbortController = new AbortController(); + + @property({ attribute: false }) + accessor host!: EditorHost; + + @property({ attribute: false }) + accessor item!: AIItemConfig; + + @property({ attribute: false }) + accessor onClick: (() => void) | undefined; +} + +declare global { + interface HTMLElementTagNameMap { + 'ai-sub-item-list': AISubItemList; + } +} diff --git a/blocksuite/blocks/src/_common/components/ai-item/const.ts b/blocksuite/blocks/src/_common/components/ai-item/const.ts new file mode 100644 index 0000000000..f42dbe3789 --- /dev/null +++ b/blocksuite/blocks/src/_common/components/ai-item/const.ts @@ -0,0 +1,2 @@ +export const SUBMENU_OFFSET_MAIN_AXIS = 12; +export const SUBMENU_OFFSET_CROSS_AXIS = -60; diff --git a/blocksuite/blocks/src/_common/components/ai-item/index.ts b/blocksuite/blocks/src/_common/components/ai-item/index.ts new file mode 100644 index 0000000000..5842791cd9 --- /dev/null +++ b/blocksuite/blocks/src/_common/components/ai-item/index.ts @@ -0,0 +1,2 @@ +export * from './ai-item-list.js'; +export * from './types.js'; diff --git a/blocksuite/blocks/src/_common/components/ai-item/styles.ts b/blocksuite/blocks/src/_common/components/ai-item/styles.ts new file mode 100644 index 0000000000..e3649668cd --- /dev/null +++ b/blocksuite/blocks/src/_common/components/ai-item/styles.ts @@ -0,0 +1,71 @@ +import { css } from 'lit'; + +export const menuItemStyles = css` + .menu-item { + position: relative; + width: 100%; + display: flex; + flex-direction: row; + align-items: center; + padding: 4px var(--item-padding, 12px); + gap: 4px; + align-self: stretch; + border-radius: 4px; + box-sizing: border-box; + } + .menu-item:hover { + background: var(--affine-hover-color); + cursor: pointer; + } + .item-icon { + display: flex; + color: var(--item-icon-color, var(--affine-brand-color)); + } + .menu-item:hover .item-icon { + color: var(--item-icon-hover-color, var(--affine-brand-color)); + } + .menu-item.discard:hover { + background: var(--affine-background-error-color); + .item-name, + .item-icon, + .enter-icon { + color: var(--affine-error-color); + } + } + .item-name { + display: flex; + padding: 0px 4px; + align-items: baseline; + flex: 1 0 0; + color: var(--affine-text-primary-color); + text-align: start; + white-space: nowrap; + font-feature-settings: + 'clig' off, + 'liga' off; + font-size: var(--affine-font-sm); + font-style: normal; + font-weight: 400; + line-height: 22px; + } + + .item-beta { + color: var(--affine-text-secondary-color); + font-size: var(--affine-font-xs); + font-weight: 500; + margin-left: 0.5em; + } + + .enter-icon, + .arrow-right-icon { + color: var(--affine-icon-color); + display: flex; + } + .enter-icon { + opacity: 0; + } + .arrow-right-icon, + .menu-item:hover .enter-icon { + opacity: 1; + } +`; diff --git a/blocksuite/blocks/src/_common/components/ai-item/types.ts b/blocksuite/blocks/src/_common/components/ai-item/types.ts new file mode 100644 index 0000000000..151ca21efc --- /dev/null +++ b/blocksuite/blocks/src/_common/components/ai-item/types.ts @@ -0,0 +1,68 @@ +import type { DocMode } from '@blocksuite/affine-model'; +import type { Chain, EditorHost, InitCommandCtx } from '@blocksuite/block-std'; +import type { TemplateResult } from 'lit'; + +export interface AIItemGroupConfig { + name?: string; + items: AIItemConfig[]; +} + +export interface AIItemConfig { + name: string; + icon: TemplateResult | (() => HTMLElement); + showWhen?: ( + chain: Chain<InitCommandCtx>, + editorMode: DocMode, + host: EditorHost + ) => boolean; + subItem?: AISubItemConfig[]; + subItemOffset?: [number, number]; + handler?: (host: EditorHost) => void; + beta?: boolean; +} + +export interface AISubItemConfig { + type: string; + handler?: (host: EditorHost) => void; +} + +abstract class BaseAIError extends Error { + abstract readonly type: AIErrorType; +} + +export enum AIErrorType { + GeneralNetworkError = 'GeneralNetworkError', + PaymentRequired = 'PaymentRequired', + Unauthorized = 'Unauthorized', +} + +export class UnauthorizedError extends BaseAIError { + readonly type = AIErrorType.Unauthorized; + + constructor() { + super('Unauthorized'); + } +} + +// user has used up the quota +export class PaymentRequiredError extends BaseAIError { + readonly type = AIErrorType.PaymentRequired; + + constructor() { + super('Payment required'); + } +} + +// general 500x error +export class GeneralNetworkError extends BaseAIError { + readonly type = AIErrorType.GeneralNetworkError; + + constructor(message: string = 'Network error') { + super(message); + } +} + +export type AIError = + | UnauthorizedError + | PaymentRequiredError + | GeneralNetworkError; diff --git a/blocksuite/blocks/src/_common/components/block-selection.ts b/blocksuite/blocks/src/_common/components/block-selection.ts new file mode 100644 index 0000000000..975b76027a --- /dev/null +++ b/blocksuite/blocks/src/_common/components/block-selection.ts @@ -0,0 +1,74 @@ +import type { BlockComponent } from '@blocksuite/block-std'; +import { SignalWatcher } from '@blocksuite/global/utils'; +import { css, LitElement, type PropertyValues } from 'lit'; +import { property } from 'lit/decorators.js'; + +/** + * Renders a the block selection. + * + * @example + * ```ts + * class Block extends LitElement { + * state override styles = css` + * :host { + * position: relative; + * } + * + * render() { + * return html`<affine-block-selection></affine-block-selection> + * }; + * } + * ``` + */ +export class BlockSelection extends SignalWatcher(LitElement) { + static override styles = css` + :host { + position: absolute; + z-index: 1; + left: 0; + top: 0; + width: 100%; + height: 100%; + pointer-events: none; + background-color: var(--affine-hover-color); + border-color: transparent; + border-style: solid; + } + `; + + override connectedCallback(): void { + super.connectedCallback(); + + this.style.borderRadius = `${this.borderRadius}px`; + if (this.borderWidth !== 0) { + this.style.boxSizing = 'content-box'; + this.style.transform = `translate(-${this.borderWidth}px, -${this.borderWidth}px)`; + } + this.style.borderWidth = `${this.borderWidth}px`; + } + + override disconnectedCallback() { + super.disconnectedCallback(); + this.block = null as unknown as BlockComponent; // force gc + } + + protected override updated(_changedProperties: PropertyValues): void { + super.updated(_changedProperties); + this.style.display = this.block.selected?.is('block') ? 'block' : 'none'; + } + + @property({ attribute: false }) + accessor block!: BlockComponent; + + @property({ attribute: false }) + accessor borderRadius: number = 5; + + @property({ attribute: false }) + accessor borderWidth: number = 0; +} + +declare global { + interface HTMLElementTagNameMap { + 'affine-block-selection': BlockSelection; + } +} diff --git a/blocksuite/blocks/src/_common/components/block-zero-width.ts b/blocksuite/blocks/src/_common/components/block-zero-width.ts new file mode 100644 index 0000000000..135320e778 --- /dev/null +++ b/blocksuite/blocks/src/_common/components/block-zero-width.ts @@ -0,0 +1,53 @@ +import { focusTextModel } from '@blocksuite/affine-components/rich-text'; +import { stopPropagation } from '@blocksuite/affine-shared/utils'; +import type { BlockComponent } from '@blocksuite/block-std'; +import { css, html, LitElement } from 'lit'; +import { property } from 'lit/decorators.js'; + +export class BlockZeroWidth extends LitElement { + static override styles = css` + .block-zero-width { + position: absolute; + bottom: -15px; + height: 10px; + width: 100%; + cursor: text; + z-index: 1; + } + `; + + _handleClick = (e: MouseEvent) => { + stopPropagation(e); + if (this.block.doc.readonly) return; + const nextBlock = this.block.doc.getNext(this.block.model); + if (nextBlock?.flavour !== 'affine:paragraph') { + const [paragraphId] = this.block.doc.addSiblingBlocks(this.block.model, [ + { flavour: 'affine:paragraph' }, + ]); + focusTextModel(this.block.host.std, paragraphId); + } + }; + + override connectedCallback(): void { + super.connectedCallback(); + this.addEventListener('click', this._handleClick); + } + + override disconnectedCallback(): void { + this.removeEventListener('click', this._handleClick); + super.disconnectedCallback(); + } + + override render() { + return html`<div class="block-zero-width"></div>`; + } + + @property({ attribute: false }) + accessor block!: BlockComponent; +} + +declare global { + interface HTMLElementTagNameMap { + 'block-zero-width': BlockZeroWidth; + } +} diff --git a/blocksuite/blocks/src/_common/components/button.ts b/blocksuite/blocks/src/_common/components/button.ts new file mode 100644 index 0000000000..ceee530178 --- /dev/null +++ b/blocksuite/blocks/src/_common/components/button.ts @@ -0,0 +1,242 @@ +import { baseTheme } from '@toeverything/theme'; +import { cssVarV2 } from '@toeverything/theme/v2'; +import { + css, + html, + LitElement, + nothing, + type TemplateResult, + unsafeCSS, +} from 'lit'; +import { property, query } from 'lit/decorators.js'; + +/** + * Default size is 32px, you can override it by setting `size` property. + * For example, `<icon-button size="32px"></icon-button>`. + * + * You can also set `width` or `height` property to override the size. + * + * Set `text` property to show a text label. + * + * @example + * ```ts + * html`<icon-button @click=${this.onUnlink}> + * ${UnlinkIcon} + * </icon-button>` + * + * html`<icon-button size="32px" text="HTML" @click=${this._importHtml}> + * ${ExportToHTMLIcon} + * </icon-button>` + * ``` + */ +export class IconButton extends LitElement { + static override styles = css` + :host { + box-sizing: border-box; + display: flex; + justify-content: center; + align-items: center; + border: none; + width: var(--button-width); + height: var(--button-height); + border-radius: 4px; + background: transparent; + cursor: pointer; + user-select: none; + font-family: ${unsafeCSS(baseTheme.fontSansFamily)}; + color: var(--affine-text-primary-color); + pointer-events: auto; + padding: 4px; + } + + // This media query can detect if the device has a hover capability + @media (hover: hover) { + :host(:hover) { + background: var(--affine-hover-color); + } + } + + :host(:active) { + background: transparent; + } + + :host([disabled]), + :host(:disabled) { + background: transparent; + color: var(--affine-text-disable-color); + cursor: not-allowed; + } + + /* You can add a 'hover' attribute to the button to show the hover style */ + :host([hover='true']) { + background: var(--affine-hover-color); + } + :host([hover='false']) { + background: transparent; + } + + :host(:active[active]) { + background: transparent; + } + + /* not supported "until-found" yet */ + :host([hidden]) { + display: none; + } + + :host > .text-container { + display: flex; + flex-direction: column; + overflow: hidden; + } + + :host .text { + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; + font-size: var(--affine-font-sm); + line-height: var(--affine-line-height); + } + + :host .sub-text { + font-size: var(--affine-font-xs); + color: var( + --light-textColor-textSecondaryColor, + var(--textColor-textSecondaryColor, #8e8d91) + ); + line-height: var(--affine-line-height); + white-space: nowrap; + text-overflow: ellipsis; + overflow: hidden; + margin-top: -2px; + } + + ::slotted(svg) { + flex-shrink: 0; + color: var(--svg-icon-color); + } + + ::slotted([slot='suffix']) { + margin-left: auto; + } + `; + + constructor() { + super(); + // Allow activate button by pressing Enter key + this.addEventListener('keypress', event => { + if (this.disabled) { + return; + } + if (event.key === 'Enter' && !event.isComposing) { + this.click(); + } + }); + + // Prevent click event when disabled + this.addEventListener( + 'click', + event => { + if (this.disabled === true) { + event.preventDefault(); + event.stopPropagation(); + } + }, + { capture: true } + ); + } + + override connectedCallback() { + super.connectedCallback(); + this.tabIndex = 0; + this.role = 'button'; + + const DEFAULT_SIZE = '28px'; + if (this.size && (this.width || this.height)) { + return; + } + + let width = this.width ?? DEFAULT_SIZE; + let height = this.height ?? DEFAULT_SIZE; + if (this.size) { + width = this.size; + height = this.size; + } + + this.style.setProperty( + '--button-width', + typeof width === 'string' ? width : `${width}px` + ); + this.style.setProperty( + '--button-height', + typeof height === 'string' ? height : `${height}px` + ); + } + + override render() { + if (this.hidden) return nothing; + if (this.disabled) { + const disabledColor = cssVarV2('icon/disable'); + this.style.setProperty('--svg-icon-color', disabledColor); + this.dataset.testDisabled = 'true'; + } else { + this.dataset.testDisabled = 'false'; + const iconColor = this.active + ? cssVarV2('icon/activated') + : cssVarV2('icon/primary'); + this.style.setProperty('--svg-icon-color', iconColor); + } + + const text = this.text + ? // wrap a span around the text so we can ellipsis it automatically + html`<div class="text">${this.text}</div>` + : nothing; + + const subText = this.subText + ? html`<div class="sub-text">${this.subText}</div>` + : nothing; + + const textContainer = + this.text || this.subText + ? html`<div class="text-container">${text}${subText}</div>` + : nothing; + + return html`<slot></slot> + ${textContainer} + <slot name="suffix"></slot>`; + } + + @property({ attribute: true, type: Boolean }) + accessor active: boolean = false; + + // Do not add `{ attribute: false }` option here, otherwise the `disabled` styles will not work + @property({ attribute: true, type: Boolean }) + accessor disabled: boolean | undefined = undefined; + + @property() + accessor height: string | number | null = null; + + @property({ attribute: true, type: String }) + accessor hover: 'true' | 'false' | undefined = undefined; + + @property() + accessor size: string | number | null = null; + + @property() + accessor subText: string | TemplateResult<1> | null = null; + + @property() + accessor text: string | TemplateResult<1> | null = null; + + @query('.text-container .text') + accessor textElement: HTMLDivElement | null = null; + + @property() + accessor width: string | number | null = null; +} + +declare global { + interface HTMLElementTagNameMap { + 'icon-button': IconButton; + } +} diff --git a/blocksuite/blocks/src/_common/components/embed-card/embed-card-more-menu-popper.ts b/blocksuite/blocks/src/_common/components/embed-card/embed-card-more-menu-popper.ts new file mode 100644 index 0000000000..a0f62db83a --- /dev/null +++ b/blocksuite/blocks/src/_common/components/embed-card/embed-card-more-menu-popper.ts @@ -0,0 +1,228 @@ +import { + CenterPeekIcon, + CopyIcon, + DeleteIcon, + DuplicateIcon, + OpenIcon, + RefreshIcon, +} from '@blocksuite/affine-components/icons'; +import { isPeekable, peek } from '@blocksuite/affine-components/peek'; +import { toast } from '@blocksuite/affine-components/toast'; +import { WithDisposable } from '@blocksuite/global/utils'; +import { Slice } from '@blocksuite/store'; +import { css, html, LitElement, nothing } from 'lit'; +import { property } from 'lit/decorators.js'; + +import { + isEmbedLinkedDocBlock, + isEmbedSyncedDocBlock, +} from '../../../root-block/edgeless/utils/query.js'; +import { getBlockProps } from '../../utils/index.js'; +import type { EmbedBlockComponent } from './type.js'; + +export class EmbedCardMoreMenu extends WithDisposable(LitElement) { + static override styles = css` + .embed-card-more-menu { + box-sizing: border-box; + padding-bottom: 4px; + } + + .embed-card-more-menu-container { + border-radius: 8px; + padding: 8px; + background: var(--affine-background-overlay-panel-color); + box-shadow: var(--affine-shadow-2); + } + + .embed-card-more-menu-container > .menu-item { + display: flex; + justify-content: flex-start; + align-items: center; + width: 100%; + } + + .embed-card-more-menu-container > .menu-item:hover { + background: var(--affine-hover-color); + } + + .embed-card-more-menu-container > .menu-item:hover.delete { + background: var(--affine-background-error-color); + color: var(--affine-error-color); + } + .embed-card-more-menu-container > .menu-item:hover.delete > svg { + color: var(--affine-error-color); + } + + .embed-card-more-menu-container > .menu-item svg { + margin: 0 8px; + } + + .embed-card-more-menu-container > .divider { + width: 148px; + height: 1px; + margin: 8px; + background-color: var(--affine-border-color); + } + `; + + private get _doc() { + return this.block.doc; + } + + private get _model() { + return this.block.model; + } + + get _openButtonDisabled() { + return ( + isEmbedLinkedDocBlock(this._model) && this._model.pageId === this._doc.id + ); + } + + private get _std() { + return this.block.std; + } + + private async _copyBlock() { + const slice = Slice.fromModels(this._doc, [this._model]); + await this._std.clipboard.copySlice(slice); + toast(this.block.host, 'Copied link to clipboard'); + this.abortController.abort(); + } + + private _duplicateBlock() { + const model = this._model; + const blockProps = getBlockProps(model); + const { + width: _width, + height: _height, + xywh: _xywh, + rotate: _rotate, + zIndex: _zIndex, + ...duplicateProps + } = blockProps; + + const { doc } = model; + const parent = doc.getParent(model); + const index = parent?.children.indexOf(model); + doc.addBlock( + model.flavour as BlockSuite.Flavour, + duplicateProps, + parent, + index + ); + this.abortController.abort(); + } + + private _open() { + this.block.open(); + this.abortController.abort(); + } + + private _peek() { + peek(this.block); + } + + private _peekable() { + return isPeekable(this.block); + } + + private _refreshData() { + this.block.refreshData(); + this.abortController.abort(); + } + + override render() { + return html` + <div class="embed-card-more-menu"> + <div + class="embed-card-more-menu-container" + @pointerdown=${(e: MouseEvent) => e.stopPropagation()} + > + <icon-button + width="126px" + height="32px" + class="menu-item open" + text="Open" + @click=${() => this._open()} + ?disabled=${this._openButtonDisabled} + > + ${OpenIcon} + </icon-button> + + ${this._peekable() + ? html`<icon-button + width="126px" + height="32px" + text="Open in center peek" + class="menu-item center-peek" + @click=${() => this._peek()} + > + ${CenterPeekIcon} + </icon-button>` + : nothing} + + <icon-button + width="126px" + height="32px" + class="menu-item copy" + text="Copy" + @click=${() => this._copyBlock()} + > + ${CopyIcon} + </icon-button> + + <icon-button + width="126px" + height="32px" + class="menu-item duplicate" + text="Duplicate" + ?disabled=${this._doc.readonly} + @click=${() => this._duplicateBlock()} + > + ${DuplicateIcon} + </icon-button> + + ${isEmbedLinkedDocBlock(this._model) || + isEmbedSyncedDocBlock(this._model) + ? nothing + : html`<icon-button + width="126px" + height="32px" + class="menu-item reload" + text="Reload" + ?disabled=${this._doc.readonly} + @click=${() => this._refreshData()} + > + ${RefreshIcon} + </icon-button>`} + + <div class="divider"></div> + + <icon-button + width="126px" + height="32px" + class="menu-item delete" + text="Delete" + ?disabled=${this._doc.readonly} + @click=${() => this._doc.deleteBlock(this._model)} + > + ${DeleteIcon} + </icon-button> + </div> + </div> + `; + } + + @property({ attribute: false }) + accessor abortController!: AbortController; + + @property({ attribute: false }) + accessor block!: EmbedBlockComponent; +} + +declare global { + interface HTMLElementTagNameMap { + 'embed-card-more-menu': EmbedCardMoreMenu; + } +} diff --git a/blocksuite/blocks/src/_common/components/embed-card/embed-card-style-popper.ts b/blocksuite/blocks/src/_common/components/embed-card/embed-card-style-popper.ts new file mode 100644 index 0000000000..a27db979b4 --- /dev/null +++ b/blocksuite/blocks/src/_common/components/embed-card/embed-card-style-popper.ts @@ -0,0 +1,106 @@ +import type { + BookmarkBlockModel, + ColorScheme, + EmbedGithubModel, + EmbedLinkedDocModel, +} from '@blocksuite/affine-model'; +import { WithDisposable } from '@blocksuite/global/utils'; +import { css, html, LitElement } from 'lit'; +import { property } from 'lit/decorators.js'; +import { classMap } from 'lit/directives/class-map.js'; + +import type { EmbedCardStyle } from '../../types.js'; +import { getEmbedCardIcons } from '../../utils/url.js'; + +export class EmbedCardStyleMenu extends WithDisposable(LitElement) { + static override styles = css` + .embed-card-style-menu { + box-sizing: border-box; + padding-bottom: 8px; + } + + .embed-card-style-menu-container { + border-radius: 8px; + padding: 8px; + gap: 8px; + display: flex; + justify-content: space-between; + align-items: center; + background: var(--affine-background-overlay-panel-color); + box-shadow: var(--affine-shadow-2); + } + + .embed-card-style-menu-container > icon-button { + padding: var(--1, 0px); + } + + .embed-card-style-menu-container > icon-button.selected { + border: 1px solid var(--affine-brand-color); + } + `; + + private _setEmbedCardStyle(style: EmbedCardStyle) { + this.model.doc.updateBlock(this.model, { style }); + this.requestUpdate(); + this.abortController.abort(); + } + + override render() { + const { EmbedCardHorizontalIcon, EmbedCardListIcon } = getEmbedCardIcons( + this.theme + ); + return html` + <div class="embed-card-style-menu"> + <div + class="embed-card-style-menu-container" + @pointerdown=${(e: MouseEvent) => e.stopPropagation()} + > + <icon-button + width="76px" + height="76px" + class=${classMap({ + selected: this.model.style === 'horizontal', + 'card-style-button-horizontal': true, + })} + @click=${() => this._setEmbedCardStyle('horizontal')} + > + ${EmbedCardHorizontalIcon} + <affine-tooltip .offset=${4} + >${'Large horizontal style'}</affine-tooltip + > + </icon-button> + + <icon-button + width="76px" + height="76px" + class=${classMap({ + selected: this.model.style === 'list', + 'card-style-button-list': true, + })} + @click=${() => this._setEmbedCardStyle('list')} + > + ${EmbedCardListIcon} + <affine-tooltip .offset=${4} + >${'Small horizontal style'}</affine-tooltip + > + </icon-button> + </div> + </div> + `; + } + + @property({ attribute: false }) + accessor abortController!: AbortController; + + @property({ attribute: false }) + accessor model!: BookmarkBlockModel | EmbedGithubModel | EmbedLinkedDocModel; + + @property({ attribute: false }) + accessor theme!: ColorScheme; +} + +declare global { + interface HTMLElementTagNameMap { + 'embed-card-style-menu': EmbedCardStyleMenu; + } +} diff --git a/blocksuite/blocks/src/_common/components/embed-card/modal/embed-card-caption-edit-modal.ts b/blocksuite/blocks/src/_common/components/embed-card/modal/embed-card-caption-edit-modal.ts new file mode 100644 index 0000000000..c5f776f1ec --- /dev/null +++ b/blocksuite/blocks/src/_common/components/embed-card/modal/embed-card-caption-edit-modal.ts @@ -0,0 +1,101 @@ +import { type BlockComponent, ShadowlessElement } from '@blocksuite/block-std'; +import { WithDisposable } from '@blocksuite/global/utils'; +import type { BlockModel } from '@blocksuite/store'; +import { html } from 'lit'; +import { property, query } from 'lit/decorators.js'; +import { classMap } from 'lit/directives/class-map.js'; + +import { embedCardModalStyles } from './styles.js'; + +export class EmbedCardEditCaptionEditModal extends WithDisposable( + ShadowlessElement +) { + static override styles = embedCardModalStyles; + + private get _doc() { + return this.block.doc; + } + + private get _model() { + return this.block.model as BlockModel<{ caption: string }>; + } + + private _onKeydown(e: KeyboardEvent) { + e.stopPropagation(); + if (e.key === 'Enter' && !e.isComposing) { + this._onSave(); + } + if (e.key === 'Escape') { + this.remove(); + } + } + + private _onSave() { + const caption = this.captionInput.value; + this._doc.updateBlock(this._model, { + caption, + }); + this.remove(); + } + + override connectedCallback() { + super.connectedCallback(); + + this.updateComplete + .then(() => { + this.captionInput.focus(); + }) + .catch(console.error); + + this.disposables.addFromEvent(this, 'keydown', this._onKeydown); + } + + override render() { + return html` + <div class="embed-card-modal"> + <div class="embed-card-modal-mask" @click=${() => this.remove()}></div> + <div class="embed-card-modal-wrapper"> + <div class="embed-card-modal-row"> + <label for="card-title">Caption</label> + <textarea + class="embed-card-modal-input caption" + placeholder="Write a caption..." + .value=${this._model.caption ?? ''} + ></textarea> + </div> + <div class="embed-card-modal-row"> + <button + class=${classMap({ + 'embed-card-modal-button': true, + save: true, + })} + @click=${() => this._onSave()} + > + Save + </button> + </div> + </div> + </div> + `; + } + + @property({ attribute: false }) + accessor block!: BlockComponent; + + @query('.embed-card-modal-input.caption') + accessor captionInput!: HTMLTextAreaElement; +} + +export function toggleEmbedCardCaptionEditModal(block: BlockComponent) { + const host = block.host; + host.selection.clear(); + const embedCardEditCaptionEditModal = new EmbedCardEditCaptionEditModal(); + embedCardEditCaptionEditModal.block = block; + document.body.append(embedCardEditCaptionEditModal); +} + +declare global { + interface HTMLElementTagNameMap { + 'embed-card-caption-edit-modal': EmbedCardEditCaptionEditModal; + } +} diff --git a/blocksuite/blocks/src/_common/components/embed-card/modal/embed-card-create-modal.ts b/blocksuite/blocks/src/_common/components/embed-card/modal/embed-card-create-modal.ts new file mode 100644 index 0000000000..54da070ca6 --- /dev/null +++ b/blocksuite/blocks/src/_common/components/embed-card/modal/embed-card-create-modal.ts @@ -0,0 +1,226 @@ +import { toast } from '@blocksuite/affine-components/toast'; +import { EmbedOptionProvider } from '@blocksuite/affine-shared/services'; +import type { EditorHost } from '@blocksuite/block-std'; +import { ShadowlessElement } from '@blocksuite/block-std'; +import { + assertExists, + Bound, + Vec, + WithDisposable, +} from '@blocksuite/global/utils'; +import type { BlockModel } from '@blocksuite/store'; +import { html } from 'lit'; +import { property, query, state } from 'lit/decorators.js'; +import { classMap } from 'lit/directives/class-map.js'; + +import type { EdgelessRootBlockComponent } from '../../../../root-block/edgeless/edgeless-root-block.js'; +import { EMBED_CARD_HEIGHT, EMBED_CARD_WIDTH } from '../../../consts.js'; +import type { EmbedCardStyle } from '../../../types.js'; +import { getRootByEditorHost, isValidUrl } from '../../../utils/index.js'; +import { embedCardModalStyles } from './styles.js'; + +export class EmbedCardCreateModal extends WithDisposable(ShadowlessElement) { + static override styles = embedCardModalStyles; + + private _onCancel = () => { + this.remove(); + }; + + private _onConfirm = () => { + const url = this.input.value; + + if (!isValidUrl(url)) { + toast(this.host, 'Invalid link'); + return; + } + + const embedOptions = this.host.std + .get(EmbedOptionProvider) + .getEmbedBlockOptions(url); + + const { mode } = this.createOptions; + if (mode === 'page') { + const { parentModel, index } = this.createOptions; + let flavour = 'affine:bookmark'; + + if (embedOptions) { + flavour = embedOptions.flavour; + } + + this.host.doc.addBlock( + flavour as never, + { + url, + }, + parentModel, + index + ); + } else if (mode === 'edgeless') { + let flavour = 'affine:bookmark', + targetStyle: EmbedCardStyle = 'vertical'; + + if (embedOptions) { + flavour = embedOptions.flavour; + targetStyle = embedOptions.styles[0]; + } + + const edgelessRoot = getRootByEditorHost( + this.host + ) as EdgelessRootBlockComponent | null; + assertExists(edgelessRoot); + + const surface = edgelessRoot.surface; + const center = Vec.toVec(surface.renderer.viewport.center); + edgelessRoot.service.addBlock( + flavour, + { + url, + xywh: Bound.fromCenter( + center, + EMBED_CARD_WIDTH[targetStyle], + EMBED_CARD_HEIGHT[targetStyle] + ).serialize(), + style: targetStyle, + }, + surface.model + ); + + edgelessRoot.gfx.tool.setTool('default'); + } + this.onConfirm(); + this.remove(); + }; + + private _onDocumentKeydown = (e: KeyboardEvent) => { + e.stopPropagation(); + if (e.key === 'Enter' && !e.isComposing) { + this._onConfirm(); + } + if (e.key === 'Escape') { + this.remove(); + } + }; + + private _handleInput(e: InputEvent) { + const target = e.target as HTMLInputElement; + this._linkInputValue = target.value; + } + + override connectedCallback() { + super.connectedCallback(); + + this.updateComplete + .then(() => { + requestAnimationFrame(() => { + this.input.focus(); + }); + }) + .catch(console.error); + this.disposables.addFromEvent(this, 'keydown', this._onDocumentKeydown); + } + + override render() { + return html`<div class="embed-card-modal"> + <div class="embed-card-modal-mask" @click=${this._onCancel}></div> + <div class="embed-card-modal-wrapper"> + <div class="embed-card-modal-row"> + <div class="embed-card-modal-title">${this.titleText}</div> + </div> + + <div class="embed-card-modal-row"> + <div class="embed-card-modal-description"> + ${this.descriptionText} + </div> + </div> + + <div class="embed-card-modal-row"> + <input + class="embed-card-modal-input link" + id="card-description" + type="text" + placeholder="Input in https://..." + value=${this._linkInputValue} + @input=${this._handleInput} + /> + </div> + + <div class="embed-card-modal-row"> + <button + class=${classMap({ + 'embed-card-modal-button': true, + save: true, + })} + ?disabled=${!isValidUrl(this._linkInputValue)} + @click=${this._onConfirm} + > + Confirm + </button> + </div> + </div> + </div>`; + } + + @state() + private accessor _linkInputValue = ''; + + @property({ attribute: false }) + accessor createOptions!: + | { + mode: 'page'; + parentModel: BlockModel | string; + index?: number; + } + | { + mode: 'edgeless'; + }; + + @property({ attribute: false }) + accessor descriptionText!: string; + + @property({ attribute: false }) + accessor host!: EditorHost; + + @query('input') + accessor input!: HTMLInputElement; + + @property({ attribute: false }) + accessor onConfirm!: () => void; + + @property({ attribute: false }) + accessor titleText!: string; +} + +export async function toggleEmbedCardCreateModal( + host: EditorHost, + titleText: string, + descriptionText: string, + createOptions: + | { + mode: 'page'; + parentModel: BlockModel | string; + index?: number; + } + | { + mode: 'edgeless'; + } +): Promise<void> { + host.selection.clear(); + + const embedCardCreateModal = new EmbedCardCreateModal(); + embedCardCreateModal.host = host; + embedCardCreateModal.titleText = titleText; + embedCardCreateModal.descriptionText = descriptionText; + embedCardCreateModal.createOptions = createOptions; + + document.body.append(embedCardCreateModal); + + return new Promise(resolve => { + embedCardCreateModal.onConfirm = () => resolve(); + }); +} + +declare global { + interface HTMLElementTagNameMap { + 'embed-card-create-modal': EmbedCardCreateModal; + } +} diff --git a/blocksuite/blocks/src/_common/components/embed-card/modal/embed-card-edit-modal.ts b/blocksuite/blocks/src/_common/components/embed-card/modal/embed-card-edit-modal.ts new file mode 100644 index 0000000000..c4639d0ba4 --- /dev/null +++ b/blocksuite/blocks/src/_common/components/embed-card/modal/embed-card-edit-modal.ts @@ -0,0 +1,450 @@ +import { + EmbedLinkedDocBlockComponent, + EmbedSyncedDocBlockComponent, +} from '@blocksuite/affine-block-embed'; +import { + notifyLinkedDocClearedAliases, + notifyLinkedDocSwitchedToCard, +} from '@blocksuite/affine-components/notification'; +import { toast } from '@blocksuite/affine-components/toast'; +import type { AliasInfo } from '@blocksuite/affine-model'; +import { + EmbedLinkedDocModel, + EmbedSyncedDocModel, +} from '@blocksuite/affine-model'; +import { + type LinkEventType, + type TelemetryEvent, + TelemetryProvider, +} from '@blocksuite/affine-shared/services'; +import { FONT_SM, FONT_XS } from '@blocksuite/affine-shared/styles'; +import { unsafeCSSVarV2 } from '@blocksuite/affine-shared/theme'; +import { + listenClickAway, + stopPropagation, +} from '@blocksuite/affine-shared/utils'; +import type { + BlockComponent, + BlockStdScope, + EditorHost, +} from '@blocksuite/block-std'; +import { SignalWatcher, WithDisposable } from '@blocksuite/global/utils'; +import { autoUpdate, computePosition, flip, offset } from '@floating-ui/dom'; +import { computed, signal } from '@preact/signals-core'; +import { css, html, LitElement } from 'lit'; +import { property, query } from 'lit/decorators.js'; +import { choose } from 'lit/directives/choose.js'; +import { classMap } from 'lit/directives/class-map.js'; +import { live } from 'lit/directives/live.js'; + +import type { LinkableEmbedModel } from '../type.js'; +import { isInternalEmbedModel } from '../type.js'; + +export class EmbedCardEditModal extends SignalWatcher( + WithDisposable(LitElement) +) { + static override styles = css` + :host { + position: absolute; + top: 0; + left: 0; + z-index: var(--affine-z-index-popover); + animation: affine-popover-fade-in 0.2s ease; + } + + @keyframes affine-popover-fade-in { + from { + opacity: 0; + transform: translateY(-3px); + } + to { + opacity: 1; + transform: translateY(0); + } + } + + .embed-card-modal-wrapper { + display: flex; + padding: 12px; + flex-direction: column; + justify-content: flex-end; + align-items: flex-start; + gap: 12px; + width: 421px; + + color: var(--affine-icon-color); + box-shadow: var(--affine-overlay-shadow); + background: ${unsafeCSSVarV2('layer/background/overlayPanel')}; + border-radius: 4px; + border: 0.5px solid ${unsafeCSSVarV2('layer/insideBorder/border')}; + } + + .row { + width: 100%; + display: flex; + align-items: center; + gap: 12px; + } + + .row .input { + display: flex; + padding: 4px 10px; + width: 100%; + min-width: 100%; + box-sizing: border-box; + border-radius: 4px; + user-select: none; + background: transparent; + border: 1px solid ${unsafeCSSVarV2('input/border/default')}; + color: var(--affine-text-primary-color); + ${FONT_SM}; + } + .input::placeholder { + color: var(--affine-placeholder-color); + } + .input:focus { + border-color: ${unsafeCSSVarV2('input/border/active')}; + outline: none; + } + + textarea.input { + min-height: 80px; + resize: none; + } + + .row.actions { + justify-content: flex-end; + } + + .row.actions .button { + display: flex; + padding: 4px 12px; + align-items: center; + gap: 4px; + border-radius: 4px; + border: 1px solid ${unsafeCSSVarV2('button/innerBlackBorder')}; + background: ${unsafeCSSVarV2('button/secondary')}; + ${FONT_XS}; + color: ${unsafeCSSVarV2('text/primary')}; + } + .row.actions .button[disabled], + .row.actions .button:disabled { + pointer-events: none; + color: ${unsafeCSSVarV2('text/disable')}; + } + .row.actions .button.save { + color: ${unsafeCSSVarV2('button/pureWhiteText')}; + background: ${unsafeCSSVarV2('button/primary')}; + } + .row.actions .button[disabled].save, + .row.actions .button:disabled.save { + opacity: 0.5; + } + `; + + private _blockComponent: BlockComponent | null = null; + + private _hide = () => { + this.remove(); + }; + + private _onKeydown = (e: KeyboardEvent) => { + e.stopPropagation(); + if (e.key === 'Enter' && !(e.isComposing || e.shiftKey)) { + this._onSave(); + } + if (e.key === 'Escape') { + e.preventDefault(); + this.remove(); + } + }; + + private _onReset = () => { + const blockComponent = this._blockComponent; + + if (!blockComponent) { + this.remove(); + return; + } + + const std = blockComponent.std; + + this.model.doc.updateBlock(this.model, { title: null, description: null }); + + if ( + this.isEmbedLinkedDocModel && + blockComponent instanceof EmbedLinkedDocBlockComponent + ) { + blockComponent.refreshData(); + + notifyLinkedDocClearedAliases(std); + } + blockComponent.requestUpdate(); + + track(std, this.model, this.viewType, 'ResetedAlias', { control: 'reset' }); + + this.remove(); + }; + + private _onSave = () => { + const blockComponent = this._blockComponent; + + if (!blockComponent) { + this.remove(); + return; + } + + const title = this.title$.value.trim(); + if (title.length === 0) { + toast(this.host, 'Title can not be empty'); + return; + } + + const std = blockComponent.std; + + const description = this.description$.value.trim(); + + const props: AliasInfo = { title }; + if (description) props.description = description; + + if ( + this.isEmbedSyncedDocModel && + blockComponent instanceof EmbedSyncedDocBlockComponent + ) { + blockComponent.convertToCard(props); + + notifyLinkedDocSwitchedToCard(std); + } else { + this.model.doc.updateBlock(this.model, props); + blockComponent.requestUpdate(); + } + + track(std, this.model, this.viewType, 'SavedAlias', { control: 'save' }); + + this.remove(); + }; + + private _updateDescription = (e: InputEvent) => { + const target = e.target as HTMLTextAreaElement; + this.description$.value = target.value; + }; + + private _updateTitle = (e: InputEvent) => { + const target = e.target as HTMLInputElement; + this.title$.value = target.value; + }; + + get isEmbedLinkedDocModel() { + return this.model instanceof EmbedLinkedDocModel; + } + + get isEmbedSyncedDocModel() { + return this.model instanceof EmbedSyncedDocModel; + } + + get isInternalEmbedModel() { + return isInternalEmbedModel(this.model); + } + + get modelType(): 'linked' | 'synced' | null { + if (this.isEmbedLinkedDocModel) return 'linked'; + if (this.isEmbedSyncedDocModel) return 'synced'; + return null; + } + + get placeholders() { + if (this.isInternalEmbedModel) { + return { + title: 'Add title alias', + description: + 'Add description alias (empty to inherit document content)', + }; + } + + return { + title: 'Write a title', + description: 'Write a description...', + }; + } + + private _updateInfo() { + const title = this.model.title || this.originalDocInfo?.title || ''; + const description = + this.model.description || this.originalDocInfo?.description || ''; + + this.title$.value = title; + this.description$.value = description; + } + + override connectedCallback() { + super.connectedCallback(); + + this._updateInfo(); + } + + override firstUpdated() { + const blockComponent = this.host.std.view.getBlock(this.model.id); + if (!blockComponent) return; + + this._blockComponent = blockComponent; + + this.disposables.add( + autoUpdate(blockComponent, this, () => { + computePosition(blockComponent, this, { + placement: 'top-start', + middleware: [flip(), offset(8)], + }) + .then(({ x, y }) => { + this.style.left = `${x}px`; + this.style.top = `${y}px`; + }) + .catch(console.error); + }) + ); + + this.disposables.add(listenClickAway(this, this._hide)); + this.disposables.addFromEvent(this, 'keydown', this._onKeydown); + this.disposables.addFromEvent(this, 'pointerdown', stopPropagation); + + this.titleInput.focus(); + this.titleInput.select(); + } + + override render() { + return html` + <div class="embed-card-modal-wrapper"> + <div class="row"> + <input + class="input title" + type="text" + placeholder=${this.placeholders.title} + .value=${live(this.title$.value)} + @input=${this._updateTitle} + /> + </div> + <div class="row"> + <textarea + class="input description" + maxlength="500" + placeholder=${this.placeholders.description} + .value=${live(this.description$.value)} + @input=${this._updateDescription} + ></textarea> + </div> + <div class="row actions"> + ${choose(this.modelType, [ + [ + 'linked', + () => html` + <button + class=${classMap({ + button: true, + reset: true, + })} + .disabled=${this.resetButtonDisabled$.value} + @click=${this._onReset} + > + Reset + </button> + `, + ], + [ + 'synced', + () => html` + <button + class=${classMap({ + button: true, + cancel: true, + })} + @click=${this._hide} + > + Cancel + </button> + `, + ], + ])} + <button + class=${classMap({ + button: true, + save: true, + })} + .disabled=${this.saveButtonDisabled$.value} + @click=${this._onSave} + > + Save + </button> + </div> + </div> + `; + } + + accessor description$ = signal<string>(''); + + @property({ attribute: false }) + accessor host!: EditorHost; + + @property({ attribute: false }) + accessor model!: LinkableEmbedModel; + + @property({ attribute: false }) + accessor originalDocInfo: AliasInfo | undefined = undefined; + + accessor resetButtonDisabled$ = computed<boolean>( + () => + !( + Boolean(this.model.title$.value?.length) || + Boolean(this.model.description$.value?.length) + ) + ); + + accessor saveButtonDisabled$ = computed<boolean>( + () => this.title$.value.trim().length === 0 + ); + + accessor title$ = signal<string>(''); + + @query('.input.title') + accessor titleInput!: HTMLInputElement; + + @property({ attribute: false }) + accessor viewType!: string; +} + +export function toggleEmbedCardEditModal( + host: EditorHost, + embedCardModel: LinkableEmbedModel, + viewType: string, + originalDocInfo?: AliasInfo +) { + document.body.querySelector('embed-card-edit-modal')?.remove(); + + const embedCardEditModal = new EmbedCardEditModal(); + embedCardEditModal.model = embedCardModel; + embedCardEditModal.host = host; + embedCardEditModal.viewType = viewType; + embedCardEditModal.originalDocInfo = originalDocInfo; + document.body.append(embedCardEditModal); +} + +declare global { + interface HTMLElementTagNameMap { + 'embed-card-edit-modal': EmbedCardEditModal; + } +} + +function track( + std: BlockStdScope, + model: LinkableEmbedModel, + viewType: string, + event: LinkEventType, + props: Partial<TelemetryEvent> +) { + std.getOptional(TelemetryProvider)?.track(event, { + segment: 'toolbar', + page: 'doc editor', + module: 'embed card edit popup', + type: `${viewType} view`, + category: isInternalEmbedModel(model) ? 'linked doc' : 'link', + ...props, + }); +} diff --git a/blocksuite/blocks/src/_common/components/embed-card/modal/index.ts b/blocksuite/blocks/src/_common/components/embed-card/modal/index.ts new file mode 100644 index 0000000000..df177666cf --- /dev/null +++ b/blocksuite/blocks/src/_common/components/embed-card/modal/index.ts @@ -0,0 +1,2 @@ +export * from './embed-card-create-modal.js'; +export * from './embed-card-edit-modal.js'; diff --git a/blocksuite/blocks/src/_common/components/embed-card/modal/styles.ts b/blocksuite/blocks/src/_common/components/embed-card/modal/styles.ts new file mode 100644 index 0000000000..98b17e52f3 --- /dev/null +++ b/blocksuite/blocks/src/_common/components/embed-card/modal/styles.ts @@ -0,0 +1,120 @@ +import { FONT_XS, PANEL_BASE } from '@blocksuite/affine-shared/styles'; +import { css } from 'lit'; + +export const embedCardModalStyles = css` + .embed-card-modal-mask { + position: absolute; + left: 0; + right: 0; + top: 0; + bottom: 0; + margin: auto; + z-index: 1; + } + + .embed-card-modal-wrapper { + ${PANEL_BASE}; + flex-direction: column; + position: absolute; + left: 0; + right: 0; + top: 0; + bottom: 0; + margin: auto; + z-index: 2; + width: 305px; + height: max-content; + padding: 12px; + gap: 12px; + border-radius: 8px; + font-size: var(--affine-font-xs); + line-height: 20px; + } + + .embed-card-modal-row { + display: flex; + flex-direction: column; + align-self: stretch; + } + + .embed-card-modal-row label { + padding: 0px 2px; + color: var(--affine-text-secondary-color); + font-weight: 600; + } + .embed-card-modal-input { + display: flex; + padding-left: 10px; + padding-right: 10px; + border-radius: 8px; + border: 1px solid var(--affine-border-color); + background: var(--affine-white-10); + color: var(--affine-text-primary-color); + ${FONT_XS}; + } + input.embed-card-modal-input { + padding-top: 4px; + padding-bottom: 4px; + } + textarea.embed-card-modal-input { + padding-top: 6px; + padding-bottom: 6px; + min-width: 100%; + max-width: 100%; + } + .embed-card-modal-input:focus { + border-color: var(--affine-blue-700); + box-shadow: var(--affine-active-shadow); + outline: none; + } + .embed-card-modal-input::placeholder { + color: var(--affine-placeholder-color); + } + + .embed-card-modal-row:has(.embed-card-modal-button) { + flex-direction: row; + gap: 4px; + justify-content: flex-end; + } + .embed-card-modal-row:has(.embed-card-modal-button.reset) { + justify-content: space-between; + } + + .embed-card-modal-button { + padding: 4px 18px; + border-radius: 8px; + box-sizing: border-box; + } + .embed-card-modal-button.save { + border: 1px solid var(--affine-black-10); + background: var(--affine-primary-color); + color: var(--affine-pure-white); + } + .embed-card-modal-button[disabled] { + pointer-events: none; + cursor: not-allowed; + color: var(--affine-text-disable-color); + background: transparent; + } + .embed-card-modal-button.reset { + padding: 4px 0; + border: none; + background: transparent; + text-decoration: underline; + color: var(--affine-secondary-color); + user-select: none; + } + + .embed-card-modal-title { + font-size: 18px; + font-weight: 600; + line-height: 26px; + user-select: none; + } + .embed-card-modal-description { + font-size: 15px; + font-weight: 500; + line-height: 24px; + user-select: none; + } +`; diff --git a/blocksuite/blocks/src/_common/components/embed-card/type.ts b/blocksuite/blocks/src/_common/components/embed-card/type.ts new file mode 100644 index 0000000000..80e57c1114 --- /dev/null +++ b/blocksuite/blocks/src/_common/components/embed-card/type.ts @@ -0,0 +1,79 @@ +import { + EmbedFigmaBlockComponent, + EmbedGithubBlockComponent, + EmbedHtmlBlockComponent, + EmbedLinkedDocBlockComponent, + EmbedLoomBlockComponent, + EmbedSyncedDocBlockComponent, + EmbedYoutubeBlockComponent, +} from '@blocksuite/affine-block-embed'; +import type { + BookmarkBlockModel, + EmbedFigmaModel, + EmbedGithubModel, + EmbedHtmlModel, + EmbedLoomModel, + EmbedYoutubeModel, +} from '@blocksuite/affine-model'; +import { + EmbedLinkedDocModel, + EmbedSyncedDocModel, +} from '@blocksuite/affine-model'; +import type { BlockComponent } from '@blocksuite/block-std'; + +import { BookmarkBlockComponent } from '../../../bookmark-block/bookmark-block.js'; + +export type ExternalEmbedBlockComponent = + | BookmarkBlockComponent + | EmbedFigmaBlockComponent + | EmbedGithubBlockComponent + | EmbedLoomBlockComponent + | EmbedYoutubeBlockComponent; + +export type InternalEmbedBlockComponent = + | EmbedLinkedDocBlockComponent + | EmbedSyncedDocBlockComponent; + +export type LinkableEmbedBlockComponent = + | ExternalEmbedBlockComponent + | InternalEmbedBlockComponent; + +export type EmbedBlockComponent = + | LinkableEmbedBlockComponent + | EmbedHtmlBlockComponent; + +export type ExternalEmbedModel = + | BookmarkBlockModel + | EmbedFigmaModel + | EmbedGithubModel + | EmbedLoomModel + | EmbedYoutubeModel; + +export type InternalEmbedModel = EmbedLinkedDocModel | EmbedSyncedDocModel; + +export type LinkableEmbedModel = ExternalEmbedModel | InternalEmbedModel; + +export type EmbedModel = LinkableEmbedModel | EmbedHtmlModel; + +export function isEmbedCardBlockComponent( + block: BlockComponent +): block is EmbedBlockComponent { + return ( + block instanceof BookmarkBlockComponent || + block instanceof EmbedFigmaBlockComponent || + block instanceof EmbedGithubBlockComponent || + block instanceof EmbedHtmlBlockComponent || + block instanceof EmbedLoomBlockComponent || + block instanceof EmbedYoutubeBlockComponent || + block instanceof EmbedLinkedDocBlockComponent || + block instanceof EmbedSyncedDocBlockComponent + ); +} + +export function isInternalEmbedModel( + model: EmbedModel +): model is InternalEmbedModel { + return ( + model instanceof EmbedLinkedDocModel || model instanceof EmbedSyncedDocModel + ); +} diff --git a/blocksuite/blocks/src/_common/components/file-drop-manager.ts b/blocksuite/blocks/src/_common/components/file-drop-manager.ts new file mode 100644 index 0000000000..1acb9b24e9 --- /dev/null +++ b/blocksuite/blocks/src/_common/components/file-drop-manager.ts @@ -0,0 +1,173 @@ +import type { DragIndicator } from '@blocksuite/affine-components/drag-indicator'; +import { + getClosestBlockComponentByPoint, + isInsidePageEditor, + matchFlavours, +} from '@blocksuite/affine-shared/utils'; +import type { BlockService, EditorHost } from '@blocksuite/block-std'; +import type { IVec } from '@blocksuite/global/utils'; +import { assertExists, Point } from '@blocksuite/global/utils'; +import type { BlockModel } from '@blocksuite/store'; + +import { calcDropTarget, type DropResult } from '../../_common/utils/index.js'; + +export type onDropProps = { + files: File[]; + targetModel: BlockModel | null; + place: 'before' | 'after'; + point: IVec; +}; + +export type FileDropOptions = { + flavour: string; + onDrop?: ({ + files, + targetModel, + place, + point, + }: onDropProps) => Promise<boolean> | void; +}; + +export class FileDropManager { + private static _dropResult: DropResult | null = null; + + private _blockService: BlockService; + + private _fileDropOptions: FileDropOptions; + + private _indicator!: DragIndicator; + + private _onDrop = (event: DragEvent) => { + this._indicator.rect = null; + + const { onDrop } = this._fileDropOptions; + if (!onDrop) return; + + const dataTransfer = event.dataTransfer; + if (!dataTransfer) return; + + const effectAllowed = dataTransfer.effectAllowed; + if (effectAllowed === 'none') return; + + const droppedFiles = dataTransfer.files; + if (!droppedFiles || !droppedFiles.length) return; + + event.preventDefault(); + + const { targetModel, type: place } = this; + const { x, y } = event; + + onDrop({ + files: [...droppedFiles], + targetModel, + place, + point: [x, y], + })?.catch(console.error); + }; + + onDragLeave = () => { + FileDropManager._dropResult = null; + this._indicator.rect = null; + }; + + onDragOver = (event: DragEvent) => { + event.preventDefault(); + + const dataTransfer = event.dataTransfer; + if (!dataTransfer) return; + + const effectAllowed = dataTransfer.effectAllowed; + if (effectAllowed === 'none') return; + + const { clientX, clientY } = event; + const point = new Point(clientX, clientY); + const element = getClosestBlockComponentByPoint(point.clone()); + + let result: DropResult | null = null; + if (element) { + const model = element.model; + const parent = this.doc.getParent(model); + if (!matchFlavours(parent, ['affine:surface'])) { + result = calcDropTarget(point, model, element); + } + } + if (result) { + FileDropManager._dropResult = result; + this._indicator.rect = result.rect; + } else { + FileDropManager._dropResult = null; + this._indicator.rect = null; + } + }; + + get doc() { + return this._blockService.doc; + } + + get editorHost(): EditorHost { + return this._blockService.std.host; + } + + get targetModel(): BlockModel | null { + let targetModel = FileDropManager._dropResult?.modelState.model || null; + + if (!targetModel && isInsidePageEditor(this.editorHost)) { + const rootModel = this.doc.root; + assertExists(rootModel); + + let lastNote = rootModel.children[rootModel.children.length - 1]; + if (!lastNote || !matchFlavours(lastNote, ['affine:note'])) { + const newNoteId = this.doc.addBlock('affine:note', {}, rootModel.id); + const newNote = this.doc.getBlockById(newNoteId); + assertExists(newNote); + lastNote = newNote; + } + + const lastItem = lastNote.children[lastNote.children.length - 1]; + if (lastItem) { + targetModel = lastItem; + } else { + const newParagraphId = this.doc.addBlock( + 'affine:paragraph', + {}, + lastNote, + 0 + ); + const newParagraph = this.doc.getBlockById(newParagraphId); + assertExists(newParagraph); + targetModel = newParagraph; + } + } + return targetModel; + } + + get type(): 'before' | 'after' { + return !FileDropManager._dropResult || + FileDropManager._dropResult.type !== 'before' + ? 'after' + : 'before'; + } + + constructor(blockService: BlockService, fileDropOptions: FileDropOptions) { + this._blockService = blockService; + this._fileDropOptions = fileDropOptions; + + this._indicator = document.querySelector( + 'affine-drag-indicator' + ) as DragIndicator; + if (!this._indicator) { + this._indicator = document.createElement( + 'affine-drag-indicator' + ) as DragIndicator; + document.body.append(this._indicator); + } + + if (fileDropOptions.onDrop) { + this._blockService.disposables.addFromEvent( + this._blockService.std.host, + 'drop', + this._onDrop + ); + } + } +} diff --git a/blocksuite/blocks/src/_common/components/filterable-list/index.ts b/blocksuite/blocks/src/_common/components/filterable-list/index.ts new file mode 100644 index 0000000000..139145cd83 --- /dev/null +++ b/blocksuite/blocks/src/_common/components/filterable-list/index.ts @@ -0,0 +1,260 @@ +import { + type AdvancedPortalOptions, + createLitPortal, +} from '@blocksuite/affine-components/portal'; +import { WithDisposable } from '@blocksuite/global/utils'; +import { DoneIcon, SearchIcon } from '@blocksuite/icons/lit'; +import { autoPlacement, offset, type Placement, size } from '@floating-ui/dom'; +import { html, LitElement, nothing } from 'lit'; +import { property, query, state } from 'lit/decorators.js'; +import { classMap } from 'lit/directives/class-map.js'; + +import { PAGE_HEADER_HEIGHT } from '../../consts.js'; +import { filterableListStyles } from './styles.js'; +import type { FilterableListItem, FilterableListOptions } from './types.js'; + +export * from './types.js'; + +export class FilterableListComponent<Props = unknown> extends WithDisposable( + LitElement +) { + static override styles = filterableListStyles; + + private _buildContent(items: FilterableListItem<Props>[]) { + return items.map((item, idx) => { + const focussed = this._curFocusIndex === idx; + + return html` + <icon-button + class=${classMap({ + 'filterable-item': true, + focussed, + })} + @mouseover=${() => (this._curFocusIndex = idx)} + @click=${() => this._select(item)} + hover=${focussed} + width="100%" + height="32px" + > + ${item.icon ?? nothing} ${item.label ?? item.name} + <div slot="suffix"> + ${this.options.active?.(item) ? DoneIcon() : nothing} + </div> + </icon-button> + `; + }); + } + + private _filterItems() { + const searchFilter = !this._filterText + ? this.options.items + : this.options.items.filter( + item => + item.name.startsWith(this._filterText.toLowerCase()) || + item.aliases?.some(alias => + alias.startsWith(this._filterText.toLowerCase()) + ) + ); + return searchFilter.sort((a, b) => { + const isActiveA = this.options.active?.(a); + const isActiveB = this.options.active?.(b); + + if (isActiveA && !isActiveB) return -1; + if (!isActiveA && isActiveB) return 1; + + return this.listFilter?.(a, b) ?? 0; + }); + } + + private _scrollFocusedItemIntoView() { + this.updateComplete + .then(() => { + this._focussedItem?.scrollIntoView({ + block: 'nearest', + inline: 'start', + }); + }) + .catch(console.error); + } + + private _select(item: FilterableListItem) { + this.abortController?.abort(); + this.options.onSelect(item); + } + + override connectedCallback() { + super.connectedCallback(); + requestAnimationFrame(() => { + this._filterInput.focus(); + }); + } + + override render() { + const filteredItems = this._filterItems(); + const content = this._buildContent(filteredItems); + const isFlip = !!this.placement?.startsWith('top'); + + const _handleInputKeydown = (ev: KeyboardEvent) => { + switch (ev.key) { + case 'ArrowUp': { + ev.preventDefault(); + this._curFocusIndex = + (this._curFocusIndex + content.length - 1) % content.length; + this._scrollFocusedItemIntoView(); + break; + } + case 'ArrowDown': { + ev.preventDefault(); + this._curFocusIndex = (this._curFocusIndex + 1) % content.length; + this._scrollFocusedItemIntoView(); + break; + } + case 'Enter': { + if (ev.isComposing) break; + ev.preventDefault(); + const item = filteredItems[this._curFocusIndex]; + this._select(item); + break; + } + case 'Escape': { + ev.preventDefault(); + this.abortController?.abort(); + break; + } + } + }; + + return html` + <div + class=${classMap({ 'affine-filterable-list': true, flipped: isFlip })} + > + <div class="input-wrapper"> + ${SearchIcon()} + <input + id="filter-input" + type="text" + placeholder=${this.options?.placeholder ?? 'Search'} + @input="${() => { + this._filterText = this._filterInput?.value; + this._curFocusIndex = 0; + }}" + @keydown="${_handleInputKeydown}" + /> + </div> + + <editor-toolbar-separator + data-orientation="horizontal" + ></editor-toolbar-separator> + <div class="items-container">${content}</div> + </div> + `; + } + + @state() + private accessor _curFocusIndex = 0; + + @query('#filter-input') + private accessor _filterInput!: HTMLInputElement; + + @state() + private accessor _filterText = ''; + + @query('.filterable-item.focussed') + private accessor _focussedItem!: HTMLElement | null; + + @property({ attribute: false }) + accessor abortController: AbortController | null = null; + + @property({ attribute: false }) + accessor listFilter: + | ((a: FilterableListItem<Props>, b: FilterableListItem<Props>) => number) + | undefined = undefined; + + @property({ attribute: false }) + accessor options!: FilterableListOptions<Props>; + + @property({ attribute: false }) + accessor placement: Placement | undefined = undefined; +} + +export function showPopFilterableList({ + options, + filter, + abortController = new AbortController(), + referenceElement, + container, + maxHeight = 440, + portalStyles, +}: { + options: FilterableListComponent['options']; + referenceElement: Element; + container?: Element; + abortController?: AbortController; + filter?: FilterableListComponent['listFilter']; + maxHeight?: number; + portalStyles?: AdvancedPortalOptions['portalStyles']; +}) { + const portalPadding = { + top: PAGE_HEADER_HEIGHT + 12, + bottom: 12, + } as const; + + const list = new FilterableListComponent(); + list.options = options; + list.listFilter = filter; + list.abortController = abortController; + + createLitPortal({ + closeOnClickAway: true, + template: ({ positionSlot }) => { + positionSlot.on(({ placement }) => { + list.placement = placement; + }); + + return list; + }, + container, + portalStyles, + computePosition: { + referenceElement, + placement: 'bottom-start', + middleware: [ + offset(4), + autoPlacement({ + allowedPlacements: ['top-start', 'bottom-start'], + padding: portalPadding, + }), + size({ + padding: portalPadding, + apply({ availableHeight, elements, placement }) { + Object.assign(elements.floating.style, { + height: '100%', + maxHeight: `${Math.min(maxHeight, availableHeight)}px`, + pointerEvents: 'none', + ...(placement.startsWith('top') + ? { + display: 'flex', + alignItems: 'flex-end', + } + : { + display: null, + alignItems: null, + }), + }); + }, + }), + ], + autoUpdate: { + // fix the lang list position incorrectly when scrolling + animationFrame: true, + }, + }, + abortController, + }); +} + +declare global { + interface HTMLElementTagNameMap { + 'affine-filterable-list': FilterableListComponent; + } +} diff --git a/blocksuite/blocks/src/_common/components/filterable-list/styles.ts b/blocksuite/blocks/src/_common/components/filterable-list/styles.ts new file mode 100644 index 0000000000..b845e2d514 --- /dev/null +++ b/blocksuite/blocks/src/_common/components/filterable-list/styles.ts @@ -0,0 +1,109 @@ +import { PANEL_BASE } from '@blocksuite/affine-shared/styles'; +import { css } from 'lit'; + +import { scrollbarStyle } from '../utils.js'; + +export const filterableListStyles = css` + :host { + ${PANEL_BASE}; + + flex-direction: column; + padding: 0; + + max-height: 100%; + pointer-events: auto; + overflow: hidden; + z-index: var(--affine-z-index-popover); + } + + .affine-filterable-list { + display: flex; + flex-direction: column; + align-items: stretch; + justify-content: center; + width: 230px; + padding: 8px; + box-sizing: border-box; + overflow: hidden; + } + + .affine-filterable-list.flipped { + flex-direction: column-reverse; + } + + .items-container { + display: flex; + flex-direction: column; + gap: 4px; + flex: 1; + overflow-y: scroll; + padding-top: 5px; + padding-left: 4px; + padding-right: 4px; + } + + editor-toolbar-separator { + margin: 8px 0; + } + + .input-wrapper { + display: flex; + align-items: center; + border-radius: 4px; + padding: 4px 10px; + gap: 4px; + border-width: 1px; + border-style: solid; + border-color: transparent; + } + + .input-wrapper:focus-within { + border-color: var(--affine-blue-700); + box-shadow: var(--affine-active-shadow); + } + + ${scrollbarStyle('.items-container')} + + .filterable-item { + display: flex; + justify-content: space-between; + gap: 4px; + padding: 12px; + } + + .filterable-item > div[slot='suffix'] { + display: flex; + align-items: center; + } + + .filterable-item svg { + width: 20px; + height: 20px; + } + + .filterable-item.focussed { + color: var(--affine-blue-700); + background: var(--affine-hover-color-filled); + } + + #filter-input { + flex: 1; + align-items: center; + height: 20px; + width: 140px; + border-radius: 8px; + padding-top: 2px; + border: transparent; + background: transparent; + color: inherit; + } + + #filter-input:focus { + outline: none; + } + + #filter-input::placeholder { + color: var(--affine-placeholder-color); + font-size: var(--affine-font-sm); + } +`; diff --git a/blocksuite/blocks/src/_common/components/filterable-list/types.ts b/blocksuite/blocks/src/_common/components/filterable-list/types.ts new file mode 100644 index 0000000000..24b312818e --- /dev/null +++ b/blocksuite/blocks/src/_common/components/filterable-list/types.ts @@ -0,0 +1,18 @@ +import type { TemplateResult } from 'lit'; + +export type FilterableListItemKey = string; + +export interface FilterableListItem<Props = unknown> { + name: string; + label?: string; + icon?: TemplateResult; + aliases?: string[]; + props?: Props; +} + +export interface FilterableListOptions<Props = unknown> { + placeholder?: string; + items: FilterableListItem<Props>[]; + active?: (item: FilterableListItem) => boolean; + onSelect: (item: FilterableListItem) => void; +} diff --git a/blocksuite/blocks/src/_common/components/index.ts b/blocksuite/blocks/src/_common/components/index.ts new file mode 100644 index 0000000000..51fd439976 --- /dev/null +++ b/blocksuite/blocks/src/_common/components/index.ts @@ -0,0 +1,6 @@ +export * from './ai-item/index.js'; +export * from './block-selection.js'; +export * from './block-zero-width.js'; +export * from './file-drop-manager.js'; +export * from './menu-divider.js'; +export { scrollbarStyle } from './utils.js'; diff --git a/blocksuite/blocks/src/_common/components/loader.ts b/blocksuite/blocks/src/_common/components/loader.ts new file mode 100644 index 0000000000..bb86e47eda --- /dev/null +++ b/blocksuite/blocks/src/_common/components/loader.ts @@ -0,0 +1,103 @@ +import { BLOCK_ID_ATTR } from '@blocksuite/block-std'; +import type { BlockModel } from '@blocksuite/store'; +import { css, html, LitElement } from 'lit'; +import { property } from 'lit/decorators.js'; + +export class Loader extends LitElement { + static override styles = css` + .load-container { + margin: 10px auto; + width: var(--loader-width); + text-align: center; + } + + .load-container .load { + width: 8px; + height: 8px; + background-color: var(--affine-text-primary-color); + + border-radius: 100%; + display: inline-block; + -webkit-animation: bouncedelay 1.4s infinite ease-in-out; + animation: bouncedelay 1.4s infinite ease-in-out; + /* Prevent first note from flickering when animation starts */ + -webkit-animation-fill-mode: both; + animation-fill-mode: both; + } + .load-container .load1 { + -webkit-animation-delay: -0.32s; + animation-delay: -0.32s; + } + .load-container .load2 { + -webkit-animation-delay: -0.16s; + animation-delay: -0.16s; + } + + @-webkit-keyframes bouncedelay { + 0%, + 80%, + 100% { + -webkit-transform: scale(0.625); + } + 40% { + -webkit-transform: scale(1); + } + } + + @keyframes bouncedelay { + 0%, + 80%, + 100% { + transform: scale(0); + -webkit-transform: scale(0.625); + } + 40% { + transform: scale(1); + -webkit-transform: scale(1); + } + } + `; + + constructor() { + super(); + } + + override connectedCallback() { + super.connectedCallback(); + if (this.hostModel) { + this.setAttribute(BLOCK_ID_ATTR, this.hostModel.id); + this.dataset.serviceLoading = 'true'; + } + + const width = this.width; + this.style.setProperty( + '--loader-width', + typeof width === 'string' ? width : `${width}px` + ); + } + + override render() { + return html` + <div class="load-container"> + <div class="load load1"></div> + <div class="load load2"></div> + <div class="load"></div> + </div> + `; + } + + @property({ attribute: false }) + accessor hostModel: BlockModel | null = null; + + @property({ attribute: false }) + accessor radius: string | number = '8px'; + + @property({ attribute: false }) + accessor width: string | number = '150px'; +} + +declare global { + interface HTMLElementTagNameMap { + 'loader-element': Loader; + } +} diff --git a/blocksuite/blocks/src/_common/components/menu-divider.ts b/blocksuite/blocks/src/_common/components/menu-divider.ts new file mode 100644 index 0000000000..c9f0a4fdc8 --- /dev/null +++ b/blocksuite/blocks/src/_common/components/menu-divider.ts @@ -0,0 +1,50 @@ +import { css, html, LitElement } from 'lit'; +import { property } from 'lit/decorators.js'; +import { styleMap } from 'lit/directives/style-map.js'; + +// FIXME: horizontal +export class MenuDivider extends LitElement { + static override styles = css` + :host { + display: inline-block; + } + + .divider { + background-color: var(--affine-border-color); + } + + .divider.vertical { + width: 1px; + height: 100%; + margin: 0 var(--divider-margin); + } + + .divider.horizontal { + width: 100%; + height: 1px; + margin: var(--divider-margin) 0; + } + `; + + override render() { + const dividerStyles = styleMap({ + '--divider-margin': `${this.dividerMargin}px`, + }); + return html`<div + class="divider ${this.vertical ? 'vertical' : 'horizontal'}" + style=${dividerStyles} + ></div>`; + } + + @property({ attribute: false }) + accessor dividerMargin = 7; + + @property({ attribute: false }) + accessor vertical = false; +} + +declare global { + interface HTMLElementTagNameMap { + 'menu-divider': MenuDivider; + } +} diff --git a/blocksuite/blocks/src/_common/components/smooth-corner.ts b/blocksuite/blocks/src/_common/components/smooth-corner.ts new file mode 100644 index 0000000000..803237d471 --- /dev/null +++ b/blocksuite/blocks/src/_common/components/smooth-corner.ts @@ -0,0 +1,184 @@ +import { getFigmaSquircleSvgPath } from '@blocksuite/global/utils'; +import { css, html, LitElement, svg, type TemplateResult } from 'lit'; +import { property, state } from 'lit/decorators.js'; + +/** + * ### A component to use figma 'smoothing radius' + * + * ```html + * <smooth-corner + * .borderRadius=${10} + * .smooth=${0.5} + * .borderWidth=${2} + * .bgColor=${'white'} + * style="filter: drop-shadow(0px 0px 10px rgba(0, 0, 0, 0.1));" + * > + * <h1>Smooth Corner</h1> + * </smooth-corner> + * ``` + * + * **Just wrap your content with it.** + * - There is a ResizeObserver inside to observe the size of the content. + * - In order to use both border and shadow, we use svg to draw. + * - So we need to use `stroke` and `drop-shadow` to replace `border` and `box-shadow`. + * + * #### required properties + * - `borderRadius`: Equal to the border-radius + * - `smooth`: From 0 to 1, refer to the figma smoothing radius + * + * #### customizable style properties + * Provides some commonly used styles, dealing with their mapping with SVG attributes, such as: + * - `borderWidth` (stroke-width) + * - `borderColor` (stroke) + * - `bgColor` (fill) + * - `bgOpacity` (fill-opacity) + * + * #### More customization + * Use css to customize this component, such as drop-shadow: + * ```css + * smooth-corner { + * filter: drop-shadow(0px 0px 10px rgba(0, 0, 0, 0.1)); + * } + * ``` + */ +export class SmoothCorner extends LitElement { + static override styles = css` + :host { + position: relative; + } + .smooth-corner-bg, + .smooth-corner-border { + position: absolute; + top: 0; + left: 0; + pointer-events: none; + } + .smooth-corner-border { + z-index: 2; + } + .smooth-corner-content { + position: relative; + z-index: 1; + width: 100%; + height: 100%; + } + `; + + private _resizeObserver: ResizeObserver | null = null; + + get _path() { + return getFigmaSquircleSvgPath({ + width: this.width, + height: this.height, + cornerRadius: this.borderRadius, // defaults to 0 + cornerSmoothing: this.smooth, // cornerSmoothing goes from 0 to 1 + }); + } + + constructor() { + super(); + this._resizeObserver = new ResizeObserver(entries => { + for (const entry of entries) { + this.width = entry.contentRect.width; + this.height = entry.contentRect.height; + } + }); + } + + private _getSvg(className: string, path: TemplateResult) { + return svg`<svg + class="${className}" + width=${this.width + this.borderWidth} + height=${this.height + this.borderWidth} + viewBox="0 0 ${this.width + this.borderWidth} ${ + this.height + this.borderWidth + }" + xmlns="http://www.w3.org/2000/svg" + > + ${path} + </svg>`; + } + + override connectedCallback(): void { + super.connectedCallback(); + this._resizeObserver?.observe(this); + } + + override disconnectedCallback(): void { + super.disconnectedCallback(); + this._resizeObserver?.unobserve(this); + } + + override render() { + return html`${this._getSvg( + 'smooth-corner-bg', + svg`<path + d="${this._path}" + fill="${this.bgColor}" + fill-opacity="${this.bgOpacity}" + transform="translate(${this.borderWidth / 2} ${this.borderWidth / 2})" + >` + )} + ${this._getSvg( + 'smooth-corner-border', + svg`<path + fill="none" + d="${this._path}" + stroke="${this.borderColor}" + stroke-width="${this.borderWidth}" + transform="translate(${this.borderWidth / 2} ${this.borderWidth / 2})" + >` + )} + <div class="smooth-corner-content"> + <slot></slot> + </div>`; + } + + /** + * Background color of the element + */ + @property({ type: String }) + accessor bgColor: string = 'white'; + + /** + * Background opacity of the element + */ + @property({ type: Number }) + accessor bgOpacity: number = 1; + + /** + * Border color of the element + */ + @property({ type: String }) + accessor borderColor: string = 'black'; + + /** + * Equal to the border-radius + */ + @property({ type: Number }) + accessor borderRadius = 0; + + /** + * Border width of the element in px + */ + @property({ type: Number }) + accessor borderWidth: number = 2; + + @state() + accessor height: number = 0; + + /** + * From 0 to 1 + */ + @property({ type: Number }) + accessor smooth: number = 0; + + @state() + accessor width: number = 0; +} + +declare global { + interface HTMLElementTagNameMap { + 'smooth-corner': SmoothCorner; + } +} diff --git a/blocksuite/blocks/src/_common/components/toggle-switch.ts b/blocksuite/blocks/src/_common/components/toggle-switch.ts new file mode 100644 index 0000000000..0e60bf5df1 --- /dev/null +++ b/blocksuite/blocks/src/_common/components/toggle-switch.ts @@ -0,0 +1,89 @@ +import { css, html, LitElement } from 'lit'; +import { property } from 'lit/decorators.js'; + +const styles = css` + :host { + display: flex; + } + + .switch { + height: 0; + width: 0; + visibility: hidden; + margin: 0; + } + + label { + cursor: pointer; + text-indent: -9999px; + width: 38px; + height: 20px; + background: var(--affine-icon-color); + border: 1px solid var(--affine-black-10); + display: block; + border-radius: 20px; + position: relative; + } + + label:after { + content: ''; + position: absolute; + top: 1px; + left: 1px; + width: 16px; + height: 16px; + background: var(--affine-white); + border: 1px solid var(--affine-black-10); + border-radius: 16px; + transition: 0.1s; + } + + label.on { + background: var(--affine-primary-color); + } + + label.on:after { + left: calc(100% - 1px); + transform: translateX(-100%); + } + + label:active:after { + width: 24px; + } +`; + +export class ToggleSwitch extends LitElement { + static override styles = styles; + + private _toggleSwitch() { + this.on = !this.on; + if (this.onChange) { + this.onChange(this.on); + } + } + + override render() { + return html` + <label class=${this.on ? 'on' : ''}> + <input + type="checkbox" + class="switch" + ?checked=${this.on} + @change=${this._toggleSwitch} + /> + </label> + `; + } + + @property({ attribute: false }) + accessor on = false; + + @property({ attribute: false }) + accessor onChange: ((on: boolean) => void) | undefined = undefined; +} + +declare global { + interface HTMLElementTagNameMap { + 'toggle-switch': ToggleSwitch; + } +} diff --git a/blocksuite/blocks/src/_common/components/utils.ts b/blocksuite/blocks/src/_common/components/utils.ts new file mode 100644 index 0000000000..5a88279822 --- /dev/null +++ b/blocksuite/blocks/src/_common/components/utils.ts @@ -0,0 +1,256 @@ +import type { AffineInlineEditor } from '@blocksuite/affine-components/rich-text'; +import { getInlineEditorByModel } from '@blocksuite/affine-components/rich-text'; +import { + getCurrentNativeRange, + isControlledKeyboardEvent, +} from '@blocksuite/affine-shared/utils'; +import type { EditorHost } from '@blocksuite/block-std'; +import type { InlineEditor, InlineRange } from '@blocksuite/inline'; +import { BlockModel } from '@blocksuite/store'; +import { css, unsafeCSS } from 'lit'; + +export function getQuery( + inlineEditor: InlineEditor, + startRange: InlineRange | null +) { + const nativeRange = getCurrentNativeRange(); + if (!nativeRange) { + return null; + } + if (nativeRange.startContainer !== nativeRange.endContainer) { + return null; + } + const curRange = inlineEditor.getInlineRange(); + if (!startRange || !curRange) { + return null; + } + if (curRange.index < startRange.index) { + return null; + } + const text = inlineEditor.yText.toString(); + return text.slice(startRange.index, curRange.index); +} + +interface ObserverParams { + target: HTMLElement; + signal: AbortSignal; + onInput?: (isComposition: boolean) => void; + onDelete?: () => void; + onMove?: (step: 1 | -1) => void; + onConfirm?: () => void; + onAbort?: () => void; + onPaste?: () => void; + interceptor?: (e: KeyboardEvent, next: () => void) => void; +} + +export const createKeydownObserver = ({ + target, + signal, + onInput, + onDelete, + onMove, + onConfirm, + onAbort, + onPaste, + interceptor = (_, next) => next(), +}: ObserverParams) => { + const keyDownListener = (e: KeyboardEvent) => { + if (e.key === 'Process' || e.isComposing) return; + + if (e.defaultPrevented) return; + + if (isControlledKeyboardEvent(e)) { + const isOnlyCmd = (e.ctrlKey || e.metaKey) && !e.altKey && !e.shiftKey; + // Ctrl/Cmd + alphabet key + if (isOnlyCmd && e.key.length === 1) { + switch (e.key) { + // Previous command + case 'p': { + onMove?.(-1); + e.stopPropagation(); + e.preventDefault(); + return; + } + // Next command + case 'n': { + onMove?.(1); + e.stopPropagation(); + e.preventDefault(); + return; + } + // Paste command + case 'v': { + onPaste?.(); + return; + } + } + } + + // Pressing **only** modifier key is allowed and will be ignored + // Because we don't know the user's intention + // Aborting here will cause the above hotkeys to not work + if (e.key === 'Control' || e.key === 'Meta' || e.key === 'Alt') { + e.stopPropagation(); + return; + } + + // Abort when press modifier key + any other key to avoid weird behavior + // e.g. press ctrl + a to select all + onAbort?.(); + return; + } + + e.stopPropagation(); + + if ( + // input abc, 123, etc. + !isControlledKeyboardEvent(e) && + e.key.length === 1 + ) { + onInput?.(false); + return; + } + + switch (e.key) { + case 'Backspace': { + onDelete?.(); + return; + } + case 'Enter': { + if (e.shiftKey) { + onAbort?.(); + return; + } + onConfirm?.(); + e.preventDefault(); + return; + } + case 'Tab': { + if (e.shiftKey) { + onMove?.(-1); + } else { + onMove?.(1); + } + e.preventDefault(); + return; + } + case 'ArrowUp': { + if (e.shiftKey) { + onAbort?.(); + return; + } + onMove?.(-1); + e.preventDefault(); + return; + } + case 'ArrowDown': { + if (e.shiftKey) { + onAbort?.(); + return; + } + onMove?.(1); + e.preventDefault(); + return; + } + case 'Escape': + case 'ArrowLeft': + case 'ArrowRight': { + onAbort?.(); + return; + } + default: + // Other control keys + return; + } + }; + + target.addEventListener( + 'keydown', + (e: KeyboardEvent) => interceptor(e, () => keyDownListener(e)), + { + // Workaround: Use capture to prevent the event from triggering the keyboard bindings action + capture: true, + signal, + } + ); + + // Fix paste input + target.addEventListener('paste', () => onDelete?.(), { signal }); + + // Fix composition input + target.addEventListener('compositionend', () => onInput?.(true), { signal }); +}; + +/** + * Remove specified text from the current range. + */ +export function cleanSpecifiedTail( + editorHost: EditorHost, + inlineEditorOrModel: AffineInlineEditor | BlockModel, + str: string +) { + if (!str) { + console.warn('Failed to clean text! Unexpected empty string'); + return; + } + const inlineEditor = + inlineEditorOrModel instanceof BlockModel + ? getInlineEditorByModel(editorHost, inlineEditorOrModel) + : inlineEditorOrModel; + if (!inlineEditor) { + return; + } + const inlineRange = inlineEditor.getInlineRange(); + if (!inlineRange) { + return; + } + const idx = inlineRange.index - str.length; + const textStr = inlineEditor.yText.toString().slice(idx, idx + str.length); + if (textStr !== str) { + console.warn( + `Failed to clean text! Text mismatch expected: ${str} but actual: ${textStr}` + ); + return; + } + inlineEditor.deleteText({ index: idx, length: str.length }); + inlineEditor.setInlineRange({ + index: idx, + length: 0, + }); +} + +/** + * You should add a container before the scrollbar style to prevent the style pollution of the whole doc. + */ +export const scrollbarStyle = (container: string) => { + if (!container) { + console.error( + 'To prevent style pollution of the whole doc, you must add a container before the scrollbar style.' + ); + return css``; + } + + // sanitize container name + if (container.includes('{') || container.includes('}')) { + console.error('Invalid container name! Please use a valid CSS selector.'); + return css``; + } + + return css` + ${unsafeCSS(container)} { + scrollbar-gutter: stable; + } + ${unsafeCSS(container)}::-webkit-scrollbar { + -webkit-appearance: none; + width: 4px; + height: 4px; + } + ${unsafeCSS(container)}::-webkit-scrollbar-thumb { + border-radius: 2px; + background-color: #b1b1b1; + } + ${unsafeCSS(container)}::-webkit-scrollbar-corner { + display: none; + } + `; +}; diff --git a/blocksuite/blocks/src/_common/configs/move-block.ts b/blocksuite/blocks/src/_common/configs/move-block.ts new file mode 100644 index 0000000000..266255283a --- /dev/null +++ b/blocksuite/blocks/src/_common/configs/move-block.ts @@ -0,0 +1,125 @@ +import type { BlockSelection, BlockStdScope } from '@blocksuite/block-std'; + +const getSelection = (std: BlockStdScope) => std.selection; + +function getBlockSelectionBySide(std: BlockStdScope, tail: boolean) { + const selection = getSelection(std); + const selections = selection.filter('block'); + const sel = selections.at(tail ? -1 : 0) as BlockSelection | undefined; + return sel ?? null; +} + +function getTextSelection(std: BlockStdScope) { + const selection = getSelection(std); + return selection.find('text'); +} + +const pathToBlock = (std: BlockStdScope, blockId: string) => + std.view.getBlock(blockId); + +interface MoveBlockConfig { + name: string; + hotkey: string[]; + action: (std: BlockStdScope) => void; +} + +export const moveBlockConfigs: MoveBlockConfig[] = [ + { + name: 'Move Up', + hotkey: ['Mod-Alt-ArrowUp', 'Mod-Shift-ArrowUp'], + action: std => { + const doc = std.doc; + const textSelection = getTextSelection(std); + if (textSelection) { + const currentModel = pathToBlock( + std, + textSelection.from.blockId + )?.model; + if (!currentModel) return; + + const previousSiblingModel = doc.getPrev(currentModel); + if (!previousSiblingModel) return; + + const parentModel = std.doc.getParent(previousSiblingModel); + if (!parentModel) return; + + std.doc.moveBlocks( + [currentModel], + parentModel, + previousSiblingModel, + true + ); + std.host.updateComplete + .then(() => { + std.range.syncTextSelectionToRange(textSelection); + }) + .catch(console.error); + return true; + } + const blockSelection = getBlockSelectionBySide(std, true); + if (blockSelection) { + const currentModel = pathToBlock(std, blockSelection.blockId)?.model; + if (!currentModel) return; + + const previousSiblingModel = doc.getPrev(currentModel); + if (!previousSiblingModel) return; + + const parentModel = doc.getParent(previousSiblingModel); + if (!parentModel) return; + + doc.moveBlocks( + [currentModel], + parentModel, + previousSiblingModel, + false + ); + return true; + } + return; + }, + }, + { + name: 'Move Down', + hotkey: ['Mod-Alt-ArrowDown', 'Mod-Shift-ArrowDown'], + action: std => { + const doc = std.doc; + const textSelection = getTextSelection(std); + if (textSelection) { + const currentModel = pathToBlock( + std, + textSelection.from.blockId + )?.model; + if (!currentModel) return; + + const nextSiblingModel = doc.getNext(currentModel); + if (!nextSiblingModel) return; + + const parentModel = doc.getParent(nextSiblingModel); + if (!parentModel) return; + + doc.moveBlocks([currentModel], parentModel, nextSiblingModel, false); + std.host.updateComplete + .then(() => { + std.range.syncTextSelectionToRange(textSelection); + }) + .catch(console.error); + return true; + } + const blockSelection = getBlockSelectionBySide(std, true); + if (blockSelection) { + const currentModel = pathToBlock(std, blockSelection.blockId)?.model; + if (!currentModel) return; + + const nextSiblingModel = doc.getNext(currentModel); + if (!nextSiblingModel) return; + + const parentModel = doc.getParent(nextSiblingModel); + if (!parentModel) return; + + doc.moveBlocks([currentModel], parentModel, nextSiblingModel, false); + return true; + } + return; + }, + }, +]; diff --git a/blocksuite/blocks/src/_common/configs/quick-action/config.ts b/blocksuite/blocks/src/_common/configs/quick-action/config.ts new file mode 100644 index 0000000000..1c9f408071 --- /dev/null +++ b/blocksuite/blocks/src/_common/configs/quick-action/config.ts @@ -0,0 +1,152 @@ +import { + CopyIcon, + DatabaseTableViewIcon20, + LinkedDocIcon, +} from '@blocksuite/affine-components/icons'; +import { toast } from '@blocksuite/affine-components/toast'; +import { matchFlavours } from '@blocksuite/affine-shared/utils'; +import type { EditorHost } from '@blocksuite/block-std'; +import { tableViewMeta } from '@blocksuite/data-view/view-presets'; +import { assertExists } from '@blocksuite/global/utils'; +import type { TemplateResult } from 'lit'; + +import { convertToDatabase } from '../../../database-block/data-source.js'; +import { DATABASE_CONVERT_WHITE_LIST } from '../../../database-block/utils/block-utils.js'; +import { + convertSelectedBlocksToLinkedDoc, + getTitleFromSelectedModels, + notifyDocCreated, + promptDocTitle, +} from '../../utils/render-linked-doc.js'; + +export interface QuickActionConfig { + id: string; + name: string; + disabledToolTip?: string; + icon: TemplateResult<1>; + hotkey?: string; + showWhen: (host: EditorHost) => boolean; + enabledWhen: (host: EditorHost) => boolean; + action: (host: EditorHost) => void; +} + +export const quickActionConfig: QuickActionConfig[] = [ + { + id: 'copy', + name: 'Copy', + disabledToolTip: undefined, + icon: CopyIcon, + hotkey: undefined, + showWhen: () => true, + enabledWhen: () => true, + action: host => { + host.std.command + .chain() + .getSelectedModels() + .with({ + onCopy: () => { + toast(host, 'Copied to clipboard'); + }, + }) + .draftSelectedModels() + .copySelectedModels() + .run(); + }, + }, + { + id: 'convert-to-database', + name: 'Group as Table', + disabledToolTip: + 'Contains Block types that cannot be converted to Database', + icon: DatabaseTableViewIcon20, + showWhen: host => { + const [_, ctx] = host.std.command + .chain() + .getSelectedModels({ + types: ['block', 'text'], + }) + .run(); + const { selectedModels } = ctx; + if (!selectedModels || selectedModels.length === 0) return false; + + const firstBlock = selectedModels[0]; + assertExists(firstBlock); + if (matchFlavours(firstBlock, ['affine:database'])) { + return false; + } + + return true; + }, + enabledWhen: host => { + const [_, ctx] = host.std.command + .chain() + .getSelectedModels({ + types: ['block', 'text'], + }) + .run(); + const { selectedModels } = ctx; + if (!selectedModels || selectedModels.length === 0) return false; + + return selectedModels.every(block => + DATABASE_CONVERT_WHITE_LIST.includes(block.flavour) + ); + }, + action: host => { + convertToDatabase(host, tableViewMeta.type); + }, + }, + { + id: 'convert-to-linked-doc', + name: 'Create Linked Doc', + icon: LinkedDocIcon, + hotkey: `Mod-Shift-l`, + showWhen: host => { + const [_, ctx] = host.std.command + .chain() + .getSelectedModels({ + types: ['block'], + }) + .run(); + const { selectedModels } = ctx; + return !!selectedModels && selectedModels.length > 0; + }, + enabledWhen: host => { + const [_, ctx] = host.std.command + .chain() + .getSelectedModels({ + types: ['block'], + }) + .run(); + const { selectedModels } = ctx; + return !!selectedModels && selectedModels.length > 0; + }, + action: host => { + const [_, ctx] = host.std.command + .chain() + .getSelectedModels({ + types: ['block'], + mode: 'highest', + }) + .draftSelectedModels() + .run(); + const { selectedModels, draftedModels } = ctx; + assertExists(selectedModels); + if (!selectedModels.length || !draftedModels) return; + + host.selection.clear(); + + const doc = host.doc; + const autofill = getTitleFromSelectedModels(selectedModels); + void promptDocTitle(host, autofill).then(title => { + if (title === null) return; + convertSelectedBlocksToLinkedDoc( + host.std, + doc, + draftedModels, + title + ).catch(console.error); + notifyDocCreated(host, doc); + }); + }, + }, +]; diff --git a/blocksuite/blocks/src/_common/configs/text-conversion.ts b/blocksuite/blocks/src/_common/configs/text-conversion.ts new file mode 100644 index 0000000000..d9890a0331 --- /dev/null +++ b/blocksuite/blocks/src/_common/configs/text-conversion.ts @@ -0,0 +1,136 @@ +import { + BulletedListIcon, + CheckBoxIcon, + CodeBlockIcon, + DividerIcon, + Heading1Icon, + Heading2Icon, + Heading3Icon, + Heading4Icon, + Heading5Icon, + Heading6Icon, + NumberedListIcon, + QuoteIcon, + TextIcon, +} from '@blocksuite/affine-components/icons'; +import type { TemplateResult } from 'lit'; + +/** + * Text primitive entries used in slash menu and format bar, + * which are also used for registering hotkeys for converting block flavours. + */ +export interface TextConversionConfig { + flavour: BlockSuite.Flavour; + type?: string; + name: string; + description?: string; + hotkey: string[] | null; + icon: TemplateResult<1>; +} + +export const textConversionConfigs: TextConversionConfig[] = [ + { + flavour: 'affine:paragraph', + type: 'text', + name: 'Text', + description: 'Start typing with plain text.', + hotkey: [`Mod-Alt-0`, `Mod-Shift-0`], + icon: TextIcon, + }, + { + flavour: 'affine:paragraph', + type: 'h1', + name: 'Heading 1', + description: 'Headings in the largest font.', + hotkey: [`Mod-Alt-1`, `Mod-Shift-1`], + icon: Heading1Icon, + }, + { + flavour: 'affine:paragraph', + type: 'h2', + name: 'Heading 2', + description: 'Headings in the 2nd font size.', + hotkey: [`Mod-Alt-2`, `Mod-Shift-2`], + icon: Heading2Icon, + }, + { + flavour: 'affine:paragraph', + type: 'h3', + name: 'Heading 3', + description: 'Headings in the 3rd font size.', + hotkey: [`Mod-Alt-3`, `Mod-Shift-3`], + icon: Heading3Icon, + }, + { + flavour: 'affine:paragraph', + type: 'h4', + name: 'Heading 4', + description: 'Headings in the 4th font size.', + hotkey: [`Mod-Alt-4`, `Mod-Shift-4`], + icon: Heading4Icon, + }, + { + flavour: 'affine:paragraph', + type: 'h5', + name: 'Heading 5', + description: 'Headings in the 5th font size.', + hotkey: [`Mod-Alt-5`, `Mod-Shift-5`], + icon: Heading5Icon, + }, + { + flavour: 'affine:paragraph', + type: 'h6', + name: 'Heading 6', + description: 'Headings in the 6th font size.', + hotkey: [`Mod-Alt-6`, `Mod-Shift-6`], + icon: Heading6Icon, + }, + { + flavour: 'affine:list', + type: 'bulleted', + name: 'Bulleted List', + description: 'Create a bulleted list.', + hotkey: [`Mod-Alt-8`, `Mod-Shift-8`], + icon: BulletedListIcon, + }, + { + flavour: 'affine:list', + type: 'numbered', + name: 'Numbered List', + description: 'Create a numbered list.', + hotkey: [`Mod-Alt-9`, `Mod-Shift-9`], + icon: NumberedListIcon, + }, + { + flavour: 'affine:list', + type: 'todo', + name: 'To-do List', + description: 'Add tasks to a to-do list.', + hotkey: null, + icon: CheckBoxIcon, + }, + { + flavour: 'affine:code', + type: undefined, + name: 'Code Block', + description: 'Code snippet with formatting.', + hotkey: [`Mod-Alt-c`], + icon: CodeBlockIcon, + }, + { + flavour: 'affine:paragraph', + type: 'quote', + name: 'Quote', + description: 'Add a blockquote for emphasis.', + hotkey: null, + icon: QuoteIcon, + }, + { + flavour: 'affine:divider', + type: 'divider', + name: 'Divider', + description: 'Visually separate content.', + hotkey: [`Mod-Alt-d`, `Mod-Shift-d`], + icon: DividerIcon, + }, +]; diff --git a/blocksuite/blocks/src/_common/consts.ts b/blocksuite/blocks/src/_common/consts.ts new file mode 100644 index 0000000000..67201234c0 --- /dev/null +++ b/blocksuite/blocks/src/_common/consts.ts @@ -0,0 +1 @@ +export * from '@blocksuite/affine-shared/consts'; diff --git a/blocksuite/blocks/src/_common/edgeless/frame/consts.ts b/blocksuite/blocks/src/_common/edgeless/frame/consts.ts new file mode 100644 index 0000000000..e029b8bba7 --- /dev/null +++ b/blocksuite/blocks/src/_common/edgeless/frame/consts.ts @@ -0,0 +1 @@ +export type NavigatorMode = 'fill' | 'fit'; diff --git a/blocksuite/blocks/src/_common/edgeless/mindmap/index.ts b/blocksuite/blocks/src/_common/edgeless/mindmap/index.ts new file mode 100644 index 0000000000..92d61c208d --- /dev/null +++ b/blocksuite/blocks/src/_common/edgeless/mindmap/index.ts @@ -0,0 +1,68 @@ +import { MindmapElementModel } from '@blocksuite/affine-model'; +import type { Viewport } from '@blocksuite/block-std/gfx'; + +export function isMindmapNode(el: BlockSuite.EdgelessModel) { + return ( + el.group instanceof MindmapElementModel || el instanceof MindmapElementModel + ); +} + +export function isSingleMindMapNode(els: BlockSuite.EdgelessModel[]) { + return els.length === 1 && els[0].group instanceof MindmapElementModel; +} + +export function isElementOutsideViewport( + viewport: Viewport, + element: BlockSuite.EdgelessModel, + padding: [number, number] = [0, 0] +) { + const elementBound = element.elementBound; + + padding[0] /= viewport.zoom; + padding[1] /= viewport.zoom; + + elementBound.x -= padding[1]; + elementBound.w += padding[1]; + elementBound.y -= padding[0]; + elementBound.h += padding[0]; + + return !viewport.viewportBounds.contains(elementBound); +} + +export function getNearestTranslation( + viewport: Viewport, + element: BlockSuite.EdgelessModel, + padding: [number, number] = [0, 0] +) { + const viewportBound = viewport.viewportBounds; + const elementBound = element.elementBound; + let dx = 0; + let dy = 0; + + if (elementBound.x - padding[1] < viewportBound.x) { + dx = viewportBound.x - (elementBound.x - padding[1]); + } else if ( + elementBound.x + elementBound.w + padding[1] > + viewportBound.x + viewportBound.w + ) { + dx = + viewportBound.x + + viewportBound.w - + (elementBound.x + elementBound.w + padding[1]); + } + + if (elementBound.y - padding[0] < viewportBound.y) { + dy = elementBound.y - padding[0] - viewportBound.y; + } else if ( + elementBound.y + elementBound.h + padding[0] > + viewportBound.y + viewportBound.h + ) { + dy = + elementBound.y + + elementBound.h + + padding[0] - + (viewportBound.y + viewportBound.h); + } + + return [dx, dy]; +} diff --git a/blocksuite/blocks/src/_common/export-manager/export-manager.ts b/blocksuite/blocks/src/_common/export-manager/export-manager.ts new file mode 100644 index 0000000000..b5b2bfabd6 --- /dev/null +++ b/blocksuite/blocks/src/_common/export-manager/export-manager.ts @@ -0,0 +1,590 @@ +import { + type CanvasRenderer, + SurfaceElementModel, +} from '@blocksuite/affine-block-surface'; +import { + GroupElementModel, + type RootBlockModel, +} from '@blocksuite/affine-model'; +import { FetchUtils } from '@blocksuite/affine-shared/adapters'; +import { + CANVAS_EXPORT_IGNORE_TAGS, + DEFAULT_IMAGE_PROXY_ENDPOINT, +} from '@blocksuite/affine-shared/consts'; +import { + isInsidePageEditor, + matchFlavours, +} from '@blocksuite/affine-shared/utils'; +import { + type BlockStdScope, + type EditorHost, + type ExtensionType, + StdIdentifier, +} from '@blocksuite/block-std'; +import { BlockSuiteError, ErrorCode } from '@blocksuite/global/exceptions'; +import type { IBound } from '@blocksuite/global/utils'; +import { Bound } from '@blocksuite/global/utils'; +import type { Doc } from '@blocksuite/store'; + +import { + getBlockComponentByModel, + getRootByEditorHost, +} from '../../_common/utils/index.js'; +import type { GfxBlockModel } from '../../root-block/edgeless/block-model.js'; +import type { EdgelessRootBlockComponent } from '../../root-block/edgeless/edgeless-root-block.js'; +import { getBlocksInFrameBound } from '../../root-block/edgeless/frame-manager.js'; +import { xywhArrayToObject } from '../../root-block/edgeless/utils/convert.js'; +import { getBackgroundGrid } from '../../root-block/edgeless/utils/query.js'; +import { FileExporter } from './file-exporter.js'; + +// eslint-disable-next-line +type Html2CanvasFunction = typeof import('html2canvas').default; + +export type ExportOptions = { + imageProxyEndpoint: string; +}; +export class ExportManager { + private _exportOptions: ExportOptions = { + imageProxyEndpoint: DEFAULT_IMAGE_PROXY_ENDPOINT, + }; + + private _replaceRichTextWithSvgElement = (element: HTMLElement) => { + const richList = Array.from(element.querySelectorAll('.inline-editor')); + richList.forEach(rich => { + const svgEle = this._elementToSvgElement( + rich.cloneNode(true) as HTMLElement, + rich.clientWidth, + rich.clientHeight + 1 + ); + rich.parentElement?.append(svgEle); + rich.remove(); + }); + }; + + replaceImgSrcWithSvg = async (element: HTMLElement) => { + const imgList = Array.from(element.querySelectorAll('img')); + // Create an array of promises + const promises = imgList.map(img => { + return FetchUtils.fetchImage( + img.src, + undefined, + this._exportOptions.imageProxyEndpoint + ) + .then(response => response && response.blob()) + .then(async blob => { + if (!blob) return; + // If the file type is SVG, set svg width and height + if (blob.type === 'image/svg+xml') { + // Parse the SVG + const parser = new DOMParser(); + const svgDoc = parser.parseFromString( + await blob.text(), + 'image/svg+xml' + ); + const svgElement = + svgDoc.documentElement as unknown as SVGSVGElement; + + // Check if the SVG has width and height attributes + if ( + !svgElement.hasAttribute('width') && + !svgElement.hasAttribute('height') + ) { + // Get the viewBox + const viewBox = svgElement.viewBox.baseVal; + // Set the SVG width and height + svgElement.setAttribute('width', `${viewBox.width}px`); + svgElement.setAttribute('height', `${viewBox.height}px`); + } + + // Replace the img src with the modified SVG + const serializer = new XMLSerializer(); + const newSvgStr = serializer.serializeToString(svgElement); + img.src = + 'data:image/svg+xml;charset=utf-8,' + + encodeURIComponent(newSvgStr); + } + }); + }); + + // Wait for all promises to resolve + await Promise.all(promises); + }; + + get doc(): Doc { + return this.std.doc; + } + + get editorHost(): EditorHost { + return this.std.host; + } + + constructor(readonly std: BlockStdScope) {} + + private _checkCanContinueToCanvas(pathName: string, editorMode: boolean) { + if ( + location.pathname !== pathName || + isInsidePageEditor(this.editorHost) !== editorMode + ) { + throw new BlockSuiteError( + ErrorCode.EdgelessExportError, + 'Unable to export content to canvas' + ); + } + } + + private async _checkReady() { + const pathname = location.pathname; + const editorMode = isInsidePageEditor(this.editorHost); + + const promise = new Promise((resolve, reject) => { + let count = 0; + const checkReactRender = setInterval(() => { + try { + this._checkCanContinueToCanvas(pathname, editorMode); + } catch (e) { + clearInterval(checkReactRender); + reject(e); + } + const rootModel = this.doc.root; + const rootComponent = this.doc.root + ? getBlockComponentByModel(this.editorHost, rootModel) + : null; + const imageCard = rootComponent?.querySelector( + 'affine-image-fallback-card' + ); + const isReady = + !imageCard || imageCard.getAttribute('imageState') === '0'; + if (rootComponent && isReady) { + clearInterval(checkReactRender); + resolve(true); + } + count++; + if (count > 10 * 60) { + clearInterval(checkReactRender); + resolve(false); + } + }, 100); + }); + return promise; + } + + private _createCanvas(bound: IBound, fillStyle: string) { + const canvas = document.createElement('canvas'); + const dpr = window.devicePixelRatio || 1; + const ctx = canvas.getContext('2d') as CanvasRenderingContext2D; + + canvas.width = (bound.w + 100) * dpr; + canvas.height = (bound.h + 100) * dpr; + + ctx.scale(dpr, dpr); + ctx.fillStyle = fillStyle; + ctx.fillRect(0, 0, canvas.width, canvas.height); + + return { canvas, ctx }; + } + + private _disableMediaPrint() { + document.querySelectorAll('.media-print').forEach(mediaPrint => { + mediaPrint.classList.add('hide'); + }); + } + + private async _docToCanvas(): Promise<HTMLCanvasElement | void> { + const html2canvas = (await import('html2canvas')).default; + if (!(html2canvas instanceof Function)) return; + + const pathname = location.pathname; + const editorMode = isInsidePageEditor(this.editorHost); + + const rootComponent = getRootByEditorHost(this.editorHost); + if (!rootComponent) return; + const viewportElement = rootComponent.viewportElement; + if (!viewportElement) return; + const pageContainer = viewportElement.querySelector( + '.affine-page-root-block-container' + ); + const rect = pageContainer?.getBoundingClientRect(); + const { viewport } = rootComponent; + if (!viewport) return; + const pageWidth = rect?.width; + const pageLeft = rect?.left ?? 0; + const viewportHeight = viewportElement?.scrollHeight; + + const html2canvasOption = { + ignoreElements: function (element: Element) { + if ( + CANVAS_EXPORT_IGNORE_TAGS.includes(element.tagName) || + element.classList.contains('dg') + ) { + return true; + } else if ( + (element.classList.contains('close') && + element.parentElement?.classList.contains( + 'meta-data-expanded-title' + )) || + (element.classList.contains('expand') && + element.parentElement?.classList.contains('meta-data')) + ) { + // the close and expand buttons in affine-doc-meta-data is not needed to be showed + return true; + } else { + return false; + } + }, + onclone: async (_documentClone: Document, element: HTMLElement) => { + element.style.height = `${viewportHeight}px`; + this._replaceRichTextWithSvgElement(element); + await this.replaceImgSrcWithSvg(element); + }, + backgroundColor: window.getComputedStyle(viewportElement).backgroundColor, + x: pageLeft - viewport.left, + width: pageWidth, + height: viewportHeight, + useCORS: this._exportOptions.imageProxyEndpoint ? false : true, + proxy: this._exportOptions.imageProxyEndpoint, + }; + + let data: HTMLCanvasElement; + try { + this._enableMediaPrint(); + data = await html2canvas( + viewportElement as HTMLElement, + html2canvasOption + ); + } finally { + this._disableMediaPrint(); + } + this._checkCanContinueToCanvas(pathname, editorMode); + return data; + } + + private _drawEdgelessBackground( + ctx: CanvasRenderingContext2D, + { + size, + backgroundColor, + gridColor, + }: { + size: number; + backgroundColor: string; + gridColor: string; + } + ) { + const svgImg = `<svg width='${ctx.canvas.width}px' height='${ctx.canvas.height}px' xmlns='http://www.w3.org/2000/svg' style='background-size:${size}px ${size}px;background-color:${backgroundColor}; background-image: radial-gradient(${gridColor} 1px, ${backgroundColor} 1px)'></svg>`; + const img = new Image(); + const cleanup = () => { + img.onload = null; + img.onerror = null; + }; + + return new Promise<void>((resolve, reject) => { + img.onload = () => { + cleanup(); + ctx.drawImage(img, 0, 0); + resolve(); + }; + img.onerror = e => { + cleanup(); + reject(e); + }; + + img.src = `data:image/svg+xml,${encodeURIComponent(svgImg)}`; + }); + } + + private _elementToSvgElement( + node: HTMLElement, + width: number, + height: number + ) { + const xmlns = 'http://www.w3.org/2000/svg'; + const svg = document.createElementNS(xmlns, 'svg'); + const foreignObject = document.createElementNS(xmlns, 'foreignObject'); + + svg.setAttribute('width', `${width}`); + svg.setAttribute('height', `${height}`); + svg.setAttribute('viewBox', `0 0 ${width} ${height}`); + + foreignObject.setAttribute('width', '100%'); + foreignObject.setAttribute('height', '100%'); + foreignObject.setAttribute('x', '0'); + foreignObject.setAttribute('y', '0'); + foreignObject.setAttribute('externalResourcesRequired', 'true'); + + svg.append(foreignObject); + foreignObject.append(node); + return svg; + } + + private _enableMediaPrint() { + document.querySelectorAll('.media-print').forEach(mediaPrint => { + mediaPrint.classList.remove('hide'); + }); + } + + private async _html2canvas( + htmlElement: HTMLElement, + options: Parameters<Html2CanvasFunction>[1] = {} + ) { + const html2canvas = (await import('html2canvas')) + .default as unknown as Html2CanvasFunction; + const html2canvasOption = { + ignoreElements: function (element: Element) { + if ( + CANVAS_EXPORT_IGNORE_TAGS.includes(element.tagName) || + element.classList.contains('dg') + ) { + return true; + } else { + return false; + } + }, + onclone: async (documentClone: Document, element: HTMLElement) => { + // html2canvas can't support transform feature + element.style.setProperty('transform', 'none'); + const layer = element.classList.contains('.affine-edgeless-layer') + ? element + : null; + + if (layer instanceof HTMLElement) { + layer.style.setProperty('transform', 'none'); + } + + const boxShadowEles = documentClone.querySelectorAll( + "[style*='box-shadow']" + ); + boxShadowEles.forEach(function (element) { + if (element instanceof HTMLElement) { + element.style.setProperty('box-shadow', 'none'); + } + }); + + this._replaceRichTextWithSvgElement(element); + await this.replaceImgSrcWithSvg(element); + }, + useCORS: this._exportOptions.imageProxyEndpoint ? false : true, + proxy: this._exportOptions.imageProxyEndpoint, + }; + + let data: HTMLCanvasElement; + try { + this._enableMediaPrint(); + data = await html2canvas( + htmlElement, + Object.assign(html2canvasOption, options) + ); + } finally { + this._disableMediaPrint(); + } + return data; + } + + private async _toCanvas(): Promise<HTMLCanvasElement | void> { + try { + await this._checkReady(); + } catch (e: unknown) { + console.error('Failed to export to canvas'); + console.error(e); + return; + } + + if (isInsidePageEditor(this.editorHost)) { + return this._docToCanvas(); + } else { + const rootModel = this.doc.root; + if (!rootModel) return; + + const edgeless = getBlockComponentByModel( + this.editorHost, + rootModel + ) as EdgelessRootBlockComponent; + const bound = edgeless.gfx.elementsBound; + return this.edgelessToCanvas(edgeless.surface.renderer, bound, edgeless); + } + } + + // TODO: refactor of this part + async edgelessToCanvas( + surfaceRenderer: CanvasRenderer, + bound: IBound, + edgeless?: EdgelessRootBlockComponent, + nodes?: GfxBlockModel[], + surfaces?: BlockSuite.SurfaceElementModel[], + edgelessBackground?: { + zoom: number; + } + ): Promise<HTMLCanvasElement | undefined> { + const rootModel = this.doc.root; + if (!rootModel) return; + + const pathname = location.pathname; + const editorMode = isInsidePageEditor(this.editorHost); + const rootComponent = getRootByEditorHost(this.editorHost); + if (!rootComponent) return; + const viewportElement = rootComponent.viewportElement; + if (!viewportElement) return; + const containerComputedStyle = window.getComputedStyle(viewportElement); + + const html2canvas = (element: HTMLElement) => + this._html2canvas(element, { + backgroundColor: containerComputedStyle.backgroundColor, + }); + const container = rootComponent.querySelector( + '.affine-block-children-container' + ); + + if (!container) return; + + const { ctx, canvas } = this._createCanvas( + bound, + window.getComputedStyle(container).backgroundColor + ); + + if (edgelessBackground) { + await this._drawEdgelessBackground(ctx, { + backgroundColor: containerComputedStyle.getPropertyValue( + '--affine-background-primary-color' + ), + size: getBackgroundGrid(edgelessBackground.zoom, true).gap, + gridColor: containerComputedStyle.getPropertyValue( + '--affine-edgeless-grid-color' + ), + }); + } + + const blocks = + nodes ?? + edgeless?.service.gfx.getElementsByBound(bound, { type: 'block' }) ?? + []; + for (const block of blocks) { + if (matchFlavours(block, ['affine:image'])) { + if (!block.sourceId) return; + + const blob = await block.doc.blobSync.get(block.sourceId); + if (!blob) return; + + const blobToImage = (blob: Blob) => + new Promise<HTMLImageElement>((resolve, reject) => { + const img = new Image(); + img.onload = () => resolve(img); + img.onerror = reject; + img.src = URL.createObjectURL(blob); + }); + const blockBound = xywhArrayToObject(block); + ctx.drawImage( + await blobToImage(blob), + blockBound.x - bound.x, + blockBound.y - bound.y, + blockBound.w, + blockBound.h + ); + } + const blockComponent = this.editorHost.view.getBlock(block.id); + if (blockComponent) { + const blockBound = xywhArrayToObject(block); + const canvasData = await this._html2canvas( + blockComponent as HTMLElement + ); + ctx.drawImage( + canvasData, + blockBound.x - bound.x + 50, + blockBound.y - bound.y + 50, + blockBound.w, + blockBound.h + ); + } + + if (matchFlavours(block, ['affine:frame'])) { + // TODO(@L-Sun): use children of frame instead of bound + const blocksInsideFrame = getBlocksInFrameBound(this.doc, block, false); + const frameBound = Bound.deserialize(block.xywh); + + // eslint-disable-next-line @typescript-eslint/prefer-for-of + for (let i = 0; i < blocksInsideFrame.length; i++) { + const element = blocksInsideFrame[i]; + const htmlElement = this.editorHost.view.getBlock(block.id); + const blockBound = xywhArrayToObject(element); + const canvasData = await html2canvas(htmlElement as HTMLElement); + + ctx.drawImage( + canvasData, + blockBound.x - bound.x + 50, + blockBound.y - bound.y + 50, + blockBound.w, + (blockBound.w / canvasData.width) * canvasData.height + ); + } + const surfaceCanvas = surfaceRenderer.getCanvasByBound(frameBound); + + ctx.drawImage(surfaceCanvas, 50, 50, frameBound.w, frameBound.h); + } + + this._checkCanContinueToCanvas(pathname, editorMode); + } + + if (surfaces?.length) { + const surfaceElements = surfaces.flatMap(element => + element instanceof GroupElementModel + ? (element.childElements.filter( + el => el instanceof SurfaceElementModel + ) as SurfaceElementModel[]) + : element + ); + const surfaceCanvas = surfaceRenderer.getCanvasByBound( + bound, + surfaceElements + ); + + ctx.drawImage(surfaceCanvas, 50, 50, bound.w, bound.h); + } + + return canvas; + } + + async exportPdf() { + const rootModel = this.doc.root; + if (!rootModel) return; + const canvasImage = await this._toCanvas(); + if (!canvasImage) { + return; + } + + const PDFLib = await import('pdf-lib'); + const pdfDoc = await PDFLib.PDFDocument.create(); + const page = pdfDoc.addPage([canvasImage.width, canvasImage.height]); + const imageEmbed = await pdfDoc.embedPng(canvasImage.toDataURL('PNG')); + const { width, height } = imageEmbed.scale(1); + page.drawImage(imageEmbed, { + x: 0, + y: 0, + width, + height, + }); + const pdfBase64 = await pdfDoc.saveAsBase64({ dataUri: true }); + + FileExporter.exportFile( + (rootModel as RootBlockModel).title.toString() + '.pdf', + pdfBase64 + ); + } + + async exportPng() { + const rootModel = this.doc.root; + if (!rootModel) return; + const canvasImage = await this._toCanvas(); + if (!canvasImage) { + return; + } + + FileExporter.exportPng( + (this.doc.root as RootBlockModel).title.toString(), + canvasImage.toDataURL('image/png') + ); + } +} + +export const ExportManagerExtension: ExtensionType = { + setup: di => { + di.add(ExportManager, [StdIdentifier]); + }, +}; diff --git a/blocksuite/blocks/src/_common/export-manager/file-exporter.ts b/blocksuite/blocks/src/_common/export-manager/file-exporter.ts new file mode 100644 index 0000000000..65141f2e13 --- /dev/null +++ b/blocksuite/blocks/src/_common/export-manager/file-exporter.ts @@ -0,0 +1,81 @@ +/* eslint-disable no-control-regex */ +// Context: Lean towards breaking out any localizable content into constants so it's +// easier to track content we may need to localize in the future. (i18n) +const UNTITLED_PAGE_NAME = 'Untitled'; + +/** Tools for exporting files to device. For example, via browser download. */ +export const FileExporter = { + /** + * Create a download for the user's browser. + * + * @param filename + * @param text + * @param mimeType like `"text/plain"`, `"text/html"`, `"application/javascript"`, etc. See {@link https://developer.mozilla.org/en-US/docs/Web/HTTP/Basics_of_HTTP/MIME_types/Common_types mdn docs List of MIME types}. + * + * @remarks + * Only accepts data in utf-8 encoding (html files, javascript source, text files, etc). + * + * @example + * const todoMDText = `# Todo items + * [ ] Item 1 + * [ ] Item 2 + * ` + * FileExporter.exportFile("Todo list.md", todoMDText, "text/plain") + * + * @example + * const stateJsonContent = JSON.stringify({ a: 1, b: 2, c: 3 }) + * FileExporter.exportFile("state.json", jsonContent, "application/json") + */ + exportFile(filename: string, dataURL: string) { + const element = document.createElement('a'); + element.setAttribute('href', dataURL); + const safeFilename = getSafeFileName(filename); + element.setAttribute('download', safeFilename); + + element.style.display = 'none'; + document.body.append(element); + + element.click(); + + element.remove(); + }, + exportPng(docTitle: string | undefined, dataURL: string) { + const title = docTitle?.trim() || UNTITLED_PAGE_NAME; + FileExporter.exportFile(title + '.png', dataURL); + }, +}; + +function getSafeFileName(string: string) { + const replacement = ' '; + const filenameReservedRegex = /[<>:"/\\|?*\u0000-\u001F]/g; + const windowsReservedNameRegex = /^(con|prn|aux|nul|com\d|lpt\d)$/i; + const reControlChars = /[\u0000-\u001F\u0080-\u009F]/g; + const reTrailingPeriods = /\.+$/; + const allowedLength = 50; + + function trimRepeated(string: string, target: string) { + const escapeStringRegexp = target + .replace(/[|\\{}()[\]^$+*?.]/g, '\\$&') + .replace(/-/g, '\\x2d'); + const regex = new RegExp(`(?:${escapeStringRegexp}){2,}`, 'g'); + return string.replace(regex, target); + } + + string = string + .normalize('NFD') + .replace(filenameReservedRegex, replacement) + .replace(reControlChars, replacement) + .replace(reTrailingPeriods, ''); + + string = trimRepeated(string, replacement); + string = windowsReservedNameRegex.test(string) + ? string + replacement + : string; + const extIndex = string.lastIndexOf('.'); + const filename = string.slice(0, extIndex).trim(); + const extension = string.slice(extIndex); + string = + filename.slice(0, Math.max(1, allowedLength - extension.length)) + + extension; + return string; +} diff --git a/blocksuite/blocks/src/_common/test-utils/test-utils.ts b/blocksuite/blocks/src/_common/test-utils/test-utils.ts new file mode 100644 index 0000000000..0f0b9847f8 --- /dev/null +++ b/blocksuite/blocks/src/_common/test-utils/test-utils.ts @@ -0,0 +1,50 @@ +import type { BlockSnapshot, SliceSnapshot } from '@blocksuite/store'; + +import { + mergeToCodeModel, + transformModel, +} from '../../root-block/utils/operations/model.js'; + +class DocTestUtils { + // block model operations (data layer) + mergeToCodeModel = mergeToCodeModel; + + transformModel = transformModel; +} + +export class TestUtils { + docTestUtils = new DocTestUtils(); +} + +export function nanoidReplacement(snapshot: BlockSnapshot | SliceSnapshot) { + return JSON.parse(nanoidReplacementString(JSON.stringify(snapshot))); +} + +const escapedSnapshotAttributes = new Set([ + '"attributes"', + '"conditions"', + '"iconColumn"', + '"background"', + '"LinkedPage"', + '"elementIds"', +]); + +function nanoidReplacementString(snapshotString: string) { + const re = + /("block:[A-Za-z0-9-_]{10}")|("[A-Za-z0-9-_]{10}")|("var\(--affine-v2-chip-label-[a-z]{3,10}\)")|("[A-Za-z0-9-_=]{44}")/g; + const matches = snapshotString.matchAll(re); + const matchesReplaceMap = new Map(); + let escapedNumber = 0; + Array.from(matches).forEach((match, index) => { + if (escapedSnapshotAttributes.has(match[0])) { + matchesReplaceMap.set(match[0], match[0]); + escapedNumber++; + } else { + matchesReplaceMap.set( + match[0], + `"matchesReplaceMap[${index - escapedNumber}]"` + ); + } + }); + return snapshotString.replace(re, match => matchesReplaceMap.get(match)); +} diff --git a/blocksuite/blocks/src/_common/transformers/html.ts b/blocksuite/blocks/src/_common/transformers/html.ts new file mode 100644 index 0000000000..e09d1c6116 --- /dev/null +++ b/blocksuite/blocks/src/_common/transformers/html.ts @@ -0,0 +1,168 @@ +import { sha } from '@blocksuite/global/utils'; +import type { Doc, DocCollection } from '@blocksuite/store'; +import { extMimeMap, Job } from '@blocksuite/store'; + +import { HtmlAdapter } from '../adapters/html-adapter/html.js'; +import { + defaultImageProxyMiddleware, + docLinkBaseURLMiddleware, + fileNameMiddleware, + titleMiddleware, +} from './middlewares.js'; +import { createAssetsArchive, download, Unzip } from './utils.js'; + +type ImportHTMLToDocOptions = { + collection: DocCollection; + html: string; + fileName?: string; +}; + +type ImportHTMLZipOptions = { + collection: DocCollection; + imported: Blob; +}; + +/** + * Exports a doc to HTML format. + * + * @param doc - The doc to be exported. + * @returns A Promise that resolves when the export is complete. + */ +async function exportDoc(doc: Doc) { + const job = new Job({ + collection: doc.collection, + middlewares: [docLinkBaseURLMiddleware, titleMiddleware], + }); + const snapshot = job.docToSnapshot(doc); + const adapter = new HtmlAdapter(job); + if (!snapshot) { + return; + } + const htmlResult = await adapter.fromDocSnapshot({ + snapshot, + assets: job.assetsManager, + }); + + let downloadBlob: Blob; + const docTitle = doc.meta?.title || 'Untitled'; + let name: string; + const contentBlob = new Blob([htmlResult.file], { type: 'plain/text' }); + if (htmlResult.assetsIds.length > 0) { + const zip = await createAssetsArchive(job.assets, htmlResult.assetsIds); + + await zip.file('index.html', contentBlob); + + downloadBlob = await zip.generate(); + name = `${docTitle}.zip`; + } else { + downloadBlob = contentBlob; + name = `${docTitle}.html`; + } + download(downloadBlob, name); +} + +/** + * Imports HTML content into a new doc within a collection. + * + * @param options - The import options. + * @param options.collection - The target doc collection. + * @param options.html - The HTML content to import. + * @param options.fileName - Optional filename for the imported doc. + * @returns A Promise that resolves to the ID of the newly created doc, or undefined if import fails. + */ +async function importHTMLToDoc({ + collection, + html, + fileName, +}: ImportHTMLToDocOptions) { + const job = new Job({ + collection, + middlewares: [ + defaultImageProxyMiddleware, + fileNameMiddleware(fileName), + docLinkBaseURLMiddleware, + ], + }); + const htmlAdapter = new HtmlAdapter(job); + const page = await htmlAdapter.toDoc({ + file: html, + assets: job.assetsManager, + }); + if (!page) { + return; + } + return page.id; +} + +/** + * Imports a zip file containing HTML files and assets into a collection. + * + * @param options - The import options. + * @param options.collection - The target doc collection. + * @param options.imported - The zip file as a Blob. + * @returns A Promise that resolves to an array of IDs of the newly created docs. + */ +async function importHTMLZip({ collection, imported }: ImportHTMLZipOptions) { + const unzip = new Unzip(); + await unzip.load(imported); + + const docIds: string[] = []; + const pendingAssets = new Map<string, File>(); + const pendingPathBlobIdMap = new Map<string, string>(); + const htmlBlobs: [string, Blob][] = []; + + for (const { path, content: blob } of unzip) { + if (path.includes('__MACOSX') || path.includes('.DS_Store')) { + continue; + } + + const fileName = path.split('/').pop() ?? ''; + if (fileName.endsWith('.html')) { + htmlBlobs.push([fileName, blob]); + } else { + const ext = path.split('.').at(-1) ?? ''; + const mime = extMimeMap.get(ext) ?? ''; + const key = await sha(await blob.arrayBuffer()); + pendingPathBlobIdMap.set(path, key); + pendingAssets.set(key, new File([blob], fileName, { type: mime })); + } + } + + await Promise.all( + htmlBlobs.map(async ([fileName, blob]) => { + const fileNameWithoutExt = fileName.replace(/\.[^/.]+$/, ''); + const job = new Job({ + collection, + middlewares: [ + defaultImageProxyMiddleware, + fileNameMiddleware(fileNameWithoutExt), + docLinkBaseURLMiddleware, + ], + }); + const assets = job.assets; + const pathBlobIdMap = job.assetsManager.getPathBlobIdMap(); + for (const [key, value] of pendingAssets.entries()) { + assets.set(key, value); + } + for (const [key, value] of pendingPathBlobIdMap.entries()) { + pathBlobIdMap.set(key, value); + } + const htmlAdapter = new HtmlAdapter(job); + const html = await blob.text(); + const doc = await htmlAdapter.toDoc({ + file: html, + assets: job.assetsManager, + }); + if (doc) { + docIds.push(doc.id); + } + }) + ); + return docIds; +} + +export const HtmlTransformer = { + exportDoc, + importHTMLToDoc, + importHTMLZip, +}; diff --git a/blocksuite/blocks/src/_common/transformers/index.ts b/blocksuite/blocks/src/_common/transformers/index.ts new file mode 100644 index 0000000000..11253674fd --- /dev/null +++ b/blocksuite/blocks/src/_common/transformers/index.ts @@ -0,0 +1,15 @@ +export { HtmlTransformer } from './html.js'; +export { MarkdownTransformer } from './markdown.js'; +export { + customImageProxyMiddleware, + defaultImageProxyMiddleware, + docLinkBaseURLMiddleware, + docLinkBaseURLMiddlewareBuilder, + embedSyncedDocMiddleware, + replaceIdMiddleware, + setImageProxyMiddlewareURL, + titleMiddleware, +} from './middlewares.js'; +export { NotionHtmlTransformer } from './notion-html.js'; +export { createAssetsArchive, download } from './utils.js'; +export { ZipTransformer } from './zip.js'; diff --git a/blocksuite/blocks/src/_common/transformers/markdown.ts b/blocksuite/blocks/src/_common/transformers/markdown.ts new file mode 100644 index 0000000000..3b7b6b61c5 --- /dev/null +++ b/blocksuite/blocks/src/_common/transformers/markdown.ts @@ -0,0 +1,217 @@ +import { BlockSuiteError, ErrorCode } from '@blocksuite/global/exceptions'; +import { assertExists, sha } from '@blocksuite/global/utils'; +import type { Doc, DocCollection } from '@blocksuite/store'; +import { extMimeMap, Job } from '@blocksuite/store'; + +import { MarkdownAdapter } from '../adapters/markdown/index.js'; +import { + defaultImageProxyMiddleware, + docLinkBaseURLMiddleware, + fileNameMiddleware, + titleMiddleware, +} from './middlewares.js'; +import { createAssetsArchive, download, Unzip } from './utils.js'; + +type ImportMarkdownToBlockOptions = { + doc: Doc; + markdown: string; + blockId: string; +}; + +type ImportMarkdownToDocOptions = { + collection: DocCollection; + markdown: string; + fileName?: string; +}; + +type ImportMarkdownZipOptions = { + collection: DocCollection; + imported: Blob; +}; + +/** + * Exports a doc to a Markdown file or a zip archive containing Markdown and assets. + * @param doc The doc to export + * @returns A Promise that resolves when the export is complete + */ +async function exportDoc(doc: Doc) { + const job = new Job({ + collection: doc.collection, + middlewares: [docLinkBaseURLMiddleware, titleMiddleware], + }); + const snapshot = job.docToSnapshot(doc); + + const adapter = new MarkdownAdapter(job); + if (!snapshot) { + return; + } + + const markdownResult = await adapter.fromDocSnapshot({ + snapshot, + assets: job.assetsManager, + }); + + let downloadBlob: Blob; + const docTitle = doc.meta?.title || 'Untitled'; + let name: string; + const contentBlob = new Blob([markdownResult.file], { type: 'plain/text' }); + if (markdownResult.assetsIds.length > 0) { + if (!job.assets) { + throw new BlockSuiteError(ErrorCode.ValueNotExists, 'No assets found'); + } + const zip = await createAssetsArchive(job.assets, markdownResult.assetsIds); + + await zip.file('index.md', contentBlob); + + downloadBlob = await zip.generate(); + name = `${docTitle}.zip`; + } else { + downloadBlob = contentBlob; + name = `${docTitle}.md`; + } + download(downloadBlob, name); +} + +/** + * Imports Markdown content into a specific block within a doc. + * @param options Object containing import options + * @param options.doc The target doc + * @param options.markdown The Markdown content to import + * @param options.blockId The ID of the block where the content will be imported + * @returns A Promise that resolves when the import is complete + */ +async function importMarkdownToBlock({ + doc, + markdown, + blockId, +}: ImportMarkdownToBlockOptions) { + const job = new Job({ + collection: doc.collection, + middlewares: [defaultImageProxyMiddleware, docLinkBaseURLMiddleware], + }); + const adapter = new MarkdownAdapter(job); + const snapshot = await adapter.toSliceSnapshot({ + file: markdown, + assets: job.assetsManager, + workspaceId: doc.collection.id, + pageId: doc.id, + }); + + assertExists(snapshot, 'import markdown failed, expected to get a snapshot'); + + const blocks = snapshot.content.flatMap(x => x.children); + + for (const block of blocks) { + await job.snapshotToBlock(block, doc, blockId); + } + + return; +} + +/** + * Imports Markdown content into a new doc within a collection. + * @param options Object containing import options + * @param options.collection The target doc collection + * @param options.markdown The Markdown content to import + * @param options.fileName Optional filename for the imported doc + * @returns A Promise that resolves to the ID of the newly created doc, or undefined if import fails + */ +async function importMarkdownToDoc({ + collection, + markdown, + fileName, +}: ImportMarkdownToDocOptions) { + const job = new Job({ + collection, + middlewares: [ + defaultImageProxyMiddleware, + fileNameMiddleware(fileName), + docLinkBaseURLMiddleware, + ], + }); + const mdAdapter = new MarkdownAdapter(job); + const page = await mdAdapter.toDoc({ + file: markdown, + assets: job.assetsManager, + }); + if (!page) { + return; + } + return page.id; +} + +/** + * Imports a zip file containing Markdown files and assets into a collection. + * @param options Object containing import options + * @param options.collection The target doc collection + * @param options.imported The zip file as a Blob + * @returns A Promise that resolves to an array of IDs of the newly created docs + */ +async function importMarkdownZip({ + collection, + imported, +}: ImportMarkdownZipOptions) { + const unzip = new Unzip(); + await unzip.load(imported); + + const docIds: string[] = []; + const pendingAssets = new Map<string, File>(); + const pendingPathBlobIdMap = new Map<string, string>(); + const markdownBlobs: [string, Blob][] = []; + + for (const { path, content: blob } of unzip) { + if (path.includes('__MACOSX') || path.includes('.DS_Store')) { + continue; + } + + const fileName = path.split('/').pop() ?? ''; + if (fileName.endsWith('.md')) { + markdownBlobs.push([fileName, blob]); + } else { + const ext = path.split('.').at(-1) ?? ''; + const mime = extMimeMap.get(ext) ?? ''; + const key = await sha(await blob.arrayBuffer()); + pendingPathBlobIdMap.set(path, key); + pendingAssets.set(key, new File([blob], fileName, { type: mime })); + } + } + + await Promise.all( + markdownBlobs.map(async ([fileName, blob]) => { + const fileNameWithoutExt = fileName.replace(/\.[^/.]+$/, ''); + const job = new Job({ + collection, + middlewares: [ + defaultImageProxyMiddleware, + fileNameMiddleware(fileNameWithoutExt), + docLinkBaseURLMiddleware, + ], + }); + const assets = job.assets; + const pathBlobIdMap = job.assetsManager.getPathBlobIdMap(); + for (const [key, value] of pendingAssets.entries()) { + assets.set(key, value); + } + for (const [key, value] of pendingPathBlobIdMap.entries()) { + pathBlobIdMap.set(key, value); + } + const mdAdapter = new MarkdownAdapter(job); + const markdown = await blob.text(); + const doc = await mdAdapter.toDoc({ + file: markdown, + assets: job.assetsManager, + }); + if (doc) { + docIds.push(doc.id); + } + }) + ); + return docIds; +} + +export const MarkdownTransformer = { + exportDoc, + importMarkdownToBlock, + importMarkdownToDoc, + importMarkdownZip, +}; diff --git a/blocksuite/blocks/src/_common/transformers/middlewares.ts b/blocksuite/blocks/src/_common/transformers/middlewares.ts new file mode 100644 index 0000000000..32e8159964 --- /dev/null +++ b/blocksuite/blocks/src/_common/transformers/middlewares.ts @@ -0,0 +1,292 @@ +import type { + DatabaseBlockModel, + EmbedLinkedDocModel, + EmbedSyncedDocModel, + ListBlockModel, + ParagraphBlockModel, + SurfaceRefBlockModel, +} from '@blocksuite/affine-model'; +import { assertExists } from '@blocksuite/global/utils'; +import type { DeltaOperation, JobMiddleware } from '@blocksuite/store'; + +import { DEFAULT_IMAGE_PROXY_ENDPOINT } from '../consts.js'; + +export const replaceIdMiddleware: JobMiddleware = ({ slots, collection }) => { + const idMap = new Map<string, string>(); + slots.afterImport.on(payload => { + if ( + payload.type === 'block' && + payload.snapshot.flavour === 'affine:database' + ) { + const model = payload.model as DatabaseBlockModel; + Object.keys(model.cells).forEach(cellId => { + if (idMap.has(cellId)) { + model.cells[idMap.get(cellId)!] = model.cells[cellId]; + delete model.cells[cellId]; + } + }); + } + + // replace LinkedPage pageId with new id in paragraph blocks + if ( + payload.type === 'block' && + ['affine:list', 'affine:paragraph'].includes(payload.snapshot.flavour) + ) { + const model = payload.model as ParagraphBlockModel | ListBlockModel; + let prev = 0; + const delta: DeltaOperation[] = []; + for (const d of model.text.toDelta()) { + if (d.attributes?.reference?.pageId) { + const newId = idMap.get(d.attributes.reference.pageId); + if (!newId) { + prev += d.insert?.length ?? 0; + continue; + } + + if (prev > 0) { + delta.push({ retain: prev }); + } + + delta.push({ + retain: d.insert?.length ?? 0, + attributes: { + reference: { + ...d.attributes.reference, + pageId: newId, + }, + }, + }); + prev = 0; + } else { + prev += d.insert?.length ?? 0; + } + } + if (delta.length > 0) { + model.text.applyDelta(delta); + } + } + + if ( + payload.type === 'block' && + payload.snapshot.flavour === 'affine:surface-ref' + ) { + const model = payload.model as SurfaceRefBlockModel; + const original = model.reference; + // If there exists a replacement, replace the reference with the new id. + // Otherwise, + // 1. If the reference is an affine:frame not in doc, generate a new id. + // 2. If the reference is graph, keep the original id. + if (idMap.has(original)) { + model.reference = idMap.get(original)!; + } else if ( + model.refFlavour === 'affine:frame' && + !model.doc.hasBlock(original) + ) { + const newId = collection.idGenerator(); + idMap.set(original, newId); + model.reference = newId; + } + } + + // TODO(@fundon): process linked block/element + if ( + payload.type === 'block' && + ['affine:embed-linked-doc', 'affine:embed-synced-doc'].includes( + payload.snapshot.flavour + ) + ) { + const model = payload.model as EmbedLinkedDocModel | EmbedSyncedDocModel; + const original = model.pageId; + // If the pageId is not in the doc, generate a new id. + // If we already have a replacement, use it. + if (!collection.getDoc(original)) { + if (idMap.has(original)) { + model.pageId = idMap.get(original)!; + } else { + const newId = collection.idGenerator(); + idMap.set(original, newId); + model.pageId = newId; + } + } + } + }); + slots.beforeImport.on(payload => { + if (payload.type === 'page') { + if (idMap.has(payload.snapshot.meta.id)) { + payload.snapshot.meta.id = idMap.get(payload.snapshot.meta.id)!; + return; + } + const newId = collection.idGenerator(); + idMap.set(payload.snapshot.meta.id, newId); + payload.snapshot.meta.id = newId; + return; + } + + if (payload.type === 'block') { + const { snapshot } = payload; + if (snapshot.flavour === 'affine:page') { + const index = snapshot.children.findIndex( + c => c.flavour === 'affine:surface' + ); + if (index !== -1) { + const [surface] = snapshot.children.splice(index, 1); + snapshot.children.push(surface); + } + } + + const original = snapshot.id; + let newId: string; + if (idMap.has(original)) { + newId = idMap.get(original)!; + } else { + newId = collection.idGenerator(); + idMap.set(original, newId); + } + snapshot.id = newId; + + if (snapshot.flavour === 'affine:surface') { + // Generate new IDs for images and frames in advance. + snapshot.children.forEach(child => { + const original = child.id; + if (idMap.has(original)) { + newId = idMap.get(original)!; + } else { + newId = collection.idGenerator(); + idMap.set(original, newId); + } + }); + + Object.entries( + snapshot.props.elements as Record<string, Record<string, unknown>> + ).forEach(([_, value]) => { + switch (value.type) { + case 'connector': { + let connection = value.source as Record<string, string>; + if (idMap.has(connection.id)) { + const newId = idMap.get(connection.id); + assertExists(newId, 'reference id must exist'); + connection.id = newId; + } + connection = value.target as Record<string, string>; + if (idMap.has(connection.id)) { + const newId = idMap.get(connection.id); + assertExists(newId, 'reference id must exist'); + connection.id = newId; + } + break; + } + case 'group': { + // @ts-expect-error FIXME: ts error + const json = value.children.json as Record<string, unknown>; + Object.entries(json).forEach(([key, value]) => { + if (idMap.has(key)) { + delete json[key]; + const newKey = idMap.get(key); + assertExists(newKey, 'reference id must exist'); + json[newKey] = value; + } + }); + break; + } + default: + break; + } + }); + } + } + }); +}; + +export const customImageProxyMiddleware = ( + imageProxyURL: string +): JobMiddleware => { + return ({ adapterConfigs }) => { + adapterConfigs.set('imageProxy', imageProxyURL); + }; +}; + +const customDocLinkBaseUrlMiddleware = (baseUrl: string): JobMiddleware => { + return ({ adapterConfigs, collection }) => { + const docLinkBaseUrl = baseUrl + ? `${baseUrl}/workspace/${collection.id}` + : ''; + adapterConfigs.set('docLinkBaseUrl', docLinkBaseUrl); + }; +}; + +export const titleMiddleware: JobMiddleware = ({ + slots, + collection, + adapterConfigs, +}) => { + slots.beforeExport.on(() => { + for (const meta of collection.meta.docMetas) { + adapterConfigs.set('title:' + meta.id, meta.title); + } + }); +}; + +export const docLinkBaseURLMiddlewareBuilder = (baseUrl: string) => { + let middleware = customDocLinkBaseUrlMiddleware(baseUrl); + return { + get: () => middleware, + set: (url: string) => { + middleware = customDocLinkBaseUrlMiddleware(url); + }, + }; +}; + +const defaultDocLinkBaseURLMiddlewareBuilder = docLinkBaseURLMiddlewareBuilder( + typeof window !== 'undefined' ? window.location.origin : '.' +); + +export const docLinkBaseURLMiddleware = + defaultDocLinkBaseURLMiddlewareBuilder.get(); + +export const setDocLinkBaseURLMiddleware = + defaultDocLinkBaseURLMiddlewareBuilder.set; + +const imageProxyMiddlewareBuilder = () => { + let middleware = customImageProxyMiddleware(DEFAULT_IMAGE_PROXY_ENDPOINT); + return { + get: () => middleware, + set: (url: string) => { + middleware = customImageProxyMiddleware(url); + }, + }; +}; + +const defaultImageProxyMiddlewarBuilder = imageProxyMiddlewareBuilder(); + +export const setImageProxyMiddlewareURL = defaultImageProxyMiddlewarBuilder.set; + +export const defaultImageProxyMiddleware = + defaultImageProxyMiddlewarBuilder.get(); + +export const embedSyncedDocMiddleware = + (type: 'content'): JobMiddleware => + ({ adapterConfigs }) => { + adapterConfigs.set('embedSyncedDocExportType', type); + }; + +export const fileNameMiddleware = + (fileName?: string): JobMiddleware => + ({ slots }) => { + slots.beforeImport.on(payload => { + if (payload.type !== 'page') { + return; + } + if (!fileName) { + return; + } + payload.snapshot.meta.title = fileName; + payload.snapshot.blocks.props.title = { + '$blocksuite:internal:text$': true, + delta: [ + { + insert: fileName, + }, + ], + }; + }); + }; diff --git a/blocksuite/blocks/src/_common/transformers/notion-html.ts b/blocksuite/blocks/src/_common/transformers/notion-html.ts new file mode 100644 index 0000000000..bdddd16bb1 --- /dev/null +++ b/blocksuite/blocks/src/_common/transformers/notion-html.ts @@ -0,0 +1,146 @@ +import { sha } from '@blocksuite/global/utils'; +import { type DocCollection, extMimeMap, Job } from '@blocksuite/store'; + +import { NotionHtmlAdapter } from '../adapters/notion-html/notion-html.js'; +import { defaultImageProxyMiddleware } from './middlewares.js'; +import { Unzip } from './utils.js'; + +type ImportNotionZipOptions = { + collection: DocCollection; + imported: Blob; +}; + +/** + * Imports a Notion zip file into the BlockSuite collection. + * + * @param {ImportNotionZipOptions} options - The options for importing. + * @param {DocCollection} options.collection - The BlockSuite document collection. + * @param {Blob} options.imported - The imported zip file as a Blob. + * + * @returns {Promise<{entryId: string | undefined, pageIds: string[], isWorkspaceFile: boolean, hasMarkdown: boolean}>} + * A promise that resolves to an object containing: + * - entryId: The ID of the entry page (if any). + * - pageIds: An array of imported page IDs. + * - isWorkspaceFile: Whether the imported file is a workspace file. + * - hasMarkdown: Whether the zip contains markdown files. + */ +async function importNotionZip({ + collection, + imported, +}: ImportNotionZipOptions) { + const pageIds: string[] = []; + let isWorkspaceFile = false; + let hasMarkdown = false; + let entryId: string | undefined; + const parseZipFile = async (path: File | Blob) => { + const unzip = new Unzip(); + await unzip.load(path); + const zipFile = new Map<string, Blob>(); + const pageMap = new Map<string, string>(); + const pagePaths: string[] = []; + const promises: Promise<void>[] = []; + const pendingAssets = new Map<string, Blob>(); + const pendingPathBlobIdMap = new Map<string, string>(); + for (const { path, content, index } of unzip) { + if (path.startsWith('__MACOSX/')) continue; + + zipFile.set(path, content); + + const lastSplitIndex = path.lastIndexOf('/'); + + const fileName = path.substring(lastSplitIndex + 1); + if (fileName.endsWith('.md')) { + hasMarkdown = true; + continue; + } + if (fileName.endsWith('.html')) { + if (path.endsWith('/index.html')) { + isWorkspaceFile = true; + continue; + } + if (lastSplitIndex !== -1) { + const text = await content.text(); + const doc = new DOMParser().parseFromString(text, 'text/html'); + const pageBody = doc.querySelector('.page-body'); + if (pageBody && pageBody.children.length === 0) { + // Skip empty pages + continue; + } + } + const id = collection.idGenerator(); + const splitPath = path.split('/'); + while (splitPath.length > 0) { + pageMap.set(splitPath.join('/'), id); + splitPath.shift(); + } + pagePaths.push(path); + if (entryId === undefined && lastSplitIndex === -1) { + entryId = id; + } + continue; + } + if (index === 0 && fileName.endsWith('.csv')) { + window.open( + 'https://affine.pro/blog/import-your-data-from-notion-into-affine', + '_blank' + ); + continue; + } + if (fileName.endsWith('.zip')) { + const innerZipFile = content; + if (innerZipFile) { + promises.push(...(await parseZipFile(innerZipFile))); + } + continue; + } + const blob = content; + const ext = path.split('.').at(-1) ?? ''; + const mime = extMimeMap.get(ext) ?? ''; + const key = await sha(await blob.arrayBuffer()); + const filePathSplit = path.split('/'); + while (filePathSplit.length > 1) { + pendingPathBlobIdMap.set(filePathSplit.join('/'), key); + filePathSplit.shift(); + } + pendingAssets.set(key, new File([blob], fileName, { type: mime })); + } + const pagePromises = Array.from(pagePaths).map(async path => { + const job = new Job({ + collection: collection, + middlewares: [defaultImageProxyMiddleware], + }); + const htmlAdapter = new NotionHtmlAdapter(job); + const assets = job.assetsManager.getAssets(); + const pathBlobIdMap = job.assetsManager.getPathBlobIdMap(); + for (const [key, value] of pendingAssets.entries()) { + if (!assets.has(key)) { + assets.set(key, value); + } + } + for (const [key, value] of pendingPathBlobIdMap.entries()) { + if (!pathBlobIdMap.has(key)) { + pathBlobIdMap.set(key, value); + } + } + const page = await htmlAdapter.toDoc({ + file: await zipFile.get(path)!.text(), + pageId: pageMap.get(path), + pageMap, + assets: job.assetsManager, + }); + if (page) { + pageIds.push(page.id); + } + }); + promises.push(...pagePromises); + return promises; + }; + const allPromises = await parseZipFile(imported); + await Promise.all(allPromises.flat()); + entryId = entryId ?? pageIds[0]; + return { entryId, pageIds, isWorkspaceFile, hasMarkdown }; +} + +export const NotionHtmlTransformer = { + importNotionZip, +}; diff --git a/blocksuite/blocks/src/_common/transformers/utils.ts b/blocksuite/blocks/src/_common/transformers/utils.ts new file mode 100644 index 0000000000..cf32561563 --- /dev/null +++ b/blocksuite/blocks/src/_common/transformers/utils.ts @@ -0,0 +1,115 @@ +import { extMimeMap, getAssetName } from '@blocksuite/store'; +import * as fflate from 'fflate'; + +export class Zip { + private compressed = new Uint8Array(); + + private finalize?: () => void; + + private finalized = false; + + private zip = new fflate.Zip((err, chunk, final) => { + if (!err) { + const temp = new Uint8Array(this.compressed.length + chunk.length); + temp.set(this.compressed); + temp.set(chunk, this.compressed.length); + this.compressed = temp; + } + if (final) { + this.finalized = true; + this.finalize?.(); + } + }); + + async file(path: string, content: Blob | File | string) { + const deflate = new fflate.ZipDeflate(path); + this.zip.add(deflate); + if (typeof content === 'string') { + deflate.push(fflate.strToU8(content), true); + } else { + deflate.push(new Uint8Array(await content.arrayBuffer()), true); + } + } + + folder(folderPath: string) { + return { + folder: (folderPath2: string) => { + return this.folder(`${folderPath}/${folderPath2}`); + }, + file: async (name: string, blob: Blob) => { + await this.file(`${folderPath}/${name}`, blob); + }, + generate: async () => { + return this.generate(); + }, + }; + } + + async generate() { + this.zip.end(); + return new Promise<Blob>(resolve => { + if (this.finalized) { + resolve(new Blob([this.compressed], { type: 'application/zip' })); + } else { + this.finalize = () => + resolve(new Blob([this.compressed], { type: 'application/zip' })); + } + }); + } +} + +export class Unzip { + private unzipped?: ReturnType<typeof fflate.unzipSync>; + + async load(blob: Blob) { + this.unzipped = fflate.unzipSync(new Uint8Array(await blob.arrayBuffer())); + } + + *[Symbol.iterator]() { + const keys = Object.keys(this.unzipped ?? {}); + let index = 0; + while (keys.length) { + const path = keys.shift()!; + if (path.includes('__MACOSX') || path.includes('DS_Store')) { + continue; + } + const lastSplitIndex = path.lastIndexOf('/'); + const fileName = path.substring(lastSplitIndex + 1); + const fileExt = + fileName.lastIndexOf('.') === -1 ? '' : fileName.split('.').at(-1); + const mime = extMimeMap.get(fileExt ?? ''); + const content = new File([this.unzipped![path]], fileName, { + type: mime ?? '', + }) as Blob; + yield { path, content, index }; + index++; + } + } +} + +export async function createAssetsArchive( + assetsMap: Map<string, Blob>, + assetsIds: string[] +) { + const zip = new Zip(); + + for (const [id, blob] of assetsMap) { + if (!assetsIds.includes(id)) continue; + const name = getAssetName(assetsMap, id); + await zip.folder('assets').file(name, blob); + } + + return zip; +} + +export function download(blob: Blob, name: string) { + const element = document.createElement('a'); + element.setAttribute('download', name); + const fileURL = URL.createObjectURL(blob); + element.setAttribute('href', fileURL); + element.style.display = 'none'; + document.body.append(element); + element.click(); + element.remove(); + URL.revokeObjectURL(fileURL); +} diff --git a/blocksuite/blocks/src/_common/transformers/zip.ts b/blocksuite/blocks/src/_common/transformers/zip.ts new file mode 100644 index 0000000000..939b16f0d5 --- /dev/null +++ b/blocksuite/blocks/src/_common/transformers/zip.ts @@ -0,0 +1,120 @@ +import { sha } from '@blocksuite/global/utils'; +import type { Doc, DocCollection, DocSnapshot } from '@blocksuite/store'; +import { extMimeMap, getAssetName, Job } from '@blocksuite/store'; + +import { download, Unzip, Zip } from '../transformers/utils.js'; +import { replaceIdMiddleware, titleMiddleware } from './middlewares.js'; + +async function exportDocs(collection: DocCollection, docs: Doc[]) { + const zip = new Zip(); + const job = new Job({ collection }); + const snapshots = await Promise.all(docs.map(job.docToSnapshot)); + + const collectionInfo = job.collectionInfoToSnapshot(); + await zip.file('info.json', JSON.stringify(collectionInfo, null, 2)); + + await Promise.all( + snapshots + .filter((snapshot): snapshot is DocSnapshot => !!snapshot) + .map(async snapshot => { + const snapshotName = `${snapshot.meta.id}.snapshot.json`; + await zip.file(snapshotName, JSON.stringify(snapshot, null, 2)); + }) + ); + + const assets = zip.folder('assets'); + const assetsMap = job.assets; + + for (const [id, blob] of assetsMap) { + const ext = getAssetName(assetsMap, id).split('.').at(-1); + const name = `${id}.${ext}`; + await assets.file(name, blob); + } + + const downloadBlob = await zip.generate(); + return download(downloadBlob, `${collection.id}.bs.zip`); +} + +async function importDocs(collection: DocCollection, imported: Blob) { + const unzip = new Unzip(); + await unzip.load(imported); + + const assetBlobs: [string, Blob][] = []; + const snapshotsBlobs: Blob[] = []; + + for (const { path, content: blob } of unzip) { + if (path.includes('MACOSX') || path.includes('DS_Store')) { + continue; + } + + if (path.startsWith('assets/')) { + assetBlobs.push([path, blob]); + continue; + } + + if (path === 'info.json') { + continue; + } + + if (path.endsWith('.snapshot.json')) { + snapshotsBlobs.push(blob); + continue; + } + } + + const job = new Job({ + collection, + middlewares: [replaceIdMiddleware, titleMiddleware], + }); + const assetsMap = job.assets; + + assetBlobs.forEach(([name, blob]) => { + const nameWithExt = name.replace('assets/', ''); + const assetsId = nameWithExt.replace(/\.[^/.]+$/, ''); + const ext = nameWithExt.split('.').at(-1) ?? ''; + const mime = extMimeMap.get(ext) ?? ''; + const file = new File([blob], nameWithExt, { + type: mime, + }); + assetsMap.set(assetsId, file); + }); + + return Promise.all( + snapshotsBlobs.map(async blob => { + const json = await blob.text(); + const snapshot = JSON.parse(json) as DocSnapshot; + const tasks: Promise<void>[] = []; + + job.walk(snapshot, block => { + const sourceId = block.props?.sourceId as string | undefined; + + if (sourceId && sourceId.startsWith('/')) { + const removeSlashId = sourceId.replace(/^\//, ''); + + if (assetsMap.has(removeSlashId)) { + const blob = assetsMap.get(removeSlashId)!; + + tasks.push( + blob + .arrayBuffer() + .then(buffer => sha(buffer)) + .then(hash => { + assetsMap.set(hash, blob); + block.props.sourceId = hash; + }) + ); + } + } + }); + + await Promise.all(tasks); + + return job.snapshotToDoc(snapshot); + }) + ); +} + +export const ZipTransformer = { + exportDocs, + importDocs, +}; diff --git a/blocksuite/blocks/src/_common/types.ts b/blocksuite/blocks/src/_common/types.ts new file mode 100644 index 0000000000..b79cc6ab6f --- /dev/null +++ b/blocksuite/blocks/src/_common/types.ts @@ -0,0 +1,28 @@ +import type { + BrushElementModel, + ConnectorElementModel, + DocMode, + GroupElementModel, +} from '@blocksuite/affine-model'; +import type { Slot } from '@blocksuite/global/utils'; +import type { Doc } from '@blocksuite/store'; + +/** Common context interface definition for block models. */ + +type EditorSlots = { + docUpdated: Slot<{ newDocId: string }>; +}; + +export type AbstractEditor = { + doc: Doc; + mode: DocMode; + readonly slots: EditorSlots; +} & HTMLElement; + +export type Connectable = Exclude< + BlockSuite.EdgelessModel, + ConnectorElementModel | BrushElementModel | GroupElementModel +>; + +export type { EmbedCardStyle } from '@blocksuite/affine-model'; +export * from '@blocksuite/affine-shared/types'; diff --git a/blocksuite/blocks/src/_common/utils/drag-and-drop.ts b/blocksuite/blocks/src/_common/utils/drag-and-drop.ts new file mode 100644 index 0000000000..6c03d377cb --- /dev/null +++ b/blocksuite/blocks/src/_common/utils/drag-and-drop.ts @@ -0,0 +1,174 @@ +import { + getClosestBlockComponentByElement, + getRectByBlockComponent, + matchFlavours, +} from '@blocksuite/affine-shared/utils'; +import type { BlockComponent } from '@blocksuite/block-std'; +import { type Point, Rect } from '@blocksuite/global/utils'; +import type { BlockModel } from '@blocksuite/store'; + +import type { EditingState } from '../types.js'; +import { DropFlags, getDropRectByPoint } from './query.js'; + +/** + * A dropping type. + */ +export type DroppingType = 'none' | 'before' | 'after' | 'database'; + +export type DropResult = { + type: DroppingType; + rect: Rect; + modelState: EditingState; +}; + +/** + * Calculates the drop target. + */ +export function calcDropTarget( + point: Point, + model: BlockModel, + element: Element, + draggingElements: BlockComponent[] = [], + scale: number = 1, + flavour: string | null = null // for block-hub +): DropResult | null { + const schema = model.doc.getSchemaByFlavour('affine:database'); + const children = schema?.model.children ?? []; + + let shouldAppendToDatabase = true; + + if (children.length) { + if (draggingElements.length) { + shouldAppendToDatabase = draggingElements + .map(el => el.model) + .every(m => children.includes(m.flavour)); + } else if (flavour) { + shouldAppendToDatabase = children.includes(flavour); + } + } + + if (!shouldAppendToDatabase && !matchFlavours(model, ['affine:database'])) { + const databaseBlockComponent = element.closest('affine-database'); + if (databaseBlockComponent) { + element = databaseBlockComponent; + model = databaseBlockComponent.model; + } + } + + let type: DroppingType = 'none'; + const height = 3 * scale; + const { rect: domRect, flag } = getDropRectByPoint(point, model, element); + + if (flag === DropFlags.EmptyDatabase) { + // empty database + const rect = Rect.fromDOMRect(domRect); + rect.top -= height / 2; + rect.height = height; + type = 'database'; + + return { + type, + rect, + modelState: { + model, + rect: domRect, + element: element as BlockComponent, + }, + }; + } else if (flag === DropFlags.Database) { + // not empty database + const distanceToTop = Math.abs(domRect.top - point.y); + const distanceToBottom = Math.abs(domRect.bottom - point.y); + const before = distanceToTop < distanceToBottom; + type = before ? 'before' : 'after'; + + return { + type, + rect: Rect.fromLWTH( + domRect.left, + domRect.width, + (before ? domRect.top - 1 : domRect.bottom) - height / 2, + height + ), + modelState: { + model, + rect: domRect, + element: element as BlockComponent, + }, + }; + } + + const distanceToTop = Math.abs(domRect.top - point.y); + const distanceToBottom = Math.abs(domRect.bottom - point.y); + const before = distanceToTop < distanceToBottom; + + type = before ? 'before' : 'after'; + let offsetY = 4; + + if (type === 'before') { + // before + let prev; + let prevRect; + + prev = element.previousElementSibling; + if (prev) { + if ( + draggingElements.length && + prev === draggingElements[draggingElements.length - 1] + ) { + type = 'none'; + } else { + prevRect = getRectByBlockComponent(prev); + } + } else { + prev = element.parentElement?.previousElementSibling; + if (prev) { + prevRect = prev.getBoundingClientRect(); + } + } + + if (prevRect) { + offsetY = (domRect.top - prevRect.bottom) / 2; + } + } else { + // after + let next; + let nextRect; + + next = element.nextElementSibling; + if (next) { + if (draggingElements.length && next === draggingElements[0]) { + type = 'none'; + next = null; + } + } else { + next = getClosestBlockComponentByElement( + element.parentElement + )?.nextElementSibling; + } + + if (next) { + nextRect = getRectByBlockComponent(next); + offsetY = (nextRect.top - domRect.bottom) / 2; + } + } + + if (type === 'none') return null; + + let top = domRect.top; + if (type === 'before') { + top -= offsetY; + } else { + top += domRect.height + offsetY; + } + + return { + type, + rect: Rect.fromLWTH(domRect.left, domRect.width, top - height / 2, height), + modelState: { + model, + rect: domRect, + element: element as BlockComponent, + }, + }; +} diff --git a/blocksuite/blocks/src/_common/utils/index.ts b/blocksuite/blocks/src/_common/utils/index.ts new file mode 100644 index 0000000000..ecb289f2ff --- /dev/null +++ b/blocksuite/blocks/src/_common/utils/index.ts @@ -0,0 +1,17 @@ +// Compat with SSR +export * from '../types.js'; +export * from './drag-and-drop.js'; +export * from './query.js'; +export { + createButtonPopper, + getBlockProps, + getImageFilesFromLocal, + isMiddleButtonPressed, + isRightButtonPressed, + isValidUrl, + matchFlavours, + on, + once, + openFileOrFiles, + requestThrottledConnectedFrame, +} from '@blocksuite/affine-shared/utils'; diff --git a/blocksuite/blocks/src/_common/utils/query.ts b/blocksuite/blocks/src/_common/utils/query.ts new file mode 100644 index 0000000000..f70139ee5e --- /dev/null +++ b/blocksuite/blocks/src/_common/utils/query.ts @@ -0,0 +1,211 @@ +import { + getRectByBlockComponent, + matchFlavours, +} from '@blocksuite/affine-shared/utils'; +import { BLOCK_ID_ATTR, type EditorHost } from '@blocksuite/block-std'; +import type { Point } from '@blocksuite/global/utils'; +import { assertExists } from '@blocksuite/global/utils'; +import type { BlockModel } from '@blocksuite/store'; + +import type { RootBlockComponent } from '../../index.js'; + +const ATTR_SELECTOR = `[${BLOCK_ID_ATTR}]`; + +/** + * This function is used to build model's "normal" block path. + * If this function does not meet your needs, you may need to build path manually to satisfy your needs. + * You should not modify this function. + */ +export function buildPath(model: BlockModel | null): string[] { + const path: string[] = []; + let current = model; + while (current) { + path.unshift(current.id); + current = current.doc.getParent(current); + } + return path; +} + +export function getRootByEditorHost( + editorHost: EditorHost +): RootBlockComponent | null { + return ( + getPageRootByEditorHost(editorHost) ?? + getEdgelessRootByEditorHost(editorHost) + ); +} + +/** If it's not in the page mode, it will return `null` directly */ +export function getPageRootByEditorHost(editorHost: EditorHost) { + return editorHost.querySelector('affine-page-root'); +} + +/** If it's not in the edgeless mode, it will return `null` directly */ +export function getEdgelessRootByEditorHost(editorHost: EditorHost) { + return editorHost.querySelector('affine-edgeless-root'); +} + +/** + * Get block component by model. + * Note that this function is used for compatibility only, and may be removed in the future. + * + * @deprecated + */ +export function getBlockComponentByModel( + editorHost: EditorHost, + model: BlockModel | null +) { + if (!model) return null; + return editorHost.view.getBlock(model.id); +} + +function isEdgelessChildNote({ classList }: Element) { + return classList.contains('note-background'); +} + +/** + * Get hovering note with given a point in edgeless mode. + */ +export function getHoveringNote(point: Point) { + return ( + document.elementsFromPoint(point.x, point.y).find(isEdgelessChildNote) || + null + ); +} + +/** + * Gets the table of the database. + */ +function getDatabaseBlockTableElement(element: Element) { + return element.querySelector('.affine-database-block-table'); +} + +/** + * Gets the column header of the database. + */ +function getDatabaseBlockColumnHeaderElement(element: Element) { + return element.querySelector('.affine-database-column-header'); +} + +/** + * Gets the rows of the database. + */ +function getDatabaseBlockRowsElement(element: Element) { + return element.querySelector('.affine-database-block-rows'); +} + +/** + * Returns a flag for the drop target. + */ +export enum DropFlags { + Normal, + Database, + EmptyDatabase, +} + +/** + * Gets the drop rect by block and point. + */ +export function getDropRectByPoint( + point: Point, + model: BlockModel, + element: Element +): { + rect: DOMRect; + flag: DropFlags; +} { + const result = { + rect: getRectByBlockComponent(element), + flag: DropFlags.Normal, + }; + + const isDatabase = matchFlavours(model, ['affine:database']); + + if (isDatabase) { + const table = getDatabaseBlockTableElement(element); + if (!table) { + return result; + } + let bounds = table.getBoundingClientRect(); + if (model.isEmpty.value) { + result.flag = DropFlags.EmptyDatabase; + + if (point.y < bounds.top) return result; + + const header = getDatabaseBlockColumnHeaderElement(element); + assertExists(header); + bounds = header.getBoundingClientRect(); + result.rect = new DOMRect( + result.rect.left, + bounds.bottom, + result.rect.width, + 1 + ); + } else { + result.flag = DropFlags.Database; + const rows = getDatabaseBlockRowsElement(element); + assertExists(rows); + const rowsBounds = rows.getBoundingClientRect(); + + if (point.y < rowsBounds.top || point.y > rowsBounds.bottom) + return result; + + const elements = document.elementsFromPoint(point.x, point.y); + const len = elements.length; + let e; + let i = 0; + for (; i < len; i++) { + e = elements[i]; + + if (e.classList.contains('affine-database-block-row-cell-content')) { + result.rect = getCellRect(e, bounds); + return result; + } + + if (e.classList.contains('affine-database-block-row')) { + e = e.querySelector(ATTR_SELECTOR); + assertExists(e); + result.rect = getCellRect(e, bounds); + return result; + } + } + } + } else { + const parent = element.parentElement; + if (parent?.classList.contains('affine-database-block-row-cell-content')) { + result.flag = DropFlags.Database; + result.rect = getCellRect(parent); + return result; + } + } + + return result; +} + +function getCellRect(element: Element, bounds?: DOMRect) { + if (!bounds) { + const table = element.closest('.affine-database-block-table'); + assertExists(table); + bounds = table.getBoundingClientRect(); + } + // affine-database-block-row-cell + const col = element.parentElement; + assertExists(col); + // affine-database-block-row + const row = col.parentElement; + assertExists(row); + const colRect = col.getBoundingClientRect(); + return new DOMRect( + bounds.left, + colRect.top, + colRect.right - bounds.left, + colRect.height + ); +} + +/** + * Return `true` if the element has class name in the class list. + */ +export function hasClassNameInList(element: Element, classList: string[]) { + return classList.some(className => element.classList.contains(className)); +} diff --git a/blocksuite/blocks/src/_common/utils/render-linked-doc.ts b/blocksuite/blocks/src/_common/utils/render-linked-doc.ts new file mode 100644 index 0000000000..5e5c6f9878 --- /dev/null +++ b/blocksuite/blocks/src/_common/utils/render-linked-doc.ts @@ -0,0 +1,246 @@ +import type { FrameBlockModel, NoteBlockModel } from '@blocksuite/affine-model'; +import { NoteDisplayMode } from '@blocksuite/affine-model'; +import { + DocModeProvider, + NotificationProvider, +} from '@blocksuite/affine-shared/services'; +import { getBlockProps, matchFlavours } from '@blocksuite/affine-shared/utils'; +import type { EditorHost } from '@blocksuite/block-std'; +import { assertExists } from '@blocksuite/global/utils'; +import { + type BlockModel, + type BlockSnapshot, + type Doc, + type DraftModel, + Slice, +} from '@blocksuite/store'; + +import { GfxBlockModel } from '../../root-block/edgeless/block-model.js'; +import { + getElementProps, + mapFrameIds, + sortEdgelessElements, +} from '../../root-block/edgeless/utils/clone-utils.js'; +import { + isFrameBlock, + isNoteBlock, +} from '../../root-block/edgeless/utils/query.js'; +import { getSurfaceBlock } from '../../surface-ref-block/utils.js'; + +export function promptDocTitle(host: EditorHost, autofill?: string) { + const notification = host.std.getOptional(NotificationProvider); + if (!notification) return Promise.resolve(undefined); + + return notification.prompt({ + title: 'Create linked doc', + message: 'Enter a title for the new doc.', + placeholder: 'Untitled', + autofill, + confirmText: 'Confirm', + cancelText: 'Cancel', + }); +} + +export function getTitleFromSelectedModels(selectedModels: DraftModel[]) { + const firstBlock = selectedModels[0]; + if ( + matchFlavours(firstBlock, ['affine:paragraph']) && + firstBlock.type.startsWith('h') + ) { + return firstBlock.text.toString(); + } + return undefined; +} + +export function notifyDocCreated(host: EditorHost, doc: Doc) { + const notification = host.std.getOptional(NotificationProvider); + if (!notification) return; + + const abortController = new AbortController(); + const clear = () => { + doc.history.off('stack-item-added', addHandler); + doc.history.off('stack-item-popped', popHandler); + disposable.dispose(); + }; + const closeNotify = () => { + abortController.abort(); + clear(); + }; + + // edit or undo or switch doc, close notify toast + const addHandler = doc.history.on('stack-item-added', closeNotify); + const popHandler = doc.history.on('stack-item-popped', closeNotify); + const disposable = host.slots.unmounted.on(closeNotify); + + notification.notify({ + title: 'Linked doc created', + message: 'You can click undo to recovery block content', + accent: 'info', + duration: 10 * 1000, + action: { + label: 'Undo', + onClick: () => { + doc.undo(); + clear(); + }, + }, + abort: abortController.signal, + onClose: clear, + }); +} + +export function addBlocksToDoc( + targetDoc: Doc, + model: BlockModel, + parentId: string +) { + // Add current block to linked doc + const blockProps = getBlockProps(model); + const newModelId = targetDoc.addBlock( + model.flavour as BlockSuite.Flavour, + blockProps, + parentId + ); + // Add children to linked doc, parent is the new model + const children = model.children; + if (children.length > 0) { + children.forEach(child => { + addBlocksToDoc(targetDoc, child, newModelId); + }); + } +} + +export async function convertSelectedBlocksToLinkedDoc( + std: BlockSuite.Std, + doc: Doc, + selectedModels: DraftModel[] | Promise<DraftModel[]>, + docTitle?: string +) { + const models = await selectedModels; + const slice = std.clipboard.sliceToSnapshot(Slice.fromModels(doc, models)); + if (!slice) { + return; + } + const firstBlock = models[0]; + assertExists(firstBlock); + // if title undefined, use the first heading block content as doc title + const title = docTitle || getTitleFromSelectedModels(models); + const linkedDoc = createLinkedDocFromSlice(std, doc, slice.content, title); + // insert linked doc card + doc.addSiblingBlocks( + doc.getBlock(firstBlock.id)!.model, + [ + { + flavour: 'affine:embed-linked-doc', + pageId: linkedDoc.id, + }, + ], + 'before' + ); + // delete selected elements + models.forEach(model => doc.deleteBlock(model)); + return linkedDoc; +} + +export function createLinkedDocFromSlice( + std: BlockSuite.Std, + doc: Doc, + snapshots: BlockSnapshot[], + docTitle?: string +) { + // const modelsWithChildren = (list:BlockModel[]):BlockModel[]=>list.flatMap(model=>[model,...modelsWithChildren(model.children)]) + const linkedDoc = doc.collection.createDoc({}); + linkedDoc.load(() => { + const rootId = linkedDoc.addBlock('affine:page', { + title: new doc.Text(docTitle), + }); + linkedDoc.addBlock('affine:surface', {}, rootId); + const noteId = linkedDoc.addBlock('affine:note', {}, rootId); + snapshots.forEach(snapshot => { + std.clipboard + .pasteBlockSnapshot(snapshot, linkedDoc, noteId) + .catch(console.error); + }); + }); + return linkedDoc; +} + +export function createLinkedDocFromNote( + doc: Doc, + note: NoteBlockModel, + docTitle?: string +) { + const linkedDoc = doc.collection.createDoc({}); + linkedDoc.load(() => { + const rootId = linkedDoc.addBlock('affine:page', { + title: new doc.Text(docTitle), + }); + linkedDoc.addBlock('affine:surface', {}, rootId); + const blockProps = getBlockProps(note); + // keep note props & show in both mode + const noteId = linkedDoc.addBlock( + 'affine:note', + { + ...blockProps, + hidden: false, + displayMode: NoteDisplayMode.DocAndEdgeless, + }, + rootId + ); + // Add note to linked doc recursively + note.children.forEach(model => { + addBlocksToDoc(linkedDoc, model, noteId); + }); + }); + + return linkedDoc; +} + +export function createLinkedDocFromEdgelessElements( + host: EditorHost, + elements: BlockSuite.EdgelessModel[], + docTitle?: string +) { + const linkedDoc = host.doc.collection.createDoc({}); + linkedDoc.load(() => { + const rootId = linkedDoc.addBlock('affine:page', { + title: new host.doc.Text(docTitle), + }); + const surfaceId = linkedDoc.addBlock('affine:surface', {}, rootId); + const surface = getSurfaceBlock(linkedDoc); + if (!surface) return; + + const sortedElements = sortEdgelessElements(elements); + const ids = new Map<string, string>(); + sortedElements.forEach(model => { + let newId = model.id; + if (model instanceof GfxBlockModel) { + const blockProps = getBlockProps(model); + if (isNoteBlock(model)) { + newId = linkedDoc.addBlock('affine:note', blockProps, rootId); + // Add note children to linked doc recursively + model.children.forEach(model => { + addBlocksToDoc(linkedDoc, model, newId); + }); + } else { + if (isFrameBlock(model)) { + mapFrameIds(blockProps as unknown as FrameBlockModel, ids); + } + + newId = linkedDoc.addBlock( + model.flavour as BlockSuite.Flavour, + blockProps, + surfaceId + ); + } + } else { + const props = getElementProps(model, ids); + newId = surface.addElement(props); + } + ids.set(model.id, newId); + }); + }); + + host.std.get(DocModeProvider).setPrimaryMode('edgeless', linkedDoc.id); + return linkedDoc; +} diff --git a/blocksuite/blocks/src/_common/utils/url.ts b/blocksuite/blocks/src/_common/utils/url.ts new file mode 100644 index 0000000000..52b9e3a82e --- /dev/null +++ b/blocksuite/blocks/src/_common/utils/url.ts @@ -0,0 +1,89 @@ +import { + DarkLoadingIcon, + EmbedCardDarkBannerIcon, + EmbedCardDarkCubeIcon, + EmbedCardDarkHorizontalIcon, + EmbedCardDarkListIcon, + EmbedCardDarkVerticalIcon, + EmbedCardLightBannerIcon, + EmbedCardLightCubeIcon, + EmbedCardLightHorizontalIcon, + EmbedCardLightListIcon, + EmbedCardLightVerticalIcon, + LightLoadingIcon, +} from '@blocksuite/affine-components/icons'; +import { + ColorScheme, + type DocMode, + DocModes, + type ReferenceInfo, +} from '@blocksuite/affine-model'; +import type { TemplateResult } from 'lit'; + +type EmbedCardIcons = { + LoadingIcon: TemplateResult<1>; + EmbedCardBannerIcon: TemplateResult<1>; + EmbedCardHorizontalIcon: TemplateResult<1>; + EmbedCardListIcon: TemplateResult<1>; + EmbedCardVerticalIcon: TemplateResult<1>; + EmbedCardCubeIcon: TemplateResult<1>; +}; + +export function getEmbedCardIcons(theme: ColorScheme): EmbedCardIcons { + if (theme === ColorScheme.Light) { + return { + LoadingIcon: LightLoadingIcon, + EmbedCardBannerIcon: EmbedCardLightBannerIcon, + EmbedCardHorizontalIcon: EmbedCardLightHorizontalIcon, + EmbedCardListIcon: EmbedCardLightListIcon, + EmbedCardVerticalIcon: EmbedCardLightVerticalIcon, + EmbedCardCubeIcon: EmbedCardLightCubeIcon, + }; + } else { + return { + LoadingIcon: DarkLoadingIcon, + EmbedCardBannerIcon: EmbedCardDarkBannerIcon, + EmbedCardHorizontalIcon: EmbedCardDarkHorizontalIcon, + EmbedCardListIcon: EmbedCardDarkListIcon, + EmbedCardVerticalIcon: EmbedCardDarkVerticalIcon, + EmbedCardCubeIcon: EmbedCardDarkCubeIcon, + }; + } +} + +export function extractSearchParams(link: string) { + try { + const url = new URL(link); + const mode = url.searchParams.get('mode') as DocMode | undefined; + + if (mode && DocModes.includes(mode)) { + const params: ReferenceInfo['params'] = { mode: mode as DocMode }; + const blockIds = url.searchParams + .get('blockIds') + ?.trim() + .split(',') + .map(id => id.trim()) + .filter(id => id.length); + const elementIds = url.searchParams + .get('elementIds') + ?.trim() + .split(',') + .map(id => id.trim()) + .filter(id => id.length); + + if (blockIds?.length) { + params.blockIds = blockIds; + } + + if (elementIds?.length) { + params.elementIds = elementIds; + } + + return { params }; + } + } catch (err) { + console.error(err); + } + + return null; +} diff --git a/blocksuite/blocks/src/_specs/common.ts b/blocksuite/blocks/src/_specs/common.ts new file mode 100644 index 0000000000..100265d9ca --- /dev/null +++ b/blocksuite/blocks/src/_specs/common.ts @@ -0,0 +1,58 @@ +import { EmbedExtensions } from '@blocksuite/affine-block-embed'; +import { ListBlockSpec } from '@blocksuite/affine-block-list'; +import { ParagraphBlockSpec } from '@blocksuite/affine-block-paragraph'; +import { RichTextExtensions } from '@blocksuite/affine-components/rich-text'; +import { EditPropsStore } from '@blocksuite/affine-shared/services'; +import type { ExtensionType } from '@blocksuite/block-std'; + +import { + AdapterFactoryExtensions, + BlockAdapterMatcherExtensions, +} from '../_common/adapters/extension.js'; +import { AttachmentBlockSpec } from '../attachment-block/attachment-spec.js'; +import { BookmarkBlockSpec } from '../bookmark-block/bookmark-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 { + EdgelessNoteBlockSpec, + NoteBlockSpec, +} from '../note-block/note-spec.js'; + +export const CommonFirstPartyBlockSpecs: ExtensionType[] = [ + RichTextExtensions, + EditPropsStore, + ListBlockSpec, + NoteBlockSpec, + DatabaseBlockSpec, + DataViewBlockSpec, + DividerBlockSpec, + CodeBlockSpec, + ImageBlockSpec, + ParagraphBlockSpec, + BookmarkBlockSpec, + AttachmentBlockSpec, + EmbedExtensions, + BlockAdapterMatcherExtensions, + AdapterFactoryExtensions, +].flat(); + +export const EdgelessFirstPartyBlockSpecs: ExtensionType[] = [ + RichTextExtensions, + EditPropsStore, + ListBlockSpec, + EdgelessNoteBlockSpec, + DatabaseBlockSpec, + DataViewBlockSpec, + DividerBlockSpec, + CodeBlockSpec, + ImageBlockSpec, + ParagraphBlockSpec, + BookmarkBlockSpec, + AttachmentBlockSpec, + EmbedExtensions, + BlockAdapterMatcherExtensions, + AdapterFactoryExtensions, +].flat(); diff --git a/blocksuite/blocks/src/_specs/group/common.ts b/blocksuite/blocks/src/_specs/group/common.ts new file mode 100644 index 0000000000..10df7ac8ba --- /dev/null +++ b/blocksuite/blocks/src/_specs/group/common.ts @@ -0,0 +1,44 @@ +import { + EmbedFigmaBlockSpec, + EmbedGithubBlockSpec, + EmbedHtmlBlockSpec, + EmbedLinkedDocBlockSpec, + EmbedLoomBlockSpec, + EmbedSyncedDocBlockSpec, + EmbedYoutubeBlockSpec, +} from '@blocksuite/affine-block-embed'; +import { ListBlockSpec } from '@blocksuite/affine-block-list'; +import { ParagraphBlockSpec } from '@blocksuite/affine-block-paragraph'; + +import { AttachmentBlockSpec } from '../../attachment-block/attachment-spec.js'; +import { BookmarkBlockSpec } from '../../bookmark-block/bookmark-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 { + EdgelessNoteBlockSpec, + NoteBlockSpec, +} from '../../note-block/note-spec.js'; + +export { + AttachmentBlockSpec, + BookmarkBlockSpec, + CodeBlockSpec, + DatabaseBlockSpec, + DataViewBlockSpec, + DividerBlockSpec, + EdgelessNoteBlockSpec, + EmbedFigmaBlockSpec, + EmbedGithubBlockSpec, + EmbedHtmlBlockSpec, + EmbedLinkedDocBlockSpec, + EmbedLoomBlockSpec, + EmbedSyncedDocBlockSpec, + EmbedYoutubeBlockSpec, + ImageBlockSpec, + ListBlockSpec, + NoteBlockSpec, + ParagraphBlockSpec, +}; diff --git a/blocksuite/blocks/src/_specs/group/edgeless.ts b/blocksuite/blocks/src/_specs/group/edgeless.ts new file mode 100644 index 0000000000..f21777b54d --- /dev/null +++ b/blocksuite/blocks/src/_specs/group/edgeless.ts @@ -0,0 +1,16 @@ +import { EdgelessSurfaceBlockSpec } from '@blocksuite/affine-block-surface'; + +import { EdgelessTextBlockSpec } from '../../edgeless-text-block/index.js'; +import { FrameBlockSpec } from '../../frame-block/frame-spec.js'; +import { LatexBlockSpec } from '../../latex-block/latex-spec.js'; +import { EdgelessRootBlockSpec } from '../../root-block/edgeless/edgeless-root-spec.js'; +import { EdgelessSurfaceRefBlockSpec } from '../../surface-ref-block/surface-ref-spec.js'; + +export { + EdgelessRootBlockSpec, + EdgelessSurfaceBlockSpec, + EdgelessSurfaceRefBlockSpec, + EdgelessTextBlockSpec, + FrameBlockSpec, + LatexBlockSpec, +}; diff --git a/blocksuite/blocks/src/_specs/group/page.ts b/blocksuite/blocks/src/_specs/group/page.ts new file mode 100644 index 0000000000..dffb3e1584 --- /dev/null +++ b/blocksuite/blocks/src/_specs/group/page.ts @@ -0,0 +1,6 @@ +import { PageSurfaceBlockSpec } from '@blocksuite/affine-block-surface'; + +import { PageRootBlockSpec } from '../../root-block/page/page-root-spec.js'; +import { PageSurfaceRefBlockSpec } from '../../surface-ref-block/surface-ref-spec.js'; + +export { PageRootBlockSpec, PageSurfaceBlockSpec, PageSurfaceRefBlockSpec }; diff --git a/blocksuite/blocks/src/_specs/index.ts b/blocksuite/blocks/src/_specs/index.ts new file mode 100644 index 0000000000..fe3771862a --- /dev/null +++ b/blocksuite/blocks/src/_specs/index.ts @@ -0,0 +1,6 @@ +export * from './group/common.js'; +export * from './preset/edgeless-specs.js'; +export * from './preset/mobile-patch.js'; +export * from './preset/page-specs.js'; +export * from './preset/preview-specs.js'; +export { SpecBuilder, SpecProvider } from '@blocksuite/affine-shared/utils'; diff --git a/blocksuite/blocks/src/_specs/preset/edgeless-specs.ts b/blocksuite/blocks/src/_specs/preset/edgeless-specs.ts new file mode 100644 index 0000000000..4431524137 --- /dev/null +++ b/blocksuite/blocks/src/_specs/preset/edgeless-specs.ts @@ -0,0 +1,73 @@ +import { + ConnectionOverlay, + EdgelessSurfaceBlockSpec, +} from '@blocksuite/affine-block-surface'; +import { FontLoaderService } from '@blocksuite/affine-shared/services'; +import type { ExtensionType } from '@blocksuite/block-std'; + +import { EdgelessTextBlockSpec } from '../../edgeless-text-block/edgeless-text-spec.js'; +import { FrameBlockSpec } from '../../frame-block/frame-spec.js'; +import { LatexBlockSpec } from '../../latex-block/latex-spec.js'; +import { EdgelessRootBlockSpec } from '../../root-block/edgeless/edgeless-root-spec.js'; +import { + EdgelessFrameManager, + FrameOverlay, +} from '../../root-block/edgeless/frame-manager.js'; +import { BrushTool } from '../../root-block/edgeless/gfx-tool/brush-tool.js'; +import { ConnectorTool } from '../../root-block/edgeless/gfx-tool/connector-tool.js'; +import { CopilotTool } from '../../root-block/edgeless/gfx-tool/copilot-tool.js'; +import { DefaultTool } from '../../root-block/edgeless/gfx-tool/default-tool.js'; +import { MindMapIndicatorOverlay } from '../../root-block/edgeless/gfx-tool/default-tool-ext/mind-map-ext/indicator-overlay.js'; +import { EmptyTool } from '../../root-block/edgeless/gfx-tool/empty-tool.js'; +import { EraserTool } from '../../root-block/edgeless/gfx-tool/eraser-tool.js'; +import { PresentTool } from '../../root-block/edgeless/gfx-tool/frame-navigator-tool.js'; +import { FrameTool } from '../../root-block/edgeless/gfx-tool/frame-tool.js'; +import { LassoTool } from '../../root-block/edgeless/gfx-tool/lasso-tool.js'; +import { NoteTool } from '../../root-block/edgeless/gfx-tool/note-tool.js'; +import { PanTool } from '../../root-block/edgeless/gfx-tool/pan-tool.js'; +import { ShapeTool } from '../../root-block/edgeless/gfx-tool/shape-tool.js'; +import { TemplateTool } from '../../root-block/edgeless/gfx-tool/template-tool.js'; +import { TextTool } from '../../root-block/edgeless/gfx-tool/text-tool.js'; +import { EditPropsMiddlewareBuilder } from '../../root-block/edgeless/middlewares/base.js'; +import { EdgelessSnapManager } from '../../root-block/edgeless/utils/snap-manager.js'; +import { EdgelessSurfaceRefBlockSpec } from '../../surface-ref-block/surface-ref-spec.js'; +import { EdgelessFirstPartyBlockSpecs } from '../common.js'; + +export const EdgelessToolExtension: ExtensionType[] = [ + DefaultTool, + PanTool, + EraserTool, + TextTool, + ShapeTool, + NoteTool, + BrushTool, + ConnectorTool, + CopilotTool, + TemplateTool, + EmptyTool, + FrameTool, + LassoTool, + PresentTool, +]; + +export const EdgelessBuiltInManager: ExtensionType[] = [ + ConnectionOverlay, + FrameOverlay, + MindMapIndicatorOverlay, + EdgelessSnapManager, + EdgelessFrameManager, + EditPropsMiddlewareBuilder, +]; + +export const EdgelessEditorBlockSpecs: ExtensionType[] = [ + EdgelessRootBlockSpec, + ...EdgelessFirstPartyBlockSpecs, + EdgelessSurfaceBlockSpec, + EdgelessSurfaceRefBlockSpec, + FrameBlockSpec, + EdgelessTextBlockSpec, + LatexBlockSpec, + FontLoaderService, + EdgelessToolExtension, + EdgelessBuiltInManager, +].flat(); diff --git a/blocksuite/blocks/src/_specs/preset/mobile-patch.ts b/blocksuite/blocks/src/_specs/preset/mobile-patch.ts new file mode 100644 index 0000000000..9b26e38d43 --- /dev/null +++ b/blocksuite/blocks/src/_specs/preset/mobile-patch.ts @@ -0,0 +1,119 @@ +import { + type ReferenceNodeConfig, + ReferenceNodeConfigIdentifier, +} from '@blocksuite/affine-components/rich-text'; +import { + type BlockStdScope, + ConfigIdentifier, + LifeCycleWatcher, + WidgetViewMapIdentifier, + type WidgetViewMapType, +} from '@blocksuite/block-std'; +import type { Container } from '@blocksuite/global/di'; + +import type { CodeBlockConfig } from '../../code-block/code-block-config.js'; +import { AFFINE_EMBED_CARD_TOOLBAR_WIDGET } from '../../root-block/widgets/embed-card-toolbar/embed-card-toolbar.js'; +import { AFFINE_FORMAT_BAR_WIDGET } from '../../root-block/widgets/format-bar/format-bar.js'; +import { AFFINE_SLASH_MENU_WIDGET } from '../../root-block/widgets/slash-menu/index.js'; + +export class MobileSpecsPatches extends LifeCycleWatcher { + static override key = 'mobile-patches'; + + constructor(std: BlockStdScope) { + super(std); + + std.doc.awarenessStore.setFlag('enable_mobile_keyboard_toolbar', true); + std.doc.awarenessStore.setFlag('enable_mobile_linked_doc_menu', true); + } + + static override setup(di: Container) { + super.setup(di); + + // Hide reference popup on mobile. + { + const prev = di.getFactory(ReferenceNodeConfigIdentifier); + di.override(ReferenceNodeConfigIdentifier, provider => { + return { + ...prev?.(provider), + hidePopup: true, + } satisfies ReferenceNodeConfig; + }); + } + + // Hide number lines for code block on mobile. + { + const codeConfigIdentifier = ConfigIdentifier('affine:code'); + const prev = di.getFactory(codeConfigIdentifier); + di.override(codeConfigIdentifier, provider => { + return { + ...prev?.(provider), + showLineNumbers: false, + } satisfies CodeBlockConfig; + }); + } + + // Disable root level widgets for mobile. + { + const rootWidgetViewMapIdentifier = + WidgetViewMapIdentifier('affine:page'); + + const prev = di.getFactory(rootWidgetViewMapIdentifier); + + di.override(rootWidgetViewMapIdentifier, provider => { + const ignoreWidgets = [ + AFFINE_FORMAT_BAR_WIDGET, + AFFINE_EMBED_CARD_TOOLBAR_WIDGET, + AFFINE_SLASH_MENU_WIDGET, + ]; + + const newMap = { ...prev?.(provider) }; + + ignoreWidgets.forEach(widget => { + if (widget in newMap) delete newMap[widget]; + }); + + return newMap; + }); + } + + // Disable block level toolbar widgets for mobile. + { + di.override( + WidgetViewMapIdentifier('affine:code'), + (): WidgetViewMapType => ({}) + ); + + di.override( + WidgetViewMapIdentifier('affine:image'), + (): WidgetViewMapType => ({}) + ); + + di.override( + WidgetViewMapIdentifier('affine:surface-ref'), + (): WidgetViewMapType => ({}) + ); + } + } + + override mounted() { + // remove slash placeholder for mobile: `type / ...` + { + const paragraphService = this.std.getService('affine:paragraph'); + if (!paragraphService) return; + + paragraphService.placeholderGenerator = model => { + const placeholders = { + text: '', + h1: 'Heading 1', + h2: 'Heading 2', + h3: 'Heading 3', + h4: 'Heading 4', + h5: 'Heading 5', + h6: 'Heading 6', + quote: '', + }; + return placeholders[model.type]; + }; + } + } +} diff --git a/blocksuite/blocks/src/_specs/preset/page-specs.ts b/blocksuite/blocks/src/_specs/preset/page-specs.ts new file mode 100644 index 0000000000..d1e033a5c3 --- /dev/null +++ b/blocksuite/blocks/src/_specs/preset/page-specs.ts @@ -0,0 +1,17 @@ +import { PageSurfaceBlockSpec } from '@blocksuite/affine-block-surface'; +import { FontLoaderService } from '@blocksuite/affine-shared/services'; +import type { ExtensionType } from '@blocksuite/block-std'; + +import { LatexBlockSpec } from '../../latex-block/latex-spec.js'; +import { PageRootBlockSpec } from '../../root-block/page/page-root-spec.js'; +import { PageSurfaceRefBlockSpec } from '../../surface-ref-block/surface-ref-spec.js'; +import { CommonFirstPartyBlockSpecs } from '../common.js'; + +export const PageEditorBlockSpecs: ExtensionType[] = [ + PageRootBlockSpec, + ...CommonFirstPartyBlockSpecs, + PageSurfaceBlockSpec, + PageSurfaceRefBlockSpec, + LatexBlockSpec, + FontLoaderService, +].flat(); diff --git a/blocksuite/blocks/src/_specs/preset/preview-specs.ts b/blocksuite/blocks/src/_specs/preset/preview-specs.ts new file mode 100644 index 0000000000..bc7661aa4e --- /dev/null +++ b/blocksuite/blocks/src/_specs/preset/preview-specs.ts @@ -0,0 +1,64 @@ +import { + EdgelessSurfaceBlockSpec, + PageSurfaceBlockSpec, +} from '@blocksuite/affine-block-surface'; +import { RefNodeSlotsExtension } from '@blocksuite/affine-components/rich-text'; +import { + DocDisplayMetaService, + DocModeService, + EmbedOptionService, + FontLoaderService, + ThemeService, +} from '@blocksuite/affine-shared/services'; +import { + BlockViewExtension, + type ExtensionType, + FlavourExtension, +} from '@blocksuite/block-std'; +import { literal } from 'lit/static-html.js'; + +import { EdgelessTextBlockSpec } from '../../edgeless-text-block/index.js'; +import { FrameBlockSpec } from '../../frame-block/frame-spec.js'; +import { LatexBlockSpec } from '../../latex-block/latex-spec.js'; +import { PreviewEdgelessRootBlockSpec } from '../../root-block/edgeless/edgeless-root-spec.js'; +import { PageRootService } from '../../root-block/page/page-root-service.js'; +import { + EdgelessSurfaceRefBlockSpec, + PageSurfaceRefBlockSpec, +} from '../../surface-ref-block/surface-ref-spec.js'; +import { + CommonFirstPartyBlockSpecs, + EdgelessFirstPartyBlockSpecs, +} from '../common.js'; + +const PreviewPageSpec: ExtensionType[] = [ + FlavourExtension('affine:page'), + PageRootService, + DocModeService, + ThemeService, + EmbedOptionService, + BlockViewExtension('affine:page', literal`affine-preview-root`), + DocDisplayMetaService, +]; + +export const PreviewEdgelessEditorBlockSpecs: ExtensionType[] = [ + PreviewEdgelessRootBlockSpec, + ...EdgelessFirstPartyBlockSpecs, + EdgelessSurfaceBlockSpec, + EdgelessSurfaceRefBlockSpec, + FrameBlockSpec, + EdgelessTextBlockSpec, + LatexBlockSpec, + FontLoaderService, + RefNodeSlotsExtension(), +].flat(); + +export const PreviewEditorBlockSpecs: ExtensionType[] = [ + PreviewPageSpec, + ...CommonFirstPartyBlockSpecs, + PageSurfaceBlockSpec, + PageSurfaceRefBlockSpec, + LatexBlockSpec, + FontLoaderService, + RefNodeSlotsExtension(), +].flat(); diff --git a/blocksuite/blocks/src/_specs/register-specs.ts b/blocksuite/blocks/src/_specs/register-specs.ts new file mode 100644 index 0000000000..88f2381338 --- /dev/null +++ b/blocksuite/blocks/src/_specs/register-specs.ts @@ -0,0 +1,18 @@ +import { SpecProvider } from '@blocksuite/affine-shared/utils'; + +import { EdgelessEditorBlockSpecs } from './preset/edgeless-specs.js'; +import { PageEditorBlockSpecs } from './preset/page-specs.js'; +import { + PreviewEdgelessEditorBlockSpecs, + PreviewEditorBlockSpecs, +} from './preset/preview-specs.js'; + +export function registerSpecs() { + SpecProvider.getInstance().addSpec('page', PageEditorBlockSpecs); + SpecProvider.getInstance().addSpec('edgeless', EdgelessEditorBlockSpecs); + SpecProvider.getInstance().addSpec('page:preview', PreviewEditorBlockSpecs); + SpecProvider.getInstance().addSpec( + 'edgeless:preview', + PreviewEdgelessEditorBlockSpecs + ); +} diff --git a/blocksuite/blocks/src/attachment-block/adapters/notion-html.ts b/blocksuite/blocks/src/attachment-block/adapters/notion-html.ts new file mode 100644 index 0000000000..be692cf866 --- /dev/null +++ b/blocksuite/blocks/src/attachment-block/adapters/notion-html.ts @@ -0,0 +1,115 @@ +import { AttachmentBlockSchema } from '@blocksuite/affine-model'; +import { + BlockNotionHtmlAdapterExtension, + type BlockNotionHtmlAdapterMatcher, + FetchUtils, + HastUtils, +} from '@blocksuite/affine-shared/adapters'; +import { getFilenameFromContentDisposition } from '@blocksuite/affine-shared/utils'; +import { sha } from '@blocksuite/global/utils'; +import { getAssetName, nanoid } from '@blocksuite/store'; + +export const attachmentBlockNotionHtmlAdapterMatcher: BlockNotionHtmlAdapterMatcher = + { + flavour: AttachmentBlockSchema.model.flavour, + toMatch: o => { + return ( + HastUtils.isElement(o.node) && + o.node.tagName === 'figure' && + !!HastUtils.querySelector(o.node, '.source') + ); + }, + fromMatch: () => false, + toBlockSnapshot: { + enter: async (o, context) => { + if (!HastUtils.isElement(o.node)) { + return; + } + const { assets, walkerContext } = context; + if (!assets) { + return; + } + + const embededFigureWrapper = HastUtils.querySelector(o.node, '.source'); + let embededURL = ''; + if (embededFigureWrapper) { + const embedA = HastUtils.querySelector(embededFigureWrapper, 'a'); + embededURL = + typeof embedA?.properties.href === 'string' + ? embedA.properties.href + : ''; + } + if (embededURL) { + let blobId = ''; + let name = ''; + let type = ''; + let size = 0; + if (!FetchUtils.fetchable(embededURL)) { + const embededURLSplit = embededURL.split('/'); + while (embededURLSplit.length > 0) { + const key = assets + .getPathBlobIdMap() + .get(decodeURIComponent(embededURLSplit.join('/'))); + if (key) { + blobId = key; + break; + } + embededURLSplit.shift(); + } + const value = assets.getAssets().get(blobId); + if (value) { + name = getAssetName(assets.getAssets(), blobId); + size = value.size; + type = value.type; + } + } else { + const res = await fetch(embededURL).catch(error => { + console.warn('Error fetching embed:', error); + return null; + }); + if (!res) { + return; + } + const resCloned = res.clone(); + name = + getFilenameFromContentDisposition( + res.headers.get('Content-Disposition') ?? '' + ) ?? + (embededURL.split('/').at(-1) ?? 'file') + + '.' + + (res.headers.get('Content-Type')?.split('/').at(-1) ?? 'blob'); + const file = new File([await res.blob()], name, { + type: res.headers.get('Content-Type') ?? '', + }); + size = file.size; + type = file.type; + blobId = await sha(await resCloned.arrayBuffer()); + assets?.getAssets().set(blobId, file); + await assets?.writeToBlob(blobId); + } + walkerContext + .openNode( + { + type: 'block', + id: nanoid(), + flavour: AttachmentBlockSchema.model.flavour, + props: { + name, + size, + type, + sourceId: blobId, + }, + children: [], + }, + 'children' + ) + .closeNode(); + walkerContext.skipAllChildren(); + } + }, + }, + fromBlockSnapshot: {}, + }; + +export const AttachmentBlockNotionHtmlAdapterExtension = + BlockNotionHtmlAdapterExtension(attachmentBlockNotionHtmlAdapterMatcher); diff --git a/blocksuite/blocks/src/attachment-block/attachment-block.ts b/blocksuite/blocks/src/attachment-block/attachment-block.ts new file mode 100644 index 0000000000..f25e14c994 --- /dev/null +++ b/blocksuite/blocks/src/attachment-block/attachment-block.ts @@ -0,0 +1,299 @@ +import { CaptionedBlockComponent } from '@blocksuite/affine-components/caption'; +import { HoverController } from '@blocksuite/affine-components/hover'; +import { + AttachmentIcon16, + getAttachmentFileIcons, +} from '@blocksuite/affine-components/icons'; +import { Peekable } from '@blocksuite/affine-components/peek'; +import { toast } from '@blocksuite/affine-components/toast'; +import { + type AttachmentBlockModel, + AttachmentBlockStyles, +} from '@blocksuite/affine-model'; +import { ThemeProvider } from '@blocksuite/affine-shared/services'; +import { humanFileSize } from '@blocksuite/affine-shared/utils'; +import { Slice } from '@blocksuite/store'; +import { flip, offset } from '@floating-ui/dom'; +import { html, nothing } from 'lit'; +import { property, state } from 'lit/decorators.js'; +import { classMap } from 'lit/directives/class-map.js'; +import { ref } from 'lit/directives/ref.js'; +import { styleMap } from 'lit/directives/style-map.js'; + +import { getEmbedCardIcons } from '../_common/utils/url.js'; +import type { AttachmentBlockService } from './attachment-service.js'; +import { AttachmentOptionsTemplate } from './components/options.js'; +import { AttachmentEmbedProvider } from './embed.js'; +import { styles } from './styles.js'; +import { checkAttachmentBlob, downloadAttachmentBlob } from './utils.js'; + +@Peekable() +export class AttachmentBlockComponent extends CaptionedBlockComponent< + AttachmentBlockModel, + AttachmentBlockService +> { + static override styles = styles; + + protected _isDragging = false; + + protected _isResizing = false; + + protected _isSelected = false; + + protected _whenHover: HoverController | null = new HoverController( + this, + ({ abortController }) => { + const selection = this.host.selection; + const textSelection = selection.find('text'); + if ( + !!textSelection && + (!!textSelection.to || !!textSelection.from.length) + ) { + return null; + } + + const blockSelections = selection.filter('block'); + if ( + blockSelections.length > 1 || + (blockSelections.length === 1 && + blockSelections[0].blockId !== this.blockId) + ) { + return null; + } + + return { + template: AttachmentOptionsTemplate({ + block: this, + model: this.model, + abortController, + }), + computePosition: { + referenceElement: this, + placement: 'top-start', + middleware: [flip(), offset(4)], + autoUpdate: true, + }, + }; + } + ); + + blockDraggable = true; + + protected containerStyleMap = styleMap({ + position: 'relative', + width: '100%', + margin: '18px 0px', + }); + + convertTo = () => { + return this.std + .get(AttachmentEmbedProvider) + .convertTo(this.model, this.service.maxFileSize); + }; + + copy = () => { + const slice = Slice.fromModels(this.doc, [this.model]); + this.std.clipboard.copySlice(slice).catch(console.error); + toast(this.host, 'Copied to clipboard'); + }; + + download = () => { + downloadAttachmentBlob(this); + }; + + embedded = () => { + return this.std + .get(AttachmentEmbedProvider) + .embedded(this.model, this.service.maxFileSize); + }; + + open = () => { + if (!this.blobUrl) { + return; + } + window.open(this.blobUrl, '_blank'); + }; + + refreshData = () => { + checkAttachmentBlob(this).catch(console.error); + }; + + protected get embedView() { + return this.std + .get(AttachmentEmbedProvider) + .render(this.model, this.blobUrl, this.service.maxFileSize); + } + + private _selectBlock() { + const selectionManager = this.host.selection; + const blockSelection = selectionManager.create('block', { + blockId: this.blockId, + }); + selectionManager.setGroup('note', [blockSelection]); + } + + override connectedCallback() { + super.connectedCallback(); + + this.refreshData(); + + this.contentEditable = 'false'; + + if (!this.model.style) { + this.doc.withoutTransact(() => { + this.doc.updateBlock(this.model, { + style: AttachmentBlockStyles[1], + }); + }); + } + + this.model.propsUpdated.on(({ key }) => { + if (key === 'sourceId') { + // Reset the blob url when the sourceId is changed + if (this.blobUrl) { + URL.revokeObjectURL(this.blobUrl); + this.blobUrl = undefined; + } + this.refreshData(); + } + }); + + // Workaround for https://github.com/toeverything/blocksuite/issues/4724 + this.disposables.add( + this.std.get(ThemeProvider).theme$.subscribe(() => this.requestUpdate()) + ); + + // this is required to prevent iframe from capturing pointer events + this.disposables.add( + this.std.selection.slots.changed.on(() => { + this._isSelected = + !!this.selected?.is('block') || !!this.selected?.is('surface'); + + this._showOverlay = + this._isResizing || this._isDragging || !this._isSelected; + }) + ); + // this is required to prevent iframe from capturing pointer events + this.handleEvent('dragStart', () => { + this._isDragging = true; + this._showOverlay = + this._isResizing || this._isDragging || !this._isSelected; + }); + + this.handleEvent('dragEnd', () => { + this._isDragging = false; + this._showOverlay = + this._isResizing || this._isDragging || !this._isSelected; + }); + } + + override disconnectedCallback() { + if (this.blobUrl) { + URL.revokeObjectURL(this.blobUrl); + } + super.disconnectedCallback(); + } + + override firstUpdated() { + // lazy bindings + this.disposables.addFromEvent(this, 'click', this.onClick); + } + + protected onClick(event: MouseEvent) { + // the peek view need handle shift + click + if (event.defaultPrevented) return; + + event.stopPropagation(); + + this._selectBlock(); + } + + override renderBlock() { + const { name, size, style } = this.model; + const cardStyle = style ?? AttachmentBlockStyles[1]; + + const theme = this.std.get(ThemeProvider).theme; + const { LoadingIcon } = getEmbedCardIcons(theme); + + const titleIcon = this.loading ? LoadingIcon : AttachmentIcon16; + const titleText = this.loading ? 'Loading...' : name; + const infoText = this.error ? 'File loading failed.' : humanFileSize(size); + + const fileType = name.split('.').pop() ?? ''; + const FileTypeIcon = getAttachmentFileIcons(fileType); + + const embedView = this.embedView; + + return html` + <div + ${this._whenHover ? ref(this._whenHover.setReference) : nothing} + class="affine-attachment-container" + draggable="${this.blockDraggable ? 'true' : 'false'}" + style=${this.containerStyleMap} + > + ${embedView + ? html`<div class="affine-attachment-embed-container"> + ${embedView} + + <div + class=${classMap({ + 'affine-attachment-iframe-overlay': true, + hide: !this._showOverlay, + })} + ></div> + </div>` + : html`<div + class=${classMap({ + 'affine-attachment-card': true, + [cardStyle]: true, + loading: this.loading, + error: this.error, + unsynced: false, + })} + > + <div class="affine-attachment-content"> + <div class="affine-attachment-content-title"> + <div class="affine-attachment-content-title-icon"> + ${titleIcon} + </div> + + <div class="affine-attachment-content-title-text"> + ${titleText} + </div> + </div> + + <div class="affine-attachment-content-info">${infoText}</div> + </div> + + <div class="affine-attachment-banner">${FileTypeIcon}</div> + </div>`} + </div> + `; + } + + @state() + protected accessor _showOverlay = true; + + @property({ attribute: false }) + accessor allowEmbed = false; + + @property({ attribute: false }) + accessor blobUrl: string | undefined = undefined; + + @property({ attribute: false }) + accessor downloading = false; + + @property({ attribute: false }) + accessor error = false; + + @property({ attribute: false }) + accessor loading = false; + + override accessor useCaptionEditor = true; +} + +declare global { + interface HTMLElementTagNameMap { + 'affine-attachment': AttachmentBlockComponent; + } +} diff --git a/blocksuite/blocks/src/attachment-block/attachment-edgeless-block.ts b/blocksuite/blocks/src/attachment-block/attachment-edgeless-block.ts new file mode 100644 index 0000000000..d6914cfa5f --- /dev/null +++ b/blocksuite/blocks/src/attachment-block/attachment-edgeless-block.ts @@ -0,0 +1,74 @@ +import type { HoverController } from '@blocksuite/affine-components/hover'; +import { AttachmentBlockStyles } from '@blocksuite/affine-model'; +import { + EMBED_CARD_HEIGHT, + EMBED_CARD_WIDTH, +} from '@blocksuite/affine-shared/consts'; +import { toGfxBlockComponent } from '@blocksuite/block-std'; +import { styleMap } from 'lit/directives/style-map.js'; + +import type { EdgelessRootService } from '../root-block/index.js'; +import { AttachmentBlockComponent } from './attachment-block.js'; + +export class AttachmentEdgelessBlockComponent extends toGfxBlockComponent( + AttachmentBlockComponent +) { + protected override _whenHover: HoverController | null = null; + + override blockDraggable = false; + + get rootService() { + return this.std.getService('affine:page') as EdgelessRootService; + } + + override connectedCallback(): void { + super.connectedCallback(); + + const rootService = this.rootService; + + this._disposables.add( + rootService.slots.elementResizeStart.on(() => { + this._isResizing = true; + this._showOverlay = true; + }) + ); + + this._disposables.add( + rootService.slots.elementResizeEnd.on(() => { + this._isResizing = false; + this._showOverlay = + this._isResizing || this._isDragging || !this._isSelected; + }) + ); + } + + override onClick(_: MouseEvent) { + return; + } + + override renderGfxBlock() { + const { style$ } = this.model; + const cardStyle = style$.value ?? AttachmentBlockStyles[1]; + const width = EMBED_CARD_WIDTH[cardStyle]; + const height = EMBED_CARD_HEIGHT[cardStyle]; + const bound = this.model.elementBound; + const scaleX = bound.w / width; + const scaleY = bound.h / height; + + this.containerStyleMap = styleMap({ + width: `${width}px`, + height: `${height}px`, + transform: `scale(${scaleX}, ${scaleY})`, + transformOrigin: '0 0', + overflow: 'hidden', + }); + + return this.renderPageContent(); + } +} + +declare global { + interface HTMLElementTagNameMap { + 'affine-edgeless-attachment': AttachmentEdgelessBlockComponent; + } +} diff --git a/blocksuite/blocks/src/attachment-block/attachment-service.ts b/blocksuite/blocks/src/attachment-block/attachment-service.ts new file mode 100644 index 0000000000..3e01be68fd --- /dev/null +++ b/blocksuite/blocks/src/attachment-block/attachment-service.ts @@ -0,0 +1,128 @@ +import { AttachmentBlockSchema } from '@blocksuite/affine-model'; +import { + DragHandleConfigExtension, + TelemetryProvider, +} from '@blocksuite/affine-shared/services'; +import { + captureEventTarget, + convertDragPreviewDocToEdgeless, + convertDragPreviewEdgelessToDoc, + isInsideEdgelessEditor, + matchFlavours, +} from '@blocksuite/affine-shared/utils'; +import { BlockService } from '@blocksuite/block-std'; +import { GfxControllerIdentifier } from '@blocksuite/block-std/gfx'; + +import { + FileDropManager, + type FileDropOptions, +} from '../_common/components/file-drop-manager.js'; +import { EMBED_CARD_HEIGHT, EMBED_CARD_WIDTH } from '../_common/consts.js'; +import { addAttachments } from '../root-block/edgeless/utils/common.js'; +import type { AttachmentBlockComponent } from './attachment-block.js'; +import { AttachmentEdgelessBlockComponent } from './attachment-edgeless-block.js'; +import { addSiblingAttachmentBlocks } from './utils.js'; + +export class AttachmentBlockService extends BlockService { + static override readonly flavour = AttachmentBlockSchema.model.flavour; + + private _fileDropOptions: FileDropOptions = { + flavour: this.flavour, + onDrop: async ({ files, targetModel, place, point }) => { + if (!files.length) return false; + + // generic attachment block for all files except images + const attachmentFiles = files.filter( + file => !file.type.startsWith('image/') + ); + + if (targetModel && !matchFlavours(targetModel, ['affine:surface'])) { + await addSiblingAttachmentBlocks( + this.host, + attachmentFiles, + this.maxFileSize, + targetModel, + place + ); + } else if (isInsideEdgelessEditor(this.host)) { + const gfx = this.std.get(GfxControllerIdentifier); + point = gfx.viewport.toViewCoordFromClientCoord(point); + await addAttachments(this.std, attachmentFiles, point); + + this.std.getOptional(TelemetryProvider)?.track('CanvasElementAdded', { + control: 'canvas:drop', + page: 'whiteboard editor', + module: 'toolbar', + segment: 'toolbar', + type: 'attachment', + }); + } + + return true; + }, + }; + + fileDropManager!: FileDropManager; + + maxFileSize = 10 * 1000 * 1000; // 10MB (default) + + override mounted(): void { + super.mounted(); + + this.fileDropManager = new FileDropManager(this, this._fileDropOptions); + } +} + +export const AttachmentDragHandleOption = DragHandleConfigExtension({ + flavour: AttachmentBlockSchema.model.flavour, + edgeless: true, + onDragEnd: props => { + const { state, draggingElements, editorHost } = props; + if ( + draggingElements.length !== 1 || + !matchFlavours(draggingElements[0].model, [ + AttachmentBlockSchema.model.flavour, + ]) + ) + return false; + + const blockComponent = draggingElements[0] as + | AttachmentBlockComponent + | AttachmentEdgelessBlockComponent; + const isInSurface = + blockComponent instanceof AttachmentEdgelessBlockComponent; + const target = captureEventTarget(state.raw.target); + const isTargetEdgelessContainer = + target?.classList.contains('edgeless-container'); + + if (isInSurface) { + const style = blockComponent.model.style; + const targetStyle = style === 'cubeThick' ? 'horizontalThin' : style; + return convertDragPreviewEdgelessToDoc({ + blockComponent, + style: targetStyle, + ...props, + }); + } else if (isTargetEdgelessContainer) { + let style = blockComponent.model.style ?? 'cubeThick'; + const embed = blockComponent.model.embed; + if (embed) { + style = 'cubeThick'; + editorHost.doc.updateBlock(blockComponent.model, { + style, + embed: false, + }); + } + + return convertDragPreviewDocToEdgeless({ + blockComponent, + cssSelector: '.affine-attachment-container', + width: EMBED_CARD_WIDTH[style], + height: EMBED_CARD_HEIGHT[style], + ...props, + }); + } + + return false; + }, +}); diff --git a/blocksuite/blocks/src/attachment-block/attachment-spec.ts b/blocksuite/blocks/src/attachment-block/attachment-spec.ts new file mode 100644 index 0000000000..34f371df8d --- /dev/null +++ b/blocksuite/blocks/src/attachment-block/attachment-spec.ts @@ -0,0 +1,28 @@ +import { + BlockViewExtension, + type ExtensionType, + FlavourExtension, +} from '@blocksuite/block-std'; +import { literal } from 'lit/static-html.js'; + +import { + AttachmentBlockService, + AttachmentDragHandleOption, +} from './attachment-service.js'; +import { + AttachmentEmbedConfigExtension, + AttachmentEmbedService, +} from './embed.js'; + +export const AttachmentBlockSpec: ExtensionType[] = [ + FlavourExtension('affine:attachment'), + AttachmentBlockService, + BlockViewExtension('affine:attachment', model => { + return model.parent?.flavour === 'affine:surface' + ? literal`affine-edgeless-attachment` + : literal`affine-attachment`; + }), + AttachmentDragHandleOption, + AttachmentEmbedConfigExtension(), + AttachmentEmbedService, +]; diff --git a/blocksuite/blocks/src/attachment-block/components/config.ts b/blocksuite/blocks/src/attachment-block/components/config.ts new file mode 100644 index 0000000000..5bcb411aff --- /dev/null +++ b/blocksuite/blocks/src/attachment-block/components/config.ts @@ -0,0 +1,77 @@ +import { + CopyIcon, + DeleteIcon, + DownloadIcon, + DuplicateIcon, + RefreshIcon, +} from '@blocksuite/affine-components/icons'; +import type { MenuItemGroup } from '@blocksuite/affine-components/toolbar'; + +import { cloneAttachmentProperties } from '../utils.js'; +import type { AttachmentToolbarMoreMenuContext } from './context.js'; + +export const BUILT_IN_GROUPS: MenuItemGroup<AttachmentToolbarMoreMenuContext>[] = + [ + { + type: 'clipboard', + items: [ + { + type: 'copy', + label: 'Copy', + icon: CopyIcon, + disabled: ({ doc }) => doc.readonly, + action: ctx => ctx.blockComponent.copy(), + }, + { + type: 'duplicate', + label: 'Duplicate', + icon: DuplicateIcon, + disabled: ({ doc }) => doc.readonly, + action: ({ doc, blockComponent, close }) => { + const model = blockComponent.model; + const prop: { flavour: 'affine:attachment' } = { + flavour: 'affine:attachment', + ...cloneAttachmentProperties(model), + }; + doc.addSiblingBlocks(model, [prop]); + close(); + }, + }, + { + type: 'reload', + label: 'Reload', + icon: RefreshIcon, + disabled: ({ doc }) => doc.readonly, + action: ({ blockComponent, close }) => { + blockComponent.refreshData(); + close(); + }, + }, + { + type: 'download', + label: 'Download', + icon: DownloadIcon, + disabled: ({ doc }) => doc.readonly, + action: ({ blockComponent, close }) => { + blockComponent.download(); + close(); + }, + }, + ], + }, + { + type: 'delete', + items: [ + { + type: 'delete', + label: 'Delete', + icon: DeleteIcon, + disabled: ({ doc }) => doc.readonly, + action: ({ doc, blockComponent, close }) => { + doc.deleteBlock(blockComponent.model); + close(); + }, + }, + ], + }, + ]; diff --git a/blocksuite/blocks/src/attachment-block/components/context.ts b/blocksuite/blocks/src/attachment-block/components/context.ts new file mode 100644 index 0000000000..6f9d77a9a6 --- /dev/null +++ b/blocksuite/blocks/src/attachment-block/components/context.ts @@ -0,0 +1,44 @@ +import { MenuContext } from '../../root-block/configs/toolbar.js'; +import type { AttachmentBlockComponent } from '../attachment-block.js'; + +export class AttachmentToolbarMoreMenuContext extends MenuContext { + override close = () => { + this.abortController.abort(); + }; + + get doc() { + return this.blockComponent.doc; + } + + get host() { + return this.blockComponent.host; + } + + get selectedBlockModels() { + if (this.blockComponent.model) return [this.blockComponent.model]; + return []; + } + + get std() { + return this.blockComponent.std; + } + + constructor( + public blockComponent: AttachmentBlockComponent, + public abortController: AbortController + ) { + super(); + } + + isEmpty() { + return false; + } + + isMultiple() { + return false; + } + + isSingle() { + return true; + } +} diff --git a/blocksuite/blocks/src/attachment-block/components/options.ts b/blocksuite/blocks/src/attachment-block/components/options.ts new file mode 100644 index 0000000000..bd33cf0fd3 --- /dev/null +++ b/blocksuite/blocks/src/attachment-block/components/options.ts @@ -0,0 +1,225 @@ +import { + CaptionIcon, + DownloadIcon, + EditIcon, + MoreVerticalIcon, + SmallArrowDownIcon, +} from '@blocksuite/affine-components/icons'; +import { createLitPortal } from '@blocksuite/affine-components/portal'; +import { + cloneGroups, + renderGroups, + renderToolbarSeparator, +} from '@blocksuite/affine-components/toolbar'; +import { + type AttachmentBlockModel, + defaultAttachmentProps, +} from '@blocksuite/affine-model'; +import { + EMBED_CARD_HEIGHT, + EMBED_CARD_WIDTH, +} from '@blocksuite/affine-shared/consts'; +import { Bound } from '@blocksuite/global/utils'; +import { flip, offset } from '@floating-ui/dom'; +import { html, nothing } from 'lit'; +import { join } from 'lit/directives/join.js'; +import { repeat } from 'lit/directives/repeat.js'; + +import { getMoreMenuConfig } from '../../root-block/configs/toolbar.js'; +import type { AttachmentBlockComponent } from '../attachment-block.js'; +import { BUILT_IN_GROUPS } from './config.js'; +import { AttachmentToolbarMoreMenuContext } from './context.js'; +import { RenameModal } from './rename-model.js'; +import { styles } from './styles.js'; + +export function attachmentViewToggleMenu({ + block, + callback, +}: { + block: AttachmentBlockComponent; + callback?: () => void; +}) { + const model = block.model; + const readonly = model.doc.readonly; + const embedded = model.embed; + const viewType = embedded ? 'embed' : 'card'; + const viewActions = [ + { + type: 'card', + label: 'Card view', + disabled: readonly || !embedded, + action: () => { + const style = defaultAttachmentProps.style!; + const width = EMBED_CARD_WIDTH[style]; + const height = EMBED_CARD_HEIGHT[style]; + const bound = Bound.deserialize(model.xywh); + bound.w = width; + bound.h = height; + model.doc.updateBlock(model, { + style, + embed: false, + xywh: bound.serialize(), + }); + callback?.(); + }, + }, + { + type: 'embed', + label: 'Embed view', + disabled: readonly || embedded || !block.embedded(), + action: () => { + block.convertTo(); + callback?.(); + }, + }, + ]; + + return html` + <editor-menu-button + .contentPadding=${'8px'} + .button=${html` + <editor-icon-button + aria-label="Switch view" + .justify=${'space-between'} + .labelHeight=${'20px'} + .iconContainerWidth=${'110px'} + > + <div class="label"> + <span style="text-transform: capitalize">${viewType}</span> + view + </div> + ${SmallArrowDownIcon} + </editor-icon-button> + `} + > + <div data-size="small" data-orientation="vertical"> + ${repeat( + viewActions, + button => button.type, + ({ type, label, action, disabled }) => html` + <editor-menu-action + data-testid=${`link-to-${type}`} + ?data-selected=${type === viewType} + ?disabled=${disabled} + @click=${action} + > + ${label} + </editor-menu-action> + ` + )} + </div> + </editor-menu-button> + `; +} + +export function AttachmentOptionsTemplate({ + block, + model, + abortController, +}: { + block: AttachmentBlockComponent; + model: AttachmentBlockModel; + abortController: AbortController; +}) { + const std = block.std; + const editorHost = block.host; + const readonly = model.doc.readonly; + const context = new AttachmentToolbarMoreMenuContext(block, abortController); + const groups = getMoreMenuConfig(std).configure(cloneGroups(BUILT_IN_GROUPS)); + const moreMenuActions = renderGroups(groups, context); + + const buttons = [ + // preview + // html` + // <editor-icon-button aria-label="Preview" .tooltip=${'Preview'}> + // ${ViewIcon} + // </editor-icon-button> + // `, + + readonly + ? nothing + : html` + <editor-icon-button + aria-label="Rename" + .tooltip=${'Rename'} + @click=${() => { + abortController.abort(); + const renameAbortController = new AbortController(); + createLitPortal({ + template: RenameModal({ + model, + editorHost, + abortController: renameAbortController, + }), + computePosition: { + referenceElement: block, + placement: 'top-start', + middleware: [flip(), offset(4)], + // It has a overlay mask, so we don't need to update the position. + // autoUpdate: true, + }, + abortController: renameAbortController, + }); + }} + > + ${EditIcon} + </editor-icon-button> + `, + + attachmentViewToggleMenu({ + block, + callback: () => abortController.abort(), + }), + + readonly + ? nothing + : html` + <editor-icon-button + aria-label="Download" + .tooltip=${'Download'} + @click=${() => block.download()} + > + ${DownloadIcon} + </editor-icon-button> + `, + + readonly + ? nothing + : html` + <editor-icon-button + aria-label="Caption" + .tooltip=${'Caption'} + @click=${() => block.captionEditor?.show()} + > + ${CaptionIcon} + </editor-icon-button> + `, + + html` + <editor-menu-button + .contentPadding=${'8px'} + .button=${html` + <editor-icon-button aria-label="More" .tooltip=${'More'}> + ${MoreVerticalIcon} + </editor-icon-button> + `} + > + <div data-size="large" data-orientation="vertical"> + ${moreMenuActions} + </div> + </editor-menu-button> + `, + ]; + + return html` + <style> + ${styles} + </style> + <editor-toolbar class="affine-attachment-toolbar"> + ${join( + buttons.filter(button => button !== nothing), + renderToolbarSeparator + )} + </editor-toolbar> + `; +} diff --git a/blocksuite/blocks/src/attachment-block/components/rename-model.ts b/blocksuite/blocks/src/attachment-block/components/rename-model.ts new file mode 100644 index 0000000000..8613ea16e2 --- /dev/null +++ b/blocksuite/blocks/src/attachment-block/components/rename-model.ts @@ -0,0 +1,92 @@ +import { ConfirmIcon } from '@blocksuite/affine-components/icons'; +import { toast } from '@blocksuite/affine-components/toast'; +import type { AttachmentBlockModel } from '@blocksuite/affine-model'; +import type { EditorHost } from '@blocksuite/block-std'; +import { html } from 'lit'; +import { createRef, ref } from 'lit/directives/ref.js'; + +import { renameStyles } from './styles.js'; + +export const RenameModal = ({ + editorHost, + model, + abortController, +}: { + editorHost: EditorHost; + model: AttachmentBlockModel; + abortController: AbortController; +}) => { + const inputRef = createRef<HTMLInputElement>(); + // Fix auto focus + setTimeout(() => inputRef.value?.focus()); + const originalName = model.name; + const nameWithoutExtension = originalName.slice( + 0, + originalName.lastIndexOf('.') + ); + const originalExtension = originalName.slice(originalName.lastIndexOf('.')); + const includeExtension = + originalExtension.includes('.') && + originalExtension.length <= 7 && + // including the dot + originalName.length > originalExtension.length; + + let fileName = includeExtension ? nameWithoutExtension : originalName; + const extension = includeExtension ? originalExtension : ''; + + const onConfirm = () => { + const newFileName = fileName + extension; + if (!newFileName) { + toast(editorHost, 'File name cannot be empty'); + return; + } + model.doc.updateBlock(model, { + name: newFileName, + }); + abortController.abort(); + }; + const onInput = (e: InputEvent) => { + fileName = (e.target as HTMLInputElement).value; + }; + const onKeydown = (e: KeyboardEvent) => { + e.stopPropagation(); + + if (e.key === 'Escape' && !e.isComposing) { + abortController.abort(); + return; + } + if (e.key === 'Enter' && !e.isComposing) { + onConfirm(); + return; + } + }; + + return html` + <style> + ${renameStyles} + </style> + <div + class="affine-attachment-rename-overlay-mask" + @click="${() => abortController.abort()}" + ></div> + <div class="affine-attachment-rename-container"> + <div class="affine-attachment-rename-input-wrapper"> + <input + ${ref(inputRef)} + type="text" + .value=${fileName} + @input=${onInput} + @keydown=${onKeydown} + /> + <span class="affine-attachment-rename-extension">${extension}</span> + </div> + <editor-icon-button + class="affine-confirm-button" + .iconSize=${'24px'} + @click=${onConfirm} + > + ${ConfirmIcon} + </editor-icon-button> + </div> + `; +}; diff --git a/blocksuite/blocks/src/attachment-block/components/styles.ts b/blocksuite/blocks/src/attachment-block/components/styles.ts new file mode 100644 index 0000000000..2bfc99e1ef --- /dev/null +++ b/blocksuite/blocks/src/attachment-block/components/styles.ts @@ -0,0 +1,101 @@ +import { FONT_XS, PANEL_BASE } from '@blocksuite/affine-shared/styles'; +import { css } from 'lit'; + +export const renameStyles = css` + .affine-attachment-rename-container { + ${PANEL_BASE}; + position: relative; + display: flex; + align-items: center; + width: 320px; + gap: 12px; + padding: 12px; + z-index: var(--affine-z-index-popover); + } + + .affine-attachment-rename-input-wrapper { + display: flex; + min-width: 280px; + height: 30px; + box-sizing: border-box; + padding: 4px 10px; + background: var(--affine-white-10); + border-radius: 4px; + border: 1px solid var(--affine-border-color); + } + + .affine-attachment-rename-input-wrapper:focus-within { + border-color: var(--affine-blue-700); + box-shadow: var(--affine-active-shadow); + } + + .affine-attachment-rename-input-wrapper input { + flex: 1; + border: none; + outline: none; + background: transparent; + color: var(--affine-text-primary-color); + ${FONT_XS}; + } + + .affine-attachment-rename-input-wrapper input::placeholder { + color: var(--affine-placeholder-color); + } + + .affine-attachment-rename-extension { + font-size: var(--affine-font-xs); + color: var(--affine-text-secondary-color); + } + + .affine-attachment-rename-overlay-mask { + position: fixed; + top: 0; + left: 0; + width: 100vw; + height: 100vh; + z-index: var(--affine-z-index-popover); + } +`; + +export const moreMenuStyles = css` + .affine-attachment-options-more { + box-sizing: border-box; + padding-bottom: 4px; + } + + .affine-attachment-options-more-container { + display: flex; + flex-direction: column; + align-items: center; + color: var(--affine-text-primary-color); + + border-radius: 8px; + padding: 8px; + background: var(--affine-background-overlay-panel-color); + box-shadow: var(--affine-shadow-2); + } + + .affine-attachment-options-more-container > icon-button { + display: flex; + align-items: center; + padding: 8px; + gap: 8px; + } + .affine-attachment-options-more-container > icon-button[hidden] { + display: none; + } + + .affine-attachment-options-more-container > icon-button:hover.danger { + background: var(--affine-background-error-color); + color: var(--affine-error-color); + } + .affine-attachment-options-more-container > icon-button:hover.danger > svg { + color: var(--affine-error-color); + } +`; + +export const styles = css` + :host { + z-index: 1; + } +`; diff --git a/blocksuite/blocks/src/attachment-block/embed.ts b/blocksuite/blocks/src/attachment-block/embed.ts new file mode 100644 index 0000000000..3eb19541ac --- /dev/null +++ b/blocksuite/blocks/src/attachment-block/embed.ts @@ -0,0 +1,191 @@ +import type { + AttachmentBlockModel, + ImageBlockProps, +} from '@blocksuite/affine-model'; +import { withTempBlobData } from '@blocksuite/affine-shared/utils'; +import type { ExtensionType } from '@blocksuite/block-std'; +import { Extension } from '@blocksuite/block-std'; +import type { Container } from '@blocksuite/global/di'; +import { createIdentifier } from '@blocksuite/global/di'; +import type { TemplateResult } from 'lit'; +import { html } from 'lit'; + +import { transformModel } from '../root-block/utils/operations/model.js'; + +export type AttachmentEmbedConfig = { + name: string; + /** + * Check if the attachment can be turned into embed view. + */ + check: (model: AttachmentBlockModel, maxFileSize: number) => boolean; + /** + * The action will be executed when the 「Turn into embed view」 button is clicked. + */ + action?: (model: AttachmentBlockModel) => Promise<void> | void; + /** + * The template will be used to render the embed view. + */ + template?: (model: AttachmentBlockModel, blobUrl: string) => TemplateResult; +}; + +// Single embed config. +export const AttachmentEmbedConfigIdentifier = + createIdentifier<AttachmentEmbedConfig>( + 'AffineAttachmentEmbedConfigIdentifier' + ); + +export function AttachmentEmbedConfigExtension( + configs: AttachmentEmbedConfig[] = embedConfig +): ExtensionType { + return { + setup: di => { + configs.forEach(option => { + di.addImpl(AttachmentEmbedConfigIdentifier(option.name), () => option); + }); + }, + }; +} + +// A embed config map. +export const AttachmentEmbedConfigMapIdentifier = createIdentifier< + Map<string, AttachmentEmbedConfig> +>('AffineAttachmentEmbedConfigMapIdentifier'); + +export const AttachmentEmbedProvider = createIdentifier<AttachmentEmbedService>( + 'AffineAttachmentEmbedProvider' +); + +export class AttachmentEmbedService extends Extension { + // 10MB + static MAX_EMBED_SIZE = 10 * 1024 * 1024; + + get keys() { + return this.configs.keys(); + } + + get values() { + return this.configs.values(); + } + + constructor(private configs: Map<string, AttachmentEmbedConfig>) { + super(); + } + + static override setup(di: Container) { + di.addImpl(AttachmentEmbedConfigMapIdentifier, provider => + provider.getAll(AttachmentEmbedConfigIdentifier) + ); + di.addImpl(AttachmentEmbedProvider, AttachmentEmbedService, [ + AttachmentEmbedConfigMapIdentifier, + ]); + } + + // Converts to embed view. + convertTo( + model: AttachmentBlockModel, + maxFileSize = AttachmentEmbedService.MAX_EMBED_SIZE + ) { + const config = this.values.find(config => config.check(model, maxFileSize)); + if (!config || !config.action) { + model.doc.updateBlock(model, { embed: true }); + return; + } + config.action(model)?.catch(console.error); + } + + embedded( + model: AttachmentBlockModel, + maxFileSize = AttachmentEmbedService.MAX_EMBED_SIZE + ) { + return this.values.some(config => config.check(model, maxFileSize)); + } + + render( + model: AttachmentBlockModel, + blobUrl?: string, + maxFileSize = AttachmentEmbedService.MAX_EMBED_SIZE + ) { + if (!model.embed || !blobUrl) return; + + const config = this.values.find(config => config.check(model, maxFileSize)); + if (!config || !config.template) { + console.error('No embed view template found!', model, model.type); + return; + } + + return config.template(model, blobUrl); + } +} + +const embedConfig: AttachmentEmbedConfig[] = [ + { + name: 'image', + check: model => + model.doc.schema.flavourSchemaMap.has('affine:image') && + model.type.startsWith('image/'), + action: model => turnIntoImageBlock(model), + }, + { + name: 'pdf', + check: (model, maxFileSize) => + model.type === 'application/pdf' && model.size <= maxFileSize, + template: (_, blobUrl) => { + // More options: https://tinytip.co/tips/html-pdf-params/ + // https://chromium.googlesource.com/chromium/src/+/refs/tags/121.0.6153.1/chrome/browser/resources/pdf/open_pdf_params_parser.ts + const parameters = '#toolbar=0'; + return html`<iframe + style="width: 100%; color-scheme: auto;" + height="480" + src=${blobUrl + parameters} + loading="lazy" + scrolling="no" + frameborder="no" + allowTransparency + allowfullscreen + type="application/pdf" + ></iframe>`; + }, + }, + { + name: 'video', + check: (model, maxFileSize) => + model.type.startsWith('video/') && model.size <= maxFileSize, + template: (_, blobUrl) => + html`<video width="100%;" height="480" controls src=${blobUrl}></video>`, + }, + { + name: 'audio', + check: (model, maxFileSize) => + model.type.startsWith('audio/') && model.size <= maxFileSize, + template: (_, blobUrl) => + html`<audio controls src=${blobUrl} style="margin: 4px;"></audio>`, + }, +]; + +/** + * Turn the attachment block into an image block. + */ +export function turnIntoImageBlock(model: AttachmentBlockModel) { + if (!model.doc.schema.flavourSchemaMap.has('affine:image')) { + console.error('The image flavour is not supported!'); + return; + } + + const sourceId = model.sourceId; + if (!sourceId) return; + + const { saveAttachmentData, getImageData } = withTempBlobData(); + saveAttachmentData(sourceId, { name: model.name }); + + const imageConvertData = model.sourceId + ? getImageData(model.sourceId) + : undefined; + + const imageProp: Partial<ImageBlockProps> = { + sourceId, + caption: model.caption, + size: model.size, + ...imageConvertData, + }; + transformModel(model, 'affine:image', imageProp); +} diff --git a/blocksuite/blocks/src/attachment-block/index.ts b/blocksuite/blocks/src/attachment-block/index.ts new file mode 100644 index 0000000000..fddf77a8c5 --- /dev/null +++ b/blocksuite/blocks/src/attachment-block/index.ts @@ -0,0 +1,8 @@ +export * from './attachment-block.js'; +export * from './attachment-service.js'; +export { attachmentViewToggleMenu } from './components/options.js'; +export { + type AttachmentEmbedConfig, + AttachmentEmbedConfigIdentifier, + AttachmentEmbedProvider, +} from './embed.js'; diff --git a/blocksuite/blocks/src/attachment-block/styles.ts b/blocksuite/blocks/src/attachment-block/styles.ts new file mode 100644 index 0000000000..920348307e --- /dev/null +++ b/blocksuite/blocks/src/attachment-block/styles.ts @@ -0,0 +1,156 @@ +import { css } from 'lit'; + +import { EMBED_CARD_HEIGHT, EMBED_CARD_WIDTH } from '../_common/consts.js'; + +export const styles = css` + .affine-attachment-card { + margin: 0 auto; + box-sizing: border-box; + display: flex; + gap: 12px; + + width: 100%; + height: ${EMBED_CARD_HEIGHT.horizontalThin}px; + + padding: 12px; + border-radius: 8px; + border: 1px solid var(--affine-background-tertiary-color); + + opacity: var(--add, 1); + background: var(--affine-background-primary-color); + user-select: none; + } + + .affine-attachment-content { + height: 100%; + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 12px; + flex: 1 0 0; + + border-radius: var(--1, 0px); + opacity: var(--add, 1); + } + + .affine-attachment-content-title { + display: flex; + flex-direction: row; + gap: 8px; + align-items: center; + + align-self: stretch; + padding: var(--1, 0px); + border-radius: var(--1, 0px); + opacity: var(--add, 1); + } + + .affine-attachment-content-title-icon { + display: flex; + width: 16px; + height: 16px; + align-items: center; + justify-content: center; + } + + .affine-attachment-content-title-icon svg { + width: 16px; + height: 16px; + fill: var(--affine-background-primary-color); + } + + .affine-attachment-content-title-text { + display: -webkit-box; + -webkit-line-clamp: 1; + -webkit-box-orient: vertical; + + word-break: break-all; + overflow: hidden; + text-overflow: ellipsis; + color: var(--affine-text-primary-color); + + font-family: var(--affine-font-family); + font-size: var(--affine-font-sm); + font-style: normal; + font-weight: 600; + line-height: 22px; + } + + .affine-attachment-content-info { + display: -webkit-box; + -webkit-box-orient: vertical; + -webkit-line-clamp: 1; + flex: 1 0 0; + + word-break: break-all; + overflow: hidden; + color: var(--affine-text-secondary-color); + text-overflow: ellipsis; + + font-family: var(--affine-font-family); + font-size: var(--affine-font-xs); + font-style: normal; + font-weight: 400; + line-height: 20px; + } + + .affine-attachment-banner { + display: flex; + align-items: center; + justify-content: center; + } + + .affine-attachment-banner svg { + width: 40px; + height: 40px; + } + + .affine-attachment-card.loading { + background: var(--affine-background-secondary-color); + + .affine-attachment-content-title-text { + color: var(--affine-placeholder-color); + } + } + + .affine-attachment-card.error, + .affine-attachment-card.unsynced { + background: var(--affine-background-secondary-color); + } + + .affine-attachment-card.cubeThick { + width: ${EMBED_CARD_WIDTH.cubeThick}px; + height: ${EMBED_CARD_HEIGHT.cubeThick}px; + + flex-direction: column-reverse; + + .affine-attachment-content { + width: 100%; + flex-direction: column; + align-items: flex-start; + justify-content: space-between; + } + + .affine-attachment-banner { + justify-content: flex-start; + } + } + + .affine-attachment-embed-container { + position: relative; + width: 100%; + height: 100%; + } + + .affine-attachment-iframe-overlay { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + } + + .affine-attachment-iframe-overlay.hide { + display: none; + } +`; diff --git a/blocksuite/blocks/src/attachment-block/utils.ts b/blocksuite/blocks/src/attachment-block/utils.ts new file mode 100644 index 0000000000..84b0f1551d --- /dev/null +++ b/blocksuite/blocks/src/attachment-block/utils.ts @@ -0,0 +1,253 @@ +import { toast } from '@blocksuite/affine-components/toast'; +import type { + AttachmentBlockModel, + AttachmentBlockProps, +} from '@blocksuite/affine-model'; +import { defaultAttachmentProps } from '@blocksuite/affine-model'; +import { TelemetryProvider } from '@blocksuite/affine-shared/services'; +import { humanFileSize } from '@blocksuite/affine-shared/utils'; +import type { EditorHost } from '@blocksuite/block-std'; +import type { BlockModel } from '@blocksuite/store'; + +import type { AttachmentBlockComponent } from './attachment-block.js'; + +export function cloneAttachmentProperties(model: AttachmentBlockModel) { + const clonedProps = {} as AttachmentBlockProps; + for (const cur in defaultAttachmentProps) { + const key = cur as keyof AttachmentBlockProps; + // @ts-expect-error it's safe because we just cloned the props simply + clonedProps[key] = model[ + key + ] as AttachmentBlockProps[keyof AttachmentBlockProps]; + } + return clonedProps; +} + +const attachmentUploads = new Set<string>(); +export function setAttachmentUploading(blockId: string) { + attachmentUploads.add(blockId); +} +export function setAttachmentUploaded(blockId: string) { + attachmentUploads.delete(blockId); +} +function isAttachmentUploading(blockId: string) { + return attachmentUploads.has(blockId); +} + +/** + * This function will not verify the size of the file. + */ +export async function uploadAttachmentBlob( + editorHost: EditorHost, + blockId: string, + blob: Blob, + filetype: string, + isEdgeless?: boolean +): Promise<void> { + if (isAttachmentUploading(blockId)) { + return; + } + + const doc = editorHost.doc; + let sourceId: string | undefined; + + try { + setAttachmentUploading(blockId); + sourceId = await doc.blobSync.set(blob); + } catch (error) { + console.error(error); + if (error instanceof Error) { + toast( + editorHost, + `Failed to upload attachment! ${error.message || error.toString()}` + ); + } + } finally { + setAttachmentUploaded(blockId); + + const block = doc.getBlock(blockId); + + doc.withoutTransact(() => { + if (!block) return; + + doc.updateBlock(block.model, { + sourceId, + } satisfies Partial<AttachmentBlockProps>); + }); + + editorHost.std + .getOptional(TelemetryProvider) + ?.track('AttachmentUploadedEvent', { + page: `${isEdgeless ? 'whiteboard' : 'doc'} editor`, + module: 'attachment', + segment: 'attachment', + control: 'uploader', + type: filetype, + category: block && sourceId ? 'success' : 'failure', + }); + } +} + +async function getAttachmentBlob(model: AttachmentBlockModel) { + const sourceId = model.sourceId; + if (!sourceId) { + return null; + } + + const doc = model.doc; + let blob = await doc.blobSync.get(sourceId); + + if (blob) { + blob = new Blob([blob], { type: model.type }); + } + + return blob; +} + +export async function checkAttachmentBlob(block: AttachmentBlockComponent) { + const model = block.model; + const { id, sourceId } = model; + + if (isAttachmentUploading(id)) { + block.loading = true; + block.error = false; + block.allowEmbed = false; + if (block.blobUrl) { + URL.revokeObjectURL(block.blobUrl); + block.blobUrl = undefined; + } + return; + } + + try { + if (!sourceId) { + return; + } + + const blob = await getAttachmentBlob(model); + if (!blob) { + return; + } + + block.loading = false; + block.error = false; + block.allowEmbed = block.embedded(); + if (block.blobUrl) { + URL.revokeObjectURL(block.blobUrl); + } + block.blobUrl = URL.createObjectURL(blob); + } catch (error) { + console.warn(error, model, sourceId); + + block.loading = false; + block.error = true; + block.allowEmbed = false; + if (block.blobUrl) { + URL.revokeObjectURL(block.blobUrl); + block.blobUrl = undefined; + } + } +} + +/** + * Since the size of the attachment may be very large, + * the download process may take a long time! + */ +export function downloadAttachmentBlob(block: AttachmentBlockComponent) { + const { host, model, loading, error, downloading, blobUrl } = block; + if (downloading) { + toast(host, 'Download in progress...'); + return; + } + + if (loading) { + toast(host, 'Please wait, file is loading...'); + return; + } + + const name = model.name; + const shortName = name.length < 20 ? name : name.slice(0, 20) + '...'; + + if (error || !blobUrl) { + toast(host, `Failed to download ${shortName}!`); + return; + } + + block.downloading = true; + + toast(host, `Downloading ${shortName}`); + + const tmpLink = document.createElement('a'); + const event = new MouseEvent('click'); + tmpLink.download = name; + tmpLink.href = blobUrl; + tmpLink.dispatchEvent(event); + tmpLink.remove(); + + block.downloading = false; +} + +export async function getFileType(file: File) { + if (file.type) { + return file.type; + } + // If the file type is not available, try to get it from the buffer. + const buffer = await file.arrayBuffer(); + const FileType = await import('file-type'); + const fileType = await FileType.fileTypeFromBuffer(buffer); + return fileType ? fileType.mime : ''; +} + +/** + * Add a new attachment block before / after the specified block. + */ +export async function addSiblingAttachmentBlocks( + editorHost: EditorHost, + files: File[], + maxFileSize: number, + targetModel: BlockModel, + place: 'before' | 'after' = 'after' +) { + if (!files.length) { + return; + } + + const isSizeExceeded = files.some(file => file.size > maxFileSize); + if (isSizeExceeded) { + toast( + editorHost, + `You can only upload files less than ${humanFileSize( + maxFileSize, + true, + 0 + )}` + ); + return; + } + + const doc = targetModel.doc; + + // Get the types of all files + const types = await Promise.all(files.map(file => getFileType(file))); + const attachmentBlockProps: (Partial<AttachmentBlockProps> & { + flavour: 'affine:attachment'; + })[] = files.map((file, index) => ({ + flavour: 'affine:attachment', + name: file.name, + size: file.size, + type: types[index], + })); + + const blockIds = doc.addSiblingBlocks( + targetModel, + attachmentBlockProps, + place + ); + + blockIds.forEach( + (blockId, index) => + void uploadAttachmentBlob(editorHost, blockId, files[index], types[index]) + ); + + return blockIds; +} diff --git a/blocksuite/blocks/src/bookmark-block/adapters/html.ts b/blocksuite/blocks/src/bookmark-block/adapters/html.ts new file mode 100644 index 0000000000..869b447402 --- /dev/null +++ b/blocksuite/blocks/src/bookmark-block/adapters/html.ts @@ -0,0 +1,10 @@ +import { createEmbedBlockHtmlAdapterMatcher } from '@blocksuite/affine-block-embed'; +import { BookmarkBlockSchema } from '@blocksuite/affine-model'; +import { BlockHtmlAdapterExtension } from '@blocksuite/affine-shared/adapters'; + +export const bookmarkBlockHtmlAdapterMatcher = + createEmbedBlockHtmlAdapterMatcher(BookmarkBlockSchema.model.flavour); + +export const BookmarkBlockHtmlAdapterExtension = BlockHtmlAdapterExtension( + bookmarkBlockHtmlAdapterMatcher +); diff --git a/blocksuite/blocks/src/bookmark-block/adapters/index.ts b/blocksuite/blocks/src/bookmark-block/adapters/index.ts new file mode 100644 index 0000000000..b4dd5a6d2a --- /dev/null +++ b/blocksuite/blocks/src/bookmark-block/adapters/index.ts @@ -0,0 +1,4 @@ +export * from './html.js'; +export * from './markdown.js'; +export * from './notion-html.js'; +export * from './plain-text.js'; diff --git a/blocksuite/blocks/src/bookmark-block/adapters/markdown.ts b/blocksuite/blocks/src/bookmark-block/adapters/markdown.ts new file mode 100644 index 0000000000..9bcb816b3d --- /dev/null +++ b/blocksuite/blocks/src/bookmark-block/adapters/markdown.ts @@ -0,0 +1,9 @@ +import { createEmbedBlockMarkdownAdapterMatcher } from '@blocksuite/affine-block-embed'; +import { BookmarkBlockSchema } from '@blocksuite/affine-model'; +import { BlockMarkdownAdapterExtension } from '@blocksuite/affine-shared/adapters'; + +export const bookmarkBlockMarkdownAdapterMatcher = + createEmbedBlockMarkdownAdapterMatcher(BookmarkBlockSchema.model.flavour); + +export const BookmarkBlockMarkdownAdapterExtension = + BlockMarkdownAdapterExtension(bookmarkBlockMarkdownAdapterMatcher); diff --git a/blocksuite/blocks/src/bookmark-block/adapters/notion-html.ts b/blocksuite/blocks/src/bookmark-block/adapters/notion-html.ts new file mode 100644 index 0000000000..9a31a42661 --- /dev/null +++ b/blocksuite/blocks/src/bookmark-block/adapters/notion-html.ts @@ -0,0 +1,71 @@ +import { BookmarkBlockSchema } from '@blocksuite/affine-model'; +import { + BlockNotionHtmlAdapterExtension, + type BlockNotionHtmlAdapterMatcher, + HastUtils, +} from '@blocksuite/affine-shared/adapters'; +import { nanoid } from '@blocksuite/store'; + +export const bookmarkBlockNotionHtmlAdapterMatcher: BlockNotionHtmlAdapterMatcher = + { + flavour: BookmarkBlockSchema.model.flavour, + toMatch: o => { + return ( + HastUtils.isElement(o.node) && + o.node.tagName === 'figure' && + !!HastUtils.querySelector(o.node, '.bookmark') + ); + }, + fromMatch: () => false, + toBlockSnapshot: { + enter: (o, context) => { + if (!HastUtils.isElement(o.node)) { + return; + } + const bookmark = HastUtils.querySelector(o.node, '.bookmark'); + if (!bookmark) { + return; + } + + const { walkerContext } = context; + const bookmarkURL = bookmark.properties?.href; + const bookmarkTitle = HastUtils.getTextContent( + HastUtils.querySelector(bookmark, '.bookmark-title') + ); + const bookmarkDescription = HastUtils.getTextContent( + HastUtils.querySelector(bookmark, '.bookmark-description') + ); + const bookmarkIcon = HastUtils.querySelector( + bookmark, + '.bookmark-icon' + ); + const bookmarkIconURL = + typeof bookmarkIcon?.properties?.src === 'string' + ? bookmarkIcon.properties.src + : ''; + walkerContext + .openNode( + { + type: 'block', + id: nanoid(), + flavour: BookmarkBlockSchema.model.flavour, + props: { + type: 'card', + url: bookmarkURL ?? '', + title: bookmarkTitle, + description: bookmarkDescription, + icon: bookmarkIconURL, + }, + children: [], + }, + 'children' + ) + .closeNode(); + walkerContext.skipAllChildren(); + }, + }, + fromBlockSnapshot: {}, + }; + +export const BookmarkBlockNotionHtmlAdapterExtension = + BlockNotionHtmlAdapterExtension(bookmarkBlockNotionHtmlAdapterMatcher); diff --git a/blocksuite/blocks/src/bookmark-block/adapters/plain-text.ts b/blocksuite/blocks/src/bookmark-block/adapters/plain-text.ts new file mode 100644 index 0000000000..53652bdb8d --- /dev/null +++ b/blocksuite/blocks/src/bookmark-block/adapters/plain-text.ts @@ -0,0 +1,9 @@ +import { createEmbedBlockPlainTextAdapterMatcher } from '@blocksuite/affine-block-embed'; +import { BookmarkBlockSchema } from '@blocksuite/affine-model'; +import { BlockPlainTextAdapterExtension } from '@blocksuite/affine-shared/adapters'; + +export const bookmarkBlockPlainTextAdapterMatcher = + createEmbedBlockPlainTextAdapterMatcher(BookmarkBlockSchema.model.flavour); + +export const BookmarkBlockPlainTextAdapterExtension = + BlockPlainTextAdapterExtension(bookmarkBlockPlainTextAdapterMatcher); diff --git a/blocksuite/blocks/src/bookmark-block/bookmark-block.ts b/blocksuite/blocks/src/bookmark-block/bookmark-block.ts new file mode 100644 index 0000000000..0eee80e1fd --- /dev/null +++ b/blocksuite/blocks/src/bookmark-block/bookmark-block.ts @@ -0,0 +1,118 @@ +import { + CaptionedBlockComponent, + SelectedStyle, +} from '@blocksuite/affine-components/caption'; +import type { BookmarkBlockModel } from '@blocksuite/affine-model'; +import { DocModeProvider } from '@blocksuite/affine-shared/services'; +import { html } from 'lit'; +import { property, query } from 'lit/decorators.js'; +import { classMap } from 'lit/directives/class-map.js'; +import { type StyleInfo, styleMap } from 'lit/directives/style-map.js'; + +import { BOOKMARK_MIN_WIDTH } from '../root-block/edgeless/utils/consts.js'; +import type { BookmarkBlockService } from './bookmark-service.js'; +import { refreshBookmarkUrlData } from './utils.js'; + +export class BookmarkBlockComponent extends CaptionedBlockComponent< + BookmarkBlockModel, + BookmarkBlockService +> { + private _fetchAbortController?: AbortController; + + blockDraggable = true; + + protected containerStyleMap!: ReturnType<typeof styleMap>; + + open = () => { + let link = this.model.url; + if (!link.match(/^[a-zA-Z]+:\/\//)) { + link = 'https://' + link; + } + window.open(link, '_blank'); + }; + + refreshData = () => { + refreshBookmarkUrlData(this, this._fetchAbortController?.signal).catch( + console.error + ); + }; + + override connectedCallback() { + super.connectedCallback(); + + const mode = this.std.get(DocModeProvider).getEditorMode(); + const miniWidth = `${BOOKMARK_MIN_WIDTH}px`; + + this.containerStyleMap = styleMap({ + position: 'relative', + width: '100%', + ...(mode === 'edgeless' ? { miniWidth } : {}), + }); + + this._fetchAbortController = new AbortController(); + + this.contentEditable = 'false'; + + if (!this.model.description && !this.model.title) { + this.refreshData(); + } + + this.disposables.add( + this.model.propsUpdated.on(({ key }) => { + if (key === 'url') { + this.refreshData(); + } + }) + ); + } + + override disconnectedCallback(): void { + super.disconnectedCallback(); + this._fetchAbortController?.abort(); + } + + override renderBlock() { + const selected = !!this.selected?.is('block'); + return html` + <div + draggable="${this.blockDraggable ? 'true' : 'false'}" + class=${classMap({ + 'affine-bookmark-container': true, + 'selected-style': selected, + })} + style=${this.containerStyleMap} + > + <bookmark-card + .bookmark=${this} + .loading=${this.loading} + .error=${this.error} + ></bookmark-card> + </div> + `; + } + + protected override accessor blockContainerStyles: StyleInfo = { + margin: '18px 0', + }; + + @query('bookmark-card') + accessor bookmarkCard!: HTMLElement; + + @property({ attribute: false }) + accessor error = false; + + @property({ attribute: false }) + accessor loading = false; + + override accessor selectedStyle = SelectedStyle.Border; + + override accessor useCaptionEditor = true; + + override accessor useZeroWidth = true; +} + +declare global { + interface HTMLElementTagNameMap { + 'affine-bookmark': BookmarkBlockComponent; + } +} diff --git a/blocksuite/blocks/src/bookmark-block/bookmark-edgeless-block.ts b/blocksuite/blocks/src/bookmark-block/bookmark-edgeless-block.ts new file mode 100644 index 0000000000..350a66df94 --- /dev/null +++ b/blocksuite/blocks/src/bookmark-block/bookmark-edgeless-block.ts @@ -0,0 +1,53 @@ +import { + EMBED_CARD_HEIGHT, + EMBED_CARD_WIDTH, +} from '@blocksuite/affine-shared/consts'; +import { toGfxBlockComponent } from '@blocksuite/block-std'; +import { styleMap } from 'lit/directives/style-map.js'; + +import { BookmarkBlockComponent } from './bookmark-block.js'; + +export class BookmarkEdgelessBlockComponent extends toGfxBlockComponent( + BookmarkBlockComponent +) { + override blockDraggable = false; + + override getRenderingRect() { + const elementBound = this.model.elementBound; + const style = this.model.style$.value; + + return { + x: elementBound.x, + y: elementBound.y, + w: EMBED_CARD_WIDTH[style], + h: EMBED_CARD_HEIGHT[style], + zIndex: this.toZIndex(), + }; + } + + override renderGfxBlock() { + const style = this.model.style$.value; + const width = EMBED_CARD_WIDTH[style]; + const height = EMBED_CARD_HEIGHT[style]; + const bound = this.model.elementBound; + const scaleX = bound.w / width; + const scaleY = bound.h / height; + + this.containerStyleMap = styleMap({ + width: `100%`, + height: `100%`, + transform: `scale(${scaleX}, ${scaleY})`, + transformOrigin: '0 0', + }); + + return this.renderPageContent(); + } + + protected override accessor blockContainerStyles = {}; +} + +declare global { + interface HTMLElementTagNameMap { + 'affine-edgeless-bookmark': BookmarkEdgelessBlockComponent; + } +} diff --git a/blocksuite/blocks/src/bookmark-block/bookmark-service.ts b/blocksuite/blocks/src/bookmark-block/bookmark-service.ts new file mode 100644 index 0000000000..d0c54ed320 --- /dev/null +++ b/blocksuite/blocks/src/bookmark-block/bookmark-service.ts @@ -0,0 +1,77 @@ +import { LinkPreviewer } from '@blocksuite/affine-block-embed'; +import { BookmarkBlockSchema } from '@blocksuite/affine-model'; +import { + EMBED_CARD_HEIGHT, + EMBED_CARD_WIDTH, +} from '@blocksuite/affine-shared/consts'; +import { DragHandleConfigExtension } from '@blocksuite/affine-shared/services'; +import { + captureEventTarget, + convertDragPreviewDocToEdgeless, + convertDragPreviewEdgelessToDoc, + matchFlavours, +} from '@blocksuite/affine-shared/utils'; +import { BlockService } from '@blocksuite/block-std'; + +import type { BookmarkBlockComponent } from './bookmark-block.js'; +import { BookmarkEdgelessBlockComponent } from './bookmark-edgeless-block.js'; + +export class BookmarkBlockService extends BlockService { + static override readonly flavour = BookmarkBlockSchema.model.flavour; + + private static readonly linkPreviewer = new LinkPreviewer(); + + static setLinkPreviewEndpoint = + BookmarkBlockService.linkPreviewer.setEndpoint; + + queryUrlData = (url: string, signal?: AbortSignal) => { + return BookmarkBlockService.linkPreviewer.query(url, signal); + }; +} + +export const BookmarkDragHandleOption = DragHandleConfigExtension({ + flavour: BookmarkBlockSchema.model.flavour, + edgeless: true, + onDragEnd: props => { + const { state, draggingElements } = props; + if ( + draggingElements.length !== 1 || + !matchFlavours(draggingElements[0].model, [ + BookmarkBlockSchema.model.flavour, + ]) + ) + return false; + + const blockComponent = draggingElements[0] as + | BookmarkBlockComponent + | BookmarkEdgelessBlockComponent; + const isInSurface = + blockComponent instanceof BookmarkEdgelessBlockComponent; + const target = captureEventTarget(state.raw.target); + const isTargetEdgelessContainer = + target?.classList.contains('edgeless-container'); + + if (isInSurface) { + const style = blockComponent.model.style; + const targetStyle = + style === 'vertical' || style === 'cube' ? 'horizontal' : style; + return convertDragPreviewEdgelessToDoc({ + blockComponent, + style: targetStyle, + ...props, + }); + } else if (isTargetEdgelessContainer) { + const style = blockComponent.model.style; + + return convertDragPreviewDocToEdgeless({ + blockComponent, + cssSelector: 'bookmark-card', + width: EMBED_CARD_WIDTH[style], + height: EMBED_CARD_HEIGHT[style], + ...props, + }); + } + + return false; + }, +}); diff --git a/blocksuite/blocks/src/bookmark-block/bookmark-spec.ts b/blocksuite/blocks/src/bookmark-block/bookmark-spec.ts new file mode 100644 index 0000000000..2bf50b1ac4 --- /dev/null +++ b/blocksuite/blocks/src/bookmark-block/bookmark-spec.ts @@ -0,0 +1,25 @@ +import { + BlockViewExtension, + CommandExtension, + type ExtensionType, + FlavourExtension, +} from '@blocksuite/block-std'; +import { literal } from 'lit/static-html.js'; + +import { + BookmarkBlockService, + BookmarkDragHandleOption, +} from './bookmark-service.js'; +import { commands } from './commands/index.js'; + +export const BookmarkBlockSpec: ExtensionType[] = [ + FlavourExtension('affine:bookmark'), + BookmarkBlockService, + CommandExtension(commands), + BlockViewExtension('affine:bookmark', model => { + return model.parent?.flavour === 'affine:surface' + ? literal`affine-edgeless-bookmark` + : literal`affine-bookmark`; + }), + BookmarkDragHandleOption, +]; diff --git a/blocksuite/blocks/src/bookmark-block/commands/index.ts b/blocksuite/blocks/src/bookmark-block/commands/index.ts new file mode 100644 index 0000000000..4772254a2b --- /dev/null +++ b/blocksuite/blocks/src/bookmark-block/commands/index.ts @@ -0,0 +1,7 @@ +import type { BlockCommands } from '@blocksuite/block-std'; + +import { insertBookmarkCommand } from './insert-bookmark.js'; + +export const commands: BlockCommands = { + insertBookmark: insertBookmarkCommand, +}; diff --git a/blocksuite/blocks/src/bookmark-block/commands/insert-bookmark.ts b/blocksuite/blocks/src/bookmark-block/commands/insert-bookmark.ts new file mode 100644 index 0000000000..41be3ae388 --- /dev/null +++ b/blocksuite/blocks/src/bookmark-block/commands/insert-bookmark.ts @@ -0,0 +1,23 @@ +import { insertEmbedCard } from '@blocksuite/affine-block-embed'; +import type { EmbedCardStyle } from '@blocksuite/affine-model'; +import { EmbedOptionProvider } from '@blocksuite/affine-shared/services'; +import type { Command } from '@blocksuite/block-std'; + +export const insertBookmarkCommand: Command< + never, + 'insertedLinkType', + { url: string } +> = (ctx, next) => { + const { url, std } = ctx; + const embedOptions = std.get(EmbedOptionProvider).getEmbedBlockOptions(url); + + let flavour = 'affine:bookmark'; + let targetStyle: EmbedCardStyle = 'vertical'; + const props: Record<string, unknown> = { url }; + if (embedOptions) { + flavour = embedOptions.flavour; + targetStyle = embedOptions.styles[0]; + } + insertEmbedCard(std, { flavour, targetStyle, props }); + next(); +}; diff --git a/blocksuite/blocks/src/bookmark-block/components/bookmark-card.ts b/blocksuite/blocks/src/bookmark-block/components/bookmark-card.ts new file mode 100644 index 0000000000..b4651f1a1f --- /dev/null +++ b/blocksuite/blocks/src/bookmark-block/components/bookmark-card.ts @@ -0,0 +1,164 @@ +import { WebIcon16 } from '@blocksuite/affine-components/icons'; +import { ThemeProvider } from '@blocksuite/affine-shared/services'; +import { getHostName } from '@blocksuite/affine-shared/utils'; +import { ShadowlessElement } from '@blocksuite/block-std'; +import { WithDisposable } from '@blocksuite/global/utils'; +import { OpenInNewIcon } from '@blocksuite/icons/lit'; +import { html } from 'lit'; +import { property, state } from 'lit/decorators.js'; +import { classMap } from 'lit/directives/class-map.js'; + +import { getEmbedCardIcons } from '../../_common/utils/url.js'; +import type { BookmarkBlockComponent } from '../bookmark-block.js'; +import { styles } from '../styles.js'; + +export class BookmarkCard extends WithDisposable(ShadowlessElement) { + static override styles = styles; + + private _handleClick(event: MouseEvent) { + event.stopPropagation(); + const model = this.bookmark.model; + + if (model.parent?.flavour !== 'affine:surface') { + this._selectBlock(); + } + } + + private _handleDoubleClick(event: MouseEvent) { + event.stopPropagation(); + this.bookmark.open(); + } + + private _selectBlock() { + const selectionManager = this.bookmark.host.selection; + const blockSelection = selectionManager.create('block', { + blockId: this.bookmark.blockId, + }); + selectionManager.setGroup('note', [blockSelection]); + } + + override connectedCallback(): void { + super.connectedCallback(); + + this.disposables.add( + this.bookmark.model.propsUpdated.on(() => { + this.requestUpdate(); + }) + ); + + this.disposables.add( + this.bookmark.std + .get(ThemeProvider) + .theme$.subscribe(() => this.requestUpdate()) + ); + + this.disposables.add( + this.bookmark.selection.slots.changed.on(() => { + this._isSelected = + !!this.bookmark.selected?.is('block') || + !!this.bookmark.selected?.is('surface'); + }) + ); + } + + override render() { + const { icon, title, url, description, image, style } = this.bookmark.model; + + const cardClassMap = classMap({ + loading: this.loading, + error: this.error, + [style]: true, + selected: this._isSelected, + }); + + const domainName = url.match( + /^(?:https?:\/\/)?(?:[^@\n]+@)?(?:www\.)?([^:/\n]+)/im + )?.[1]; + + const titleText = this.loading + ? 'Loading...' + : !title + ? this.error + ? (domainName ?? 'Link card') + : '' + : title; + + const theme = this.bookmark.std.get(ThemeProvider).theme; + const { LoadingIcon, EmbedCardBannerIcon } = getEmbedCardIcons(theme); + + const titleIconType = + !icon?.split('.').pop() || icon?.split('.').pop() === 'svg' + ? 'svg+xml' + : icon?.split('.').pop(); + + const titleIcon = this.loading + ? LoadingIcon + : icon + ? html`<object + type="image/${titleIconType}" + data=${icon} + draggable="false" + > + ${WebIcon16} + </object>` + : WebIcon16; + + const descriptionText = this.loading + ? '' + : !description + ? this.error + ? 'Failed to retrieve link information.' + : url + : (description ?? ''); + + const bannerImage = + !this.loading && image + ? html`<object type="image/webp" data=${image} draggable="false"> + ${EmbedCardBannerIcon} + </object>` + : EmbedCardBannerIcon; + + return html` + <div + class="affine-bookmark-card ${cardClassMap}" + @click=${this._handleClick} + @dblclick=${this._handleDoubleClick} + > + <div class="affine-bookmark-content"> + <div class="affine-bookmark-content-title"> + <div class="affine-bookmark-content-title-icon">${titleIcon}</div> + <div class="affine-bookmark-content-title-text">${titleText}</div> + </div> + <div class="affine-bookmark-content-description"> + ${descriptionText} + </div> + <div class="affine-bookmark-content-url" @click=${this.bookmark.open}> + <span>${getHostName(url)}</span> + <div class="affine-bookmark-content-url-icon"> + ${OpenInNewIcon({ width: '12', height: '12' })} + </div> + </div> + </div> + <div class="affine-bookmark-banner">${bannerImage}</div> + </div> + `; + } + + @state() + private accessor _isSelected = false; + + @property({ attribute: false }) + accessor bookmark!: BookmarkBlockComponent; + + @property({ attribute: false }) + accessor error!: boolean; + + @property({ attribute: false }) + accessor loading!: boolean; +} + +declare global { + interface HTMLElementTagNameMap { + 'bookmark-card': BookmarkCard; + } +} diff --git a/blocksuite/blocks/src/bookmark-block/index.ts b/blocksuite/blocks/src/bookmark-block/index.ts new file mode 100644 index 0000000000..267e521a6f --- /dev/null +++ b/blocksuite/blocks/src/bookmark-block/index.ts @@ -0,0 +1,3 @@ +export * from './adapters/index.js'; +export * from './bookmark-block.js'; +export * from './bookmark-service.js'; diff --git a/blocksuite/blocks/src/bookmark-block/styles.ts b/blocksuite/blocks/src/bookmark-block/styles.ts new file mode 100644 index 0000000000..bdd264ea9b --- /dev/null +++ b/blocksuite/blocks/src/bookmark-block/styles.ts @@ -0,0 +1,286 @@ +import { unsafeCSSVar } from '@blocksuite/affine-shared/theme'; +import { baseTheme } from '@toeverything/theme'; +import { css, unsafeCSS } from 'lit'; + +import { EMBED_CARD_HEIGHT, EMBED_CARD_WIDTH } from '../_common/consts.js'; + +export const styles = css` + .affine-bookmark-card { + container: affine-bookmark-card / inline-size; + margin: 0 auto; + box-sizing: border-box; + display: flex; + width: 100%; + height: ${EMBED_CARD_HEIGHT.horizontal}px; + + border-radius: 8px; + border: 1px solid var(--affine-background-tertiary-color); + + opacity: var(--add, 1); + background: var(--affine-background-primary-color); + user-select: none; + } + + .affine-bookmark-content { + width: calc(100% - 204px); + height: 100%; + display: flex; + flex-direction: column; + align-self: stretch; + gap: 4px; + padding: 12px; + border-radius: var(--1, 0px); + opacity: var(--add, 1); + } + + .affine-bookmark-content-title { + display: flex; + flex-direction: row; + gap: 8px; + align-items: center; + + align-self: stretch; + padding: var(--1, 0px); + border-radius: var(--1, 0px); + opacity: var(--add, 1); + } + + .affine-bookmark-content-title-icon { + display: flex; + width: 16px; + height: 16px; + justify-content: center; + align-items: center; + } + + .affine-bookmark-content-title-icon img, + .affine-bookmark-content-title-icon object, + .affine-bookmark-content-title-icon svg { + width: 16px; + height: 16px; + fill: var(--affine-background-primary-color); + } + + .affine-bookmark-content-title-text { + display: -webkit-box; + -webkit-line-clamp: 1; + -webkit-box-orient: vertical; + + word-break: break-word; + overflow: hidden; + text-overflow: ellipsis; + color: var(--affine-text-primary-color); + + font-family: var(--affine-font-family); + font-size: var(--affine-font-sm); + font-style: normal; + font-weight: 600; + line-height: 22px; + } + + .affine-bookmark-content-description { + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + + flex-grow: 1; + + white-space: normal; + word-break: break-word; + overflow: hidden; + text-overflow: ellipsis; + color: var(--affine-text-primary-color); + + font-family: var(--affine-font-family); + font-size: var(--affine-font-xs); + font-style: normal; + font-weight: 400; + line-height: 20px; + } + + .affine-bookmark-content-url { + display: flex; + align-items: center; + justify-content: flex-start; + gap: 4px; + width: max-content; + max-width: 100%; + cursor: pointer; + } + + .affine-bookmark-content-url > span { + display: -webkit-box; + -webkit-line-clamp: 1; + -webkit-box-orient: vertical; + + word-break: break-all; + white-space: normal; + overflow: hidden; + text-overflow: ellipsis; + color: var(--affine-text-secondary-color); + + font-family: ${unsafeCSS(baseTheme.fontSansFamily)}; + font-size: var(--affine-font-xs); + font-style: normal; + font-weight: 400; + line-height: 20px; + } + .affine-bookmark-content-url:hover > span { + color: var(--affine-link-color); + } + .affine-bookmark-content-url:hover { + fill: var(--affine-link-color); + } + + .affine-bookmark-content-url-icon { + display: flex; + align-items: center; + justify-content: center; + width: 12px; + height: 20px; + } + .affine-bookmark-content-url-icon { + height: 12px; + width: 12px; + color: ${unsafeCSSVar('iconSecondary')}; + } + + .affine-bookmark-banner { + margin: 12px 12px 0px 0px; + width: 204px; + max-width: 100%; + height: 102px; + opacity: var(--add, 1); + } + + .affine-bookmark-banner img, + .affine-bookmark-banner object, + .affine-bookmark-banner svg { + width: 204px; + max-width: 100%; + height: 102px; + object-fit: cover; + border-radius: 4px 4px var(--1, 0px) var(--1, 0px); + } + + .affine-bookmark-card.loading { + .affine-bookmark-content-title-text { + color: var(--affine-placeholder-color); + } + } + + .affine-bookmark-card.error { + .affine-bookmark-content-description { + color: var(--affine-placeholder-color); + } + } + + .affine-bookmark-card.selected { + .affine-bookmark-content-url > span { + color: var(--affine-link-color); + } + .affine-bookmark-content-url .affine-bookmark-content-url-icon { + color: var(--affine-link-color); + } + } + + .affine-bookmark-card.list { + height: ${EMBED_CARD_HEIGHT.list}px; + + .affine-bookmark-content { + width: 100%; + flex-direction: row; + align-items: center; + justify-content: space-between; + } + + .affine-bookmark-content-title { + width: calc(100% - 204px); + } + + .affine-bookmark-content-url { + width: 204px; + justify-content: flex-end; + } + + .affine-bookmark-content-description { + display: none; + } + + .affine-bookmark-banner { + display: none; + } + } + + .affine-bookmark-card.vertical { + width: ${EMBED_CARD_WIDTH.vertical}px; + height: ${EMBED_CARD_HEIGHT.vertical}px; + flex-direction: column-reverse; + + .affine-bookmark-content { + width: 100%; + } + + .affine-bookmark-content-description { + -webkit-line-clamp: 6; + max-height: 120px; + } + + .affine-bookmark-content-url { + flex-grow: 1; + align-items: flex-end; + } + + .affine-bookmark-banner { + width: 340px; + height: 170px; + margin-left: 12px; + } + + .affine-bookmark-banner img, + .affine-bookmark-banner object, + .affine-bookmark-banner svg { + width: 340px; + height: 170px; + } + } + + .affine-bookmark-card.cube { + width: ${EMBED_CARD_WIDTH.cube}px; + height: ${EMBED_CARD_HEIGHT.cube}px; + + .affine-bookmark-content { + width: 100%; + flex-direction: column; + align-items: flex-start; + justify-content: space-between; + } + + .affine-bookmark-content-title { + flex-direction: column; + gap: 4px; + align-items: flex-start; + } + + .affine-bookmark-content-title-text { + -webkit-line-clamp: 2; + } + + .affine-bookmark-content-description { + display: none; + } + + .affine-bookmark-banner { + display: none; + } + } + + @container affine-bookmark-card (width < 375px) { + .affine-bookmark-content { + width: 100%; + } + .affine-bookmark-banner { + display: none; + } + } +`; diff --git a/blocksuite/blocks/src/bookmark-block/utils.ts b/blocksuite/blocks/src/bookmark-block/utils.ts new file mode 100644 index 0000000000..aa7d69856b --- /dev/null +++ b/blocksuite/blocks/src/bookmark-block/utils.ts @@ -0,0 +1,49 @@ +import { isAbortError } from '@blocksuite/affine-shared/utils'; +import { assertExists } from '@blocksuite/global/utils'; + +import type { BookmarkBlockComponent } from './bookmark-block.js'; + +export async function refreshBookmarkUrlData( + bookmarkElement: BookmarkBlockComponent, + signal?: AbortSignal +) { + let title = null, + description = null, + icon = null, + image = null; + + try { + bookmarkElement.loading = true; + + const queryUrlData = bookmarkElement.service?.queryUrlData; + assertExists(queryUrlData); + + const bookmarkUrlData = await queryUrlData( + bookmarkElement.model.url, + signal + ); + + title = bookmarkUrlData.title ?? null; + description = bookmarkUrlData.description ?? null; + icon = bookmarkUrlData.icon ?? null; + image = bookmarkUrlData.image ?? null; + + if (!title && !description && !icon && !image) { + bookmarkElement.error = true; + } + + if (signal?.aborted) return; + + bookmarkElement.doc.updateBlock(bookmarkElement.model, { + title, + description, + icon, + image, + }); + } catch (error) { + if (signal?.aborted || isAbortError(error)) return; + throw error; + } finally { + bookmarkElement.loading = false; + } +} diff --git a/blocksuite/blocks/src/code-block/adapters/html.ts b/blocksuite/blocks/src/code-block/adapters/html.ts new file mode 100644 index 0000000000..6547798738 --- /dev/null +++ b/blocksuite/blocks/src/code-block/adapters/html.ts @@ -0,0 +1,93 @@ +import { CodeBlockSchema } from '@blocksuite/affine-model'; +import { + BlockHtmlAdapterExtension, + type BlockHtmlAdapterMatcher, + HastUtils, +} from '@blocksuite/affine-shared/adapters'; +import { nanoid } from '@blocksuite/store'; +import { bundledLanguagesInfo, codeToHast } from 'shiki'; + +export const codeBlockHtmlAdapterMatcher: BlockHtmlAdapterMatcher = { + flavour: CodeBlockSchema.model.flavour, + toMatch: o => HastUtils.isElement(o.node) && o.node.tagName === 'pre', + fromMatch: o => o.node.flavour === 'affine:code', + toBlockSnapshot: { + enter: (o, context) => { + if (!HastUtils.isElement(o.node)) { + return; + } + const code = HastUtils.querySelector(o.node, 'code'); + if (!code) { + return; + } + + const codeText = + code.children.length === 1 && code.children[0].type === 'text' + ? code.children[0] + : { ...code, tagName: 'div' }; + let codeLang = Array.isArray(code.properties?.className) + ? code.properties.className.find( + className => + typeof className === 'string' && className.startsWith('code-') + ) + : undefined; + codeLang = + typeof codeLang === 'string' + ? codeLang.replace('code-', '') + : undefined; + + const { walkerContext, deltaConverter } = context; + walkerContext + .openNode( + { + type: 'block', + id: nanoid(), + flavour: 'affine:code', + props: { + language: codeLang ?? 'Plain Text', + text: { + '$blocksuite:internal:text$': true, + delta: deltaConverter.astToDelta(codeText, { + trim: false, + pre: true, + }), + }, + }, + children: [], + }, + 'children' + ) + .closeNode(); + walkerContext.skipAllChildren(); + }, + }, + fromBlockSnapshot: { + enter: async (o, context) => { + const { walkerContext } = context; + const rawLang = o.node.props.language as string | null; + const matchedLang = rawLang + ? (bundledLanguagesInfo.find( + info => + info.id === rawLang || + info.name === rawLang || + info.aliases?.includes(rawLang) + )?.id ?? 'text') + : 'text'; + + // @ts-expect-error FIXME: ts error + const text = o.node.props.text.delta as DeltaInsert[]; + const code = text.map(delta => delta.insert).join(''); + const hast = await codeToHast(code, { + lang: matchedLang, + theme: 'light-plus', + }); + + // @ts-expect-error FIXME: ts error + walkerContext.openNode(hast, 'children').closeNode(); + }, + }, +}; + +export const CodeBlockHtmlAdapterExtension = BlockHtmlAdapterExtension( + codeBlockHtmlAdapterMatcher +); diff --git a/blocksuite/blocks/src/code-block/adapters/index.ts b/blocksuite/blocks/src/code-block/adapters/index.ts new file mode 100644 index 0000000000..b4dd5a6d2a --- /dev/null +++ b/blocksuite/blocks/src/code-block/adapters/index.ts @@ -0,0 +1,4 @@ +export * from './html.js'; +export * from './markdown.js'; +export * from './notion-html.js'; +export * from './plain-text.js'; diff --git a/blocksuite/blocks/src/code-block/adapters/markdown.ts b/blocksuite/blocks/src/code-block/adapters/markdown.ts new file mode 100644 index 0000000000..34b978b1c4 --- /dev/null +++ b/blocksuite/blocks/src/code-block/adapters/markdown.ts @@ -0,0 +1,70 @@ +import { CodeBlockSchema } from '@blocksuite/affine-model'; +import { + BlockMarkdownAdapterExtension, + type BlockMarkdownAdapterMatcher, + type MarkdownAST, +} from '@blocksuite/affine-shared/adapters'; +import type { DeltaInsert } from '@blocksuite/inline'; +import { nanoid } from '@blocksuite/store'; +import type { Code } from 'mdast'; + +const isCodeNode = (node: MarkdownAST): node is Code => node.type === 'code'; + +export const codeBlockMarkdownAdapterMatcher: BlockMarkdownAdapterMatcher = { + flavour: CodeBlockSchema.model.flavour, + toMatch: o => isCodeNode(o.node), + fromMatch: o => o.node.flavour === 'affine:code', + toBlockSnapshot: { + enter: (o, context) => { + if (!isCodeNode(o.node)) { + return; + } + const { walkerContext } = context; + walkerContext + .openNode( + { + type: 'block', + id: nanoid(), + flavour: 'affine:code', + props: { + language: o.node.lang ?? 'Plain Text', + text: { + '$blocksuite:internal:text$': true, + delta: [ + { + insert: o.node.value, + }, + ], + }, + }, + children: [], + }, + 'children' + ) + .closeNode(); + }, + }, + fromBlockSnapshot: { + enter: (o, context) => { + const text = (o.node.props.text ?? { delta: [] }) as { + delta: DeltaInsert[]; + }; + const { walkerContext } = context; + walkerContext + .openNode( + { + type: 'code', + lang: (o.node.props.language as string) ?? null, + meta: null, + value: text.delta.map(delta => delta.insert).join(''), + }, + 'children' + ) + .closeNode(); + }, + }, +}; + +export const CodeBlockMarkdownAdapterExtension = BlockMarkdownAdapterExtension( + codeBlockMarkdownAdapterMatcher +); diff --git a/blocksuite/blocks/src/code-block/adapters/notion-html.ts b/blocksuite/blocks/src/code-block/adapters/notion-html.ts new file mode 100644 index 0000000000..4fa971baf5 --- /dev/null +++ b/blocksuite/blocks/src/code-block/adapters/notion-html.ts @@ -0,0 +1,56 @@ +import { CodeBlockSchema } from '@blocksuite/affine-model'; +import { + BlockNotionHtmlAdapterExtension, + type BlockNotionHtmlAdapterMatcher, + HastUtils, +} from '@blocksuite/affine-shared/adapters'; +import { nanoid } from '@blocksuite/store'; + +export const codeBlockNotionHtmlAdapterMatcher: BlockNotionHtmlAdapterMatcher = + { + flavour: CodeBlockSchema.model.flavour, + toMatch: o => HastUtils.isElement(o.node) && o.node.tagName === 'pre', + fromMatch: () => false, + toBlockSnapshot: { + enter: (o, context) => { + if (!HastUtils.isElement(o.node)) { + return; + } + const code = HastUtils.querySelector(o.node, 'code'); + if (!code) { + return; + } + const { walkerContext, deltaConverter } = context; + const codeText = + code.children.length === 1 && code.children[0].type === 'text' + ? code.children[0] + : { ...code, tag: 'div' }; + walkerContext + .openNode( + { + type: 'block', + id: nanoid(), + flavour: CodeBlockSchema.model.flavour, + props: { + language: 'Plain Text', + text: { + '$blocksuite:internal:text$': true, + delta: deltaConverter.astToDelta(codeText, { + trim: false, + pre: true, + }), + }, + }, + children: [], + }, + 'children' + ) + .closeNode(); + walkerContext.skipAllChildren(); + }, + }, + fromBlockSnapshot: {}, + }; + +export const CodeBlockNotionHtmlAdapterExtension = + BlockNotionHtmlAdapterExtension(codeBlockNotionHtmlAdapterMatcher); diff --git a/blocksuite/blocks/src/code-block/adapters/plain-text.ts b/blocksuite/blocks/src/code-block/adapters/plain-text.ts new file mode 100644 index 0000000000..6fc4aa9eec --- /dev/null +++ b/blocksuite/blocks/src/code-block/adapters/plain-text.ts @@ -0,0 +1,26 @@ +import { CodeBlockSchema } from '@blocksuite/affine-model'; +import { + BlockPlainTextAdapterExtension, + type BlockPlainTextAdapterMatcher, +} from '@blocksuite/affine-shared/adapters'; +import type { DeltaInsert } from '@blocksuite/inline'; + +export const codeBlockPlainTextAdapterMatcher: BlockPlainTextAdapterMatcher = { + flavour: CodeBlockSchema.model.flavour, + toMatch: () => false, + fromMatch: o => o.node.flavour === CodeBlockSchema.model.flavour, + toBlockSnapshot: {}, + fromBlockSnapshot: { + enter: (o, context) => { + const text = (o.node.props.text ?? { delta: [] }) as { + delta: DeltaInsert[]; + }; + const buffer = text.delta.map(delta => delta.insert).join(''); + context.textBuffer.content += buffer; + context.textBuffer.content += '\n'; + }, + }, +}; + +export const CodeBlockPlainTextAdapterExtension = + BlockPlainTextAdapterExtension(codeBlockPlainTextAdapterMatcher); diff --git a/blocksuite/blocks/src/code-block/clipboard/index.ts b/blocksuite/blocks/src/code-block/clipboard/index.ts new file mode 100644 index 0000000000..ee2d8799ba --- /dev/null +++ b/blocksuite/blocks/src/code-block/clipboard/index.ts @@ -0,0 +1,100 @@ +import { + type BlockComponent, + Clipboard, + type UIEventHandler, +} from '@blocksuite/block-std'; +import { assertExists, DisposableGroup } from '@blocksuite/global/utils'; + +import { HtmlAdapter, PlainTextAdapter } from '../../_common/adapters/index.js'; +import { pasteMiddleware } from '../../root-block/clipboard/middlewares/index.js'; + +export class CodeClipboardController { + private _clipboard!: Clipboard; + + protected _disposables = new DisposableGroup(); + + protected _init = () => { + this._clipboard.registerAdapter('text/plain', PlainTextAdapter, 90); + this._clipboard.registerAdapter('text/html', HtmlAdapter, 80); + const paste = pasteMiddleware(this._std); + this._clipboard.use(paste); + + this._disposables.add({ + dispose: () => { + this._clipboard.unregisterAdapter('text/plain'); + this._clipboard.unregisterAdapter('text/html'); + this._clipboard.unuse(paste); + }, + }); + }; + + host: BlockComponent; + + onPagePaste: UIEventHandler = ctx => { + const e = ctx.get('clipboardState').raw; + e.preventDefault(); + + this._std.doc.captureSync(); + this._std.command + .chain() + .try(cmd => [ + cmd.getTextSelection().inline<'currentSelectionPath'>((ctx, next) => { + const textSelection = ctx.currentTextSelection; + assertExists(textSelection); + const end = textSelection.to ?? textSelection.from; + next({ currentSelectionPath: end.blockId }); + }), + cmd.getBlockSelections().inline<'currentSelectionPath'>((ctx, next) => { + const currentBlockSelections = ctx.currentBlockSelections; + assertExists(currentBlockSelections); + const blockSelection = currentBlockSelections.at(-1); + if (!blockSelection) { + return; + } + next({ currentSelectionPath: blockSelection.blockId }); + }), + ]) + .getBlockIndex() + .try(cmd => [cmd.getTextSelection().deleteText()]) + .inline((ctx, next) => { + if (!ctx.parentBlock) { + return; + } + this._clipboard + .paste( + e, + this._std.doc, + ctx.parentBlock.model.id, + ctx.blockIndex ? ctx.blockIndex + 1 : 1 + ) + .catch(console.error); + + return next(); + }) + .run(); + return true; + }; + + private get _std() { + return this.host.std; + } + + constructor(host: BlockComponent) { + this.host = host; + } + + hostConnected() { + if (this._disposables.disposed) { + this._disposables = new DisposableGroup(); + } + if (navigator.clipboard) { + this._clipboard = new Clipboard(this._std); + this.host.handleEvent('paste', this.onPagePaste); + this._init(); + } + } + + hostDisconnected() { + this._disposables.dispose(); + } +} diff --git a/blocksuite/blocks/src/code-block/code-block-config.ts b/blocksuite/blocks/src/code-block/code-block-config.ts new file mode 100644 index 0000000000..fd94d55c13 --- /dev/null +++ b/blocksuite/blocks/src/code-block/code-block-config.ts @@ -0,0 +1,15 @@ +import type { BundledLanguageInfo, ThemeInput } from 'shiki'; + +export interface CodeBlockConfig { + theme?: { + dark?: ThemeInput; + light?: ThemeInput; + }; + langs?: BundledLanguageInfo[]; + + /** + * Whether to show line numbers in the code block. + * @default true + */ + showLineNumbers?: boolean; +} diff --git a/blocksuite/blocks/src/code-block/code-block-inline.ts b/blocksuite/blocks/src/code-block/code-block-inline.ts new file mode 100644 index 0000000000..356996f9d5 --- /dev/null +++ b/blocksuite/blocks/src/code-block/code-block-inline.ts @@ -0,0 +1,41 @@ +import { + BackgroundInlineSpecExtension, + BoldInlineSpecExtension, + CodeInlineSpecExtension, + ColorInlineSpecExtension, + InlineManagerExtension, + InlineSpecExtension, + ItalicInlineSpecExtension, + LatexInlineSpecExtension, + LinkInlineSpecExtension, + StrikeInlineSpecExtension, + UnderlineInlineSpecExtension, +} from '@blocksuite/affine-components/rich-text'; +import { html } from 'lit'; +import { z } from 'zod'; + +export const CodeBlockUnitSpecExtension = InlineSpecExtension({ + name: 'code-block-unit', + schema: z.undefined(), + match: () => true, + renderer: ({ delta }) => { + return html`<affine-code-unit .delta=${delta}></affine-code-unit>`; + }, +}); + +export const CodeBlockInlineManagerExtension = InlineManagerExtension({ + id: 'CodeBlockInlineManager', + enableMarkdown: false, + specs: [ + BoldInlineSpecExtension.identifier, + ItalicInlineSpecExtension.identifier, + UnderlineInlineSpecExtension.identifier, + StrikeInlineSpecExtension.identifier, + CodeInlineSpecExtension.identifier, + BackgroundInlineSpecExtension.identifier, + ColorInlineSpecExtension.identifier, + LatexInlineSpecExtension.identifier, + LinkInlineSpecExtension.identifier, + CodeBlockUnitSpecExtension.identifier, + ], +}); diff --git a/blocksuite/blocks/src/code-block/code-block-service.ts b/blocksuite/blocks/src/code-block/code-block-service.ts new file mode 100644 index 0000000000..24470db920 --- /dev/null +++ b/blocksuite/blocks/src/code-block/code-block-service.ts @@ -0,0 +1,75 @@ +import { textKeymap } from '@blocksuite/affine-components/rich-text'; +import { CodeBlockSchema, ColorScheme } from '@blocksuite/affine-model'; +import { ThemeProvider } from '@blocksuite/affine-shared/services'; +import { BlockService } from '@blocksuite/block-std'; +import { type Signal, signal } from '@preact/signals-core'; +import { + bundledLanguagesInfo, + createHighlighterCore, + type HighlighterCore, + type MaybeGetter, +} from 'shiki'; +import getWasm from 'shiki/wasm'; + +import { + CODE_BLOCK_DEFAULT_DARK_THEME, + CODE_BLOCK_DEFAULT_LIGHT_THEME, +} from './highlight/const.js'; + +export class CodeBlockService extends BlockService { + static override readonly flavour = CodeBlockSchema.model.flavour; + + private _darkThemeKey: string | undefined; + + private _lightThemeKey: string | undefined; + + highlighter$: Signal<HighlighterCore | null> = signal(null); + + get langs() { + return this.std.getConfig('affine:code')?.langs ?? bundledLanguagesInfo; + } + + get themeKey() { + const theme = this.std.get(ThemeProvider).theme$.value; + return theme === ColorScheme.Dark + ? this._darkThemeKey + : this._lightThemeKey; + } + + override mounted(): void { + super.mounted(); + + this.bindHotKey(textKeymap(this.std)); + + createHighlighterCore({ + loadWasm: getWasm, + }) + .then(async highlighter => { + const config = this.std.getConfig('affine:code'); + const darkTheme = config?.theme?.dark ?? CODE_BLOCK_DEFAULT_DARK_THEME; + const lightTheme = + config?.theme?.light ?? CODE_BLOCK_DEFAULT_LIGHT_THEME; + + this._darkThemeKey = (await normalizeGetter(darkTheme)).name; + this._lightThemeKey = (await normalizeGetter(lightTheme)).name; + + await highlighter.loadTheme(darkTheme, lightTheme); + + this.highlighter$.value = highlighter; + + this.disposables.add(() => { + highlighter.dispose(); + }); + }) + .catch(console.error); + } +} + +/** + * https://github.com/shikijs/shiki/blob/933415cdc154fe74ccfb6bbb3eb6a7b7bf183e60/packages/core/src/internal.ts#L31 + */ +export async function normalizeGetter<T>(p: MaybeGetter<T>): Promise<T> { + return Promise.resolve(typeof p === 'function' ? (p as any)() : p).then( + r => r.default || r + ); +} diff --git a/blocksuite/blocks/src/code-block/code-block-spec.ts b/blocksuite/blocks/src/code-block/code-block-spec.ts new file mode 100644 index 0000000000..5b95b7bed2 --- /dev/null +++ b/blocksuite/blocks/src/code-block/code-block-spec.ts @@ -0,0 +1,25 @@ +import { + BlockViewExtension, + type ExtensionType, + FlavourExtension, + WidgetViewMapExtension, +} from '@blocksuite/block-std'; +import { literal, unsafeStatic } from 'lit/static-html.js'; + +import { AFFINE_CODE_TOOLBAR_WIDGET } from '../root-block/widgets/code-toolbar/index.js'; +import { + CodeBlockInlineManagerExtension, + CodeBlockUnitSpecExtension, +} from './code-block-inline.js'; +import { CodeBlockService } from './code-block-service.js'; + +export const CodeBlockSpec: ExtensionType[] = [ + FlavourExtension('affine:code'), + CodeBlockService, + BlockViewExtension('affine:code', literal`affine-code`), + WidgetViewMapExtension('affine:code', { + codeToolbar: literal`${unsafeStatic(AFFINE_CODE_TOOLBAR_WIDGET)}`, + }), + CodeBlockInlineManagerExtension, + CodeBlockUnitSpecExtension, +]; diff --git a/blocksuite/blocks/src/code-block/code-block.ts b/blocksuite/blocks/src/code-block/code-block.ts new file mode 100644 index 0000000000..780a933de9 --- /dev/null +++ b/blocksuite/blocks/src/code-block/code-block.ts @@ -0,0 +1,440 @@ +import { CaptionedBlockComponent } from '@blocksuite/affine-components/caption'; +import { + focusTextModel, + type RichText, +} from '@blocksuite/affine-components/rich-text'; +import type { CodeBlockModel } from '@blocksuite/affine-model'; +import { BRACKET_PAIRS, NOTE_SELECTOR } from '@blocksuite/affine-shared/consts'; +import { NotificationProvider } from '@blocksuite/affine-shared/services'; +import { getViewportElement } from '@blocksuite/affine-shared/utils'; +import type { BlockComponent } from '@blocksuite/block-std'; +import { getInlineRangeProvider } from '@blocksuite/block-std'; +import { IS_MAC } from '@blocksuite/global/env'; +import { noop } from '@blocksuite/global/utils'; +import { + INLINE_ROOT_ATTR, + type InlineRangeProvider, + type InlineRootElement, + type VLine, +} from '@blocksuite/inline'; +import { Slice } from '@blocksuite/store'; +import { computed, effect, type Signal, signal } from '@preact/signals-core'; +import { html, nothing, type TemplateResult } from 'lit'; +import { query } from 'lit/decorators.js'; +import { classMap } from 'lit/directives/class-map.js'; +import type { ThemedToken } from 'shiki'; + +import { EdgelessRootBlockComponent } from '../root-block/edgeless/edgeless-root-block.js'; +import { CodeClipboardController } from './clipboard/index.js'; +import { CodeBlockInlineManagerExtension } from './code-block-inline.js'; +import type { CodeBlockService } from './code-block-service.js'; +import { codeBlockStyles } from './styles.js'; + +export class CodeBlockComponent extends CaptionedBlockComponent< + CodeBlockModel, + CodeBlockService +> { + static override styles = codeBlockStyles; + + private _inlineRangeProvider: InlineRangeProvider | null = null; + + clipboardController = new CodeClipboardController(this); + + highlightTokens$: Signal<ThemedToken[][]> = signal([]); + + languageName$: Signal<string> = computed(() => { + const lang = this.model.language$.value; + if (lang === null) { + return 'Plain Text'; + } + + const matchedInfo = this.service.langs.find(info => info.id === lang); + return matchedInfo ? matchedInfo.name : 'Plain Text'; + }); + + get inlineEditor() { + const inlineRoot = this.querySelector<InlineRootElement>( + `[${INLINE_ROOT_ATTR}]` + ); + return inlineRoot?.inlineEditor; + } + + get inlineManager() { + return this.std.get(CodeBlockInlineManagerExtension.identifier); + } + + get notificationService() { + return this.std.getOptional(NotificationProvider); + } + + get readonly() { + return this.doc.readonly; + } + + override get topContenteditableElement() { + if (this.rootComponent instanceof EdgelessRootBlockComponent) { + const el = this.closest<BlockComponent>(NOTE_SELECTOR); + return el; + } + return this.rootComponent; + } + + private _updateHighlightTokens() { + const modelLang = this.model.language$.value; + if (modelLang === null) { + this.highlightTokens$.value = []; + return; + } + + const matchedInfo = this.service.langs.find( + info => + info.id === modelLang || + info.name === modelLang || + info.aliases?.includes(modelLang) + ); + + if (matchedInfo) { + this.model.language$.value = matchedInfo.id; + const langImport = matchedInfo.import; + const lang = matchedInfo.id; + + const highlighter = this.service.highlighter$.value; + const theme = this.service.themeKey; + if (!theme || !highlighter) { + this.highlightTokens$.value = []; + return; + } + + noop(this.model.text.deltas$.value); + const code = this.model.text.toString(); + + const loadedLanguages = highlighter.getLoadedLanguages(); + if (!loadedLanguages.includes(lang)) { + highlighter + .loadLanguage(langImport) + .then(() => { + this.highlightTokens$.value = highlighter.codeToTokensBase(code, { + lang, + theme, + }); + }) + .catch(console.error); + } else { + this.highlightTokens$.value = highlighter.codeToTokensBase(code, { + lang, + theme, + }); + } + } else { + this.highlightTokens$.value = []; + // clear language if not found + this.model.language$.value = null; + } + } + + override connectedCallback() { + super.connectedCallback(); + + // set highlight options getter used by "exportToHtml" + this.clipboardController.hostConnected(); + + this.disposables.add( + effect(() => { + this._updateHighlightTokens(); + }) + ); + this.disposables.add( + effect(() => { + noop(this.highlightTokens$.value); + this._richTextElement?.inlineEditor?.render(); + }) + ); + + const selectionManager = this.host.selection; + const INDENT_SYMBOL = ' '; + const LINE_BREAK_SYMBOL = '\n'; + const allIndexOf = ( + text: string, + symbol: string, + start = 0, + end = text.length + ) => { + const indexArr: number[] = []; + let i = start; + + while (i < end) { + const index = text.indexOf(symbol, i); + if (index === -1 || index > end) { + break; + } + indexArr.push(index); + i = index + 1; + } + return indexArr; + }; + + // TODO: move to service for better performance + this.bindHotKey({ + Backspace: ctx => { + const state = ctx.get('keyboardState'); + const textSelection = selectionManager.find('text'); + if (!textSelection) { + state.raw.preventDefault(); + return; + } + + const from = textSelection.from; + + if (from.index === 0 && from.length === 0) { + state.raw.preventDefault(); + selectionManager.setGroup('note', [ + selectionManager.create('block', { blockId: this.blockId }), + ]); + return true; + } + + const inlineEditor = this.inlineEditor; + const inlineRange = inlineEditor?.getInlineRange(); + if (!inlineRange || !inlineEditor) return; + const left = inlineEditor.yText.toString()[inlineRange.index - 1]; + const right = inlineEditor.yText.toString()[inlineRange.index]; + const leftBrackets = BRACKET_PAIRS.map(pair => pair.left); + if (BRACKET_PAIRS[leftBrackets.indexOf(left)]?.right === right) { + const index = inlineRange.index - 1; + inlineEditor.deleteText({ + index: index, + length: 2, + }); + inlineEditor.setInlineRange({ + index: index, + length: 0, + }); + state.raw.preventDefault(); + return true; + } + + return; + }, + Tab: ctx => { + if (this.doc.readonly) return; + const state = ctx.get('keyboardState'); + const event = state.raw; + const inlineEditor = this.inlineEditor; + if (!inlineEditor) return; + const inlineRange = inlineEditor.getInlineRange(); + if (inlineRange) { + event.stopPropagation(); + event.preventDefault(); + + const text = this.inlineEditor.yText.toString(); + const index = text.lastIndexOf( + LINE_BREAK_SYMBOL, + inlineRange.index - 1 + ); + const indexArr = allIndexOf( + text, + LINE_BREAK_SYMBOL, + inlineRange.index, + inlineRange.index + inlineRange.length + ) + .map(i => i + 1) + .reverse(); + if (index !== -1) { + indexArr.push(index + 1); + } else { + indexArr.push(0); + } + indexArr.forEach(i => { + if (!this.inlineEditor) return; + this.inlineEditor.insertText( + { + index: i, + length: 0, + }, + INDENT_SYMBOL + ); + }); + this.inlineEditor.setInlineRange({ + index: inlineRange.index + 2, + length: + inlineRange.length + (indexArr.length - 1) * INDENT_SYMBOL.length, + }); + + return true; + } + + return; + }, + 'Shift-Tab': ctx => { + const state = ctx.get('keyboardState'); + const event = state.raw; + const inlineEditor = this.inlineEditor; + if (!inlineEditor) return; + const inlineRange = inlineEditor.getInlineRange(); + if (inlineRange) { + event.stopPropagation(); + event.preventDefault(); + + const text = this.inlineEditor.yText.toString(); + const index = text.lastIndexOf( + LINE_BREAK_SYMBOL, + inlineRange.index - 1 + ); + let indexArr = allIndexOf( + text, + LINE_BREAK_SYMBOL, + inlineRange.index, + inlineRange.index + inlineRange.length + ) + .map(i => i + 1) + .reverse(); + if (index !== -1) { + indexArr.push(index + 1); + } else { + indexArr.push(0); + } + indexArr = indexArr.filter( + i => text.slice(i, i + 2) === INDENT_SYMBOL + ); + indexArr.forEach(i => { + if (!this.inlineEditor) return; + this.inlineEditor.deleteText({ + index: i, + length: 2, + }); + }); + if (indexArr.length > 0) { + this.inlineEditor.setInlineRange({ + index: + inlineRange.index - + (indexArr[indexArr.length - 1] < inlineRange.index ? 2 : 0), + length: + inlineRange.length - + (indexArr.length - 1) * INDENT_SYMBOL.length, + }); + } + + return true; + } + + return; + }, + 'Control-d': () => { + if (!IS_MAC) return; + return true; + }, + Delete: () => { + return true; + }, + Enter: () => { + this.doc.captureSync(); + return true; + }, + 'Mod-Enter': () => { + const { model, std } = this; + if (!model || !std) return; + const inlineEditor = this.inlineEditor; + const inlineRange = inlineEditor?.getInlineRange(); + if (!inlineRange || !inlineEditor) return; + const isEnd = model.text.length === inlineRange.index; + if (!isEnd) return; + const parent = this.doc.getParent(model); + if (!parent) return; + const index = parent.children.indexOf(model); + if (index === -1) return; + const id = this.doc.addBlock('affine:paragraph', {}, parent, index + 1); + focusTextModel(std, id); + return true; + }, + }); + + this._inlineRangeProvider = getInlineRangeProvider(this); + } + + copyCode() { + const model = this.model; + const slice = Slice.fromModels(model.doc, [model]); + this.std.clipboard + .copySlice(slice) + .then(() => { + this.notificationService?.toast('Copied to clipboard'); + }) + .catch(e => { + this.notificationService?.toast('Copied failed, something went wrong'); + console.error(e); + }); + } + + override disconnectedCallback() { + super.disconnectedCallback(); + this.clipboardController.hostDisconnected(); + } + + override async getUpdateComplete() { + const result = await super.getUpdateComplete(); + await this._richTextElement?.updateComplete; + return result; + } + + override renderBlock(): TemplateResult<1> { + const showLineNumbers = + this.std.getConfig('affine:code')?.showLineNumbers ?? true; + + return html` + <div + class=${classMap({ + 'affine-code-block-container': true, + wrap: this.model.wrap, + })} + > + <rich-text + .yText=${this.model.text.yText} + .inlineEventSource=${this.topContenteditableElement ?? nothing} + .undoManager=${this.doc.history} + .attributesSchema=${this.inlineManager.getSchema()} + .attributeRenderer=${this.inlineManager.getRenderer()} + .readonly=${this.doc.readonly} + .inlineRangeProvider=${this._inlineRangeProvider} + .enableClipboard=${false} + .enableUndoRedo=${false} + .wrapText=${this.model.wrap} + .verticalScrollContainerGetter=${() => getViewportElement(this.host)} + .vLineRenderer=${showLineNumbers + ? (vLine: VLine) => { + return html` + <span contenteditable="false" class="line-number" + >${vLine.index + 1}</span + > + ${vLine.renderVElements()} + `; + } + : undefined} + > + </rich-text> + + ${this.renderChildren(this.model)} ${Object.values(this.widgets)} + </div> + `; + } + + setWrap(wrap: boolean) { + this.doc.updateBlock(this.model, { wrap }); + } + + @query('rich-text') + private accessor _richTextElement: RichText | null = null; + + override accessor blockContainerStyles = { + margin: '18px 0', + }; + + override accessor useCaptionEditor = true; + + override accessor useZeroWidth = true; +} + +declare global { + interface HTMLElementTagNameMap { + 'affine-code': CodeBlockComponent; + } +} diff --git a/blocksuite/blocks/src/code-block/highlight/affine-code-unit.ts b/blocksuite/blocks/src/code-block/highlight/affine-code-unit.ts new file mode 100644 index 0000000000..11e805bb39 --- /dev/null +++ b/blocksuite/blocks/src/code-block/highlight/affine-code-unit.ts @@ -0,0 +1,100 @@ +import type { AffineTextAttributes } from '@blocksuite/affine-shared/types'; +import { ShadowlessElement } from '@blocksuite/block-std'; +import { type DeltaInsert, ZERO_WIDTH_SPACE } from '@blocksuite/inline'; +import { html } from 'lit'; +import { property } from 'lit/decorators.js'; +import { styleMap } from 'lit/directives/style-map.js'; +import type { ThemedToken } from 'shiki'; + +export class AffineCodeUnit extends ShadowlessElement { + get codeBlock() { + return this.closest('affine-code'); + } + + get vElement() { + return this.closest('v-element'); + } + + override render() { + const plainContent = html`<span + ><v-text .str=${this.delta.insert}></v-text + ></span>`; + + const codeBlock = this.codeBlock; + const vElement = this.vElement; + if (!codeBlock || !vElement) return plainContent; + const tokens = codeBlock.highlightTokens$.value; + if (tokens.length === 0) return plainContent; + // copy the tokens to avoid modifying the original tokens + const lineTokens = structuredClone(tokens[vElement.lineIndex]); + if (lineTokens.length === 0) return plainContent; + + const startOffset = vElement.startOffset; + const endOffset = vElement.endOffset; + const includedTokens: ThemedToken[] = []; + lineTokens.forEach(token => { + if ( + (token.offset <= startOffset && + token.offset + token.content.length >= startOffset) || + (token.offset >= startOffset && + token.offset + token.content.length <= endOffset) || + (token.offset <= endOffset && + token.offset + token.content.length >= endOffset) + ) { + includedTokens.push(token); + } + }); + if (includedTokens.length === 0) return plainContent; + + if (includedTokens.length === 1) { + const token = includedTokens[0]; + const content = token.content.slice( + startOffset - token.offset, + endOffset - token.offset + ); + + return html`<v-text + .str=${content} + style=${styleMap({ + color: token.color, + })} + ></v-text>`; + } else { + const firstToken = includedTokens[0]; + const lastToken = includedTokens[includedTokens.length - 1]; + + const firstContent = firstToken.content.slice( + startOffset - firstToken.offset, + firstToken.content.length + ); + const lastContent = lastToken.content.slice( + 0, + endOffset - lastToken.offset + ); + firstToken.content = firstContent; + lastToken.content = lastContent; + + const vTexts = includedTokens.map(token => { + return html`<v-text + .str=${token.content} + style=${styleMap({ + color: token.color, + })} + ></v-text>`; + }); + + return html`<span>${vTexts}</span>`; + } + } + + @property({ type: Object }) + accessor delta: DeltaInsert<AffineTextAttributes> = { + insert: ZERO_WIDTH_SPACE, + }; +} + +declare global { + interface HTMLElementTagNameMap { + 'affine-code-unit': AffineCodeUnit; + } +} diff --git a/blocksuite/blocks/src/code-block/highlight/const.ts b/blocksuite/blocks/src/code-block/highlight/const.ts new file mode 100644 index 0000000000..138102a62d --- /dev/null +++ b/blocksuite/blocks/src/code-block/highlight/const.ts @@ -0,0 +1,6 @@ +export const CODE_BLOCK_DEFAULT_DARK_THEME = import( + 'shiki/themes/dark-plus.mjs' +); +export const CODE_BLOCK_DEFAULT_LIGHT_THEME = import( + 'shiki/themes/light-plus.mjs' +); diff --git a/blocksuite/blocks/src/code-block/index.ts b/blocksuite/blocks/src/code-block/index.ts new file mode 100644 index 0000000000..d8c83b62f8 --- /dev/null +++ b/blocksuite/blocks/src/code-block/index.ts @@ -0,0 +1,3 @@ +export * from './adapters/markdown.js'; +export * from './code-block.js'; +export * from './code-block-config.js'; diff --git a/blocksuite/blocks/src/code-block/styles.ts b/blocksuite/blocks/src/code-block/styles.ts new file mode 100644 index 0000000000..5fd5955b63 --- /dev/null +++ b/blocksuite/blocks/src/code-block/styles.ts @@ -0,0 +1,49 @@ +import { css } from 'lit'; + +export const codeBlockStyles = css` + affine-code { + position: relative; + } + + .affine-code-block-container { + font-size: var(--affine-font-xs); + line-height: var(--affine-line-height); + position: relative; + padding: 12px; + background: var(--affine-background-code-block); + border-radius: 10px; + box-sizing: border-box; + } + + .affine-code-block-container .inline-editor { + font-family: var(--affine-font-code-family); + font-variant-ligatures: none; + } + + .affine-code-block-container v-line { + position: relative; + display: inline-grid !important; + grid-template-columns: auto minmax(0, 1fr); + } + + .affine-code-block-container div:has(> v-line) { + display: grid; + } + + .affine-code-block-container .line-number { + position: sticky; + text-align: left; + padding-right: 4px; + width: 24px; + word-break: break-word; + white-space: nowrap; + left: -0.5px; + z-index: 1; + background: var(--affine-background-code-block); + font-size: var(--affine-font-xs); + line-height: var(--affine-line-height); + color: var(--affine-text-secondary); + box-sizing: border-box; + user-select: none; + } +`; diff --git a/blocksuite/blocks/src/data-view-block/block-meta/base.ts b/blocksuite/blocks/src/data-view-block/block-meta/base.ts new file mode 100644 index 0000000000..9ff59d20ba --- /dev/null +++ b/blocksuite/blocks/src/data-view-block/block-meta/base.ts @@ -0,0 +1,36 @@ +import type { PropertyMetaConfig } from '@blocksuite/data-view'; +import type { Disposable } from '@blocksuite/global/utils'; +import type { Block, BlockModel } from '@blocksuite/store'; + +type PropertyMeta< + T extends BlockModel = BlockModel, + Value = unknown, + ColumnData extends NonNullable<unknown> = NonNullable<unknown>, +> = { + name: string; + key: string; + metaConfig: PropertyMetaConfig<string, ColumnData, Value>; + 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<T extends BlockModel = BlockModel> = { + selector: (block: Block) => boolean; + properties: PropertyMeta<T>[]; +}; +export const createBlockMeta = <T extends BlockModel>( + options: Omit<BlockMeta<T>, 'properties'> +) => { + const meta: BlockMeta = { + ...options, + properties: [], + }; + return { + ...meta, + addProperty: <Value>(property: PropertyMeta<T, Value>) => { + meta.properties.push(property as PropertyMeta); + }, + }; +}; diff --git a/blocksuite/blocks/src/data-view-block/block-meta/index.ts b/blocksuite/blocks/src/data-view-block/block-meta/index.ts new file mode 100644 index 0000000000..24e9ea9ab0 --- /dev/null +++ b/blocksuite/blocks/src/data-view-block/block-meta/index.ts @@ -0,0 +1,6 @@ +import type { BlockMeta } from './base.js'; +import { todoMeta } from './todo.js'; + +export const blockMetaMap = { + todo: todoMeta, +} satisfies Record<string, BlockMeta>; diff --git a/blocksuite/blocks/src/data-view-block/block-meta/todo.ts b/blocksuite/blocks/src/data-view-block/block-meta/todo.ts new file mode 100644 index 0000000000..0a6f81165f --- /dev/null +++ b/blocksuite/blocks/src/data-view-block/block-meta/todo.ts @@ -0,0 +1,60 @@ +import { type ListBlockModel, ListBlockSchema } from '@blocksuite/affine-model'; +import { propertyPresets } from '@blocksuite/data-view/property-presets'; + +import { richTextColumnConfig } from '../../database-block/properties/rich-text/cell-renderer.js'; +import { createBlockMeta } from './base.js'; + +export const todoMeta = createBlockMeta<ListBlockModel>({ + selector: block => { + if (block.flavour !== ListBlockSchema.model.flavour) { + return false; + } + + return (block.model as ListBlockModel).type === 'todo'; + }, +}); +todoMeta.addProperty({ + name: 'Content', + key: 'todo-title', + metaConfig: richTextColumnConfig, + get: block => block.text.yText, + set: (_block, _value) => { + // + }, + updated: (block, callback) => { + block.text?.yText.observe(callback); + return { + dispose: () => { + block.text?.yText.unobserve(callback); + }, + }; + }, +}); +todoMeta.addProperty({ + name: 'Checked', + key: 'todo-checked', + metaConfig: propertyPresets.checkboxPropertyConfig, + get: block => block.checked, + set: (block, value) => { + block.checked = value; + }, + updated: (block, callback) => { + return block.propsUpdated.on(({ key }) => { + if (key === 'checked') { + callback(); + } + }); + }, +}); + +todoMeta.addProperty({ + name: 'Source', + key: 'todo-source', + metaConfig: propertyPresets.textPropertyConfig, + get: block => block.doc.meta?.title ?? '', + updated: (block, callback) => { + return block.doc.collection.meta.docMetaUpdated.on(() => { + callback(); + }); + }, +}); diff --git a/blocksuite/blocks/src/data-view-block/columns/index.ts b/blocksuite/blocks/src/data-view-block/columns/index.ts new file mode 100644 index 0000000000..6e3349c258 --- /dev/null +++ b/blocksuite/blocks/src/data-view-block/columns/index.ts @@ -0,0 +1,18 @@ +import type { PropertyMetaConfig } from '@blocksuite/data-view'; +import { propertyPresets } from '@blocksuite/data-view/property-presets'; + +import { richTextColumnConfig } from '../../database-block/properties/rich-text/cell-renderer.js'; + +export const queryBlockColumns = [ + propertyPresets.datePropertyConfig, + propertyPresets.numberPropertyConfig, + propertyPresets.progressPropertyConfig, + propertyPresets.selectPropertyConfig, + propertyPresets.multiSelectPropertyConfig, + propertyPresets.checkboxPropertyConfig, +]; +export const queryBlockHiddenColumns = [richTextColumnConfig]; +const queryBlockAllColumns = [...queryBlockColumns, ...queryBlockHiddenColumns]; +export const queryBlockAllColumnMap = Object.fromEntries( + queryBlockAllColumns.map(v => [v.type, v as PropertyMetaConfig]) +); diff --git a/blocksuite/blocks/src/data-view-block/data-source.ts b/blocksuite/blocks/src/data-view-block/data-source.ts new file mode 100644 index 0000000000..1a6ee03831 --- /dev/null +++ b/blocksuite/blocks/src/data-view-block/data-source.ts @@ -0,0 +1,304 @@ +import type { Column } from '@blocksuite/affine-model'; +import { + insertPositionToIndex, + type InsertToPosition, +} from '@blocksuite/affine-shared/utils'; +import type { EditorHost } from '@blocksuite/block-std'; +import { DataSourceBase, type PropertyMetaConfig } from '@blocksuite/data-view'; +import { propertyPresets } from '@blocksuite/data-view/property-presets'; +import { assertExists, Slot } from '@blocksuite/global/utils'; +import type { Block, Doc } from '@blocksuite/store'; + +import { + databaseBlockAllPropertyMap, + databasePropertyConverts, +} from '../database-block/properties/index.js'; +import type { BlockMeta } from './block-meta/base.js'; +import { blockMetaMap } from './block-meta/index.js'; +import { queryBlockAllColumnMap, queryBlockColumns } from './columns/index.js'; +import type { DataViewBlockModel } from './data-view-model.js'; + +export type BlockQueryDataSourceConfig = { + type: keyof typeof blockMetaMap; +}; + +// @ts-expect-error FIXME: ts error +export class BlockQueryDataSource extends DataSourceBase { + private columnMetaMap = new Map<string, PropertyMetaConfig<any, any, any>>(); + + private meta: BlockMeta; + + blockMap = new Map<string, Block>(); + + docDisposeMap = new Map<string, () => 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: DataViewBlockModel, + 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}`; + } + + 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.multiSelectPropertyConfig.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<string, unknown> { + 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<string, unknown>): 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?.( + currentData as 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/blocksuite/blocks/src/data-view-block/data-view-block.ts b/blocksuite/blocks/src/data-view-block/data-view-block.ts new file mode 100644 index 0000000000..3135d5347e --- /dev/null +++ b/blocksuite/blocks/src/data-view-block/data-view-block.ts @@ -0,0 +1,330 @@ +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 { PeekViewProvider } from '@blocksuite/affine-components/peek'; +import { toast } from '@blocksuite/affine-components/toast'; +import { + NotificationProvider, + type TelemetryEventMap, + TelemetryProvider, +} from '@blocksuite/affine-shared/services'; +import { RANGE_SYNC_EXCLUDE_ATTR } from '@blocksuite/block-std'; +import { + createRecordDetail, + createUniComponentFromWebComponent, + DatabaseSelection, + type DataSource, + DataView, + dataViewCommonStyle, + type DataViewProps, + type DataViewSelection, + type DataViewWidget, + type DataViewWidgetProps, + defineUniComponent, + renderUniLit, + uniMap, +} from '@blocksuite/data-view'; +import { widgetPresets } from '@blocksuite/data-view/widget-presets'; +import { Slice } from '@blocksuite/store'; +import { computed, signal } from '@preact/signals-core'; +import { css, nothing, unsafeCSS } from 'lit'; +import { html } from 'lit/static-html.js'; + +import { BlockRenderer } from '../database-block/detail-panel/block-renderer.js'; +import { NoteRenderer } from '../database-block/detail-panel/note-renderer.js'; +import type { NoteBlockComponent } from '../note-block/index.js'; +import { + EdgelessRootBlockComponent, + type RootService, +} from '../root-block/index.js'; +import { BlockQueryDataSource } from './data-source.js'; +import type { DataViewBlockModel } from './data-view-model.js'; + +export class DataViewBlockComponent extends CaptionedBlockComponent<DataViewBlockModel> { + 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 { + 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', + onChange: 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<RootService>('affine:page'); + }; + + headerWidget: DataViewWidget = defineUniComponent( + (props: DataViewWidgetProps) => { + return html` + <div style="margin-bottom: 16px;display:flex;flex-direction: column"> + <div style="display:flex;gap:8px;padding: 0 6px;margin-bottom: 8px;"> + <div>${this.model.title}</div> + ${this.renderDatabaseOps()} + </div> + <div + style="display:flex;align-items:center;justify-content: space-between;gap: 12px" + class="database-header-bar" + > + <div style="flex:1"> + ${renderUniLit(widgetPresets.viewBar, props)} + </div> + ${renderUniLit(this.toolsWidget, props)} + </div> + ${renderUniLit(widgetPresets.quickSettingBar, props)} + </div> + `; + } + ); + + selection$ = computed(() => { + const databaseSelection = this.selection.value.find( + (selection): selection is DatabaseSelection => { + if (selection.blockId !== this.blockId) { + return false; + } + return selection instanceof DatabaseSelection; + } + ); + return databaseSelection?.viewSelection; + }); + + setSelection = (selection: DataViewSelection | undefined) => { + this.selection.setGroup( + 'note', + selection + ? [ + new DatabaseSelection({ + blockId: this.blockId, + viewSelection: selection, + }), + ] + : [] + ); + }; + + toolsWidget: DataViewWidget = widgetPresets.createTools({ + table: [ + widgetPresets.tools.filter, + widgetPresets.tools.search, + widgetPresets.tools.viewOptions, + widgetPresets.tools.tableAddRow, + ], + kanban: [ + widgetPresets.tools.filter, + widgetPresets.tools.search, + widgetPresets.tools.viewOptions, + ], + }); + + get dataSource(): DataSource { + if (!this._dataSource) { + this._dataSource = new BlockQueryDataSource(this.host, this.model, { + type: 'todo', + }); + } + return this._dataSource; + } + + override get topContenteditableElement() { + if (this.rootComponent instanceof EdgelessRootBlockComponent) { + const note = this.closest<NoteBlockComponent>('affine-note'); + return note; + } + return this.rootComponent; + } + + get view() { + return this.dataView.expose; + } + + private renderDatabaseOps() { + if (this.doc.readonly) { + return nothing; + } + return html` <div class="database-ops" @click="${this._clickDatabaseOps}"> + ${MoreHorizontalIcon} + </div>`; + } + + override connectedCallback() { + super.connectedCallback(); + + this.setAttribute(RANGE_SYNC_EXCLUDE_ATTR, 'true'); + } + + override renderBlock() { + const peekViewService = this.std.getOptional(PeekViewProvider); + const telemetryService = this.std.getOptional(TelemetryProvider); + return html` + <div contenteditable="false" style="position: relative"> + ${this.dataView.render({ + virtualPadding$: signal(0), + bindHotkey: this._bindHotkey, + handleEvent: this._handleEvent, + selection$: this.selection$, + setSelection: this.setSelection, + dataSource: this.dataSource, + headerWidget: this.headerWidget, + clipboard: this.std.clipboard, + notification: { + toast: message => { + const notification = this.std.getOptional(NotificationProvider); + if (notification) { + notification.toast(message); + } else { + toast(this.host, message); + } + }, + }, + eventTrace: (key, params) => { + telemetryService?.track(key, { + ...(params as TelemetryEventMap[typeof key]), + blockId: this.blockId, + }); + }, + detailPanelConfig: { + openDetailPanel: (target, data) => { + if (peekViewService) { + const template = createRecordDetail({ + ...data, + openDoc: () => {}, + detail: { + header: uniMap( + createUniComponentFromWebComponent(BlockRenderer), + props => ({ + ...props, + host: this.host, + }) + ), + note: uniMap( + createUniComponentFromWebComponent(NoteRenderer), + props => ({ + ...props, + model: this.model, + host: this.host, + }) + ), + }, + }); + return peekViewService.peek({ target, template }); + } else { + return Promise.resolve(); + } + }, + }, + })} + </div> + `; + } +} + +declare global { + interface HTMLElementTagNameMap { + 'affine-data-view': DataViewBlockComponent; + } +} diff --git a/blocksuite/blocks/src/data-view-block/data-view-model.ts b/blocksuite/blocks/src/data-view-block/data-view-model.ts new file mode 100644 index 0000000000..24007a3ecc --- /dev/null +++ b/blocksuite/blocks/src/data-view-block/data-view-model.ts @@ -0,0 +1,95 @@ +import type { Column } from '@blocksuite/affine-model'; +import { + arrayMove, + insertPositionToIndex, + type InsertToPosition, +} from '@blocksuite/affine-shared/utils'; +import type { DataViewDataType } from '@blocksuite/data-view'; +import { BlockModel, defineBlockSchema } from '@blocksuite/store'; + +type Props = { + title: string; + views: DataViewDataType[]; + columns: Column[]; + cells: Record<string, Record<string, unknown>>; +}; + +export class DataViewBlockModel extends BlockModel<Props> { + 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<DataViewDataType> + ) { + 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 DataViewBlockSchema = defineBlockSchema({ + flavour: 'affine:data-view', + props: (): Props => ({ + views: [], + title: '', + columns: [], + cells: {}, + }), + metadata: { + role: 'hub', + version: 1, + parent: ['affine:note'], + children: ['affine:paragraph', 'affine:list'], + }, + toModel: () => { + return new DataViewBlockModel(); + }, +}); diff --git a/blocksuite/blocks/src/data-view-block/data-view-spec.ts b/blocksuite/blocks/src/data-view-block/data-view-spec.ts new file mode 100644 index 0000000000..342e5f2013 --- /dev/null +++ b/blocksuite/blocks/src/data-view-block/data-view-spec.ts @@ -0,0 +1,14 @@ +import { + BlockViewExtension, + type ExtensionType, + FlavourExtension, +} from '@blocksuite/block-std'; +import { literal } from 'lit/static-html.js'; + +import { DataViewBlockService } from './database-service.js'; + +export const DataViewBlockSpec: ExtensionType[] = [ + FlavourExtension('affine:data-view'), + DataViewBlockService, + BlockViewExtension('affine:data-view', literal`affine-data-view`), +]; diff --git a/blocksuite/blocks/src/data-view-block/database-service.ts b/blocksuite/blocks/src/data-view-block/database-service.ts new file mode 100644 index 0000000000..1c21b4d87e --- /dev/null +++ b/blocksuite/blocks/src/data-view-block/database-service.ts @@ -0,0 +1,13 @@ +import { BlockService } from '@blocksuite/block-std'; +import { DatabaseSelection } from '@blocksuite/data-view'; + +import { DataViewBlockSchema } from './data-view-model.js'; + +export class DataViewBlockService extends BlockService { + static override readonly flavour = DataViewBlockSchema.model.flavour; + + override mounted(): void { + super.mounted(); + this.selectionManager.register(DatabaseSelection); + } +} diff --git a/blocksuite/blocks/src/data-view-block/index.ts b/blocksuite/blocks/src/data-view-block/index.ts new file mode 100644 index 0000000000..1d022c0823 --- /dev/null +++ b/blocksuite/blocks/src/data-view-block/index.ts @@ -0,0 +1,12 @@ +import type { DataViewBlockModel } from './data-view-model.js'; + +export * from './data-view-block.js'; +export * from './data-view-model.js'; + +declare global { + namespace BlockSuite { + interface BlockModels { + 'affine:data-view': DataViewBlockModel; + } + } +} diff --git a/blocksuite/blocks/src/data-view-block/views/index.ts b/blocksuite/blocks/src/data-view-block/views/index.ts new file mode 100644 index 0000000000..1f8e74b2ef --- /dev/null +++ b/blocksuite/blocks/src/data-view-block/views/index.ts @@ -0,0 +1,11 @@ +import type { ViewMeta } from '@blocksuite/data-view'; +import { viewPresets } from '@blocksuite/data-view/view-presets'; + +export const blockQueryViews: ViewMeta[] = [ + viewPresets.tableViewMeta, + viewPresets.kanbanViewMeta, +]; + +export const blockQueryViewMap = Object.fromEntries( + blockQueryViews.map(view => [view.type, view]) +); diff --git a/blocksuite/blocks/src/database-block/adapters/html.ts b/blocksuite/blocks/src/database-block/adapters/html.ts new file mode 100644 index 0000000000..c29dd7e4f0 --- /dev/null +++ b/blocksuite/blocks/src/database-block/adapters/html.ts @@ -0,0 +1,292 @@ +import { + type Column, + DatabaseBlockSchema, + type SerializedCells, +} from '@blocksuite/affine-model'; +import { + BlockHtmlAdapterExtension, + type BlockHtmlAdapterMatcher, + HastUtils, + type InlineHtmlAST, + TextUtils, +} from '@blocksuite/affine-shared/adapters'; +import type { DeltaInsert } from '@blocksuite/inline'; +import { type BlockSnapshot, nanoid } from '@blocksuite/store'; +import { format } from 'date-fns/format'; +import type { Element } from 'hast'; + +const DATABASE_NODE_TYPES = new Set(['table', 'thead', 'tbody', 'th', 'tr']); + +export const databaseBlockHtmlAdapterMatcher: BlockHtmlAdapterMatcher = { + flavour: DatabaseBlockSchema.model.flavour, + toMatch: o => + HastUtils.isElement(o.node) && DATABASE_NODE_TYPES.has(o.node.tagName), + fromMatch: o => o.node.flavour === DatabaseBlockSchema.model.flavour, + toBlockSnapshot: { + enter: (o, context) => { + if (!HastUtils.isElement(o.node)) { + return; + } + const { walkerContext } = context; + if (o.node.tagName === 'table') { + const tableHeader = HastUtils.querySelector(o.node, 'thead'); + if (!tableHeader) { + return; + } + const tableHeaderRow = HastUtils.querySelector(tableHeader, 'tr'); + if (!tableHeaderRow) { + return; + } + // Table header row as database header row + const viewsColumns = tableHeaderRow.children.map(() => { + return { + id: nanoid(), + hide: false, + width: 180, + }; + }); + + // Build database cells from table body rows + const cells = Object.create(null); + const tableBody = HastUtils.querySelector(o.node, 'tbody'); + tableBody?.children.forEach(row => { + const rowId = nanoid(); + cells[rowId] = Object.create(null); + (row as Element).children.forEach((cell, index) => { + cells[rowId][viewsColumns[index].id] = { + columnId: viewsColumns[index].id, + value: TextUtils.createText( + (cell as Element).children + .map(child => ('value' in child ? child.value : '')) + .join('') + ), + }; + }); + }); + + // Build database columns from table header row + const columns = tableHeaderRow.children.map((_child, index) => { + return { + type: index === 0 ? 'title' : 'rich-text', + name: (_child as Element).children + .map(child => ('value' in child ? child.value : '')) + .join(''), + data: {}, + id: viewsColumns[index].id, + }; + }); + + walkerContext.openNode( + { + type: 'block', + id: nanoid(), + flavour: 'affine:database', + props: { + views: [ + { + id: nanoid(), + name: 'Table View', + mode: 'table', + columns: [], + filter: { + type: 'group', + op: 'and', + conditions: [], + }, + header: { + titleColumn: viewsColumns[0]?.id, + iconColumn: 'type', + }, + }, + ], + title: { + '$blocksuite:internal:text$': true, + delta: [], + }, + cells, + columns, + }, + children: [], + }, + 'children' + ); + walkerContext.setNodeContext('affine:table:rowid', Object.keys(cells)); + walkerContext.skipChildren(1); + } + + // The first child of each table body row is the database title cell + if (o.node.tagName === 'tr') { + const { deltaConverter } = context; + walkerContext + .openNode({ + type: 'block', + id: + ( + walkerContext.getNodeContext( + 'affine:table:rowid' + ) as Array<string> + ).shift() ?? nanoid(), + flavour: 'affine:paragraph', + props: { + text: { + '$blocksuite:internal:text$': true, + delta: deltaConverter.astToDelta(o.node.children[0]), + }, + type: 'text', + }, + children: [], + }) + .closeNode(); + walkerContext.skipAllChildren(); + } + }, + leave: (o, context) => { + if (!HastUtils.isElement(o.node)) { + return; + } + const { walkerContext } = context; + if (o.node.tagName === 'table') { + walkerContext.closeNode(); + } + }, + }, + fromBlockSnapshot: { + enter: (o, context) => { + const { walkerContext } = context; + const columns = o.node.props.columns as Array<Column>; + const children = o.node.children; + const cells = o.node.props.cells as SerializedCells; + + const createAstTableCell = ( + children: InlineHtmlAST[] + ): InlineHtmlAST => ({ + type: 'element', + tagName: 'td', + properties: Object.create(null), + children, + }); + + const createAstTableHeaderCell = ( + children: InlineHtmlAST[] + ): InlineHtmlAST => ({ + type: 'element', + tagName: 'th', + properties: Object.create(null), + children, + }); + + const createAstTableRow = (cells: InlineHtmlAST[]): Element => ({ + type: 'element', + tagName: 'tr', + properties: Object.create(null), + children: cells, + }); + + const { deltaConverter } = context; + const htmlAstRows = Array.prototype.map.call( + children, + (v: BlockSnapshot) => { + const rowCells = Array.prototype.map.call(columns, col => { + const cell = cells[v.id]?.[col.id]; + if (!cell && col.type !== 'title') { + return createAstTableCell([{ type: 'text', value: '' }]); + } + switch (col.type) { + case 'rich-text': + return createAstTableCell( + deltaConverter.deltaToAST( + (cell.value as { delta: DeltaInsert[] }).delta + ) + ); + case 'title': + return createAstTableCell( + deltaConverter.deltaToAST( + (v.props.text as { delta: DeltaInsert[] }).delta + ) + ); + case 'date': + return createAstTableCell([ + { + type: 'text', + value: format(new Date(cell.value as number), 'yyyy-MM-dd'), + }, + ]); + case 'select': { + const value = + (col.data.options.find( + (opt: Record<string, string>) => opt.id === cell.value + )?.value as string) ?? ''; + return createAstTableCell([{ type: 'text', value }]); + } + case 'multi-select': { + const value = Array.prototype.map + .call( + cell.value, + val => + col.data.options.find( + (opt: Record<string, string>) => val === opt.id + ).value ?? '' + ) + .filter(Boolean) + .join(','); + return createAstTableCell([{ type: 'text', value }]); + } + case 'checkbox': { + return createAstTableCell([ + { type: 'text', value: String(cell.value) }, + ]); + } + // eslint-disable-next-line sonarjs/no-duplicated-branches + default: + return createAstTableCell([ + { type: 'text', value: String(cell.value) }, + ]); + } + }) as InlineHtmlAST[]; + return createAstTableRow(rowCells); + } + ) as Element[]; + + // Handle first row (header). + const headerRow = createAstTableRow( + Array.prototype.map.call(columns, v => + createAstTableHeaderCell([ + { + type: 'text', + value: v.name ?? '', + }, + ]) + ) as Element[] + ); + + const tableHeaderAst: Element = { + type: 'element', + tagName: 'thead', + properties: Object.create(null), + children: [headerRow], + }; + + const tableBodyAst: Element = { + type: 'element', + tagName: 'tbody', + properties: Object.create(null), + children: [...htmlAstRows], + }; + + walkerContext + .openNode({ + type: 'element', + tagName: 'table', + properties: Object.create(null), + children: [tableHeaderAst, tableBodyAst], + }) + .closeNode(); + + walkerContext.skipAllChildren(); + }, + }, +}; + +export const DatabaseBlockHtmlAdapterExtension = BlockHtmlAdapterExtension( + databaseBlockHtmlAdapterMatcher +); diff --git a/blocksuite/blocks/src/database-block/adapters/index.ts b/blocksuite/blocks/src/database-block/adapters/index.ts new file mode 100644 index 0000000000..94b5ef70c3 --- /dev/null +++ b/blocksuite/blocks/src/database-block/adapters/index.ts @@ -0,0 +1,3 @@ +export * from './html.js'; +export * from './markdown.js'; +export * from './notion-html.js'; diff --git a/blocksuite/blocks/src/database-block/adapters/markdown.ts b/blocksuite/blocks/src/database-block/adapters/markdown.ts new file mode 100644 index 0000000000..c013d5cd4a --- /dev/null +++ b/blocksuite/blocks/src/database-block/adapters/markdown.ts @@ -0,0 +1,253 @@ +import { + type Column, + DatabaseBlockSchema, + type SerializedCells, +} from '@blocksuite/affine-model'; +import { + BlockMarkdownAdapterExtension, + type BlockMarkdownAdapterMatcher, + type MarkdownAST, + TextUtils, +} from '@blocksuite/affine-shared/adapters'; +import type { DeltaInsert } from '@blocksuite/inline'; +import { type BlockSnapshot, nanoid } from '@blocksuite/store'; +import { format } from 'date-fns/format'; +import type { TableRow } from 'mdast'; + +const DATABASE_NODE_TYPES = new Set(['table', 'tableRow']); + +const isDatabaseNode = (node: MarkdownAST) => + DATABASE_NODE_TYPES.has(node.type); + +export const databaseBlockMarkdownAdapterMatcher: BlockMarkdownAdapterMatcher = + { + flavour: DatabaseBlockSchema.model.flavour, + toMatch: o => isDatabaseNode(o.node), + fromMatch: o => o.node.flavour === DatabaseBlockSchema.model.flavour, + toBlockSnapshot: { + enter: (o, context) => { + const { walkerContext } = context; + if (o.node.type === 'table') { + const viewsColumns = o.node.children[0].children.map(() => { + return { + id: nanoid(), + hide: false, + width: 180, + }; + }); + const cells = Object.create(null); + o.node.children.slice(1).forEach(row => { + const rowId = nanoid(); + cells[rowId] = Object.create(null); + row.children.slice(1).forEach((cell, index) => { + cells[rowId][viewsColumns[index + 1].id] = { + columnId: viewsColumns[index + 1].id, + value: TextUtils.createText( + cell.children + .map(child => ('value' in child ? child.value : '')) + .join('') + ), + }; + }); + }); + const columns = o.node.children[0].children.map((_child, index) => { + return { + type: index === 0 ? 'title' : 'rich-text', + name: _child.children + .map(child => ('value' in child ? child.value : '')) + .join(''), + data: {}, + id: viewsColumns[index].id, + }; + }); + walkerContext.openNode( + { + type: 'block', + id: nanoid(), + flavour: 'affine:database', + props: { + views: [ + { + id: nanoid(), + name: 'Table View', + mode: 'table', + columns: [], + filter: { + type: 'group', + op: 'and', + conditions: [], + }, + header: { + titleColumn: viewsColumns[0]?.id, + iconColumn: 'type', + }, + }, + ], + title: { + '$blocksuite:internal:text$': true, + delta: [], + }, + cells, + columns, + }, + children: [], + }, + 'children' + ); + walkerContext.setNodeContext( + 'affine:table:rowid', + Object.keys(cells) + ); + walkerContext.skipChildren(1); + } + + if (o.node.type === 'tableRow') { + const { deltaConverter } = context; + walkerContext + .openNode({ + type: 'block', + id: + ( + walkerContext.getNodeContext( + 'affine:table:rowid' + ) as Array<string> + ).shift() ?? nanoid(), + flavour: 'affine:paragraph', + props: { + text: { + '$blocksuite:internal:text$': true, + delta: deltaConverter.astToDelta(o.node.children[0]), + }, + type: 'text', + }, + children: [], + }) + .closeNode(); + walkerContext.skipAllChildren(); + } + }, + leave: (o, context) => { + const { walkerContext } = context; + if (o.node.type === 'table') { + walkerContext.closeNode(); + } + }, + }, + fromBlockSnapshot: { + enter: (o, context) => { + const { walkerContext, deltaConverter } = context; + const rows: TableRow[] = []; + const columns = o.node.props.columns as Array<Column>; + const children = o.node.children; + const cells = o.node.props.cells as SerializedCells; + const createAstCell = (children: MarkdownAST[]) => ({ + type: 'tableCell', + children, + }); + const mdAstCells = Array.prototype.map.call( + children, + (v: BlockSnapshot) => + Array.prototype.map.call(columns, col => { + const cell = cells[v.id]?.[col.id]; + if (!cell && col.type !== 'title') { + return createAstCell([{ type: 'text', value: '' }]); + } + switch (col.type) { + case 'link': + case 'progress': + case 'number': + return createAstCell([ + { + type: 'text', + value: cell.value as string, + }, + ]); + case 'rich-text': + return createAstCell( + deltaConverter.deltaToAST( + (cell.value as { delta: DeltaInsert[] }).delta + ) + ); + case 'title': + return createAstCell( + deltaConverter.deltaToAST( + (v.props.text as { delta: DeltaInsert[] }).delta + ) + ); + case 'date': + return createAstCell([ + { + type: 'text', + value: format( + new Date(cell.value as number), + 'yyyy-MM-dd' + ), + }, + ]); + case 'select': { + const value = col.data.options.find( + (opt: Record<string, string>) => opt.id === cell.value + )?.value; + return createAstCell([{ type: 'text', value }]); + } + case 'multi-select': { + const value = Array.prototype.map + .call( + cell.value, + val => + col.data.options.find( + (opt: Record<string, string>) => val === opt.id + ).value + ) + .filter(Boolean) + .join(','); + return createAstCell([{ type: 'text', value }]); + } + case 'checkbox': { + return createAstCell([ + { type: 'text', value: cell.value as string }, + ]); + } + // eslint-disable-next-line sonarjs/no-duplicated-branches + default: + return createAstCell([ + { type: 'text', value: cell.value as string }, + ]); + } + }) + ); + + // Handle first row. + if (Array.isArray(columns)) { + rows.push({ + type: 'tableRow', + children: Array.prototype.map.call(columns, v => + createAstCell([ + { + type: 'text', + value: v.name, + }, + ]) + ) as [], + }); + } + + // Handle 2-... rows + Array.prototype.forEach.call(mdAstCells, children => { + rows.push({ type: 'tableRow', children }); + }); + + walkerContext + .openNode({ + type: 'table', + children: rows, + }) + .closeNode(); + + walkerContext.skipAllChildren(); + }, + }, + }; + +export const DatabaseBlockMarkdownAdapterExtension = + BlockMarkdownAdapterExtension(databaseBlockMarkdownAdapterMatcher); diff --git a/blocksuite/blocks/src/database-block/adapters/notion-html.ts b/blocksuite/blocks/src/database-block/adapters/notion-html.ts new file mode 100644 index 0000000000..74c365cef4 --- /dev/null +++ b/blocksuite/blocks/src/database-block/adapters/notion-html.ts @@ -0,0 +1,348 @@ +import { DatabaseBlockSchema } from '@blocksuite/affine-model'; +import { + BlockNotionHtmlAdapterExtension, + type BlockNotionHtmlAdapterMatcher, + HastUtils, + TextUtils, +} from '@blocksuite/affine-shared/adapters'; +import { getTagColor } from '@blocksuite/data-view'; +import { type BlockSnapshot, nanoid } from '@blocksuite/store'; + +const ColumnClassMap: Record<string, string> = { + typesSelect: 'select', + typesMultipleSelect: 'multi-select', + typesNumber: 'number', + typesCheckbox: 'checkbox', + typesText: 'rich-text', + typesTitle: 'title', +}; + +const NotionDatabaseToken = '.collection-content'; +const NotionDatabaseTitleToken = '.collection-title'; + +type BlocksuiteTableColumn = { + type: string; + name: string; + data: { + options?: { + id: string; + value: string; + color: string; + }[]; + }; + id: string; +}; + +type BlocksuiteTableRow = Record< + string, + { + columnId: string; + value: unknown; + } +>; + +const DATABASE_NODE_TYPES = new Set(['table', 'th', 'tr']); + +export const databaseBlockNotionHtmlAdapterMatcher: BlockNotionHtmlAdapterMatcher = + { + flavour: DatabaseBlockSchema.model.flavour, + toMatch: o => + HastUtils.isElement(o.node) && DATABASE_NODE_TYPES.has(o.node.tagName), + fromMatch: () => false, + toBlockSnapshot: { + enter: (o, context) => { + if (!HastUtils.isElement(o.node)) { + return; + } + const { walkerContext, deltaConverter, pageMap } = context; + switch (o.node.tagName) { + case 'th': { + const columnId = nanoid(); + const columnTypeClass = HastUtils.querySelector(o.node, 'svg') + ?.properties?.className; + const columnType = Array.isArray(columnTypeClass) + ? (ColumnClassMap[columnTypeClass[0]] ?? 'rich-text') + : 'rich-text'; + walkerContext.pushGlobalContextStack<BlocksuiteTableColumn>( + 'hast:table:column', + { + type: columnType, + name: HastUtils.getTextContent( + HastUtils.getTextChildrenOnlyAst(o.node) + ), + data: Object.create(null), + id: columnId, + } + ); + // disable icon img in th + walkerContext.setGlobalContext('hast:disableimg', true); + break; + } + case 'tr': { + if ( + o.parent?.node.type === 'element' && + o.parent.node.tagName === 'tbody' + ) { + const columns = + walkerContext.getGlobalContextStack<BlocksuiteTableColumn>( + 'hast:table:column' + ); + const row = Object.create(null); + let plainTable = false; + HastUtils.getElementChildren(o.node).forEach((child, index) => { + if (plainTable || columns[index] === undefined) { + plainTable = true; + if (columns[index] === undefined) { + columns.push({ + type: 'rich-text', + name: '', + data: Object.create(null), + id: nanoid(), + }); + walkerContext.pushGlobalContextStack<BlockSnapshot>( + 'hast:table:children', + { + type: 'block', + id: nanoid(), + flavour: 'affine:paragraph', + props: { + text: { + '$blocksuite:internal:text$': true, + delta: deltaConverter.astToDelta(child), + }, + type: 'text', + }, + children: [], + } + ); + } + walkerContext.pushGlobalContextStack<BlockSnapshot>( + 'hast:table:children', + { + type: 'block', + id: nanoid(), + flavour: 'affine:paragraph', + props: { + text: { + '$blocksuite:internal:text$': true, + delta: deltaConverter.astToDelta(child), + }, + type: 'text', + }, + children: [], + } + ); + row[columns[index].id] = { + columnId: columns[index].id, + value: HastUtils.getTextContent(child), + }; + } else if (HastUtils.querySelector(child, '.cell-title')) { + walkerContext.pushGlobalContextStack<BlockSnapshot>( + 'hast:table:children', + { + type: 'block', + id: nanoid(), + flavour: 'affine:paragraph', + props: { + text: { + '$blocksuite:internal:text$': true, + delta: deltaConverter.astToDelta(child, { pageMap }), + }, + type: 'text', + }, + children: [], + } + ); + columns[index].type = 'title'; + return; + } + const optionIds: string[] = []; + if (HastUtils.querySelector(child, '.selected-value')) { + if (!('options' in columns[index].data)) { + columns[index].data.options = []; + } + if ( + !['multi-select', 'select'].includes(columns[index].type) + ) { + columns[index].type = 'select'; + } + if ( + columns[index].type === 'select' && + child.type === 'element' && + child.children.length > 1 + ) { + columns[index].type = 'multi-select'; + } + child.type === 'element' && + child.children.forEach(span => { + const filteredArray = columns[index].data.options?.filter( + option => + option.value === HastUtils.getTextContent(span) + ); + const id = filteredArray?.length + ? filteredArray[0].id + : nanoid(); + if (!filteredArray?.length) { + columns[index].data.options?.push({ + id, + value: HastUtils.getTextContent(span), + color: getTagColor(), + }); + } + optionIds.push(id); + }); + // Expand will be done when leaving the table + row[columns[index].id] = { + columnId: columns[index].id, + value: optionIds, + }; + } else if (HastUtils.querySelector(child, '.checkbox')) { + if (columns[index].type !== 'checkbox') { + columns[index].type = 'checkbox'; + } + row[columns[index].id] = { + columnId: columns[index].id, + value: HastUtils.querySelector(child, '.checkbox-on') + ? true + : false, + }; + } else if (columns[index].type === 'number') { + const text = HastUtils.getTextContent(child); + const number = Number(text); + if (Number.isNaN(number)) { + columns[index].type = 'rich-text'; + row[columns[index].id] = { + columnId: columns[index].id, + value: TextUtils.createText(text), + }; + } else { + row[columns[index].id] = { + columnId: columns[index].id, + value: number, + }; + } + } else { + row[columns[index].id] = { + columnId: columns[index].id, + value: HastUtils.getTextContent(child), + }; + } + if ( + columns[index].type === 'rich-text' && + !TextUtils.isText(row[columns[index].id].value) + ) { + row[columns[index].id] = { + columnId: columns[index].id, + value: TextUtils.createText(row[columns[index].id].value), + }; + } + }); + walkerContext.setGlobalContextStack('hast:table:column', columns); + walkerContext.pushGlobalContextStack('hast:table:rows', row); + } + } + } + }, + leave: (o, context) => { + if (!HastUtils.isElement(o.node)) { + return; + } + const { walkerContext } = context; + switch (o.node.tagName) { + case 'table': { + const columns = + walkerContext.getGlobalContextStack<BlocksuiteTableColumn>( + 'hast:table:column' + ); + walkerContext.setGlobalContextStack('hast:table:column', []); + const children = walkerContext.getGlobalContextStack<BlockSnapshot>( + 'hast:table:children' + ); + walkerContext.setGlobalContextStack('hast:table:children', []); + const cells = Object.create(null); + walkerContext + .getGlobalContextStack<BlocksuiteTableRow>('hast:table:rows') + .forEach((row, i) => { + Object.keys(row).forEach(columnId => { + if ( + columns.find(column => column.id === columnId)?.type === + 'select' + ) { + row[columnId].value = (row[columnId].value as string[])[0]; + } + }); + cells[children.at(i)?.id ?? nanoid()] = row; + }); + walkerContext.setGlobalContextStack('hast:table:cells', []); + let databaseTitle = ''; + if ( + o.parent?.node.type === 'element' && + HastUtils.querySelector(o.parent.node, NotionDatabaseToken) + ) { + databaseTitle = HastUtils.getTextContent( + HastUtils.querySelector(o.parent.node, NotionDatabaseTitleToken) + ); + } + walkerContext.openNode( + { + type: 'block', + id: nanoid(), + flavour: DatabaseBlockSchema.model.flavour, + props: { + views: [ + { + id: nanoid(), + name: 'Table View', + mode: 'table', + columns: [], + filter: { + type: 'group', + op: 'and', + conditions: [], + }, + header: { + titleColumn: + columns.find(column => column.type === 'title')?.id ?? + '', + iconColumn: 'type', + }, + }, + ], + title: { + '$blocksuite:internal:text$': true, + delta: databaseTitle + ? [ + { + insert: databaseTitle, + }, + ] + : [], + }, + columns, + cells, + }, + children: [], + }, + 'children' + ); + children.forEach(child => { + walkerContext.openNode(child, 'children').closeNode(); + }); + walkerContext.closeNode(); + walkerContext.cleanGlobalContextStack('hast:table:column'); + walkerContext.cleanGlobalContextStack('hast:table:rows'); + walkerContext.cleanGlobalContextStack('hast:table:children'); + break; + } + case 'th': { + walkerContext.setGlobalContext('hast:disableimg', false); + break; + } + } + }, + }, + fromBlockSnapshot: {}, + }; + +export const DatabaseBlockNotionHtmlAdapterExtension = + BlockNotionHtmlAdapterExtension(databaseBlockNotionHtmlAdapterMatcher); diff --git a/blocksuite/blocks/src/database-block/block-icons.ts b/blocksuite/blocks/src/database-block/block-icons.ts new file mode 100644 index 0000000000..446fe73505 --- /dev/null +++ b/blocksuite/blocks/src/database-block/block-icons.ts @@ -0,0 +1,46 @@ +import type { ParagraphType } from '@blocksuite/affine-model'; +import { + BulletedListIcon, + CheckBoxCheckLinearIcon, + Heading1Icon, + Heading2Icon, + Heading3Icon, + Heading4Icon, + Heading5Icon, + Heading6Icon, + NumberedListIcon, + QuoteIcon, + TextIcon, +} from '@blocksuite/icons/lit'; +import type { BlockModel } from '@blocksuite/store'; +import type { TemplateResult } from '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<ParagraphType, TemplateResult> + )[type]; + } + if (model.flavour === 'affine:list') { + return ( + { + bulleted: BulletedListIcon(), + numbered: NumberedListIcon(), + todo: CheckBoxCheckLinearIcon(), + }[model.type ?? 'bulleted'] ?? BulletedListIcon() + ); + } + return TextIcon(); +}; diff --git a/blocksuite/blocks/src/database-block/commands.ts b/blocksuite/blocks/src/database-block/commands.ts new file mode 100644 index 0000000000..d002bf46c2 --- /dev/null +++ b/blocksuite/blocks/src/database-block/commands.ts @@ -0,0 +1,41 @@ +import type { BlockCommands, Command } from '@blocksuite/block-std'; + +export const insertDatabaseBlockCommand: Command< + 'selectedModels', + 'insertedDatabaseBlockId', + { + viewType: string; + place?: 'after' | 'before'; + removeEmptyLine?: boolean; + } +> = (ctx, next) => { + const { selectedModels, viewType, place, removeEmptyLine, std } = ctx; + if (!selectedModels?.length) return; + + const targetModel = + place === 'before' + ? selectedModels[0] + : selectedModels[selectedModels.length - 1]; + + const service = std.getService('affine:database'); + if (!service) return; + + const result = std.doc.addSiblingBlocks( + targetModel, + [{ flavour: 'affine:database' }], + place + ); + if (result.length === 0) return; + + service.initDatabaseBlock(std.doc, targetModel, result[0], viewType, false); + + if (removeEmptyLine && targetModel.text?.length === 0) { + std.doc.deleteBlock(targetModel); + } + + next({ insertedDatabaseBlockId: result[0] }); +}; + +export const commands: BlockCommands = { + insertDatabaseBlock: insertDatabaseBlockCommand, +}; diff --git a/blocksuite/blocks/src/database-block/components/layout.ts b/blocksuite/blocks/src/database-block/components/layout.ts new file mode 100644 index 0000000000..8d9846ecf4 --- /dev/null +++ b/blocksuite/blocks/src/database-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` + <div @click="${this.close}" class="close-modal">${CloseIcon()}</div> + ${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<void>(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/blocksuite/blocks/src/database-block/components/title/index.ts b/blocksuite/blocks/src/database-block/components/title/index.ts new file mode 100644 index 0000000000..67eeb70a90 --- /dev/null +++ b/blocksuite/blocks/src/database-block/components/title/index.ts @@ -0,0 +1,185 @@ +import { stopPropagation } from '@blocksuite/affine-shared/utils'; +import { ShadowlessElement } from '@blocksuite/block-std'; +import { WithDisposable } from '@blocksuite/global/utils'; +import type { Text } from '@blocksuite/store'; +import { css, html } from 'lit'; +import { property, query, state } from 'lit/decorators.js'; +import { classMap } from 'lit/directives/class-map.js'; +import { styleMap } from 'lit/directives/style-map.js'; + +import type { DatabaseBlockComponent } from '../../database-block.js'; + +export class DatabaseTitle extends WithDisposable(ShadowlessElement) { + static override styles = css` + .affine-database-title { + position: relative; + flex: 1; + font-family: inherit; + font-size: 20px; + line-height: 28px; + font-weight: 600; + color: var(--affine-text-primary-color); + overflow: hidden; + } + + .affine-database-title textarea { + font-size: inherit; + line-height: inherit; + font-weight: inherit; + letter-spacing: inherit; + font-family: inherit; + border: none; + background-color: transparent; + padding: 0; + position: absolute; + left: 0; + top: 0; + bottom: 0; + right: 0; + outline: none; + resize: none; + scrollbar-width: none; + } + + .affine-database-title .text { + user-select: none; + opacity: 0; + white-space: pre-wrap; + } + + .affine-database-title[data-title-focus='false'] textarea { + opacity: 0; + } + + .affine-database-title[data-title-focus='false'] .text { + text-overflow: ellipsis; + overflow: hidden; + opacity: 1; + white-space: pre; + } + + .affine-database-title [data-title-empty='true']::before { + content: 'Untitled'; + position: absolute; + pointer-events: none; + color: var(--affine-text-primary-color); + } + + .affine-database-title [data-title-focus='true']::before { + color: var(--affine-placeholder-color); + } + `; + + private compositionEnd = () => { + this.titleText.replace(0, this.titleText.length, this.input.value); + }; + + private onBlur = () => { + this.isFocus = false; + }; + + private onFocus = () => { + this.isFocus = true; + if (this.database?.viewSelection$?.value) { + this.database?.setSelection(undefined); + } + }; + + private onInput = (e: InputEvent) => { + this.text = this.input.value; + if (!e.isComposing) { + this.titleText.replace(0, this.titleText.length, this.input.value); + } + }; + + private onKeyDown = (event: KeyboardEvent) => { + event.stopPropagation(); + if (event.key === 'Enter' && !event.isComposing) { + event.preventDefault(); + this.onPressEnterKey?.(); + return; + } + }; + + updateText = () => { + if (!this.isFocus) { + this.input.value = this.titleText.toString(); + this.text = this.input.value; + } + }; + + get database() { + return this.closest<DatabaseBlockComponent>('affine-database'); + } + + override connectedCallback() { + super.connectedCallback(); + requestAnimationFrame(() => { + this.updateText(); + }); + this.titleText.yText.observe(this.updateText); + this.disposables.add(() => { + this.titleText.yText.unobserve(this.updateText); + }); + } + + override render() { + const isEmpty = !this.text; + + const classList = classMap({ + 'affine-database-title': true, + ellipsis: !this.isFocus, + }); + const untitledStyle = styleMap({ + height: isEmpty ? 'auto' : 0, + opacity: isEmpty && !this.isFocus ? 1 : 0, + }); + return html` <div + class="${classList}" + data-title-empty="${isEmpty}" + data-title-focus="${this.isFocus}" + > + <div class="text" style="${untitledStyle}">Untitled</div> + <div class="text">${this.text}</div> + <textarea + .disabled="${this.readonly}" + @input="${this.onInput}" + @keydown="${this.onKeyDown}" + @copy="${stopPropagation}" + @paste="${stopPropagation}" + @focus="${this.onFocus}" + @blur="${this.onBlur}" + @compositionend="${this.compositionEnd}" + data-block-is-database-title="true" + title="${this.titleText.toString()}" + ></textarea> + </div>`; + } + + @query('textarea') + private accessor input!: HTMLTextAreaElement; + + @state() + accessor isComposing = false; + + @state() + private accessor isFocus = false; + + @property({ attribute: false }) + accessor onPressEnterKey: (() => void) | undefined = undefined; + + @property({ attribute: false }) + accessor readonly!: boolean; + + @state() + private accessor text = ''; + + @property({ attribute: false }) + accessor titleText!: Text; +} + +declare global { + interface HTMLElementTagNameMap { + 'affine-database-title': DatabaseTitle; + } +} diff --git a/blocksuite/blocks/src/database-block/config.ts b/blocksuite/blocks/src/database-block/config.ts new file mode 100644 index 0000000000..94c4dcfd24 --- /dev/null +++ b/blocksuite/blocks/src/database-block/config.ts @@ -0,0 +1,60 @@ +import type { MenuOptions } from '@blocksuite/affine-components/context-menu'; +import { + type DatabaseBlockModel, + DatabaseBlockSchema, +} from '@blocksuite/affine-model'; +import { DragHandleConfigExtension } from '@blocksuite/affine-shared/services'; +import { captureEventTarget } from '@blocksuite/affine-shared/utils'; + +export interface DatabaseOptionsConfig { + configure: (model: DatabaseBlockModel, options: MenuOptions) => MenuOptions; +} + +let canDrop = false; +export const DatabaseDragHandleOption = DragHandleConfigExtension({ + flavour: DatabaseBlockSchema.model.flavour, + onDragMove: ({ state }) => { + const target = captureEventTarget(state.raw.target); + const database = target?.closest('affine-database'); + if (!database) return false; + const view = database.view; + if (view && target instanceof HTMLElement && database.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 database = targetEl?.closest('affine-database'); + if (!database) { + return false; + } + const view = database.view; + if ( + canDrop && + view && + view.moveTo && + target instanceof HTMLElement && + database.parentElement?.contains(target) + ) { + const blocks = draggingElements.map(v => v.model); + editorHost.doc.moveBlocks(blocks, database.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/blocksuite/blocks/src/database-block/context/host-context.ts b/blocksuite/blocks/src/database-block/context/host-context.ts new file mode 100644 index 0000000000..642bf10e21 --- /dev/null +++ b/blocksuite/blocks/src/database-block/context/host-context.ts @@ -0,0 +1,7 @@ +import type { EditorHost } from '@blocksuite/block-std'; +import { createContextKey } from '@blocksuite/data-view'; + +export const HostContextKey = createContextKey<EditorHost | undefined>( + 'editor-host', + undefined +); diff --git a/blocksuite/blocks/src/database-block/data-source.ts b/blocksuite/blocks/src/database-block/data-source.ts new file mode 100644 index 0000000000..9e6f78474c --- /dev/null +++ b/blocksuite/blocks/src/database-block/data-source.ts @@ -0,0 +1,498 @@ +/* eslint-disable @typescript-eslint/no-non-null-assertion */ +import type { DatabaseBlockModel } from '@blocksuite/affine-model'; +import { + insertPositionToIndex, + type InsertToPosition, +} from '@blocksuite/affine-shared/utils'; +import type { EditorHost } from '@blocksuite/block-std'; +import { + type DatabaseFlags, + DataSourceBase, + type DataViewDataType, + getTagColor, + type PropertyMetaConfig, + type TypeInstance, + type ViewManager, + ViewManagerBase, + type ViewMeta, +} from '@blocksuite/data-view'; +import { propertyPresets } from '@blocksuite/data-view/property-presets'; +import { IS_MOBILE } from '@blocksuite/global/env'; +import { assertExists } from '@blocksuite/global/utils'; +import { type BlockModel, nanoid, Text } from '@blocksuite/store'; +import { computed, type ReadonlySignal } from '@preact/signals-core'; + +import { getIcon } from './block-icons.js'; +import { + databaseBlockAllPropertyMap, + databaseBlockPropertyList, + databasePropertyConverts, +} from './properties/index.js'; +import { titlePurePropertyConfig } from './properties/title/define.js'; +import { + addProperty, + applyCellsUpdate, + applyPropertyUpdate, + copyCellsByProperty, + deleteRows, + deleteView, + duplicateView, + findPropertyIndex, + getCell, + getProperty, + moveViewTo, + updateCell, + updateCells, + updateProperty, + updateView, +} from './utils/block-utils.js'; +import { + databaseBlockViewConverts, + databaseBlockViewMap, + databaseBlockViews, +} from './views/index.js'; + +export class DatabaseBlockDataSource extends DataSourceBase { + private _batch = 0; + + private readonly _model: DatabaseBlockModel; + + override featureFlags$: ReadonlySignal<DatabaseFlags> = computed(() => { + return { + enable_number_formatting: + this.doc.awarenessStore.getFlag('enable_database_number_formatting') ?? + false, + }; + }); + + properties$: ReadonlySignal<string[]> = computed(() => { + return this._model.columns$.value.map(column => column.id); + }); + + readonly$: ReadonlySignal<boolean> = computed(() => { + return ( + this._model.doc.readonly || + // TODO(@L-Sun): use block level readonly + IS_MOBILE + ); + }); + + rows$: ReadonlySignal<string[]> = computed(() => { + return this._model.children.map(v => v.id); + }); + + viewConverts = databaseBlockViewConverts; + + viewDataList$: ReadonlySignal<DataViewDataType[]> = computed(() => { + return this._model.views$.value as DataViewDataType[]; + }); + + override viewManager: ViewManager = new ViewManagerBase(this); + + viewMetas = databaseBlockViews; + + get doc() { + return this._model.doc; + } + + get propertyMetas(): PropertyMetaConfig<any, any, any>[] { + return databaseBlockPropertyList; + } + + constructor(model: DatabaseBlockModel) { + super(); + this._model = model; + } + + private _runCapture() { + if (this._batch) { + return; + } + + this._batch = requestAnimationFrame(() => { + this.doc.captureSync(); + this._batch = 0; + }); + } + + private getModelById(rowId: string): BlockModel | undefined { + return this._model.children[this._model.childMap.value.get(rowId) ?? -1]; + } + + private newPropertyName() { + let i = 1; + while ( + this._model.columns$.value.some(column => column.name === `Column ${i}`) + ) { + i++; + } + return `Column ${i}`; + } + + cellValueChange(rowId: string, propertyId: string, value: unknown): void { + this._runCapture(); + + const type = this.propertyTypeGet(propertyId); + const update = this.propertyMetaGet(type).config.valueUpdate; + let newValue = value; + if (update) { + const old = this.cellValueGet(rowId, propertyId); + newValue = update({ + value: old, + data: this.propertyDataGet(propertyId), + dataSource: this, + newValue: value, + }); + } + if (type === 'title' && newValue instanceof Text) { + this._model.doc.transact(() => { + this._model.text?.clear(); + this._model.text?.join(newValue); + }); + return; + } + if (this._model.columns$.value.some(v => v.id === propertyId)) { + updateCell(this._model, rowId, { + columnId: propertyId, + value: newValue, + }); + applyCellsUpdate(this._model); + } + } + + cellValueGet(rowId: string, propertyId: string): unknown { + if (propertyId === 'type') { + const model = this.getModelById(rowId); + if (!model) { + return; + } + return getIcon(model); + } + const type = this.propertyTypeGet(propertyId); + if (type === 'title') { + const model = this.getModelById(rowId); + return model?.text; + } + return getCell(this._model, rowId, propertyId)?.value; + } + + propertyAdd(insertToPosition: InsertToPosition, type?: string): string { + this.doc.captureSync(); + const result = addProperty( + this._model, + insertToPosition, + databaseBlockAllPropertyMap[ + type ?? propertyPresets.multiSelectPropertyConfig.type + ].create(this.newPropertyName()) + ); + applyPropertyUpdate(this._model); + return result; + } + + propertyDataGet(propertyId: string): Record<string, unknown> { + return ( + this._model.columns$.value.find(v => v.id === propertyId)?.data ?? {} + ); + } + + propertyDataSet(propertyId: string, data: Record<string, unknown>): void { + this._runCapture(); + + updateProperty(this._model, propertyId, () => ({ data })); + applyPropertyUpdate(this._model); + } + + propertyDataTypeGet(propertyId: string): TypeInstance | 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: data.data, + dataSource: this, + }); + } + + propertyDelete(id: string): void { + this.doc.captureSync(); + const index = findPropertyIndex(this._model, id); + if (index < 0) return; + + this.doc.transact(() => { + this._model.columns = this._model.columns.filter((_, i) => i !== index); + }); + } + + propertyDuplicate(propertyId: string): string { + this.doc.captureSync(); + const currentSchema = getProperty(this._model, propertyId); + assertExists(currentSchema); + const { id: copyId, ...nonIdProps } = currentSchema; + const names = new Set(this._model.columns$.value.map(v => v.name)); + let index = 1; + while (names.has(`${nonIdProps.name}(${index})`)) { + index++; + } + const schema = { ...nonIdProps, name: `${nonIdProps.name}(${index})` }; + const id = addProperty( + this._model, + { + before: false, + id: propertyId, + }, + schema + ); + copyCellsByProperty(this._model, copyId, id); + applyPropertyUpdate(this._model); + return id; + } + + propertyMetaGet(type: string): PropertyMetaConfig { + return databaseBlockAllPropertyMap[type]; + } + + propertyNameGet(propertyId: string): string { + if (propertyId === 'type') { + return 'Block Type'; + } + return ( + this._model.columns$.value.find(v => v.id === propertyId)?.name ?? '' + ); + } + + propertyNameSet(propertyId: string, name: string): void { + this.doc.captureSync(); + updateProperty(this._model, propertyId, () => ({ name })); + applyPropertyUpdate(this._model); + } + + override propertyReadonlyGet(propertyId: string): boolean { + if (propertyId === 'type') return true; + return false; + } + + propertyTypeGet(propertyId: string): string { + if (propertyId === 'type') { + return 'image'; + } + return ( + this._model.columns$.value.find(v => v.id === propertyId)?.type ?? '' + ); + } + + propertyTypeSet(propertyId: string, toType: string): void { + const currentType = this.propertyTypeGet(propertyId); + const currentData = this.propertyDataGet(propertyId); + const rows = this.rows$.value; + const currentCells = rows.map(rowId => + this.cellValueGet(rowId, propertyId) + ); + const convertFunction = databasePropertyConverts.find( + v => v.from === currentType && v.to === toType + )?.convert; + const result = convertFunction?.( + currentData as any, + + currentCells as any + ) ?? { + property: databaseBlockAllPropertyMap[toType].config.defaultData(), + cells: currentCells.map(() => undefined), + }; + this.doc.captureSync(); + updateProperty(this._model, propertyId, () => ({ + type: toType, + data: result.property, + })); + const cells: Record<string, unknown> = {}; + currentCells.forEach((value, i) => { + if (value != null || result.cells[i] != null) { + cells[rows[i]] = result.cells[i]; + } + }); + updateCells(this._model, propertyId, cells); + applyPropertyUpdate(this._model); + } + + rowAdd(insertPosition: InsertToPosition | number): string { + this.doc.captureSync(); + const index = + typeof insertPosition === 'number' + ? insertPosition + : insertPositionToIndex(insertPosition, this._model.children); + return this.doc.addBlock('affine:paragraph', {}, this._model.id, index); + } + + rowDelete(ids: string[]): void { + this.doc.captureSync(); + for (const id of ids) { + const block = this.doc.getBlock(id); + if (block) { + this.doc.deleteBlock(block.model); + } + } + deleteRows(this._model, ids); + } + + rowMove(rowId: string, position: InsertToPosition): void { + const model = this.doc.getBlockById(rowId); + if (model) { + const index = insertPositionToIndex(position, this._model.children); + const target = this._model.children[index]; + if (target?.id === rowId) { + return; + } + this.doc.moveBlocks([model], this._model, target); + } + } + + viewDataAdd(viewData: DataViewDataType): string { + this._model.doc.captureSync(); + this._model.doc.transact(() => { + this._model.views = [...this._model.views, viewData]; + }); + return viewData.id; + } + + viewDataDelete(viewId: string): void { + this._model.doc.captureSync(); + deleteView(this._model, viewId); + } + + viewDataDuplicate(id: string): string { + return duplicateView(this._model, id); + } + + viewDataGet(viewId: string): DataViewDataType { + return this.viewDataList$.value.find(data => data.id === viewId)!; + } + + viewDataMoveTo(id: string, position: InsertToPosition): void { + moveViewTo(this._model, id, position); + } + + viewDataUpdate<ViewData extends DataViewDataType>( + id: string, + updater: (data: ViewData) => Partial<ViewData> + ): void { + updateView(this._model, id, updater); + } + + viewMetaGet(type: string): ViewMeta { + return databaseBlockViewMap[type]; + } + + viewMetaGetById(viewId: string): ViewMeta { + const view = this.viewDataGet(viewId); + return this.viewMetaGet(view.mode); + } +} + +export const databaseViewAddView = ( + model: DatabaseBlockModel, + viewType: string +) => { + const dataSource = new DatabaseBlockDataSource(model); + dataSource.viewManager.viewAdd(viewType); +}; +export const databaseViewInitEmpty = ( + model: DatabaseBlockModel, + viewType: string +) => { + addProperty( + model, + 'start', + titlePurePropertyConfig.create(titlePurePropertyConfig.config.name) + ); + databaseViewAddView(model, viewType); +}; +export const databaseViewInitConvert = ( + model: DatabaseBlockModel, + viewType: string +) => { + addProperty( + model, + 'end', + propertyPresets.multiSelectPropertyConfig.create('Tag', { options: [] }) + ); + databaseViewInitEmpty(model, viewType); +}; +export const databaseViewInitTemplate = ( + model: DatabaseBlockModel, + viewType: string +) => { + const ids = [nanoid(), nanoid(), nanoid()]; + const statusId = addProperty( + model, + 'end', + propertyPresets.selectPropertyConfig.create('Status', { + options: [ + { + id: ids[0], + color: getTagColor(), + value: 'TODO', + }, + { + id: ids[1], + color: getTagColor(), + value: 'In Progress', + }, + { + id: ids[2], + color: getTagColor(), + value: 'Done', + }, + ], + }) + ); + for (let i = 0; i < 4; i++) { + const rowId = model.doc.addBlock( + 'affine:paragraph', + { + text: new model.doc.Text(`Task ${i + 1}`), + }, + model.id + ); + updateCell(model, rowId, { + columnId: statusId, + value: ids[i], + }); + } + databaseViewInitEmpty(model, viewType); +}; +export const convertToDatabase = (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:database', + {}, + parentModel, + parentModel.children.indexOf(selectedModels[0]) + ); + const databaseModel = host.doc.getBlock(id)?.model as + | DatabaseBlockModel + | undefined; + if (!databaseModel) { + return; + } + databaseViewInitConvert(databaseModel, viewType); + applyPropertyUpdate(databaseModel); + host.doc.moveBlocks(selectedModels, databaseModel); + + const selectionManager = host.selection; + selectionManager.clear(); +}; diff --git a/blocksuite/blocks/src/database-block/database-block.ts b/blocksuite/blocks/src/database-block/database-block.ts new file mode 100644 index 0000000000..b649c16880 --- /dev/null +++ b/blocksuite/blocks/src/database-block/database-block.ts @@ -0,0 +1,487 @@ +import { CaptionedBlockComponent } from '@blocksuite/affine-components/caption'; +import { + menu, + popMenu, + popupTargetFromElement, +} from '@blocksuite/affine-components/context-menu'; +import { DragIndicator } from '@blocksuite/affine-components/drag-indicator'; +import { PeekViewProvider } from '@blocksuite/affine-components/peek'; +import { toast } from '@blocksuite/affine-components/toast'; +import type { DatabaseBlockModel } from '@blocksuite/affine-model'; +import { NOTE_SELECTOR } from '@blocksuite/affine-shared/consts'; +import { + DocModeProvider, + NotificationProvider, + type TelemetryEventMap, + TelemetryProvider, +} from '@blocksuite/affine-shared/services'; +import { RANGE_SYNC_EXCLUDE_ATTR } from '@blocksuite/block-std'; +import { + createRecordDetail, + createUniComponentFromWebComponent, + DatabaseSelection, + DataView, + dataViewCommonStyle, + type DataViewInstance, + type DataViewProps, + type DataViewSelection, + type DataViewWidget, + type DataViewWidgetProps, + defineUniComponent, + renderUniLit, + type SingleView, + uniMap, +} from '@blocksuite/data-view'; +import { widgetPresets } from '@blocksuite/data-view/widget-presets'; +import { Rect } from '@blocksuite/global/utils'; +import { + CopyIcon, + DeleteIcon, + MoreHorizontalIcon, +} from '@blocksuite/icons/lit'; +import { Slice } from '@blocksuite/store'; +import { autoUpdate } from '@floating-ui/dom'; +import { computed, signal } from '@preact/signals-core'; +import { css, html, nothing, unsafeCSS } from 'lit'; + +import type { NoteBlockComponent } from '../note-block/index.js'; +import { EdgelessRootBlockComponent } from '../root-block/index.js'; +import { getDropResult } from '../root-block/widgets/drag-handle/utils.js'; +import { popSideDetail } from './components/layout.js'; +import type { DatabaseOptionsConfig } from './config.js'; +import { HostContextKey } from './context/host-context.js'; +import { DatabaseBlockDataSource } from './data-source.js'; +import type { DatabaseBlockService } from './database-service.js'; +import { BlockRenderer } from './detail-panel/block-renderer.js'; +import { NoteRenderer } from './detail-panel/note-renderer.js'; +import { currentViewStorage } from './utils/current-view.js'; +import { getSingleDocIdFromText } from './utils/title-doc.js'; + +export class DatabaseBlockComponent extends CaptionedBlockComponent< + DatabaseBlockModel, + DatabaseBlockService +> { + 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 { + padding: 2px; + border-radius: 4px; + display: flex; + cursor: pointer; + align-items: center; + height: max-content; + } + + .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) => { + const options = this.optionsConfig.configure(this.model, { + items: [ + menu.input({ + initialValue: this.model.title.toString(), + placeholder: 'Untitled', + onChange: 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 Database', + 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?: DatabaseBlockDataSource; + + private dataView = new DataView(); + + private renderTitle = (dataViewMethod: DataViewInstance) => { + const addRow = () => dataViewMethod.addRow?.('start'); + return html` <affine-database-title + style="overflow: hidden" + .titleText="${this.model.title}" + .readonly="${this.dataSource.readonly$.value}" + .onPressEnterKey="${addRow}" + ></affine-database-title>`; + }; + + _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, + }), + }; + }; + + createTemplate = ( + data: { + view: SingleView; + rowId: string; + }, + openDoc: (docId: string) => void + ) => { + return createRecordDetail({ + ...data, + openDoc, + detail: { + header: uniMap( + createUniComponentFromWebComponent(BlockRenderer), + props => ({ + ...props, + host: this.host, + }) + ), + note: uniMap( + createUniComponentFromWebComponent(NoteRenderer), + props => ({ + ...props, + model: this.model, + host: this.host, + }) + ), + }, + }); + }; + + headerWidget: DataViewWidget = defineUniComponent( + (props: DataViewWidgetProps) => { + return html` + <div style="margin-bottom: 16px;display:flex;flex-direction: column"> + <div + style="display:flex;gap:12px;margin-bottom: 8px;align-items: center" + > + ${this.renderTitle(props.dataViewInstance)} + ${this.renderDatabaseOps()} + </div> + <div + style="display:flex;align-items:center;justify-content: space-between;gap: 12px" + class="database-header-bar" + > + <div style="flex:1"> + ${renderUniLit(widgetPresets.viewBar, { + ...props, + onChangeView: id => { + currentViewStorage.setCurrentView(this.blockId, id); + }, + })} + </div> + ${renderUniLit(this.toolsWidget, props)} + </div> + ${renderUniLit(widgetPresets.quickSettingBar, props)} + </div> + `; + } + ); + + indicator = new DragIndicator(); + + onDrag = (evt: MouseEvent, id: string): (() => void) => { + const result = getDropResult(evt); + if (result && result.rect) { + document.body.append(this.indicator); + this.indicator.rect = Rect.fromLWTH( + result.rect.left, + result.rect.width, + result.rect.top, + result.rect.height + ); + return () => { + this.indicator.remove(); + const model = this.doc.getBlock(id)?.model; + const target = this.doc.getBlock(result.dropBlockId)?.model ?? null; + let parent = this.doc.getParent(result.dropBlockId); + const shouldInsertIn = result.dropType === 'in'; + if (shouldInsertIn) { + parent = target; + } + if (model && target && parent) { + if (shouldInsertIn) { + this.doc.moveBlocks([model], parent); + } else { + this.doc.moveBlocks( + [model], + parent, + target, + result.dropType === 'before' + ); + } + } + }; + } + this.indicator.remove(); + return () => {}; + }; + + setSelection = (selection: DataViewSelection | undefined) => { + if (selection) { + getSelection()?.removeAllRanges(); + } + this.selection.setGroup( + 'note', + selection + ? [ + new DatabaseSelection({ + blockId: this.blockId, + viewSelection: selection, + }), + ] + : [] + ); + }; + + toolsWidget: DataViewWidget = widgetPresets.createTools({ + table: [ + widgetPresets.tools.filter, + widgetPresets.tools.sort, + widgetPresets.tools.search, + widgetPresets.tools.viewOptions, + widgetPresets.tools.tableAddRow, + ], + kanban: [ + widgetPresets.tools.filter, + widgetPresets.tools.sort, + widgetPresets.tools.search, + widgetPresets.tools.viewOptions, + widgetPresets.tools.tableAddRow, + ], + }); + + viewSelection$ = computed(() => { + const databaseSelection = this.selection.value.find( + (selection): selection is DatabaseSelection => { + if (selection.blockId !== this.blockId) { + return false; + } + return selection instanceof DatabaseSelection; + } + ); + return databaseSelection?.viewSelection; + }); + + virtualPadding$ = signal(0); + + get dataSource(): DatabaseBlockDataSource { + if (!this._dataSource) { + this._dataSource = new DatabaseBlockDataSource(this.model); + this._dataSource.contextSet(HostContextKey, this.host); + const id = currentViewStorage.getCurrentView(this.model.id); + if (id) { + this.dataSource.viewManager.setCurrentView(id); + } + } + return this._dataSource; + } + + get optionsConfig(): DatabaseOptionsConfig { + return { + configure: (_model, options) => options, + ...this.std.getConfig('affine:page')?.databaseOptions, + }; + } + + override get topContenteditableElement() { + if (this.rootComponent instanceof EdgelessRootBlockComponent) { + const note = this.closest<NoteBlockComponent>(NOTE_SELECTOR); + return note; + } + return this.rootComponent; + } + + get view() { + return this.dataView.expose; + } + + private renderDatabaseOps() { + if (this.dataSource.readonly$.value) { + return nothing; + } + return html` <div class="database-ops" @click="${this._clickDatabaseOps}"> + ${MoreHorizontalIcon()} + </div>`; + } + + override connectedCallback() { + super.connectedCallback(); + + this.setAttribute(RANGE_SYNC_EXCLUDE_ATTR, 'true'); + this.listenFullWidthChange(); + } + + listenFullWidthChange() { + if (!this.doc.awarenessStore.getFlag('enable_database_full_width')) { + return; + } + if (this.std.get(DocModeProvider).getEditorMode() === 'edgeless') { + return; + } + this.disposables.add( + autoUpdate(this.host, this, () => { + const padding = + this.getBoundingClientRect().left - + this.host.getBoundingClientRect().left; + this.virtualPadding$.value = Math.max(0, padding - 72); + }) + ); + } + + override renderBlock() { + const peekViewService = this.std.getOptional(PeekViewProvider); + const telemetryService = this.std.getOptional(TelemetryProvider); + return html` + <div + contenteditable="false" + style="position: relative;background-color: var(--affine-background-primary-color);border-radius: 4px" + > + ${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, + clipboard: this.std.clipboard, + notification: { + toast: message => { + const notification = this.std.getOptional(NotificationProvider); + if (notification) { + notification.toast(message); + } else { + toast(this.host, message); + } + }, + }, + eventTrace: (key, params) => { + telemetryService?.track(key, { + ...(params as TelemetryEventMap[typeof key]), + blockId: this.blockId, + }); + }, + detailPanelConfig: { + openDetailPanel: (target, data) => { + if (peekViewService) { + const openDoc = (docId: string) => { + return peekViewService.peek({ + docId, + databaseId: this.blockId, + databaseDocId: this.model.doc.id, + databaseRowId: data.rowId, + target: this, + }); + }; + const doc = getSingleDocIdFromText( + this.model.doc.getBlock(data.rowId)?.model?.text + ); + if (doc) { + return openDoc(doc); + } + const abort = new AbortController(); + return new Promise<void>(focusBack => { + peekViewService + .peek( + { + target, + template: this.createTemplate(data, docId => { + // abort.abort(); + openDoc(docId).then(focusBack).catch(focusBack); + }), + }, + { abortSignal: abort.signal } + ) + .then(focusBack) + .catch(focusBack); + }); + } else { + return popSideDetail( + this.createTemplate(data, () => { + // + }) + ); + } + }, + }, + })} + </div> + `; + } + + override accessor useZeroWidth = true; +} + +declare global { + interface HTMLElementTagNameMap { + 'affine-database': DatabaseBlockComponent; + } +} diff --git a/blocksuite/blocks/src/database-block/database-service.ts b/blocksuite/blocks/src/database-block/database-service.ts new file mode 100644 index 0000000000..1fe16aff79 --- /dev/null +++ b/blocksuite/blocks/src/database-block/database-service.ts @@ -0,0 +1,59 @@ +import { + type DatabaseBlockModel, + DatabaseBlockSchema, +} from '@blocksuite/affine-model'; +import { BlockService } from '@blocksuite/block-std'; +import { viewPresets } from '@blocksuite/data-view/view-presets'; +import type { BlockModel, Doc } from '@blocksuite/store'; + +import { + databaseViewAddView, + databaseViewInitEmpty, + databaseViewInitTemplate, +} from './data-source.js'; +import { + addProperty, + applyPropertyUpdate, + updateCell, + updateView, +} from './utils/block-utils.js'; + +export class DatabaseBlockService extends BlockService { + static override readonly flavour = DatabaseBlockSchema.model.flavour; + + addColumn = addProperty; + + applyColumnUpdate = applyPropertyUpdate; + + databaseViewAddView = databaseViewAddView; + + databaseViewInitEmpty = databaseViewInitEmpty; + + updateCell = updateCell; + + updateView = updateView; + + viewPresets = viewPresets; + + initDatabaseBlock( + doc: Doc, + model: BlockModel, + databaseId: string, + viewType: string, + isAppendNewRow = true + ) { + const blockModel = doc.getBlock(databaseId)?.model as + | DatabaseBlockModel + | undefined; + if (!blockModel) { + return; + } + databaseViewInitTemplate(blockModel, viewType); + if (isAppendNewRow) { + const parent = doc.getParent(model); + if (!parent) return; + doc.addBlock('affine:paragraph', {}, parent.id); + } + applyPropertyUpdate(blockModel); + } +} diff --git a/blocksuite/blocks/src/database-block/database-spec.ts b/blocksuite/blocks/src/database-block/database-spec.ts new file mode 100644 index 0000000000..d3277c2d79 --- /dev/null +++ b/blocksuite/blocks/src/database-block/database-spec.ts @@ -0,0 +1,21 @@ +import { + BlockViewExtension, + CommandExtension, + type ExtensionType, + FlavourExtension, +} from '@blocksuite/block-std'; +import { DatabaseSelectionExtension } from '@blocksuite/data-view'; +import { literal } from 'lit/static-html.js'; + +import { commands } from './commands.js'; +import { DatabaseDragHandleOption } from './config.js'; +import { DatabaseBlockService } from './database-service.js'; + +export const DatabaseBlockSpec: ExtensionType[] = [ + FlavourExtension('affine:database'), + DatabaseBlockService, + CommandExtension(commands), + BlockViewExtension('affine:database', literal`affine-database`), + DatabaseDragHandleOption, + DatabaseSelectionExtension, +]; diff --git a/blocksuite/blocks/src/database-block/detail-panel/block-renderer.ts b/blocksuite/blocks/src/database-block/detail-panel/block-renderer.ts new file mode 100644 index 0000000000..7fa55c5c62 --- /dev/null +++ b/blocksuite/blocks/src/database-block/detail-panel/block-renderer.ts @@ -0,0 +1,161 @@ +import { DefaultInlineManagerExtension } from '@blocksuite/affine-components/rich-text'; +import type { EditorHost } from '@blocksuite/block-std'; +import { ShadowlessElement } from '@blocksuite/block-std'; +import type { DetailSlotProps } from '@blocksuite/data-view'; +import type { + KanbanSingleView, + TableSingleView, +} from '@blocksuite/data-view/view-presets'; +import { WithDisposable } from '@blocksuite/global/utils'; +import { css, html } from 'lit'; +import { property } from 'lit/decorators.js'; + +export class BlockRenderer + extends WithDisposable(ShadowlessElement) + implements DetailSlotProps +{ + static override styles = css` + database-datasource-block-renderer { + padding-top: 36px; + padding-bottom: 16px; + display: flex; + flex-direction: column; + gap: 16px; + margin-bottom: 12px; + border-bottom: 1px solid var(--affine-border-color); + font-size: var(--affine-font-base); + line-height: var(--affine-line-height); + } + + database-datasource-block-renderer .tips-placeholder { + display: none; + } + + database-datasource-block-renderer rich-text { + font-size: 15px; + line-height: 24px; + } + + database-datasource-block-renderer.empty rich-text::before { + content: 'Untitled'; + position: absolute; + color: var(--affine-text-disable-color); + font-size: 15px; + line-height: 24px; + user-select: none; + pointer-events: none; + } + + .database-block-detail-header-icon { + width: 20px; + height: 20px; + padding: 2px; + border-radius: 4px; + background-color: var(--affine-background-secondary-color); + } + + .database-block-detail-header-icon svg { + width: 16px; + height: 16px; + } + `; + + get attributeRenderer() { + return this.inlineManager.getRenderer(); + } + + get attributesSchema() { + return this.inlineManager.getSchema(); + } + + get inlineManager() { + return this.host.std.get(DefaultInlineManagerExtension.identifier); + } + + get model() { + return this.host?.doc.getBlock(this.rowId)?.model; + } + + get service() { + return this.host.std.getService('affine:database'); + } + + override connectedCallback() { + super.connectedCallback(); + if (this.model && this.model.text) { + const cb = () => { + if (this.model?.text?.length == 0) { + this.classList.add('empty'); + } else { + this.classList.remove('empty'); + } + }; + this.model.text.yText.observe(cb); + this.disposables.add(() => { + this.model?.text?.yText.unobserve(cb); + }); + } + this._disposables.addFromEvent( + this, + 'keydown', + e => { + if (e.key === 'Enter' && !e.shiftKey && !e.isComposing) { + e.stopPropagation(); + e.preventDefault(); + return; + } + if ( + e.key === 'Backspace' && + !e.shiftKey && + !e.metaKey && + this.model?.text?.length === 0 + ) { + e.stopPropagation(); + e.preventDefault(); + return; + } + }, + true + ); + } + + protected override render(): unknown { + const model = this.model; + if (!model) { + return; + } + return html` + ${this.renderIcon()} + <rich-text + .yText=${model.text} + .attributesSchema=${this.attributesSchema} + .attributeRenderer=${this.attributeRenderer} + .embedChecker=${this.inlineManager.embedChecker} + .markdownShortcutHandler=${this.inlineManager.markdownShortcutHandler} + class="inline-editor" + ></rich-text> + `; + } + + renderIcon() { + const iconColumn = this.view.mainProperties$.value.iconColumn; + if (!iconColumn) { + return; + } + return html` <div class="database-block-detail-header-icon"> + ${this.view.cellValueGet(this.rowId, iconColumn)} + </div>`; + } + + @property({ attribute: false }) + accessor host!: EditorHost; + + @property({ attribute: false }) + accessor openDoc!: (docId: string) => void; + + @property({ attribute: false }) + accessor rowId!: string; + + @property({ attribute: false }) + accessor view!: TableSingleView | KanbanSingleView; +} diff --git a/blocksuite/blocks/src/database-block/detail-panel/note-renderer.ts b/blocksuite/blocks/src/database-block/detail-panel/note-renderer.ts new file mode 100644 index 0000000000..a5fa6bb5d3 --- /dev/null +++ b/blocksuite/blocks/src/database-block/detail-panel/note-renderer.ts @@ -0,0 +1,118 @@ +import { REFERENCE_NODE } from '@blocksuite/affine-components/rich-text'; +import type { + DatabaseBlockModel, + RootBlockModel, +} from '@blocksuite/affine-model'; +import type { AffineTextAttributes } from '@blocksuite/affine-shared/types'; +import { + createDefaultDoc, + matchFlavours, +} from '@blocksuite/affine-shared/utils'; +import { type EditorHost, ShadowlessElement } from '@blocksuite/block-std'; +import type { DetailSlotProps, SingleView } from '@blocksuite/data-view'; +import { SignalWatcher, WithDisposable } from '@blocksuite/global/utils'; +import type { BaseTextAttributes } from '@blocksuite/inline'; +import { computed } from '@preact/signals-core'; +import { css, html } from 'lit'; +import { property } from 'lit/decorators.js'; + +import { isPureText } from '../utils/title-doc.js'; + +export class NoteRenderer + extends SignalWatcher(WithDisposable(ShadowlessElement)) + implements DetailSlotProps +{ + static override styles = css` + database-datasource-note-renderer { + width: 100%; + --affine-editor-side-padding: 0; + flex: 1; + } + `; + + @property({ attribute: false }) + accessor rowId!: string; + + rowText$ = computed(() => { + return this.databaseBlock.doc.getBlock(this.rowId)?.model?.text; + }); + + allowCreateDoc$ = computed(() => { + return isPureText(this.rowText$.value); + }); + + get databaseBlock(): DatabaseBlockModel { + return this.model; + } + + addNote() { + const collection = this.host?.std.collection; + if (!collection) { + return; + } + const note = createDefaultDoc(collection); + if (note) { + this.openDoc(note.id); + const rowContent = this.rowText$.value?.toString(); + this.rowText$.value?.replace( + 0, + this.rowText$.value.length, + REFERENCE_NODE, + { + reference: { + type: 'LinkedPage', + pageId: note.id, + }, + } satisfies AffineTextAttributes as BaseTextAttributes + ); + collection.setDocMeta(note.id, { title: rowContent }); + if (note.root) { + (note.root as RootBlockModel).title.insert(rowContent ?? '', 0); + note.root.children + .find(child => child.flavour === 'affine:note') + ?.children.find(block => + matchFlavours(block, [ + 'affine:paragraph', + 'affine:list', + 'affine:code', + ]) + ); + } + } + } + + protected override render(): unknown { + return html` + <div + style="height: 1px;max-width: var(--affine-editor-width);background-color: var(--affine-border-color);margin: auto;margin-bottom: 16px" + ></div> + ${this.renderNote()} + `; + } + + renderNote() { + if (this.allowCreateDoc$.value) { + return html` <div> + <div + @click="${this.addNote}" + style="max-width: var(--affine-editor-width);margin: auto;cursor: pointer;color: var(--affine-text-disable-color)" + > + Click to create a linked doc in center peek. + </div> + </div>`; + } + return; + } + + @property({ attribute: false }) + accessor host!: EditorHost; + + @property({ attribute: false }) + accessor model!: DatabaseBlockModel; + + @property({ attribute: false }) + accessor openDoc!: (docId: string) => void; + + @property({ attribute: false }) + accessor view!: SingleView; +} diff --git a/blocksuite/blocks/src/database-block/effects.ts b/blocksuite/blocks/src/database-block/effects.ts new file mode 100644 index 0000000000..3ec223ec52 --- /dev/null +++ b/blocksuite/blocks/src/database-block/effects.ts @@ -0,0 +1,30 @@ +import type { DatabaseBlockModel } from '@blocksuite/affine-model'; + +import type { insertDatabaseBlockCommand } from './commands.js'; + +export function effects() { + // TODO(@L-Sun): move other effects to this file +} + +declare global { + namespace BlockSuite { + interface BlockModels { + 'affine:database': DatabaseBlockModel; + } + + interface CommandContext { + insertedDatabaseBlockId?: string; + } + + interface Commands { + /** + * insert a database block after or before the current block selection + * @param latex the LaTeX content. A input dialog will be shown if not provided + * @param removeEmptyLine remove the current block if it is empty + * @param place where to insert the LaTeX block + * @returns the id of the inserted LaTeX block + */ + insertDatabaseBlock: typeof insertDatabaseBlockCommand; + } + } +} diff --git a/blocksuite/blocks/src/database-block/index.ts b/blocksuite/blocks/src/database-block/index.ts new file mode 100644 index 0000000000..f34697cf18 --- /dev/null +++ b/blocksuite/blocks/src/database-block/index.ts @@ -0,0 +1,15 @@ +import type { DatabaseBlockModel } from '@blocksuite/affine-model'; + +export * from './adapters/markdown.js'; +export type { DatabaseOptionsConfig } from './config.js'; +export * from './data-source.js'; +export * from './database-block.js'; +export * from './database-service.js'; +export { databaseBlockColumns } from './properties/index.js'; +declare global { + namespace BlockSuite { + interface BlockModels { + 'affine:database': DatabaseBlockModel; + } + } +} diff --git a/blocksuite/blocks/src/database-block/properties/converts.ts b/blocksuite/blocks/src/database-block/properties/converts.ts new file mode 100644 index 0000000000..149001f697 --- /dev/null +++ b/blocksuite/blocks/src/database-block/properties/converts.ts @@ -0,0 +1,176 @@ +import { clamp } from '@blocksuite/affine-shared/utils'; +import { + createPropertyConvert, + getTagColor, + type SelectTag, +} from '@blocksuite/data-view'; +import { presetPropertyConverts } from '@blocksuite/data-view/property-presets'; +import { propertyModelPresets } from '@blocksuite/data-view/property-pure-presets'; +import { nanoid, Text } from '@blocksuite/store'; + +import { richTextColumnModelConfig } from './rich-text/define.js'; + +export const databasePropertyConverts = [ + ...presetPropertyConverts, + createPropertyConvert( + richTextColumnModelConfig, + propertyModelPresets.selectPropertyModelConfig, + (_property, cells) => { + const options: Record<string, SelectTag> = {}; + const getTag = (name: string) => { + if (options[name]) return options[name]; + const tag: SelectTag = { + id: nanoid(), + value: name, + color: getTagColor(), + }; + options[name] = tag; + return tag; + }; + return { + cells: cells.map(v => { + const tags = v?.toString().split(','); + const value = tags?.[0]?.trim(); + if (value) { + return getTag(value).id; + } + return undefined; + }), + property: { + options: Object.values(options), + }, + }; + } + ), + createPropertyConvert( + richTextColumnModelConfig, + propertyModelPresets.multiSelectPropertyModelConfig, + (_property, cells) => { + const options: Record<string, SelectTag> = {}; + // eslint-disable-next-line sonarjs/no-identical-functions + const getTag = (name: string) => { + if (options[name]) return options[name]; + const tag: SelectTag = { + id: nanoid(), + value: name, + color: getTagColor(), + }; + options[name] = tag; + return tag; + }; + return { + cells: cells.map(v => { + const result: string[] = []; + const values = v?.toString().split(','); + values?.forEach(value => { + value = value.trim(); + if (value) { + result.push(getTag(value).id); + } + }); + return result; + }), + property: { + options: Object.values(options), + }, + }; + } + ), + createPropertyConvert( + richTextColumnModelConfig, + propertyModelPresets.numberPropertyModelConfig, + (_property, cells) => { + return { + property: { + decimal: 0, + format: 'number' as const, + }, + cells: cells.map(v => { + const num = v ? parseFloat(v.toString()) : NaN; + return isNaN(num) ? undefined : num; + }), + }; + } + ), + createPropertyConvert( + richTextColumnModelConfig, + propertyModelPresets.progressPropertyModelConfig, + (_property, cells) => { + return { + property: {}, + cells: cells.map(v => { + const progress = v ? parseInt(v.toString()) : NaN; + return !isNaN(progress) ? clamp(progress, 0, 100) : undefined; + }), + }; + } + ), + createPropertyConvert( + richTextColumnModelConfig, + propertyModelPresets.checkboxPropertyModelConfig, + (_property, cells) => { + const truthyValues = new Set(['yes', 'true']); + return { + property: {}, + cells: cells.map(v => + v && truthyValues.has(v.toString().toLowerCase()) ? true : undefined + ), + }; + } + ), + createPropertyConvert( + propertyModelPresets.checkboxPropertyModelConfig, + richTextColumnModelConfig, + (_property, cells) => { + return { + property: {}, + cells: cells.map(v => new Text(v ? 'Yes' : 'No').yText), + }; + } + ), + createPropertyConvert( + propertyModelPresets.multiSelectPropertyModelConfig, + richTextColumnModelConfig, + (property, cells) => { + const optionMap = Object.fromEntries( + property.options.map(v => [v.id, v]) + ); + return { + property: {}, + cells: cells.map( + arr => + new Text(arr?.map(v => optionMap[v]?.value ?? '').join(',')).yText + ), + }; + } + ), + createPropertyConvert( + propertyModelPresets.numberPropertyModelConfig, + richTextColumnModelConfig, + (_property, cells) => ({ + property: {}, + cells: cells.map(v => new Text(v?.toString()).yText), + }) + ), + createPropertyConvert( + propertyModelPresets.progressPropertyModelConfig, + richTextColumnModelConfig, + (_property, cells) => ({ + property: {}, + cells: cells.map(v => new Text(v?.toString()).yText), + }) + ), + createPropertyConvert( + propertyModelPresets.selectPropertyModelConfig, + richTextColumnModelConfig, + (property, cells) => { + const optionMap = Object.fromEntries( + property.options.map(v => [v.id, v]) + ); + return { + property: {}, + cells: cells.map(v => new Text(v ? optionMap[v]?.value : '').yText), + }; + } + ), +]; diff --git a/blocksuite/blocks/src/database-block/properties/index.ts b/blocksuite/blocks/src/database-block/properties/index.ts new file mode 100644 index 0000000000..4fadec6779 --- /dev/null +++ b/blocksuite/blocks/src/database-block/properties/index.ts @@ -0,0 +1,38 @@ +import type { PropertyMetaConfig } from '@blocksuite/data-view'; +import { propertyPresets } from '@blocksuite/data-view/property-presets'; + +import { linkColumnConfig } from './link/cell-renderer.js'; +import { richTextColumnConfig } from './rich-text/cell-renderer.js'; +import { titleColumnConfig } from './title/cell-renderer.js'; + +export * from './converts.js'; +const { + checkboxPropertyConfig, + datePropertyConfig, + multiSelectPropertyConfig, + numberPropertyConfig, + progressPropertyConfig, + selectPropertyConfig, +} = propertyPresets; +export const databaseBlockColumns = { + checkboxColumnConfig: checkboxPropertyConfig, + dateColumnConfig: datePropertyConfig, + multiSelectColumnConfig: multiSelectPropertyConfig, + numberColumnConfig: numberPropertyConfig, + progressColumnConfig: progressPropertyConfig, + selectColumnConfig: selectPropertyConfig, + linkColumnConfig, + richTextColumnConfig, +}; +export const databaseBlockPropertyList = Object.values(databaseBlockColumns); +export const databaseBlockHiddenColumns = [ + propertyPresets.imagePropertyConfig, + titleColumnConfig, +]; +const databaseBlockAllColumns = [ + ...databaseBlockPropertyList, + ...databaseBlockHiddenColumns, +]; +export const databaseBlockAllPropertyMap = Object.fromEntries( + databaseBlockAllColumns.map(v => [v.type, v as PropertyMetaConfig]) +); diff --git a/blocksuite/blocks/src/database-block/properties/link/cell-renderer.ts b/blocksuite/blocks/src/database-block/properties/link/cell-renderer.ts new file mode 100644 index 0000000000..b19bc79736 --- /dev/null +++ b/blocksuite/blocks/src/database-block/properties/link/cell-renderer.ts @@ -0,0 +1,256 @@ +import { RefNodeSlotsProvider } from '@blocksuite/affine-components/rich-text'; +import { ParseDocUrlProvider } from '@blocksuite/affine-shared/services'; +import { unsafeCSSVarV2 } from '@blocksuite/affine-shared/theme'; +import { + isValidUrl, + normalizeUrl, + stopPropagation, +} from '@blocksuite/affine-shared/utils'; +import { + BaseCellRenderer, + createFromBaseCellRenderer, + createIcon, +} from '@blocksuite/data-view'; +import { EditIcon } from '@blocksuite/icons/lit'; +import { baseTheme } from '@toeverything/theme'; +import { css, nothing, unsafeCSS } from 'lit'; +import { query, state } from 'lit/decorators.js'; +import { html } from 'lit/static-html.js'; + +import { HostContextKey } from '../../context/host-context.js'; +import { linkColumnModelConfig } from './define.js'; + +export class LinkCell extends BaseCellRenderer<string> { + static override styles = css` + affine-database-link-cell { + width: 100%; + user-select: none; + position: relative; + } + + affine-database-link-cell:hover .affine-database-link-icon { + visibility: visible; + } + + .affine-database-link { + display: flex; + position: relative; + align-items: center; + width: 100%; + height: 100%; + outline: none; + overflow: hidden; + font-size: var(--data-view-cell-text-size); + line-height: var(--data-view-cell-text-line-height); + word-break: break-all; + } + + affine-database-link-node { + flex: 1; + word-break: break-all; + } + + .affine-database-link-icon { + position: absolute; + right: 8px; + top: 8px; + display: flex; + align-items: center; + visibility: hidden; + cursor: pointer; + background: ${unsafeCSSVarV2('button/iconButtonSolid')}; + color: ${unsafeCSSVarV2('icon/primary')}; + box-shadow: var(--affine-button-shadow); + border-radius: 4px; + font-size: 14px; + padding: 2px; + } + + .affine-database-link-icon:hover { + background: var(--affine-hover-color); + } + + .data-view-link-column-linked-doc { + text-decoration: underline; + text-decoration-color: var(--affine-divider-color); + transition: text-decoration-color 0.2s ease-out; + cursor: pointer; + } + + .data-view-link-column-linked-doc:hover { + text-decoration-color: var(--affine-icon-color); + } + `; + + private _onClick = (event: Event) => { + event.stopPropagation(); + const value = this.value ?? ''; + + if (!value || !isValidUrl(value)) { + this.selectCurrentCell(true); + return; + } + + if (isValidUrl(value)) { + const target = event.target as HTMLElement; + const link = target.querySelector<HTMLAnchorElement>('.link-node'); + if (link) { + event.preventDefault(); + link.click(); + } + return; + } + }; + + private _onEdit = (e: Event) => { + e.stopPropagation(); + this.selectCurrentCell(true); + }; + + private preValue?: string; + + openDoc = (e: MouseEvent) => { + e.stopPropagation(); + if (!this.docId) { + return; + } + const std = this.std; + if (!std) { + return; + } + + std + .getOptional(RefNodeSlotsProvider) + ?.docLinkClicked.emit({ pageId: this.docId }); + }; + + get std() { + const host = this.view.contextGet(HostContextKey); + return host?.std; + } + + override render() { + const linkText = this.value ?? ''; + const docName = + this.docId && this.std?.collection.getDoc(this.docId)?.meta?.title; + return html` + <div class="affine-database-link" @click="${this._onClick}"> + ${docName + ? html`<span + class="data-view-link-column-linked-doc" + @click="${this.openDoc}" + >${docName}</span + >` + : html` <affine-database-link-node + .link="${linkText}" + ></affine-database-link-node>`} + </div> + ${docName || linkText + ? html` <div class="affine-database-link-icon" @click="${this._onEdit}"> + ${EditIcon()} + </div>` + : nothing} + `; + } + + override updated() { + if (this.value !== this.preValue) { + const std = this.std; + this.preValue = this.value; + if (!this.value || !isValidUrl(this.value)) { + this.docId = undefined; + return; + } + + this.docId = + std?.getOptional(ParseDocUrlProvider)?.parseDocUrl(this.value)?.docId ?? + undefined; + } + } + + @state() + accessor docId: string | undefined = undefined; +} + +export class LinkCellEditing extends BaseCellRenderer<string> { + static override styles = css` + affine-database-link-cell-editing { + width: 100%; + cursor: text; + } + + .affine-database-link-editing { + display: flex; + align-items: center; + width: 100%; + padding: 0; + border: none; + font-family: ${unsafeCSS(baseTheme.fontSansFamily)}; + color: var(--affine-text-primary-color); + font-weight: 400; + background-color: transparent; + font-size: var(--data-view-cell-text-size); + line-height: var(--data-view-cell-text-line-height); + word-break: break-all; + } + + .affine-database-link-editing:focus { + outline: none; + } + `; + + private _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`<input + class="affine-database-link-editing link" + .value="${linkText}" + @keydown="${this._onKeydown}" + @pointerdown="${stopPropagation}" + />`; + } + + @query('.affine-database-link-editing') + private accessor _container!: HTMLInputElement; +} + +export const linkColumnConfig = linkColumnModelConfig.createPropertyMeta({ + icon: createIcon('LinkIcon'), + cellRenderer: { + view: createFromBaseCellRenderer(LinkCell), + edit: createFromBaseCellRenderer(LinkCellEditing), + }, +}); diff --git a/blocksuite/blocks/src/database-block/properties/link/components/link-node.ts b/blocksuite/blocks/src/database-block/properties/link/components/link-node.ts new file mode 100644 index 0000000000..ae34b5f5f2 --- /dev/null +++ b/blocksuite/blocks/src/database-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`<span class="normal-text">${this.link}</span>`; + } + + return html`<a + class="link-node" + href=${this.link} + rel="noopener noreferrer" + target="_blank" + ><span class="link-node-text">${this.link}</span></a + >`; + } + + @property({ attribute: false }) + accessor link!: string; +} + +declare global { + interface HTMLElementTagNameMap { + 'affine-database-link-node': LinkNode; + } +} diff --git a/blocksuite/blocks/src/database-block/properties/link/define.ts b/blocksuite/blocks/src/database-block/properties/link/define.ts new file mode 100644 index 0000000000..2c865f4a37 --- /dev/null +++ b/blocksuite/blocks/src/database-block/properties/link/define.ts @@ -0,0 +1,18 @@ +import { propertyType, t } from '@blocksuite/data-view'; + +export const linkColumnType = propertyType('link'); +export const linkColumnModelConfig = linkColumnType.modelConfig<string>({ + name: 'Link', + type: () => t.string.instance(), + defaultData: () => ({}), + cellToString: ({ value }) => value?.toString() ?? '', + cellFromString: ({ value }) => { + return { + value: value, + }; + }, + cellToJson: ({ value }) => value ?? null, + cellFromJson: ({ value }) => (typeof value !== 'string' ? undefined : value), + + isEmpty: ({ value }) => value == null || value.length == 0, +}); diff --git a/blocksuite/blocks/src/database-block/properties/rich-text/cell-renderer.ts b/blocksuite/blocks/src/database-block/properties/rich-text/cell-renderer.ts new file mode 100644 index 0000000000..be5809fbe5 --- /dev/null +++ b/blocksuite/blocks/src/database-block/properties/rich-text/cell-renderer.ts @@ -0,0 +1,398 @@ +import { + type AffineInlineEditor, + DefaultInlineManagerExtension, + type RichText, +} from '@blocksuite/affine-components/rich-text'; +import type { AffineTextAttributes } from '@blocksuite/affine-shared/types'; +import { getViewportElement } from '@blocksuite/affine-shared/utils'; +import { + BaseCellRenderer, + createFromBaseCellRenderer, + createIcon, +} from '@blocksuite/data-view'; +import { IS_MAC } from '@blocksuite/global/env'; +import { assertExists } from '@blocksuite/global/utils'; +import { Text } from '@blocksuite/store'; +import { css, nothing, type PropertyValues } from 'lit'; +import { query } from 'lit/decorators.js'; +import { keyed } from 'lit/directives/keyed.js'; +import { html } from 'lit/static-html.js'; + +import { HostContextKey } from '../../context/host-context.js'; +import type { DatabaseBlockComponent } from '../../database-block.js'; +import { richTextColumnModelConfig } from './define.js'; + +function toggleStyle( + inlineEditor: AffineInlineEditor, + attrs: AffineTextAttributes +): void { + const inlineRange = inlineEditor.getInlineRange(); + if (!inlineRange) return; + + const root = inlineEditor.rootElement; + if (!root) { + return; + } + + const deltas = inlineEditor.getDeltasByInlineRange(inlineRange); + let oldAttributes: AffineTextAttributes = {}; + + for (const [delta] of deltas) { + const attributes = delta.attributes; + + if (!attributes) { + continue; + } + + oldAttributes = { ...attributes }; + } + + const newAttributes = Object.fromEntries( + Object.entries(attrs).map(([k, v]) => { + if ( + typeof v === 'boolean' && + v === (oldAttributes as Record<string, unknown>)[k] + ) { + return [k, !v]; + } else { + return [k, v]; + } + }) + ); + + inlineEditor.formatText(inlineRange, newAttributes, { + mode: 'merge', + }); + root.blur(); + + inlineEditor.syncInlineRange(); +} + +export class RichTextCell extends BaseCellRenderer<Text> { + static override styles = css` + affine-database-rich-text-cell { + display: flex; + align-items: center; + width: 100%; + user-select: none; + } + + .affine-database-rich-text { + display: flex; + flex-direction: column; + justify-content: center; + width: 100%; + height: 100%; + outline: none; + font-size: var(--data-view-cell-text-size); + line-height: var(--data-view-cell-text-line-height); + word-break: break-all; + } + + .affine-database-rich-text v-line { + display: flex !important; + align-items: center; + height: 100%; + width: 100%; + } + + .affine-database-rich-text v-line > div { + flex-grow: 1; + } + `; + + get attributeRenderer() { + return this.inlineManager?.getRenderer(); + } + + get attributesSchema() { + return this.inlineManager?.getSchema(); + } + + get inlineEditor() { + assertExists(this._richTextElement); + const inlineEditor = this._richTextElement.inlineEditor; + assertExists(inlineEditor); + return inlineEditor; + } + + get inlineManager() { + return this.view + .contextGet(HostContextKey) + ?.std.get(DefaultInlineManagerExtension.identifier); + } + + get service() { + return this.view + .contextGet(HostContextKey) + ?.std.getService('affine:database'); + } + + get topContenteditableElement() { + const databaseBlock = + this.closest<DatabaseBlockComponent>('affine-database'); + return databaseBlock?.topContenteditableElement; + } + + private changeUserSelectAccordToReadOnly() { + if (this && this instanceof HTMLElement) { + this.style.userSelect = this.readonly ? 'text' : 'none'; + } + } + + override connectedCallback() { + super.connectedCallback(); + this.changeUserSelectAccordToReadOnly(); + } + + override render() { + if (!this.service) return nothing; + if (!this.value || !(this.value instanceof Text)) { + return html`<div class="affine-database-rich-text"></div>`; + } + return keyed( + this.value, + html`<rich-text + .yText=${this.value} + .attributesSchema=${this.attributesSchema} + .attributeRenderer=${this.attributeRenderer} + .embedChecker=${this.inlineManager?.embedChecker} + .markdownShortcutHandler=${this.inlineManager?.markdownShortcutHandler} + .readonly=${true} + class="affine-database-rich-text inline-editor" + ></rich-text>` + ); + } + + override updated(changedProperties: PropertyValues) { + if (changedProperties.has('readonly')) { + this.changeUserSelectAccordToReadOnly(); + } + } + + @query('rich-text') + private accessor _richTextElement: RichText | null = null; +} + +export class RichTextCellEditing extends BaseCellRenderer<Text> { + static override styles = css` + affine-database-rich-text-cell-editing { + display: flex; + align-items: center; + width: 100%; + min-width: 1px; + cursor: text; + } + + .affine-database-rich-text { + display: flex; + flex-direction: column; + justify-content: center; + width: 100%; + height: 100%; + outline: none; + } + + .affine-database-rich-text v-line { + display: flex !important; + align-items: center; + height: 100%; + width: 100%; + } + + .affine-database-rich-text v-line > div { + flex-grow: 1; + } + `; + + private _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(); + } + + // eslint-disable-next-line sonarjs/no-identical-functions + get inlineEditor() { + assertExists(this._richTextElement); + const inlineEditor = this._richTextElement.inlineEditor; + assertExists(inlineEditor); + return inlineEditor; + } + + // eslint-disable-next-line sonarjs/no-identical-functions + get inlineManager() { + return this.view + .contextGet(HostContextKey) + ?.std.get(DefaultInlineManagerExtension.identifier); + } + + // eslint-disable-next-line sonarjs/no-identical-functions + get service() { + return this.view + .contextGet(HostContextKey) + ?.std.getService('affine:database'); + } + + // eslint-disable-next-line sonarjs/no-identical-functions + get topContenteditableElement() { + const databaseBlock = + this.closest<DatabaseBlockComponent>('affine-database'); + return databaseBlock?.topContenteditableElement; + } + + override connectedCallback() { + super.connectedCallback(); + + if (!this.value || typeof this.value === 'string') { + this._initYText(this.value); + } + + const selectAll = (e: KeyboardEvent) => { + if (e.key === 'a' && (IS_MAC ? e.metaKey : e.ctrlKey)) { + e.stopPropagation(); + e.preventDefault(); + this.inlineEditor.selectAll(); + } + }; + this.addEventListener('keydown', selectAll); + this.disposables.addFromEvent(this, 'keydown', selectAll); + } + + override firstUpdated() { + this._richTextElement?.updateComplete + .then(() => { + this.disposables.add( + this.inlineEditor.slots.keydown.on(this._handleKeyDown) + ); + + this.inlineEditor.focusEnd(); + }) + .catch(console.error); + } + + override render() { + if (!this.service) return nothing; + return html`<rich-text + .yText=${this.value} + .inlineEventSource=${this.topContenteditableElement} + .attributesSchema=${this.attributesSchema} + .attributeRenderer=${this.attributeRenderer} + .embedChecker=${this.inlineManager?.embedChecker} + .markdownShortcutHandler=${this.inlineManager?.markdownShortcutHandler} + .verticalScrollContainerGetter=${() => + this.topContenteditableElement?.host + ? getViewportElement(this.topContenteditableElement.host) + : null} + class="affine-database-rich-text inline-editor" + ></rich-text>`; + } + + @query('rich-text') + private accessor _richTextElement: RichText | null = null; +} + +declare global { + interface HTMLElementTagNameMap { + 'affine-database-rich-text-cell-editing': RichTextCellEditing; + } +} + +export const richTextColumnConfig = + richTextColumnModelConfig.createPropertyMeta({ + icon: createIcon('TextIcon'), + + cellRenderer: { + view: createFromBaseCellRenderer(RichTextCell), + edit: createFromBaseCellRenderer(RichTextCellEditing), + }, + }); diff --git a/blocksuite/blocks/src/database-block/properties/rich-text/define.ts b/blocksuite/blocks/src/database-block/properties/rich-text/define.ts new file mode 100644 index 0000000000..8c2ad9e0e3 --- /dev/null +++ b/blocksuite/blocks/src/database-block/properties/rich-text/define.ts @@ -0,0 +1,34 @@ +import { propertyType, t } from '@blocksuite/data-view'; +import { Text } from '@blocksuite/store'; + +import { type RichTextCellType, toYText } from '../utils.js'; + +export const richTextColumnType = propertyType('rich-text'); + +export const richTextColumnModelConfig = + richTextColumnType.modelConfig<RichTextCellType>({ + name: 'Text', + type: () => t.richText.instance(), + defaultData: () => ({}), + cellToString: ({ value }) => value?.toString() ?? '', + cellFromString: ({ value }) => { + return { + value: new Text(value), + }; + }, + cellToJson: ({ value }) => value?.toString() ?? null, + cellFromJson: ({ value }) => + typeof value !== 'string' ? undefined : new Text(value), + onUpdate: ({ value, callback }) => { + const yText = toYText(value); + yText.observe(callback); + callback(); + return { + dispose: () => { + yText.unobserve(callback); + }, + }; + }, + isEmpty: ({ value }) => value == null || value.length === 0, + values: ({ value }) => (value?.toString() ? [value.toString()] : []), + }); diff --git a/blocksuite/blocks/src/database-block/properties/title/cell-renderer.ts b/blocksuite/blocks/src/database-block/properties/title/cell-renderer.ts new file mode 100644 index 0000000000..5bf879899b --- /dev/null +++ b/blocksuite/blocks/src/database-block/properties/title/cell-renderer.ts @@ -0,0 +1,30 @@ +import { + type CellRenderProps, + createFromBaseCellRenderer, + createIcon, + uniMap, +} from '@blocksuite/data-view'; +import { TableSingleView } from '@blocksuite/data-view/view-presets'; + +import { titlePurePropertyConfig } from './define.js'; +import { HeaderAreaTextCell, HeaderAreaTextCellEditing } from './text.js'; + +export const titleColumnConfig = titlePurePropertyConfig.createPropertyMeta({ + icon: createIcon('TitleIcon'), + cellRenderer: { + view: uniMap( + createFromBaseCellRenderer(HeaderAreaTextCell), + (props: CellRenderProps) => ({ + ...props, + showIcon: props.cell.view instanceof TableSingleView, + }) + ), + edit: uniMap( + createFromBaseCellRenderer(HeaderAreaTextCellEditing), + (props: CellRenderProps) => ({ + ...props, + showIcon: props.cell.view instanceof TableSingleView, + }) + ), + }, +}); diff --git a/blocksuite/blocks/src/database-block/properties/title/define.ts b/blocksuite/blocks/src/database-block/properties/title/define.ts new file mode 100644 index 0000000000..a1217e32db --- /dev/null +++ b/blocksuite/blocks/src/database-block/properties/title/define.ts @@ -0,0 +1,62 @@ +import { propertyType, t } from '@blocksuite/data-view'; +import { Text } from '@blocksuite/store'; + +import { HostContextKey } from '../../context/host-context.js'; +import { isLinkedDoc } from '../../utils/title-doc.js'; + +export const titleColumnType = propertyType('title'); + +export const titlePurePropertyConfig = titleColumnType.modelConfig<Text>({ + name: 'Title', + type: () => t.richText.instance(), + defaultData: () => ({}), + cellToString: ({ value }) => value?.toString() ?? '', + cellFromString: ({ value }) => { + return { + value: value, + }; + }, + cellToJson: ({ value, dataSource }) => { + const host = dataSource.contextGet(HostContextKey); + if (host) { + const collection = host.std.collection; + const deltas = value.deltas$.value; + const text = deltas + .map(delta => { + if (isLinkedDoc(delta)) { + const linkedDocId = delta.attributes?.reference?.pageId as string; + return collection.getDoc(linkedDocId)?.meta?.title; + } + return delta.insert; + }) + .join(''); + return text; + } + return value?.toString() ?? null; + }, + cellFromJson: ({ value }) => + typeof value !== 'string' ? undefined : new Text(value), + onUpdate: ({ value, callback }) => { + value.yText.observe(callback); + callback(); + return { + dispose: () => { + value.yText.unobserve(callback); + }, + }; + }, + valueUpdate: ({ value, newValue }) => { + const v = newValue as unknown; + if (typeof v === 'string') { + value.replace(0, value.length, v); + return value; + } + if (v == null) { + value.replace(0, value.length, ''); + return value; + } + return newValue; + }, + isEmpty: ({ value }) => value == null || value.length === 0, + values: ({ value }) => (value?.toString() ? [value.toString()] : []), +}); diff --git a/blocksuite/blocks/src/database-block/properties/title/icon.ts b/blocksuite/blocks/src/database-block/properties/title/icon.ts new file mode 100644 index 0000000000..08b1c35584 --- /dev/null +++ b/blocksuite/blocks/src/database-block/properties/title/icon.ts @@ -0,0 +1,21 @@ +import { BaseCellRenderer } from '@blocksuite/data-view'; +import { css, html } from 'lit'; + +export class IconCell extends BaseCellRenderer<string> { + static override styles = css` + affine-database-image-cell { + width: 100%; + height: 100%; + display: flex; + align-items: center; + } + affine-database-image-cell img { + width: 20px; + height: 20px; + } + `; + + override render() { + return html`<img src=${this.value ?? ''}></img>`; + } +} diff --git a/blocksuite/blocks/src/database-block/properties/title/text.ts b/blocksuite/blocks/src/database-block/properties/title/text.ts new file mode 100644 index 0000000000..00345d32fb --- /dev/null +++ b/blocksuite/blocks/src/database-block/properties/title/text.ts @@ -0,0 +1,417 @@ +import { + DefaultInlineManagerExtension, + type RichText, +} from '@blocksuite/affine-components/rich-text'; +import type { RootBlockModel } from '@blocksuite/affine-model'; +import { ParseDocUrlProvider } from '@blocksuite/affine-shared/services'; +import { + getViewportElement, + isValidUrl, +} from '@blocksuite/affine-shared/utils'; +import { BaseCellRenderer } from '@blocksuite/data-view'; +import { IS_MAC } from '@blocksuite/global/env'; +import { assertExists } from '@blocksuite/global/utils'; +import { LinkedPageIcon } from '@blocksuite/icons/lit'; +import type { DeltaInsert } from '@blocksuite/inline'; +import type { BlockSnapshot, Text } from '@blocksuite/store'; +import { computed, effect, signal } from '@preact/signals-core'; +import { css, type TemplateResult } from 'lit'; +import { property, query } from 'lit/decorators.js'; +import { html } from 'lit/static-html.js'; + +import { ClipboardAdapter } from '../../../root-block/clipboard/adapter.js'; +import { HostContextKey } from '../../context/host-context.js'; +import type { DatabaseBlockComponent } from '../../database-block.js'; +import { getSingleDocIdFromText } from '../../utils/title-doc.js'; + +const styles = css` + data-view-header-area-text { + width: 100%; + display: flex; + } + + data-view-header-area-text rich-text { + pointer-events: none; + user-select: none; + } + + data-view-header-area-text-editing { + width: 100%; + display: flex; + cursor: text; + } + + .data-view-header-area-rich-text { + display: flex; + flex-direction: column; + justify-content: center; + width: 100%; + height: 100%; + outline: none; + word-break: break-all; + font-size: var(--data-view-cell-text-size); + line-height: var(--data-view-cell-text-line-height); + } + + .data-view-header-area-rich-text v-line { + display: flex !important; + align-items: center; + height: 100%; + width: 100%; + } + + .data-view-header-area-rich-text v-line > div { + flex-grow: 1; + } + + .data-view-header-area-icon { + height: max-content; + display: flex; + align-items: center; + margin-right: 8px; + padding: 2px; + border-radius: 4px; + margin-top: 2px; + background-color: var(--affine-background-secondary-color); + } + + .data-view-header-area-icon svg { + width: 14px; + height: 14px; + fill: var(--affine-icon-color); + color: var(--affine-icon-color); + } +`; + +abstract class BaseTextCell extends BaseCellRenderer<Text> { + static override styles = styles; + + activity = true; + + docId$ = signal<string>(); + + isLinkedDoc$ = computed(() => false); + + linkedDocTitle$ = computed(() => { + if (!this.docId$.value) { + return this.value; + } + const doc = this.host?.std.collection.getDoc(this.docId$.value); + const root = doc?.root as RootBlockModel; + return root.title; + }); + + get attributeRenderer() { + return this.inlineManager?.getRenderer(); + } + + get attributesSchema() { + return this.inlineManager?.getSchema(); + } + + get host() { + return this.view.contextGet(HostContextKey); + } + + get inlineEditor() { + return this.richText.inlineEditor; + } + + get inlineManager() { + return this.host?.std.get(DefaultInlineManagerExtension.identifier); + } + + get service() { + return this.host?.std.getService('affine:database'); + } + + get topContenteditableElement() { + const databaseBlock = + this.closest<DatabaseBlockComponent>('affine-database'); + return databaseBlock?.topContenteditableElement; + } + + override connectedCallback() { + super.connectedCallback(); + const yText = this.value?.yText; + if (yText) { + const cb = () => { + const id = getSingleDocIdFromText(this.value); + this.docId$.value = id; + }; + cb(); + if (this.activity) { + yText.observe(cb); + this.disposables.add(() => { + yText.unobserve(cb); + }); + } + } + } + + protected override render(): unknown { + return html`${this.renderIcon()}${this.renderBlockText()}`; + } + + abstract renderBlockText(): TemplateResult; + + renderIcon() { + if (this.docId$.value) { + return html` <div class="data-view-header-area-icon"> + ${LinkedPageIcon()} + </div>`; + } + if (!this.showIcon) { + return; + } + const iconColumn = this.view.mainProperties$.value.iconColumn; + if (!iconColumn) return; + + const icon = this.view.cellValueGet(this.cell.rowId, iconColumn) as string; + if (!icon) return; + + return html` <div class="data-view-header-area-icon">${icon}</div>`; + } + + abstract renderLinkedDoc(): TemplateResult; + + @query('rich-text') + accessor richText!: RichText; + + @property({ attribute: false }) + accessor showIcon = false; +} + +export class HeaderAreaTextCell extends BaseTextCell { + override renderBlockText() { + return html` <rich-text + .yText="${this.value}" + .attributesSchema="${this.attributesSchema}" + .attributeRenderer="${this.attributeRenderer}" + .embedChecker="${this.inlineManager?.embedChecker}" + .markdownShortcutHandler="${this.inlineManager?.markdownShortcutHandler}" + .readonly="${true}" + class="data-view-header-area-rich-text" + ></rich-text>`; + } + + override renderLinkedDoc(): TemplateResult { + return html` <rich-text + .yText="${this.linkedDocTitle$.value}" + .readonly="${true}" + class="data-view-header-area-rich-text" + ></rich-text>`; + } +} + +export class HeaderAreaTextCellEditing extends BaseTextCell { + private _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; + const inlineRange = inlineEditor?.getInlineRange(); + if (!inlineRange) return; + if (e.clipboardData) { + try { + const getDeltas = (snapshot: BlockSnapshot): DeltaInsert[] => { + // @ts-expect-error FIXME: ts error + const text = snapshot.props?.text?.delta; + return text + ? [...text, ...(snapshot.children?.flatMap(getDeltas) ?? [])] + : snapshot.children?.flatMap(getDeltas); + }; + const snapshot = this.std?.clipboard?.readFromClipboard( + e.clipboardData + )[ClipboardAdapter.MIME]; + const deltas = ( + JSON.parse(snapshot).snapshot.content as BlockSnapshot[] + ).flatMap(getDeltas); + deltas.forEach(delta => this.insertDelta(delta)); + return; + } catch { + // + } + } + const text = e.clipboardData + ?.getData('text/plain') + ?.replace(/\r?\n|\r/g, '\n'); + if (!text) return; + e.preventDefault(); + e.stopPropagation(); + if (isValidUrl(text)) { + const std = this.std; + const result = std?.getOptional(ParseDocUrlProvider)?.parseDocUrl(text); + if (result) { + const text = ' '; + inlineEditor?.insertText(inlineRange, text, { + reference: { + type: 'LinkedPage', + pageId: result.docId, + params: { + blockIds: result.blockIds, + elementIds: result.elementIds, + mode: result.mode, + }, + }, + }); + inlineEditor?.setInlineRange({ + index: inlineRange.index + text.length, + length: 0, + }); + } else { + inlineEditor?.insertText(inlineRange, text, { + link: text, + }); + inlineEditor?.setInlineRange({ + index: inlineRange.index + text.length, + length: 0, + }); + } + } else { + inlineEditor?.insertText(inlineRange, text); + inlineEditor?.setInlineRange({ + index: inlineRange.index + text.length, + length: 0, + }); + } + }; + + override activity = false; + + insertDelta = (delta: DeltaInsert) => { + const inlineEditor = this.inlineEditor; + const range = inlineEditor?.getInlineRange(); + if (!range || !delta.insert) { + return; + } + inlineEditor?.insertText(range, delta.insert, delta.attributes); + inlineEditor?.setInlineRange({ + index: range.index + delta.insert.length, + length: 0, + }); + }; + + private get std() { + return this.host?.std; + } + + override connectedCallback() { + super.connectedCallback(); + const selectAll = (e: KeyboardEvent) => { + if (e.key === 'a' && (IS_MAC ? e.metaKey : e.ctrlKey)) { + e.stopPropagation(); + e.preventDefault(); + this.inlineEditor?.selectAll(); + } + }; + this.addEventListener('keydown', selectAll); + this.disposables.add(() => { + this.removeEventListener('keydown', selectAll); + }); + } + + override firstUpdated(props: Map<string, unknown>) { + super.firstUpdated(props); + if (!this.isLinkedDoc$.value) { + this.disposables.addFromEvent(this.richText, 'copy', this._onCopy); + this.disposables.addFromEvent(this.richText, 'cut', this._onCut); + this.disposables.addFromEvent(this.richText, 'paste', this._onPaste); + } + this.richText.updateComplete + .then(() => { + this.inlineEditor?.focusEnd(); + + this.disposables.add( + effect(() => { + const inlineRange = this.inlineEditor?.inlineRange$.value; + if (inlineRange) { + if (!this.isEditing) { + this.selectCurrentCell(true); + } + } else { + if (this.isEditing) { + this.selectCurrentCell(false); + } + } + }) + ); + }) + .catch(console.error); + } + + override renderBlockText() { + return html` <rich-text + .yText="${this.value}" + .inlineEventSource="${this.topContenteditableElement}" + .attributesSchema="${this.attributesSchema}" + .attributeRenderer="${this.attributeRenderer}" + .embedChecker="${this.inlineManager?.embedChecker}" + .markdownShortcutHandler="${this.inlineManager?.markdownShortcutHandler}" + .readonly="${this.readonly}" + .enableClipboard="${false}" + .verticalScrollContainerGetter="${() => + this.topContenteditableElement?.host + ? getViewportElement(this.topContenteditableElement.host) + : null}" + class="data-view-header-area-rich-text can-link-doc" + ></rich-text>`; + } + + override renderLinkedDoc(): TemplateResult { + return html` <rich-text + .yText="${this.linkedDocTitle$.value}" + .inlineEventSource="${this.topContenteditableElement}" + .readonly="${this.readonly}" + .enableClipboard="${true}" + .verticalScrollContainerGetter="${() => + this.topContenteditableElement?.host + ? getViewportElement(this.topContenteditableElement.host) + : null}" + class="data-view-header-area-rich-text" + ></rich-text>`; + } +} + +declare global { + interface HTMLElementTagNameMap { + 'data-view-header-area-text': HeaderAreaTextCell; + 'data-view-header-area-text-editing': HeaderAreaTextCellEditing; + } +} diff --git a/blocksuite/blocks/src/database-block/properties/utils.ts b/blocksuite/blocks/src/database-block/properties/utils.ts new file mode 100644 index 0000000000..851c5eb61a --- /dev/null +++ b/blocksuite/blocks/src/database-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/blocksuite/blocks/src/database-block/utils/block-utils.ts b/blocksuite/blocks/src/database-block/utils/block-utils.ts new file mode 100644 index 0000000000..d332c8bbab --- /dev/null +++ b/blocksuite/blocks/src/database-block/utils/block-utils.ts @@ -0,0 +1,247 @@ +import type { + Cell, + Column, + ColumnUpdater, + DatabaseBlockModel, + ViewBasicDataType, +} from '@blocksuite/affine-model'; +import { + arrayMove, + insertPositionToIndex, + type InsertToPosition, +} from '@blocksuite/affine-shared/utils'; +import type { BlockModel } from '@blocksuite/store'; + +export function addProperty( + model: DatabaseBlockModel, + position: InsertToPosition, + column: Omit<Column, 'id'> & { + id?: string; + } +): string { + const id = column.id ?? model.doc.generateBlockId(); + if (model.columns.some(v => v.id === id)) { + return id; + } + model.doc.transact(() => { + const col: Column = { + ...column, + id, + }; + model.columns.splice( + insertPositionToIndex(position, model.columns), + 0, + col + ); + }); + return id; +} + +export function applyCellsUpdate(model: DatabaseBlockModel) { + model.doc.updateBlock(model, { + cells: model.cells, + }); +} + +export function applyPropertyUpdate(model: DatabaseBlockModel) { + model.doc.updateBlock(model, { + columns: model.columns, + }); +} + +export function applyViewsUpdate(model: DatabaseBlockModel) { + model.doc.updateBlock(model, { + views: model.views, + }); +} + +export function copyCellsByProperty( + model: DatabaseBlockModel, + 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: DatabaseBlockModel, + columnId: Column['id'] +) { + const index = findPropertyIndex(model, columnId); + if (index < 0) return; + + model.doc.transact(() => { + model.columns.splice(index, 1); + }); +} + +export function deleteRows(model: DatabaseBlockModel, rowIds: string[]) { + model.doc.transact(() => { + for (const rowId of rowIds) { + delete model.cells[rowId]; + } + }); +} + +export function deleteView(model: DatabaseBlockModel, id: string) { + model.doc.captureSync(); + model.doc.transact(() => { + model.views = model.views.filter(v => v.id !== id); + }); +} + +export function duplicateView(model: DatabaseBlockModel, 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: DatabaseBlockModel, id: Column['id']) { + return model.columns.findIndex(v => v.id === id); +} + +export function getCell( + model: DatabaseBlockModel, + rowId: BlockModel['id'], + columnId: Column['id'] +): Cell | null { + if (columnId === 'title') { + return { + columnId: 'title', + value: rowId, + }; + } + const yRow = model.cells$.value[rowId]; + const yCell = yRow?.[columnId] ?? null; + if (!yCell) return null; + + return { + columnId: yCell.columnId, + value: yCell.value, + }; +} + +export function getProperty( + model: DatabaseBlockModel, + id: Column['id'] +): Column | undefined { + return model.columns.find(v => v.id === id); +} + +export function moveViewTo( + model: DatabaseBlockModel, + 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: DatabaseBlockModel, + rowId: string, + cell: Cell +) { + if ( + rowId === '__proto__' || + rowId === 'constructor' || + rowId === 'prototype' + ) { + throw new Error('Invalid rowId'); + } + const hasRow = rowId in model.cells; + if (!hasRow) { + model.cells[rowId] = Object.create(null); + } + model.doc.transact(() => { + model.cells[rowId][cell.columnId] = { + columnId: cell.columnId, + value: cell.value, + }; + }); +} + +export function updateCells( + model: DatabaseBlockModel, + columnId: string, + cells: Record<string, unknown> +) { + model.doc.transact(() => { + Object.entries(cells).forEach(([rowId, value]) => { + if ( + rowId === '__proto__' || + rowId === 'constructor' || + rowId === 'prototype' + ) { + throw new Error('Invalid rowId'); + } + if (!model.cells[rowId]) { + model.cells[rowId] = Object.create(null); + } + model.cells[rowId][columnId] = { + columnId, + value, + }; + }); + }); +} + +export function updateProperty( + model: DatabaseBlockModel, + 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 = <ViewData extends ViewBasicDataType>( + model: DatabaseBlockModel, + id: string, + update: (data: ViewData) => Partial<ViewData> +) => { + 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 DATABASE_CONVERT_WHITE_LIST = ['affine:list', 'affine:paragraph']; diff --git a/blocksuite/blocks/src/database-block/utils/current-view.ts b/blocksuite/blocks/src/database-block/utils/current-view.ts new file mode 100644 index 0000000000..c1316215f6 --- /dev/null +++ b/blocksuite/blocks/src/database-block/utils/current-view.ts @@ -0,0 +1,52 @@ +import { z } from 'zod'; + +const currentViewListSchema = z.array( + z.object({ + blockId: z.string(), + viewId: z.string(), + }) +); +const maxLength = 20; +const currentViewListKey = 'blocksuite:databaseBlock:view:currentViewList'; +const storage = globalThis.sessionStorage; +const createCurrentViewStorage = () => { + const getList = () => { + const string = storage?.getItem(currentViewListKey); + if (!string) { + return; + } + try { + const result = currentViewListSchema.safeParse(JSON.parse(string)); + if (result.success) { + return result.data; + } + } catch { + // do nothing + } + return; + }; + const saveList = () => { + storage.setItem(currentViewListKey, JSON.stringify(list)); + }; + + const list = getList() ?? []; + + return { + getCurrentView: (blockId: string) => { + return list.find(item => item.blockId === blockId)?.viewId; + }, + setCurrentView: (blockId: string, viewId: string) => { + const configIndex = list.findIndex(item => item.blockId === blockId); + if (configIndex >= 0) { + list.splice(configIndex, 1); + } + if (list.length >= maxLength) { + list.pop(); + } + list.unshift({ blockId, viewId }); + saveList(); + }, + }; +}; + +export const currentViewStorage = createCurrentViewStorage(); diff --git a/blocksuite/blocks/src/database-block/utils/title-doc.ts b/blocksuite/blocks/src/database-block/utils/title-doc.ts new file mode 100644 index 0000000000..4fff4e83eb --- /dev/null +++ b/blocksuite/blocks/src/database-block/utils/title-doc.ts @@ -0,0 +1,30 @@ +import type { AffineTextAttributes } from '@blocksuite/affine-shared/types'; +import type { DeltaOperation, Text } from '@blocksuite/store'; + +export const getSingleDocIdFromText = (text?: Text) => { + const deltas = text?.deltas$.value; + if (!deltas) return; + let linkedDocId: string | undefined = undefined; + for (const delta of deltas) { + if (isLinkedDoc(delta)) { + if (linkedDocId) { + return; + } + linkedDocId = delta.attributes?.reference?.pageId as string; + } else if (delta.insert) { + return; + } + } + return linkedDocId; +}; + +export const isLinkedDoc = (delta: DeltaOperation) => { + const attributes: AffineTextAttributes | undefined = delta.attributes; + return attributes?.reference?.type === 'LinkedPage'; +}; + +export const isPureText = (text?: Text): boolean => { + const deltas = text?.deltas$.value; + if (!deltas) return true; + return deltas.every(v => !isLinkedDoc(v)); +}; diff --git a/blocksuite/blocks/src/database-block/views/index.ts b/blocksuite/blocks/src/database-block/views/index.ts new file mode 100644 index 0000000000..21b24fa95e --- /dev/null +++ b/blocksuite/blocks/src/database-block/views/index.ts @@ -0,0 +1,12 @@ +import type { ViewMeta } from '@blocksuite/data-view'; +import { viewConverts, viewPresets } from '@blocksuite/data-view/view-presets'; + +export const databaseBlockViews: ViewMeta[] = [ + viewPresets.tableViewMeta, + viewPresets.kanbanViewMeta, +]; + +export const databaseBlockViewMap = Object.fromEntries( + databaseBlockViews.map(view => [view.type, view]) +); +export const databaseBlockViewConverts = [...viewConverts]; diff --git a/blocksuite/blocks/src/database-block/widgets/index.ts b/blocksuite/blocks/src/database-block/widgets/index.ts new file mode 100644 index 0000000000..75e9c52b6c --- /dev/null +++ b/blocksuite/blocks/src/database-block/widgets/index.ts @@ -0,0 +1 @@ +export const commonTools = []; diff --git a/blocksuite/blocks/src/divider-block/adapters/html.ts b/blocksuite/blocks/src/divider-block/adapters/html.ts new file mode 100644 index 0000000000..1ecc36cd5f --- /dev/null +++ b/blocksuite/blocks/src/divider-block/adapters/html.ts @@ -0,0 +1,53 @@ +import { DividerBlockSchema } from '@blocksuite/affine-model'; +import { + BlockHtmlAdapterExtension, + type BlockHtmlAdapterMatcher, + HastUtils, +} from '@blocksuite/affine-shared/adapters'; +import { nanoid } from '@blocksuite/store'; + +export const dividerBlockHtmlAdapterMatcher: BlockHtmlAdapterMatcher = { + flavour: DividerBlockSchema.model.flavour, + toMatch: o => HastUtils.isElement(o.node) && o.node.tagName === 'hr', + fromMatch: o => o.node.flavour === DividerBlockSchema.model.flavour, + toBlockSnapshot: { + enter: (o, context) => { + if (!HastUtils.isElement(o.node)) { + return; + } + const { walkerContext } = context; + walkerContext + .openNode( + { + type: 'block', + id: nanoid(), + flavour: 'affine:divider', + props: {}, + children: [], + }, + 'children' + ) + .closeNode(); + }, + }, + fromBlockSnapshot: { + enter: (_, context) => { + const { walkerContext } = context; + walkerContext + .openNode( + { + type: 'element', + tagName: 'hr', + properties: {}, + children: [], + }, + 'children' + ) + .closeNode(); + }, + }, +}; + +export const DividerBlockHtmlAdapterExtension = BlockHtmlAdapterExtension( + dividerBlockHtmlAdapterMatcher +); diff --git a/blocksuite/blocks/src/divider-block/adapters/index.ts b/blocksuite/blocks/src/divider-block/adapters/index.ts new file mode 100644 index 0000000000..b4dd5a6d2a --- /dev/null +++ b/blocksuite/blocks/src/divider-block/adapters/index.ts @@ -0,0 +1,4 @@ +export * from './html.js'; +export * from './markdown.js'; +export * from './notion-html.js'; +export * from './plain-text.js'; diff --git a/blocksuite/blocks/src/divider-block/adapters/markdown.ts b/blocksuite/blocks/src/divider-block/adapters/markdown.ts new file mode 100644 index 0000000000..93d2faa840 --- /dev/null +++ b/blocksuite/blocks/src/divider-block/adapters/markdown.ts @@ -0,0 +1,50 @@ +import { DividerBlockSchema } from '@blocksuite/affine-model'; +import { + BlockMarkdownAdapterExtension, + type BlockMarkdownAdapterMatcher, + type MarkdownAST, +} from '@blocksuite/affine-shared/adapters'; +import { nanoid } from '@blocksuite/store'; +import type { ThematicBreak } from 'mdast'; + +const isDividerNode = (node: MarkdownAST): node is ThematicBreak => + node.type === 'thematicBreak'; + +export const dividerBlockMarkdownAdapterMatcher: BlockMarkdownAdapterMatcher = { + flavour: DividerBlockSchema.model.flavour, + toMatch: o => isDividerNode(o.node), + fromMatch: o => o.node.flavour === DividerBlockSchema.model.flavour, + toBlockSnapshot: { + enter: (_, context) => { + const { walkerContext } = context; + walkerContext + .openNode( + { + type: 'block', + id: nanoid(), + flavour: 'affine:divider', + props: {}, + children: [], + }, + 'children' + ) + .closeNode(); + }, + }, + fromBlockSnapshot: { + enter: (_, context) => { + const { walkerContext } = context; + walkerContext + .openNode( + { + type: 'thematicBreak', + }, + 'children' + ) + .closeNode(); + }, + }, +}; + +export const DividerBlockMarkdownAdapterExtension = + BlockMarkdownAdapterExtension(dividerBlockMarkdownAdapterMatcher); diff --git a/blocksuite/blocks/src/divider-block/adapters/notion-html.ts b/blocksuite/blocks/src/divider-block/adapters/notion-html.ts new file mode 100644 index 0000000000..ba0c077ee3 --- /dev/null +++ b/blocksuite/blocks/src/divider-block/adapters/notion-html.ts @@ -0,0 +1,38 @@ +import { DividerBlockSchema } from '@blocksuite/affine-model'; +import { + BlockNotionHtmlAdapterExtension, + type BlockNotionHtmlAdapterMatcher, + HastUtils, +} from '@blocksuite/affine-shared/adapters'; +import { nanoid } from '@blocksuite/store'; + +export const dividerBlockNotionHtmlAdapterMatcher: BlockNotionHtmlAdapterMatcher = + { + flavour: DividerBlockSchema.model.flavour, + toMatch: o => HastUtils.isElement(o.node) && o.node.tagName === 'hr', + fromMatch: () => false, + toBlockSnapshot: { + enter: (o, context) => { + if (!HastUtils.isElement(o.node)) { + return; + } + const { walkerContext } = context; + walkerContext + .openNode( + { + type: 'block', + id: nanoid(), + flavour: DividerBlockSchema.model.flavour, + props: {}, + children: [], + }, + 'children' + ) + .closeNode(); + }, + }, + fromBlockSnapshot: {}, + }; + +export const DividerBlockNotionHtmlAdapterExtension = + BlockNotionHtmlAdapterExtension(dividerBlockNotionHtmlAdapterMatcher); diff --git a/blocksuite/blocks/src/divider-block/adapters/plain-text.ts b/blocksuite/blocks/src/divider-block/adapters/plain-text.ts new file mode 100644 index 0000000000..5571157213 --- /dev/null +++ b/blocksuite/blocks/src/divider-block/adapters/plain-text.ts @@ -0,0 +1,21 @@ +import { DividerBlockSchema } from '@blocksuite/affine-model'; +import { + BlockPlainTextAdapterExtension, + type BlockPlainTextAdapterMatcher, +} from '@blocksuite/affine-shared/adapters'; + +export const dividerBlockPlainTextAdapterMatcher: BlockPlainTextAdapterMatcher = + { + flavour: DividerBlockSchema.model.flavour, + toMatch: () => false, + fromMatch: o => o.node.flavour === DividerBlockSchema.model.flavour, + toBlockSnapshot: {}, + fromBlockSnapshot: { + enter: (_, context) => { + context.textBuffer.content += '---\n'; + }, + }, + }; + +export const DividerBlockPlainTextAdapterExtension = + BlockPlainTextAdapterExtension(dividerBlockPlainTextAdapterMatcher); diff --git a/blocksuite/blocks/src/divider-block/divider-block.ts b/blocksuite/blocks/src/divider-block/divider-block.ts new file mode 100644 index 0000000000..1f922397d6 --- /dev/null +++ b/blocksuite/blocks/src/divider-block/divider-block.ts @@ -0,0 +1,49 @@ +import { CaptionedBlockComponent } from '@blocksuite/affine-components/caption'; +import type { DividerBlockModel } from '@blocksuite/affine-model'; +import { html } from 'lit'; + +import { BLOCK_CHILDREN_CONTAINER_PADDING_LEFT } from '../_common/consts.js'; +import { dividerBlockStyles } from './styles.js'; + +export class DividerBlockComponent extends CaptionedBlockComponent<DividerBlockModel> { + static override styles = dividerBlockStyles; + + override connectedCallback() { + super.connectedCallback(); + + this.contentEditable = 'false'; + + this.handleEvent('click', () => { + this.host.selection.setGroup('note', [ + this.host.selection.create('block', { + blockId: this.blockId, + }), + ]); + }); + } + + override renderBlock() { + const children = html`<div + class="affine-block-children-container" + style="padding-left: ${BLOCK_CHILDREN_CONTAINER_PADDING_LEFT}px" + > + ${this.renderChildren(this.model)} + </div>`; + + return html` + <div class="affine-divider-block-container"> + <hr /> + + ${children} + </div> + `; + } + + override accessor useZeroWidth = true; +} + +declare global { + interface HTMLElementTagNameMap { + 'affine-divider': DividerBlockComponent; + } +} diff --git a/blocksuite/blocks/src/divider-block/divider-spec.ts b/blocksuite/blocks/src/divider-block/divider-spec.ts new file mode 100644 index 0000000000..fa6d715eca --- /dev/null +++ b/blocksuite/blocks/src/divider-block/divider-spec.ts @@ -0,0 +1,6 @@ +import { BlockViewExtension, type ExtensionType } from '@blocksuite/block-std'; +import { literal } from 'lit/static-html.js'; + +export const DividerBlockSpec: ExtensionType[] = [ + BlockViewExtension('affine:divider', literal`affine-divider`), +]; diff --git a/blocksuite/blocks/src/divider-block/index.ts b/blocksuite/blocks/src/divider-block/index.ts new file mode 100644 index 0000000000..1b81b4051d --- /dev/null +++ b/blocksuite/blocks/src/divider-block/index.ts @@ -0,0 +1,2 @@ +export * from './adapters/markdown.js'; +export * from './divider-block.js'; diff --git a/blocksuite/blocks/src/divider-block/styles.ts b/blocksuite/blocks/src/divider-block/styles.ts new file mode 100644 index 0000000000..2ab768566a --- /dev/null +++ b/blocksuite/blocks/src/divider-block/styles.ts @@ -0,0 +1,19 @@ +import { css } from 'lit'; + +export const dividerBlockStyles = css` + .affine-divider-block-container { + position: relative; + width: 100%; + height: 1px; + display: flex; + flex-direction: column; + justify-content: center; + padding: 18px 8px; + margin-top: var(--affine-paragraph-space); + } + .affine-divider-block-container hr { + border: none; + border-top: 1px solid var(--affine-divider-color); + width: 100%; + } +`; diff --git a/blocksuite/blocks/src/edgeless-text-block/commands/index.ts b/blocksuite/blocks/src/edgeless-text-block/commands/index.ts new file mode 100644 index 0000000000..b8ea759323 --- /dev/null +++ b/blocksuite/blocks/src/edgeless-text-block/commands/index.ts @@ -0,0 +1,7 @@ +import type { BlockCommands } from '@blocksuite/block-std'; + +import { insertEdgelessTextCommand } from './insert-edgeless-text.js'; + +export const commands: BlockCommands = { + insertEdgelessText: insertEdgelessTextCommand, +}; diff --git a/blocksuite/blocks/src/edgeless-text-block/commands/insert-edgeless-text.ts b/blocksuite/blocks/src/edgeless-text-block/commands/insert-edgeless-text.ts new file mode 100644 index 0000000000..cf74080274 --- /dev/null +++ b/blocksuite/blocks/src/edgeless-text-block/commands/insert-edgeless-text.ts @@ -0,0 +1,107 @@ +import { focusTextModel } from '@blocksuite/affine-components/rich-text'; +import type { Command } from '@blocksuite/block-std'; +import { Bound } from '@blocksuite/global/utils'; + +import { EdgelessRootService } from '../../root-block/edgeless/edgeless-root-service.js'; +import { getSurfaceBlock } from '../../surface-ref-block/utils.js'; +import { + EDGELESS_TEXT_BLOCK_MIN_HEIGHT, + EDGELESS_TEXT_BLOCK_MIN_WIDTH, + EdgelessTextBlockComponent, +} from '../edgeless-text-block.js'; + +export const insertEdgelessTextCommand: Command< + never, + 'textId', + { + x: number; + y: number; + } +> = (ctx, next) => { + const { std, x, y } = ctx; + const host = std.host; + const doc = host.doc; + const edgelessService = std.getService('affine:page'); + const surface = getSurfaceBlock(doc); + if (!(edgelessService instanceof EdgelessRootService) || !surface) { + next(); + return; + } + + const zoom = edgelessService.zoom; + const textId = edgelessService.addBlock( + 'affine:edgeless-text', + { + xywh: new Bound( + x - (EDGELESS_TEXT_BLOCK_MIN_WIDTH * zoom) / 2, + y - (EDGELESS_TEXT_BLOCK_MIN_HEIGHT * zoom) / 2, + EDGELESS_TEXT_BLOCK_MIN_WIDTH * zoom, + EDGELESS_TEXT_BLOCK_MIN_HEIGHT * zoom + ).serialize(), + }, + surface.id + ); + + const blockId = doc.addBlock('affine:paragraph', { type: 'text' }, textId); + host.updateComplete + .then(() => { + edgelessService.selection.set({ + elements: [textId], + editing: true, + }); + const disposable = edgelessService.selection.slots.updated.on(() => { + const editing = edgelessService.selection.editing; + const id = edgelessService.selection.selectedIds[0]; + if (!editing || id !== textId) { + const textBlock = host.view.getBlock(textId); + if (textBlock instanceof EdgelessTextBlockComponent) { + textBlock.model.hasMaxWidth = true; + } + + disposable.dispose(); + } + }); + + focusTextModel(std, blockId); + host.updateComplete + .then(() => { + const edgelessText = host.view.getBlock(textId); + const paragraph = host.view.getBlock(blockId); + if (!edgelessText || !paragraph) return; + + const abortController = new AbortController(); + edgelessText.addEventListener( + 'focusout', + e => { + if (edgelessText.model.children.length > 1) return; + if ( + !paragraph.model.text || + (paragraph.model.text.length === 0 && e.relatedTarget !== null) + ) { + doc.deleteBlock(edgelessText.model); + } + }, + { + once: true, + signal: abortController.signal, + } + ); + paragraph.model.deleted.once(() => { + abortController.abort(); + }); + edgelessText.addEventListener( + 'beforeinput', + () => { + abortController.abort(); + }, + { + once: true, + } + ); + }) + .catch(console.error); + }) + .catch(console.error); + + next({ textId }); +}; diff --git a/blocksuite/blocks/src/edgeless-text-block/edgeless-text-block.ts b/blocksuite/blocks/src/edgeless-text-block/edgeless-text-block.ts new file mode 100644 index 0000000000..ed041542eb --- /dev/null +++ b/blocksuite/blocks/src/edgeless-text-block/edgeless-text-block.ts @@ -0,0 +1,347 @@ +import { TextUtils } from '@blocksuite/affine-block-surface'; +import type { EdgelessTextBlockModel } from '@blocksuite/affine-model'; +import { ThemeProvider } from '@blocksuite/affine-shared/services'; +import { matchFlavours } from '@blocksuite/affine-shared/utils'; +import type { BlockComponent } from '@blocksuite/block-std'; +import { GfxBlockComponent } from '@blocksuite/block-std'; +import { Bound } from '@blocksuite/global/utils'; +import { css, html } from 'lit'; +import { query, state } from 'lit/decorators.js'; +import { type StyleInfo, styleMap } from 'lit/directives/style-map.js'; + +import type { EdgelessRootService } from '../root-block/index.js'; + +export const EDGELESS_TEXT_BLOCK_MIN_WIDTH = 50; +export const EDGELESS_TEXT_BLOCK_MIN_HEIGHT = 50; + +export class EdgelessTextBlockComponent extends GfxBlockComponent<EdgelessTextBlockModel> { + static override styles = css` + .edgeless-text-block-container[data-max-width='false'] .inline-editor span { + word-break: keep-all !important; + text-wrap: nowrap !important; + } + + .edgeless-text-block-container affine-paragraph, + affine-list { + color: var(--edgeless-text-color); + font-family: var(--edgeless-text-font-family); + font-style: var(--edgeless-text-font-style); + font-weight: var(--edgeless-text-font-weight); + text-align: var(--edgeless-text-text-align); + } + `; + + private _resizeObserver = new ResizeObserver(() => { + if (this.doc.readonly) { + return; + } + + if (!this.model.hasMaxWidth) { + this._updateW(); + } + + this._updateH(); + }); + + get rootService() { + return this.std.getService('affine:page') as EdgelessRootService; + } + + private _updateH() { + const bound = Bound.deserialize(this.model.xywh); + const rect = this._textContainer.getBoundingClientRect(); + bound.h = rect.height / this.gfx.viewport.zoom; + + this.doc.updateBlock(this.model, { + xywh: bound.serialize(), + }); + } + + private _updateW() { + const bound = Bound.deserialize(this.model.xywh); + const rect = this._textContainer.getBoundingClientRect(); + bound.w = Math.max( + rect.width / this.gfx.viewport.zoom, + EDGELESS_TEXT_BLOCK_MIN_WIDTH * this.gfx.viewport.zoom + ); + + this.doc.updateBlock(this.model, { + xywh: bound.serialize(), + }); + } + + checkWidthOverflow(width: number) { + let wValid = true; + + const oldWidthStr = this._textContainer.style.width; + this._textContainer.style.width = `${width}px`; + if ( + this.childrenContainer.scrollWidth > this.childrenContainer.offsetWidth + ) { + wValid = false; + } + this._textContainer.style.width = oldWidthStr; + + return wValid; + } + + override connectedCallback() { + super.connectedCallback(); + + this.disposables.add( + this.model.propsUpdated.on(({ key }) => { + this.updateComplete + .then(() => { + if (!this.host) return; + + const command = this.host.command; + const blockSelections = this.model.children.map(child => + this.host.selection.create('block', { + blockId: child.id, + }) + ); + + if (key === 'fontStyle') { + command.exec('formatBlock', { + blockSelections, + styles: { + italic: null, + }, + }); + } else if (key === 'color') { + command.exec('formatBlock', { + blockSelections, + styles: { + color: null, + }, + }); + } else if (key === 'fontWeight') { + command.exec('formatBlock', { + blockSelections, + styles: { + bold: null, + }, + }); + } + }) + .catch(console.error); + }) + ); + } + + override firstUpdated(props: Map<string, unknown>) { + super.firstUpdated(props); + + const { disposables, rootService } = this; + const edgelessSelection = rootService.selection; + + disposables.add( + edgelessSelection.slots.updated.on(() => { + if (edgelessSelection.has(this.model.id) && edgelessSelection.editing) { + this._editing = true; + } else { + this._editing = false; + } + }) + ); + + this._resizeObserver.observe(this._textContainer); + disposables.add(() => { + this._resizeObserver.disconnect(); + }); + + disposables.addFromEvent(this._textContainer, 'click', e => { + if (!this._editing) return; + + const containerRect = this._textContainer.getBoundingClientRect(); + const isTop = e.clientY < containerRect.top + containerRect.height / 2; + + let newParagraphId: string | null = null; + if (isTop) { + const firstChild = this.model.firstChild(); + if ( + !firstChild || + !matchFlavours(firstChild, ['affine:list', 'affine:paragraph']) + ) { + newParagraphId = this.doc.addBlock( + 'affine:paragraph', + {}, + this.model.id, + 0 + ); + } + } else { + const lastChild = this.model.lastChild(); + if ( + !lastChild || + !matchFlavours(lastChild, ['affine:list', 'affine:paragraph']) + ) { + newParagraphId = this.doc.addBlock( + 'affine:paragraph', + {}, + this.model.id + ); + } + } + + if (newParagraphId) { + this.rootService.selectionManager.setGroup('note', [ + this.rootService.selectionManager.create('text', { + from: { + blockId: newParagraphId, + index: 0, + length: 0, + }, + to: null, + }), + ]); + } + }); + + disposables.addFromEvent(this._textContainer, 'focusout', () => { + if (!this._editing) return; + + this.rootService.selectionManager.clear(); + }); + + let composingWidth = EDGELESS_TEXT_BLOCK_MIN_WIDTH; + disposables.addFromEvent(this, 'compositionupdate', () => { + composingWidth = Math.max( + this._textContainer.offsetWidth, + EDGELESS_TEXT_BLOCK_MIN_HEIGHT + ); + }); + disposables.addFromEvent(this, 'compositionend', () => { + if (this.model.hasMaxWidth) { + composingWidth = EDGELESS_TEXT_BLOCK_MIN_WIDTH; + return; + } + // when IME finish container will crash to a small width, so + // we set a max width to prevent this + this._textContainer.style.width = `${composingWidth}px`; + this.model.hasMaxWidth = true; + requestAnimationFrame(() => { + this._textContainer.style.width = ''; + }); + }); + } + + override getCSSTransform(): string { + const viewport = this.gfx.viewport; + const { translateX, translateY, zoom } = viewport; + const bound = Bound.deserialize(this.model.xywh); + + const scaledX = bound.x * zoom; + const scaledY = bound.y * zoom; + const deltaX = scaledX - bound.x; + const deltaY = scaledY - bound.y; + + return `translate(${translateX + deltaX}px, ${translateY + deltaY}px) scale(${zoom * this.model.scale})`; + } + + override getRenderingRect() { + const { xywh, scale, rotate, hasMaxWidth } = this.model; + const bound = Bound.deserialize(xywh); + const w = hasMaxWidth ? bound.w / scale : undefined; + + return { + x: bound.x, + y: bound.y, + w, + h: bound.h / scale, + rotate, + zIndex: this.toZIndex(), + }; + } + + override renderGfxBlock() { + const { model } = this; + const { rotate, hasMaxWidth } = model; + const editing = this._editing; + const containerStyle: StyleInfo = { + transform: `rotate(${rotate}deg)`, + transformOrigin: 'center', + border: `1px solid ${editing ? 'var(--affine—primary—color, #1e96eb)' : 'transparent'}`, + borderRadius: '4px', + boxSizing: 'border-box', + boxShadow: editing ? '0px 0px 0px 2px rgba(30, 150, 235, 0.3)' : 'none', + fontWeight: '400', + lineHeight: 'var(--affine-line-height)', + }; + + return html` + <div + class="edgeless-text-block-container" + data-max-width="${hasMaxWidth}" + style=${styleMap(containerStyle)} + > + <div + style=${styleMap({ + pointerEvents: editing ? 'auto' : 'none', + userSelect: editing ? 'auto' : 'none', + })} + contenteditable=${editing} + > + ${this.renderPageContent()} + </div> + </div> + `; + } + + override renderPageContent() { + const { fontFamily, fontStyle, fontWeight, textAlign } = this.model; + const color = this.std + .get(ThemeProvider) + .generateColorProperty(this.model.color, '#000000'); + + const style = styleMap({ + '--edgeless-text-color': color, + '--edgeless-text-font-family': TextUtils.wrapFontFamily(fontFamily), + '--edgeless-text-font-style': fontStyle, + '--edgeless-text-font-weight': fontWeight, + '--edgeless-text-text-align': textAlign, + '--affine-list-margin': '0', + '--affine-paragraph-margin': '0', + }); + + return html` + <div style=${style} class="affine-block-children-container"> + ${this.renderChildren(this.model)} + </div> + `; + } + + tryFocusEnd() { + const paragraphOrLists = Array.from( + this.querySelectorAll<BlockComponent>('affine-paragraph, affine-list') + ); + const last = paragraphOrLists.at(-1); + if (last) { + this.host.selection.setGroup('note', [ + this.host.selection.create('text', { + from: { + blockId: last.blockId, + index: last.model.text?.length ?? 0, + length: 0, + }, + to: null, + }), + ]); + } + } + + @state() + private accessor _editing = false; + + @query('.edgeless-text-block-container') + private accessor _textContainer!: HTMLDivElement; + + @query('.affine-block-children-container') + accessor childrenContainer!: HTMLDivElement; +} + +declare global { + interface HTMLElementTagNameMap { + 'affine-edgeless-text': EdgelessTextBlockComponent; + } +} diff --git a/blocksuite/blocks/src/edgeless-text-block/edgeless-text-spec.ts b/blocksuite/blocks/src/edgeless-text-block/edgeless-text-spec.ts new file mode 100644 index 0000000000..71b2e590f6 --- /dev/null +++ b/blocksuite/blocks/src/edgeless-text-block/edgeless-text-spec.ts @@ -0,0 +1,13 @@ +import { + BlockViewExtension, + CommandExtension, + type ExtensionType, +} from '@blocksuite/block-std'; +import { literal } from 'lit/static-html.js'; + +import { commands } from './commands/index.js'; + +export const EdgelessTextBlockSpec: ExtensionType[] = [ + CommandExtension(commands), + BlockViewExtension('affine:edgeless-text', literal`affine-edgeless-text`), +]; diff --git a/blocksuite/blocks/src/edgeless-text-block/index.ts b/blocksuite/blocks/src/edgeless-text-block/index.ts new file mode 100644 index 0000000000..8a2d31ee97 --- /dev/null +++ b/blocksuite/blocks/src/edgeless-text-block/index.ts @@ -0,0 +1,2 @@ +export * from './edgeless-text-block.js'; +export * from './edgeless-text-spec.js'; diff --git a/blocksuite/blocks/src/effects.ts b/blocksuite/blocks/src/effects.ts new file mode 100644 index 0000000000..14fe25309a --- /dev/null +++ b/blocksuite/blocks/src/effects.ts @@ -0,0 +1,629 @@ +import { effects as blockEmbedEffects } from '@blocksuite/affine-block-embed/effects'; +import { effects as blockListEffects } from '@blocksuite/affine-block-list/effects'; +import { effects as blockParagraphEffects } from '@blocksuite/affine-block-paragraph/effects'; +import { effects as blockSurfaceEffects } from '@blocksuite/affine-block-surface/effects'; +import { effects as componentCaptionEffects } from '@blocksuite/affine-components/caption'; +import { effects as componentContextMenuEffects } from '@blocksuite/affine-components/context-menu'; +import { effects as componentDatePickerEffects } from '@blocksuite/affine-components/date-picker'; +import { effects as componentDragIndicatorEffects } from '@blocksuite/affine-components/drag-indicator'; +import { effects as componentPortalEffects } from '@blocksuite/affine-components/portal'; +import { effects as componentRichTextEffects } from '@blocksuite/affine-components/rich-text'; +import { effects as componentToggleButtonEffects } from '@blocksuite/affine-components/toggle-button'; +import { effects as componentToolbarEffects } from '@blocksuite/affine-components/toolbar'; +import { effects as widgetScrollAnchoringEffects } from '@blocksuite/affine-widget-scroll-anchoring/effects'; +import type { BlockComponent } from '@blocksuite/block-std'; +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 type { BlockModel } from '@blocksuite/store'; + +import { AIItem } from './_common/components/ai-item/ai-item.js'; +import { AISubItemList } from './_common/components/ai-item/ai-sub-item-list.js'; +import { IconButton } from './_common/components/button.js'; +import { EmbedCardMoreMenu } from './_common/components/embed-card/embed-card-more-menu-popper.js'; +import { EmbedCardStyleMenu } from './_common/components/embed-card/embed-card-style-popper.js'; +import { EmbedCardEditCaptionEditModal } from './_common/components/embed-card/modal/embed-card-caption-edit-modal.js'; +import { EmbedCardCreateModal } from './_common/components/embed-card/modal/embed-card-create-modal.js'; +import { EmbedCardEditModal } from './_common/components/embed-card/modal/embed-card-edit-modal.js'; +import { FilterableListComponent } from './_common/components/filterable-list/index.js'; +import { + AIItemList, + BlockSelection, + BlockZeroWidth, + MenuDivider, +} from './_common/components/index.js'; +import { Loader } from './_common/components/loader.js'; +import { SmoothCorner } from './_common/components/smooth-corner.js'; +import { ToggleSwitch } from './_common/components/toggle-switch.js'; +import { registerSpecs } from './_specs/register-specs.js'; +import { AttachmentEdgelessBlockComponent } from './attachment-block/attachment-edgeless-block.js'; +import { + AttachmentBlockComponent, + type AttachmentBlockService, +} from './attachment-block/index.js'; +import { BookmarkEdgelessBlockComponent } from './bookmark-block/bookmark-edgeless-block.js'; +import type { insertBookmarkCommand } from './bookmark-block/commands/insert-bookmark.js'; +import { BookmarkCard } from './bookmark-block/components/bookmark-card.js'; +import { + BookmarkBlockComponent, + type BookmarkBlockService, +} from './bookmark-block/index.js'; +import { AffineCodeUnit } from './code-block/highlight/affine-code-unit.js'; +import { + CodeBlockComponent, + type CodeBlockConfig, +} from './code-block/index.js'; +import { DataViewBlockComponent } from './data-view-block/index.js'; +import { CenterPeek } from './database-block/components/layout.js'; +import { DatabaseTitle } from './database-block/components/title/index.js'; +import { BlockRenderer } from './database-block/detail-panel/block-renderer.js'; +import { NoteRenderer } from './database-block/detail-panel/note-renderer.js'; +import { effects as blockDatabaseEffects } from './database-block/effects.js'; +import { + DatabaseBlockComponent, + type DatabaseBlockService, +} from './database-block/index.js'; +import { + LinkCell, + LinkCellEditing, +} from './database-block/properties/link/cell-renderer.js'; +import { LinkNode } from './database-block/properties/link/components/link-node.js'; +import { + RichTextCell, + RichTextCellEditing, +} from './database-block/properties/rich-text/cell-renderer.js'; +import { IconCell } from './database-block/properties/title/icon.js'; +import { + HeaderAreaTextCell, + HeaderAreaTextCellEditing, +} from './database-block/properties/title/text.js'; +import { DividerBlockComponent } from './divider-block/index.js'; +import type { insertEdgelessTextCommand } from './edgeless-text-block/commands/insert-edgeless-text.js'; +import { EdgelessTextBlockComponent } from './edgeless-text-block/index.js'; +import { FrameBlockComponent } from './frame-block/index.js'; +import { ImageBlockFallbackCard } from './image-block/components/image-block-fallback.js'; +import { ImageBlockPageComponent } from './image-block/components/page-image-block.js'; +import { effects as blockImageEffects } from './image-block/effects.js'; +import { + ImageBlockComponent, + type ImageBlockService, + ImageEdgelessBlockComponent, +} from './image-block/index.js'; +import { effects as blockLatexEffects } from './latex-block/effects.js'; +import { LatexBlockComponent } from './latex-block/index.js'; +import type { updateBlockType } from './note-block/commands/block-type.js'; +import type { dedentBlock } from './note-block/commands/dedent-block.js'; +import type { dedentBlockToRoot } from './note-block/commands/dedent-block-to-root.js'; +import type { dedentBlocks } from './note-block/commands/dedent-blocks.js'; +import type { dedentBlocksToRoot } from './note-block/commands/dedent-blocks-to-root.js'; +import type { focusBlockEnd } from './note-block/commands/focus-block-end.js'; +import type { focusBlockStart } from './note-block/commands/focus-block-start.js'; +import type { indentBlock } from './note-block/commands/indent-block.js'; +import type { indentBlocks } from './note-block/commands/indent-blocks.js'; +import type { selectBlock } from './note-block/commands/select-block.js'; +import type { selectBlocksBetween } from './note-block/commands/select-blocks-between.js'; +import { + EdgelessNoteBlockComponent, + EdgelessNoteMask, + NoteBlockComponent, + type NoteBlockService, +} from './note-block/index.js'; +import { EdgelessAutoCompletePanel } from './root-block/edgeless/components/auto-complete/auto-complete-panel.js'; +import { EdgelessAutoComplete } from './root-block/edgeless/components/auto-complete/edgeless-auto-complete.js'; +import { EdgelessToolIconButton } from './root-block/edgeless/components/buttons/tool-icon-button.js'; +import { EdgelessToolbarButton } from './root-block/edgeless/components/buttons/toolbar-button.js'; +import { EdgelessColorPickerButton } from './root-block/edgeless/components/color-picker/button.js'; +import { EdgelessColorPicker } from './root-block/edgeless/components/color-picker/color-picker.js'; +import { EdgelessColorCustomButton } from './root-block/edgeless/components/color-picker/custom-button.js'; +import { EdgelessConnectorHandle } from './root-block/edgeless/components/connector/connector-handle.js'; +import { + NOTE_SLICER_WIDGET, + NoteSlicer, +} from './root-block/edgeless/components/note-slicer/index.js'; +import { EdgelessAlignPanel } from './root-block/edgeless/components/panel/align-panel.js'; +import { CardStylePanel } from './root-block/edgeless/components/panel/card-style-panel.js'; +import { + EdgelessColorButton, + EdgelessColorPanel, + EdgelessTextColorIcon, +} from './root-block/edgeless/components/panel/color-panel.js'; +import { EdgelessFontFamilyPanel } from './root-block/edgeless/components/panel/font-family-panel.js'; +import { EdgelessFontWeightAndStylePanel } from './root-block/edgeless/components/panel/font-weight-and-style-panel.js'; +import { EdgelessLineWidthPanel } from './root-block/edgeless/components/panel/line-width-panel.js'; +import { NoteDisplayModePanel } from './root-block/edgeless/components/panel/note-display-mode-panel.js'; +import { EdgelessNoteShadowPanel } from './root-block/edgeless/components/panel/note-shadow-panel.js'; +import { EdgelessOneRowColorPanel } from './root-block/edgeless/components/panel/one-row-color-panel.js'; +import { EdgelessScalePanel } from './root-block/edgeless/components/panel/scale-panel.js'; +import { EdgelessShapePanel } from './root-block/edgeless/components/panel/shape-panel.js'; +import { EdgelessShapeStylePanel } from './root-block/edgeless/components/panel/shape-style-panel.js'; +import { EdgelessSizePanel } from './root-block/edgeless/components/panel/size-panel.js'; +import { StrokeStylePanel } from './root-block/edgeless/components/panel/stroke-style-panel.js'; +import { + EDGELESS_NAVIGATOR_BLACK_BACKGROUND_WIDGET, + EdgelessNavigatorBlackBackgroundWidget, +} from './root-block/edgeless/components/presentation/edgeless-navigator-black-background.js'; +import { + EDGELESS_DRAGGING_AREA_WIDGET, + EdgelessDraggingAreaRectWidget, +} from './root-block/edgeless/components/rects/edgeless-dragging-area-rect.js'; +import { + EDGELESS_SELECTED_RECT_WIDGET, + EdgelessSelectedRectWidget, +} from './root-block/edgeless/components/rects/edgeless-selected-rect.js'; +import { EdgelessConnectorLabelEditor } from './root-block/edgeless/components/text/edgeless-connector-label-editor.js'; +import { EdgelessFrameTitleEditor } from './root-block/edgeless/components/text/edgeless-frame-title-editor.js'; +import { EdgelessGroupTitleEditor } from './root-block/edgeless/components/text/edgeless-group-title-editor.js'; +import { EdgelessShapeTextEditor } from './root-block/edgeless/components/text/edgeless-shape-text-editor.js'; +import { EdgelessTextEditor } from './root-block/edgeless/components/text/edgeless-text-editor.js'; +import { EdgelessBrushMenu } from './root-block/edgeless/components/toolbar/brush/brush-menu.js'; +import { EdgelessBrushToolButton } from './root-block/edgeless/components/toolbar/brush/brush-tool-button.js'; +import { EdgelessSlideMenu } from './root-block/edgeless/components/toolbar/common/slide-menu.js'; +import { EdgelessConnectorMenu } from './root-block/edgeless/components/toolbar/connector/connector-menu.js'; +import { EdgelessConnectorToolButton } from './root-block/edgeless/components/toolbar/connector/connector-tool-button.js'; +import { EdgelessDefaultToolButton } from './root-block/edgeless/components/toolbar/default/default-tool-button.js'; +import { + EDGELESS_TOOLBAR_WIDGET, + EdgelessToolbarWidget, +} from './root-block/edgeless/components/toolbar/edgeless-toolbar.js'; +import { EdgelessEraserToolButton } from './root-block/edgeless/components/toolbar/eraser/eraser-tool-button.js'; +import { EdgelessFrameMenu } from './root-block/edgeless/components/toolbar/frame/frame-menu.js'; +import { EdgelessFrameToolButton } from './root-block/edgeless/components/toolbar/frame/frame-tool-button.js'; +import { EdgelessLassoToolButton } from './root-block/edgeless/components/toolbar/lasso/lasso-tool-button.js'; +import { EdgelessLinkToolButton } from './root-block/edgeless/components/toolbar/link/link-tool-button.js'; +import { MindMapPlaceholder } from './root-block/edgeless/components/toolbar/mindmap/mindmap-importing-placeholder.js'; +import { EdgelessMindmapMenu } from './root-block/edgeless/components/toolbar/mindmap/mindmap-menu.js'; +import { EdgelessMindmapToolButton } from './root-block/edgeless/components/toolbar/mindmap/mindmap-tool-button.js'; +import { EdgelessNoteMenu } from './root-block/edgeless/components/toolbar/note/note-menu.js'; +import { EdgelessNoteSeniorButton } from './root-block/edgeless/components/toolbar/note/note-senior-button.js'; +import { EdgelessNoteToolButton } from './root-block/edgeless/components/toolbar/note/note-tool-button.js'; +import { EdgelessFrameOrderButton } from './root-block/edgeless/components/toolbar/present/frame-order-button.js'; +import { EdgelessFrameOrderMenu } from './root-block/edgeless/components/toolbar/present/frame-order-menu.js'; +import { EdgelessNavigatorSettingButton } from './root-block/edgeless/components/toolbar/present/navigator-setting-button.js'; +import { EdgelessPresentButton } from './root-block/edgeless/components/toolbar/present/present-button.js'; +import { PresentationToolbar } from './root-block/edgeless/components/toolbar/presentation-toolbar.js'; +import { EdgelessToolbarShapeDraggable } from './root-block/edgeless/components/toolbar/shape/shape-draggable.js'; +import { EdgelessShapeMenu } from './root-block/edgeless/components/toolbar/shape/shape-menu.js'; +import { EdgelessShapeToolButton } from './root-block/edgeless/components/toolbar/shape/shape-tool-button.js'; +import { EdgelessShapeToolElement } from './root-block/edgeless/components/toolbar/shape/shape-tool-element.js'; +import { OverlayScrollbar } from './root-block/edgeless/components/toolbar/template/overlay-scrollbar.js'; +import { AffineTemplateLoading } from './root-block/edgeless/components/toolbar/template/template-loading.js'; +import { EdgelessTemplatePanel } from './root-block/edgeless/components/toolbar/template/template-panel.js'; +import { EdgelessTemplateButton } from './root-block/edgeless/components/toolbar/template/template-tool-button.js'; +import { EdgelessTextMenu } from './root-block/edgeless/components/toolbar/text/text-menu.js'; +import { EdgelessRootPreviewBlockComponent } from './root-block/edgeless/edgeless-root-preview-block.js'; +import { + AFFINE_AI_PANEL_WIDGET, + AFFINE_EDGELESS_COPILOT_WIDGET, + AFFINE_EMBED_CARD_TOOLBAR_WIDGET, + AFFINE_FORMAT_BAR_WIDGET, + AffineAIPanelWidget, + AffineDocRemoteSelectionWidget, + AffineDragHandleWidget, + AffineEdgelessZoomToolbarWidget, + AffineFormatBarWidget, + AffineImageToolbarWidget, + AffineInnerModalWidget, + AffineModalWidget, + AffinePageDraggingAreaWidget, + AffinePieMenuWidget, + AffineSlashMenuWidget, + AffineSurfaceRefToolbar, + EdgelessCopilotToolbarEntry, + EdgelessCopilotWidget, + EdgelessRemoteSelectionWidget, + EdgelessRootBlockComponent, + EmbedCardToolbar, + FramePreview, + PageRootBlockComponent, + PreviewRootBlockComponent, + type RootBlockConfig, + type RootService, +} from './root-block/index.js'; +import { AIFinishTip } from './root-block/widgets/ai-panel/components/finish-tip.js'; +import { GeneratingPlaceholder } from './root-block/widgets/ai-panel/components/generating-placeholder.js'; +import { + AIPanelAnswer, + AIPanelDivider, + AIPanelError, + AIPanelGenerating, + AIPanelInput, +} from './root-block/widgets/ai-panel/components/index.js'; +import { effects as widgetCodeToolbarEffects } from './root-block/widgets/code-toolbar/effects.js'; +import { AFFINE_DOC_REMOTE_SELECTION_WIDGET } from './root-block/widgets/doc-remote-selection/index.js'; +import { DragPreview } from './root-block/widgets/drag-handle/components/drag-preview.js'; +import { DropIndicator } from './root-block/widgets/drag-handle/components/drop-indicator.js'; +import { AFFINE_DRAG_HANDLE_WIDGET } from './root-block/widgets/drag-handle/consts.js'; +import { + AFFINE_EDGELESS_AUTO_CONNECT_WIDGET, + EdgelessAutoConnectWidget, +} from './root-block/widgets/edgeless-auto-connect/edgeless-auto-connect.js'; +import { EdgelessCopilotPanel } from './root-block/widgets/edgeless-copilot-panel/index.js'; +import { AFFINE_EDGELESS_REMOTE_SELECTION_WIDGET } from './root-block/widgets/edgeless-remote-selection/index.js'; +import { AFFINE_EDGELESS_ZOOM_TOOLBAR_WIDGET } from './root-block/widgets/edgeless-zoom-toolbar/index.js'; +import { ZoomBarToggleButton } from './root-block/widgets/edgeless-zoom-toolbar/zoom-bar-toggle-button.js'; +import { EdgelessZoomToolbar } from './root-block/widgets/edgeless-zoom-toolbar/zoom-toolbar.js'; +import { effects as widgetEdgelessElementToolbarEffects } from './root-block/widgets/element-toolbar/effects.js'; +import { effects as widgetFrameTitleEffects } from './root-block/widgets/frame-title/effects.js'; +import { AffineImageToolbar } from './root-block/widgets/image-toolbar/components/image-toolbar.js'; +import { AFFINE_IMAGE_TOOLBAR_WIDGET } from './root-block/widgets/image-toolbar/index.js'; +import { AFFINE_INNER_MODAL_WIDGET } from './root-block/widgets/inner-modal/inner-modal.js'; +import { effects as widgetMobileToolbarEffects } from './root-block/widgets/keyboard-toolbar/effects.js'; +import { effects as widgetLinkedDocEffects } from './root-block/widgets/linked-doc/effects.js'; +import { AffineCustomModal } from './root-block/widgets/modal/custom-modal.js'; +import { AFFINE_MODAL_WIDGET } from './root-block/widgets/modal/modal.js'; +import { AFFINE_PAGE_DRAGGING_AREA_WIDGET } from './root-block/widgets/page-dragging-area/page-dragging-area.js'; +import { PieNodeCenter } from './root-block/widgets/pie-menu/components/pie-node-center.js'; +import { PieNodeChild } from './root-block/widgets/pie-menu/components/pie-node-child.js'; +import { PieNodeContent } from './root-block/widgets/pie-menu/components/pie-node-content.js'; +import { PieCenterRotator } from './root-block/widgets/pie-menu/components/rotator.js'; +import { AFFINE_PIE_MENU_WIDGET } from './root-block/widgets/pie-menu/index.js'; +import { PieMenu } from './root-block/widgets/pie-menu/menu.js'; +import { PieNode } from './root-block/widgets/pie-menu/node.js'; +import { AFFINE_SLASH_MENU_WIDGET } from './root-block/widgets/slash-menu/index.js'; +import { + InnerSlashMenu, + SlashMenu, +} from './root-block/widgets/slash-menu/slash-menu-popover.js'; +import { AFFINE_SURFACE_REF_TOOLBAR } from './root-block/widgets/surface-ref-toolbar/surface-ref-toolbar.js'; +import { + AFFINE_VIEWPORT_OVERLAY_WIDGET, + AffineViewportOverlayWidget, +} from './root-block/widgets/viewport-overlay/viewport-overlay.js'; +import { + MindmapRootBlock, + MindmapSurfaceBlock, + MiniMindmapPreview, +} from './surface-block/mini-mindmap/index.js'; +import { effects as blockSurfaceRefEffects } from './surface-ref-block/effects.js'; +import { + EdgelessSurfaceRefBlockComponent, + SurfaceRefBlockComponent, + type SurfaceRefBlockService, +} from './surface-ref-block/index.js'; +import { SurfaceRefGenericBlockPortal } from './surface-ref-block/portal/generic-block.js'; +import { SurfaceRefNotePortal } from './surface-ref-block/portal/note.js'; + +export function effects() { + registerSpecs(); + + stdEffects(); + inlineEffects(); + + blockListEffects(); + blockParagraphEffects(); + blockEmbedEffects(); + blockSurfaceEffects(); + dataViewEffects(); + blockImageEffects(); + blockDatabaseEffects(); + blockSurfaceRefEffects(); + blockLatexEffects(); + + componentCaptionEffects(); + componentContextMenuEffects(); + componentDatePickerEffects(); + componentPortalEffects(); + componentRichTextEffects(); + componentToolbarEffects(); + componentDragIndicatorEffects(); + componentToggleButtonEffects(); + + widgetScrollAnchoringEffects(); + widgetMobileToolbarEffects(); + widgetLinkedDocEffects(); + widgetFrameTitleEffects(); + widgetEdgelessElementToolbarEffects(); + widgetCodeToolbarEffects(); + + customElements.define('affine-database-title', DatabaseTitle); + customElements.define( + 'affine-edgeless-bookmark', + BookmarkEdgelessBlockComponent + ); + customElements.define('affine-image', ImageBlockComponent); + customElements.define('data-view-header-area-icon', IconCell); + customElements.define('affine-database-link-cell', LinkCell); + customElements.define('affine-database-link-cell-editing', LinkCellEditing); + customElements.define('affine-bookmark', BookmarkBlockComponent); + customElements.define('affine-edgeless-image', ImageEdgelessBlockComponent); + customElements.define('data-view-header-area-text', HeaderAreaTextCell); + customElements.define( + 'data-view-header-area-text-editing', + HeaderAreaTextCellEditing + ); + customElements.define('affine-code-unit', AffineCodeUnit); + customElements.define('affine-database-rich-text-cell', RichTextCell); + customElements.define( + 'affine-database-rich-text-cell-editing', + RichTextCellEditing + ); + customElements.define('affine-edgeless-text', EdgelessTextBlockComponent); + customElements.define('center-peek', CenterPeek); + customElements.define( + 'affine-edgeless-attachment', + AttachmentEdgelessBlockComponent + ); + customElements.define('database-datasource-note-renderer', NoteRenderer); + customElements.define('database-datasource-block-renderer', BlockRenderer); + customElements.define('affine-attachment', AttachmentBlockComponent); + customElements.define('affine-latex', LatexBlockComponent); + customElements.define('affine-page-root', PageRootBlockComponent); + customElements.define('edgeless-note-mask', EdgelessNoteMask); + customElements.define('affine-edgeless-note', EdgelessNoteBlockComponent); + customElements.define('affine-preview-root', PreviewRootBlockComponent); + customElements.define('affine-page-image', ImageBlockPageComponent); + customElements.define('affine-code', CodeBlockComponent); + customElements.define('affine-image-fallback-card', ImageBlockFallbackCard); + customElements.define('mini-mindmap-preview', MiniMindmapPreview); + customElements.define('affine-frame', FrameBlockComponent); + customElements.define('mini-mindmap-surface-block', MindmapSurfaceBlock); + customElements.define('affine-data-view', DataViewBlockComponent); + customElements.define('affine-edgeless-root', EdgelessRootBlockComponent); + customElements.define('affine-divider', DividerBlockComponent); + customElements.define('edgeless-copilot-panel', EdgelessCopilotPanel); + customElements.define( + 'edgeless-copilot-toolbar-entry', + EdgelessCopilotToolbarEntry + ); + customElements.define( + 'affine-edgeless-surface-ref', + EdgelessSurfaceRefBlockComponent + ); + customElements.define( + 'edgeless-color-custom-button', + EdgelessColorCustomButton + ); + customElements.define('edgeless-connector-handle', EdgelessConnectorHandle); + customElements.define('edgeless-zoom-toolbar', EdgelessZoomToolbar); + customElements.define( + 'affine-edgeless-root-preview', + EdgelessRootPreviewBlockComponent + ); + customElements.define('affine-custom-modal', AffineCustomModal); + customElements.define('affine-database', DatabaseBlockComponent); + customElements.define('affine-surface-ref', SurfaceRefBlockComponent); + customElements.define('pie-node-child', PieNodeChild); + customElements.define('pie-node-content', PieNodeContent); + customElements.define('pie-node-center', PieNodeCenter); + customElements.define('pie-center-rotator', PieCenterRotator); + customElements.define('affine-slash-menu', SlashMenu); + customElements.define('inner-slash-menu', InnerSlashMenu); + customElements.define('generating-placeholder', GeneratingPlaceholder); + customElements.define('ai-finish-tip', AIFinishTip); + customElements.define('ai-panel-divider', AIPanelDivider); + customElements.define(NOTE_SLICER_WIDGET, NoteSlicer); + customElements.define( + EDGELESS_NAVIGATOR_BLACK_BACKGROUND_WIDGET, + EdgelessNavigatorBlackBackgroundWidget + ); + customElements.define('zoom-bar-toggle-button', ZoomBarToggleButton); + customElements.define( + EDGELESS_DRAGGING_AREA_WIDGET, + EdgelessDraggingAreaRectWidget + ); + customElements.define('icon-button', IconButton); + customElements.define('affine-pie-menu', PieMenu); + customElements.define('loader-element', Loader); + customElements.define('edgeless-brush-menu', EdgelessBrushMenu); + customElements.define( + 'surface-ref-generic-block-portal', + SurfaceRefGenericBlockPortal + ); + customElements.define('edgeless-brush-tool-button', EdgelessBrushToolButton); + customElements.define( + 'edgeless-connector-tool-button', + EdgelessConnectorToolButton + ); + customElements.define('affine-pie-node', PieNode); + customElements.define( + 'edgeless-default-tool-button', + EdgelessDefaultToolButton + ); + customElements.define('surface-ref-note-portal', SurfaceRefNotePortal); + customElements.define('edgeless-connector-menu', EdgelessConnectorMenu); + customElements.define('smooth-corner', SmoothCorner); + customElements.define('toggle-switch', ToggleSwitch); + customElements.define('ai-panel-answer', AIPanelAnswer); + customElements.define('ai-item-list', AIItemList); + customElements.define( + 'edgeless-eraser-tool-button', + EdgelessEraserToolButton + ); + customElements.define('edgeless-frame-menu', EdgelessFrameMenu); + customElements.define('edgeless-frame-tool-button', EdgelessFrameToolButton); + customElements.define('ai-panel-input', AIPanelInput); + customElements.define('ai-panel-generating', AIPanelGenerating); + customElements.define('ai-item', AIItem); + customElements.define('ai-sub-item-list', AISubItemList); + customElements.define('edgeless-link-tool-button', EdgelessLinkToolButton); + customElements.define('embed-card-more-menu', EmbedCardMoreMenu); + customElements.define('edgeless-mindmap-menu', EdgelessMindmapMenu); + customElements.define('embed-card-style-menu', EmbedCardStyleMenu); + customElements.define('edgeless-lasso-tool-button', EdgelessLassoToolButton); + customElements.define('affine-filterable-list', FilterableListComponent); + customElements.define('ai-panel-error', AIPanelError); + customElements.define( + EDGELESS_SELECTED_RECT_WIDGET, + EdgelessSelectedRectWidget + ); + customElements.define('mindmap-import-placeholder', MindMapPlaceholder); + customElements.define( + 'edgeless-note-senior-button', + EdgelessNoteSeniorButton + ); + customElements.define('edgeless-align-panel', EdgelessAlignPanel); + customElements.define('card-style-panel', CardStylePanel); + customElements.define( + 'embed-card-caption-edit-modal', + EmbedCardEditCaptionEditModal + ); + customElements.define('edgeless-color-button', EdgelessColorButton); + customElements.define('edgeless-color-panel', EdgelessColorPanel); + customElements.define('edgeless-text-color-icon', EdgelessTextColorIcon); + customElements.define('embed-card-create-modal', EmbedCardCreateModal); + customElements.define('embed-card-edit-modal', EmbedCardEditModal); + customElements.define( + 'edgeless-mindmap-tool-button', + EdgelessMindmapToolButton + ); + customElements.define('edgeless-note-tool-button', EdgelessNoteToolButton); + customElements.define('edgeless-note-menu', EdgelessNoteMenu); + customElements.define('edgeless-line-width-panel', EdgelessLineWidthPanel); + customElements.define('affine-database-link-node', LinkNode); + customElements.define( + 'edgeless-frame-order-button', + EdgelessFrameOrderButton + ); + customElements.define('edgeless-frame-order-menu', EdgelessFrameOrderMenu); + customElements.define( + 'edgeless-auto-complete-panel', + EdgelessAutoCompletePanel + ); + customElements.define( + 'edgeless-navigator-setting-button', + EdgelessNavigatorSettingButton + ); + customElements.define('edgeless-present-button', EdgelessPresentButton); + customElements.define('edgeless-color-picker', EdgelessColorPicker); + customElements.define('overlay-scrollbar', OverlayScrollbar); + customElements.define('affine-note', NoteBlockComponent); + customElements.define('affine-template-loading', AffineTemplateLoading); + customElements.define( + 'edgeless-color-picker-button', + EdgelessColorPickerButton + ); + customElements.define('edgeless-auto-complete', EdgelessAutoComplete); + customElements.define( + 'edgeless-font-weight-and-style-panel', + EdgelessFontWeightAndStylePanel + ); + customElements.define('edgeless-note-shadow-panel', EdgelessNoteShadowPanel); + customElements.define('edgeless-templates-panel', EdgelessTemplatePanel); + customElements.define('edgeless-text-menu', EdgelessTextMenu); + customElements.define('edgeless-template-button', EdgelessTemplateButton); + customElements.define('edgeless-tool-icon-button', EdgelessToolIconButton); + customElements.define('edgeless-size-panel', EdgelessSizePanel); + customElements.define('edgeless-scale-panel', EdgelessScalePanel); + customElements.define('edgeless-font-family-panel', EdgelessFontFamilyPanel); + customElements.define('edgeless-shape-panel', EdgelessShapePanel); + customElements.define('note-display-mode-panel', NoteDisplayModePanel); + customElements.define('edgeless-toolbar-button', EdgelessToolbarButton); + customElements.define('frame-preview', FramePreview); + customElements.define('bookmark-card', BookmarkCard); + customElements.define('presentation-toolbar', PresentationToolbar); + customElements.define('edgeless-shape-menu', EdgelessShapeMenu); + customElements.define('stroke-style-panel', StrokeStylePanel); + customElements.define('edgeless-shape-tool-button', EdgelessShapeToolButton); + customElements.define( + 'edgeless-connector-label-editor', + EdgelessConnectorLabelEditor + ); + customElements.define('block-zero-width', BlockZeroWidth); + customElements.define( + 'edgeless-shape-tool-element', + EdgelessShapeToolElement + ); + customElements.define('edgeless-shape-text-editor', EdgelessShapeTextEditor); + customElements.define( + 'edgeless-group-title-editor', + EdgelessGroupTitleEditor + ); + customElements.define('affine-drag-preview', DragPreview); + customElements.define(EDGELESS_TOOLBAR_WIDGET, EdgelessToolbarWidget); + customElements.define('edgeless-shape-style-panel', EdgelessShapeStylePanel); + customElements.define( + 'edgeless-frame-title-editor', + EdgelessFrameTitleEditor + ); + customElements.define( + 'edgeless-one-row-color-panel', + EdgelessOneRowColorPanel + ); + customElements.define('edgeless-text-editor', EdgelessTextEditor); + customElements.define('affine-image-toolbar', AffineImageToolbar); + customElements.define('affine-drop-indicator', DropIndicator); + customElements.define('mini-mindmap-root-block', MindmapRootBlock); + customElements.define('affine-block-selection', BlockSelection); + customElements.define('menu-divider', MenuDivider); + customElements.define('edgeless-slide-menu', EdgelessSlideMenu); + customElements.define( + 'edgeless-toolbar-shape-draggable', + EdgelessToolbarShapeDraggable + ); + + customElements.define(AFFINE_AI_PANEL_WIDGET, AffineAIPanelWidget); + customElements.define(AFFINE_EMBED_CARD_TOOLBAR_WIDGET, EmbedCardToolbar); + customElements.define(AFFINE_INNER_MODAL_WIDGET, AffineInnerModalWidget); + customElements.define( + AFFINE_DOC_REMOTE_SELECTION_WIDGET, + AffineDocRemoteSelectionWidget + ); + customElements.define(AFFINE_MODAL_WIDGET, AffineModalWidget); + customElements.define( + AFFINE_PAGE_DRAGGING_AREA_WIDGET, + AffinePageDraggingAreaWidget + ); + customElements.define(AFFINE_DRAG_HANDLE_WIDGET, AffineDragHandleWidget); + customElements.define(AFFINE_PIE_MENU_WIDGET, AffinePieMenuWidget); + customElements.define(AFFINE_EDGELESS_COPILOT_WIDGET, EdgelessCopilotWidget); + + customElements.define(AFFINE_IMAGE_TOOLBAR_WIDGET, AffineImageToolbarWidget); + customElements.define(AFFINE_SLASH_MENU_WIDGET, AffineSlashMenuWidget); + customElements.define( + AFFINE_EDGELESS_REMOTE_SELECTION_WIDGET, + EdgelessRemoteSelectionWidget + ); + customElements.define( + AFFINE_VIEWPORT_OVERLAY_WIDGET, + AffineViewportOverlayWidget + ); + customElements.define( + AFFINE_EDGELESS_ZOOM_TOOLBAR_WIDGET, + AffineEdgelessZoomToolbarWidget + ); + customElements.define(AFFINE_SURFACE_REF_TOOLBAR, AffineSurfaceRefToolbar); + customElements.define( + AFFINE_EDGELESS_AUTO_CONNECT_WIDGET, + EdgelessAutoConnectWidget + ); + customElements.define(AFFINE_FORMAT_BAR_WIDGET, AffineFormatBarWidget); +} + +declare global { + namespace BlockSuite { + interface Commands { + selectBlock: typeof selectBlock; + selectBlocksBetween: typeof selectBlocksBetween; + focusBlockStart: typeof focusBlockStart; + focusBlockEnd: typeof focusBlockEnd; + indentBlocks: typeof indentBlocks; + dedentBlock: typeof dedentBlock; + dedentBlocksToRoot: typeof dedentBlocksToRoot; + dedentBlocks: typeof dedentBlocks; + indentBlock: typeof indentBlock; + insertBookmark: typeof insertBookmarkCommand; + updateBlockType: typeof updateBlockType; + insertEdgelessText: typeof insertEdgelessTextCommand; + dedentBlockToRoot: typeof dedentBlockToRoot; + } + interface CommandContext { + focusBlock?: BlockComponent | null; + anchorBlock?: BlockComponent | null; + updatedBlocks?: BlockModel[]; + textId?: string; + } + interface BlockConfigs { + 'affine:code': CodeBlockConfig; + 'affine:page': RootBlockConfig; + } + interface BlockServices { + 'affine:note': NoteBlockService; + 'affine:page': RootService; + 'affine:attachment': AttachmentBlockService; + 'affine:bookmark': BookmarkBlockService; + 'affine:database': DatabaseBlockService; + 'affine:image': ImageBlockService; + 'affine:surface-ref': SurfaceRefBlockService; + } + } +} diff --git a/blocksuite/blocks/src/frame-block/frame-block.ts b/blocksuite/blocks/src/frame-block/frame-block.ts new file mode 100644 index 0000000000..1250575cf2 --- /dev/null +++ b/blocksuite/blocks/src/frame-block/frame-block.ts @@ -0,0 +1,96 @@ +import type { FrameBlockModel } from '@blocksuite/affine-model'; +import { ThemeProvider } from '@blocksuite/affine-shared/services'; +import { GfxBlockComponent } from '@blocksuite/block-std'; +import { Bound } from '@blocksuite/global/utils'; +import { cssVarV2 } from '@toeverything/theme/v2'; +import { html } from 'lit'; +import { state } from 'lit/decorators.js'; +import { styleMap } from 'lit/directives/style-map.js'; + +import type { EdgelessRootService } from '../root-block/index.js'; + +export class FrameBlockComponent extends GfxBlockComponent<FrameBlockModel> { + get rootService() { + return this.std.getService('affine:page') as EdgelessRootService; + } + + override connectedCallback() { + super.connectedCallback(); + + this._disposables.add( + this.doc.slots.blockUpdated.on(({ type, id }) => { + if (id === this.model.id && type === 'update') { + this.requestUpdate(); + } + }) + ); + this._disposables.add( + this.gfx.viewport.viewportUpdated.on(() => { + this.requestUpdate(); + }) + ); + } + + /** + * Due to potentially very large frame sizes, CSS scaling can cause iOS Safari to crash. + * To mitigate this issue, we combine size calculations within the rendering rect. + */ + override getCSSTransform(): string { + return ''; + } + + override getRenderingRect() { + const viewport = this.gfx.viewport; + const { translateX, translateY, zoom } = viewport; + const { xywh, rotate } = this.model; + const bound = Bound.deserialize(xywh); + + const scaledX = bound.x * zoom + translateX; + const scaledY = bound.y * zoom + translateY; + + return { + x: scaledX, + y: scaledY, + w: bound.w * zoom, + h: bound.h * zoom, + rotate, + zIndex: this.toZIndex(), + }; + } + + override renderGfxBlock() { + const { model, showBorder, rootService, std } = this; + const backgroundColor = std + .get(ThemeProvider) + .generateColorProperty(model.background, '--affine-platte-transparent'); + const _isNavigator = + this.gfx.tool.currentToolName$.value === 'frameNavigator'; + const frameIndex = rootService.layer.getZIndex(model); + + return html` + <div + class="affine-frame-container" + style=${styleMap({ + zIndex: `${frameIndex}`, + backgroundColor, + height: '100%', + width: '100%', + borderRadius: '2px', + border: + _isNavigator || !showBorder + ? 'none' + : `1px solid ${cssVarV2('edgeless/frame/border/default')}`, + })} + ></div> + `; + } + + @state() + accessor showBorder = true; +} + +declare global { + interface HTMLElementTagNameMap { + 'affine-frame': FrameBlockComponent; + } +} diff --git a/blocksuite/blocks/src/frame-block/frame-spec.ts b/blocksuite/blocks/src/frame-block/frame-spec.ts new file mode 100644 index 0000000000..563a6cdde4 --- /dev/null +++ b/blocksuite/blocks/src/frame-block/frame-spec.ts @@ -0,0 +1,6 @@ +import { BlockViewExtension, type ExtensionType } from '@blocksuite/block-std'; +import { literal } from 'lit/static-html.js'; + +export const FrameBlockSpec: ExtensionType[] = [ + BlockViewExtension('affine:frame', literal`affine-frame`), +]; diff --git a/blocksuite/blocks/src/frame-block/index.ts b/blocksuite/blocks/src/frame-block/index.ts new file mode 100644 index 0000000000..ad2526ce10 --- /dev/null +++ b/blocksuite/blocks/src/frame-block/index.ts @@ -0,0 +1,2 @@ +export * from './frame-block.js'; +export * from './frame-spec.js'; diff --git a/blocksuite/blocks/src/image-block/adapters/html.ts b/blocksuite/blocks/src/image-block/adapters/html.ts new file mode 100644 index 0000000000..b71815b5f7 --- /dev/null +++ b/blocksuite/blocks/src/image-block/adapters/html.ts @@ -0,0 +1,145 @@ +import { ImageBlockSchema } from '@blocksuite/affine-model'; +import { + BlockHtmlAdapterExtension, + type BlockHtmlAdapterMatcher, + FetchUtils, + HastUtils, +} from '@blocksuite/affine-shared/adapters'; +import { getFilenameFromContentDisposition } from '@blocksuite/affine-shared/utils'; +import { sha } from '@blocksuite/global/utils'; +import { getAssetName, nanoid } from '@blocksuite/store'; + +export const imageBlockHtmlAdapterMatcher: BlockHtmlAdapterMatcher = { + flavour: ImageBlockSchema.model.flavour, + toMatch: o => HastUtils.isElement(o.node) && o.node.tagName === 'img', + fromMatch: o => o.node.flavour === ImageBlockSchema.model.flavour, + toBlockSnapshot: { + enter: async (o, context) => { + if (!HastUtils.isElement(o.node)) { + return; + } + const { assets, walkerContext, configs } = context; + if (!assets) { + return; + } + const image = o.node; + const imageURL = + typeof image?.properties.src === 'string' ? image.properties.src : ''; + if (imageURL) { + let blobId = ''; + if (!FetchUtils.fetchable(imageURL)) { + const imageURLSplit = imageURL.split('/'); + while (imageURLSplit.length > 0) { + const key = assets + .getPathBlobIdMap() + .get(decodeURIComponent(imageURLSplit.join('/'))); + if (key) { + blobId = key; + break; + } + imageURLSplit.shift(); + } + } else { + try { + const res = await FetchUtils.fetchImage( + imageURL, + undefined, + configs.get('imageProxy') as string + ); + if (!res) { + return; + } + const clonedRes = res.clone(); + const name = + getFilenameFromContentDisposition( + res.headers.get('Content-Disposition') ?? '' + ) ?? + (imageURL.split('/').at(-1) ?? 'image') + + '.' + + (res.headers.get('Content-Type')?.split('/').at(-1) ?? 'png'); + const file = new File([await res.blob()], name, { + type: res.headers.get('Content-Type') ?? '', + }); + blobId = await sha(await clonedRes.arrayBuffer()); + assets?.getAssets().set(blobId, file); + await assets?.writeToBlob(blobId); + } catch { + return; + } + } + walkerContext + .openNode( + { + type: 'block', + id: nanoid(), + flavour: 'affine:image', + props: { + sourceId: blobId, + }, + children: [], + }, + 'children' + ) + .closeNode(); + walkerContext.skipAllChildren(); + } + }, + }, + fromBlockSnapshot: { + enter: async (o, context) => { + const blobId = (o.node.props.sourceId ?? '') as string; + const { assets, walkerContext, updateAssetIds } = context; + if (!assets) { + return; + } + + await assets.readFromBlob(blobId); + const blob = assets.getAssets().get(blobId); + updateAssetIds?.(blobId); + if (!blob) { + return; + } + const blobName = getAssetName(assets.getAssets(), blobId); + const isScaledImage = o.node.props.width && o.node.props.height; + const widthStyle = isScaledImage + ? { + width: `${o.node.props.width}px`, + height: `${o.node.props.height}px`, + } + : {}; + + walkerContext + .openNode( + { + type: 'element', + tagName: 'figure', + properties: { + className: ['affine-image-block-container'], + }, + children: [], + }, + 'children' + ) + .openNode( + { + type: 'element', + tagName: 'img', + properties: { + src: `assets/${blobName}`, + alt: blobName, + title: (o.node.props.caption as string | undefined) ?? null, + ...widthStyle, + }, + children: [], + }, + 'children' + ) + .closeNode() + .closeNode(); + }, + }, +}; + +export const ImageBlockHtmlAdapterExtension = BlockHtmlAdapterExtension( + imageBlockHtmlAdapterMatcher +); diff --git a/blocksuite/blocks/src/image-block/adapters/index.ts b/blocksuite/blocks/src/image-block/adapters/index.ts new file mode 100644 index 0000000000..94b5ef70c3 --- /dev/null +++ b/blocksuite/blocks/src/image-block/adapters/index.ts @@ -0,0 +1,3 @@ +export * from './html.js'; +export * from './markdown.js'; +export * from './notion-html.js'; diff --git a/blocksuite/blocks/src/image-block/adapters/markdown.ts b/blocksuite/blocks/src/image-block/adapters/markdown.ts new file mode 100644 index 0000000000..683bec452a --- /dev/null +++ b/blocksuite/blocks/src/image-block/adapters/markdown.ts @@ -0,0 +1,119 @@ +import { ImageBlockSchema } from '@blocksuite/affine-model'; +import { + BlockMarkdownAdapterExtension, + type BlockMarkdownAdapterMatcher, + FetchUtils, + type MarkdownAST, +} from '@blocksuite/affine-shared/adapters'; +import { getFilenameFromContentDisposition } from '@blocksuite/affine-shared/utils'; +import { sha } from '@blocksuite/global/utils'; +import { getAssetName, nanoid } from '@blocksuite/store'; + +const isImageNode = (node: MarkdownAST) => node.type === 'image'; + +export const imageBlockMarkdownAdapterMatcher: BlockMarkdownAdapterMatcher = { + flavour: ImageBlockSchema.model.flavour, + toMatch: o => isImageNode(o.node), + fromMatch: o => o.node.flavour === ImageBlockSchema.model.flavour, + toBlockSnapshot: { + enter: async (o, context) => { + const { configs, walkerContext, assets } = context; + let blobId = ''; + const imageURL = 'url' in o.node ? o.node.url : ''; + if (!assets || !imageURL) { + return; + } + if (!FetchUtils.fetchable(imageURL)) { + const imageURLSplit = imageURL.split('/'); + while (imageURLSplit.length > 0) { + const key = assets + .getPathBlobIdMap() + .get(decodeURIComponent(imageURLSplit.join('/'))); + if (key) { + blobId = key; + break; + } + imageURLSplit.shift(); + } + } else { + const res = await FetchUtils.fetchImage( + imageURL, + undefined, + configs.get('imageProxy') as string + ); + if (!res) { + return; + } + const clonedRes = res.clone(); + const file = new File( + [await res.blob()], + getFilenameFromContentDisposition( + res.headers.get('Content-Disposition') ?? '' + ) ?? + (imageURL.split('/').at(-1) ?? 'image') + + '.' + + (res.headers.get('Content-Type')?.split('/').at(-1) ?? 'png'), + { + type: res.headers.get('Content-Type') ?? '', + } + ); + blobId = await sha(await clonedRes.arrayBuffer()); + assets?.getAssets().set(blobId, file); + await assets?.writeToBlob(blobId); + } + walkerContext + .openNode( + { + type: 'block', + id: nanoid(), + flavour: 'affine:image', + props: { + sourceId: blobId, + }, + children: [], + }, + 'children' + ) + .closeNode(); + }, + }, + fromBlockSnapshot: { + enter: async (o, context) => { + const { assets, walkerContext, updateAssetIds } = context; + const blobId = (o.node.props.sourceId ?? '') as string; + if (!assets) { + return; + } + await assets.readFromBlob(blobId); + const blob = assets.getAssets().get(blobId); + if (!blob) { + return; + } + const blobName = getAssetName(assets.getAssets(), blobId); + updateAssetIds?.(blobId); + walkerContext + .openNode( + { + type: 'paragraph', + children: [], + }, + 'children' + ) + .openNode( + { + type: 'image', + url: `assets/${blobName}`, + title: (o.node.props.caption as string | undefined) ?? null, + alt: (blob as File).name ?? null, + }, + 'children' + ) + .closeNode() + .closeNode(); + }, + }, +}; + +export const ImageBlockMarkdownAdapterExtension = BlockMarkdownAdapterExtension( + imageBlockMarkdownAdapterMatcher +); diff --git a/blocksuite/blocks/src/image-block/adapters/notion-html.ts b/blocksuite/blocks/src/image-block/adapters/notion-html.ts new file mode 100644 index 0000000000..a883a82b02 --- /dev/null +++ b/blocksuite/blocks/src/image-block/adapters/notion-html.ts @@ -0,0 +1,139 @@ +import { ImageBlockSchema } from '@blocksuite/affine-model'; +import { + BlockNotionHtmlAdapterExtension, + type BlockNotionHtmlAdapterMatcher, + FetchUtils, + HastUtils, +} from '@blocksuite/affine-shared/adapters'; +import { getFilenameFromContentDisposition } from '@blocksuite/affine-shared/utils'; +import { sha } from '@blocksuite/global/utils'; +import { + type AssetsManager, + type ASTWalkerContext, + type BlockSnapshot, + nanoid, +} from '@blocksuite/store'; + +async function processImageNode( + imageURL: string, + walkerContext: ASTWalkerContext<BlockSnapshot>, + assets: AssetsManager, + configs: Map<string, string> +) { + let blobId = ''; + if (!FetchUtils.fetchable(imageURL)) { + const imageURLSplit = imageURL.split('/'); + while (imageURLSplit.length > 0) { + const key = assets + .getPathBlobIdMap() + .get(decodeURIComponent(imageURLSplit.join('/'))); + if (key) { + blobId = key; + break; + } + imageURLSplit.shift(); + } + } else { + const res = await FetchUtils.fetchImage( + imageURL, + undefined, + configs.get('imageProxy') as string + ); + if (!res) { + return; + } + const clonedRes = res.clone(); + const name = + getFilenameFromContentDisposition( + res.headers.get('Content-Disposition') ?? '' + ) ?? + (imageURL.split('/').at(-1) ?? 'image') + + '.' + + (res.headers.get('Content-Type')?.split('/').at(-1) ?? 'png'); + const file = new File([await res.blob()], name, { + type: res.headers.get('Content-Type') ?? '', + }); + blobId = await sha(await clonedRes.arrayBuffer()); + assets?.getAssets().set(blobId, file); + await assets?.writeToBlob(blobId); + } + walkerContext + .openNode( + { + type: 'block', + id: nanoid(), + flavour: ImageBlockSchema.model.flavour, + props: { + sourceId: blobId, + }, + children: [], + }, + 'children' + ) + .closeNode(); + walkerContext.skipAllChildren(); +} + +export const imageBlockNotionHtmlAdapterMatcher: BlockNotionHtmlAdapterMatcher = + { + flavour: ImageBlockSchema.model.flavour, + toMatch: o => { + return ( + HastUtils.isElement(o.node) && + (o.node.tagName === 'img' || + (o.node.tagName === 'figure' && + !!HastUtils.querySelector(o.node, '.image'))) + ); + }, + fromMatch: () => false, + toBlockSnapshot: { + enter: async (o, context) => { + if (!HastUtils.isElement(o.node)) { + return; + } + const { assets, walkerContext, configs } = context; + if (!assets) { + return; + } + if (walkerContext.getGlobalContext('hast:disableimg')) { + return; + } + + switch (o.node.tagName) { + case 'img': { + const image = o.node; + const imageURL = + typeof image?.properties.src === 'string' + ? image.properties.src + : ''; + if (imageURL) { + await processImageNode(imageURL, walkerContext, assets, configs); + } + break; + } + case 'figure': { + const imageFigureWrapper = HastUtils.querySelector( + o.node, + '.image' + ); + let imageURL = ''; + if (imageFigureWrapper) { + const image = HastUtils.querySelector(imageFigureWrapper, 'img'); + imageURL = + typeof image?.properties.src === 'string' + ? image.properties.src + : ''; + } + if (imageURL) { + await processImageNode(imageURL, walkerContext, assets, configs); + } + break; + } + } + }, + }, + fromBlockSnapshot: {}, + }; + +export const ImageBlockNotionHtmlAdapterExtension = + BlockNotionHtmlAdapterExtension(imageBlockNotionHtmlAdapterMatcher); diff --git a/blocksuite/blocks/src/image-block/commands/index.ts b/blocksuite/blocks/src/image-block/commands/index.ts new file mode 100644 index 0000000000..ab253b6fb6 --- /dev/null +++ b/blocksuite/blocks/src/image-block/commands/index.ts @@ -0,0 +1,9 @@ +import { getImageSelectionsCommand } from '@blocksuite/affine-shared/commands'; +import type { BlockCommands } from '@blocksuite/block-std'; + +import { insertImagesCommand } from './insert-images.js'; + +export const commands: BlockCommands = { + getImageSelections: getImageSelectionsCommand, + insertImages: insertImagesCommand, +}; diff --git a/blocksuite/blocks/src/image-block/commands/insert-images.ts b/blocksuite/blocks/src/image-block/commands/insert-images.ts new file mode 100644 index 0000000000..2cd4e511e9 --- /dev/null +++ b/blocksuite/blocks/src/image-block/commands/insert-images.ts @@ -0,0 +1,44 @@ +import { getImageFilesFromLocal } from '@blocksuite/affine-shared/utils'; +import type { Command } from '@blocksuite/block-std'; + +import { addSiblingImageBlock } from '../utils.js'; + +export const insertImagesCommand: Command< + 'selectedModels', + 'insertedImageIds', + { removeEmptyLine?: boolean; place?: 'after' | 'before' } +> = (ctx, next) => { + const { selectedModels, place, removeEmptyLine, std } = ctx; + if (!selectedModels) return; + + return next({ + insertedImageIds: getImageFilesFromLocal().then(imageFiles => { + if (imageFiles.length === 0) return []; + + if (selectedModels.length === 0) return []; + + const targetModel = + place === 'before' + ? selectedModels[0] + : selectedModels[selectedModels.length - 1]; + + const imageService = std.getService('affine:image'); + if (!imageService) return []; + + const maxFileSize = imageService.maxFileSize; + + const result = addSiblingImageBlock( + std.host, + imageFiles, + maxFileSize, + targetModel, + place + ); + if (removeEmptyLine && targetModel.text?.length === 0) { + std.doc.deleteBlock(targetModel); + } + + return result ?? []; + }), + }); +}; diff --git a/blocksuite/blocks/src/image-block/components/image-block-fallback.ts b/blocksuite/blocks/src/image-block/components/image-block-fallback.ts new file mode 100644 index 0000000000..f603640f55 --- /dev/null +++ b/blocksuite/blocks/src/image-block/components/image-block-fallback.ts @@ -0,0 +1,136 @@ +import type { ImageBlockModel } from '@blocksuite/affine-model'; +import { humanFileSize } from '@blocksuite/affine-shared/utils'; +import { modelContext, ShadowlessElement } from '@blocksuite/block-std'; +import { WithDisposable } from '@blocksuite/global/utils'; +import { consume } from '@lit/context'; +import { css, html } from 'lit'; +import { property } from 'lit/decorators.js'; +import { styleMap } from 'lit/directives/style-map.js'; + +import { FailedImageIcon, ImageIcon, LoadingIcon } from '../styles.js'; + +export const SURFACE_IMAGE_CARD_WIDTH = 220; +export const SURFACE_IMAGE_CARD_HEIGHT = 122; +export const NOTE_IMAGE_CARD_WIDTH = 752; +export const NOTE_IMAGE_CARD_HEIGHT = 78; + +export class ImageBlockFallbackCard extends WithDisposable(ShadowlessElement) { + static override styles = css` + .affine-image-fallback-card-container { + width: 100%; + height: 100%; + display: flex; + align-items: center; + justify-content: center; + } + + .affine-image-fallback-card { + display: flex; + flex-direction: column; + justify-content: space-between; + background-color: var(--affine-background-secondary-color, #f4f4f5); + border-radius: 8px; + border: 1px solid var(--affine-background-tertiary-color, #eee); + padding: 12px; + } + + .affine-image-fallback-card-content { + display: flex; + align-items: center; + gap: 8px; + color: var(--affine-placeholder-color); + text-align: justify; + font-family: var(--affine-font-family); + font-size: var(--affine-font-sm); + font-style: normal; + font-weight: 600; + line-height: var(--affine-line-height); + user-select: none; + } + + .affine-image-card-size { + overflow: hidden; + padding-top: 12px; + color: var(--affine-text-secondary-color); + text-overflow: ellipsis; + font-size: 10px; + font-style: normal; + font-weight: 400; + line-height: 20px; + user-select: none; + } + `; + + override render() { + const { mode, loading, error, model } = this; + + const isEdgeless = mode === 'edgeless'; + const width = isEdgeless + ? `${SURFACE_IMAGE_CARD_WIDTH}px` + : `${NOTE_IMAGE_CARD_WIDTH}px`; + const height = isEdgeless + ? `${SURFACE_IMAGE_CARD_HEIGHT}px` + : `${NOTE_IMAGE_CARD_HEIGHT}px`; + + const rotate = isEdgeless ? model.rotate : 0; + + const cardStyleMap = styleMap({ + transform: `rotate(${rotate}deg)`, + transformOrigin: 'center', + width, + height, + }); + + const titleIcon = loading + ? LoadingIcon + : error + ? FailedImageIcon + : ImageIcon; + + const titleText = loading + ? 'Loading image...' + : error + ? 'Image loading failed.' + : 'Image'; + + const size = + !!model.size && model.size > 0 + ? humanFileSize(model.size, true, 0) + : null; + + return html` + <div class="affine-image-fallback-card-container"> + <div + class="affine-image-fallback-card drag-target" + style=${cardStyleMap} + > + <div class="affine-image-fallback-card-content"> + ${titleIcon} + <span class="affine-image-fallback-card-title-text" + >${titleText}</span + > + </div> + <div class="affine-image-card-size">${size}</div> + </div> + </div> + `; + } + + @property({ attribute: false }) + accessor error!: boolean; + + @property({ attribute: false }) + accessor loading!: boolean; + + @property({ attribute: false }) + accessor mode!: 'page' | 'edgeless'; + + @consume({ context: modelContext }) + accessor model!: ImageBlockModel; +} + +declare global { + interface HTMLElementTagNameMap { + 'affine-image-fallback-card': ImageBlockFallbackCard; + } +} diff --git a/blocksuite/blocks/src/image-block/components/image-selected-rect.ts b/blocksuite/blocks/src/image-block/components/image-selected-rect.ts new file mode 100644 index 0000000000..225dd43728 --- /dev/null +++ b/blocksuite/blocks/src/image-block/components/image-selected-rect.ts @@ -0,0 +1,83 @@ +import { html } from 'lit'; + +const styles = html`<style> + .affine-page-selected-embed-rects-container { + position: absolute; + border: 2px solid var(--affine-primary-color); + left: 0; + top: 0; + width: 100%; + height: calc(100% + 1px); + user-select: none; + pointer-events: none; + box-sizing: border-box; + line-height: 0; + } + + .affine-page-selected-embed-rects-container .resize { + position: absolute; + padding: 5px; + pointer-events: auto; + z-index: 1; + } + + .affine-page-selected-embed-rects-container .resize-inner { + width: 10px; + height: 10px; + border-radius: 50%; + background: white; + border: 2px solid var(--affine-primary-color); + pointer-events: none; + } + + .affine-page-selected-embed-rects-container .resize.top-left { + left: 0; + top: 0; + transform: translate(-50%, -50%); + cursor: nwse-resize; /*resizer cursor*/ + } + .affine-page-selected-embed-rects-container .resize.top-right { + right: 0; + top: 0; + transform: translate(50%, -50%); + cursor: nesw-resize; + } + .affine-page-selected-embed-rects-container .resize.bottom-left { + left: 0; + bottom: 0; + transform: translate(-50%, 50%); + cursor: nesw-resize; + } + .affine-page-selected-embed-rects-container .resize.bottom-right { + right: 0; + bottom: 0; + transform: translate(50%, 50%); + cursor: nwse-resize; + } +</style>`; + +export function ImageSelectedRect(readonly: boolean) { + if (readonly) { + return html`${styles} + <div + class="affine-page-selected-embed-rects-container resizable resizes" + ></div> `; + } + return html` + ${styles} + <div class="affine-page-selected-embed-rects-container resizable resizes"> + <div class="resize top-left"> + <div class="resize-inner"></div> + </div> + <div class="resize top-right"> + <div class="resize-inner"></div> + </div> + <div class="resize bottom-left"> + <div class="resize-inner"></div> + </div> + <div class="resize bottom-right"> + <div class="resize-inner"></div> + </div> + </div> + `; +} diff --git a/blocksuite/blocks/src/image-block/components/page-image-block.ts b/blocksuite/blocks/src/image-block/components/page-image-block.ts new file mode 100644 index 0000000000..ef5d6856fe --- /dev/null +++ b/blocksuite/blocks/src/image-block/components/page-image-block.ts @@ -0,0 +1,348 @@ +import type { BaseSelection, UIEventStateContext } from '@blocksuite/block-std'; +import { ShadowlessElement } from '@blocksuite/block-std'; +import { WithDisposable } from '@blocksuite/global/utils'; +import { css, html, type PropertyValues } from 'lit'; +import { property, query, state } from 'lit/decorators.js'; +import { styleMap } from 'lit/directives/style-map.js'; + +import type { ImageBlockComponent } from '../image-block.js'; +import { ImageResizeManager } from '../image-resize-manager.js'; +import { shouldResizeImage } from '../utils.js'; +import { ImageSelectedRect } from './image-selected-rect.js'; + +export class ImageBlockPageComponent extends WithDisposable(ShadowlessElement) { + static override styles = css` + affine-page-image { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + line-height: 0; + cursor: pointer; + } + + affine-page-image .resizable-img { + position: relative; + max-width: 100%; + } + + affine-page-image .resizable-img img { + width: 100%; + height: 100%; + } + `; + + private _isDragging = false; + + private get _doc() { + return this.block.doc; + } + + private get _host() { + return this.block.host; + } + + private get _model() { + return this.block.model; + } + + private _bindKeyMap() { + const selection = this._host.selection; + + const addParagraph = (ctx: UIEventStateContext) => { + const parent = this._doc.getParent(this._model); + if (!parent) return; + + const index = parent.children.indexOf(this._model); + const blockId = this._doc.addBlock( + 'affine:paragraph', + {}, + parent, + index + 1 + ); + + const event = ctx.get('defaultState').event; + event.preventDefault(); + + selection.update(selList => + selList + .filter<BaseSelection>(sel => !sel.is('image')) + .concat( + selection.create('text', { + from: { + blockId, + index: 0, + length: 0, + }, + to: null, + }) + ) + ); + }; + + this.block.bindHotKey({ + Escape: () => { + selection.update(selList => { + return selList.map(sel => { + const current = + sel.is('image') && sel.blockId === this.block.blockId; + if (current) { + return selection.create('block', { blockId: this.block.blockId }); + } + return sel; + }); + }); + return true; + }, + Delete: ctx => { + if (this._host.doc.readonly || !this._isSelected) return; + + addParagraph(ctx); + this._doc.deleteBlock(this._model); + return true; + }, + Backspace: ctx => { + if (this._host.doc.readonly || !this._isSelected) return; + + addParagraph(ctx); + this._doc.deleteBlock(this._model); + return true; + }, + Enter: ctx => { + if (this._host.doc.readonly || !this._isSelected) return; + + addParagraph(ctx); + return true; + }, + ArrowDown: ctx => { + const std = this._host.std; + + // If the selection is not image selection, we should not handle it. + if (!std.selection.find('image')) { + return false; + } + + const event = ctx.get('keyboardState'); + event.raw.preventDefault(); + + std.command + .chain() + .getNextBlock({ path: this.block.blockId }) + .inline((ctx, next) => { + const { nextBlock } = ctx; + if (!nextBlock) return; + + return next({ focusBlock: nextBlock }); + }) + .focusBlockStart() + .run(); + return true; + }, + ArrowUp: ctx => { + const std = this._host.std; + + // If the selection is not image selection, we should not handle it. + + if (!std.selection.find('image')) { + return false; + } + + const event = ctx.get('keyboardState'); + event.raw.preventDefault(); + + std.command + .chain() + .getPrevBlock({ path: this.block.blockId }) + .inline((ctx, next) => { + const { prevBlock } = ctx; + if (!prevBlock) return; + + return next({ focusBlock: prevBlock }); + }) + .focusBlockEnd() + .run(); + return true; + }, + }); + } + + private _handleError() { + this.block.error = true; + } + + private _handleSelection() { + const selection = this._host.selection; + this._disposables.add( + selection.slots.changed.on(selList => { + this._isSelected = selList.some( + sel => sel.blockId === this.block.blockId && sel.is('image') + ); + }) + ); + + this._disposables.add( + this._model.propsUpdated.on(() => { + this.requestUpdate(); + }) + ); + + this._disposables.addFromEvent( + this.resizeImg, + 'click', + (event: MouseEvent) => { + // the peek view need handle shift + click + if (event.shiftKey) return; + + event.stopPropagation(); + selection.update(selList => { + return selList + .filter(sel => !['block', 'image', 'text'].includes(sel.type)) + .concat(selection.create('image', { blockId: this.block.blockId })); + }); + return true; + } + ); + + this.block.handleEvent( + 'click', + () => { + if (!this._isSelected) return; + + selection.update(selList => + selList.filter( + sel => !(sel.is('image') && sel.blockId === this.block.blockId) + ) + ); + }, + { + global: true, + } + ); + } + + private _normalizeImageSize() { + // If is dragging, we should use the real size of the image + if (this._isDragging && this.resizeImg) { + return { + width: this.resizeImg.style.width, + }; + } + + const { width, height } = this._model; + if (!width || !height) { + return { + width: 'unset', + height: 'unset', + }; + } + + return { + width: `${width}px`, + }; + } + + private _observeDrag() { + const imageResizeManager = new ImageResizeManager(); + + this._disposables.add( + this._host.event.add('dragStart', ctx => { + const pointerState = ctx.get('pointerState'); + const target = pointerState.event.target; + if (shouldResizeImage(this, target)) { + this._isDragging = true; + imageResizeManager.onStart(pointerState); + return true; + } + return false; + }) + ); + + this._disposables.add( + this._host.event.add('dragMove', ctx => { + const pointerState = ctx.get('pointerState'); + if (this._isDragging) { + imageResizeManager.onMove(pointerState); + return true; + } + return false; + }) + ); + + this._disposables.add( + this._host.event.add('dragEnd', () => { + if (this._isDragging) { + this._isDragging = false; + imageResizeManager.onEnd(); + return true; + } + return false; + }) + ); + } + + override connectedCallback() { + super.connectedCallback(); + + this._bindKeyMap(); + + this._observeDrag(); + } + + override firstUpdated(changedProperties: PropertyValues) { + super.firstUpdated(changedProperties); + + this._handleSelection(); + + // The embed block can not be focused, + // so the active element will be the last activated element. + // If the active element is the title textarea, + // any event will dispatch from it and be ignored. (Most events will ignore title) + // so we need to blur it. + // See also https://developer.mozilla.org/en-US/docs/Web/API/Document/activeElement + this.addEventListener('click', () => { + if ( + document.activeElement && + document.activeElement instanceof HTMLElement + ) { + document.activeElement.blur(); + } + }); + } + + override render() { + const imageSize = this._normalizeImageSize(); + + const imageSelectedRect = this._isSelected + ? ImageSelectedRect(this._doc.readonly) + : null; + + return html` + <div class="resizable-img" style=${styleMap(imageSize)}> + <img + class="drag-target" + src=${this.block.blobUrl ?? ''} + draggable="false" + @error=${this._handleError} + loading="lazy" + /> + + ${imageSelectedRect} + </div> + `; + } + + @state() + accessor _isSelected = false; + + @property({ attribute: false }) + accessor block!: ImageBlockComponent; + + @query('.resizable-img') + accessor resizeImg!: HTMLElement; +} + +declare global { + interface HTMLElementTagNameMap { + 'affine-page-image': ImageBlockPageComponent; + } +} diff --git a/blocksuite/blocks/src/image-block/effects.ts b/blocksuite/blocks/src/image-block/effects.ts new file mode 100644 index 0000000000..a1770e2f5f --- /dev/null +++ b/blocksuite/blocks/src/image-block/effects.ts @@ -0,0 +1,26 @@ +import type { getImageSelectionsCommand } from '@blocksuite/affine-shared/commands'; + +import type { insertImagesCommand } from './commands/insert-images.js'; + +export function effects() { + // TODO(@L-Sun): move other effects to this file +} + +declare global { + namespace BlockSuite { + interface CommandContext { + insertedImageIds?: Promise<string[]>; + } + + interface Commands { + getImageSelections: typeof getImageSelectionsCommand; + /** + * open file dialog to insert images before or after the current block selection + * @param removeEmptyLine remove the current block if it is empty + * @param place where to insert the images + * @returns a promise that resolves to the inserted image ids + */ + insertImages: typeof insertImagesCommand; + } + } +} diff --git a/blocksuite/blocks/src/image-block/image-block.ts b/blocksuite/blocks/src/image-block/image-block.ts new file mode 100644 index 0000000000..3925006727 --- /dev/null +++ b/blocksuite/blocks/src/image-block/image-block.ts @@ -0,0 +1,152 @@ +import { CaptionedBlockComponent } from '@blocksuite/affine-components/caption'; +import { Peekable } from '@blocksuite/affine-components/peek'; +import type { ImageBlockModel } from '@blocksuite/affine-model'; +import { IS_MOBILE } from '@blocksuite/global/env'; +import { html } from 'lit'; +import { property, query, state } from 'lit/decorators.js'; +import { styleMap } from 'lit/directives/style-map.js'; +import { when } from 'lit/directives/when.js'; + +import type { ImageBlockFallbackCard } from './components/image-block-fallback.js'; +import type { ImageBlockPageComponent } from './components/page-image-block.js'; +import type { ImageBlockService } from './image-service.js'; +import { + copyImageBlob, + downloadImageBlob, + fetchImageBlob, + turnImageIntoCardView, +} from './utils.js'; + +@Peekable({ + enableOn: () => !IS_MOBILE, +}) +export class ImageBlockComponent extends CaptionedBlockComponent< + ImageBlockModel, + ImageBlockService +> { + convertToCardView = () => { + turnImageIntoCardView(this).catch(console.error); + }; + + copy = () => { + copyImageBlob(this).catch(console.error); + }; + + download = () => { + downloadImageBlob(this).catch(console.error); + }; + + refreshData = () => { + this.retryCount = 0; + fetchImageBlob(this).catch(console.error); + }; + + get resizableImg() { + return this.pageImage?.resizeImg; + } + + private _handleClick(event: MouseEvent) { + // the peek view need handle shift + click + if (event.defaultPrevented) return; + + event.stopPropagation(); + const selectionManager = this.host.selection; + const blockSelection = selectionManager.create('block', { + blockId: this.blockId, + }); + selectionManager.setGroup('note', [blockSelection]); + } + + override connectedCallback() { + super.connectedCallback(); + + this.refreshData(); + this.contentEditable = 'false'; + this._disposables.add( + this.model.propsUpdated.on(({ key }) => { + if (key === 'sourceId') { + this.refreshData(); + } + }) + ); + } + + override disconnectedCallback() { + if (this.blobUrl) { + URL.revokeObjectURL(this.blobUrl); + } + super.disconnectedCallback(); + } + + override firstUpdated() { + // lazy bindings + this.disposables.addFromEvent(this, 'click', this._handleClick); + } + + override renderBlock() { + const containerStyleMap = styleMap({ + position: 'relative', + width: '100%', + }); + + return html` + <div class="affine-image-container" style=${containerStyleMap}> + ${when( + this.loading || this.error, + () => + html`<affine-image-fallback-card + .error=${this.error} + .loading=${this.loading} + .mode=${'page'} + ></affine-image-fallback-card>`, + () => html`<affine-page-image .block=${this}></affine-page-image>` + )} + </div> + + ${Object.values(this.widgets)} + `; + } + + override updated() { + this.fallbackCard?.requestUpdate(); + } + + @property({ attribute: false }) + accessor blob: Blob | undefined = undefined; + + @property({ attribute: false }) + accessor blobUrl: string | undefined = undefined; + + override accessor blockContainerStyles = { margin: '18px 0' }; + + @property({ attribute: false }) + accessor downloading = false; + + @property({ attribute: false }) + accessor error = false; + + @query('affine-image-fallback-card') + accessor fallbackCard: ImageBlockFallbackCard | null = null; + + @state() + accessor lastSourceId!: string; + + @property({ attribute: false }) + accessor loading = false; + + @query('affine-page-image') + private accessor pageImage: ImageBlockPageComponent | null = null; + + @property({ attribute: false }) + accessor retryCount = 0; + + override accessor useCaptionEditor = true; + + override accessor useZeroWidth = true; +} + +declare global { + interface HTMLElementTagNameMap { + 'affine-image': ImageBlockComponent; + } +} diff --git a/blocksuite/blocks/src/image-block/image-edgeless-block.ts b/blocksuite/blocks/src/image-block/image-edgeless-block.ts new file mode 100644 index 0000000000..f88a2297f4 --- /dev/null +++ b/blocksuite/blocks/src/image-block/image-edgeless-block.ts @@ -0,0 +1,161 @@ +import type { BlockCaptionEditor } from '@blocksuite/affine-components/caption'; +import { Peekable } from '@blocksuite/affine-components/peek'; +import type { ImageBlockModel } from '@blocksuite/affine-model'; +import { GfxBlockComponent } from '@blocksuite/block-std'; +import { css, html } from 'lit'; +import { property, query, state } from 'lit/decorators.js'; +import { styleMap } from 'lit/directives/style-map.js'; +import { when } from 'lit/directives/when.js'; + +import type { ImageBlockFallbackCard } from './components/image-block-fallback.js'; +import type { ImageBlockService } from './image-service.js'; +import { + copyImageBlob, + downloadImageBlob, + fetchImageBlob, + resetImageSize, + turnImageIntoCardView, +} from './utils.js'; + +@Peekable() +export class ImageEdgelessBlockComponent extends GfxBlockComponent< + ImageBlockModel, + ImageBlockService +> { + static override styles = css` + affine-edgeless-image .resizable-img, + affine-edgeless-image .resizable-img img { + width: 100%; + height: 100%; + } + `; + + convertToCardView = () => { + turnImageIntoCardView(this).catch(console.error); + }; + + copy = () => { + copyImageBlob(this).catch(console.error); + }; + + download = () => { + downloadImageBlob(this).catch(console.error); + }; + + refreshData = () => { + this.retryCount = 0; + fetchImageBlob(this) + .then(() => { + const { width, height } = this.model; + if (!width || !height) { + return resetImageSize(this); + } + + return; + }) + .catch(console.error); + }; + + private _handleError(error: Error) { + this.dispatchEvent(new CustomEvent('error', { detail: error })); + } + + override connectedCallback() { + super.connectedCallback(); + + this.refreshData(); + this.contentEditable = 'false'; + this.disposables.add( + this.model.propsUpdated.on(({ key }) => { + if (key === 'sourceId') { + this.refreshData(); + } + }) + ); + } + + override disconnectedCallback() { + if (this.blobUrl) { + URL.revokeObjectURL(this.blobUrl); + } + super.disconnectedCallback(); + } + + override renderGfxBlock() { + const rotate = this.model.rotate ?? 0; + const containerStyleMap = styleMap({ + position: 'relative', + width: '100%', + transform: `rotate(${rotate}deg)`, + transformOrigin: 'center', + }); + + return html` + <div class="affine-image-container" style=${containerStyleMap}> + ${when( + this.loading || this.error || !this.blobUrl, + () => + html`<affine-image-fallback-card + .error=${this.error} + .loading=${this.loading} + .mode=${'page'} + ></affine-image-fallback-card>`, + () => + html`<div class="resizable-img"> + <img + class="drag-target" + src=${this.blobUrl ?? ''} + draggable="false" + @error=${this._handleError} + loading="lazy" + /> + </div>` + )} + <affine-block-selection .block=${this}></affine-block-selection> + </div> + <block-caption-editor></block-caption-editor> + + ${Object.values(this.widgets)} + `; + } + + override updated() { + this.fallbackCard?.requestUpdate(); + } + + @property({ attribute: false }) + accessor blob: Blob | undefined = undefined; + + @property({ attribute: false }) + accessor blobUrl: string | undefined = undefined; + + @query('block-caption-editor') + accessor captionEditor!: BlockCaptionEditor | null; + + @property({ attribute: false }) + accessor downloading = false; + + @property({ attribute: false }) + accessor error = false; + + @query('affine-image-fallback-card') + accessor fallbackCard: ImageBlockFallbackCard | null = null; + + @state() + accessor lastSourceId!: string; + + @property({ attribute: false }) + accessor loading = false; + + @query('.resizable-img') + accessor resizableImg!: HTMLDivElement; + + @property({ attribute: false }) + accessor retryCount = 0; +} + +declare global { + interface HTMLElementTagNameMap { + 'affine-edgeless-image': ImageEdgelessBlockComponent; + } +} diff --git a/blocksuite/blocks/src/image-block/image-resize-manager.ts b/blocksuite/blocks/src/image-block/image-resize-manager.ts new file mode 100644 index 0000000000..3b2752e7bf --- /dev/null +++ b/blocksuite/blocks/src/image-block/image-resize-manager.ts @@ -0,0 +1,99 @@ +import { DocModeProvider } from '@blocksuite/affine-shared/services'; +import { + getClosestBlockComponentByElement, + getModelByElement, +} from '@blocksuite/affine-shared/utils'; +import type { BlockComponent, PointerEventState } from '@blocksuite/block-std'; +import { assertExists } from '@blocksuite/global/utils'; + +import type { EdgelessRootBlockComponent } from '../root-block/index.js'; +import { getClosestRootBlockComponent } from '../root-block/utils/query.js'; + +export class ImageResizeManager { + private _activeComponent: BlockComponent | null = null; + + private _dragMoveTarget = 'right'; + + private _imageCenterX = 0; + + private _imageContainer: HTMLElement | null = null; + + private _zoom = 1; + + onEnd() { + assertExists(this._activeComponent); + assertExists(this._imageContainer); + + const dragModel = getModelByElement(this._activeComponent); + dragModel?.page.captureSync(); + const { width, height } = this._imageContainer.getBoundingClientRect(); + dragModel?.page.updateBlock(dragModel, { + width: width / this._zoom, + height: height / this._zoom, + }); + } + + onMove(e: PointerEventState) { + assertExists(this._activeComponent); + const activeComponent = this._activeComponent; + const activeImgContainer = this._imageContainer; + assertExists(activeImgContainer); + const activeImg = activeComponent.querySelector('img'); + assertExists(activeImg); + + let width = 0; + if (this._dragMoveTarget === 'right') { + width = (e.raw.pageX - this._imageCenterX) * 2; + } else { + width = (this._imageCenterX - e.raw.pageX) * 2; + } + + const MIN_WIDTH = 50; + if (width < MIN_WIDTH) { + width = MIN_WIDTH; + } + if (width > activeComponent.getBoundingClientRect().width) { + width = activeComponent.getBoundingClientRect().width; + } + + const height = width * (activeImg.naturalHeight / activeImg.naturalWidth); + + const containerRect = activeImgContainer.getBoundingClientRect(); + if (containerRect.width === width && containerRect.height === height) + return; + + requestAnimationFrame(() => { + activeImgContainer.style.width = (width / this._zoom).toFixed(2) + 'px'; + }); + } + + onStart(e: PointerEventState) { + const eventTarget = e.raw.target as HTMLElement; + this._activeComponent = getClosestBlockComponentByElement( + eventTarget + ) as BlockComponent; + + const rootComponent = getClosestRootBlockComponent(this._activeComponent); + if ( + rootComponent && + rootComponent.service.std.get(DocModeProvider).getEditorMode() === + 'edgeless' + ) { + this._zoom = ( + rootComponent as EdgelessRootBlockComponent + ).service.viewport.zoom; + } else { + this._zoom = 1; + } + + this._imageContainer = eventTarget.closest('.resizable-img'); + assertExists(this._imageContainer); + const rect = this._imageContainer.getBoundingClientRect() as DOMRect; + this._imageCenterX = rect.left + rect.width / 2; + if (eventTarget.className.includes('right')) { + this._dragMoveTarget = 'right'; + } else { + this._dragMoveTarget = 'left'; + } + } +} diff --git a/blocksuite/blocks/src/image-block/image-service.ts b/blocksuite/blocks/src/image-block/image-service.ts new file mode 100644 index 0000000000..90a91e952a --- /dev/null +++ b/blocksuite/blocks/src/image-block/image-service.ts @@ -0,0 +1,107 @@ +import { ImageBlockSchema } from '@blocksuite/affine-model'; +import { + DragHandleConfigExtension, + TelemetryProvider, +} from '@blocksuite/affine-shared/services'; +import { + captureEventTarget, + convertDragPreviewDocToEdgeless, + convertDragPreviewEdgelessToDoc, + isInsideEdgelessEditor, + matchFlavours, +} from '@blocksuite/affine-shared/utils'; +import { BlockService } from '@blocksuite/block-std'; +import { GfxControllerIdentifier } from '@blocksuite/block-std/gfx'; + +import { + FileDropManager, + type FileDropOptions, +} from '../_common/components/file-drop-manager.js'; +import { setImageProxyMiddlewareURL } from '../_common/transformers/middlewares.js'; +import { addImages } from '../root-block/edgeless/utils/common.js'; +import type { ImageBlockComponent } from './image-block.js'; +import { ImageEdgelessBlockComponent } from './image-edgeless-block.js'; +import { addSiblingImageBlock } from './utils.js'; + +export class ImageBlockService extends BlockService { + static override readonly flavour = ImageBlockSchema.model.flavour; + + static setImageProxyURL = setImageProxyMiddlewareURL; + + private _fileDropOptions: FileDropOptions = { + flavour: this.flavour, + onDrop: async ({ files, targetModel, place, point }) => { + const imageFiles = files.filter(file => file.type.startsWith('image/')); + if (!imageFiles.length) return false; + + if (targetModel && !matchFlavours(targetModel, ['affine:surface'])) { + addSiblingImageBlock( + this.host, + imageFiles, + this.maxFileSize, + targetModel, + place + ); + } else if (isInsideEdgelessEditor(this.host)) { + const gfx = this.std.get(GfxControllerIdentifier); + point = gfx.viewport.toViewCoordFromClientCoord(point); + await addImages(this.std, files, point); + + this.std.getOptional(TelemetryProvider)?.track('CanvasElementAdded', { + control: 'canvas:drop', + page: 'whiteboard editor', + module: 'toolbar', + segment: 'toolbar', + type: 'image', + }); + } + + return true; + }, + }; + + fileDropManager!: FileDropManager; + + maxFileSize = 10 * 1000 * 1000; // 10MB (default) + + override mounted(): void { + super.mounted(); + + this.fileDropManager = new FileDropManager(this, this._fileDropOptions); + } +} + +export const ImageDragHandleOption = DragHandleConfigExtension({ + flavour: ImageBlockSchema.model.flavour, + edgeless: true, + onDragEnd: props => { + const { state, draggingElements } = props; + if ( + draggingElements.length !== 1 || + !matchFlavours(draggingElements[0].model, [ + ImageBlockSchema.model.flavour, + ]) + ) + return false; + + const blockComponent = draggingElements[0] as ImageBlockComponent; + const isInSurface = blockComponent instanceof ImageEdgelessBlockComponent; + const target = captureEventTarget(state.raw.target); + const isTargetEdgelessContainer = + target?.classList.contains('edgeless-container'); + + if (isInSurface) { + return convertDragPreviewEdgelessToDoc({ + blockComponent, + ...props, + }); + } else if (isTargetEdgelessContainer) { + return convertDragPreviewDocToEdgeless({ + blockComponent, + cssSelector: '.drag-target', + ...props, + }); + } + return false; + }, +}); diff --git a/blocksuite/blocks/src/image-block/image-spec.ts b/blocksuite/blocks/src/image-block/image-spec.ts new file mode 100644 index 0000000000..30e73efa9d --- /dev/null +++ b/blocksuite/blocks/src/image-block/image-spec.ts @@ -0,0 +1,32 @@ +import { ImageSelectionExtension } from '@blocksuite/affine-shared/selection'; +import { + BlockViewExtension, + CommandExtension, + type ExtensionType, + FlavourExtension, + WidgetViewMapExtension, +} from '@blocksuite/block-std'; +import { literal } from 'lit/static-html.js'; + +import { commands } from './commands/index.js'; +import { ImageBlockService, ImageDragHandleOption } from './image-service.js'; + +export const ImageBlockSpec: ExtensionType[] = [ + FlavourExtension('affine:image'), + ImageBlockService, + CommandExtension(commands), + BlockViewExtension('affine:image', model => { + const parent = model.doc.getParent(model.id); + + if (parent?.flavour === 'affine:surface') { + return literal`affine-edgeless-image`; + } + + return literal`affine-image`; + }), + WidgetViewMapExtension('affine:image', { + imageToolbar: literal`affine-image-toolbar-widget`, + }), + ImageDragHandleOption, + ImageSelectionExtension, +]; diff --git a/blocksuite/blocks/src/image-block/index.ts b/blocksuite/blocks/src/image-block/index.ts new file mode 100644 index 0000000000..b80e0df52e --- /dev/null +++ b/blocksuite/blocks/src/image-block/index.ts @@ -0,0 +1,6 @@ +export * from './adapters/markdown.js'; +export * from './image-block.js'; +export * from './image-edgeless-block.js'; +export * from './image-service.js'; +export { uploadBlobForImage } from './utils.js'; +export { ImageSelection } from '@blocksuite/affine-shared/selection'; diff --git a/blocksuite/blocks/src/image-block/styles.ts b/blocksuite/blocks/src/image-block/styles.ts new file mode 100644 index 0000000000..2717e1e057 --- /dev/null +++ b/blocksuite/blocks/src/image-block/styles.ts @@ -0,0 +1,72 @@ +import { html } from 'lit'; + +export const LoadingIcon = html`<svg + width="16" + height="16" + viewBox="0 0 16 16" + xmlns="http://www.w3.org/2000/svg" +> + <style xmlns="http://www.w3.org/2000/svg"> + .spinner { + transform-origin: center; + animation: spinner_animate 0.75s infinite linear; + } + @keyframes spinner_animate { + 100% { + transform: rotate(360deg); + } + } + </style> + <path + d="M14.6666 7.99992C14.6666 11.6818 11.6818 14.6666 7.99992 14.6666C4.31802 14.6666 1.33325 11.6818 1.33325 7.99992C1.33325 4.31802 4.31802 1.33325 7.99992 1.33325C11.6818 1.33325 14.6666 4.31802 14.6666 7.99992ZM3.30003 7.99992C3.30003 10.5956 5.40424 12.6998 7.99992 12.6998C10.5956 12.6998 12.6998 10.5956 12.6998 7.99992C12.6998 5.40424 10.5956 3.30003 7.99992 3.30003C5.40424 3.30003 3.30003 5.40424 3.30003 7.99992Z" + fill-opacity="0.1" + /> + <path + d="M13.6833 7.99992C14.2263 7.99992 14.674 7.55732 14.5942 7.02014C14.5142 6.48171 14.3684 5.95388 14.1591 5.4487C13.8241 4.63986 13.333 3.90493 12.714 3.28587C12.0949 2.66682 11.36 2.17575 10.5511 1.84072C10.046 1.63147 9.51812 1.48564 8.9797 1.40564C8.44251 1.32583 7.99992 1.77351 7.99992 2.31659C7.99992 2.85967 8.44486 3.28962 8.9761 3.40241C9.25681 3.46201 9.53214 3.54734 9.79853 3.65768C10.3688 3.89388 10.8869 4.24008 11.3233 4.67652C11.7598 5.11295 12.106 5.63108 12.3422 6.20131C12.4525 6.4677 12.5378 6.74303 12.5974 7.02374C12.7102 7.55498 13.1402 7.99992 13.6833 7.99992Z" + fill="#1E96EB" + class="spinner" + /> +</svg>`; + +export const ImageIcon = html`<svg + width="16" + height="16" + viewBox="0 0 16 16" + fill="none" + xmlns="http://www.w3.org/2000/svg" +> + <path + fill-rule="evenodd" + clip-rule="evenodd" + d="M4 2.16667C2.98748 2.16667 2.16667 2.98748 2.16667 4V10.6667V12C2.16667 13.0125 2.98748 13.8333 4 13.8333H12C13.0125 13.8333 13.8333 13.0125 13.8333 12V9.33333V4C13.8333 2.98748 13.0125 2.16667 12 2.16667H4ZM3.16667 12V10.8738L6.07741 7.96303C6.40285 7.63759 6.93048 7.63759 7.25592 7.96303L8.97978 9.68689L10.3131 11.0202C10.5084 11.2155 10.825 11.2155 11.0202 11.0202C11.2155 10.825 11.2155 10.5084 11.0202 10.3131L10.0404 9.33333L10.7441 8.6297C11.0695 8.30426 11.5972 8.30426 11.9226 8.6297L12.8333 9.54044V12C12.8333 12.4602 12.4602 12.8333 12 12.8333H4C3.53976 12.8333 3.16667 12.4602 3.16667 12ZM7.96303 7.25592L9.33333 8.62623L10.037 7.92259C10.7529 7.20663 11.9137 7.20663 12.6297 7.92259L12.8333 8.12623V4C12.8333 3.53976 12.4602 3.16667 12 3.16667H4C3.53976 3.16667 3.16667 3.53976 3.16667 4V9.45956L5.3703 7.25592C6.08626 6.53996 7.24707 6.53996 7.96303 7.25592ZM9.33333 6C9.70152 6 10 5.70152 10 5.33333C10 4.96514 9.70152 4.66667 9.33333 4.66667C8.96514 4.66667 8.66667 4.96514 8.66667 5.33333C8.66667 5.70152 8.96514 6 9.33333 6Z" + fill="#77757D" + /> +</svg> `; + +export const FailedImageIcon = html`<svg + width="16" + height="16" + viewBox="0 0 16 16" + fill="none" + xmlns="http://www.w3.org/2000/svg" +> + <path + fill-rule="evenodd" + clip-rule="evenodd" + d="M2.1665 4.00008C2.1665 2.98756 2.98732 2.16675 3.99984 2.16675H11.9998C13.0124 2.16675 13.8332 2.98756 13.8332 4.00008V7.33341C13.8332 7.60956 13.6093 7.83341 13.3332 7.83341C13.057 7.83341 12.8332 7.60956 12.8332 7.33341V4.00008C12.8332 3.53984 12.4601 3.16675 11.9998 3.16675H3.99984C3.5396 3.16675 3.1665 3.53984 3.1665 4.00008V9.45964L5.37014 7.256C6.0861 6.54004 7.2469 6.54004 7.96287 7.256L8.35339 7.64653C8.54865 7.84179 8.54865 8.15837 8.35339 8.35363C8.15813 8.5489 7.84155 8.5489 7.64628 8.35363L7.25576 7.96311C6.93032 7.63767 6.40268 7.63767 6.07725 7.96311L3.1665 10.8739V12.0001C3.1665 12.4603 3.5396 12.8334 3.99984 12.8334H7.33317C7.60931 12.8334 7.83317 13.0573 7.83317 13.3334C7.83317 13.6096 7.60931 13.8334 7.33317 13.8334H3.99984C2.98732 13.8334 2.1665 13.0126 2.1665 12.0001V4.00008Z" + fill="#77757D" + fill-opacity="0.6" + /> + <path + d="M9.99984 5.33341C9.99984 5.7016 9.70136 6.00008 9.33317 6.00008C8.96498 6.00008 8.6665 5.7016 8.6665 5.33341C8.6665 4.96522 8.96498 4.66675 9.33317 4.66675C9.70136 4.66675 9.99984 4.96522 9.99984 5.33341Z" + fill="#77757D" + fill-opacity="0.6" + /> + <path + fill-rule="evenodd" + clip-rule="evenodd" + d="M8.97962 8.97986C9.17488 8.7846 9.49146 8.7846 9.68672 8.97986L11.3332 10.6263L12.9796 8.97986C13.1749 8.7846 13.4915 8.7846 13.6867 8.97986C13.882 9.17512 13.882 9.49171 13.6867 9.68697L12.0403 11.3334L13.6867 12.9799C13.882 13.1751 13.882 13.4917 13.6867 13.687C13.4915 13.8822 13.1749 13.8822 12.9796 13.687L11.3332 12.0405L9.68672 13.687C9.49146 13.8822 9.17488 13.8822 8.97962 13.687C8.78435 13.4917 8.78435 13.1751 8.97962 12.9799L10.6261 11.3334L8.97962 9.68697C8.78435 9.49171 8.78435 9.17512 8.97962 8.97986Z" + fill="#77757D" + fill-opacity="0.6" + /> +</svg> `; diff --git a/blocksuite/blocks/src/image-block/utils.ts b/blocksuite/blocks/src/image-block/utils.ts new file mode 100644 index 0000000000..6286ee9ae5 --- /dev/null +++ b/blocksuite/blocks/src/image-block/utils.ts @@ -0,0 +1,400 @@ +import { toast } from '@blocksuite/affine-components/toast'; +import type { + AttachmentBlockProps, + ImageBlockModel, + ImageBlockProps, +} from '@blocksuite/affine-model'; +import { + downloadBlob, + humanFileSize, + withTempBlobData, +} from '@blocksuite/affine-shared/utils'; +import type { EditorHost } from '@blocksuite/block-std'; +import { BlockSuiteError, ErrorCode } from '@blocksuite/global/exceptions'; +import type { BlockModel } from '@blocksuite/store'; + +import { readImageSize } from '../root-block/edgeless/components/utils.js'; +import { transformModel } from '../root-block/utils/operations/model.js'; +import type { ImageBlockComponent } from './image-block.js'; +import type { ImageEdgelessBlockComponent } from './image-edgeless-block.js'; + +const MAX_RETRY_COUNT = 3; +const DEFAULT_ATTACHMENT_NAME = 'affine-attachment'; + +const imageUploads = new Set<string>(); +export function setImageUploading(blockId: string) { + imageUploads.add(blockId); +} +export function setImageUploaded(blockId: string) { + imageUploads.delete(blockId); +} +export function isImageUploading(blockId: string) { + return imageUploads.has(blockId); +} + +export async function uploadBlobForImage( + editorHost: EditorHost, + blockId: string, + blob: Blob +): Promise<void> { + if (isImageUploading(blockId)) { + console.error('The image is already uploading!'); + return; + } + setImageUploading(blockId); + const doc = editorHost.doc; + let sourceId: string | undefined; + + try { + sourceId = await doc.blobSync.set(blob); + } catch (error) { + console.error(error); + if (error instanceof Error) { + toast( + editorHost, + `Failed to upload image! ${error.message || error.toString()}` + ); + } + } finally { + setImageUploaded(blockId); + + const imageModel = doc.getBlockById(blockId) as ImageBlockModel | null; + + doc.withoutTransact(() => { + if (!imageModel) { + return; + } + doc.updateBlock(imageModel, { + sourceId, + } satisfies Partial<ImageBlockProps>); + }); + } +} + +async function getImageBlob(model: ImageBlockModel) { + const sourceId = model.sourceId; + if (!sourceId) { + return null; + } + + const doc = model.doc; + const blob = await doc.blobSync.get(sourceId); + + if (!blob) { + return null; + } + + if (!blob.type) { + const buffer = await blob.arrayBuffer(); + const FileType = await import('file-type'); + const fileType = await FileType.fileTypeFromBuffer(buffer); + if (!fileType?.mime.startsWith('image/')) { + return null; + } + + return new Blob([buffer], { type: fileType.mime }); + } + + if (!blob.type.startsWith('image/')) { + return null; + } + + return blob; +} + +export async function fetchImageBlob( + block: ImageBlockComponent | ImageEdgelessBlockComponent +) { + try { + if (block.model.sourceId !== block.lastSourceId || !block.blobUrl) { + block.loading = true; + block.error = false; + block.blob = undefined; + + if (block.blobUrl) { + URL.revokeObjectURL(block.blobUrl); + block.blobUrl = undefined; + } + } else if (block.blobUrl) { + return; + } + + const { model } = block; + const { id, sourceId, doc } = model; + + if (isImageUploading(id)) { + return; + } + + if (!sourceId) { + return; + } + + const blob = await doc.blobSync.get(sourceId); + if (!blob) { + return; + } + + block.loading = false; + block.blob = blob; + block.blobUrl = URL.createObjectURL(blob); + block.lastSourceId = sourceId; + } catch (error) { + block.retryCount++; + console.warn(`${error}, retrying`, block.retryCount); + + if (block.retryCount < MAX_RETRY_COUNT) { + setTimeout(() => { + fetchImageBlob(block).catch(console.error); + // 1s, 2s, 3s + }, 1000 * block.retryCount); + } else { + block.loading = false; + block.error = true; + } + } +} + +export async function downloadImageBlob( + block: ImageBlockComponent | ImageEdgelessBlockComponent +) { + const { host, downloading } = block; + if (downloading) { + toast(host, 'Download in progress...'); + return; + } + + block.downloading = true; + + const blob = await getImageBlob(block.model); + if (!blob) { + toast(host, `Unable to download image!`); + return; + } + + toast(host, `Downloading image...`); + + downloadBlob(blob, 'image'); + + block.downloading = false; +} + +export async function resetImageSize( + block: ImageBlockComponent | ImageEdgelessBlockComponent +) { + const { blob, model } = block; + if (!blob) { + return; + } + + const file = new File([blob], 'image.png', { type: blob.type }); + const size = await readImageSize(file); + block.doc.updateBlock(model, { + width: size.width, + height: size.height, + }); +} + +function convertToString(blob: Blob): Promise<string | null> { + return new Promise(resolve => { + const reader = new FileReader(); + reader.addEventListener('load', _ => resolve(reader.result as string)); + reader.addEventListener('error', () => resolve(null)); + reader.readAsDataURL(blob); + }); +} + +function convertToPng(blob: Blob): Promise<Blob | null> { + return new Promise(resolve => { + const reader = new FileReader(); + reader.addEventListener('load', _ => { + const img = new Image(); + img.onload = () => { + const c = document.createElement('canvas'); + c.width = img.width; + c.height = img.height; + const ctx = c.getContext('2d'); + if (!ctx) return; + ctx.drawImage(img, 0, 0); + c.toBlob(resolve, 'image/png'); + }; + img.onerror = () => resolve(null); + img.src = reader.result as string; + }); + reader.addEventListener('error', () => resolve(null)); + reader.readAsDataURL(blob); + }); +} + +export async function copyImageBlob( + block: ImageBlockComponent | ImageEdgelessBlockComponent +) { + const { host, model } = block; + let blob = await getImageBlob(model); + if (!blob) { + console.error('Failed to get image blob'); + return; + } + + try { + // @ts-expect-error FIXME: ts error + if (window.apis?.clipboard?.copyAsImageFromString) { + const dataURL = await convertToString(blob); + if (!dataURL) + throw new BlockSuiteError( + ErrorCode.DefaultRuntimeError, + 'Cant convert a blob to data URL.' + ); + // @ts-expect-error FIXME: ts error + await window.apis.clipboard?.copyAsImageFromString(dataURL); + } else { + // DOMException: Type image/jpeg not supported on write. + if (blob.type !== 'image/png') { + const pngBlob = await convertToPng(blob); + if (!pngBlob) { + console.error('Failed to convert blob to PNG'); + return; + } + blob = pngBlob; + } + + if (!globalThis.isSecureContext) { + console.error( + 'Clipboard API is not available in insecure context', + blob.type, + blob + ); + return; + } + + await navigator.clipboard.write([ + new ClipboardItem({ [blob.type]: blob }), + ]); + } + + toast(host, 'Copied image to clipboard'); + } catch (error) { + console.error(error); + } +} + +export function shouldResizeImage(node: Node, target: EventTarget | null) { + return !!( + target && + target instanceof HTMLElement && + node.contains(target) && + target.classList.contains('resize') + ); +} + +export function addSiblingImageBlock( + editorHost: EditorHost, + files: File[], + maxFileSize: number, + targetModel: BlockModel, + place: 'after' | 'before' = 'after' +) { + const imageFiles = files.filter(file => file.type.startsWith('image/')); + if (!imageFiles.length) { + return; + } + + const isSizeExceeded = imageFiles.some(file => file.size > maxFileSize); + if (isSizeExceeded) { + toast( + editorHost, + `You can only upload files less than ${humanFileSize( + maxFileSize, + true, + 0 + )}` + ); + return; + } + + const imageBlockProps: Partial<ImageBlockProps> & + { + flavour: 'affine:image'; + }[] = imageFiles.map(file => ({ + flavour: 'affine:image', + size: file.size, + })); + + const doc = editorHost.doc; + const blockIds = doc.addSiblingBlocks(targetModel, imageBlockProps, place); + blockIds.forEach( + (blockId, index) => + void uploadBlobForImage(editorHost, blockId, imageFiles[index]) + ); + return blockIds; +} + +export function addImageBlocks( + editorHost: EditorHost, + files: File[], + maxFileSize: number, + parent?: BlockModel | string | null, + parentIndex?: number +) { + const imageFiles = files.filter(file => file.type.startsWith('image/')); + if (!imageFiles.length) { + return; + } + + const isSizeExceeded = imageFiles.some(file => file.size > maxFileSize); + if (isSizeExceeded) { + toast( + editorHost, + `You can only upload files less than ${humanFileSize( + maxFileSize, + true, + 0 + )}` + ); + return; + } + + const doc = editorHost.doc; + const blockIds = imageFiles.map(file => + doc.addBlock('affine:image', { size: file.size }, parent, parentIndex) + ); + blockIds.forEach( + (blockId, index) => + void uploadBlobForImage(editorHost, blockId, imageFiles[index]) + ); + return blockIds; +} + +/** + * Turn the image block into a attachment block. + */ +export async function turnImageIntoCardView( + block: ImageBlockComponent | ImageEdgelessBlockComponent +) { + const doc = block.doc; + if (!doc.schema.flavourSchemaMap.has('affine:attachment')) { + console.error('The attachment flavour is not supported!'); + return; + } + + const model = block.model; + const sourceId = model.sourceId; + const blob = await getImageBlob(model); + if (!sourceId || !blob) { + console.error('Image data not available'); + return; + } + + const { saveImageData, getAttachmentData } = withTempBlobData(); + saveImageData(sourceId, { width: model.width, height: model.height }); + const attachmentConvertData = getAttachmentData(sourceId); + const attachmentProp: Partial<AttachmentBlockProps> = { + sourceId, + name: DEFAULT_ATTACHMENT_NAME, + size: blob.size, + type: blob.type, + caption: model.caption, + ...attachmentConvertData, + }; + transformModel(model, 'affine:attachment', attachmentProp); +} diff --git a/blocksuite/blocks/src/index.ts b/blocksuite/blocks/src/index.ts new file mode 100644 index 0000000000..ad3d57427b --- /dev/null +++ b/blocksuite/blocks/src/index.ts @@ -0,0 +1,138 @@ +/* eslint-disable @typescript-eslint/triple-slash-reference */ +/// <reference path="./effects.ts" /> +import { deserializeXYWH, Point } from '@blocksuite/global/utils'; + +import { matchFlavours } from './_common/utils/index.js'; +import { splitElements } from './root-block/edgeless/utils/clipboard-utils.js'; +import { isCanvasElement } from './root-block/edgeless/utils/query.js'; + +export * from './_common/adapters/index.js'; +export * from './_common/components/ai-item/index.js'; +export { scrollbarStyle } from './_common/components/index.js'; +export { type NavigatorMode } from './_common/edgeless/frame/consts.js'; +export { + ExportManager, + ExportManagerExtension, +} from './_common/export-manager/export-manager.js'; +export * from './_common/test-utils/test-utils.js'; +export * from './_common/transformers/index.js'; +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 './code-block/index.js'; +export * from './data-view-block/index.js'; +export * from './database-block/index.js'; +export * from './divider-block/index.js'; +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 './note-block/index.js'; +export { EdgelessTemplatePanel } from './root-block/edgeless/components/toolbar/template/template-panel.js'; +export type { + Template, + TemplateCategory, + TemplateManager, +} from './root-block/edgeless/components/toolbar/template/template-type.js'; +export { + EdgelessFrameManager, + FrameOverlay, +} from './root-block/edgeless/frame-manager.js'; +export { CopilotTool } from './root-block/edgeless/gfx-tool/copilot-tool.js'; +export * from './root-block/edgeless/gfx-tool/index.js'; +export { EditPropsMiddlewareBuilder } from './root-block/edgeless/middlewares/base.js'; +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 './schemas.js'; +export { + markdownToMindmap, + MindmapSurfaceBlock, + MiniMindmapPreview, +} from './surface-block/mini-mindmap/index.js'; +export * from './surface-ref-block/index.js'; +export * from '@blocksuite/affine-block-embed'; +export * from '@blocksuite/affine-block-list'; +export * from '@blocksuite/affine-block-paragraph'; +export * from '@blocksuite/affine-block-surface'; +export { type MenuOptions } from '@blocksuite/affine-components/context-menu'; +export { + HoverController, + whenHover, +} from '@blocksuite/affine-components/hover'; +export { + ArrowDownSmallIcon, + CloseIcon, + DocIcon, + DualLinkIcon16, + LinkedDocIcon, + PlusIcon, + TagsIcon, +} from '@blocksuite/affine-components/icons'; +export * from '@blocksuite/affine-components/icons'; +export * from '@blocksuite/affine-components/peek'; +export { + createLitPortal, + createSimplePortal, +} from '@blocksuite/affine-components/portal'; +export * from '@blocksuite/affine-components/rich-text'; +export { toast } from '@blocksuite/affine-components/toast'; +export { + type AdvancedMenuItem, + type FatMenuItems, + groupsToActions, + type MenuItem, + type MenuItemGroup, + renderActions, + renderGroups, + renderToolbarSeparator, + Tooltip, +} from '@blocksuite/affine-components/toolbar'; +export * from '@blocksuite/affine-model'; +export * from '@blocksuite/affine-shared/services'; +export { + ColorVariables, + FontFamilyVariables, + SizeVariables, + StyleVariables, +} from '@blocksuite/affine-shared/theme'; +export { + createButtonPopper, + createDefaultDoc, + findNoteBlockModel, + isInsideEdgelessEditor, + isInsidePageEditor, + matchFlavours, + on, + once, + openFileOrFiles, + printToPdf, +} from '@blocksuite/affine-shared/utils'; + +export const BlocksUtils = { + splitElements, + matchFlavours, + deserializeXYWH, + isCanvasElement, + Point, +}; + +const env: Record<string, unknown> = + typeof globalThis !== 'undefined' + ? globalThis + : typeof window !== 'undefined' + ? window + : typeof global !== 'undefined' + ? global + : {}; +const importIdentifier = '__ $BLOCKSUITE_BLOCKS$ __'; + +if (env[importIdentifier] === true) { + // https://github.com/yjs/yjs/issues/438 + console.error( + '@blocksuite/blocks was already imported. This breaks constructor checks and will lead to issues!' + ); +} + +env[importIdentifier] = true; diff --git a/blocksuite/blocks/src/latex-block/adapters/index.ts b/blocksuite/blocks/src/latex-block/adapters/index.ts new file mode 100644 index 0000000000..b30abd9352 --- /dev/null +++ b/blocksuite/blocks/src/latex-block/adapters/index.ts @@ -0,0 +1,3 @@ +export * from './markdown.js'; +export * from './notion-html.js'; +export * from './plain-text.js'; diff --git a/blocksuite/blocks/src/latex-block/adapters/markdown.ts b/blocksuite/blocks/src/latex-block/adapters/markdown.ts new file mode 100644 index 0000000000..a14643b9d4 --- /dev/null +++ b/blocksuite/blocks/src/latex-block/adapters/markdown.ts @@ -0,0 +1,55 @@ +import { LatexBlockSchema } from '@blocksuite/affine-model'; +import { + BlockMarkdownAdapterExtension, + type BlockMarkdownAdapterMatcher, + type MarkdownAST, +} from '@blocksuite/affine-shared/adapters'; +import { nanoid } from '@blocksuite/store'; + +const isLatexNode = (node: MarkdownAST) => node.type === 'math'; + +export const latexBlockMarkdownAdapterMatcher: BlockMarkdownAdapterMatcher = { + flavour: LatexBlockSchema.model.flavour, + toMatch: o => isLatexNode(o.node), + fromMatch: o => o.node.flavour === LatexBlockSchema.model.flavour, + toBlockSnapshot: { + enter: (o, context) => { + const latex = 'value' in o.node ? o.node.value : ''; + const { walkerContext } = context; + walkerContext + .openNode( + { + type: 'block', + id: nanoid(), + flavour: 'affine:latex', + props: { + latex, + }, + children: [], + }, + 'children' + ) + .closeNode(); + }, + }, + fromBlockSnapshot: { + enter: (o, context) => { + const latex = + 'latex' in o.node.props ? (o.node.props.latex as string) : ''; + const { walkerContext } = context; + walkerContext + .openNode( + { + type: 'math', + value: latex, + }, + 'children' + ) + .closeNode(); + }, + }, +}; + +export const LatexBlockMarkdownAdapterExtension = BlockMarkdownAdapterExtension( + latexBlockMarkdownAdapterMatcher +); diff --git a/blocksuite/blocks/src/latex-block/adapters/notion-html.ts b/blocksuite/blocks/src/latex-block/adapters/notion-html.ts new file mode 100644 index 0000000000..7ef8b375a5 --- /dev/null +++ b/blocksuite/blocks/src/latex-block/adapters/notion-html.ts @@ -0,0 +1,50 @@ +import { LatexBlockSchema } from '@blocksuite/affine-model'; +import { + BlockNotionHtmlAdapterExtension, + type BlockNotionHtmlAdapterMatcher, + HastUtils, +} from '@blocksuite/affine-shared/adapters'; +import { nanoid } from '@blocksuite/store'; + +export const latexBlockNotionHtmlAdapterMatcher: BlockNotionHtmlAdapterMatcher = + { + flavour: LatexBlockSchema.model.flavour, + toMatch: o => { + return ( + HastUtils.isElement(o.node) && + o.node.tagName === 'figure' && + !!HastUtils.querySelector(o.node, '.equation-container') + ); + }, + fromMatch: () => false, + toBlockSnapshot: { + enter: (o, context) => { + if (!HastUtils.isElement(o.node)) { + return; + } + const { walkerContext } = context; + const latex = HastUtils.getTextContent( + HastUtils.querySelector(o.node, 'annotation') + ); + walkerContext + .openNode( + { + type: 'block', + id: nanoid(), + flavour: LatexBlockSchema.model.flavour, + props: { + latex, + }, + children: [], + }, + 'children' + ) + .closeNode(); + walkerContext.skipAllChildren(); + }, + }, + fromBlockSnapshot: {}, + }; + +export const LatexBlockNotionHtmlAdapterExtension = + BlockNotionHtmlAdapterExtension(latexBlockNotionHtmlAdapterMatcher); diff --git a/blocksuite/blocks/src/latex-block/adapters/plain-text.ts b/blocksuite/blocks/src/latex-block/adapters/plain-text.ts new file mode 100644 index 0000000000..045d8144dc --- /dev/null +++ b/blocksuite/blocks/src/latex-block/adapters/plain-text.ts @@ -0,0 +1,29 @@ +import { LatexBlockSchema } from '@blocksuite/affine-model'; +import { + BlockPlainTextAdapterExtension, + type BlockPlainTextAdapterMatcher, +} from '@blocksuite/affine-shared/adapters'; + +const latexPrefix = 'LaTex, with value: '; + +export const latexBlockPlainTextAdapterMatcher: BlockPlainTextAdapterMatcher = { + flavour: LatexBlockSchema.model.flavour, + toMatch: () => false, + fromMatch: o => o.node.flavour === LatexBlockSchema.model.flavour, + toBlockSnapshot: {}, + fromBlockSnapshot: { + enter: (o, context) => { + const latex = + 'latex' in o.node.props ? (o.node.props.latex as string) : ''; + + const { textBuffer } = context; + if (latex) { + textBuffer.content += `${latexPrefix}${latex}`; + textBuffer.content += '\n'; + } + }, + }, +}; + +export const LatexBlockPlainTextAdapterExtension = + BlockPlainTextAdapterExtension(latexBlockPlainTextAdapterMatcher); diff --git a/blocksuite/blocks/src/latex-block/commands.ts b/blocksuite/blocks/src/latex-block/commands.ts new file mode 100644 index 0000000000..994839e002 --- /dev/null +++ b/blocksuite/blocks/src/latex-block/commands.ts @@ -0,0 +1,57 @@ +import type { LatexProps } from '@blocksuite/affine-model'; +import type { BlockCommands, Command } from '@blocksuite/block-std'; +import { assertInstanceOf } from '@blocksuite/global/utils'; + +import { LatexBlockComponent } from './latex-block.js'; + +export const insertLatexBlockCommand: Command< + 'selectedModels', + 'insertedLatexBlockId', + { + latex?: string; + place?: 'after' | 'before'; + removeEmptyLine?: boolean; + } +> = (ctx, next) => { + const { selectedModels, latex, place, removeEmptyLine, std } = ctx; + if (!selectedModels?.length) return; + + const targetModel = + place === 'before' + ? selectedModels[0] + : selectedModels[selectedModels.length - 1]; + + const latexBlockProps: Partial<LatexProps> & { + flavour: 'affine:latex'; + } = { + flavour: 'affine:latex', + latex: latex ?? '', + }; + + const result = std.doc.addSiblingBlocks( + targetModel, + [latexBlockProps], + place + ); + if (result.length === 0) return; + + if (removeEmptyLine && targetModel.text?.length === 0) { + std.doc.deleteBlock(targetModel); + } + + next({ + insertedLatexBlockId: std.host.updateComplete.then(async () => { + if (!latex) { + const blockComponent = std.view.getBlock(result[0]); + assertInstanceOf(blockComponent, LatexBlockComponent); + await blockComponent.updateComplete; + blockComponent.toggleEditor(); + } + return result[0]; + }), + }); +}; + +export const commands: BlockCommands = { + insertLatexBlock: insertLatexBlockCommand, +}; diff --git a/blocksuite/blocks/src/latex-block/effects.ts b/blocksuite/blocks/src/latex-block/effects.ts new file mode 100644 index 0000000000..90ace3b2cb --- /dev/null +++ b/blocksuite/blocks/src/latex-block/effects.ts @@ -0,0 +1,24 @@ +import type { insertLatexBlockCommand } from './commands.js'; + +export function effects() { + // TODO(@L-Sun): move other effects to this file +} + +declare global { + namespace BlockSuite { + interface CommandContext { + insertedLatexBlockId?: Promise<string>; + } + + interface Commands { + /** + * insert a LaTeX block after or before the current block selection + * @param latex the LaTeX content. A input dialog will be shown if not provided + * @param place where to insert the LaTeX block + * @param removeEmptyLine remove the current block if it is empty + * @returns the id of the inserted LaTeX block + */ + insertLatexBlock: typeof insertLatexBlockCommand; + } + } +} diff --git a/blocksuite/blocks/src/latex-block/index.ts b/blocksuite/blocks/src/latex-block/index.ts new file mode 100644 index 0000000000..81f19a35df --- /dev/null +++ b/blocksuite/blocks/src/latex-block/index.ts @@ -0,0 +1,3 @@ +export * from './adapters/index.js'; +export * from './latex-block.js'; +export * from './latex-spec.js'; diff --git a/blocksuite/blocks/src/latex-block/latex-block.ts b/blocksuite/blocks/src/latex-block/latex-block.ts new file mode 100644 index 0000000000..ae6d59e59d --- /dev/null +++ b/blocksuite/blocks/src/latex-block/latex-block.ts @@ -0,0 +1,151 @@ +import { CaptionedBlockComponent } from '@blocksuite/affine-components/caption'; +import { createLitPortal } from '@blocksuite/affine-components/portal'; +import type { LatexBlockModel } from '@blocksuite/affine-model'; +import type { Placement } from '@floating-ui/dom'; +import { effect } from '@preact/signals-core'; +import katex from 'katex'; +import { html, render } from 'lit'; +import { query } from 'lit/decorators.js'; + +import { latexBlockStyles } from './styles.js'; + +export class LatexBlockComponent extends CaptionedBlockComponent<LatexBlockModel> { + static override styles = latexBlockStyles; + + private _editorAbortController: AbortController | null = null; + + get editorPlacement(): Placement { + return 'bottom'; + } + + get isBlockSelected() { + const blockSelection = this.selection.filter('block'); + return blockSelection.some( + selection => selection.blockId === this.model.id + ); + } + + override firstUpdated(props: Map<string, unknown>) { + super.firstUpdated(props); + + const { disposables } = this; + + this._editorAbortController?.abort(); + this._editorAbortController = new AbortController(); + disposables.add(() => { + this._editorAbortController?.abort(); + }); + + const katexContainer = this._katexContainer; + if (!katexContainer) return; + + disposables.add( + effect(() => { + const latex = this.model.latex$.value; + + katexContainer.replaceChildren(); + // @ts-expect-error FIXME: ts error + delete katexContainer['_$litPart$']; + + if (latex.length === 0) { + render( + html`<span class="latex-block-empty-placeholder">Equation</span>`, + katexContainer + ); + } else { + try { + katex.render(latex, katexContainer, { + displayMode: true, + output: 'mathml', + }); + } catch { + katexContainer.replaceChildren(); + // @ts-expect-error FIXME: ts error + delete katexContainer['_$litPart$']; + render( + html`<span class="latex-block-error-placeholder" + >Error equation</span + >`, + katexContainer + ); + } + } + }) + ); + + this.disposables.addFromEvent(this, 'click', () => { + if (this.isBlockSelected) { + this.toggleEditor(); + } else { + this.selectBlock(); + } + }); + } + + removeEditor(portal: HTMLDivElement) { + portal.remove(); + } + + override renderBlock() { + return html` + <div contenteditable="false" class="latex-block-container"> + <div class="katex"></div> + </div> + `; + } + + selectBlock() { + this.host.command.exec('selectBlock', { + focusBlock: this, + }); + } + + toggleEditor() { + const katexContainer = this._katexContainer; + if (!katexContainer) return; + + this._editorAbortController?.abort(); + this._editorAbortController = new AbortController(); + + this.selection.setGroup('note', []); + + const portal = createLitPortal({ + template: html`<latex-editor-menu + .std=${this.std} + .latexSignal=${this.model.latex$} + .abortController=${this._editorAbortController} + ></latex-editor-menu>`, + container: this.host, + computePosition: { + referenceElement: this, + placement: this.editorPlacement, + autoUpdate: { + animationFrame: true, + }, + }, + closeOnClickAway: true, + abortController: this._editorAbortController, + shadowDom: false, + portalStyles: { + zIndex: 'var(--affine-z-index-popover)', + }, + }); + + this._editorAbortController.signal.addEventListener( + 'abort', + () => { + this.removeEditor(portal); + }, + { once: true } + ); + } + + @query('.latex-block-container') + private accessor _katexContainer!: HTMLDivElement; +} + +declare global { + interface HTMLElementTagNameMap { + 'affine-latex': LatexBlockComponent; + } +} diff --git a/blocksuite/blocks/src/latex-block/latex-spec.ts b/blocksuite/blocks/src/latex-block/latex-spec.ts new file mode 100644 index 0000000000..eeb86c1aaa --- /dev/null +++ b/blocksuite/blocks/src/latex-block/latex-spec.ts @@ -0,0 +1,13 @@ +import { + BlockViewExtension, + CommandExtension, + type ExtensionType, +} from '@blocksuite/block-std'; +import { literal } from 'lit/static-html.js'; + +import { commands } from './commands.js'; + +export const LatexBlockSpec: ExtensionType[] = [ + BlockViewExtension('affine:latex', literal`affine-latex`), + CommandExtension(commands), +]; diff --git a/blocksuite/blocks/src/latex-block/styles.ts b/blocksuite/blocks/src/latex-block/styles.ts new file mode 100644 index 0000000000..9cf36ba11c --- /dev/null +++ b/blocksuite/blocks/src/latex-block/styles.ts @@ -0,0 +1,40 @@ +import { unsafeCSSVar, unsafeCSSVarV2 } from '@blocksuite/affine-shared/theme'; +import { css } from 'lit'; + +export const latexBlockStyles = css` + .latex-block-container { + display: flex; + position: relative; + width: 100%; + height: 100%; + padding: 10px 24px; + flex-direction: column; + align-items: center; + justify-content: center; + border-radius: 4px; + overflow-x: auto; + user-select: none; + } + + .latex-block-container:hover { + background: ${unsafeCSSVar('hoverColor')}; + } + + .latex-block-error-placeholder { + color: ${unsafeCSSVarV2('text/highlight/fg/red')}; + font-family: Inter; + font-size: 12px; + font-weight: 500; + line-height: normal; + user-select: none; + } + + .latex-block-empty-placeholder { + color: ${unsafeCSSVarV2('text/secondary')}; + font-family: Inter; + font-size: 12px; + font-weight: 500; + line-height: normal; + user-select: none; + } +`; diff --git a/blocksuite/blocks/src/note-block/adapters/html.ts b/blocksuite/blocks/src/note-block/adapters/html.ts new file mode 100644 index 0000000000..6eb08514bc --- /dev/null +++ b/blocksuite/blocks/src/note-block/adapters/html.ts @@ -0,0 +1,44 @@ +import { NoteBlockSchema, NoteDisplayMode } from '@blocksuite/affine-model'; +import { + BlockHtmlAdapterExtension, + type BlockHtmlAdapterMatcher, +} from '@blocksuite/affine-shared/adapters'; + +/** + * Create a html adapter matcher for note block. + * + * @param displayModeToSkip - The note with specific display mode to skip. + * For example, the note with display mode `EdgelessOnly` should not be converted to html when current editor mode is `Doc(Page)`. + * @returns The html adapter matcher. + */ +const createNoteBlockHtmlAdapterMatcher = ( + displayModeToSkip: NoteDisplayMode +): BlockHtmlAdapterMatcher => ({ + flavour: NoteBlockSchema.model.flavour, + toMatch: () => false, + fromMatch: o => o.node.flavour === NoteBlockSchema.model.flavour, + toBlockSnapshot: {}, + fromBlockSnapshot: { + enter: (o, context) => { + const node = o.node; + if (node.props.displayMode === displayModeToSkip) { + context.walkerContext.skipAllChildren(); + } + }, + }, +}); + +export const docNoteBlockHtmlAdapterMatcher = createNoteBlockHtmlAdapterMatcher( + NoteDisplayMode.EdgelessOnly +); + +export const edgelessNoteBlockHtmlAdapterMatcher = + createNoteBlockHtmlAdapterMatcher(NoteDisplayMode.DocOnly); + +export const DocNoteBlockHtmlAdapterExtension = BlockHtmlAdapterExtension( + docNoteBlockHtmlAdapterMatcher +); + +export const EdgelessNoteBlockHtmlAdapterExtension = BlockHtmlAdapterExtension( + edgelessNoteBlockHtmlAdapterMatcher +); diff --git a/blocksuite/blocks/src/note-block/adapters/index.ts b/blocksuite/blocks/src/note-block/adapters/index.ts new file mode 100644 index 0000000000..d867970295 --- /dev/null +++ b/blocksuite/blocks/src/note-block/adapters/index.ts @@ -0,0 +1,26 @@ +import type { ExtensionType } from '@blocksuite/block-std'; + +import { + DocNoteBlockHtmlAdapterExtension, + EdgelessNoteBlockHtmlAdapterExtension, +} from './html.js'; +import { + DocNoteBlockMarkdownAdapterExtension, + EdgelessNoteBlockMarkdownAdapterExtension, +} from './markdown.js'; +import { + DocNoteBlockPlainTextAdapterExtension, + EdgelessNoteBlockPlainTextAdapterExtension, +} from './plain-text.js'; + +export const DocNoteBlockAdapterExtensions: ExtensionType[] = [ + DocNoteBlockMarkdownAdapterExtension, + DocNoteBlockHtmlAdapterExtension, + DocNoteBlockPlainTextAdapterExtension, +]; + +export const EdgelessNoteBlockAdapterExtensions: ExtensionType[] = [ + EdgelessNoteBlockMarkdownAdapterExtension, + EdgelessNoteBlockHtmlAdapterExtension, + EdgelessNoteBlockPlainTextAdapterExtension, +]; diff --git a/blocksuite/blocks/src/note-block/adapters/markdown.ts b/blocksuite/blocks/src/note-block/adapters/markdown.ts new file mode 100644 index 0000000000..f0d0bdd6c1 --- /dev/null +++ b/blocksuite/blocks/src/note-block/adapters/markdown.ts @@ -0,0 +1,41 @@ +import { NoteBlockSchema, NoteDisplayMode } from '@blocksuite/affine-model'; +import { + BlockMarkdownAdapterExtension, + type BlockMarkdownAdapterMatcher, +} from '@blocksuite/affine-shared/adapters'; + +/** + * Create a markdown adapter matcher for note block. + * + * @param displayModeToSkip - The note with specific display mode to skip. + * For example, the note with display mode `EdgelessOnly` should not be converted to markdown when current editor mode is `Doc`. + * @returns The markdown adapter matcher. + */ +const createNoteBlockMarkdownAdapterMatcher = ( + displayModeToSkip: NoteDisplayMode +): BlockMarkdownAdapterMatcher => ({ + flavour: NoteBlockSchema.model.flavour, + toMatch: () => false, + fromMatch: o => o.node.flavour === NoteBlockSchema.model.flavour, + toBlockSnapshot: {}, + fromBlockSnapshot: { + enter: (o, context) => { + const node = o.node; + if (node.props.displayMode === displayModeToSkip) { + context.walkerContext.skipAllChildren(); + } + }, + }, +}); + +export const docNoteBlockMarkdownAdapterMatcher = + createNoteBlockMarkdownAdapterMatcher(NoteDisplayMode.EdgelessOnly); + +export const edgelessNoteBlockMarkdownAdapterMatcher = + createNoteBlockMarkdownAdapterMatcher(NoteDisplayMode.DocOnly); + +export const DocNoteBlockMarkdownAdapterExtension = + BlockMarkdownAdapterExtension(docNoteBlockMarkdownAdapterMatcher); + +export const EdgelessNoteBlockMarkdownAdapterExtension = + BlockMarkdownAdapterExtension(edgelessNoteBlockMarkdownAdapterMatcher); diff --git a/blocksuite/blocks/src/note-block/adapters/plain-text.ts b/blocksuite/blocks/src/note-block/adapters/plain-text.ts new file mode 100644 index 0000000000..0a3ea3e595 --- /dev/null +++ b/blocksuite/blocks/src/note-block/adapters/plain-text.ts @@ -0,0 +1,41 @@ +import { NoteBlockSchema, NoteDisplayMode } from '@blocksuite/affine-model'; +import { + BlockPlainTextAdapterExtension, + type BlockPlainTextAdapterMatcher, +} from '@blocksuite/affine-shared/adapters'; + +/** + * Create a plain text adapter matcher for note block. + * + * @param displayModeToSkip - The note with specific display mode to skip. + * For example, the note with display mode `EdgelessOnly` should not be converted to plain text when current editor mode is `Doc(Page)`. + * @returns The plain text adapter matcher. + */ +const createNoteBlockPlainTextAdapterMatcher = ( + displayModeToSkip: NoteDisplayMode +): BlockPlainTextAdapterMatcher => ({ + flavour: NoteBlockSchema.model.flavour, + toMatch: () => false, + fromMatch: o => o.node.flavour === NoteBlockSchema.model.flavour, + toBlockSnapshot: {}, + fromBlockSnapshot: { + enter: (o, context) => { + const node = o.node; + if (node.props.displayMode === displayModeToSkip) { + context.walkerContext.skipAllChildren(); + } + }, + }, +}); + +export const docNoteBlockPlainTextAdapterMatcher = + createNoteBlockPlainTextAdapterMatcher(NoteDisplayMode.EdgelessOnly); + +export const edgelessNoteBlockPlainTextAdapterMatcher = + createNoteBlockPlainTextAdapterMatcher(NoteDisplayMode.DocOnly); + +export const DocNoteBlockPlainTextAdapterExtension = + BlockPlainTextAdapterExtension(docNoteBlockPlainTextAdapterMatcher); + +export const EdgelessNoteBlockPlainTextAdapterExtension = + BlockPlainTextAdapterExtension(edgelessNoteBlockPlainTextAdapterMatcher); diff --git a/blocksuite/blocks/src/note-block/commands/block-type.ts b/blocksuite/blocks/src/note-block/commands/block-type.ts new file mode 100644 index 0000000000..c51c9bc03c --- /dev/null +++ b/blocksuite/blocks/src/note-block/commands/block-type.ts @@ -0,0 +1,218 @@ +import { + asyncSetInlineRange, + focusTextModel, +} from '@blocksuite/affine-components/rich-text'; +import { matchFlavours } from '@blocksuite/affine-shared/utils'; +import type { Command } from '@blocksuite/block-std'; +import type { BlockModel } from '@blocksuite/store'; + +import { onModelTextUpdated } from '../../root-block/utils/callback.js'; +import { + mergeToCodeModel, + transformModel, +} from '../../root-block/utils/operations/model.js'; + +type UpdateBlockConfig = { + flavour: BlockSuite.Flavour; + props?: Record<string, unknown>; +}; + +export const updateBlockType: Command< + 'selectedBlocks', + 'updatedBlocks', + UpdateBlockConfig +> = (ctx, next) => { + const { std, flavour, props } = ctx; + const host = std.host; + const doc = std.doc; + + const getSelectedBlocks = () => { + let { selectedBlocks } = ctx; + + if (selectedBlocks == null) { + const [result, ctx] = std.command + .chain() + .tryAll(chain => [chain.getTextSelection(), chain.getBlockSelections()]) + .getSelectedBlocks({ types: ['text', 'block'] }) + .run(); + if (result) { + selectedBlocks = ctx.selectedBlocks; + } + } + + return selectedBlocks; + }; + + const selectedBlocks = getSelectedBlocks(); + if (!selectedBlocks || selectedBlocks.length === 0) return false; + + const blockModels = selectedBlocks.map(ele => ele.model); + + const hasSameDoc = selectedBlocks.every(block => block.doc === doc); + if (!hasSameDoc) { + // doc check + console.error( + 'Not all models have the same doc instance, the result for update text type may not be correct', + selectedBlocks + ); + } + + const mergeToCode: Command<never, 'updatedBlocks'> = (_, next) => { + if (flavour !== 'affine:code') return; + const id = mergeToCodeModel(blockModels); + if (!id) return; + const model = doc.getBlockById(id); + if (!model) return; + asyncSetInlineRange(host, model, { + index: model.text?.length ?? 0, + length: 0, + }).catch(console.error); + return next({ updatedBlocks: [model] }); + }; + const appendDivider: Command<never, 'updatedBlocks'> = (_, next) => { + if (flavour !== 'affine:divider') { + return false; + } + const model = blockModels.at(-1); + if (!model) { + return next({ updatedBlocks: [] }); + } + const parent = doc.getParent(model); + if (!parent) { + return next({ updatedBlocks: [] }); + } + const index = parent.children.indexOf(model); + const nextSibling = doc.getNext(model); + let nextSiblingId = nextSibling?.id as string; + const id = doc.addBlock('affine:divider', {}, parent, index + 1); + if (!nextSibling) { + nextSiblingId = doc.addBlock('affine:paragraph', {}, parent); + } + focusTextModel(host.std, nextSiblingId); + const newModel = doc.getBlockById(id); + if (!newModel) { + return next({ updatedBlocks: [] }); + } + return next({ updatedBlocks: [newModel] }); + }; + + const focusText: Command<'updatedBlocks'> = (ctx, next) => { + const { updatedBlocks } = ctx; + if (!updatedBlocks || updatedBlocks.length === 0) { + return false; + } + + const firstNewModel = updatedBlocks[0]; + const lastNewModel = updatedBlocks[updatedBlocks.length - 1]; + + const allTextUpdated = updatedBlocks.map(model => + onModelTextUpdated(host, model) + ); + const selectionManager = host.selection; + const textSelection = selectionManager.find('text'); + if (!textSelection) { + return false; + } + const newTextSelection = selectionManager.create('text', { + from: { + blockId: firstNewModel.id, + index: textSelection.from.index, + length: textSelection.from.length, + }, + to: textSelection.to + ? { + blockId: lastNewModel.id, + index: textSelection.to.index, + length: textSelection.to.length, + } + : null, + }); + + Promise.all(allTextUpdated) + .then(() => { + selectionManager.setGroup('note', [newTextSelection]); + }) + .catch(console.error); + return next(); + }; + + const focusBlock: Command<'updatedBlocks'> = (ctx, next) => { + const { updatedBlocks } = ctx; + if (!updatedBlocks || updatedBlocks.length === 0) { + return false; + } + + const selectionManager = host.selection; + + const blockSelections = selectionManager.filter('block'); + if (blockSelections.length === 0) { + return false; + } + requestAnimationFrame(() => { + const selections = updatedBlocks.map(model => { + return selectionManager.create('block', { + blockId: model.id, + }); + }); + + selectionManager.setGroup('note', selections); + }); + return next(); + }; + + const [result, resultCtx] = std.command + .chain() + .inline((_, next) => { + doc.captureSync(); + return next(); + }) + // update block type + .try<'updatedBlocks'>(chain => [ + chain.inline<'updatedBlocks'>(mergeToCode), + chain.inline<'updatedBlocks'>(appendDivider), + chain.inline<'updatedBlocks'>((_, next) => { + const newModels: BlockModel[] = []; + blockModels.forEach(model => { + if ( + !matchFlavours(model, [ + 'affine:paragraph', + 'affine:list', + 'affine:code', + ]) + ) { + return; + } + if (model.flavour === flavour) { + doc.updateBlock(model, props ?? {}); + newModels.push(model); + return; + } + const newId = transformModel(model, flavour, props); + const newModel = doc.getBlockById(newId); + if (newModel) { + newModels.push(newModel); + } + }); + return next({ updatedBlocks: newModels }); + }), + ]) + // focus + .try(chain => [ + chain.inline((_, next) => { + if (['affine:code', 'affine:divider'].includes(flavour)) { + return next(); + } + return false; + }), + chain.inline(focusText), + chain.inline(focusBlock), + chain.inline((_, next) => next()), + ]) + .run(); + + if (!result) { + return false; + } + + return next({ updatedBlocks: resultCtx.updatedBlocks }); +}; diff --git a/blocksuite/blocks/src/note-block/commands/dedent-block-to-root.ts b/blocksuite/blocks/src/note-block/commands/dedent-block-to-root.ts new file mode 100644 index 0000000000..a2976cb50a --- /dev/null +++ b/blocksuite/blocks/src/note-block/commands/dedent-block-to-root.ts @@ -0,0 +1,39 @@ +import { matchFlavours } from '@blocksuite/affine-shared/utils'; +import type { Command } from '@blocksuite/block-std'; + +export const dedentBlockToRoot: Command< + never, + never, + { + blockId?: string; + stopCapture?: boolean; + } +> = (ctx, next) => { + let { blockId } = ctx; + const { std, stopCapture = true } = ctx; + const { doc } = std; + if (!blockId) { + const sel = std.selection.getGroup('note').at(0); + blockId = sel?.blockId; + } + if (!blockId) return; + const model = std.doc.getBlock(blockId)?.model; + if (!model) return; + + let parent = doc.getParent(model); + let changed = false; + while (parent && !matchFlavours(parent, ['affine:note'])) { + if (!changed) { + if (stopCapture) doc.captureSync(); + changed = true; + } + std.command.exec('dedentBlock', { blockId: model.id, stopCapture: true }); + parent = doc.getParent(model); + } + + if (!changed) { + return; + } + + return next(); +}; diff --git a/blocksuite/blocks/src/note-block/commands/dedent-block.ts b/blocksuite/blocks/src/note-block/commands/dedent-block.ts new file mode 100644 index 0000000000..9944272c29 --- /dev/null +++ b/blocksuite/blocks/src/note-block/commands/dedent-block.ts @@ -0,0 +1,70 @@ +import { + calculateCollapsedSiblings, + matchFlavours, +} from '@blocksuite/affine-shared/utils'; +import type { Command } from '@blocksuite/block-std'; + +/** + * @example + * before unindent: + * - aaa + * - bbb + * - ccc| + * - ddd + * - eee + * + * after unindent: + * - aaa + * - bbb + * - ccc| + * - ddd + * - eee + */ +export const dedentBlock: Command< + never, + never, + { + blockId?: string; + stopCapture?: boolean; + } +> = (ctx, next) => { + let { blockId } = ctx; + const { std, stopCapture = true } = ctx; + const { doc } = std; + if (!blockId) { + const sel = std.selection.getGroup('note').at(0); + blockId = sel?.blockId; + } + if (!blockId) return; + const model = std.doc.getBlock(blockId)?.model; + if (!model) return; + + const parent = doc.getParent(model); + const grandParent = parent && doc.getParent(parent); + if (doc.readonly || !parent || parent.role !== 'content' || !grandParent) { + // Top most, can not unindent, do nothing + return; + } + + if (stopCapture) doc.captureSync(); + + if ( + matchFlavours(model, ['affine:paragraph']) && + model.type.startsWith('h') && + model.collapsed + ) { + const collapsedSiblings = calculateCollapsedSiblings(model); + doc.moveBlocks([model, ...collapsedSiblings], grandParent, parent, false); + return next(); + } + + try { + const nextSiblings = doc.getNexts(model); + doc.moveBlocks(nextSiblings, model); + doc.moveBlocks([model], grandParent, parent, false); + } catch { + return; + } + + return next(); +}; diff --git a/blocksuite/blocks/src/note-block/commands/dedent-blocks-to-root.ts b/blocksuite/blocks/src/note-block/commands/dedent-blocks-to-root.ts new file mode 100644 index 0000000000..4ae3bb3547 --- /dev/null +++ b/blocksuite/blocks/src/note-block/commands/dedent-blocks-to-root.ts @@ -0,0 +1,44 @@ +import { matchFlavours } from '@blocksuite/affine-shared/utils'; +import type { Command } from '@blocksuite/block-std'; + +export const dedentBlocksToRoot: Command< + never, + never, + { + blockIds?: string[]; + stopCapture?: boolean; + } +> = (ctx, next) => { + let { blockIds } = ctx; + const { std, stopCapture = true } = ctx; + const { doc } = std; + if (!blockIds || !blockIds.length) { + const text = std.selection.find('text'); + if (text) { + // If the text selection is not at the beginning of the block, use default behavior + if (text.from.index !== 0) return; + + blockIds = [text.from.blockId, text.to?.blockId].filter( + (x): x is string => !!x + ); + } else { + blockIds = std.selection.getGroup('note').map(sel => sel.blockId); + } + } + + if (!blockIds || !blockIds.length || doc.readonly) return; + + if (stopCapture) doc.captureSync(); + for (let i = blockIds.length - 1; i >= 0; i--) { + const model = blockIds[i]; + const parent = doc.getParent(model); + if (parent && !matchFlavours(parent, ['affine:note'])) { + std.command.exec('dedentBlockToRoot', { + blockId: model, + stopCapture: false, + }); + } + } + + return next(); +}; diff --git a/blocksuite/blocks/src/note-block/commands/dedent-blocks.ts b/blocksuite/blocks/src/note-block/commands/dedent-blocks.ts new file mode 100644 index 0000000000..d98536bd80 --- /dev/null +++ b/blocksuite/blocks/src/note-block/commands/dedent-blocks.ts @@ -0,0 +1,88 @@ +import { + calculateCollapsedSiblings, + matchFlavours, +} from '@blocksuite/affine-shared/utils'; +import type { Command } from '@blocksuite/block-std'; + +export const dedentBlocks: Command< + never, + never, + { + blockIds?: string[]; + stopCapture?: boolean; + } +> = (ctx, next) => { + let { blockIds } = ctx; + const { std, stopCapture = true } = ctx; + const { doc, selection, range, host } = std; + const { schema } = doc; + + if (!blockIds || !blockIds.length) { + const nativeRange = range.value; + if (nativeRange) { + const topBlocks = range.getSelectedBlockComponentsByRange(nativeRange, { + match: el => el.model.role === 'content', + mode: 'highest', + }); + if (topBlocks.length > 0) { + blockIds = topBlocks.map(block => block.blockId); + } + } else { + blockIds = std.selection.getGroup('note').map(sel => sel.blockId); + } + } + + if (!blockIds || !blockIds.length || doc.readonly) return; + + // Find the first model that can be unindented + let firstDedentIndex = -1; + for (let i = 0; i < blockIds.length; i++) { + const model = doc.getBlock(blockIds[i])?.model; + if (!model) continue; + const parent = doc.getParent(blockIds[i]); + if (!parent) continue; + const grandParent = doc.getParent(parent); + if (!grandParent) continue; + + if (schema.isValid(model.flavour, grandParent.flavour)) { + firstDedentIndex = i; + break; + } + } + + if (firstDedentIndex === -1) return; + + if (stopCapture) doc.captureSync(); + + const collapsedIds: string[] = []; + blockIds.slice(firstDedentIndex).forEach(id => { + const model = doc.getBlock(id)?.model; + if (!model) return; + if ( + matchFlavours(model, ['affine:paragraph']) && + model.type.startsWith('h') && + model.collapsed + ) { + const collapsedSiblings = calculateCollapsedSiblings(model); + collapsedIds.push(...collapsedSiblings.map(sibling => sibling.id)); + } + }); + // Models waiting to be dedented + const dedentIds = blockIds + .slice(firstDedentIndex) + .filter(id => !collapsedIds.includes(id)); + dedentIds.reverse().forEach(id => { + std.command.exec('dedentBlock', { blockId: id, stopCapture: false }); + }); + + const textSelection = selection.find('text'); + if (textSelection) { + host.updateComplete + .then(() => { + range.syncTextSelectionToRange(textSelection); + }) + .catch(console.error); + } + + return next(); +}; diff --git a/blocksuite/blocks/src/note-block/commands/focus-block-end.ts b/blocksuite/blocks/src/note-block/commands/focus-block-end.ts new file mode 100644 index 0000000000..a59a36f5d7 --- /dev/null +++ b/blocksuite/blocks/src/note-block/commands/focus-block-end.ts @@ -0,0 +1,21 @@ +import type { Command } from '@blocksuite/block-std'; + +export const focusBlockEnd: Command<'focusBlock'> = (ctx, next) => { + const { focusBlock, std } = ctx; + if (!focusBlock || !focusBlock.model.text) return; + + const { selection } = std; + + selection.setGroup('note', [ + selection.create('text', { + from: { + blockId: focusBlock.blockId, + index: focusBlock.model.text.length, + length: 0, + }, + to: null, + }), + ]); + + return next(); +}; diff --git a/blocksuite/blocks/src/note-block/commands/focus-block-start.ts b/blocksuite/blocks/src/note-block/commands/focus-block-start.ts new file mode 100644 index 0000000000..7a66a6dc74 --- /dev/null +++ b/blocksuite/blocks/src/note-block/commands/focus-block-start.ts @@ -0,0 +1,17 @@ +import type { Command } from '@blocksuite/block-std'; + +export const focusBlockStart: Command<'focusBlock'> = (ctx, next) => { + const { focusBlock, std } = ctx; + if (!focusBlock || !focusBlock.model.text) return; + + const { selection } = std; + + selection.setGroup('note', [ + selection.create('text', { + from: { blockId: focusBlock.blockId, index: 0, length: 0 }, + to: null, + }), + ]); + + return next(); +}; diff --git a/blocksuite/blocks/src/note-block/commands/indent-block.ts b/blocksuite/blocks/src/note-block/commands/indent-block.ts new file mode 100644 index 0000000000..56d4095d30 --- /dev/null +++ b/blocksuite/blocks/src/note-block/commands/indent-block.ts @@ -0,0 +1,78 @@ +import type { ListBlockModel } from '@blocksuite/affine-model'; +import { + calculateCollapsedSiblings, + matchFlavours, +} from '@blocksuite/affine-shared/utils'; +import type { Command } from '@blocksuite/block-std'; + +/** + * @example + * before indent: + * - aaa + * - bbb + * - ccc| + * - ddd + * - eee + * + * after indent: + * - aaa + * - bbb + * - ccc| + * - ddd + * - eee + */ +export const indentBlock: Command< + never, + never, + { + blockId?: string; + stopCapture?: boolean; + } +> = (ctx, next) => { + let { blockId } = ctx; + const { std, stopCapture = true } = ctx; + const { doc } = std; + const { schema } = doc; + if (!blockId) { + const sel = std.selection.getGroup('note').at(0); + blockId = sel?.blockId; + } + if (!blockId) return; + const model = std.doc.getBlock(blockId)?.model; + if (!model) return; + + const previousSibling = doc.getPrev(model); + if ( + doc.readonly || + !previousSibling || + !schema.isValid(model.flavour, previousSibling.flavour) + ) { + // can not indent, do nothing + return; + } + + if (stopCapture) doc.captureSync(); + + if ( + matchFlavours(model, ['affine:paragraph']) && + model.type.startsWith('h') && + model.collapsed + ) { + const collapsedSiblings = calculateCollapsedSiblings(model); + doc.moveBlocks([model, ...collapsedSiblings], previousSibling); + } else { + doc.moveBlocks([model], previousSibling); + } + + // update collapsed state of affine list + if ( + matchFlavours(previousSibling, ['affine:list']) && + previousSibling.collapsed + ) { + doc.updateBlock(previousSibling, { + collapsed: false, + } as Partial<ListBlockModel>); + } + + return next(); +}; diff --git a/blocksuite/blocks/src/note-block/commands/indent-blocks.ts b/blocksuite/blocks/src/note-block/commands/indent-blocks.ts new file mode 100644 index 0000000000..3a4f2f27f0 --- /dev/null +++ b/blocksuite/blocks/src/note-block/commands/indent-blocks.ts @@ -0,0 +1,130 @@ +import { + calculateCollapsedSiblings, + getNearestHeadingBefore, + matchFlavours, +} from '@blocksuite/affine-shared/utils'; +import type { Command } from '@blocksuite/block-std'; + +export const indentBlocks: Command< + never, + never, + { + blockIds?: string[]; + stopCapture?: boolean; + } +> = (ctx, next) => { + let { blockIds } = ctx; + const { std, stopCapture = true } = ctx; + const { doc, selection, range, host } = std; + const { schema } = doc; + + if (!blockIds || !blockIds.length) { + const nativeRange = range.value; + if (nativeRange) { + const topBlocks = range.getSelectedBlockComponentsByRange(nativeRange, { + match: el => el.model.role === 'content', + mode: 'highest', + }); + if (topBlocks.length > 0) { + blockIds = topBlocks.map(block => block.blockId); + } + } else { + blockIds = std.selection.getGroup('note').map(sel => sel.blockId); + } + } + + if (!blockIds || !blockIds.length || doc.readonly) return; + + // Find the first model that can be indented + let firstIndentIndex = -1; + for (let i = 0; i < blockIds.length; i++) { + const previousSibling = doc.getPrev(blockIds[i]); + const model = doc.getBlock(blockIds[i])?.model; + if ( + model && + previousSibling && + schema.isValid(model.flavour, previousSibling.flavour) + ) { + firstIndentIndex = i; + break; + } + } + + // No model can be indented + if (firstIndentIndex === -1) return; + + if (stopCapture) doc.captureSync(); + + const collapsedIds: string[] = []; + blockIds.slice(firstIndentIndex).forEach(id => { + const model = doc.getBlock(id)?.model; + if (!model) return; + if ( + matchFlavours(model, ['affine:paragraph']) && + model.type.startsWith('h') && + model.collapsed + ) { + const collapsedSiblings = calculateCollapsedSiblings(model); + collapsedIds.push(...collapsedSiblings.map(sibling => sibling.id)); + } + }); + // Models waiting to be indented + const indentIds = blockIds + .slice(firstIndentIndex) + .filter(id => !collapsedIds.includes(id)); + const firstModel = doc.getBlock(indentIds[0])?.model; + if (!firstModel) return; + + { + // > # 123 + // > # 456 + // > # 789 + // + // we need to update 123 collapsed state to false when indent 456 and 789 + + const nearestHeading = getNearestHeadingBefore(firstModel); + if ( + nearestHeading && + matchFlavours(nearestHeading, ['affine:paragraph']) && + nearestHeading.collapsed + ) { + doc.updateBlock(nearestHeading, { + collapsed: false, + }); + } + } + + indentIds.forEach(id => { + std.command.exec('indentBlock', { blockId: id, stopCapture: false }); + }); + + { + // 123 + // > # 456 + // 789 + // 012 + // + // we need to update 456 collapsed state to false when indent 789 and 012 + const nearestHeading = getNearestHeadingBefore(firstModel); + if ( + nearestHeading && + matchFlavours(nearestHeading, ['affine:paragraph']) && + nearestHeading.collapsed + ) { + doc.updateBlock(nearestHeading, { + collapsed: false, + }); + } + } + + const textSelection = selection.find('text'); + if (textSelection) { + host.updateComplete + .then(() => { + range.syncTextSelectionToRange(textSelection); + }) + .catch(console.error); + } + + return next(); +}; diff --git a/blocksuite/blocks/src/note-block/commands/index.ts b/blocksuite/blocks/src/note-block/commands/index.ts new file mode 100644 index 0000000000..c44a700e79 --- /dev/null +++ b/blocksuite/blocks/src/note-block/commands/index.ts @@ -0,0 +1,40 @@ +import { + getBlockIndexCommand, + getBlockSelectionsCommand, + getNextBlockCommand, + getPrevBlockCommand, + getSelectedBlocksCommand, +} from '@blocksuite/affine-shared/commands'; +import type { BlockCommands } from '@blocksuite/block-std'; + +import { updateBlockType } from './block-type.js'; +import { dedentBlock } from './dedent-block.js'; +import { dedentBlockToRoot } from './dedent-block-to-root.js'; +import { dedentBlocks } from './dedent-blocks.js'; +import { dedentBlocksToRoot } from './dedent-blocks-to-root.js'; +import { focusBlockEnd } from './focus-block-end.js'; +import { focusBlockStart } from './focus-block-start.js'; +import { indentBlock } from './indent-block.js'; +import { indentBlocks } from './indent-blocks.js'; +import { selectBlock } from './select-block.js'; +import { selectBlocksBetween } from './select-blocks-between.js'; + +export const commands: BlockCommands = { + // block + getBlockIndex: getBlockIndexCommand, + getPrevBlock: getPrevBlockCommand, + getNextBlock: getNextBlockCommand, + getSelectedBlocks: getSelectedBlocksCommand, + getBlockSelections: getBlockSelectionsCommand, + selectBlock, + selectBlocksBetween, + focusBlockStart, + focusBlockEnd, + updateBlockType, + indentBlock, + dedentBlock, + indentBlocks, + dedentBlocks, + dedentBlockToRoot, + dedentBlocksToRoot, +}; diff --git a/blocksuite/blocks/src/note-block/commands/select-block.ts b/blocksuite/blocks/src/note-block/commands/select-block.ts new file mode 100644 index 0000000000..658f6919c7 --- /dev/null +++ b/blocksuite/blocks/src/note-block/commands/select-block.ts @@ -0,0 +1,16 @@ +import type { Command } from '@blocksuite/block-std'; + +export const selectBlock: Command<'focusBlock'> = (ctx, next) => { + const { focusBlock, std } = ctx; + if (!focusBlock) { + return; + } + + const { selection } = std; + + selection.setGroup('note', [ + selection.create('block', { blockId: focusBlock.blockId }), + ]); + + return next(); +}; diff --git a/blocksuite/blocks/src/note-block/commands/select-blocks-between.ts b/blocksuite/blocks/src/note-block/commands/select-blocks-between.ts new file mode 100644 index 0000000000..f4d1d55a3e --- /dev/null +++ b/blocksuite/blocks/src/note-block/commands/select-blocks-between.ts @@ -0,0 +1,49 @@ +import type { Command } from '@blocksuite/block-std'; + +export const selectBlocksBetween: Command< + 'focusBlock' | 'anchorBlock', + never, + { tail: boolean } +> = (ctx, next) => { + const { focusBlock, anchorBlock, tail } = ctx; + if (!focusBlock || !anchorBlock) { + return; + } + const selection = ctx.std.selection; + + // In same block + if (anchorBlock.blockId === focusBlock.blockId) { + const blockId = focusBlock.blockId; + selection.setGroup('note', [selection.create('block', { blockId })]); + return next(); + } + + // In different blocks + const selections = [...selection.value]; + if (selections.every(sel => sel.blockId !== focusBlock.blockId)) { + if (tail) { + selections.push( + selection.create('block', { blockId: focusBlock.blockId }) + ); + } else { + selections.unshift( + selection.create('block', { blockId: focusBlock.blockId }) + ); + } + } + + let start = false; + const sel = selections.filter(sel => { + if ( + sel.blockId === anchorBlock.blockId || + sel.blockId === focusBlock.blockId + ) { + start = !start; + return true; + } + return start; + }); + + selection.setGroup('note', sel); + return next(); +}; diff --git a/blocksuite/blocks/src/note-block/index.ts b/blocksuite/blocks/src/note-block/index.ts new file mode 100644 index 0000000000..de6076b058 --- /dev/null +++ b/blocksuite/blocks/src/note-block/index.ts @@ -0,0 +1,4 @@ +export * from './commands/index.js'; +export * from './note-block.js'; +export * from './note-edgeless-block.js'; +export * from './note-service.js'; diff --git a/blocksuite/blocks/src/note-block/note-block.ts b/blocksuite/blocks/src/note-block/note-block.ts new file mode 100644 index 0000000000..15a1cd8d05 --- /dev/null +++ b/blocksuite/blocks/src/note-block/note-block.ts @@ -0,0 +1,39 @@ +import type { NoteBlockModel } from '@blocksuite/affine-model'; +import { BlockComponent } from '@blocksuite/block-std'; +import { css, html } from 'lit'; + +import type { NoteBlockService } from './note-service.js'; + +export class NoteBlockComponent extends BlockComponent< + NoteBlockModel, + NoteBlockService +> { + static override styles = css` + .affine-note-block-container { + display: flow-root; + } + .affine-note-block-container.selected { + background-color: var(--affine-hover-color); + } + `; + + override connectedCallback() { + super.connectedCallback(); + } + + override renderBlock() { + return html` + <div class="affine-note-block-container"> + <div class="affine-block-children-container"> + ${this.renderChildren(this.model)} + </div> + </div> + `; + } +} + +declare global { + interface HTMLElementTagNameMap { + 'affine-note': NoteBlockComponent; + } +} diff --git a/blocksuite/blocks/src/note-block/note-edgeless-block.ts b/blocksuite/blocks/src/note-block/note-edgeless-block.ts new file mode 100644 index 0000000000..1250ffa41f --- /dev/null +++ b/blocksuite/blocks/src/note-block/note-edgeless-block.ts @@ -0,0 +1,520 @@ +/* eslint-disable @typescript-eslint/no-non-null-assertion */ +import { MoreIndicatorIcon } from '@blocksuite/affine-components/icons'; +import type { NoteBlockModel } from '@blocksuite/affine-model'; +import { + DEFAULT_NOTE_BACKGROUND_COLOR, + NoteDisplayMode, + StrokeStyle, +} from '@blocksuite/affine-model'; +import { EDGELESS_BLOCK_CHILD_PADDING } from '@blocksuite/affine-shared/consts'; +import { ThemeProvider } from '@blocksuite/affine-shared/services'; +import { + getClosestBlockComponentByPoint, + handleNativeRangeAtPoint, + matchFlavours, + stopPropagation, +} from '@blocksuite/affine-shared/utils'; +import type { BlockComponent, EditorHost } from '@blocksuite/block-std'; +import { ShadowlessElement, toGfxBlockComponent } from '@blocksuite/block-std'; +import { + almostEqual, + Bound, + clamp, + Point, + WithDisposable, +} from '@blocksuite/global/utils'; +import type { BlockModel } from '@blocksuite/store'; +import { css, html, nothing } from 'lit'; +import { property, query, state } from 'lit/decorators.js'; +import { classMap } from 'lit/directives/class-map.js'; +import { styleMap } from 'lit/directives/style-map.js'; + +import type { EdgelessRootService } from '../root-block/index.js'; +import { NoteBlockComponent } from './note-block.js'; + +export class EdgelessNoteMask extends WithDisposable(ShadowlessElement) { + protected override firstUpdated() { + const maskDOM = this.renderRoot!.querySelector('.affine-note-mask'); + const observer = new ResizeObserver(entries => { + for (const entry of entries) { + if (!this.model.edgeless.collapse) { + const bound = Bound.deserialize(this.model.xywh); + const scale = this.model.edgeless.scale ?? 1; + const height = entry.contentRect.height * scale; + + if (!height || almostEqual(bound.h, height)) { + return; + } + + bound.h = height; + this.model.stash('xywh'); + this.model.xywh = bound.serialize(); + this.model.pop('xywh'); + } + } + }); + + observer.observe(maskDOM!); + + this._disposables.add(() => { + observer.disconnect(); + }); + } + + override render() { + const extra = this.editing ? ACTIVE_NOTE_EXTRA_PADDING : 0; + return html` + <div + class="affine-note-mask" + style=${styleMap({ + position: 'absolute', + top: `${-extra}px`, + left: `${-extra}px`, + bottom: `${-extra}px`, + right: `${-extra}px`, + zIndex: '1', + pointerEvents: this.display ? 'auto' : 'none', + borderRadius: `${ + this.model.edgeless.style.borderRadius * this.zoom + }px`, + })} + ></div> + `; + } + + @property({ attribute: false }) + accessor display!: boolean; + + @property({ attribute: false }) + accessor editing!: boolean; + + @property({ attribute: false }) + accessor host!: EditorHost; + + @property({ attribute: false }) + accessor model!: NoteBlockModel; + + @property({ attribute: false }) + accessor zoom!: number; +} + +const ACTIVE_NOTE_EXTRA_PADDING = 20; + +export class EdgelessNoteBlockComponent extends toGfxBlockComponent( + NoteBlockComponent +) { + static override styles = css` + .edgeless-note-collapse-button { + display: flex; + align-items: center; + justify-content: center; + width: 28px; + height: 28px; + z-index: 2; + position: absolute; + bottom: 0; + left: 50%; + transform: translateX(-50%); + opacity: 0.2; + transition: opacity 0.3s; + } + .edgeless-note-collapse-button:hover { + opacity: 1; + } + .edgeless-note-collapse-button.flip { + transform: translateX(-50%) rotate(180deg); + } + .edgeless-note-collapse-button.hide { + display: none; + } + + .edgeless-note-container:has(.affine-embed-synced-doc-container.editing) + > .note-background { + left: ${-ACTIVE_NOTE_EXTRA_PADDING}px !important; + top: ${-ACTIVE_NOTE_EXTRA_PADDING}px !important; + width: calc(100% + ${ACTIVE_NOTE_EXTRA_PADDING * 2}px) !important; + height: calc(100% + ${ACTIVE_NOTE_EXTRA_PADDING * 2}px) !important; + } + + .edgeless-note-container:has(.affine-embed-synced-doc-container.editing) + > edgeless-note-mask { + display: none; + } + `; + + private get _isShowCollapsedContent() { + return this.model.edgeless.collapse && (this._isResizing || this._isHover); + } + + get _zoom() { + return this.gfx.viewport.zoom; + } + + get rootService() { + return this.std.getService('affine:page') as EdgelessRootService; + } + + private _collapsedContent() { + if (!this._isShowCollapsedContent) { + return nothing; + } + + const { xywh, edgeless } = this.model; + const { borderSize } = edgeless.style; + + const extraPadding = this._editing ? ACTIVE_NOTE_EXTRA_PADDING : 0; + const extraBorder = this._editing ? borderSize : 0; + const bound = Bound.deserialize(xywh); + const scale = edgeless.scale ?? 1; + const width = bound.w / scale + extraPadding * 2 + extraBorder; + const height = bound.h / scale; + + const rect = this._notePageContent?.getBoundingClientRect(); + if (!rect) return nothing; + + const zoom = this.gfx.viewport.zoom; + this._noteFullHeight = + rect.height / scale / zoom + 2 * EDGELESS_BLOCK_CHILD_PADDING; + + if (height >= this._noteFullHeight) { + return nothing; + } + + return html` + <div + style=${styleMap({ + width: `${width}px`, + height: `${this._noteFullHeight - height}px`, + position: 'absolute', + left: `${-(extraPadding + extraBorder / 2)}px`, + top: `${height + extraPadding + extraBorder / 2}px`, + background: 'var(--affine-white)', + opacity: 0.5, + pointerEvents: 'none', + borderLeft: '2px var(--affine-blue) solid', + borderBottom: '2px var(--affine-blue) solid', + borderRight: '2px var(--affine-blue) solid', + borderRadius: '0 0 8px 8px', + })} + ></div> + `; + } + + private _handleClickAtBackground(e: MouseEvent) { + e.stopPropagation(); + if (!this._editing) return; + + const rect = this.getBoundingClientRect(); + const offsetY = 16 * this._zoom; + const offsetX = 2 * this._zoom; + const x = clamp(e.x, rect.left + offsetX, rect.right - offsetX); + const y = clamp(e.y, rect.top + offsetY, rect.bottom - offsetY); + handleNativeRangeAtPoint(x, y); + + if (this.doc.readonly) return; + + this._tryAddParagraph(x, y); + } + + private _hovered() { + if ( + this.selection.value.some( + sel => sel.type === 'surface' && sel.blockId === this.model.id + ) + ) { + this._isHover = true; + } + } + + private _leaved() { + if (this._isHover) { + this._isHover = false; + } + } + + private _setCollapse(event: MouseEvent) { + event.stopImmediatePropagation(); + + const { collapse, collapsedHeight } = this.model.edgeless; + + if (collapse) { + this.model.doc.updateBlock(this.model, () => { + this.model.edgeless.collapse = false; + }); + } else if (collapsedHeight) { + const { xywh, edgeless } = this.model; + const bound = Bound.deserialize(xywh); + bound.h = collapsedHeight * (edgeless.scale ?? 1); + this.model.doc.updateBlock(this.model, () => { + this.model.edgeless.collapse = true; + this.model.xywh = bound.serialize(); + }); + } + + this.selection.clear(); + } + + private _tryAddParagraph(x: number, y: number) { + const nearest = getClosestBlockComponentByPoint( + new Point(x, y) + ) as BlockComponent | null; + if (!nearest) return; + + const nearestBBox = nearest.getBoundingClientRect(); + const yRel = y - nearestBBox.top; + + const insertPos: 'before' | 'after' = + yRel < nearestBBox.height / 2 ? 'before' : 'after'; + + const nearestModel = nearest.model as BlockModel; + const nearestModelIdx = this.model.children.indexOf(nearestModel); + + const children = this.model.children; + const siblingModel = + children[ + clamp( + nearestModelIdx + (insertPos === 'before' ? -1 : 1), + 0, + children.length + ) + ]; + + if ( + (!nearestModel.text || + !matchFlavours(nearestModel, ['affine:paragraph', 'affine:list'])) && + (!siblingModel || + !siblingModel.text || + !matchFlavours(siblingModel, ['affine:paragraph', 'affine:list'])) + ) { + const [pId] = this.doc.addSiblingBlocks( + nearestModel, + [{ flavour: 'affine:paragraph' }], + insertPos + ); + + this.updateComplete + .then(() => { + this.std.selection.setGroup('note', [ + this.std.selection.create('text', { + from: { + blockId: pId, + index: 0, + length: 0, + }, + to: null, + }), + ]); + }) + .catch(console.error); + } + } + + override connectedCallback(): void { + super.connectedCallback(); + + const selection = this.rootService.selection; + + this._editing = selection.has(this.model.id) && selection.editing; + this._disposables.add( + selection.slots.updated.on(() => { + if (selection.has(this.model.id) && selection.editing) { + this._editing = true; + } else { + this._editing = false; + } + }) + ); + } + + override firstUpdated() { + const { _disposables } = this; + const selection = this.rootService.selection; + + _disposables.add( + this.rootService.slots.elementResizeStart.on(() => { + if (selection.selectedElements.includes(this.model)) { + this._isResizing = true; + } + }) + ); + + _disposables.add( + this.rootService.slots.elementResizeEnd.on(() => { + this._isResizing = false; + }) + ); + + const observer = new MutationObserver(() => { + const rect = this._notePageContent?.getBoundingClientRect(); + if (!rect) return; + const zoom = this.gfx.viewport.zoom; + const scale = this.model.edgeless.scale ?? 1; + this._noteFullHeight = + rect.height / scale / zoom + 2 * EDGELESS_BLOCK_CHILD_PADDING; + }); + if (this._notePageContent) { + observer.observe(this, { childList: true, subtree: true }); + _disposables.add(() => observer.disconnect()); + } + } + + override getRenderingRect() { + const { xywh, edgeless } = this.model; + const { collapse, scale = 1 } = edgeless; + + const bound = Bound.deserialize(xywh); + const width = bound.w / scale; + const height = bound.h / scale; + + return { + x: bound.x, + y: bound.y, + w: width, + h: collapse ? height : 'inherit', + zIndex: this.toZIndex(), + }; + } + + override renderGfxBlock() { + const { model } = this; + const { displayMode } = model; + if (!!displayMode && displayMode === NoteDisplayMode.DocOnly) + return nothing; + + const { xywh, edgeless } = model; + const { borderRadius, borderSize, borderStyle, shadowType } = + edgeless.style; + const { collapse, collapsedHeight, scale = 1 } = edgeless; + + const bound = Bound.deserialize(xywh); + const width = bound.w / scale; + const height = bound.h / scale; + + const style = { + height: '100%', + padding: `${EDGELESS_BLOCK_CHILD_PADDING}px`, + boxSizing: 'border-box', + borderRadius: borderRadius + 'px', + pointerEvents: 'all', + transformOrigin: '0 0', + transform: `scale(${scale})`, + fontWeight: '400', + lineHeight: 'var(--affine-line-height)', + }; + + const extra = this._editing ? ACTIVE_NOTE_EXTRA_PADDING : 0; + const backgroundColor = this.std + .get(ThemeProvider) + .generateColorProperty(model.background, DEFAULT_NOTE_BACKGROUND_COLOR); + + const backgroundStyle = { + position: 'absolute', + left: `${-extra}px`, + top: `${-extra}px`, + width: `${width + extra * 2}px`, + height: `calc(100% + ${extra * 2}px)`, + borderRadius: borderRadius + 'px', + transition: this._editing + ? 'left 0.3s, top 0.3s, width 0.3s, height 0.3s' + : 'none', + backgroundColor, + border: `${borderSize}px ${ + borderStyle === StrokeStyle.Dash ? 'dashed' : borderStyle + } var(--affine-black-10)`, + boxShadow: this._editing + ? 'var(--affine-active-shadow)' + : !shadowType + ? 'none' + : `var(${shadowType})`, + }; + + const isCollapsable = + collapse != null && + collapsedHeight != null && + collapsedHeight !== this._noteFullHeight; + + const isCollapseArrowUp = collapse + ? this._noteFullHeight < height + : !!collapsedHeight && collapsedHeight < height; + + return html` + <div + class="edgeless-note-container" + style=${styleMap(style)} + data-model-height="${bound.h}" + @mouseleave=${this._leaved} + @mousemove=${this._hovered} + data-scale="${scale}" + > + <div + class="note-background" + style=${styleMap(backgroundStyle)} + @pointerdown=${stopPropagation} + @click=${this._handleClickAtBackground} + ></div> + + <div + class="edgeless-note-page-content" + style=${styleMap({ + width: '100%', + height: '100%', + 'overflow-y': this._isShowCollapsedContent ? 'initial' : 'clip', + })} + > + ${this.renderPageContent()} + </div> + + ${isCollapsable + ? html`<div + class="${classMap({ + 'edgeless-note-collapse-button': true, + flip: isCollapseArrowUp, + hide: this._isSelected, + })}" + style=${styleMap({ + bottom: this._editing ? `${-extra}px` : '0', + })} + @mousedown=${stopPropagation} + @mouseup=${stopPropagation} + @click=${this._setCollapse} + > + ${MoreIndicatorIcon} + </div>` + : nothing} + ${this._collapsedContent()} + + <edgeless-note-mask + .model=${this.model} + .display=${!this._editing} + .host=${this.host} + .zoom=${this.gfx.viewport.zoom ?? 1} + .editing=${this._editing} + ></edgeless-note-mask> + </div> + `; + } + + @state() + private accessor _editing = false; + + @state() + private accessor _isHover = false; + + @state() + private accessor _isResizing = false; + + @state() + private accessor _isSelected = false; + + @state() + private accessor _noteFullHeight = 0; + + @query('.edgeless-note-page-content .affine-note-block-container') + private accessor _notePageContent: HTMLElement | null = null; +} + +declare global { + interface HTMLElementTagNameMap { + 'affine-edgeless-note': EdgelessNoteBlockComponent; + } +} diff --git a/blocksuite/blocks/src/note-block/note-service.ts b/blocksuite/blocks/src/note-block/note-service.ts new file mode 100644 index 0000000000..90651284a5 --- /dev/null +++ b/blocksuite/blocks/src/note-block/note-service.ts @@ -0,0 +1,642 @@ +import type { NoteBlockModel } from '@blocksuite/affine-model'; +import { NoteBlockSchema } from '@blocksuite/affine-model'; +import { DragHandleConfigExtension } from '@blocksuite/affine-shared/services'; +import { matchFlavours } from '@blocksuite/affine-shared/utils'; +import { + type BaseSelection, + type BlockComponent, + type BlockSelection, + BlockService, + type UIEventHandler, + type UIEventStateContext, +} from '@blocksuite/block-std'; + +import { moveBlockConfigs } from '../_common/configs/move-block.js'; +import { quickActionConfig } from '../_common/configs/quick-action/config.js'; +import { textConversionConfigs } from '../_common/configs/text-conversion.js'; +import { onModelElementUpdated } from '../root-block/utils/callback.js'; +import { getDuplicateBlocks } from '../root-block/widgets/drag-handle/utils.js'; + +export class NoteBlockService extends BlockService { + static override readonly flavour = NoteBlockSchema.model.flavour; + + private _anchorSel: BlockSelection | null = null; + + private _bindMoveBlockHotKey = () => { + return moveBlockConfigs.reduce( + (acc, config) => { + const keys = config.hotkey.reduce( + (acc, key) => { + return { + ...acc, + [key]: ctx => { + ctx.get('defaultState').event.preventDefault(); + return config.action(this.std); + }, + }; + }, + {} as Record<string, UIEventHandler> + ); + return { + ...acc, + ...keys, + }; + }, + {} as Record<string, UIEventHandler> + ); + }; + + private _bindQuickActionHotKey = () => { + return quickActionConfig + .filter(config => config.hotkey) + .reduce( + (acc, config) => { + return { + ...acc, + [config.hotkey!]: ctx => { + if (!config.showWhen(this.std.host)) return; + + ctx.get('defaultState').event.preventDefault(); + config.action(this.std.host); + }, + }; + }, + {} as Record<string, UIEventHandler> + ); + }; + + private _bindTextConversionHotKey = () => { + return textConversionConfigs + .filter(item => item.hotkey) + .reduce( + (acc, item) => { + const keymap = item.hotkey!.reduce( + (acc, key) => { + return { + ...acc, + [key]: ctx => { + ctx.get('defaultState').event.preventDefault(); + const [result] = this._std.command + .chain() + .updateBlockType({ + flavour: item.flavour, + props: { + type: item.type, + }, + }) + .inline((ctx, next) => { + const newModels = ctx.updatedBlocks; + const host = ctx.std.host; + if (!host || !newModels) { + return; + } + + if (item.flavour !== 'affine:code') { + return; + } + + const [codeModel] = newModels; + onModelElementUpdated(host, codeModel, codeElement => { + this._std.selection.setGroup('note', [ + this._std.selection.create('text', { + from: { + blockId: codeElement.blockId, + index: 0, + length: codeModel.text?.length ?? 0, + }, + to: null, + }), + ]); + }).catch(console.error); + + next(); + }) + .run(); + + return result; + }, + }; + }, + {} as Record<string, UIEventHandler> + ); + + return { + ...acc, + ...keymap, + }; + }, + {} as Record<string, UIEventHandler> + ); + }; + + private _focusBlock: BlockComponent | null = null; + + private _getClosestNoteByBlockId = (blockId: string) => { + const doc = this._std.doc; + let parent = doc.getBlock(blockId)?.model ?? null; + while (parent) { + if (matchFlavours(parent, [NoteBlockSchema.model.flavour])) { + return parent; + } + parent = doc.getParent(parent); + } + return null; + }; + + private _onArrowDown = (ctx: UIEventStateContext) => { + const event = ctx.get('defaultState').event; + + const [result] = this._std.command + .chain() + .inline((_, next) => { + this._reset(); + return next(); + }) + .try(cmd => [ + // text selection - select the next block + // 1. is paragraph, list, code block - follow the default behavior + // 2. is not - select the next block (use block selection instead of text selection) + cmd + .getTextSelection() + .inline<'currentSelectionPath'>((ctx, next) => { + const currentTextSelection = ctx.currentTextSelection; + if (!currentTextSelection) { + return; + } + return next({ currentSelectionPath: currentTextSelection.blockId }); + }) + .getNextBlock() + .inline((ctx, next) => { + const { nextBlock } = ctx; + + if (!nextBlock) { + return; + } + + if ( + !matchFlavours(nextBlock.model, [ + 'affine:paragraph', + 'affine:list', + 'affine:code', + ]) + ) { + this._std.command + .chain() + .with({ + focusBlock: nextBlock, + }) + .selectBlock() + .run(); + } + + return next({}); + }), + + // block selection - select the next block + // 1. is paragraph, list, code block - focus it + // 2. is not - select it using block selection + cmd + .getBlockSelections() + .inline<'currentSelectionPath'>((ctx, next) => { + const currentBlockSelections = ctx.currentBlockSelections; + const blockSelection = currentBlockSelections?.at(-1); + if (!blockSelection) { + return; + } + return next({ currentSelectionPath: blockSelection.blockId }); + }) + .getNextBlock() + .inline<'focusBlock'>((ctx, next) => { + const { nextBlock } = ctx; + if (!nextBlock) { + return; + } + + event.preventDefault(); + if ( + matchFlavours(nextBlock.model, [ + 'affine:paragraph', + 'affine:list', + 'affine:code', + ]) + ) { + this._std.command + .chain() + .focusBlockStart({ focusBlock: nextBlock }) + .run(); + return next(); + } + + this._std.command + .chain() + .with({ focusBlock: nextBlock }) + .selectBlock() + .run(); + return next(); + }), + ]) + .run(); + + return result; + }; + + private _onArrowUp = (ctx: UIEventStateContext) => { + const event = ctx.get('defaultState').event; + + const [result] = this._std.command + .chain() + .inline((_, next) => { + this._reset(); + return next(); + }) + .try(cmd => [ + // text selection - select the previous block + // 1. is paragraph, list, code block - follow the default behavior + // 2. is not - select the previous block (use block selection instead of text selection) + cmd + .getTextSelection() + .inline<'currentSelectionPath'>((ctx, next) => { + const currentTextSelection = ctx.currentTextSelection; + if (!currentTextSelection) { + return; + } + return next({ currentSelectionPath: currentTextSelection.blockId }); + }) + .getPrevBlock() + .inline((ctx, next) => { + const { prevBlock } = ctx; + + if (!prevBlock) { + return; + } + + if ( + !matchFlavours(prevBlock.model, [ + 'affine:paragraph', + 'affine:list', + 'affine:code', + ]) + ) { + this._std.command + .chain() + .with({ + focusBlock: prevBlock, + }) + .selectBlock() + .run(); + } + + return next({}); + }), + // block selection - select the previous block + // 1. is paragraph, list, code block - focus it + // 2. is not - select it using block selection + cmd + .getBlockSelections() + .inline<'currentSelectionPath'>((ctx, next) => { + const currentBlockSelections = ctx.currentBlockSelections; + const blockSelection = currentBlockSelections?.at(-1); + if (!blockSelection) { + return; + } + return next({ currentSelectionPath: blockSelection.blockId }); + }) + .getPrevBlock() + .inline<'focusBlock'>((ctx, next) => { + const { prevBlock } = ctx; + if (!prevBlock) { + return; + } + + if ( + matchFlavours(prevBlock.model, [ + 'affine:paragraph', + 'affine:list', + 'affine:code', + ]) + ) { + event.preventDefault(); + this._std.command + .chain() + .focusBlockEnd({ focusBlock: prevBlock }) + .run(); + return next(); + } + + this._std.command + .chain() + .with({ focusBlock: prevBlock }) + .selectBlock() + .run(); + return next(); + }), + ]) + .run(); + + return result; + }; + + private _onBlockShiftDown = (cmd: BlockSuite.CommandChain) => { + return cmd + .getBlockSelections() + .inline<'currentSelectionPath' | 'anchorBlock'>((ctx, next) => { + const blockSelections = ctx.currentBlockSelections; + if (!blockSelections) { + return; + } + + if (!this._anchorSel) { + this._anchorSel = blockSelections.at(-1) ?? null; + } + if (!this._anchorSel) { + return; + } + + const anchorBlock = ctx.std.view.getBlock(this._anchorSel.blockId); + if (!anchorBlock) { + return; + } + return next({ + anchorBlock, + currentSelectionPath: + this._focusBlock?.blockId ?? anchorBlock?.blockId, + }); + }) + .getNextBlock({}) + .inline<'focusBlock'>((ctx, next) => { + const nextBlock = ctx.nextBlock; + if (!nextBlock) { + return; + } + this._focusBlock = nextBlock; + return next({ + focusBlock: this._focusBlock, + }); + }) + .selectBlocksBetween({ tail: true }); + }; + + private _onBlockShiftUp = (cmd: BlockSuite.CommandChain) => { + return cmd + .getBlockSelections() + .inline<'currentSelectionPath' | 'anchorBlock'>((ctx, next) => { + const blockSelections = ctx.currentBlockSelections; + if (!blockSelections) { + return; + } + if (!this._anchorSel) { + this._anchorSel = blockSelections.at(0) ?? null; + } + if (!this._anchorSel) { + return; + } + const anchorBlock = ctx.std.view.getBlock(this._anchorSel.blockId); + if (!anchorBlock) { + return; + } + return next({ + anchorBlock, + currentSelectionPath: + this._focusBlock?.blockId ?? anchorBlock?.blockId, + }); + }) + .getPrevBlock({}) + .inline((ctx, next) => { + const prevBlock = ctx.prevBlock; + if (!prevBlock) { + return; + } + this._focusBlock = prevBlock; + return next({ + focusBlock: this._focusBlock, + }); + }) + .selectBlocksBetween({ tail: false }); + }; + + private _onEnter = (ctx: UIEventStateContext) => { + const event = ctx.get('defaultState').event; + const [result] = this._std.command + .chain() + .getBlockSelections() + .inline((ctx, next) => { + const blockSelection = ctx.currentBlockSelections?.at(-1); + if (!blockSelection) { + return; + } + + const { view, doc, selection } = ctx.std; + + const element = view.getBlock(blockSelection.blockId); + if (!element) { + return; + } + + const { model } = element; + const parent = doc.getParent(model); + if (!parent) { + return; + } + + const index = parent.children.indexOf(model) ?? undefined; + + const blockId = doc.addBlock('affine:paragraph', {}, parent, index + 1); + + const sel = selection.create('text', { + from: { + blockId, + index: 0, + length: 0, + }, + to: null, + }); + + event.preventDefault(); + selection.setGroup('note', [sel]); + + return next(); + }) + .run(); + + return result; + }; + + private _onEsc = () => { + const [result] = this._std.command + .chain() + .getBlockSelections() + .inline((ctx, next) => { + const blockSelection = ctx.currentBlockSelections?.at(-1); + if (!blockSelection) { + return; + } + + ctx.std.selection.update(selList => { + return selList.filter(sel => !sel.is('block')); + }); + + return next(); + }) + .run(); + + return result; + }; + + private _onSelectAll: UIEventHandler = ctx => { + const selection = this._std.selection; + const block = selection.find('block'); + if (!block) { + return; + } + const note = this._getClosestNoteByBlockId(block.blockId); + if (!note) { + return; + } + ctx.get('defaultState').event.preventDefault(); + const children = note.children; + const blocks: BlockSelection[] = children.map(child => { + return selection.create('block', { + blockId: child.id, + }); + }); + selection.update(selList => { + return selList + .filter<BaseSelection>(sel => !sel.is('block')) + .concat(blocks); + }); + }; + + private _onShiftArrowDown = () => { + const [result] = this._std.command + .chain() + .try(cmd => [ + // block selection + this._onBlockShiftDown(cmd), + ]) + .run(); + + return result; + }; + + private _onShiftArrowUp = () => { + const [result] = this._std.command + .chain() + .try(cmd => [ + // block selection + this._onBlockShiftUp(cmd), + ]) + .run(); + + return result; + }; + + private _reset = () => { + this._anchorSel = null; + this._focusBlock = null; + }; + + private get _std() { + return this.std; + } + + override mounted() { + super.mounted(); + this.handleEvent('keyDown', ctx => { + const state = ctx.get('keyboardState'); + if (['Control', 'Meta', 'Shift'].includes(state.raw.key)) { + return; + } + this._reset(); + }); + + this.bindHotKey({ + ...this._bindMoveBlockHotKey(), + ...this._bindQuickActionHotKey(), + ...this._bindTextConversionHotKey(), + Tab: ctx => { + const { success } = this.std.command.exec('indentBlocks'); + + if (!success) return; + + ctx.get('keyboardState').raw.preventDefault(); + return true; + }, + 'Shift-Tab': ctx => { + const { success } = this.std.command.exec('dedentBlocks'); + + if (!success) return; + + ctx.get('keyboardState').raw.preventDefault(); + return true; + }, + 'Mod-Backspace': ctx => { + const { success } = this.std.command.exec('dedentBlocksToRoot'); + + if (!success) return; + + ctx.get('keyboardState').raw.preventDefault(); + return true; + }, + ArrowDown: this._onArrowDown, + ArrowUp: this._onArrowUp, + 'Shift-ArrowDown': this._onShiftArrowDown, + 'Shift-ArrowUp': this._onShiftArrowUp, + Escape: this._onEsc, + Enter: this._onEnter, + 'Mod-a': this._onSelectAll, + }); + } +} + +export const NoteDragHandleOption = DragHandleConfigExtension({ + flavour: NoteBlockSchema.model.flavour, + edgeless: true, + onDragEnd: ({ + draggingElements, + dropBlockId, + dropType, + state, + editorHost, + }) => { + if ( + draggingElements.length !== 1 || + !matchFlavours(draggingElements[0].model, [NoteBlockSchema.model.flavour]) + ) { + return false; + } + + if (dropType === 'in') { + return true; + } + + const noteBlock = draggingElements[0].model as NoteBlockModel; + const targetBlock = editorHost.doc.getBlockById(dropBlockId); + const parentBlock = editorHost.doc.getParent(dropBlockId); + if (!targetBlock || !parentBlock) { + return true; + } + + const altKey = state.raw.altKey; + if (altKey) { + const duplicateBlocks = getDuplicateBlocks(noteBlock.children); + + const parentIndex = + parentBlock.children.indexOf(targetBlock) + + (dropType === 'after' ? 1 : 0); + + editorHost.doc.addBlocks(duplicateBlocks, parentBlock, parentIndex); + } else { + editorHost.doc.moveBlocks( + noteBlock.children, + parentBlock, + targetBlock, + dropType === 'before' + ); + + editorHost.doc.deleteBlock(noteBlock); + editorHost.selection.setGroup('gfx', []); + } + + return true; + }, +}); diff --git a/blocksuite/blocks/src/note-block/note-spec.ts b/blocksuite/blocks/src/note-block/note-spec.ts new file mode 100644 index 0000000000..84bef46997 --- /dev/null +++ b/blocksuite/blocks/src/note-block/note-spec.ts @@ -0,0 +1,31 @@ +import { + BlockViewExtension, + CommandExtension, + type ExtensionType, + FlavourExtension, +} from '@blocksuite/block-std'; +import { literal } from 'lit/static-html.js'; + +import { + DocNoteBlockAdapterExtensions, + EdgelessNoteBlockAdapterExtensions, +} from './adapters/index.js'; +import { commands } from './commands/index.js'; +import { NoteBlockService, NoteDragHandleOption } from './note-service.js'; + +export const NoteBlockSpec: ExtensionType[] = [ + FlavourExtension('affine:note'), + NoteBlockService, + CommandExtension(commands), + BlockViewExtension('affine:note', literal`affine-note`), + DocNoteBlockAdapterExtensions, +].flat(); + +export const EdgelessNoteBlockSpec: ExtensionType[] = [ + FlavourExtension('affine:note'), + NoteBlockService, + CommandExtension(commands), + BlockViewExtension('affine:note', literal`affine-edgeless-note`), + NoteDragHandleOption, + EdgelessNoteBlockAdapterExtensions, +].flat(); diff --git a/blocksuite/blocks/src/root-block/adapters/html.ts b/blocksuite/blocks/src/root-block/adapters/html.ts new file mode 100644 index 0000000000..0a22e0f4be --- /dev/null +++ b/blocksuite/blocks/src/root-block/adapters/html.ts @@ -0,0 +1,135 @@ +import { RootBlockSchema } from '@blocksuite/affine-model'; +import { + BlockHtmlAdapterExtension, + type BlockHtmlAdapterMatcher, + HastUtils, +} from '@blocksuite/affine-shared/adapters'; + +export const rootBlockHtmlAdapterMatcher: BlockHtmlAdapterMatcher = { + flavour: RootBlockSchema.model.flavour, + toMatch: o => HastUtils.isElement(o.node) && o.node.tagName === 'header', + fromMatch: o => o.node.flavour === RootBlockSchema.model.flavour, + toBlockSnapshot: { + enter: (o, context) => { + if (!HastUtils.isElement(o.node)) { + return; + } + const { walkerContext } = context; + if (o.node.tagName === 'header') { + walkerContext.skipAllChildren(); + } + }, + }, + fromBlockSnapshot: { + enter: (_, context) => { + const { walkerContext } = context; + const htmlRootDocContext = + walkerContext.getGlobalContext('hast:html-root-doc'); + const isRootDoc = htmlRootDocContext ?? true; + if (!isRootDoc) { + return; + } + + walkerContext + .openNode( + { + type: 'element', + tagName: 'html', + properties: {}, + children: [], + }, + 'children' + ) + .openNode( + { + type: 'element', + tagName: 'head', + properties: {}, + children: [], + }, + 'children' + ) + .openNode( + { + type: 'element', + tagName: 'style', + properties: {}, + children: [], + }, + 'children' + ) + .openNode( + { + type: 'text', + value: ` + input[type='checkbox'] { + display: none; + } + label:before { + background: rgb(30, 150, 235); + border-radius: 3px; + height: 16px; + width: 16px; + display: inline-block; + cursor: pointer; + } + input[type='checkbox'] + label:before { + content: ''; + background: rgb(30, 150, 235); + color: #fff; + font-size: 16px; + line-height: 16px; + text-align: center; + } + input[type='checkbox']:checked + label:before { + content: '✓'; + } + `.replace(/\s\s+/g, ''), + }, + 'children' + ) + .closeNode() + .closeNode() + .closeNode() + .openNode( + { + type: 'element', + tagName: 'body', + properties: {}, + children: [], + }, + 'children' + ) + .openNode( + { + type: 'element', + tagName: 'div', + properties: { + style: 'width: 70vw; margin: 60px auto;', + }, + children: [], + }, + 'children' + ) + .openNode({ + type: 'comment', + value: 'BlockSuiteDocTitlePlaceholder', + }) + .closeNode(); + }, + leave: (_, context) => { + const { walkerContext } = context; + const htmlRootDocContext = + walkerContext.getGlobalContext('hast:html-root-doc'); + const isRootDoc = htmlRootDocContext ?? true; + if (!isRootDoc) { + return; + } + walkerContext.closeNode().closeNode().closeNode(); + }, + }, +}; + +export const RootBlockHtmlAdapterExtension = BlockHtmlAdapterExtension( + rootBlockHtmlAdapterMatcher +); diff --git a/blocksuite/blocks/src/root-block/adapters/index.ts b/blocksuite/blocks/src/root-block/adapters/index.ts new file mode 100644 index 0000000000..94b5ef70c3 --- /dev/null +++ b/blocksuite/blocks/src/root-block/adapters/index.ts @@ -0,0 +1,3 @@ +export * from './html.js'; +export * from './markdown.js'; +export * from './notion-html.js'; diff --git a/blocksuite/blocks/src/root-block/adapters/markdown.ts b/blocksuite/blocks/src/root-block/adapters/markdown.ts new file mode 100644 index 0000000000..c10879bd16 --- /dev/null +++ b/blocksuite/blocks/src/root-block/adapters/markdown.ts @@ -0,0 +1,36 @@ +import { RootBlockSchema } from '@blocksuite/affine-model'; +import { + BlockMarkdownAdapterExtension, + type BlockMarkdownAdapterMatcher, +} from '@blocksuite/affine-shared/adapters'; +import type { DeltaInsert } from '@blocksuite/inline'; + +export const rootBlockMarkdownAdapterMatcher: BlockMarkdownAdapterMatcher = { + flavour: RootBlockSchema.model.flavour, + toMatch: () => false, + fromMatch: o => o.node.flavour === RootBlockSchema.model.flavour, + toBlockSnapshot: {}, + fromBlockSnapshot: { + enter: (o, context) => { + const title = (o.node.props.title ?? { delta: [] }) as { + delta: DeltaInsert[]; + }; + const { walkerContext, deltaConverter } = context; + if (title.delta.length === 0) return; + walkerContext + .openNode( + { + type: 'heading', + depth: 1, + children: deltaConverter.deltaToAST(title.delta, 0), + }, + 'children' + ) + .closeNode(); + }, + }, +}; + +export const RootBlockMarkdownAdapterExtension = BlockMarkdownAdapterExtension( + rootBlockMarkdownAdapterMatcher +); diff --git a/blocksuite/blocks/src/root-block/adapters/notion-html.ts b/blocksuite/blocks/src/root-block/adapters/notion-html.ts new file mode 100644 index 0000000000..70a6daced5 --- /dev/null +++ b/blocksuite/blocks/src/root-block/adapters/notion-html.ts @@ -0,0 +1,28 @@ +import { RootBlockSchema } from '@blocksuite/affine-model'; +import { + BlockNotionHtmlAdapterExtension, + type BlockNotionHtmlAdapterMatcher, + HastUtils, +} from '@blocksuite/affine-shared/adapters'; + +export const rootBlockNotionHtmlAdapterMatcher: BlockNotionHtmlAdapterMatcher = + { + flavour: RootBlockSchema.model.flavour, + toMatch: o => HastUtils.isElement(o.node) && o.node.tagName === 'header', + fromMatch: () => false, + toBlockSnapshot: { + enter: (o, context) => { + if (!HastUtils.isElement(o.node)) { + return; + } + const { walkerContext } = context; + if (o.node.tagName === 'header') { + walkerContext.skipAllChildren(); + } + }, + }, + fromBlockSnapshot: {}, + }; + +export const RootBlockNotionHtmlAdapterExtension = + BlockNotionHtmlAdapterExtension(rootBlockNotionHtmlAdapterMatcher); diff --git a/blocksuite/blocks/src/root-block/clipboard/adapter.ts b/blocksuite/blocks/src/root-block/clipboard/adapter.ts new file mode 100644 index 0000000000..9931549541 --- /dev/null +++ b/blocksuite/blocks/src/root-block/clipboard/adapter.ts @@ -0,0 +1,92 @@ +import { BlockSuiteError, ErrorCode } from '@blocksuite/global/exceptions'; +import { assertExists } from '@blocksuite/global/utils'; +import type { + BlockSnapshot, + DocSnapshot, + FromBlockSnapshotPayload, + FromBlockSnapshotResult, + FromDocSnapshotPayload, + FromDocSnapshotResult, + FromSliceSnapshotPayload, + FromSliceSnapshotResult, + SliceSnapshot, + ToBlockSnapshotPayload, + ToDocSnapshotPayload, + ToSliceSnapshotPayload, +} from '@blocksuite/store'; +import { BaseAdapter } from '@blocksuite/store'; + +import { decodeClipboardBlobs, encodeClipboardBlobs } from './utils.js'; + +export type FileSnapshot = { + name: string; + type: string; + content: string; +}; + +export class ClipboardAdapter extends BaseAdapter<string> { + static MIME = 'BLOCKSUITE/SNAPSHOT'; + + override fromBlockSnapshot( + _payload: FromBlockSnapshotPayload + ): Promise<FromBlockSnapshotResult<string>> { + throw new BlockSuiteError( + ErrorCode.TransformerNotImplementedError, + 'ClipboardAdapter.fromBlockSnapshot is not implemented' + ); + } + + override fromDocSnapshot( + _payload: FromDocSnapshotPayload + ): Promise<FromDocSnapshotResult<string>> { + throw new BlockSuiteError( + ErrorCode.TransformerNotImplementedError, + 'ClipboardAdapter.fromDocSnapshot is not implemented' + ); + } + + override async fromSliceSnapshot( + payload: FromSliceSnapshotPayload + ): Promise<FromSliceSnapshotResult<string>> { + const snapshot = payload.snapshot; + const assets = payload.assets; + assertExists(assets); + const map = assets.getAssets(); + const blobs: Record<string, FileSnapshot> = await encodeClipboardBlobs(map); + return { + file: JSON.stringify({ + snapshot, + blobs, + }), + assetsIds: [], + }; + } + + override toBlockSnapshot( + _payload: ToBlockSnapshotPayload<string> + ): Promise<BlockSnapshot> { + throw new BlockSuiteError( + ErrorCode.TransformerNotImplementedError, + 'ClipboardAdapter.toBlockSnapshot is not implemented' + ); + } + + override toDocSnapshot( + _payload: ToDocSnapshotPayload<string> + ): Promise<DocSnapshot> { + throw new BlockSuiteError( + ErrorCode.TransformerNotImplementedError, + 'ClipboardAdapter.toDocSnapshot is not implemented' + ); + } + + override toSliceSnapshot( + payload: ToSliceSnapshotPayload<string> + ): Promise<SliceSnapshot> { + const json = JSON.parse(payload.file); + const { blobs, snapshot } = json; + const map = payload.assets?.getAssets(); + decodeClipboardBlobs(blobs, map); + return Promise.resolve(snapshot); + } +} diff --git a/blocksuite/blocks/src/root-block/clipboard/index.ts b/blocksuite/blocks/src/root-block/clipboard/index.ts new file mode 100644 index 0000000000..1b502bc5e7 --- /dev/null +++ b/blocksuite/blocks/src/root-block/clipboard/index.ts @@ -0,0 +1,218 @@ +import type { BlockComponent, UIEventHandler } from '@blocksuite/block-std'; +import { DisposableGroup } from '@blocksuite/global/utils'; +import type { BlockSnapshot, Doc } from '@blocksuite/store'; + +import { + AttachmentAdapter, + HtmlAdapter, + ImageAdapter, + MixTextAdapter, + NotionTextAdapter, +} from '../../_common/adapters/index.js'; +import { + defaultImageProxyMiddleware, + replaceIdMiddleware, + titleMiddleware, +} from '../../_common/transformers/middlewares.js'; +import { ClipboardAdapter } from './adapter.js'; +import { copyMiddleware, pasteMiddleware } from './middlewares/index.js'; + +export class PageClipboard { + private _copySelected = (onCopy?: () => void) => { + return this._std.command + .chain() + .with({ onCopy }) + .getSelectedModels() + .draftSelectedModels() + .copySelectedModels(); + }; + + protected _disposables = new DisposableGroup(); + + protected _init = () => { + this._std.clipboard.registerAdapter( + ClipboardAdapter.MIME, + ClipboardAdapter, + 100 + ); + this._std.clipboard.registerAdapter( + 'text/_notion-text-production', + NotionTextAdapter, + 95 + ); + this._std.clipboard.registerAdapter('text/html', HtmlAdapter, 90); + [ + 'image/apng', + 'image/avif', + 'image/gif', + 'image/jpeg', + 'image/png', + 'image/svg+xml', + 'image/webp', + ].forEach(type => + this._std.clipboard.registerAdapter(type, ImageAdapter, 80) + ); + this._std.clipboard.registerAdapter('text/plain', MixTextAdapter, 70); + this._std.clipboard.registerAdapter('*/*', AttachmentAdapter, 60); + const copy = copyMiddleware(this._std); + const paste = pasteMiddleware(this._std); + this._std.clipboard.use(copy); + this._std.clipboard.use(paste); + this._std.clipboard.use(replaceIdMiddleware); + this._std.clipboard.use(titleMiddleware); + this._std.clipboard.use(defaultImageProxyMiddleware); + + this._disposables.add({ + dispose: () => { + this._std.clipboard.unregisterAdapter(ClipboardAdapter.MIME); + this._std.clipboard.unregisterAdapter('text/plain'); + [ + 'image/apng', + 'image/avif', + 'image/gif', + 'image/jpeg', + 'image/png', + 'image/svg+xml', + 'image/webp', + ].forEach(type => this._std.clipboard.unregisterAdapter(type)); + this._std.clipboard.unregisterAdapter('text/html'); + this._std.clipboard.unregisterAdapter('*/*'); + this._std.clipboard.unuse(copy); + this._std.clipboard.unuse(paste); + this._std.clipboard.unuse(replaceIdMiddleware); + this._std.clipboard.unuse(titleMiddleware); + this._std.clipboard.unuse(defaultImageProxyMiddleware); + }, + }); + }; + + host: BlockComponent; + + onBlockSnapshotPaste = async ( + snapshot: BlockSnapshot, + doc: Doc, + parent?: string, + index?: number + ) => { + const block = await this._std.clipboard.pasteBlockSnapshot( + snapshot, + doc, + parent, + index + ); + return block?.id ?? null; + }; + + onPageCopy: UIEventHandler = ctx => { + const e = ctx.get('clipboardState').raw; + e.preventDefault(); + + this._copySelected().run(); + }; + + onPageCut: UIEventHandler = ctx => { + const e = ctx.get('clipboardState').raw; + e.preventDefault(); + + this._copySelected(() => { + this._std.command + .chain() + .try(cmd => [ + cmd.getTextSelection().deleteText(), + cmd.getSelectedModels().deleteSelectedModels(), + ]) + .run(); + }).run(); + }; + + onPagePaste: UIEventHandler = ctx => { + const e = ctx.get('clipboardState').raw; + e.preventDefault(); + + this._std.doc.captureSync(); + this._std.command + .chain() + .try(cmd => [ + cmd.getTextSelection(), + cmd + .getSelectedModels() + .clearAndSelectFirstModel() + .retainFirstModel() + .deleteSelectedModels(), + ]) + .try(cmd => [ + cmd.getTextSelection().inline<'currentSelectionPath'>((ctx, next) => { + const textSelection = ctx.currentTextSelection; + if (!textSelection) { + return; + } + next({ currentSelectionPath: textSelection.from.blockId }); + }), + cmd.getBlockSelections().inline<'currentSelectionPath'>((ctx, next) => { + const currentBlockSelections = ctx.currentBlockSelections; + if (!currentBlockSelections) { + return; + } + const blockSelection = currentBlockSelections.at(-1); + if (!blockSelection) { + return; + } + next({ currentSelectionPath: blockSelection.blockId }); + }), + cmd.getImageSelections().inline<'currentSelectionPath'>((ctx, next) => { + const currentImageSelections = ctx.currentImageSelections; + if (!currentImageSelections) { + return; + } + const imageSelection = currentImageSelections.at(-1); + if (!imageSelection) { + return; + } + next({ currentSelectionPath: imageSelection.blockId }); + }), + ]) + .getBlockIndex() + .inline((ctx, next) => { + if (!ctx.parentBlock) { + return; + } + this._std.clipboard + .paste( + e, + this._std.doc, + ctx.parentBlock.model.id, + ctx.blockIndex ? ctx.blockIndex + 1 : 1 + ) + .catch(console.error); + + return next(); + }) + .run(); + }; + + private get _std() { + return this.host.std; + } + + constructor(host: BlockComponent) { + this.host = host; + } + + hostConnected() { + if (this._disposables.disposed) { + this._disposables = new DisposableGroup(); + } + if (navigator.clipboard) { + this.host.handleEvent('copy', this.onPageCopy); + this.host.handleEvent('paste', this.onPagePaste); + this.host.handleEvent('cut', this.onPageCut); + this._init(); + } + } + + hostDisconnected() { + this._disposables.dispose(); + } +} + +export { copyMiddleware, pasteMiddleware }; diff --git a/blocksuite/blocks/src/root-block/clipboard/middlewares/copy.ts b/blocksuite/blocks/src/root-block/clipboard/middlewares/copy.ts new file mode 100644 index 0000000000..e63bf3c5c1 --- /dev/null +++ b/blocksuite/blocks/src/root-block/clipboard/middlewares/copy.ts @@ -0,0 +1,54 @@ +import type { EditorHost, TextRangePoint } from '@blocksuite/block-std'; +import type { + BlockSnapshot, + DraftModel, + JobMiddleware, + JobSlots, +} from '@blocksuite/store'; + +import { matchFlavours } from '../../../_common/utils/index.js'; + +const handlePoint = ( + point: TextRangePoint, + snapshot: BlockSnapshot, + model: DraftModel +) => { + const { index, length } = point; + if (matchFlavours(model, ['affine:page'])) { + if (length === 0) return; + (snapshot.props.title as Record<string, unknown>).delta = + model.title.sliceToDelta(index, length + index); + return; + } + + if (!snapshot.props.text || length === 0) { + return; + } + (snapshot.props.text as Record<string, unknown>).delta = + model.text?.sliceToDelta(index, length + index); +}; + +const sliceText = (slots: JobSlots, std: EditorHost['std']) => { + slots.afterExport.on(payload => { + if (payload.type === 'block') { + const snapshot = payload.snapshot; + + const model = payload.model; + const text = std.selection.find('text'); + if (text && text.from.blockId === model.id) { + handlePoint(text.from, snapshot, model); + return; + } + if (text && text.to && text.to.blockId === model.id) { + handlePoint(text.to, snapshot, model); + return; + } + } + }); +}; + +export const copyMiddleware = (std: EditorHost['std']): JobMiddleware => { + return ({ slots }) => { + sliceText(slots, std); + }; +}; diff --git a/blocksuite/blocks/src/root-block/clipboard/middlewares/index.ts b/blocksuite/blocks/src/root-block/clipboard/middlewares/index.ts new file mode 100644 index 0000000000..f3a618466e --- /dev/null +++ b/blocksuite/blocks/src/root-block/clipboard/middlewares/index.ts @@ -0,0 +1,2 @@ +export * from './copy.js'; +export * from './paste.js'; diff --git a/blocksuite/blocks/src/root-block/clipboard/middlewares/paste.ts b/blocksuite/blocks/src/root-block/clipboard/middlewares/paste.ts new file mode 100644 index 0000000000..6160bc1eeb --- /dev/null +++ b/blocksuite/blocks/src/root-block/clipboard/middlewares/paste.ts @@ -0,0 +1,529 @@ +import { REFERENCE_NODE } from '@blocksuite/affine-components/rich-text'; +import type { ParagraphBlockModel } from '@blocksuite/affine-model'; +import { + ParseDocUrlProvider, + type ParseDocUrlService, + TelemetryProvider, +} from '@blocksuite/affine-shared/services'; +import type { AffineTextAttributes } from '@blocksuite/affine-shared/types'; +import { referenceToNode } from '@blocksuite/affine-shared/utils'; +import { + BLOCK_ID_ATTR, + type BlockComponent, + type EditorHost, + type TextRangePoint, + type TextSelection, +} from '@blocksuite/block-std'; +import { BlockSuiteError, ErrorCode } from '@blocksuite/global/exceptions'; +import { assertExists } from '@blocksuite/global/utils'; +import { + type BlockModel, + type BlockSnapshot, + type DeltaOperation, + DocCollection, + fromJSON, + type JobMiddleware, + type SliceSnapshot, + type Text, +} from '@blocksuite/store'; + +import { matchFlavours } from '../../../_common/utils/index.js'; +import { extractSearchParams } from '../../../_common/utils/url.js'; + +function findLastMatchingNode( + root: BlockSnapshot[], + fn: (node: BlockSnapshot) => boolean +): BlockSnapshot | null { + let lastMatchingNode: BlockSnapshot | null = null; + + function traverse(node: BlockSnapshot) { + if (fn(node)) { + lastMatchingNode = node; + } + if (node.children) { + for (const child of node.children) { + traverse(child); + } + } + } + + root.forEach(traverse); + return lastMatchingNode; +} + +// find last child that has text as prop +const findLast = (snapshot: SliceSnapshot): BlockSnapshot | null => { + return findLastMatchingNode(snapshot.content, node => !!node.props.text); +}; + +class PointState { + private _blockFromPath = (path: string) => { + const block = this.std.view.getBlock(path); + assertExists(block); + return block; + }; + + readonly block: BlockComponent; + + readonly model: BlockModel; + + readonly text: Text; + + constructor( + readonly std: EditorHost['std'], + readonly point: TextRangePoint + ) { + this.block = this._blockFromPath(point.blockId); + this.model = this.block.model; + const text = this.model.text; + if (!text) { + console.error(this.point); + throw new BlockSuiteError( + ErrorCode.TransformerError, + 'Text point without text model' + ); + } + this.text = text; + } +} + +class PasteTr { + private _getDeltas = () => { + const firstTextSnapshot = this._textFromSnapshot(this.firstSnapshot!); + const lastTextSnapshot = this._textFromSnapshot(this.lastSnapshot!); + const fromDelta = this.pointState.text.sliceToDelta( + 0, + this.pointState.point.index + ); + const toDelta = this.pointState.text.sliceToDelta( + this.pointState.point.index + this.pointState.point.length, + this.pointState.text.length + ); + const firstDelta = firstTextSnapshot.delta; + const lastDelta = lastTextSnapshot.delta; + return { + firstTextSnapshot, + lastTextSnapshot, + fromDelta, + toDelta, + firstDelta, + lastDelta, + }; + }; + + private _mergeCode = () => { + const deltas: DeltaOperation[] = [{ retain: this.pointState.point.index }]; + this.snapshot.content.forEach((blockSnapshot, i) => { + if (blockSnapshot.props.text) { + const text = this._textFromSnapshot(blockSnapshot); + if (i > 0) { + deltas.push({ insert: '\n' }); + } + deltas.push(...text.delta); + } + }); + this.pointState.text.applyDelta(deltas); + this.snapshot.content = []; + }; + + private _mergeMultiple = () => { + this._updateFlavour(); + + const { lastTextSnapshot, toDelta, firstDelta, lastDelta } = + this._getDeltas(); + + this.pointState.text.applyDelta([ + { retain: this.pointState.point.index }, + this.pointState.text.length - this.pointState.point.index > 0 + ? { delete: this.pointState.text.length - this.pointState.point.index } + : {}, + ...firstDelta, + ]); + + const removedFirstSnapshot = this.snapshot.content.shift(); + removedFirstSnapshot?.children.forEach(block => { + this.snapshot.content.unshift(block); + }); + this.pasteStartModelChildrenCount = + removedFirstSnapshot?.children.length ?? 0; + + this._updateSnapshot(); + + lastTextSnapshot.delta = [...lastDelta, ...toDelta]; + }; + + private _mergeSingle = () => { + this._updateFlavour(); + const { firstDelta } = this._getDeltas(); + const { index, length } = this.pointState.point; + + // Pastes a link + if (length && firstDelta.length === 1 && firstDelta[0].attributes?.link) { + this.pointState.text.format(index, length, firstDelta[0].attributes); + } else { + const ops: DeltaOperation[] = [{ retain: index }]; + if (length) ops.push({ delete: length }); + ops.push(...firstDelta); + + this.pointState.text.applyDelta(ops); + } + + this.snapshot.content.splice(0, 1); + this._updateSnapshot(); + }; + + private _textFromSnapshot = (snapshot: BlockSnapshot) => { + return (snapshot.props.text ?? { delta: [] }) as Record< + 'delta', + DeltaOperation[] + >; + }; + + private _updateSnapshot = () => { + if (this.snapshot.content.length === 0) { + this.firstSnapshot = this.lastSnapshot = undefined; + return; + } + this.firstSnapshot = this.snapshot.content[0]; + this.lastSnapshot = findLast(this.snapshot) ?? this.firstSnapshot; + }; + + private firstSnapshot?: BlockSnapshot; + + private readonly firstSnapshotIsPlainText: boolean; + + private lastIndex: number; + + private lastSnapshot?: BlockSnapshot; + + private needCleanup = false; + + private pasteStartModelChildrenCount = 0; + + private readonly pointState: PointState; + + canMerge = () => { + if (this.snapshot.content.length === 0) { + return false; + } + if (!this.firstSnapshot!.props.text) { + return false; + } + const firstTextSnapshot = this._textFromSnapshot(this.firstSnapshot!); + const lastTextSnapshot = this._textFromSnapshot(this.lastSnapshot!); + return ( + firstTextSnapshot && + lastTextSnapshot && + (this.pointState.text.length > 0 || this.firstSnapshotIsPlainText) + ); + }; + + convertToLinkedDoc = () => { + const parseDocUrlService = this.std.getOptional(ParseDocUrlProvider); + + if (!parseDocUrlService) { + return; + } + + const linkToDocId = new Map<string, string | null>(); + + for (const blockSnapshot of this.snapshot.content) { + if (blockSnapshot.props.text) { + const [delta, transformed] = this._transformLinkDelta( + this._textFromSnapshot(blockSnapshot).delta, + linkToDocId, + parseDocUrlService + ); + const model = this.std.doc.getBlock(blockSnapshot.id)?.model; + if (transformed && model) { + this.std.doc.captureSync(); + this.std.doc.transact(() => { + const text = model.text as Text; + text.clear(); + text.applyDelta(delta); + }); + } + } + } + + const fromPointStateText = this.pointState.model.text; + if (!fromPointStateText) { + return; + } + const [delta, transformed] = this._transformLinkDelta( + fromPointStateText.toDelta(), + linkToDocId, + parseDocUrlService + ); + if (!transformed) { + return; + } + this.std.doc.captureSync(); + this.std.doc.transact(() => { + fromPointStateText.clear(); + fromPointStateText.applyDelta(delta); + }); + }; + + focusPasted = () => { + const host = this.std.host; + + const cursorBlock = + this.pointState.model.flavour === 'affine:code' || !this.lastSnapshot + ? this.std.doc.getBlock(this.pointState.model.id) + : this.std.doc.getBlock(this.lastSnapshot.id); + if (!cursorBlock) { + return; + } + const { model: cursorModel } = cursorBlock; + + host.updateComplete + .then(() => { + const target = this.std.host.querySelector<BlockComponent>( + `[${BLOCK_ID_ATTR}="${cursorModel.id}"]` + ); + if (!target) { + return; + } + if (!cursorModel.text) { + if (matchFlavours(cursorModel, ['affine:image'])) { + const selection = this.std.selection.create('image', { + blockId: target.blockId, + }); + this.std.selection.setGroup('note', [selection]); + return; + } + const selection = this.std.selection.create('block', { + blockId: target.blockId, + }); + this.std.selection.setGroup('note', [selection]); + return; + } + const selection = this.std.selection.create('text', { + from: { + blockId: target.blockId, + index: cursorModel.text ? this.lastIndex : 0, + length: 0, + }, + to: null, + }); + this.std.selection.setGroup('note', [selection]); + }) + .catch(console.error); + }; + + pasted = () => { + if (!(this.needCleanup || this.pointState.text.length === 0)) { + return; + } + + if (this.lastSnapshot) { + const lastModel = this.std.doc.getBlock(this.lastSnapshot.id)?.model; + if (!lastModel) { + return; + } + this.std.doc.moveBlocks(this.pointState.model.children, lastModel); + } + + this.std.doc.moveBlocks( + this.std.doc + .getNexts(this.pointState.model.id) + .slice(0, this.pasteStartModelChildrenCount), + this.pointState.model + ); + + if (!this.firstSnapshotIsPlainText && this.pointState.text.length == 0) { + this.std.doc.deleteBlock(this.pointState.model); + } + }; + + constructor( + readonly std: EditorHost['std'], + readonly text: TextSelection, + readonly snapshot: SliceSnapshot + ) { + const { from } = text; + + this.pointState = new PointState(std, from); + + this.firstSnapshot = snapshot.content[0]; + this.lastSnapshot = findLast(snapshot) ?? this.firstSnapshot; + if ( + this.firstSnapshot !== this.lastSnapshot && + this.lastSnapshot.props.text && + !matchFlavours(this.pointState.model, ['affine:code']) + ) { + const text = fromJSON(this.lastSnapshot.props.text) as Text; + const doc = new DocCollection.Y.Doc(); + const temp = doc.getMap('temp'); + temp.set('text', text.yText); + this.lastIndex = text.length; + } else { + this.lastIndex = + this.pointState.point.index + + this.snapshot.content + .map(snapshot => + this._textFromSnapshot(snapshot) + .delta.map(op => { + if (op.insert) { + return op.insert.length; + } else if (op.delete) { + return -op.delete; + } else { + return 0; + } + }) + .reduce((a, b) => a + b, 0) + ) + .reduce((a, b) => a + b + 1, -1); + } + this.firstSnapshotIsPlainText = + this.firstSnapshot.flavour === 'affine:paragraph' && + this.firstSnapshot.props.type === 'text'; + } + + private _transformLinkDelta( + delta: DeltaOperation[], + linkToDocId: Map<string, string | null>, + parseDocUrlService: ParseDocUrlService + ): [DeltaOperation[], boolean] { + let transformed = false; + const needToConvert = new Map<DeltaOperation, string>(); + for (const op of delta) { + if (op.attributes?.link) { + let docId = linkToDocId.get(op.attributes.link); + if (!docId) { + const searchResult = parseDocUrlService.parseDocUrl( + op.attributes.link + ); + if (searchResult) { + const doc = this.std.collection.getDoc(searchResult.docId); + if (doc) { + docId = doc.id; + linkToDocId.set(op.attributes.link, doc.id); + } + } + } + if (docId) { + needToConvert.set(op, docId); + } + } + } + const newDelta = delta.map(op => { + if (!needToConvert.has(op)) { + return { ...op }; + } + + const link = op.attributes?.link; + + if (!link) { + return { ...op }; + } + + const pageId = needToConvert.get(op); + + if (!pageId) { + // External link + this.std.getOptional(TelemetryProvider)?.track('Link', { + page: 'doc editor', + category: 'pasted link', + other: 'external link', + type: 'link', + }); + + return { ...op }; + } + + const reference: AffineTextAttributes['reference'] = { + pageId, + type: 'LinkedPage', + }; + // Title alias + if (op.insert && op.insert !== REFERENCE_NODE && op.insert !== link) { + reference.title = op.insert; + } + + const extractedParams = extractSearchParams(link); + const isLinkedBlock = extractedParams + ? referenceToNode({ pageId, ...extractedParams }) + : false; + + Object.assign(reference, extractedParams); + + // Internal link + this.std.getOptional(TelemetryProvider)?.track('LinkedDocCreated', { + page: 'doc editor', + category: 'pasted link', + other: 'existing doc', + type: isLinkedBlock ? 'block' : 'doc', + }); + + transformed = true; + + return { + ...op, + attributes: { reference }, + insert: REFERENCE_NODE, + }; + }); + return [newDelta, transformed]; + } + + private _updateFlavour() { + this.firstSnapshot!.flavour = this.pointState.model.flavour; + if (this.firstSnapshot!.props.type) { + this.firstSnapshot!.props.type = ( + this.pointState.model as ParagraphBlockModel + ).type; + } + } + + merge() { + if (this.pointState.model.flavour === 'affine:code') { + this._mergeCode(); + return; + } + + if (this.firstSnapshot === this.lastSnapshot) { + this._mergeSingle(); + return; + } + + this.needCleanup = true; + this._mergeMultiple(); + } +} + +function flatNote(snapshot: SliceSnapshot) { + if (snapshot.content[0]?.flavour === 'affine:note') { + snapshot.content = snapshot.content[0].children; + } +} + +export const pasteMiddleware = (std: EditorHost['std']): JobMiddleware => { + return ({ slots }) => { + let tr: PasteTr | undefined; + slots.beforeImport.on(payload => { + if (payload.type === 'slice') { + const { snapshot } = payload; + flatNote(snapshot); + + const text = std.selection.find('text'); + if (!text) { + return; + } + tr = new PasteTr(std, text, payload.snapshot); + if (tr.canMerge()) { + tr.merge(); + } + } + }); + slots.afterImport.on(payload => { + if (tr && payload.type === 'slice') { + tr.pasted(); + tr.focusPasted(); + tr.convertToLinkedDoc(); + } + }); + }; +}; diff --git a/blocksuite/blocks/src/root-block/clipboard/utils.ts b/blocksuite/blocks/src/root-block/clipboard/utils.ts new file mode 100644 index 0000000000..14efcc4aba --- /dev/null +++ b/blocksuite/blocks/src/root-block/clipboard/utils.ts @@ -0,0 +1,124 @@ +import { toast } from '@blocksuite/affine-components/toast'; +import { assertExists } from '@blocksuite/global/utils'; + +import type { FileSnapshot } from './adapter.js'; + +const chars = + 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'; + +// Use a lookup table to find the index. +const lookup = typeof Uint8Array === 'undefined' ? [] : new Uint8Array(256); +for (let i = 0; i < chars.length; i++) { + lookup[chars.charCodeAt(i)] = i; +} + +export const encode = (arraybuffer: ArrayBuffer): string => { + const bytes = new Uint8Array(arraybuffer); + const len = bytes.length; + let i, + base64 = ''; + + for (i = 0; i < len; i += 3) { + base64 += chars[bytes[i] >> 2]; + base64 += chars[((bytes[i] & 3) << 4) | (bytes[i + 1] >> 4)]; + base64 += chars[((bytes[i + 1] & 15) << 2) | (bytes[i + 2] >> 6)]; + base64 += chars[bytes[i + 2] & 63]; + } + + if (len % 3 === 2) { + base64 = base64.substring(0, base64.length - 1) + '='; + } else if (len % 3 === 1) { + base64 = base64.substring(0, base64.length - 2) + '=='; + } + + return base64; +}; + +export const decode = (base64: string): ArrayBuffer => { + const len = base64.length; + let bufferLength = base64.length * 0.75, + i, + p = 0, + encoded1, + encoded2, + encoded3, + encoded4; + + if (base64[base64.length - 1] === '=') { + bufferLength--; + if (base64[base64.length - 2] === '=') { + bufferLength--; + } + } + + const arraybuffer = new ArrayBuffer(bufferLength), + bytes = new Uint8Array(arraybuffer); + + for (i = 0; i < len; i += 4) { + encoded1 = lookup[base64.charCodeAt(i)]; + encoded2 = lookup[base64.charCodeAt(i + 1)]; + encoded3 = lookup[base64.charCodeAt(i + 2)]; + encoded4 = lookup[base64.charCodeAt(i + 3)]; + + bytes[p++] = (encoded1 << 2) | (encoded2 >> 4); + bytes[p++] = ((encoded2 & 15) << 4) | (encoded3 >> 2); + bytes[p++] = ((encoded3 & 3) << 6) | (encoded4 & 63); + } + + return arraybuffer; +}; + +export async function encodeClipboardBlobs(map: Map<string, Blob>) { + const blobs: Record<string, FileSnapshot> = {}; + let sumSize = 0; + await Promise.all( + Array.from(map.entries()).map(async ([id, blob]) => { + if (blob.size > 4 * 1024 * 1024) { + const host = document.querySelector('editor-host'); + if (!host) { + return; + } + toast( + host, + (blob as File).name ?? 'File' + ' is too large to be copied' + ); + return; + } + sumSize += blob.size; + if (sumSize > 6 * 1024 * 1024) { + const host = document.querySelector('editor-host'); + if (!host) { + return; + } + toast( + host, + (blob as File).name ?? + 'File' + ' cannot be copied due to the clipboard size limit' + ); + return; + } + const content = encode(await blob.arrayBuffer()); + const file: FileSnapshot = { + name: (blob as File).name, + type: blob.type, + content, + }; + blobs[id] = file; + }) + ); + return blobs; +} + +export function decodeClipboardBlobs( + blobs: Record<string, FileSnapshot>, + map: Map<string, Blob> | undefined +) { + Object.entries<FileSnapshot>(blobs).forEach(([sourceId, file]) => { + const blob = new Blob([decode(file.content)]); + const f = new File([blob], file.name, { + type: file.type, + }); + assertExists(map); + map.set(sourceId, f); + }); +} diff --git a/blocksuite/blocks/src/root-block/commands/index.ts b/blocksuite/blocks/src/root-block/commands/index.ts new file mode 100644 index 0000000000..a611e929ff --- /dev/null +++ b/blocksuite/blocks/src/root-block/commands/index.ts @@ -0,0 +1,34 @@ +import { + getSelectedPeekableBlocksCommand, + peekSelectedBlockCommand, +} from '@blocksuite/affine-components/peek'; +import { textCommands } from '@blocksuite/affine-components/rich-text'; +import { + clearAndSelectFirstModelCommand, + copySelectedModelsCommand, + deleteSelectedModelsCommand, + draftSelectedModelsCommand, + duplicateSelectedModelsCommand, + getSelectedModelsCommand, + getSelectionRectsCommand, + retainFirstModelCommand, +} from '@blocksuite/affine-shared/commands'; +import type { BlockCommands } from '@blocksuite/block-std'; + +export const commands: BlockCommands = { + // models + clearAndSelectFirstModel: clearAndSelectFirstModelCommand, + copySelectedModels: copySelectedModelsCommand, + deleteSelectedModels: deleteSelectedModelsCommand, + draftSelectedModels: draftSelectedModelsCommand, + duplicateSelectedModels: duplicateSelectedModelsCommand, + getSelectedModels: getSelectedModelsCommand, + retainFirstModel: retainFirstModelCommand, + // text + ...textCommands, + // peekable + peekSelectedBlock: peekSelectedBlockCommand, + getSelectedPeekableBlocks: getSelectedPeekableBlocksCommand, + // rect + getSelectionRects: getSelectionRectsCommand, +}; diff --git a/blocksuite/blocks/src/root-block/configs/index.ts b/blocksuite/blocks/src/root-block/configs/index.ts new file mode 100644 index 0000000000..9f4afd14b4 --- /dev/null +++ b/blocksuite/blocks/src/root-block/configs/index.ts @@ -0,0 +1 @@ +export * from './toolbar.js'; diff --git a/blocksuite/blocks/src/root-block/configs/toolbar.ts b/blocksuite/blocks/src/root-block/configs/toolbar.ts new file mode 100644 index 0000000000..1e4172335a --- /dev/null +++ b/blocksuite/blocks/src/root-block/configs/toolbar.ts @@ -0,0 +1,44 @@ +import type { MenuItemGroup } from '@blocksuite/affine-components/toolbar'; +import type { BlockStdScope, EditorHost } from '@blocksuite/block-std'; +import type { GfxModel } from '@blocksuite/block-std/gfx'; +import type { BlockModel, Doc } from '@blocksuite/store'; + +export abstract class MenuContext { + abstract get doc(): Doc; + + get firstElement(): GfxModel | null { + return null; + } + + abstract get host(): EditorHost; + + abstract get selectedBlockModels(): BlockModel[]; + + abstract get std(): BlockStdScope; + + // Sometimes we need to close the menu. + close() {} + + isElement() { + return false; + } + + abstract isEmpty(): boolean; + + abstract isMultiple(): boolean; + + abstract isSingle(): boolean; +} + +export interface ToolbarMoreMenuConfig { + configure: <T extends MenuContext>( + groups: MenuItemGroup<T>[] + ) => MenuItemGroup<T>[]; +} + +export function getMoreMenuConfig(std: BlockStdScope): ToolbarMoreMenuConfig { + return { + configure: <T extends MenuContext>(groups: MenuItemGroup<T>[]) => groups, + ...std.getConfig('affine:page')?.toolbarMoreMenu, + }; +} diff --git a/blocksuite/blocks/src/root-block/edgeless/block-model.ts b/blocksuite/blocks/src/root-block/edgeless/block-model.ts new file mode 100644 index 0000000000..b2ac0bdddf --- /dev/null +++ b/blocksuite/blocks/src/root-block/edgeless/block-model.ts @@ -0,0 +1 @@ +export { GfxBlockElementModel as GfxBlockModel } from '@blocksuite/block-std/gfx'; diff --git a/blocksuite/blocks/src/root-block/edgeless/clipboard/clipboard.ts b/blocksuite/blocks/src/root-block/edgeless/clipboard/clipboard.ts new file mode 100644 index 0000000000..506f29c456 --- /dev/null +++ b/blocksuite/blocks/src/root-block/edgeless/clipboard/clipboard.ts @@ -0,0 +1,1452 @@ +/* eslint-disable @typescript-eslint/no-non-null-assertion */ +import { + CanvasElementType, + SurfaceGroupLikeModel, + TextUtils, +} from '@blocksuite/affine-block-surface'; +import type { Connection } from '@blocksuite/affine-model'; +import { + BookmarkStyles, + DEFAULT_NOTE_HEIGHT, + DEFAULT_NOTE_WIDTH, + ReferenceInfoSchema, +} from '@blocksuite/affine-model'; +import { + EmbedOptionProvider, + ParseDocUrlProvider, + TelemetryProvider, +} from '@blocksuite/affine-shared/services'; +import { + isInsidePageEditor, + isUrlInClipboard, + matchFlavours, + referenceToNode, +} from '@blocksuite/affine-shared/utils'; +import type { + BlockStdScope, + EditorHost, + SurfaceSelection, + UIEventStateContext, +} from '@blocksuite/block-std'; +import { + compareLayer, + type GfxCompatibleProps, + type SerializedElement, + SortOrder, +} from '@blocksuite/block-std/gfx'; +import { BlockSuiteError, ErrorCode } from '@blocksuite/global/exceptions'; +import type { IBound, IVec, SerializedXYWH } from '@blocksuite/global/utils'; +import { + assertExists, + assertType, + Bound, + DisposableGroup, + getCommonBound, + nToLast, + Vec, +} from '@blocksuite/global/utils'; +import { + type BlockSnapshot, + BlockSnapshotSchema, + DocCollection, + fromJSON, + Job, + type SliceSnapshot, +} from '@blocksuite/store'; +import DOMPurify from 'dompurify'; + +import { + CANVAS_EXPORT_IGNORE_TAGS, + EMBED_CARD_HEIGHT, + EMBED_CARD_WIDTH, +} from '../../../_common/consts.js'; +import { ExportManager } from '../../../_common/export-manager/export-manager.js'; +import { getRootByEditorHost } from '../../../_common/utils/query.js'; +import { ClipboardAdapter } from '../../clipboard/adapter.js'; +import { PageClipboard } from '../../clipboard/index.js'; +import { + decodeClipboardBlobs, + encodeClipboardBlobs, +} from '../../clipboard/utils.js'; +import type { EdgelessRootBlockComponent } from '../edgeless-root-block.js'; +import { edgelessElementsBoundFromRawData } from '../utils/bound-utils.js'; +import { createNewPresentationIndexes } from '../utils/clipboard-utils.js'; +import { + getSortedCloneElements, + serializeElement, +} from '../utils/clone-utils.js'; +import { addAttachments, addImages } from '../utils/common.js'; +import { deleteElements } from '../utils/crud.js'; +import { + isAttachmentBlock, + isCanvasElementWithText, + isImageBlock, + isTopLevelBlock, +} from '../utils/query.js'; + +const BLOCKSUITE_SURFACE = 'blocksuite/surface'; +const IMAGE_PNG = 'image/png'; + +const { GROUP, MINDMAP, CONNECTOR } = CanvasElementType; +const IMAGE_PADDING = 5; // for rotated shapes some padding is needed + +type CreationContext = { + /** + * element old id to new id + */ + oldToNewIdMap: Map<string, string>; + /** + * element old id to new layer index + */ + originalIndexes: Map<string, string>; + + /** + * frame old id to new presentation index + */ + newPresentationIndexes: Map<string, string>; +}; + +type BlockCreationFunction = ( + snapshot: BlockSnapshot, + context: CreationContext +) => Promise<string | null> | string | null; // new Id + +interface CanvasExportOptions { + dpr?: number; + padding?: number; + background?: string; +} + +interface BlockConfig { + flavour: string; + createFunction: BlockCreationFunction; +} + +export class EdgelessClipboardController extends PageClipboard { + private _blockConfigs: BlockConfig[] = []; + + private _initEdgelessClipboard = () => { + this.host.handleEvent( + 'copy', + ctx => { + const { surfaceSelections, selectedIds } = this.selectionManager; + + if (selectedIds.length === 0) return false; + + this._onCopy(ctx, surfaceSelections).catch(console.error); + return; + }, + { global: true } + ); + + this.host.handleEvent( + 'paste', + ctx => { + this._onPaste(ctx).catch(console.error); + }, + { global: true } + ); + + this.host.handleEvent( + 'cut', + ctx => { + this._onCut(ctx).catch(console.error); + }, + { global: true } + ); + }; + + private _onCopy = async ( + _context: UIEventStateContext, + surfaceSelection: SurfaceSelection[] + ) => { + const event = _context.get('clipboardState').raw; + event.preventDefault(); + + const elements = getSortedCloneElements( + this.selectionManager.selectedElements + ); + + // when note active, handle copy like page mode + if (surfaceSelection[0] && surfaceSelection[0].editing) { + // use build-in copy handler in rich-text when copy in surface text element + if (isCanvasElementWithText(elements[0])) return; + this.onPageCopy(_context); + return; + } + + await this.std.clipboard.writeToClipboard(async _items => { + const data = await prepareClipboardData(elements, this.std); + return { + ..._items, + [BLOCKSUITE_SURFACE]: JSON.stringify(data), + }; + }); + }; + + private _onCut = async (_context: UIEventStateContext) => { + const { surfaceSelections, selectedElements } = this.selectionManager; + + if (selectedElements.length === 0) return; + + const event = _context.get('clipboardState').event; + event.preventDefault(); + + await this._onCopy(_context, surfaceSelections); + + if (surfaceSelections[0]?.editing) { + // use build-in cut handler in rich-text when cut in surface text element + if (isCanvasElementWithText(selectedElements[0])) return; + this.onPageCut(_context); + return; + } + + const elements = getSortedCloneElements( + this.selectionManager.selectedElements + ); + this.doc.transact(() => { + deleteElements(this.edgeless, elements); + }); + + this.selectionManager.set({ + editing: false, + elements: [], + }); + }; + + private _onPaste = async (_context: UIEventStateContext) => { + if ( + document.activeElement instanceof HTMLInputElement || + document.activeElement instanceof HTMLTextAreaElement + ) { + return; + } + const event = _context.get('clipboardState').raw; + event.preventDefault(); + + const { surfaceSelections, selectedElements } = this.selectionManager; + + if (surfaceSelections[0]?.editing) { + // use build-in paste handler in rich-text when paste in surface text element + if (isCanvasElementWithText(selectedElements[0])) return; + this.onPagePaste(_context); + return; + } + + const data = event.clipboardData; + if (!data) return; + + const lastMousePos = this.toolManager.lastMousePos$.peek(); + const point: IVec = [lastMousePos.x, lastMousePos.y]; + + if (isPureFileInClipboard(data)) { + const files = data.files; + if (files.length === 0) return; + + const imageFiles: File[] = [], + attachmentFiles: File[] = []; + + [...files].forEach(file => { + if (file.type.startsWith('image/')) { + imageFiles.push(file); + } else { + attachmentFiles.push(file); + } + }); + + // when only images in clipboard, add image-blocks else add all files as attachments + if (attachmentFiles.length === 0) { + await addImages(this.std, imageFiles, point); + } else { + await addAttachments(this.std, [...files], point); + } + + this.std.getOptional(TelemetryProvider)?.track('CanvasElementAdded', { + control: 'canvas:paste', + page: 'whiteboard editor', + module: 'toolbar', + segment: 'toolbar', + type: attachmentFiles.length === 0 ? 'image' : 'attachment', + }); + + return; + } + + if (isUrlInClipboard(data)) { + const url = data.getData('text/plain'); + const lastMousePos = this.toolManager.lastMousePos$.peek(); + const [x, y] = this.host.service.viewport.toModelCoord( + lastMousePos.x, + lastMousePos.y + ); + + // try to interpret url as affine doc url + const parseDocUrlService = this.std.getOptional(ParseDocUrlProvider); + const docUrlInfo = parseDocUrlService?.parseDocUrl(url); + const options: Record<string, unknown> = {}; + + let flavour = 'affine:bookmark'; + let style = BookmarkStyles[0]; + let isInternalLink = false; + let isLinkedBlock = false; + + if (docUrlInfo) { + const { docId: pageId, ...params } = docUrlInfo; + + flavour = 'affine:embed-linked-doc'; + style = 'vertical'; + + isInternalLink = true; + isLinkedBlock = referenceToNode({ pageId, params }); + options.pageId = pageId; + if (params) options.params = params; + } else { + options.url = url; + + const embedOptions = this.std + .get(EmbedOptionProvider) + .getEmbedBlockOptions(url); + if (embedOptions) { + flavour = embedOptions.flavour as BlockSuite.EdgelessModelKeys; + style = embedOptions.styles[0]; + } + } + + const width = EMBED_CARD_WIDTH[style]; + const height = EMBED_CARD_HEIGHT[style]; + + options.xywh = Bound.fromCenter( + Vec.toVec({ + x, + y, + }), + width, + height + ).serialize(); + options.style = style; + + const id = this.host.service.addBlock( + flavour, + options, + this.surface.model.id + ); + + this.std.getOptional(TelemetryProvider)?.track('CanvasElementAdded', { + control: 'canvas:paste', + page: 'whiteboard editor', + module: 'toolbar', + segment: 'toolbar', + type: flavour.split(':')[1], + }); + + this.std + .getOptional(TelemetryProvider) + ?.track(isInternalLink ? 'LinkedDocCreated' : 'Link', { + page: 'whiteboard editor', + segment: 'whiteboard', + category: 'pasted link', + other: isInternalLink ? 'existing doc' : 'external link', + type: isInternalLink ? (isLinkedBlock ? 'block' : 'doc') : 'link', + }); + + this.selectionManager.set({ + editing: false, + elements: [id], + }); + + return; + } + + const svg = tryGetSvgFromClipboard(data); + if (svg) { + await addImages(this.std, [svg], point); + return; + } + try { + // check for surface elements in clipboard + const json = this.std.clipboard.readFromClipboard(data); + const mayBeSurfaceDataJson = json[BLOCKSUITE_SURFACE]; + if (mayBeSurfaceDataJson !== undefined) { + const elementsRawData = JSON.parse(mayBeSurfaceDataJson); + const { snapshot, blobs } = elementsRawData; + const job = new Job({ collection: this.std.collection }); + const map = job.assetsManager.getAssets(); + decodeClipboardBlobs(blobs, map); + for (const blobId of map.keys()) { + await job.assetsManager.writeToBlob(blobId); + } + await this._pasteShapesAndBlocks(snapshot); + return; + } + // check for slice snapshot in clipboard + const mayBeSliceDataJson = json[ClipboardAdapter.MIME]; + if (mayBeSliceDataJson === undefined) return; + const clipData = JSON.parse(mayBeSliceDataJson); + const sliceSnapShot = clipData?.snapshot as SliceSnapshot; + await this._pasteTextContentAsNote(sliceSnapShot.content); + } catch { + // if it is not parsable + await this._pasteTextContentAsNote(data.getData('text/plain')); + } + }; + + private get _exportManager() { + return this.std.getOptional(ExportManager); + } + + private get doc() { + return this.host.doc; + } + + private get edgeless() { + return this.host; + } + + private get selectionManager() { + return this.host.service.selection; + } + + private get std() { + return this.host.std; + } + + private get surface() { + return this.host.surface; + } + + private get toolManager() { + return this.host.gfx.tool; + } + + constructor(public override host: EdgelessRootBlockComponent) { + super(host); + // Register existing block creation functions + this.registerBlock('affine:note', this._createNoteBlock); + this.registerBlock('affine:edgeless-text', this._createEdgelessTextBlock); + this.registerBlock('affine:image', this._createImageBlock); + this.registerBlock('affine:frame', this._createFrameBlock); + this.registerBlock('affine:attachment', this._createAttachmentBlock); + + // external links + this.registerBlock('affine:bookmark', this._createBookmarkBlock); + this.registerBlock('affine:embed-figma', this._createFigmaEmbedBlock); + this.registerBlock('affine:embed-github', this._createGithubEmbedBlock); + this.registerBlock('affine:embed-html', this._createHtmlEmbedBlock); + this.registerBlock('affine:embed-loom', this._createLoomEmbedBlock); + this.registerBlock('affine:embed-youtube', this._createYoutubeEmbedBlock); + + // internal links + this.registerBlock( + 'affine:embed-linked-doc', + this._createLinkedDocEmbedBlock + ); + this.registerBlock( + 'affine:embed-synced-doc', + this._createSyncedDocEmbedBlock + ); + } + + private _checkCanContinueToCanvas( + host: EditorHost, + pathName: string, + editorMode: boolean + ) { + if ( + location.pathname !== pathName || + isInsidePageEditor(host) !== editorMode + ) { + throw new Error('Unable to export content to canvas'); + } + } + + private async _createAttachmentBlock(attachment: BlockSnapshot) { + const { xywh, rotate, sourceId, name, size, type, embed, style } = + attachment.props; + + if (!(await this.host.std.collection.blobSync.get(sourceId as string))) { + return null; + } + const attachmentId = this.host.service.addBlock( + 'affine:attachment', + { + xywh, + rotate, + sourceId, + name, + size, + type, + embed, + style, + }, + this.surface.model.id + ); + return attachmentId; + } + + private _createBookmarkBlock(bookmark: BlockSnapshot) { + const { xywh, style, url, caption, description, icon, image, title } = + bookmark.props; + + const bookmarkId = this.host.service.addBlock( + 'affine:bookmark', + { + xywh, + style, + url, + caption, + description, + icon, + image, + title, + }, + this.surface.model.id + ); + return bookmarkId; + } + + private _createCanvasElement( + clipboardData: SerializedElement, + context: CreationContext, + newXYWH: SerializedXYWH + ) { + if (clipboardData.type === GROUP) { + const yMap = new DocCollection.Y.Map(); + const children = clipboardData.children ?? {}; + + for (const [key, value] of Object.entries(children)) { + const newKey = context.oldToNewIdMap.get(key); + assertExists( + newKey, + 'Copy failed: cannot find the copied child in group' + ); + yMap.set(newKey, value); + } + clipboardData.children = yMap; + clipboardData.xywh = newXYWH; + } else if (clipboardData.type === MINDMAP) { + const yMap = new DocCollection.Y.Map(); + const children = clipboardData.children ?? {}; + + for (const [oldKey, oldValue] of Object.entries(children)) { + const newKey = context.oldToNewIdMap.get(oldKey); + const newValue = { + ...oldValue, + }; + assertExists( + newKey, + 'Copy failed: cannot find the copied node in mind map' + ); + + if (oldValue.parent) { + const newParent = context.oldToNewIdMap.get(oldValue.parent); + assertExists( + newParent, + 'Copy failed: cannot find the copied node in mind map' + ); + newValue.parent = newParent; + } + + yMap.set(newKey, newValue); + } + clipboardData.children = yMap; + } else if (clipboardData.type === CONNECTOR) { + const source = clipboardData.source as Connection; + const target = clipboardData.target as Connection; + + const oldBound = Bound.deserialize(clipboardData.xywh); + const newBound = Bound.deserialize(newXYWH); + const offset = Vec.sub( + [newBound.x, newBound.y], + [oldBound.x, oldBound.y] + ); + + if (source.id) { + source.id = context.oldToNewIdMap.get(source.id) ?? source.id; + } else if (source.position) { + source.position = Vec.add(source.position, offset); + } + + if (target.id) { + target.id = context.oldToNewIdMap.get(target.id) ?? target.id; + } else if (target.position) { + target.position = Vec.add(target.position, offset); + } + } else { + clipboardData.xywh = newXYWH; + } + + clipboardData.lockedBySelf = false; + + const id = this.host.service.addElement( + clipboardData.type as CanvasElementType, + clipboardData + ); + this.std.getOptional(TelemetryProvider)?.track('CanvasElementAdded', { + control: 'canvas:paste', + page: 'whiteboard editor', + module: 'toolbar', + segment: 'toolbar', + type: clipboardData.type as string, + }); + const element = this.host.service.getElementById( + id + ) as BlockSuite.SurfaceModel; + assertExists(element); + return element; + } + + private async _createEdgelessTextBlock(edgelessText: BlockSnapshot) { + const oldId = edgelessText.id; + delete edgelessText.props.index; + if (!edgelessText.props.xywh) { + console.error( + `EdgelessText block(id: ${oldId}) does not have xywh property` + ); + return null; + } + const newId = await this.onBlockSnapshotPaste( + edgelessText, + this.doc, + this.edgeless.surface.model.id + ); + if (!newId) { + console.error(`Failed to paste EdgelessText block(id: ${oldId})`); + return null; + } + + return newId; + } + + private _createFigmaEmbedBlock(figmaEmbed: BlockSnapshot) { + const { xywh, style, url, caption, title, description } = figmaEmbed.props; + + const embedFigmaId = this.host.service.addBlock( + 'affine:embed-figma', + { + xywh, + style, + url, + caption, + title, + description, + }, + this.surface.model.id + ); + return embedFigmaId; + } + + private _createFrameBlock(frame: BlockSnapshot, context: CreationContext) { + const { oldToNewIdMap, newPresentationIndexes } = context; + const { xywh, title, background, childElementIds } = frame.props; + + const newChildElementIds: Record<string, boolean> = {}; + + if (typeof childElementIds === 'object' && childElementIds !== null) { + Object.keys(childElementIds).forEach(oldId => { + const newId = oldToNewIdMap.get(oldId); + if (newId) { + newChildElementIds[newId] = true; + } + }); + } + + const frameId = this.host.service.addBlock( + 'affine:frame', + { + xywh, + background, + title: fromJSON(title), + childElementIds: newChildElementIds, + presentationIndex: newPresentationIndexes.get(frame.id), + }, + this.surface.model.id + ); + return frameId; + } + + private _createGithubEmbedBlock(githubEmbed: BlockSnapshot) { + const { + xywh, + style, + owner, + repo, + githubType, + githubId, + url, + caption, + image, + status, + statusReason, + title, + description, + createdAt, + assignees, + } = githubEmbed.props; + + const embedGithubId = this.host.service.addBlock( + 'affine:embed-github', + { + xywh, + style, + owner, + repo, + githubType, + githubId, + url, + caption, + image, + status, + statusReason, + title, + description, + createdAt, + assignees, + }, + this.surface.model.id + ); + return embedGithubId; + } + + private _createHtmlEmbedBlock(htmlEmbed: BlockSnapshot) { + const { xywh, style, caption, html, design } = htmlEmbed.props; + + const embedHtmlId = this.host.service.addBlock( + 'affine:embed-html', + { + xywh, + style, + caption, + html, + design, + }, + this.surface.model.id + ); + return embedHtmlId; + } + + private async _createImageBlock(image: BlockSnapshot) { + const { xywh, rotate, sourceId, size, width, height, caption } = + image.props; + + if (!(await this.host.std.collection.blobSync.get(sourceId as string))) { + return null; + } + return this.host.service.addBlock( + 'affine:image', + { + caption, + sourceId, + xywh, + rotate, + size, + width, + height, + }, + this.surface.model.id + ); + } + + private _createLinkedDocEmbedBlock(linkedDocEmbed: BlockSnapshot) { + const { xywh, style, caption, pageId, params, title, description } = + linkedDocEmbed.props; + const referenceInfo = ReferenceInfoSchema.parse({ + pageId, + params, + title, + description, + }); + + return this.host.service.addBlock( + 'affine:embed-linked-doc', + { + xywh, + style, + caption, + ...referenceInfo, + }, + this.surface.model.id + ); + } + + private _createLoomEmbedBlock(loomEmbed: BlockSnapshot) { + const { xywh, style, url, caption, videoId, image, title, description } = + loomEmbed.props; + + const embedLoomId = this.host.service.addBlock( + 'affine:embed-loom', + { + xywh, + style, + url, + caption, + videoId, + image, + title, + description, + }, + this.surface.model.id + ); + return embedLoomId; + } + + private async _createNoteBlock(note: BlockSnapshot) { + const oldId = note.id; + + delete note.props.index; + if (!note.props.xywh) { + console.error(`Note block(id: ${oldId}) does not have xywh property`); + return null; + } + + const newId = await this.onBlockSnapshotPaste( + note, + this.doc, + this.doc.root!.id + ); + if (!newId) { + console.error(`Failed to paste note block(id: ${oldId})`); + return null; + } + + return newId; + } + + private _createSyncedDocEmbedBlock(syncedDocEmbed: BlockSnapshot) { + const { xywh, style, caption, scale, pageId, params } = + syncedDocEmbed.props; + const referenceInfo = ReferenceInfoSchema.parse({ pageId, params }); + + return this.host.service.addBlock( + 'affine:embed-synced-doc', + { + xywh, + style, + caption, + scale, + ...referenceInfo, + }, + this.surface.model.id + ); + } + + private _createYoutubeEmbedBlock(youtubeEmbed: BlockSnapshot) { + const { + xywh, + style, + url, + caption, + videoId, + image, + title, + description, + creator, + creatorUrl, + creatorImage, + } = youtubeEmbed.props; + + const embedYoutubeId = this.host.service.addBlock( + 'affine:embed-youtube', + { + xywh, + style, + url, + caption, + videoId, + image, + title, + description, + creator, + creatorUrl, + creatorImage, + }, + this.surface.model.id + ); + return embedYoutubeId; + } + + private async _edgelessToCanvas( + edgeless: EdgelessRootBlockComponent, + bound: IBound, + nodes?: BlockSuite.EdgelessBlockModelType[], + canvasElements: BlockSuite.SurfaceModel[] = [], + { + background, + padding = IMAGE_PADDING, + dpr = window.devicePixelRatio || 1, + }: CanvasExportOptions = {} + ): Promise<HTMLCanvasElement | undefined> { + const host = edgeless.host; + const rootModel = this.doc.root; + if (!rootModel) return; + + const html2canvas = (await import('html2canvas')).default; + if (!(html2canvas instanceof Function)) return; + + const pathname = location.pathname; + const editorMode = isInsidePageEditor(host); + + const rootComponent = getRootByEditorHost(host); + assertExists(rootComponent); + + const container = rootComponent.querySelector( + '.affine-block-children-container' + ); + if (!container) return; + + const canvas = document.createElement('canvas'); + canvas.width = (bound.w + padding * 2) * dpr; + canvas.height = (bound.h + padding * 2) * dpr; + const ctx = canvas.getContext('2d'); + if (!ctx) return; + if (background) { + ctx.fillStyle = background; + ctx.fillRect(0, 0, canvas.width, canvas.height); + } + ctx.scale(dpr, dpr); + + const replaceImgSrcWithSvg = this._exportManager?.replaceImgSrcWithSvg; + const replaceRichTextWithSvgElementFunc = + this._replaceRichTextWithSvgElement.bind(this); + + const imageProxy = host.std.clipboard.configs.get('imageProxy'); + const html2canvasOption = { + ignoreElements: function (element: Element) { + if ( + CANVAS_EXPORT_IGNORE_TAGS.includes(element.tagName) || + element.classList.contains('dg') + ) { + return true; + } else { + return false; + } + }, + + onclone: async function (documentClone: Document, element: HTMLElement) { + // html2canvas can't support transform feature + element.style.setProperty('transform', 'none'); + const layer = documentClone.querySelector('.affine-edgeless-layer'); + if (layer && layer instanceof HTMLElement) { + layer.style.setProperty('transform', 'none'); + } + + const boxShadowElements = documentClone.querySelectorAll( + "[style*='box-shadow']" + ); + boxShadowElements.forEach(function (element) { + if (element instanceof HTMLElement) { + element.style.setProperty('box-shadow', 'none'); + } + }); + await replaceImgSrcWithSvg?.(element); + replaceRichTextWithSvgElementFunc(element); + }, + backgroundColor: 'transparent', + useCORS: imageProxy ? false : true, + proxy: imageProxy, + }; + + const _drawTopLevelBlock = async ( + block: BlockSuite.EdgelessBlockModelType, + isInFrame = false + ) => { + const blockComponent = this.std.view.getBlock(block.id); + if (!blockComponent) { + throw new BlockSuiteError( + ErrorCode.EdgelessExportError, + 'Could not find edgeless block component.' + ); + } + + const blockBound = Bound.deserialize(block.xywh); + const canvasData = await html2canvas( + blockComponent as HTMLElement, + html2canvasOption + ); + ctx.drawImage( + canvasData, + blockBound.x - bound.x + padding, + blockBound.y - bound.y + padding, + blockBound.w, + isInFrame + ? (blockBound.w / canvasData.width) * canvasData.height + : blockBound.h + ); + }; + + const nodeElements = + nodes ?? + (edgeless.service.gfx.getElementsByBound(bound, { + type: 'block', + }) as BlockSuite.EdgelessBlockModelType[]); + for (const nodeElement of nodeElements) { + await _drawTopLevelBlock(nodeElement); + + if (matchFlavours(nodeElement, ['affine:frame'])) { + const blocksInsideFrame: BlockSuite.EdgelessBlockModelType[] = []; + this.edgeless.service.frame + .getElementsInFrameBound(nodeElement, false) + .forEach(ele => { + if (isTopLevelBlock(ele)) { + blocksInsideFrame.push(ele as BlockSuite.EdgelessBlockModelType); + } else { + canvasElements.push(ele as BlockSuite.SurfaceModel); + } + }); + + // eslint-disable-next-line @typescript-eslint/prefer-for-of + for (let i = 0; i < blocksInsideFrame.length; i++) { + const element = blocksInsideFrame[i]; + await _drawTopLevelBlock(element, true); + } + } + + this._checkCanContinueToCanvas(host, pathname, editorMode); + } + + const surfaceCanvas = edgeless.surface.renderer.getCanvasByBound( + bound, + canvasElements + ); + ctx.drawImage(surfaceCanvas, padding, padding, bound.w, bound.h); + + return canvas; + } + + private _elementToSvgElement( + node: HTMLElement, + width: number, + height: number + ) { + const xmlns = 'http://www.w3.org/2000/svg'; + const svg = document.createElementNS(xmlns, 'svg'); + const foreignObject = document.createElementNS(xmlns, 'foreignObject'); + + svg.setAttribute('width', `${width}`); + svg.setAttribute('height', `${height}`); + svg.setAttribute('viewBox', `0 0 ${width} ${height}`); + + foreignObject.setAttribute('width', '100%'); + foreignObject.setAttribute('height', '100%'); + foreignObject.setAttribute('x', '0'); + foreignObject.setAttribute('y', '0'); + foreignObject.setAttribute('externalResourcesRequired', 'true'); + + svg.append(foreignObject); + foreignObject.append(node); + return svg; + } + + private _emitSelectionChangeAfterPaste( + canvasElementIds: string[], + blockIds: string[] + ) { + const newSelected = [ + ...canvasElementIds, + ...blockIds.filter(id => { + return isTopLevelBlock(this.doc.getBlockById(id)); + }), + ]; + + this.selectionManager.set({ + editing: false, + elements: newSelected, + }); + } + + private async _pasteShapesAndBlocks( + elementsRawData: (SerializedElement | BlockSnapshot)[] + ) { + const { canvasElements, blockModels } = + await this.createElementsFromClipboardData(elementsRawData); + this._emitSelectionChangeAfterPaste( + canvasElements.map(ele => ele.id), + blockModels.map(block => block.id) + ); + } + + private async _pasteTextContentAsNote(content: BlockSnapshot[] | string) { + const edgeless = this.host; + const lastMousePos = this.toolManager.lastMousePos$.peek(); + const [x, y] = edgeless.service.viewport.toModelCoord( + lastMousePos.x, + lastMousePos.y + ); + + const noteProps = { + xywh: new Bound( + x, + y, + DEFAULT_NOTE_WIDTH, + DEFAULT_NOTE_HEIGHT + ).serialize(), + }; + + const noteId = edgeless.service.addBlock( + 'affine:note', + noteProps, + this.doc.root!.id + ); + + this.std.getOptional(TelemetryProvider)?.track('CanvasElementAdded', { + control: 'canvas:paste', + page: 'whiteboard editor', + module: 'toolbar', + segment: 'toolbar', + type: 'note', + }); + + if (typeof content === 'string') { + TextUtils.splitIntoLines(content).forEach((line, idx) => { + edgeless.service.addBlock( + 'affine:paragraph', + { text: new DocCollection.Y.Text(line) }, + noteId, + idx + ); + }); + } else { + // eslint-disable-next-line @typescript-eslint/prefer-for-of + for (let index = 0; index < content.length; index++) { + const blockSnapshot = content[index]; + if (blockSnapshot.flavour === 'affine:note') { + for (const child of blockSnapshot.children) { + await this.onBlockSnapshotPaste(child, this.doc, noteId); + } + continue; + } + await this.onBlockSnapshotPaste(content[index], this.doc, noteId); + } + } + + edgeless.service.selection.set({ + elements: [noteId], + editing: false, + }); + edgeless.gfx.tool.setTool('default'); + } + + private _replaceRichTextWithSvgElement(element: HTMLElement) { + const richList = Array.from(element.querySelectorAll('.inline-editor')); + richList.forEach(rich => { + const svgEle = this._elementToSvgElement( + rich.cloneNode(true) as HTMLElement, + rich.clientWidth, + rich.clientHeight + 1 + ); + rich.parentElement?.append(svgEle); + rich.remove(); + }); + } + + private _updatePastedElementsIndex( + elements: BlockSuite.EdgelessModel[], + originalIndexes: Map<string, string> + ) { + function compare(a: BlockSuite.EdgelessModel, b: BlockSuite.EdgelessModel) { + if (a instanceof SurfaceGroupLikeModel && a.hasDescendant(b)) { + return SortOrder.BEFORE; + } else if (b instanceof SurfaceGroupLikeModel && b.hasDescendant(a)) { + return SortOrder.AFTER; + } else { + const aGroups = a.groups as BlockSuite.SurfaceGroupLikeModel[]; + const bGroups = b.groups as BlockSuite.SurfaceGroupLikeModel[]; + + let i = 1; + let aGroup: BlockSuite.EdgelessModel | undefined = nToLast(aGroups, i); + let bGroup: BlockSuite.EdgelessModel | undefined = nToLast(bGroups, i); + + while (aGroup === bGroup && aGroup) { + ++i; + aGroup = nToLast(aGroups, i); + bGroup = nToLast(bGroups, i); + } + + aGroup = aGroup ?? a; + bGroup = bGroup ?? b; + + return originalIndexes.get(aGroup.id) === originalIndexes.get(bGroup.id) + ? SortOrder.SAME + : originalIndexes.get(aGroup.id)! < originalIndexes.get(bGroup.id)! + ? SortOrder.BEFORE + : SortOrder.AFTER; + } + } + + const idxGenerator = this.edgeless.service.layer.createIndexGenerator(); + const sortedElements = elements.sort(compare); + sortedElements.forEach(ele => { + const newIndex = idxGenerator(); + + this.edgeless.service.updateElement(ele.id, { + index: newIndex, + }); + }); + } + + copy() { + document.dispatchEvent( + new Event('copy', { + bubbles: true, + cancelable: true, + }) + ); + } + + async copyAsPng( + blocks: BlockSuite.EdgelessBlockModelType[], + shapes: BlockSuite.SurfaceModel[] + ) { + const blocksLen = blocks.length; + const shapesLen = shapes.length; + + if (blocksLen + shapesLen === 0) return; + const canvas = await this.toCanvas(blocks, shapes); + assertExists(canvas); + // @ts-expect-error FIXME: ts error + if (window.apis?.clipboard?.copyAsImageFromString) { + // @ts-expect-error FIXME: ts error + await window.apis.clipboard?.copyAsImageFromString( + canvas.toDataURL(IMAGE_PNG) + ); + } else { + const blob: Blob = await new Promise((resolve, reject) => + canvas.toBlob( + blob => (blob ? resolve(blob) : reject('Canvas can not export blob')), + IMAGE_PNG + ) + ); + assertExists(blob); + + this.std.clipboard + .writeToClipboard(_items => { + return { + ..._items, + [IMAGE_PNG]: blob, + }; + }) + .catch(console.error); + } + } + + async createElementsFromClipboardData( + elementsRawData: (SerializedElement | BlockSnapshot)[], + pasteCenter?: IVec + ) { + let oldCommonBound, pasteX, pasteY; + { + const lastMousePos = this.toolManager.lastMousePos$.peek(); + pasteCenter = + pasteCenter ?? + this.host.service.viewport.toModelCoord(lastMousePos.x, lastMousePos.y); + const [modelX, modelY] = pasteCenter; + oldCommonBound = edgelessElementsBoundFromRawData(elementsRawData); + + pasteX = modelX - oldCommonBound.w / 2; + pasteY = modelY - oldCommonBound.h / 2; + } + + const getNewXYWH = (oldXYWH: SerializedXYWH) => { + const oldBound = Bound.deserialize(oldXYWH); + return new Bound( + oldBound.x + pasteX - oldCommonBound.x, + oldBound.y + pasteY - oldCommonBound.y, + oldBound.w, + oldBound.h + ).serialize(); + }; + + // create blocks and canvas elements + + const context: CreationContext = { + oldToNewIdMap: new Map<string, string>(), + originalIndexes: new Map<string, string>(), + newPresentationIndexes: createNewPresentationIndexes( + elementsRawData, + this.edgeless + ), + }; + + const blockModels: BlockSuite.EdgelessBlockModelType[] = []; + const canvasElements: BlockSuite.SurfaceModel[] = []; + const allElements: BlockSuite.EdgelessModel[] = []; + + for (const data of elementsRawData) { + const { data: blockSnapshot } = BlockSnapshotSchema.safeParse(data); + if (blockSnapshot) { + const oldId = blockSnapshot.id; + + const config = this._blockConfigs.find( + ({ flavour }) => flavour === blockSnapshot.flavour + ); + if (!config) continue; + + if (typeof blockSnapshot.props.index !== 'string') { + console.error(`Block(id: ${oldId}) does not have index property`); + continue; + } + const originalIndex = (blockSnapshot.props as GfxCompatibleProps).index; + + if (typeof blockSnapshot.props.xywh !== 'string') { + console.error(`Block(id: ${oldId}) does not have xywh property`); + continue; + } + + assertType<GfxCompatibleProps & unknown>(blockSnapshot.props); + + blockSnapshot.props.xywh = getNewXYWH( + blockSnapshot.props.xywh as SerializedXYWH + ); + blockSnapshot.props.lockedBySelf = false; + + const newId = await config.createFunction(blockSnapshot, context); + if (!newId) continue; + + const block = this.doc.getBlock(newId); + if (!block) continue; + + assertType<BlockSuite.EdgelessBlockModelType>(block.model); + blockModels.push(block.model); + allElements.push(block.model); + context.oldToNewIdMap.set(oldId, newId); + context.originalIndexes.set(oldId, originalIndex); + } else { + assertType<SerializedElement>(data); + const oldId = data.id; + + const element = this._createCanvasElement( + data, + context, + getNewXYWH(data.xywh) + ); + + canvasElements.push(element); + allElements.push(element); + + context.oldToNewIdMap.set(oldId, element.id); + context.originalIndexes.set(oldId, element.index); + } + } + + // remap old id to new id for the original index + const oldIds = [...context.originalIndexes.keys()]; + oldIds.forEach(oldId => { + const newId = context.oldToNewIdMap.get(oldId); + const originalIndex = context.originalIndexes.get(oldId); + if (newId && originalIndex) { + context.originalIndexes.set(newId, originalIndex); + context.originalIndexes.delete(oldId); + } + }); + + this._updatePastedElementsIndex(allElements, context.originalIndexes); + + return { + canvasElements: canvasElements, + blockModels: blockModels, + }; + } + + override hostConnected() { + if (this._disposables.disposed) { + this._disposables = new DisposableGroup(); + } + this._init(); + this._initEdgelessClipboard(); + } + + registerBlock(flavour: string, createFunction: BlockCreationFunction) { + this._blockConfigs.push({ + flavour, + createFunction: createFunction.bind(this), + }); + } + + async toCanvas( + blocks: BlockSuite.EdgelessBlockModelType[], + shapes: BlockSuite.SurfaceModel[], + options?: CanvasExportOptions + ) { + blocks.sort(compareLayer); + shapes.sort(compareLayer); + + const bounds: IBound[] = []; + blocks.forEach(block => { + bounds.push(Bound.deserialize(block.xywh)); + }); + shapes.forEach(shape => { + bounds.push(shape.elementBound); + }); + const bound = getCommonBound(bounds); + assertExists(bound, 'bound not exist'); + + const canvas = await this._edgelessToCanvas( + this.host, + bound, + blocks, + shapes, + options + ); + return canvas; + } +} + +export async function prepareClipboardData( + selectedAll: BlockSuite.EdgelessModel[], + std: BlockStdScope +) { + const job = new Job({ + collection: std.collection, + }); + const selected = await Promise.all( + selectedAll.map(async selected => { + const data = serializeElement(selected, selectedAll, job); + if (!data) { + return; + } + if (isAttachmentBlock(selected) || isImageBlock(selected)) { + await job.assetsManager.readFromBlob(data.props.sourceId as string); + } + return data; + }) + ); + const blobs = await encodeClipboardBlobs(job.assetsManager.getAssets()); + return { + snapshot: selected.filter(d => !!d), + blobs, + }; +} + +function isPureFileInClipboard(clipboardData: DataTransfer) { + const types = clipboardData.types; + return ( + (types.length === 1 && types[0] === 'Files') || + (types.length === 2 && + (types.includes('text/plain') || types.includes('text/html')) && + types.includes('Files')) + ); +} + +function tryGetSvgFromClipboard(clipboardData: DataTransfer) { + const types = clipboardData.types; + + if (types.length === 1 && types[0] !== 'text/plain') { + return null; + } + + const parser = new DOMParser(); + const svgDoc = parser.parseFromString( + clipboardData.getData('text/plain'), + 'image/svg+xml' + ); + const svg = svgDoc.documentElement; + + if (svg.tagName !== 'svg' || !svg.hasAttribute('xmlns')) { + return null; + } + const svgContent = DOMPurify.sanitize(svgDoc.documentElement, { + USE_PROFILES: { svg: true }, + }); + const blob = new Blob([svgContent], { type: 'image/svg+xml' }); + const file = new File([blob], 'pasted-image.svg', { type: 'image/svg+xml' }); + return file; +} diff --git a/blocksuite/blocks/src/root-block/edgeless/components/auto-complete/auto-complete-panel.ts b/blocksuite/blocks/src/root-block/edgeless/components/auto-complete/auto-complete-panel.ts new file mode 100644 index 0000000000..6ae2f07a8e --- /dev/null +++ b/blocksuite/blocks/src/root-block/edgeless/components/auto-complete/auto-complete-panel.ts @@ -0,0 +1,660 @@ +import { + CanvasElementType, + CommonUtils, +} from '@blocksuite/affine-block-surface'; +import { + FontFamilyIcon, + FrameIcon, + SmallNoteIcon, +} from '@blocksuite/affine-components/icons'; +import type { + Connection, + ConnectorElementModel, + ShapeElementModel, +} from '@blocksuite/affine-model'; +import { + DEFAULT_NOTE_BACKGROUND_COLOR, + DEFAULT_NOTE_WIDTH, + DEFAULT_SHAPE_FILL_COLOR, + DEFAULT_SHAPE_STROKE_COLOR, + DEFAULT_TEXT_COLOR, + FontFamily, + FontStyle, + FontWeight, + getShapeName, + GroupElementModel, + NoteBlockModel, + ShapeStyle, + TextElementModel, +} from '@blocksuite/affine-model'; +import { + EditPropsStore, + ThemeProvider, +} from '@blocksuite/affine-shared/services'; +import { captureEventTarget } from '@blocksuite/affine-shared/utils'; +import { type BlockStdScope, stdContext } from '@blocksuite/block-std'; +import { GfxControllerIdentifier } from '@blocksuite/block-std/gfx'; +import type { XYWH } from '@blocksuite/global/utils'; +import { + assertInstanceOf, + Bound, + serializeXYWH, + Vec, + WithDisposable, +} from '@blocksuite/global/utils'; +import { DocCollection } from '@blocksuite/store'; +import { consume } from '@lit/context'; +import { baseTheme } from '@toeverything/theme'; +import { css, html, LitElement, nothing, unsafeCSS } from 'lit'; +import { property } from 'lit/decorators.js'; +import { repeat } from 'lit/directives/repeat.js'; +import { styleMap } from 'lit/directives/style-map.js'; + +import type { EdgelessRootBlockComponent } from '../../edgeless-root-block.js'; +import { + SHAPE_OVERLAY_HEIGHT, + SHAPE_OVERLAY_WIDTH, +} from '../../utils/consts.js'; +import { + mountShapeTextEditor, + mountTextElementEditor, +} from '../../utils/text.js'; +import { ShapeComponentConfig } from '../toolbar/shape/shape-menu-config.js'; +import { + type AUTO_COMPLETE_TARGET_TYPE, + AutoCompleteFrameOverlay, + AutoCompleteNoteOverlay, + AutoCompleteShapeOverlay, + AutoCompleteTextOverlay, + capitalizeFirstLetter, + createShapeElement, + DEFAULT_NOTE_OVERLAY_HEIGHT, + DEFAULT_TEXT_HEIGHT, + DEFAULT_TEXT_WIDTH, + Direction, + isShape, + PANEL_HEIGHT, + PANEL_WIDTH, + type TARGET_SHAPE_TYPE, +} from './utils.js'; + +export class EdgelessAutoCompletePanel extends WithDisposable(LitElement) { + static override styles = css` + .auto-complete-panel-container { + position: absolute; + display: flex; + width: 136px; + flex-wrap: wrap; + align-items: center; + justify-content: center; + padding: 8px 0; + gap: 8px; + border-radius: 8px; + background: var(--affine-background-overlay-panel-color); + box-shadow: var(--affine-shadow-2); + z-index: 1; + } + + .row-button { + display: flex; + align-items: center; + justify-content: center; + width: 120px; + height: 28px; + padding: 4px 0; + text-align: center; + border-radius: 8px; + font-family: ${unsafeCSS(baseTheme.fontSansFamily)}; + font-size: 12px; + font-style: normal; + font-weight: 500; + border: 1px solid var(--affine-border-color, #e3e2e4); + box-sizing: border-box; + } + `; + + private _overlay: + | AutoCompleteShapeOverlay + | AutoCompleteNoteOverlay + | AutoCompleteFrameOverlay + | AutoCompleteTextOverlay + | null = null; + + get gfx() { + return this.std.get(GfxControllerIdentifier); + } + + constructor( + position: [number, number], + edgeless: EdgelessRootBlockComponent, + currentSource: ShapeElementModel | NoteBlockModel, + connector: ConnectorElementModel + ) { + super(); + this.position = position; + this.edgeless = edgeless; + this.currentSource = currentSource; + this.connector = connector; + } + + private _addFrame() { + const bound = this._generateTarget(this.connector)?.nextBound; + if (!bound) return; + + const { h } = bound; + const w = h / 0.75; + const target = this._getTargetXYWH(w, h); + if (!target) return; + + const { xywh, position } = target; + + const edgeless = this.edgeless; + const { service, surfaceBlockModel } = edgeless; + const frameMgr = service.frame; + const frameIndex = service.frames.length + 1; + const id = service.addBlock( + 'affine:frame', + { + title: new DocCollection.Y.Text(`Frame ${frameIndex}`), + xywh: serializeXYWH(...xywh), + presentationIndex: frameMgr.generatePresentationIndex(), + }, + surfaceBlockModel + ); + edgeless.doc.captureSync(); + const frame = service.getElementById(id); + if (!frame) return; + + this.connector.target = { + id, + position, + }; + + edgeless.service.selection.set({ + elements: [frame.id], + editing: false, + }); + } + + private _addNote() { + const { doc } = this.edgeless; + const service = this.edgeless.service; + const target = this._getTargetXYWH( + DEFAULT_NOTE_WIDTH, + DEFAULT_NOTE_OVERLAY_HEIGHT + ); + if (!target) return; + + const { xywh, position } = target; + const id = service.addBlock( + 'affine:note', + { + xywh: serializeXYWH(...xywh), + }, + doc.root?.id + ); + const note = doc.getBlock(id)?.model; + assertInstanceOf(note, NoteBlockModel); + doc.addBlock('affine:paragraph', { type: 'text' }, id); + const group = this.currentSource.group; + + if (group instanceof GroupElementModel) { + group.addChild(note); + } + this.connector.target = { + id, + position: position as [number, number], + }; + service.updateElement(this.connector.id, { + target: { id, position }, + }); + this.edgeless.service.selection.set({ + elements: [id], + editing: false, + }); + } + + private _addShape(targetType: TARGET_SHAPE_TYPE) { + const edgeless = this.edgeless; + const result = this._generateTarget(this.connector); + if (!result) return; + + const currentSource = this.currentSource; + const { nextBound, position } = result; + const { service } = edgeless; + const id = createShapeElement(edgeless, currentSource, targetType); + + service.updateElement(id, { xywh: nextBound.serialize() }); + service.updateElement(this.connector.id, { + target: { id, position }, + }); + + mountShapeTextEditor( + service.getElementById(id) as ShapeElementModel, + this.edgeless + ); + edgeless.service.selection.set({ + elements: [id], + editing: true, + }); + edgeless.doc.captureSync(); + } + + private _addText() { + const target = this._getTargetXYWH(DEFAULT_TEXT_WIDTH, DEFAULT_TEXT_HEIGHT); + if (!target) return; + const { xywh, position } = target; + const bound = Bound.fromXYWH(xywh); + const edgelessService = this.edgeless.service; + + const textFlag = this.edgeless.doc.awarenessStore.getFlag( + 'enable_edgeless_text' + ); + if (textFlag) { + const { textId } = this.edgeless.std.command.exec('insertEdgelessText', { + x: bound.x, + y: bound.y, + }); + if (!textId) return; + + const textElement = edgelessService.getElementById(textId); + if (!textElement) return; + + edgelessService.updateElement(this.connector.id, { + target: { id: textId, position }, + }); + if (this.currentSource.group instanceof GroupElementModel) { + this.currentSource.group.addChild(textElement); + } + + this.edgeless.service.selection.set({ + elements: [textId], + editing: false, + }); + this.edgeless.doc.captureSync(); + } else { + const textId = edgelessService.addElement(CanvasElementType.TEXT, { + xywh: bound.serialize(), + text: new DocCollection.Y.Text(), + textAlign: 'left', + fontSize: 24, + fontFamily: FontFamily.Inter, + color: DEFAULT_TEXT_COLOR, + fontWeight: FontWeight.Regular, + fontStyle: FontStyle.Normal, + }); + const textElement = edgelessService.getElementById(textId); + assertInstanceOf(textElement, TextElementModel); + + edgelessService.updateElement(this.connector.id, { + target: { id: textId, position }, + }); + if (this.currentSource.group instanceof GroupElementModel) { + this.currentSource.group.addChild(textElement); + } + + this.edgeless.service.selection.set({ + elements: [textId], + editing: false, + }); + this.edgeless.doc.captureSync(); + + mountTextElementEditor(textElement, this.edgeless); + } + } + + private _autoComplete(targetType: AUTO_COMPLETE_TARGET_TYPE) { + this._removeOverlay(); + if (!this._connectorExist()) return; + + switch (targetType) { + case 'text': + this._addText(); + break; + case 'note': + this._addNote(); + break; + case 'frame': + this._addFrame(); + break; + default: + this._addShape(targetType); + } + + this.remove(); + } + + private _connectorExist() { + return !!this.edgeless.service.getElementById(this.connector.id); + } + + private _generateTarget(connector: ConnectorElementModel) { + const { currentSource } = this; + let w = SHAPE_OVERLAY_WIDTH; + let h = SHAPE_OVERLAY_HEIGHT; + if (isShape(currentSource)) { + const bound = Bound.deserialize(currentSource.xywh); + w = bound.w; + h = bound.h; + } + const point = connector.target.position; + if (!point) return; + + const len = connector.path.length; + const angle = CommonUtils.normalizeDegAngle( + CommonUtils.toDegree( + Vec.angle(connector.path[len - 2], connector.path[len - 1]) + ) + ); + let nextBound: Bound; + let position: Connection['position']; + // direction of the connector target arrow + let direction: Direction; + + if (angle >= 45 && angle <= 135) { + nextBound = new Bound(point[0] - w / 2, point[1], w, h); + position = [0.5, 0]; + direction = Direction.Bottom; + } else if (angle >= 135 && angle <= 225) { + nextBound = new Bound(point[0] - w, point[1] - h / 2, w, h); + position = [1, 0.5]; + direction = Direction.Left; + } else if (angle >= 225 && angle <= 315) { + nextBound = new Bound(point[0] - w / 2, point[1] - h, w, h); + position = [0.5, 1]; + direction = Direction.Top; + } else { + nextBound = new Bound(point[0], point[1] - h / 2, w, h); + position = [0, 0.5]; + direction = Direction.Right; + } + + return { nextBound, position, direction }; + } + + private _getCurrentSourceInfo(): { + style: ShapeStyle; + type: AUTO_COMPLETE_TARGET_TYPE; + } { + const { currentSource } = this; + if (isShape(currentSource)) { + const { shapeType, shapeStyle, radius } = currentSource; + return { + style: shapeStyle, + type: getShapeName(shapeType, radius), + }; + } + return { + style: ShapeStyle.General, + type: 'note', + }; + } + + private _getPanelPosition() { + const { viewport } = this.edgeless.service; + const { boundingClientRect: viewportRect, zoom } = viewport; + const result = this._getTargetXYWH(PANEL_WIDTH / zoom, PANEL_HEIGHT / zoom); + const pos = result ? result.xywh.slice(0, 2) : this.position; + const coord = viewport.toViewCoord(pos[0], pos[1]); + const { width, height } = viewportRect; + + coord[0] = CommonUtils.clamp(coord[0], 20, width - 20 - PANEL_WIDTH); + coord[1] = CommonUtils.clamp(coord[1], 20, height - 20 - PANEL_HEIGHT); + + return coord; + } + + private _getTargetXYWH(width: number, height: number) { + const result = this._generateTarget(this.connector); + if (!result) return null; + + const { nextBound: bound, direction, position } = result; + if (!bound) return null; + + const { w, h } = bound; + let x = bound.x; + let y = bound.y; + + switch (direction) { + case Direction.Right: + y += h / 2 - height / 2; + break; + case Direction.Bottom: + x -= width / 2 - w / 2; + break; + case Direction.Left: + y += h / 2 - height / 2; + x -= width - w; + break; + case Direction.Top: + x -= width / 2 - w / 2; + y += h - height; + break; + } + + const xywh = [x, y, width, height] as XYWH; + + return { xywh, position }; + } + + private _removeOverlay() { + if (this._overlay) + this.edgeless.surface.renderer.removeOverlay(this._overlay); + } + + private _showFrameOverlay() { + const bound = this._generateTarget(this.connector)?.nextBound; + if (!bound) return; + + const { h } = bound; + const w = h / 0.75; + const xywh = this._getTargetXYWH(w, h)?.xywh; + if (!xywh) return; + + const strokeColor = this.std + .get(ThemeProvider) + .getCssVariableColor('--affine-black-30'); + this._overlay = new AutoCompleteFrameOverlay(this.gfx, xywh, strokeColor); + this.edgeless.surface.renderer.addOverlay(this._overlay); + } + + private _showNoteOverlay() { + const xywh = this._getTargetXYWH( + DEFAULT_NOTE_WIDTH, + DEFAULT_NOTE_OVERLAY_HEIGHT + )?.xywh; + if (!xywh) return; + + const background = this.edgeless.std + .get(ThemeProvider) + .getColorValue( + this.edgeless.std.get(EditPropsStore).lastProps$.value['affine:note'] + .background, + DEFAULT_NOTE_BACKGROUND_COLOR, + true + ); + this._overlay = new AutoCompleteNoteOverlay(this.gfx, xywh, background); + this.edgeless.surface.renderer.addOverlay(this._overlay); + } + + private _showOverlay(targetType: AUTO_COMPLETE_TARGET_TYPE) { + this._removeOverlay(); + if (!this._connectorExist()) return; + + switch (targetType) { + case 'text': + this._showTextOverlay(); + break; + case 'note': + this._showNoteOverlay(); + break; + case 'frame': + this._showFrameOverlay(); + break; + default: + this._showShapeOverlay(targetType); + } + + this.edgeless.surface.refresh(); + } + + private _showShapeOverlay(targetType: TARGET_SHAPE_TYPE) { + const bound = this._generateTarget(this.connector)?.nextBound; + if (!bound) return; + + const { x, y, w, h } = bound; + const xywh = [x, y, w, h] as XYWH; + const { shapeStyle, strokeColor, fillColor, strokeWidth, roughness } = + this.edgeless.std.get(EditPropsStore).lastProps$.value[ + `shape:${targetType}` + ]; + + const stroke = this.edgeless.std + .get(ThemeProvider) + .getColorValue(strokeColor, DEFAULT_SHAPE_STROKE_COLOR, true); + const fill = this.edgeless.std + .get(ThemeProvider) + .getColorValue(fillColor, DEFAULT_SHAPE_FILL_COLOR, true); + + const options = { + seed: 666, + roughness: roughness, + strokeLineDash: [0, 0], + stroke, + strokeWidth, + fill, + }; + + this._overlay = new AutoCompleteShapeOverlay( + this.gfx, + xywh, + targetType, + options, + shapeStyle + ); + + this.edgeless.surface.renderer.addOverlay(this._overlay); + } + + private _showTextOverlay() { + const xywh = this._getTargetXYWH( + DEFAULT_TEXT_WIDTH, + DEFAULT_TEXT_HEIGHT + )?.xywh; + if (!xywh) return; + + this._overlay = new AutoCompleteTextOverlay(this.gfx, xywh); + this.edgeless.surface.renderer.addOverlay(this._overlay); + } + + override connectedCallback() { + super.connectedCallback(); + this.edgeless.handleEvent('click', ctx => { + const { target } = ctx.get('pointerState').raw; + const element = captureEventTarget(target); + const clickAway = !element?.closest('edgeless-auto-complete-panel'); + if (clickAway) this.remove(); + }); + } + + override disconnectedCallback() { + super.disconnectedCallback(); + this._removeOverlay(); + } + + override firstUpdated() { + this.disposables.add( + this.edgeless.service.viewport.viewportUpdated.on(() => + this.requestUpdate() + ) + ); + } + + override render() { + const position = this._getPanelPosition(); + if (!position) return nothing; + + const style = styleMap({ + left: `${position[0]}px`, + top: `${position[1]}px`, + }); + const { style: currentSourceStyle, type: currentSourceType } = + this._getCurrentSourceInfo(); + + const shapeButtons = repeat( + ShapeComponentConfig, + ({ name, generalIcon, scribbledIcon, tooltip }) => html` + <edgeless-tool-icon-button + .tooltip=${tooltip} + @pointerenter=${() => this._showOverlay(name)} + @pointerleave=${() => this._removeOverlay()} + @click=${() => this._autoComplete(name)} + > + ${currentSourceStyle === 'General' ? generalIcon : scribbledIcon} + </edgeless-tool-icon-button> + ` + ); + + return html`<div class="auto-complete-panel-container" style=${style}> + ${shapeButtons} + + <edgeless-tool-icon-button + .tooltip=${'Text'} + @pointerenter=${() => this._showOverlay('text')} + @pointerleave=${() => this._removeOverlay()} + @click=${() => this._autoComplete('text')} + > + ${FontFamilyIcon} + </edgeless-tool-icon-button> + <edgeless-tool-icon-button + .tooltip=${'Note'} + @pointerenter=${() => this._showOverlay('note')} + @pointerleave=${() => this._removeOverlay()} + @click=${() => this._autoComplete('note')} + > + ${SmallNoteIcon} + </edgeless-tool-icon-button> + <edgeless-tool-icon-button + .tooltip=${'Frame'} + @pointerenter=${() => this._showOverlay('frame')} + @pointerleave=${() => this._removeOverlay()} + @click=${() => this._autoComplete('frame')} + > + ${FrameIcon} + </edgeless-tool-icon-button> + + <edgeless-tool-icon-button + .iconContainerPadding=${0} + .tooltip=${capitalizeFirstLetter(currentSourceType)} + @pointerenter=${() => this._showOverlay(currentSourceType)} + @pointerleave=${() => this._removeOverlay()} + @click=${() => this._autoComplete(currentSourceType)} + > + <div class="row-button">Add a same object</div> + </edgeless-tool-icon-button> + </div>`; + } + + @property({ attribute: false }) + accessor connector: ConnectorElementModel; + + @property({ attribute: false }) + accessor currentSource: ShapeElementModel | NoteBlockModel; + + @property({ attribute: false }) + accessor edgeless: EdgelessRootBlockComponent; + + @property({ attribute: false }) + accessor position: [number, number]; + + @consume({ + context: stdContext, + }) + accessor std!: BlockStdScope; +} + +declare global { + interface HTMLElementTagNameMap { + 'edgeless-auto-complete-panel': EdgelessAutoCompletePanel; + } +} diff --git a/blocksuite/blocks/src/root-block/edgeless/components/auto-complete/edgeless-auto-complete.ts b/blocksuite/blocks/src/root-block/edgeless/components/auto-complete/edgeless-auto-complete.ts new file mode 100644 index 0000000000..90ffe81c84 --- /dev/null +++ b/blocksuite/blocks/src/root-block/edgeless/components/auto-complete/edgeless-auto-complete.ts @@ -0,0 +1,746 @@ +import { + CanvasElementType, + type ConnectionOverlay, + ConnectorPathGenerator, + Overlay, + OverlayIdentifier, + type RoughCanvas, +} from '@blocksuite/affine-block-surface'; +import { + AutoCompleteArrowIcon, + MindMapChildIcon, + MindMapSiblingIcon, + NoteAutoCompleteIcon, +} from '@blocksuite/affine-components/icons'; +import type { + Connection, + ConnectorElementModel, + NoteBlockModel, + ShapeType, +} from '@blocksuite/affine-model'; +import { + DEFAULT_NOTE_HEIGHT, + DEFAULT_SHAPE_STROKE_COLOR, + LayoutType, + MindmapElementModel, + ShapeElementModel, + shapeMethods, +} from '@blocksuite/affine-model'; +import { handleNativeRangeAtPoint } from '@blocksuite/affine-shared/utils'; +import { type BlockStdScope, stdContext } from '@blocksuite/block-std'; +import { GfxControllerIdentifier } from '@blocksuite/block-std/gfx'; +import type { Bound, IVec } from '@blocksuite/global/utils'; +import { + assertExists, + DisposableGroup, + Vec, + WithDisposable, +} from '@blocksuite/global/utils'; +import { consume } from '@lit/context'; +import { css, html, LitElement, nothing } from 'lit'; +import { property, state } from 'lit/decorators.js'; +import { classMap } from 'lit/directives/class-map.js'; +import { styleMap } from 'lit/directives/style-map.js'; + +import type { EdgelessRootBlockComponent } from '../../edgeless-root-block.js'; +import { isNoteBlock } from '../../utils/query.js'; +import { mountShapeTextEditor } from '../../utils/text.js'; +import type { SelectedRect } from '../rects/edgeless-selected-rect.js'; +import { EdgelessAutoCompletePanel } from './auto-complete-panel.js'; +import { + createEdgelessElement, + Direction, + getPosition, + isShape, + MAIN_GAP, + nextBound, +} from './utils.js'; + +class AutoCompleteOverlay extends Overlay { + linePoints: IVec[] = []; + + renderShape: ((ctx: CanvasRenderingContext2D) => void) | null = null; + + stroke = ''; + + override render(ctx: CanvasRenderingContext2D, _rc: RoughCanvas) { + if (this.linePoints.length && this.renderShape) { + ctx.setLineDash([2, 2]); + ctx.strokeStyle = this.stroke; + ctx.beginPath(); + this.linePoints.forEach((p, index) => { + if (index === 0) ctx.moveTo(p[0], p[1]); + else ctx.lineTo(p[0], p[1]); + }); + ctx.stroke(); + + this.renderShape(ctx); + ctx.stroke(); + } + } +} + +export class EdgelessAutoComplete extends WithDisposable(LitElement) { + static override styles = css` + .edgeless-auto-complete-container { + position: absolute; + z-index: 1; + pointer-events: none; + } + .edgeless-auto-complete-arrow-wrapper { + width: 72px; + height: 44px; + position: absolute; + z-index: 1; + pointer-events: auto; + display: flex; + align-items: center; + justify-content: center; + } + .edgeless-auto-complete-arrow-wrapper.hidden { + display: none; + } + .edgeless-auto-complete-arrow { + display: flex; + align-items: center; + justify-content: center; + width: 24px; + height: 24px; + border-radius: 19px; + cursor: pointer; + pointer-events: auto; + transition: + background 0.3s linear, + box-shadow 0.2s linear; + } + .edgeless-auto-complete-arrow-wrapper.mindmap { + width: 26px; + height: 26px; + } + + .edgeless-auto-complete-arrow-wrapper:hover + > .edgeless-auto-complete-arrow { + border: 1px solid var(--affine-border-color); + box-shadow: var(--affine-shadow-1); + background: var(--affine-white); + } + + .edgeless-auto-complete-arrow-wrapper + > .edgeless-auto-complete-arrow:hover { + border: 1px solid var(--affine-white-10); + box-shadow: var(--affine-shadow-1); + background: var(--affine-primary-color); + } + + .edgeless-auto-complete-arrow-wrapper.mindmap + > .edgeless-auto-complete-arrow { + border: 1px solid var(--affine-border-color); + box-shadow: var(--affine-shadow-1); + background: var(--affine-white); + + transition: + background 0.3s linear, + color 0.2s linear; + } + + .edgeless-auto-complete-arrow-wrapper.mindmap + > .edgeless-auto-complete-arrow:hover { + border: 1px solid var(--affine-white-10); + box-shadow: var(--affine-shadow-1); + background: var(--affine-primary-color); + } + + .edgeless-auto-complete-arrow svg { + fill: #77757d; + color: #77757d; + } + .edgeless-auto-complete-arrow:hover svg { + fill: #ffffff; + color: #ffffff; + } + `; + + private _autoCompleteOverlay!: AutoCompleteOverlay; + + private _onPointerDown = (e: PointerEvent, type: Direction) => { + const { service } = this.edgeless; + const viewportRect = service.viewport.boundingClientRect; + const start = service.viewport.toModelCoord( + e.clientX - viewportRect.left, + e.clientY - viewportRect.top + ); + + if (!this.edgeless.dispatcher) return; + + let connector: ConnectorElementModel | null; + + this._disposables.addFromEvent(document, 'pointermove', e => { + const point = service.viewport.toModelCoord( + e.clientX - viewportRect.left, + e.clientY - viewportRect.top + ); + if (Vec.dist(start, point) > 8 && !this._isMoving) { + if (!this.canShowAutoComplete) return; + this._isMoving = true; + const { startPosition } = getPosition(type); + connector = this._addConnector( + { + id: this.current.id, + position: startPosition, + }, + { + position: point, + } + ); + } + if (this._isMoving) { + assertExists(connector); + const otherSideId = connector.source.id; + + connector.target = this.connectionOverlay.renderConnector( + point, + otherSideId ? [otherSideId] : [] + ); + } + }); + + this._disposables.addFromEvent(document, 'pointerup', e => { + if (!this._isMoving) { + this._generateElementOnClick(type); + } else if (connector && !connector.target.id) { + this.edgeless.service.selection.clear(); + this._createAutoCompletePanel(e, connector); + } + + this._isMoving = false; + this.connectionOverlay.clear(); + this._disposables.dispose(); + this._disposables = new DisposableGroup(); + }); + }; + + private _pathGenerator!: ConnectorPathGenerator; + + private _timer: ReturnType<typeof setTimeout> | null = null; + + get canShowAutoComplete() { + const { current } = this; + return isShape(current) || isNoteBlock(current); + } + + get connectionOverlay() { + return this.std.get(OverlayIdentifier('connection')) as ConnectionOverlay; + } + + private _addConnector(source: Connection, target: Connection) { + const { edgeless } = this; + const id = edgeless.service.addElement(CanvasElementType.CONNECTOR, { + source, + target, + }); + return edgeless.service.getElementById(id) as ConnectorElementModel; + } + + private _addMindmapNode(target: 'sibling' | 'child') { + const mindmap = this.current.group; + + if (!(mindmap instanceof MindmapElementModel)) return; + + const parent = + target === 'sibling' + ? (mindmap.getParentNode(this.current.id) ?? this.current) + : this.current; + + const parentNode = mindmap.getNode(parent.id); + + if (!parentNode) return; + + const newNode = mindmap.addNode( + parentNode.id, + target === 'sibling' ? this.current.id : undefined, + undefined, + undefined + ); + + if (parentNode.detail.collapsed) { + mindmap.toggleCollapse(parentNode); + } + + requestAnimationFrame(() => { + mountShapeTextEditor( + this.edgeless.service.getElementById(newNode) as ShapeElementModel, + this.edgeless + ); + }); + } + + private _computeLine( + type: Direction, + curShape: ShapeElementModel, + nextBound: Bound + ) { + const startBound = this.current.elementBound; + const { startPosition, endPosition } = getPosition(type); + const nextShape = { + xywh: nextBound.serialize(), + rotate: curShape.rotate, + shapeType: curShape.shapeType, + }; + const startPoint = curShape.getRelativePointLocation(startPosition); + const endPoint = curShape.getRelativePointLocation.call( + nextShape, + endPosition + ); + + return this._pathGenerator.generateOrthogonalConnectorPath({ + startBound, + endBound: nextBound, + startPoint, + endPoint, + }); + } + + private _computeNextBound(type: Direction) { + if (isShape(this.current)) { + const connectedShapes = this._getConnectedElements(this.current).filter( + e => e instanceof ShapeElementModel + ) as ShapeElementModel[]; + return nextBound(type, this.current, connectedShapes); + } else { + const bound = this.current.elementBound; + switch (type) { + case Direction.Right: { + bound.x += bound.w + MAIN_GAP; + break; + } + case Direction.Bottom: { + bound.y += bound.h + MAIN_GAP; + break; + } + case Direction.Left: { + bound.x -= bound.w + MAIN_GAP; + break; + } + case Direction.Top: { + bound.y -= bound.h + MAIN_GAP; + break; + } + } + return bound; + } + } + + private _createAutoCompletePanel( + e: PointerEvent, + connector: ConnectorElementModel + ) { + if (!this.canShowAutoComplete) return; + + const position = this.edgeless.service.viewport.toModelCoord( + e.clientX, + e.clientY + ); + const autoCompletePanel = new EdgelessAutoCompletePanel( + position, + this.edgeless, + this.current, + connector + ); + + this.edgeless.append(autoCompletePanel); + } + + private _generateElementOnClick(type: Direction) { + const { doc, service } = this.edgeless; + const bound = this._computeNextBound(type); + const id = createEdgelessElement(this.edgeless, this.current, bound); + if (isShape(this.current)) { + const { startPosition, endPosition } = getPosition(type); + this._addConnector( + { + id: this.current.id, + position: startPosition, + }, + { + id, + position: endPosition, + } + ); + + mountShapeTextEditor( + service.getElementById(id) as ShapeElementModel, + this.edgeless + ); + } else { + const model = doc.getBlockById(id); + assertExists(model); + const [x, y] = service.viewport.toViewCoord( + bound.center[0], + bound.y + DEFAULT_NOTE_HEIGHT / 2 + ); + requestAnimationFrame(() => { + handleNativeRangeAtPoint(x, y); + }); + } + + this.edgeless.service.selection.set({ + elements: [id], + editing: true, + }); + this.removeOverlay(); + } + + private _getConnectedElements(element: ShapeElementModel) { + const service = this.edgeless.service; + + return service.getConnectors(element.id).reduce((prev, current) => { + if (current.target.id === element.id && current.source.id) { + prev.push( + service.getElementById(current.source.id) as ShapeElementModel + ); + } + if (current.source.id === element.id && current.target.id) { + prev.push( + service.getElementById(current.target.id) as ShapeElementModel + ); + } + + return prev; + }, [] as ShapeElementModel[]); + } + + private _getMindmapButtons() { + const mindmap = this.current.group as MindmapElementModel; + const mindmapDirection = + this.current instanceof ShapeElementModel && + mindmap instanceof MindmapElementModel + ? mindmap.getLayoutDir(this.current.id) + : null; + const isRoot = mindmap?.tree.id === this.current.id; + const mindmapNode = mindmap.getNode(this.current.id); + + let buttons: [ + Direction, + 'child' | 'sibling', + LayoutType.LEFT | LayoutType.RIGHT, + ][] = []; + + switch (mindmapDirection) { + case LayoutType.LEFT: + buttons = [[Direction.Left, 'child', LayoutType.LEFT]]; + + if (!isRoot) { + buttons.push([Direction.Bottom, 'sibling', mindmapDirection]); + } + break; + case LayoutType.RIGHT: + buttons = [[Direction.Right, 'child', LayoutType.RIGHT]]; + + if (!isRoot) { + buttons.push([Direction.Bottom, 'sibling', mindmapDirection]); + } + break; + case LayoutType.BALANCE: + buttons = [ + [Direction.Right, 'child', LayoutType.RIGHT], + [Direction.Left, 'child', LayoutType.LEFT], + ]; + break; + default: + buttons = []; + } + + return buttons.length + ? { + mindmapNode, + buttons, + } + : null; + } + + private _initOverlay() { + const { surface } = this.edgeless; + this._autoCompleteOverlay = new AutoCompleteOverlay( + this.std.get(GfxControllerIdentifier) + ); + surface.renderer.addOverlay(this._autoCompleteOverlay); + } + + private _renderArrow() { + const isShape = this.current instanceof ShapeElementModel; + const { selectedRect } = this; + const { zoom } = this.edgeless.service.viewport; + const width = 72; + const height = 44; + + // Auto-complete arrows for shape and note are different + // Shape: right, bottom, left, top + // Note: right, left + const arrowDirections = isShape + ? [Direction.Right, Direction.Bottom, Direction.Left, Direction.Top] + : [Direction.Right, Direction.Left]; + const arrowMargin = isShape ? height / 2 : height * (2 / 3); + const Arrows = arrowDirections.map(type => { + let transform = ''; + + const icon = isShape ? AutoCompleteArrowIcon : NoteAutoCompleteIcon; + + switch (type) { + case Direction.Top: + transform += `translate(${ + selectedRect.width / 2 + }px, ${-arrowMargin}px)`; + break; + case Direction.Right: + transform += `translate(${selectedRect.width + arrowMargin}px, ${ + selectedRect.height / 2 + }px)`; + + isShape && (transform += `rotate(90deg)`); + break; + case Direction.Bottom: + transform += `translate(${selectedRect.width / 2}px, ${ + selectedRect.height + arrowMargin + }px)`; + isShape && (transform += `rotate(180deg)`); + break; + case Direction.Left: + transform += `translate(${-arrowMargin}px, ${ + selectedRect.height / 2 + }px)`; + isShape && (transform += `rotate(-90deg)`); + break; + } + transform += `translate(${-width / 2}px, ${-height / 2}px)`; + const arrowWrapperClasses = classMap({ + 'edgeless-auto-complete-arrow-wrapper': true, + hidden: !isShape && type === Direction.Left && zoom >= 1.5, + }); + + return html`<div + class=${arrowWrapperClasses} + style=${styleMap({ + transform, + transformOrigin: 'left top', + })} + > + <div + class="edgeless-auto-complete-arrow" + @mouseenter=${() => { + this._timer = setTimeout(() => { + if (this.current instanceof ShapeElementModel) { + const bound = this._computeNextBound(type); + const path = this._computeLine(type, this.current, bound); + this._showNextShape( + this.current, + bound, + path, + this.current.shapeType + ); + } + }, 300); + }} + @mouseleave=${() => { + this.removeOverlay(); + }} + @pointerdown=${(e: PointerEvent) => { + this._onPointerDown(e, type); + }} + > + ${icon} + </div> + </div>`; + }); + + return Arrows; + } + + private _renderMindMapButtons() { + const mindmapButtons = this._getMindmapButtons(); + + if (!mindmapButtons) { + return; + } + + const { selectedRect } = this; + const { zoom } = this.edgeless.service.viewport; + const size = 26; + const buttonMargin = + (mindmapButtons.mindmapNode?.children.length ?? 0) > 0 + ? size / 2 + 32 * zoom + : size / 2 + 6; + const verticalMargin = size / 2 + 6; + + return mindmapButtons.buttons.map(type => { + let transform = ''; + + const [position, target, layout] = type; + const isLeftLayout = layout === LayoutType.LEFT; + const icon = target === 'child' ? MindMapChildIcon : MindMapSiblingIcon; + + switch (position) { + case Direction.Bottom: + transform += `translate(${selectedRect.width / 2}px, ${ + selectedRect.height + verticalMargin + }px)`; + isLeftLayout && (transform += `scale(-1)`); + break; + case Direction.Right: + transform += `translate(${selectedRect.width + buttonMargin}px, ${ + selectedRect.height / 2 + }px)`; + break; + case Direction.Left: + transform += `translate(${-buttonMargin}px, ${ + selectedRect.height / 2 + }px)`; + + transform += `scale(-1)`; + break; + } + + transform += `translate(${-size / 2}px, ${-size / 2}px)`; + + const arrowWrapperClasses = classMap({ + 'edgeless-auto-complete-arrow-wrapper': true, + hidden: position === Direction.Left && zoom >= 1.5, + mindmap: true, + }); + + return html`<div + class=${arrowWrapperClasses} + style=${styleMap({ + transform, + transformOrigin: 'left top', + })} + > + <div + class="edgeless-auto-complete-arrow" + @pointerdown=${() => { + this._addMindmapNode(target); + }} + > + ${icon} + </div> + </div>`; + }); + } + + private _showNextShape( + current: ShapeElementModel, + bound: Bound, + path: IVec[], + targetType: ShapeType + ) { + const { surface } = this.edgeless; + + this._autoCompleteOverlay.stroke = surface.renderer.getColorValue( + current.strokeColor, + DEFAULT_SHAPE_STROKE_COLOR, + true + ); + this._autoCompleteOverlay.linePoints = path; + this._autoCompleteOverlay.renderShape = ctx => { + shapeMethods[targetType].draw(ctx, { ...bound, rotate: current.rotate }); + }; + surface.refresh(); + } + + override connectedCallback(): void { + super.connectedCallback(); + this._pathGenerator = new ConnectorPathGenerator({ + getElementById: id => this.edgeless.service.getElementById(id), + }); + this._initOverlay(); + } + + override firstUpdated() { + const { _disposables, edgeless } = this; + + _disposables.add( + this.edgeless.service.selection.slots.updated.on(() => { + this._autoCompleteOverlay.linePoints = []; + this._autoCompleteOverlay.renderShape = null; + }) + ); + + _disposables.add(() => this.removeOverlay()); + + _disposables.add( + edgeless.host.event.add('pointerMove', ctx => { + const evt = ctx.get('pointerState'); + const [x, y] = edgeless.gfx.viewport.toModelCoord(evt.x, evt.y); + const elm = edgeless.gfx.getElementByPoint(x, y); + + if (!elm) { + this._isHover = false; + return; + } + + this._isHover = elm === this.current ? true : false; + }) + ); + + this.edgeless.handleEvent('dragStart', () => { + this._isMoving = true; + }); + this.edgeless.handleEvent('dragEnd', () => { + this._isMoving = false; + }); + } + + removeOverlay() { + this._timer && clearTimeout(this._timer); + this.edgeless.surface.renderer.removeOverlay(this._autoCompleteOverlay); + } + + override render() { + const isShape = this.current instanceof ShapeElementModel; + const isMindMap = this.current.group instanceof MindmapElementModel; + + if (this._isMoving || (this._isHover && !isShape)) { + this.removeOverlay(); + return nothing; + } + const { selectedRect } = this; + + return html`<div + class="edgeless-auto-complete-container" + style=${styleMap({ + top: selectedRect.top + 'px', + left: selectedRect.left + 'px', + width: selectedRect.width + 'px', + height: selectedRect.height + 'px', + transform: `rotate(${selectedRect.rotate}deg)`, + })} + > + ${isMindMap ? this._renderMindMapButtons() : this._renderArrow()} + </div>`; + } + + @state() + private accessor _isHover = true; + + @state() + private accessor _isMoving = false; + + @property({ attribute: false }) + accessor current!: ShapeElementModel | NoteBlockModel; + + @property({ attribute: false }) + accessor edgeless!: EdgelessRootBlockComponent; + + @property({ attribute: false }) + accessor selectedRect!: SelectedRect; + + @consume({ + context: stdContext, + }) + accessor std!: BlockStdScope; +} + +declare global { + interface HTMLElementTagNameMap { + 'edgeless-auto-complete': EdgelessAutoComplete; + } +} diff --git a/blocksuite/blocks/src/root-block/edgeless/components/auto-complete/utils.ts b/blocksuite/blocks/src/root-block/edgeless/components/auto-complete/utils.ts new file mode 100644 index 0000000000..190f57f24a --- /dev/null +++ b/blocksuite/blocks/src/root-block/edgeless/components/auto-complete/utils.ts @@ -0,0 +1,349 @@ +import { + CommonUtils, + type Options, + Overlay, + type RoughCanvas, +} from '@blocksuite/affine-block-surface'; +import { + type Connection, + getShapeRadius, + getShapeType, + GroupElementModel, + type NoteBlockModel, + ShapeElementModel, + type ShapeName, + type ShapeStyle, +} from '@blocksuite/affine-model'; +import type { GfxController, GfxModel } from '@blocksuite/block-std/gfx'; +import { BlockSuiteError, ErrorCode } from '@blocksuite/global/exceptions'; +import type { XYWH } from '@blocksuite/global/utils'; +import { assertType, Bound } from '@blocksuite/global/utils'; +import { DocCollection } from '@blocksuite/store'; + +import type { EdgelessRootBlockComponent } from '../../edgeless-root-block.js'; +import { type Shape, ShapeFactory } from '../../utils/tool-overlay.js'; + +export enum Direction { + Right, + Bottom, + Left, + Top, +} + +export const PANEL_WIDTH = 136; +export const PANEL_HEIGHT = 108; + +export const MAIN_GAP = 100; +export const SECOND_GAP = 20; +export const DEFAULT_NOTE_OVERLAY_HEIGHT = 110; +export const DEFAULT_TEXT_WIDTH = 116; +export const DEFAULT_TEXT_HEIGHT = 24; + +export type TARGET_SHAPE_TYPE = ShapeName; +export type AUTO_COMPLETE_TARGET_TYPE = + | TARGET_SHAPE_TYPE + | 'text' + | 'note' + | 'frame'; + +class AutoCompleteTargetOverlay extends Overlay { + xywh: XYWH; + + constructor(gfx: GfxController, xywh: XYWH) { + super(gfx); + this.xywh = xywh; + } + + override render(_ctx: CanvasRenderingContext2D, _rc: RoughCanvas) {} +} + +export class AutoCompleteTextOverlay extends AutoCompleteTargetOverlay { + constructor(gfx: GfxController, xywh: XYWH) { + super(gfx, xywh); + } + + override render(ctx: CanvasRenderingContext2D, _rc: RoughCanvas) { + const [x, y, w, h] = this.xywh; + + ctx.globalAlpha = 0.4; + ctx.strokeStyle = '#1e96eb'; + ctx.lineWidth = 1; + ctx.strokeRect(x, y, w, h); + + // fill text placeholder + ctx.font = '15px sans-serif'; + ctx.fillStyle = '#C0BFC1'; + ctx.textAlign = 'center'; + ctx.textBaseline = 'middle'; + ctx.fillText("Type '/' to insert", x + w / 2, y + h / 2); + } +} + +export class AutoCompleteNoteOverlay extends AutoCompleteTargetOverlay { + private _background: string; + + constructor(gfx: GfxController, xywh: XYWH, background: string) { + super(gfx, xywh); + this._background = background; + } + + override render(ctx: CanvasRenderingContext2D, _rc: RoughCanvas) { + const [x, y, w, h] = this.xywh; + + ctx.globalAlpha = 0.4; + ctx.fillStyle = this._background; + ctx.strokeStyle = 'rgba(0, 0, 0, 0.10)'; + ctx.lineWidth = 2; + ctx.beginPath(); + ctx.roundRect(x, y, w, h, 8); + ctx.closePath(); + ctx.fill(); + ctx.stroke(); + + // fill text placeholder + ctx.font = '15px sans-serif'; + ctx.fillStyle = 'black'; + ctx.textAlign = 'left'; + ctx.textBaseline = 'middle'; + ctx.fillText("Type '/' for command", x + 24, y + h / 2); + } +} + +export class AutoCompleteFrameOverlay extends AutoCompleteTargetOverlay { + private _strokeColor; + + constructor(gfx: GfxController, xywh: XYWH, strokeColor: string) { + super(gfx, xywh); + this._strokeColor = strokeColor; + } + + override render(ctx: CanvasRenderingContext2D, _rc: RoughCanvas) { + const [x, y, w, h] = this.xywh; + // frame title background + const titleWidth = 72; + const titleHeight = 30; + const titleY = y - titleHeight - 10; + + ctx.globalAlpha = 0.4; + ctx.fillStyle = 'rgba(0, 0, 0, 0.8)'; + ctx.beginPath(); + ctx.roundRect(x, titleY, titleWidth, titleHeight, 4); + ctx.closePath(); + ctx.fill(); + + // fill title text + ctx.globalAlpha = 1; + ctx.font = '14px sans-serif'; + ctx.fillStyle = 'white'; + ctx.textAlign = 'center'; + ctx.textBaseline = 'middle'; + ctx.fillText('Frame', x + titleWidth / 2, titleY + titleHeight / 2); + + // frame stroke + ctx.globalAlpha = 0.4; + ctx.strokeStyle = this._strokeColor; + ctx.lineWidth = 2; + ctx.beginPath(); + ctx.roundRect(x, y, w, h, 8); + ctx.closePath(); + ctx.stroke(); + } +} + +export class AutoCompleteShapeOverlay extends Overlay { + private _shape: Shape; + + constructor( + gfx: GfxController, + xywh: XYWH, + type: TARGET_SHAPE_TYPE, + options: Options, + shapeStyle: ShapeStyle + ) { + super(gfx); + this._shape = ShapeFactory.createShape(xywh, type, options, shapeStyle); + } + + override render(ctx: CanvasRenderingContext2D, rc: RoughCanvas) { + ctx.globalAlpha = 0.4; + this._shape.draw(ctx, rc); + } +} + +export function nextBound( + type: Direction, + curShape: ShapeElementModel, + elements: ShapeElementModel[] +) { + const bound = Bound.deserialize(curShape.xywh); + const { x, y, w, h } = bound; + let nextBound: Bound; + let angle = 0; + switch (type) { + case Direction.Right: + angle = 0; + break; + case Direction.Bottom: + angle = 90; + break; + case Direction.Left: + angle = 180; + break; + case Direction.Top: + angle = 270; + break; + } + angle = CommonUtils.normalizeDegAngle(angle + curShape.rotate); + + if (angle >= 45 && angle <= 135) { + nextBound = new Bound(x, y + h + MAIN_GAP, w, h); + } else if (angle >= 135 && angle <= 225) { + nextBound = new Bound(x - w - MAIN_GAP, y, w, h); + } else if (angle >= 225 && angle <= 315) { + nextBound = new Bound(x, y - h - MAIN_GAP, w, h); + } else { + nextBound = new Bound(x + w + MAIN_GAP, y, w, h); + } + + function isValidBound(bound: Bound) { + return !elements.some(a => bound.isOverlapWithBound(a.elementBound)); + } + + let count = 0; + function findValidBound() { + count++; + const number = Math.ceil(count / 2); + const next = nextBound.clone(); + switch (type) { + case Direction.Right: + case Direction.Left: + next.y = + count % 2 === 1 + ? nextBound.y - (h + SECOND_GAP) * number + : nextBound.y + (h + SECOND_GAP) * number; + break; + case Direction.Bottom: + case Direction.Top: + next.x = + count % 2 === 1 + ? nextBound.x - (w + SECOND_GAP) * number + : nextBound.x + (w + SECOND_GAP) * number; + break; + } + if (isValidBound(next)) return next; + return findValidBound(); + } + + return isValidBound(nextBound) ? nextBound : findValidBound(); +} + +export function getPosition(type: Direction) { + let startPosition: Connection['position']; + let endPosition: Connection['position']; + + switch (type) { + case Direction.Right: + startPosition = [1, 0.5]; + endPosition = [0, 0.5]; + break; + case Direction.Bottom: + startPosition = [0.5, 1]; + endPosition = [0.5, 0]; + break; + case Direction.Left: + startPosition = [0, 0.5]; + endPosition = [1, 0.5]; + break; + case Direction.Top: + startPosition = [0.5, 0]; + endPosition = [0.5, 1]; + break; + } + return { startPosition, endPosition }; +} + +export function isShape(element: unknown): element is ShapeElementModel { + return element instanceof ShapeElementModel; +} + +export function capitalizeFirstLetter(str: string) { + return str.charAt(0).toUpperCase() + str.slice(1); +} + +export function createEdgelessElement( + edgeless: EdgelessRootBlockComponent, + current: ShapeElementModel | NoteBlockModel, + bound: Bound +) { + let id; + const { service } = edgeless; + + let element: GfxModel | null = null; + + if (isShape(current)) { + id = service.addElement(current.type, { + ...current.serialize(), + text: new DocCollection.Y.Text(), + xywh: bound.serialize(), + }); + element = service.getElementById(id); + } else { + const { doc } = edgeless; + id = doc.addBlock( + 'affine:note', + { + background: current.background, + displayMode: current.displayMode, + edgeless: current.edgeless, + xywh: bound.serialize(), + }, + edgeless.model.id + ); + const note = doc.getBlock(id)?.model; + if (!note) { + throw new BlockSuiteError( + ErrorCode.GfxBlockElementError, + 'Note block is not found after creation' + ); + } + assertType<NoteBlockModel>(note); + doc.updateBlock(note, () => { + note.edgeless.collapse = true; + }); + doc.addBlock('affine:paragraph', {}, note.id); + + element = note; + } + + if (!element) { + throw new BlockSuiteError( + ErrorCode.GfxBlockElementError, + 'Element is not found after creation' + ); + } + + const group = current.group; + if (group instanceof GroupElementModel) { + group.addChild(element); + } + return id; +} + +export function createShapeElement( + edgeless: EdgelessRootBlockComponent, + current: ShapeElementModel | NoteBlockModel, + targetType: TARGET_SHAPE_TYPE +) { + const service = edgeless.service; + const id = service.addElement('shape', { + shapeType: getShapeType(targetType), + radius: getShapeRadius(targetType), + text: new DocCollection.Y.Text(), + }); + const element = service.getElementById(id); + const group = current.group; + if (group instanceof GroupElementModel && element) { + group.addChild(element); + } + return id; +} diff --git a/blocksuite/blocks/src/root-block/edgeless/components/buttons/tool-icon-button.ts b/blocksuite/blocks/src/root-block/edgeless/components/buttons/tool-icon-button.ts new file mode 100644 index 0000000000..3bab78660b --- /dev/null +++ b/blocksuite/blocks/src/root-block/edgeless/components/buttons/tool-icon-button.ts @@ -0,0 +1,197 @@ +import type { Placement } from '@floating-ui/dom'; +import type { TemplateResult } from 'lit'; +import { css, html, LitElement, nothing } from 'lit'; +import { property } from 'lit/decorators.js'; +import { cache } from 'lit/directives/cache.js'; +import { styleMap } from 'lit/directives/style-map.js'; + +export class EdgelessToolIconButton extends LitElement { + static override styles = css` + .icon-container { + position: relative; + display: flex; + align-items: center; + padding: var(--icon-container-padding); + color: var(--affine-icon-color); + border-radius: 4px; + cursor: pointer; + white-space: nowrap; + box-sizing: border-box; + width: var(--icon-container-width, unset); + justify-content: var(--justify, unset); + } + + .icon-container.active-mode-color[active] { + color: var(--affine-primary-color); + } + + .icon-container.active-mode-background[active] { + background: var(--affine-hover-color); + } + + .icon-container[disabled] { + pointer-events: none; + cursor: not-allowed; + color: var(--affine-text-disable-color); + } + + .icon-container[coming] { + cursor: not-allowed; + color: var(--affine-text-disable-color); + } + + ::slotted(svg) { + flex-shrink: 0; + height: var(--icon-size, unset); + } + + ::slotted(.label) { + flex: 1; + padding: 0 4px; + overflow: hidden; + white-space: nowrap; + line-height: var(--label-height, inherit); + } + ::slotted(.label.padding0) { + padding: 0; + } + ::slotted(.label.ellipsis) { + text-overflow: ellipsis; + } + ::slotted(.label.medium) { + font-weight: 500; + } + + .icon-container[with-hover]::before { + content: ''; + display: block; + background: var(--affine-hover-color); + position: absolute; + width: 100%; + height: 100%; + top: 0; + left: 0; + border-radius: 4px; + } + `; + + constructor() { + super(); + + this.addEventListener( + 'click', + event => { + if (this.disabled) { + event.stopPropagation(); + event.preventDefault(); + } + }, + { capture: true } + ); + } + + override connectedCallback() { + super.connectedCallback(); + this.role = 'button'; + } + + override render() { + const tooltip = this.coming ? '(Coming soon)' : this.tooltip; + const classnames = `icon-container active-mode-${this.activeMode} ${this.hoverState ? 'hovered' : ''}`; + const padding = this.iconContainerPadding; + const iconContainerStyles = styleMap({ + '--icon-container-width': this.iconContainerWidth, + '--icon-container-padding': Array.isArray(padding) + ? padding.map(v => `${v}px`).join(' ') + : `${padding}px`, + '--icon-size': this.iconSize, + '--justify': this.justify, + '--label-height': this.labelHeight, + }); + + return html` + <style> + .icon-container:hover, + .icon-container.hovered { + background: ${this.hover ? `var(--affine-hover-color)` : 'inherit'}; + } + </style> + <div + class=${classnames} + style=${iconContainerStyles} + ?with-hover=${this.withHover} + ?disabled=${this.disabled} + ?active=${this.active} + > + <slot></slot> + ${cache( + this.showTooltip && tooltip + ? html`<affine-tooltip + tip-position=${this.tipPosition} + .arrow=${this.arrow} + .offset=${this.tooltipOffset} + >${tooltip}</affine-tooltip + >` + : nothing + )} + </div> + `; + } + + @property({ attribute: false }) + accessor active = false; + + @property({ attribute: false }) + accessor activeMode: 'color' | 'background' = 'color'; + + @property({ attribute: false }) + accessor arrow = true; + + @property({ attribute: false }) + accessor coming = false; + + @property({ attribute: false }) + accessor disabled = false; + + @property({ attribute: false }) + accessor hover = true; + + @property({ attribute: false }) + accessor hoverState = false; + + @property({ attribute: false }) + accessor iconContainerPadding: number | number[] = 2; + + @property({ attribute: false }) + accessor iconContainerWidth: string | undefined = undefined; + + @property({ attribute: false }) + accessor iconSize: string | undefined = undefined; + + @property({ attribute: false }) + accessor justify: string | undefined = undefined; + + @property({ attribute: false }) + accessor labelHeight: string | undefined = undefined; + + @property({ type: Boolean }) + accessor showTooltip = true; + + @property({ attribute: false }) + accessor tipPosition: Placement = 'top'; + + @property({ attribute: false }) + accessor tooltip!: string | TemplateResult<1>; + + @property({ attribute: false }) + accessor tooltipOffset = 8; + + @property({ attribute: false }) + accessor withHover: boolean | undefined = undefined; +} + +declare global { + interface HTMLElementTagNameMap { + 'edgeless-tool-icon-button': EdgelessToolIconButton; + } +} diff --git a/blocksuite/blocks/src/root-block/edgeless/components/buttons/toolbar-button.ts b/blocksuite/blocks/src/root-block/edgeless/components/buttons/toolbar-button.ts new file mode 100644 index 0000000000..01aee4e52d --- /dev/null +++ b/blocksuite/blocks/src/root-block/edgeless/components/buttons/toolbar-button.ts @@ -0,0 +1,45 @@ +import { css, html } from 'lit'; + +import { EdgelessToolIconButton } from './tool-icon-button.js'; + +export class EdgelessToolbarButton extends EdgelessToolIconButton { + static override styles = css` + .icon-container { + position: relative; + display: flex; + align-items: center; + justify-content: center; + padding: 0; + color: var(--affine-icon-color); + cursor: pointer; + } + + .icon-container.active-mode-color[active] { + color: var(--affine-primary-color); + } + + .icon-container.active-mode-background[active] { + background: var(--affine-hover-color); + } + + .icon-container[disabled] { + pointer-events: none; + cursor: not-allowed; + } + + .icon-container[coming] { + cursor: not-allowed; + color: var(--affine-text-disable-color); + } + `; + + override render() { + return html` ${super.render()} `; + } +} + +declare global { + interface HTMLElementTagNameMap { + 'edgeless-toolbar-button': EdgelessToolbarButton; + } +} diff --git a/blocksuite/blocks/src/root-block/edgeless/components/color-picker/button.ts b/blocksuite/blocks/src/root-block/edgeless/components/color-picker/button.ts new file mode 100644 index 0000000000..cb243155cc --- /dev/null +++ b/blocksuite/blocks/src/root-block/edgeless/components/color-picker/button.ts @@ -0,0 +1,180 @@ +import type { EditorMenuButton } from '@blocksuite/affine-components/toolbar'; +import { WithDisposable } from '@blocksuite/global/utils'; +import { html, LitElement } from 'lit'; +import { property, query, state } from 'lit/decorators.js'; +import { choose } from 'lit/directives/choose.js'; +import { styleMap } from 'lit/directives/style-map.js'; + +import type { ColorEvent } from '../panel/color-panel.js'; +import type { + ModeType, + PickColorDetail, + PickColorEvent, + PickColorType, +} from './types.js'; +import { keepColor, preprocessColor } from './utils.js'; + +type Type = 'normal' | 'custom'; + +export class EdgelessColorPickerButton extends WithDisposable(LitElement) { + #select = (e: ColorEvent) => { + this.#pick({ palette: e.detail }); + }; + + switchToCustomTab = (e: MouseEvent) => { + e.stopPropagation(); + if (this.colorType === 'palette') { + this.colorType = 'normal'; + } + this.tabType = 'custom'; + // refresh menu's position + this.menuButton.show(true); + }; + + get colorWithoutAlpha() { + return this.isCSSVariable ? this.color : keepColor(this.color); + } + + get customButtonStyle() { + let b = 'transparent'; + let c = 'transparent'; + if (!this.isCSSVariable) { + b = 'var(--affine-background-overlay-panel-color)'; + c = keepColor(this.color); + } + return { '--b': b, '--c': c }; + } + + get isCSSVariable() { + return this.color.startsWith('--'); + } + + get tabContentPadding() { + return `${this.tabType === 'custom' ? 0 : 8}px`; + } + + #pick(detail: PickColorDetail) { + this.pick?.({ type: 'start' }); + this.pick?.({ type: 'pick', detail }); + this.pick?.({ type: 'end' }); + } + + override firstUpdated() { + this.disposables.addFromEvent(this.menuButton, 'toggle', (e: Event) => { + const opened = (e as CustomEvent<boolean>).detail; + if (!opened && this.tabType !== 'normal') { + this.tabType = 'normal'; + } + }); + } + + override render() { + return html` + <editor-menu-button + .contentPadding=${this.tabContentPadding} + .button=${html` + <editor-icon-button + aria-label=${this.label} + .tooltip=${this.tooltip || this.label} + > + ${this.isText + ? html` + <edgeless-text-color-icon + .color=${this.colorWithoutAlpha} + ></edgeless-text-color-icon> + ` + : html` + <edgeless-color-button + .color=${this.colorWithoutAlpha} + .hollowCircle=${this.hollowCircle} + ></edgeless-color-button> + `} + </editor-icon-button> + `} + > + ${choose(this.tabType, [ + [ + 'normal', + () => html` + <div data-orientation="vertical"> + <slot name="other"></slot> + <slot name="separator"></slot> + <edgeless-color-panel + role="listbox" + .value=${this.color} + .options=${this.palettes} + .hollowCircle=${this.hollowCircle} + .openColorPicker=${this.switchToCustomTab} + .hasTransparent=${false} + @select=${this.#select} + > + <edgeless-color-custom-button + slot="custom" + style=${styleMap(this.customButtonStyle)} + .active=${!this.isCSSVariable} + @click=${this.switchToCustomTab} + ></edgeless-color-custom-button> + </edgeless-color-panel> + </div> + `, + ], + [ + 'custom', + () => html` + <edgeless-color-picker + class="custom" + .pick=${this.pick} + .colors=${{ + type: + this.colorType === 'palette' ? 'normal' : this.colorType, + modes: this.colors.map( + preprocessColor(window.getComputedStyle(this)) + ), + }} + ></edgeless-color-picker> + `, + ], + ])} + </editor-menu-button> + `; + } + + @property() + accessor color!: string; + + @property({ attribute: false }) + accessor colors: { type: ModeType; value: string }[] = []; + + @property() + accessor colorType: PickColorType = 'palette'; + + @property({ attribute: false }) + accessor hollowCircle: boolean = false; + + @property({ attribute: false }) + accessor isText!: boolean; + + @property() + accessor label!: string; + + @query('editor-menu-button') + accessor menuButton!: EditorMenuButton; + + @property({ attribute: false }) + accessor palettes: string[] = []; + + @property({ attribute: false }) + accessor pick!: (event: PickColorEvent) => void; + + @state() + accessor tabType: Type = 'normal'; + + @property() + accessor tooltip: string | undefined = undefined; +} + +declare global { + interface HTMLElementTagNameMap { + 'edgeless-color-picker-button': EdgelessColorPickerButton; + } +} diff --git a/blocksuite/blocks/src/root-block/edgeless/components/color-picker/color-picker.ts b/blocksuite/blocks/src/root-block/edgeless/components/color-picker/color-picker.ts new file mode 100644 index 0000000000..1620790d06 --- /dev/null +++ b/blocksuite/blocks/src/root-block/edgeless/components/color-picker/color-picker.ts @@ -0,0 +1,678 @@ +import { on, once, stopPropagation } from '@blocksuite/affine-shared/utils'; +import { SignalWatcher, WithDisposable } from '@blocksuite/global/utils'; +import { batch, computed, signal } from '@preact/signals-core'; +import { html, LitElement } from 'lit'; +import { property, query } from 'lit/decorators.js'; +import { classMap } from 'lit/directives/class-map.js'; +import { live } from 'lit/directives/live.js'; +import { repeat } from 'lit/directives/repeat.js'; +import { styleMap } from 'lit/directives/style-map.js'; + +import { AREA_CIRCLE_R, MATCHERS, SLIDER_CIRCLE_R } from './consts.js'; +import { COLOR_PICKER_STYLE } from './styles.js'; +import type { + Hsva, + ModeRgba, + ModeTab, + ModeType, + NavTab, + NavType, + PickColorEvent, + Point, + Rgb, +} from './types.js'; +import { + bound01, + clamp, + defaultHsva, + eq, + hsvaToHex8, + hsvaToRgba, + linearGradientAt, + parseHexToHsva, + renderCanvas, + rgbaToHex8, + rgbaToHsva, + rgbToHex, + rgbToHsv, +} from './utils.js'; + +const TABS: NavTab<NavType>[] = [ + { type: 'colors', name: 'Colors' }, + { type: 'custom', name: 'Custom' }, +]; + +export class EdgelessColorPicker extends SignalWatcher( + WithDisposable(LitElement) +) { + static override styles = COLOR_PICKER_STYLE; + + #alphaRect = new DOMRect(); + + #editAlpha = (e: InputEvent) => { + const target = e.target as HTMLInputElement; + const orignalValue = target.value; + let value = orignalValue.trim().replace(/[^0-9]/, ''); + + const alpha = clamp(0, Number(value), 100); + const a = bound01(alpha, 100); + const hsva = this.hsva$.peek(); + + value = `${alpha}`; + if (orignalValue !== value) { + target.value = value; + } + + if (hsva.a === a) return; + + const x = this.#alphaRect.width * a; + this.alphaPosX$.value = x; + + this.#pick(); + }; + + #editHex = (e: KeyboardEvent) => { + e.stopPropagation(); + + const target = e.target as HTMLInputElement; + + if (e.key === 'Enter') { + const orignalValue = target.value; + let value = orignalValue.trim().replace(MATCHERS.other, ''); + let matched; + if ( + (matched = value.match(MATCHERS.hex3)) || + (matched = value.match(MATCHERS.hex6)) + ) { + const oldHsva = this.hsva$.peek(); + const hsv = parseHexToHsva(matched[1]); + const newHsva = { ...oldHsva, ...hsv }; + + value = rgbToHex(hsvaToRgba(newHsva)); + if (orignalValue !== value) { + target.value = value; + } + + if (eq(newHsva, oldHsva)) return; + + this.#setControlsPos(newHsva); + + this.#pick(); + } else { + target.value = this.hex6WithoutHash$.peek(); + } + } + }; + + #hueRect = new DOMRect(); + + #paletteRect = new DOMRect(); + + #pick() { + const hsva = this.hsva$.peek(); + const type = this.modeType$.peek(); + const detail = { [type]: hsvaToHex8(hsva) }; + + if (type !== 'normal') { + const another = type === 'light' ? 'dark' : 'light'; + const { hsva } = this[`${another}$`].peek(); + detail[another] = hsvaToHex8(hsva); + } + + this.pick?.({ type: 'pick', detail }); + } + + #pickEnd() { + this.pick?.({ type: 'end' }); + } + + #pickStart() { + this.pick?.({ type: 'start' }); + } + + #setAlphaPos(clientX: number) { + const { left, width } = this.#alphaRect; + const x = clamp(0, clientX - left, width); + + this.alphaPosX$.value = x; + } + + #setAlphaPosWithWheel(y: number) { + const { width } = this.#alphaRect; + const px = this.alphaPosX$.peek(); + const ax = clamp(0, px + (y * width) / 100, width); + + this.alphaPosX$.value = ax; + } + + #setControlsPos({ h, s, v, a }: Hsva) { + const hx = this.#hueRect.width * h; + const px = this.#paletteRect.width * s; + const py = this.#paletteRect.height * (1 - v); + const ax = this.#alphaRect.width * a; + + batch(() => { + this.huePosX$.value = hx; + this.alphaPosX$.value = ax; + this.palettePos$.value = { x: px, y: py }; + }); + } + + #setHuePos(clientX: number) { + const { left, width } = this.#hueRect; + const x = clamp(0, clientX - left, width); + + this.huePosX$.value = x; + } + + #setHuePosWithWheel(y: number) { + const { width } = this.#hueRect; + const px = this.huePosX$.peek(); + const ax = clamp(0, px + (y * width) / 100, width); + + this.huePosX$.value = ax; + } + + #setPalettePos(clientX: number, clientY: number) { + const { left, top, width, height } = this.#paletteRect; + const x = clamp(0, clientX - left, width); + const y = clamp(0, clientY - top, height); + + this.palettePos$.value = { x, y }; + } + + #setPalettePosWithWheel(x: number, y: number) { + const { width, height } = this.#paletteRect; + const pos = this.palettePos$.peek(); + const px = clamp(0, pos.x + (x * width) / 100, width); + const py = clamp(0, pos.y + (y * height) / 100, height); + + this.palettePos$.value = { x: px, y: py }; + } + + #setRect({ left, top, width, height }: DOMRect, offset: number) { + return new DOMRect( + left + offset, + top + offset, + Math.round(width - offset * 2), + Math.round(height - offset * 2) + ); + } + + #setRects() { + this.#paletteRect = this.#setRect( + this.paletteControl.getBoundingClientRect(), + AREA_CIRCLE_R + ); + + this.#hueRect = this.#setRect( + this.hueControl.getBoundingClientRect(), + SLIDER_CIRCLE_R + ); + + this.#alphaRect = this.#setRect( + this.alphaControl.getBoundingClientRect(), + SLIDER_CIRCLE_R + ); + } + + #switchModeTab(type: ModeType) { + this.modeType$.value = type; + this.#setControlsPos(this.mode$.peek().hsva); + } + + #switchNavTab(type: NavType) { + this.navType$.value = type; + + if (type === 'colors') { + const mode = this.mode$.peek(); + this.modes$.value[0].hsva = { ...mode.hsva }; + this.modeType$.value = 'normal'; + } else { + const [normal, light, dark] = this.modes$.value; + light.hsva = { ...normal.hsva }; + dark.hsva = { ...normal.hsva }; + this.modeType$.value = 'light'; + } + } + + override firstUpdated() { + let clicked = false; + let dragged = false; + let outed = false; + let picked = false; + + let pointerenter: (() => void) | null; + let pointermove: (() => void) | null; + let pointerout: (() => void) | null; + let timerId = 0; + + this.disposables.addFromEvent(this, 'wheel', (e: WheelEvent) => { + e.stopPropagation(); + + const target = e.composedPath()[0] as HTMLElement; + const isInHue = target === this.hueControl; + const isInAlpha = !isInHue && target === this.alphaControl; + const isInPalette = !isInAlpha && target === this.paletteControl; + picked = isInHue || isInAlpha || isInPalette; + + if (timerId) { + clearTimeout(timerId); + } + + // update target rect + if (picked) { + if (!timerId) { + this.#pickStart(); + } + timerId = window.setTimeout(() => { + this.#pickEnd(); + timerId = 0; + }, 110); + } + + const update = (x: number, y: number) => { + if (!picked) return; + + const absX = Math.abs(x); + const absY = Math.abs(y); + + x = Math.sign(x); + y = Math.sign(y); + + if (Math.hypot(x, y) === 0) return; + + x *= Math.max(1, Math.log10(absX)) * -1; + y *= Math.max(1, Math.log10(absY)) * -1; + + if (isInHue) { + this.#setHuePosWithWheel(x | y); + } + + if (isInAlpha) { + this.#setAlphaPosWithWheel(x | y); + } + + if (isInPalette) { + this.#setPalettePosWithWheel(x, y); + } + + this.#pick(); + }; + + update(e.deltaX, e.deltaY); + }); + + this.disposables.addFromEvent(this, 'pointerdown', (e: PointerEvent) => { + e.stopPropagation(); + + if (timerId) { + clearTimeout(timerId); + timerId = 0; + } + + // checks pointer enter/out + pointerenter = on(this, 'pointerenter', () => (outed = false)); + pointerout = on(this, 'pointerout', () => (outed = true)); + // cleanups + once(document, 'pointerup', () => { + pointerenter?.(); + pointermove?.(); + pointerout?.(); + + if (picked) { + this.#pickEnd(); + } + + // When dragging the points, the color picker panel should not be triggered to close. + if (dragged && outed) { + once(document, 'click', stopPropagation, true); + } + + pointerenter = pointermove = pointerout = null; + clicked = dragged = outed = picked = false; + }); + + clicked = true; + + const target = e.composedPath()[0] as HTMLElement; + const isInHue = target === this.hueControl; + const isInAlpha = !isInHue && target === this.alphaControl; + const isInPalette = !isInAlpha && target === this.paletteControl; + picked = isInHue || isInAlpha || isInPalette; + + // update target rect + if (picked) { + this.#pickStart(); + + const rect = target.getBoundingClientRect(); + if (isInHue) { + this.#hueRect = this.#setRect(rect, SLIDER_CIRCLE_R); + } else if (isInAlpha) { + this.#alphaRect = this.#setRect(rect, SLIDER_CIRCLE_R); + } else if (isInPalette) { + this.#paletteRect = this.#setRect(rect, AREA_CIRCLE_R); + } + } + + const update = (x: number, y: number) => { + if (!picked) return; + + if (isInHue) { + this.#setHuePos(x); + } + + if (isInAlpha) { + this.#setAlphaPos(x); + } + + if (isInPalette) { + this.#setPalettePos(x, y); + } + + this.#pick(); + }; + + update(e.x, e.y); + + pointermove = on(document, 'pointermove', (e: PointerEvent) => { + if (!clicked) return; + if (!dragged) dragged = true; + + update(e.x, e.y); + }); + }); + this.disposables.addFromEvent(this, 'click', stopPropagation); + + const batches: (() => void)[] = []; + const { type, modes } = this.colors; + + // Updates UI states + if (['dark', 'light'].includes(type)) { + batches.push(() => { + this.modeType$.value = type; + this.navType$.value = 'custom'; + }); + } + + // Updates modes + if (modes?.length) { + batches.push(() => { + // eslint-disable-next-line sonarjs/no-ignored-return + this.modes$.value.reduce((fallback, curr, n) => { + const m = modes[n]; + curr.hsva = m ? rgbaToHsva(m.rgba) : fallback; + return { ...curr.hsva }; + }, defaultHsva()); + }); + } + + // Updates controls' positions + batches.push(() => { + const mode = this.mode$.peek(); + this.#setControlsPos(mode.hsva); + }); + + // Updates controls' rects + this.#setRects(); + + batch(() => batches.forEach(fn => fn())); + + this.updateComplete + .then(() => { + this.disposables.add( + this.hsva$.subscribe((hsva: Hsva) => { + const type = this.modeType$.peek(); + const mode = this.modes$.value.find(mode => mode.type === type); + + if (mode) { + mode.hsva = { ...hsva }; + } + }) + ); + + this.disposables.add( + this.huePosX$.subscribe((x: number) => { + const { width } = this.#hueRect; + const rgb = linearGradientAt(bound01(x, width)); + + // Updates palette canvas + renderCanvas(this.canvas, rgb); + + this.hue$.value = rgb; + }) + ); + + this.disposables.add( + this.hue$.subscribe((rgb: Rgb) => { + const hsva = this.hsva$.peek(); + const h = rgbToHsv(rgb).h; + + this.hsva$.value = { ...hsva, h }; + }) + ); + + this.disposables.add( + this.alphaPosX$.subscribe((x: number) => { + const hsva = this.hsva$.peek(); + const { width } = this.#alphaRect; + const a = bound01(x, width); + + this.hsva$.value = { ...hsva, a }; + }) + ); + + this.disposables.add( + this.palettePos$.subscribe(({ x, y }: Point) => { + const hsva = this.hsva$.peek(); + const { width, height } = this.#paletteRect; + const s = bound01(x, width); + const v = bound01(height - y, height); + + this.hsva$.value = { ...hsva, s, v }; + }) + ); + }) + .catch(console.error); + } + + override render() { + return html` + <header> + <nav> + ${repeat( + TABS, + tab => tab.type, + ({ type, name }) => html` + <button + ?active=${type === this.navType$.value} + @click=${() => this.#switchNavTab(type)} + > + ${name} + </button> + ` + )} + </nav> + </header> + + <div class="modes" ?active=${this.navType$.value === 'custom'}> + ${repeat( + [this.light$.value, this.dark$.value], + mode => mode.type, + ({ type, name, hsva }) => html` + <div + class="${classMap({ mode: true, [type]: true })}" + style=${styleMap({ '--c': hsvaToHex8(hsva) })} + > + <button + ?active=${this.modeType$.value === type} + @click=${() => this.#switchModeTab(type)} + > + <div class="color"></div> + <div>${name}</div> + </button> + </div> + ` + )} + </div> + + <div class="content"> + <div + class="color-palette-wrapper" + style=${styleMap(this.paletteStyle$.value)} + > + <canvas></canvas> + <div class="color-circle"></div> + <div class="color-palette"></div> + </div> + <div + class="color-slider-wrapper hue" + style=${styleMap(this.hueStyle$.value)} + > + <div class="color-circle"></div> + <div class="color-slider"></div> + </div> + <div + class="color-slider-wrapper alpha" + style=${styleMap(this.alphaStyle$.value)} + > + <div class="color-circle"></div> + <div class="color-slider"></div> + </div> + </div> + + <footer> + <label class="field color"> + <span>#</span> + <input + autocomplete="off" + spellcheck="false" + minlength="1" + maxlength="6" + .value=${live(this.hex6WithoutHash$.value)} + @keydown=${this.#editHex} + @cut=${stopPropagation} + @copy=${stopPropagation} + @paste=${stopPropagation} + /> + </label> + <label class="field alpha"> + <input + type="number" + min="0" + max="100" + .value=${live(this.alpha100$.value)} + @input=${this.#editAlpha} + @cut=${stopPropagation} + @copy=${stopPropagation} + @paste=${stopPropagation} + /> + <span>%</span> + </label> + </footer> + `; + } + + // 0-100 + accessor alpha100$ = computed( + () => `${Math.round(this.hsva$.value.a * 100)}` + ); + + @query('.color-slider-wrapper.alpha .color-slider') + accessor alphaControl!: HTMLDivElement; + + accessor alphaPosX$ = signal<number>(0); + + accessor alphaStyle$ = computed(() => { + const x = this.alphaPosX$.value; + const rgba = this.rgba$.value; + const hex = `#${rgbToHex(rgba)}`; + return { + '--o': rgba.a, + '--s': `${hex}00`, + '--c': `${hex}ff`, + '--x': `${x}px`, + '--r': `${SLIDER_CIRCLE_R}px`, + }; + }); + + @query('canvas') + accessor canvas!: HTMLCanvasElement; + + @property({ attribute: false }) + accessor colors: { type: ModeType; modes?: ModeRgba[] } = { type: 'normal' }; + + accessor dark$ = computed<ModeTab<ModeType>>(() => this.modes$.value[2]); + + // #ffffff + accessor hex6$ = computed(() => this.hex8$.value.substring(0, 7)); + + // ffffff + accessor hex6WithoutHash$ = computed(() => this.hex6$.value.substring(1)); + + // #ffffffff + accessor hex8$ = computed(() => rgbaToHex8(this.rgba$.value)); + + accessor hsva$ = signal<Hsva>(defaultHsva()); + + accessor hue$ = signal<Rgb>({ r: 0, g: 0, b: 0 }); + + @query('.color-slider-wrapper.hue .color-slider') + accessor hueControl!: HTMLDivElement; + + accessor huePosX$ = signal<number>(0); + + accessor hueStyle$ = computed(() => { + const x = this.huePosX$.value; + const rgb = this.hue$.value; + return { + '--x': `${x}px`, + '--c': `#${rgbToHex(rgb)}`, + '--r': `${SLIDER_CIRCLE_R}px`, + }; + }); + + accessor light$ = computed<ModeTab<ModeType>>(() => this.modes$.value[1]); + + accessor mode$ = computed<ModeTab<ModeType>>(() => { + const modeType = this.modeType$.value; + return this.modes$.value.find(mode => mode.type === modeType)!; + }); + + accessor modes$ = signal<ModeTab<ModeType>[]>([ + { type: 'normal', name: 'Normal', hsva: defaultHsva() }, + { type: 'light', name: 'Light', hsva: defaultHsva() }, + { type: 'dark', name: 'Dark', hsva: defaultHsva() }, + ]); + + accessor modeType$ = signal<ModeType>('normal'); + + accessor navType$ = signal<NavType>('colors'); + + @query('.color-palette') + accessor paletteControl!: HTMLDivElement; + + accessor palettePos$ = signal<Point>({ x: 0, y: 0 }); + + accessor paletteStyle$ = computed(() => { + const { x, y } = this.palettePos$.value; + const c = this.hex6$.value; + return { + '--c': c, + '--x': `${x}px`, + '--y': `${y}px`, + '--r': `${AREA_CIRCLE_R}px`, + }; + }); + + @property({ attribute: false }) + accessor pick!: (event: PickColorEvent) => void; + + accessor rgba$ = computed(() => hsvaToRgba(this.hsva$.value)); +} + +declare global { + interface HTMLElementTagNameMap { + 'edgeless-color-picker': EdgelessColorPicker; + } +} diff --git a/blocksuite/blocks/src/root-block/edgeless/components/color-picker/consts.ts b/blocksuite/blocks/src/root-block/edgeless/components/color-picker/consts.ts new file mode 100644 index 0000000000..af975f57a1 --- /dev/null +++ b/blocksuite/blocks/src/root-block/edgeless/components/color-picker/consts.ts @@ -0,0 +1,26 @@ +import type { Rgb } from './types.js'; + +export const AREA_CIRCLE_R = 12.5; +export const SLIDER_CIRCLE_R = 10.5; + +// [Rgb, stop][] +export const COLORS: [Rgb, number][] = [ + [{ r: 1, g: 0, b: 0 }, 0 / 6], + [{ r: 1, g: 1, b: 0 }, 1 / 6], + [{ r: 0, g: 1, b: 0 }, 2 / 6], + [{ r: 0, g: 1, b: 1 }, 3 / 6], + [{ r: 0, g: 0, b: 1 }, 4 / 6], + [{ r: 1, g: 0, b: 1 }, 5 / 6], + // eslint-disable-next-line sonarjs/no-identical-expressions + [{ r: 1, g: 0, b: 0 }, 6 / 6], +]; + +export const FIRST_COLOR = COLORS[0][0]; + +export const MATCHERS = { + hex3: /^#?([0-9a-fA-F]{3})$/, + hex6: /^#?([0-9a-fA-F]{6})$/, + hex4: /^#?([0-9a-fA-F]{4})$/, + hex8: /^#?([0-9a-fA-F]{8})$/, + other: /[^0-9a-fA-F]/, +}; diff --git a/blocksuite/blocks/src/root-block/edgeless/components/color-picker/custom-button.ts b/blocksuite/blocks/src/root-block/edgeless/components/color-picker/custom-button.ts new file mode 100644 index 0000000000..9c76ed365f --- /dev/null +++ b/blocksuite/blocks/src/root-block/edgeless/components/color-picker/custom-button.ts @@ -0,0 +1,61 @@ +import { css, html, LitElement } from 'lit'; +import { property } from 'lit/decorators.js'; + +import { colorContainerStyles } from '../panel/color-panel.js'; + +export class EdgelessColorCustomButton extends LitElement { + static override styles = css` + ${colorContainerStyles} + + .color-custom { + display: flex; + align-items: center; + justify-content: center; + width: 16px; + height: 16px; + border-radius: 50%; + box-sizing: border-box; + overflow: hidden; + padding: 2px; + border: 2px solid transparent; + background: + linear-gradient(var(--c, transparent), var(--c, transparent)) + content-box, + linear-gradient(var(--b, transparent), var(--b, transparent)) + padding-box, + conic-gradient( + from 180deg at 50% 50%, + #d21c7e 0deg, + #c240f0 30.697514712810516deg, + #434af5 62.052921652793884deg, + #3cb5f9 93.59999656677246deg, + #3ceefa 131.40000343322754deg, + #37f7bd 167.40000128746033deg, + #2df541 203.39999914169312deg, + #e7f738 239.40000772476196deg, + #fbaf3e 273.07027101516724deg, + #fd904e 300.73712825775146deg, + #f64545 329.47510957717896deg, + #f040a9 359.0167021751404deg + ) + border-box; + } + `; + + override render() { + return html` + <div class="color-container" ?active=${this.active}> + <div class="color-unit color-custom"></div> + </div> + `; + } + + @property({ attribute: false }) + accessor active!: boolean; +} + +declare global { + interface HTMLElementTagNameMap { + 'edgeless-color-custom-button': EdgelessColorCustomButton; + } +} diff --git a/blocksuite/blocks/src/root-block/edgeless/components/color-picker/index.ts b/blocksuite/blocks/src/root-block/edgeless/components/color-picker/index.ts new file mode 100644 index 0000000000..cedd0c7974 --- /dev/null +++ b/blocksuite/blocks/src/root-block/edgeless/components/color-picker/index.ts @@ -0,0 +1,3 @@ +export * from './button.js'; +export * from './color-picker.js'; +export * from './types.js'; diff --git a/blocksuite/blocks/src/root-block/edgeless/components/color-picker/styles.ts b/blocksuite/blocks/src/root-block/edgeless/components/color-picker/styles.ts new file mode 100644 index 0000000000..c2ec08e3e5 --- /dev/null +++ b/blocksuite/blocks/src/root-block/edgeless/components/color-picker/styles.ts @@ -0,0 +1,293 @@ +import { FONT_SM, FONT_XS } from '@blocksuite/affine-shared/styles'; +import { css } from 'lit'; + +export const COLOR_PICKER_STYLE = css` + :host { + display: flex; + flex-direction: column; + align-items: normal; + gap: 12px; + min-width: 198px; + padding: 16px; + } + + nav { + display: flex; + padding: 2px; + align-items: flex-start; + gap: 4px; + align-self: stretch; + border-radius: 8px; + background: var(--affine-hover-color); + } + + nav button { + display: flex; + padding: 4px 8px; + flex-direction: column; + justify-content: center; + align-items: center; + flex: 1 0 0; + + ${FONT_XS}; + color: var(--affine-text-secondary-color); + font-weight: 600; + + border-radius: 8px; + background: transparent; + border: none; + } + + nav button[active] { + color: var(--affine-text-primary-color, #121212); + background: var(--affine-background-primary-color); + box-shadow: var(--affine-shadow-1); + pointer-events: none; + } + + .modes { + display: none; + gap: 8px; + align-self: stretch; + } + .modes[active] { + display: flex; + } + + .modes .mode { + display: flex; + padding: 2px; + flex-direction: column; + flex: 1 0 0; + } + + .modes .mode button { + position: relative; + display: flex; + height: 60px; + padding: 12px 12px 8px; + flex-direction: column; + justify-content: center; + align-items: center; + gap: 4px; + align-self: stretch; + border-radius: 8px; + border: 1px solid var(--affine-border-color); + box-sizing: border-box; + + ${FONT_XS}; + font-weight: 400; + color: #8e8d91; + } + .modes .mode.light button { + background: white; + } + .modes .mode.dark button { + background: #141414; + } + .modes .mode button .color { + background: var(--c); + flex-shrink: 0; + width: 22px; + height: 22px; + border-radius: 50%; + overflow: hidden; + } + .modes .mode button[active] { + pointer-events: none; + outline: 2px solid var(--affine-brand-color, #1e96eb); + } + + .content { + display: flex; + flex-direction: column; + gap: 16px; + } + + .color-palette-wrapper { + position: relative; + width: 100%; + height: 170px; + } + + .color-palette-wrapper canvas { + position: absolute; + width: 100%; + height: 100%; + border-radius: 8px; + } + .color-palette-wrapper::after { + content: ''; + position: absolute; + top: 0; + right: 0; + left: 0; + bottom: 0; + border: 1px solid rgba(0, 0, 0, 0.1); + box-sizing: border-box; + border-radius: 8px; + overflow: hidden; + pointer-events: none; + } + + .color-circle { + position: absolute; + display: flex; + align-items: center; + justify-content: center; + width: var(--size); + height: var(--size); + left: calc(-1 * var(--size) / 2); + transform: translate(var(--x, 0), var(--y, 0)); + background: transparent; + border: 0.5px solid #e3e2e4; + border-radius: 50%; + box-sizing: border-box; + box-shadow: 0px 0px 0px 0.5px #e3e3e4 inset; + filter: drop-shadow(0px 0px 12px rgba(66, 65, 73, 0.14)); + pointer-events: none; + z-index: 2; + } + .color-circle::before { + content: ''; + position: absolute; + width: 16px; + height: 16px; + border-radius: 50%; + background: var(--c); + box-sizing: border-box; + } + .color-circle::after { + content: ''; + position: absolute; + width: calc(var(--size) - 1px); + height: calc(var(--size) - 1px); + background: transparent; + border-style: solid; + border-color: white; + border-radius: 50%; + box-sizing: border-box; + } + + .color-palette-wrapper { + --size: calc(var(--r, 12.5px) * 2); + } + .color-palette-wrapper .color-circle { + top: calc(-1 * var(--size) / 2); + } + .color-palette-wrapper .color-circle::before { + opacity: var(--o, 1); + } + .color-palette-wrapper .color-circle::after { + border-width: 4px; + } + .color-palette, + .color-slider { + position: absolute; + inset: calc(-1 * var(--size) / 2); + } + + .color-slider-wrapper:last-of-type { + margin-bottom: 12px; + } + + .color-slider-wrapper { + display: flex; + align-items: center; + position: relative; + width: 100%; + height: 12px; + } + .color-slider-wrapper::before { + content: ''; + position: absolute; + top: 0; + right: 0; + left: 0; + bottom: 0; + border-radius: 12px; + overflow: hidden; + } + .color-slider-wrapper { + --size: calc(var(--r, 10.5px) * 2); + } + .color-slider-wrapper .color-circle::after { + border-width: 2px; + } + .color-slider-wrapper.hue::before { + background: linear-gradient( + to right, + #f00 0%, + #ff0 calc(100% / 6), + #0f0 calc(200% / 6), + #0ff 50%, + #00f calc(400% / 6), + #f0f calc(500% / 6), + #f00 100% + ); + } + .color-slider-wrapper.alpha::before { + background: + linear-gradient(to right, var(--s) 0%, var(--c) 100%), + conic-gradient( + #fff 25%, + #d9d9d9 0deg, + #d9d9d9 50%, + #fff 0deg, + #fff 75%, + #d9d9d9 0deg + ) + 0% 0% / 8px 8px; + } + .color-slider-wrapper.alpha .color-circle::before { + opacity: var(--o, 1); + } + + footer { + display: flex; + justify-content: space-between; + } + + .field { + display: flex; + padding: 7px 9px; + align-items: center; + gap: 4px; + border-radius: 8px; + border: 1px solid var(--affine-border-color); + background: var(--affine-background-primary-color); + box-sizing: border-box; + } + + .field.color { + width: 132px; + } + + .field.alpha { + width: 58px; + gap: 0; + } + + input { + display: flex; + width: 100%; + padding: 0; + background: transparent; + border: none; + outline: none; + ${FONT_SM}; + font-weight: 400; + color: var(--affine-text-primary-color); + } + + /* Chrome, Safari, Edge, Opera */ + input::-webkit-outer-spin-button, + input::-webkit-inner-spin-button { + -webkit-appearance: none; + margin: 0; + } + /* Firefox */ + input[type='number'] { + -moz-appearance: textfield; + } +`; diff --git a/blocksuite/blocks/src/root-block/edgeless/components/color-picker/types.ts b/blocksuite/blocks/src/root-block/edgeless/components/color-picker/types.ts new file mode 100644 index 0000000000..820a6306cf --- /dev/null +++ b/blocksuite/blocks/src/root-block/edgeless/components/color-picker/types.ts @@ -0,0 +1,55 @@ +// https://www.w3.org/TR/css-color-4/ + +import type { ColorScheme } from '@blocksuite/affine-model'; + +// Red, green, blue. All in the range [0, 1]. +export type Rgb = { + // red 0-1 + r: number; + // green 0-1 + g: number; + // blue 0-1 + b: number; +}; + +// Red, green, blue, alpha. All in the range [0, 1]. +export type Rgba = Rgb & { + // alpha 0-1 + a: number; +}; + +// Hue, saturation, value. All in the range [0, 1]. +export type Hsv = { + // hue 0-1 + h: number; + // saturation 0-1 + s: number; + // value 0-1 + v: number; +}; + +// Hue, saturation, value, alpha. All in the range [0, 1]. +export type Hsva = Hsv & { + // alpha 0-1 + a: number; +}; + +export type Point = { x: number; y: number }; + +export type NavType = 'colors' | 'custom'; + +export type NavTab<Type> = { type: Type; name: string }; + +export type ModeType = 'normal' | `${ColorScheme}`; + +export type ModeTab<Type> = NavTab<Type> & { hsva: Hsva }; + +export type ModeRgba = { type: ModeType; rgba: Rgba }; + +export type PickColorType = 'palette' | ModeType; + +export type PickColorDetail = Partial<Record<PickColorType, string>>; + +export type PickColorEvent = + | { type: 'start' | 'end' } + | { type: 'pick'; detail: PickColorDetail }; diff --git a/blocksuite/blocks/src/root-block/edgeless/components/color-picker/utils.ts b/blocksuite/blocks/src/root-block/edgeless/components/color-picker/utils.ts new file mode 100644 index 0000000000..3d03f55f1c --- /dev/null +++ b/blocksuite/blocks/src/root-block/edgeless/components/color-picker/utils.ts @@ -0,0 +1,308 @@ +// https://www.w3.org/TR/css-color-4/ + +import type { Color, ColorScheme } from '@blocksuite/affine-model'; + +import { COLORS, FIRST_COLOR } from './consts.js'; +import type { + Hsv, + Hsva, + ModeType, + PickColorDetail, + PickColorType, + Point, + Rgb, + Rgba, +} from './types.js'; + +export const defaultPoint = (x = 0, y = 0): Point => ({ x, y }); + +export const defaultHsva = (): Hsva => ({ ...rgbToHsv(FIRST_COLOR), a: 1 }); + +export function linearGradientAt(t: number): Rgb { + if (t < 0) return COLORS[0][0]; + if (t > 1) return COLORS[COLORS.length - 1][0]; + + let low = 0; + let high = COLORS.length; + while (low < high) { + const mid = Math.floor((low + high) / 2); + const color = COLORS[mid]; + if (color[1] < t) { + low = mid + 1; + } else { + high = mid; + } + } + + if (low === 0) { + low = 1; + } + + const [rgb0, s0] = COLORS[low - 1]; + const [rgb1, s1] = COLORS[low]; + t = (t - s0) / (s1 - s0); + + const [r, g, b] = [ + lerp(rgb0.r, rgb1.r, t), + lerp(rgb0.g, rgb1.g, t), + lerp(rgb0.b, rgb1.b, t), + ]; + + return { r, g, b }; +} + +const lerp = (a: number, b: number, t: number) => a + t * (b - a); + +export const clamp = (min: number, val: number, max: number) => + Math.min(Math.max(min, val), max); + +export const bound01 = (n: number, max: number) => { + n = clamp(0, n, max); + + // Handle floating point rounding errors + if (Math.abs(n - max) < 0.000001) { + return 1; + } + + // Convert into [0, 1] range if it isn't already + return (n % max) / max; +}; + +// Converts an RGB color value to HSV +export const rgbToHsv = ({ r, g, b }: Rgb): Hsv => { + const v = Math.max(r, g, b); // value + const d = v - Math.min(r, g, b); + + if (d === 0) { + return { h: 0, s: 0, v }; + } + + const s = d / v; + let h = 0; + + if (v === r) { + h = (g - b) / d + (g < b ? 6 : 0); + } else if (v === g) { + h = (b - r) / d + 2; + } else { + h = (r - g) / d + 4; + } + + h /= 6; + + return { h, s, v }; +}; + +// Converts an HSV color value to RGB +export const hsvToRgb = ({ h, s, v }: Hsv): Rgb => { + if (h < 0) h = (h + 1) % 1; // wrap + h *= 6; + s = clamp(0, s, 1); + + const i = Math.floor(h), + f = h - i, + p = v * (1 - s), + q = v * (1 - f * s), + t = v * (1 - (1 - f) * s), + m = i % 6; + + let rgb = [0, 0, 0]; + + if (m === 0) rgb = [v, t, p]; + else if (m === 1) rgb = [q, v, p]; + else if (m === 2) rgb = [p, v, t]; + else if (m === 3) rgb = [p, q, v]; + else if (m === 4) rgb = [t, p, v]; + else if (m === 5) rgb = [v, p, q]; + + const [r, g, b] = rgb; + + return { r, g, b }; +}; + +// Converts a RGBA color value to HSVA +export const rgbaToHsva = (rgba: Rgba): Hsva => ({ + ...rgbToHsv(rgba), + a: rgba.a, +}); + +// Converts an HSVA color value to RGBA +export const hsvaToRgba = (hsva: Hsva): Rgba => ({ + ...hsvToRgb(hsva), + a: hsva.a, +}); + +// Converts a RGB color to hex +export const rgbToHex = ({ r, g, b }: Rgb) => + [r, g, b] + .map(n => n * 255) + .map(Math.round) + .map(s => s.toString(16).padStart(2, '0')) + .join(''); + +// Converts an RGBA color to CSS's hex8 string +export const rgbaToHex8 = ({ r, g, b, a }: Rgba) => { + const hex = [r, g, b, a] + .map(n => n * 255) + .map(Math.round) + .map(n => n.toString(16).padStart(2, '0')) + .join(''); + return `#${hex}`; +}; + +// Converts an HSVA color to CSS's hex8 string +export const hsvaToHex8 = (hsva: Hsva) => rgbaToHex8(hsvaToRgba(hsva)); + +// Parses an hex string to RGBA. +export const parseHexToRgba = (hex: string) => { + if (hex.startsWith('#')) { + hex = hex.substring(1); + } + + const len = hex.length; + let arr: string[] = []; + + if (len === 3 || len === 4) { + arr = hex.split('').map(s => s.repeat(2)); + } else if (len === 6 || len === 8) { + arr = Array.from<number>({ length: len / 2 }) + .fill(0) + .map((n, i) => n + i * 2) + .map(n => hex.substring(n, n + 2)); + } + + const [r, g, b, a = 1] = arr + .map(s => parseInt(s, 16)) + .map(n => bound01(n, 255)); + + return { r, g, b, a }; +}; + +// Parses an hex string to HSVA +export const parseHexToHsva = (hex: string) => rgbaToHsva(parseHexToRgba(hex)); + +// Compares two hsvs. +export const eq = (lhs: Hsv, rhs: Hsv) => + lhs.h === rhs.h && lhs.s === rhs.s && lhs.v === rhs.v; + +export const renderCanvas = (canvas: HTMLCanvasElement, rgb: Rgb) => { + const { width, height } = canvas; + const ctx = canvas.getContext('2d')!; + + ctx.globalCompositeOperation = 'color'; + ctx.clearRect(0, 0, width, height); + + // Saturation: from top to bottom + const s = ctx.createLinearGradient(0, 0, 0, height); + s.addColorStop(0, '#0000'); // transparent + s.addColorStop(1, '#000'); // black + + ctx.fillStyle = s; + ctx.fillRect(0, 0, width, height); + + // Value: from left to right + const v = ctx.createLinearGradient(0, 0, width, 0); + v.addColorStop(0, '#fff'); // white + v.addColorStop(1, `#${rgbToHex(rgb)}`); // picked color + + ctx.fillStyle = v; + ctx.fillRect(0, 0, width, height); +}; + +// Drops alpha value +export const keepColor = (color: string) => + color.length > 7 && !color.endsWith('transparent') + ? color.substring(0, 7) + : color; + +export const parseStringToRgba = (value: string) => { + value = value.trim(); + + // Compatible old format: `--affine-palette-transparent` + if (value.endsWith('transparent')) { + return { r: 1, g: 1, b: 1, a: 0 }; + } + + if (value.startsWith('#')) { + return parseHexToRgba(value); + } + + if (value.startsWith('rgb')) { + const [r, g, b, a = 1] = value + .replace(/^rgba?/, '') + .replace(/\(|\)/, '') + .split(',') + .map(s => parseFloat(s.trim())) + // In CSS, the alpha is already in the range [0, 1] + .map((n, i) => bound01(n, i === 3 ? 1 : 255)); + + return { r, g, b, a }; + } + + return { r: 0, g: 0, b: 0, a: 1 }; +}; + +// Preprocess Color +export const preprocessColor = (style: CSSStyleDeclaration) => { + return ({ type, value }: { type: ModeType; value: string }) => { + if (value.startsWith('--')) { + // Compatible old format: `--affine-palette-transparent` + value = value.endsWith('transparent') + ? 'transparent' + : style.getPropertyValue(value); + } + + const rgba = parseStringToRgba(value); + + return { type, rgba }; + }; +}; + +/** + * Packs to generate an object with a field name and picked color detail + * + * @param key - The model's field name + * @param detail - The picked color detail + * @returns An object + * + * @example + * + * ```json + * { 'fillColor': '--affine-palette-shape-yellow' } + * { 'fillColor': { normal: '#ffffffff' }} + * { 'fillColor': { light: '#fff000ff', 'dark': '#0000fff00' }} + * ``` + */ +export const packColor = (key: string, detail: PickColorDetail) => { + return { [key]: detail.palette ?? detail }; +}; + +/** + * Packs to generate a color array with the color-scheme + * + * @param colorScheme - The current color theme + * @param value - The color value + * @param oldColor - The old color + * @returns A color array + */ +export const packColorsWithColorScheme = ( + colorScheme: ColorScheme, + value: string, + oldColor: Color +) => { + const colors: { type: ModeType; value: string }[] = [ + { type: 'normal', value }, + { type: 'light', value }, + { type: 'dark', value }, + ]; + let type: PickColorType = 'palette'; + + if (typeof oldColor === 'object') { + type = colorScheme in oldColor ? colorScheme : 'normal'; + colors[0].value = oldColor.normal ?? value; + colors[1].value = oldColor.light ?? value; + colors[2].value = oldColor.dark ?? value; + } + + return { type, colors }; +}; diff --git a/blocksuite/blocks/src/root-block/edgeless/components/connector/connector-handle.ts b/blocksuite/blocks/src/root-block/edgeless/components/connector/connector-handle.ts new file mode 100644 index 0000000000..284fe85fc2 --- /dev/null +++ b/blocksuite/blocks/src/root-block/edgeless/components/connector/connector-handle.ts @@ -0,0 +1,168 @@ +import { + type ConnectionOverlay, + OverlayIdentifier, +} from '@blocksuite/affine-block-surface'; +import type { ConnectorElementModel } from '@blocksuite/affine-model'; +import { + type BlockStdScope, + docContext, + stdContext, +} from '@blocksuite/block-std'; +import { GfxControllerIdentifier } from '@blocksuite/block-std/gfx'; +import { DisposableGroup, Vec, WithDisposable } from '@blocksuite/global/utils'; +import type { Doc } from '@blocksuite/store'; +import { consume } from '@lit/context'; +import { css, html, LitElement } from 'lit'; +import { property, query } from 'lit/decorators.js'; +import { styleMap } from 'lit/directives/style-map.js'; + +import type { EdgelessRootBlockComponent } from '../../edgeless-root-block.js'; + +const SIZE = 12; +const HALF_SIZE = SIZE / 2; + +export class EdgelessConnectorHandle extends WithDisposable(LitElement) { + static override styles = css` + .line-controller { + position: absolute; + width: ${SIZE}px; + height: ${SIZE}px; + box-sizing: border-box; + border-radius: 50%; + border: 2px solid var(--affine-text-emphasis-color); + background-color: var(--affine-background-primary-color); + cursor: pointer; + z-index: 10; + pointer-events: all; + /** + * Fix: pointerEvent stops firing after a short time. + * When a gesture is started, the browser intersects the touch-action values of the touched element and its ancestors, + * up to the one that implements the gesture (in other words, the first containing scrolling element) + * https://developer.mozilla.org/en-US/docs/Web/CSS/touch-action + */ + touch-action: none; + } + .line-controller-hidden { + display: none; + } + `; + + private _lastZoom = 1; + + get connectionOverlay() { + return this.std.get(OverlayIdentifier('connection')) as ConnectionOverlay; + } + + get gfx() { + return this.std.get(GfxControllerIdentifier); + } + + private _bindEvent() { + const edgeless = this.edgeless; + + this._disposables.addFromEvent(this._startHandler, 'pointerdown', e => { + edgeless.slots.elementResizeStart.emit(); + this._capPointerDown(e, 'source'); + }); + this._disposables.addFromEvent(this._endHandler, 'pointerdown', e => { + edgeless.slots.elementResizeStart.emit(); + this._capPointerDown(e, 'target'); + }); + this._disposables.add(() => { + this.connectionOverlay.clear(); + }); + } + + private _capPointerDown(e: PointerEvent, connection: 'target' | 'source') { + const { edgeless, connector, _disposables } = this; + const { service } = edgeless; + e.stopPropagation(); + _disposables.addFromEvent(document, 'pointermove', e => { + const point = service.viewport.toModelCoordFromClientCoord([e.x, e.y]); + const isStartPointer = connection === 'source'; + const otherSideId = connector[isStartPointer ? 'target' : 'source'].id; + + connector[connection] = this.connectionOverlay.renderConnector( + point, + otherSideId ? [otherSideId] : [] + ); + this.requestUpdate(); + }); + + _disposables.addFromEvent(document, 'pointerup', () => { + this.doc.captureSync(); + _disposables.dispose(); + this._disposables = new DisposableGroup(); + this._bindEvent(); + edgeless.slots.elementResizeEnd.emit(); + }); + } + + override firstUpdated() { + const { edgeless } = this; + const { viewport } = edgeless.service; + + this._lastZoom = viewport.zoom; + edgeless.service.viewport.viewportUpdated.on(() => { + if (viewport.zoom !== this._lastZoom) { + this._lastZoom = viewport.zoom; + this.requestUpdate(); + } + }); + + this._bindEvent(); + } + + override render() { + const { service } = this.edgeless; + // path is relative to the element's xywh + const { path } = this.connector; + const zoom = service.viewport.zoom; + const startPoint = Vec.subScalar(Vec.mul(path[0], zoom), HALF_SIZE); + const endPoint = Vec.subScalar( + Vec.mul(path[path.length - 1], zoom), + HALF_SIZE + ); + const startStyle = { + transform: `translate3d(${startPoint[0]}px,${startPoint[1]}px,0)`, + }; + const endStyle = { + transform: `translate3d(${endPoint[0]}px,${endPoint[1]}px,0)`, + }; + return html` + <div + class="line-controller line-start" + style=${styleMap(startStyle)} + ></div> + <div class="line-controller line-end" style=${styleMap(endStyle)}></div> + `; + } + + @query('.line-end') + private accessor _endHandler!: HTMLDivElement; + + @query('.line-start') + private accessor _startHandler!: HTMLDivElement; + + @property({ attribute: false }) + accessor connector!: ConnectorElementModel; + + @consume({ + context: docContext, + }) + accessor doc!: Doc; + + @property({ attribute: false }) + accessor edgeless!: EdgelessRootBlockComponent; + + @consume({ + context: stdContext, + }) + accessor std!: BlockStdScope; +} + +declare global { + interface HTMLElementTagNameMap { + 'edgeless-connector-handle': EdgelessConnectorHandle; + } +} diff --git a/blocksuite/blocks/src/root-block/edgeless/components/frame/frame-preview.ts b/blocksuite/blocks/src/root-block/edgeless/components/frame/frame-preview.ts new file mode 100644 index 0000000000..e583a65074 --- /dev/null +++ b/blocksuite/blocks/src/root-block/edgeless/components/frame/frame-preview.ts @@ -0,0 +1,246 @@ +import type { FrameBlockModel } from '@blocksuite/affine-model'; +import { + BlockServiceWatcher, + BlockStdScope, + type EditorHost, + ShadowlessElement, +} from '@blocksuite/block-std'; +import { + Bound, + debounce, + deserializeXYWH, + DisposableGroup, + WithDisposable, +} from '@blocksuite/global/utils'; +import { BlockViewType, type Doc, type Query } from '@blocksuite/store'; +import { css, html, nothing, type PropertyValues } from 'lit'; +import { property, query, state } from 'lit/decorators.js'; +import { styleMap } from 'lit/directives/style-map.js'; + +import { SpecProvider } from '../../../../_specs/index.js'; +import type { EdgelessRootPreviewBlockComponent } from '../../edgeless-root-preview-block.js'; +import type { EdgelessRootService } from '../../edgeless-root-service.js'; + +const DEFAULT_PREVIEW_CONTAINER_WIDTH = 280; +const DEFAULT_PREVIEW_CONTAINER_HEIGHT = 166; + +const styles = css` + .frame-preview-container { + display: block; + width: 100%; + height: 100%; + box-sizing: border-box; + position: relative; + } + + .frame-preview-surface-container { + position: relative; + display: flex; + align-items: center; + justify-content: center; + box-sizing: border-box; + overflow: hidden; + } + + .frame-preview-viewport { + max-width: 100%; + box-sizing: border-box; + margin: 0 auto; + position: relative; + overflow: hidden; + pointer-events: none; + user-select: none; + + .edgeless-background { + background-color: transparent; + background-image: none; + } + } +`; + +export class FramePreview extends WithDisposable(ShadowlessElement) { + static override styles = styles; + + private _clearFrameDisposables = () => { + this._frameDisposables?.dispose(); + this._frameDisposables = null; + }; + + private _docFilter: Query = { + mode: 'loose', + match: [ + { + flavour: 'affine:frame', + viewType: BlockViewType.Hidden, + }, + ], + }; + + private _frameDisposables: DisposableGroup | null = null; + + private _previewDoc: Doc | null = null; + + private _previewSpec = SpecProvider.getInstance().getSpec('edgeless:preview'); + + private _updateFrameViewportWH = () => { + const [, , w, h] = deserializeXYWH(this.frame.xywh); + + let scale = 1; + if (this.fillScreen) { + scale = Math.max(this.surfaceWidth / w, this.surfaceHeight / h); + } else { + scale = Math.min(this.surfaceWidth / w, this.surfaceHeight / h); + } + + this.frameViewportWH = { + width: w * scale, + height: h * scale, + }; + }; + + get _originalDoc() { + return this.frame.doc; + } + + private _initPreviewDoc() { + this._previewDoc = this._originalDoc.collection.getDoc( + this._originalDoc.id, + { + query: this._docFilter, + readonly: true, + } + ); + this.disposables.add(() => { + this._originalDoc.blockCollection.clearQuery(this._docFilter); + }); + } + + private _initSpec() { + const refreshViewport = this._refreshViewport.bind(this); + class FramePreviewWatcher extends BlockServiceWatcher { + static override readonly flavour = 'affine:page'; + + override mounted() { + const blockService = this.blockService; + blockService.disposables.add( + blockService.specSlots.viewConnected.on(({ component }) => { + const edgelessBlock = + component as EdgelessRootPreviewBlockComponent; + + edgelessBlock.editorViewportSelector = 'frame-preview-viewport'; + edgelessBlock.service.viewport.sizeUpdated.once(() => { + refreshViewport(); + }); + }) + ); + } + } + this._previewSpec.extend([FramePreviewWatcher]); + } + + private _refreshViewport() { + const previewEditorHost = this.previewEditor; + + if (!previewEditorHost) return; + + const edgelessService = previewEditorHost.std.getService( + 'affine:page' + ) as EdgelessRootService; + + const frameBound = Bound.deserialize(this.frame.xywh); + edgelessService.viewport.setViewportByBound(frameBound); + } + + private _renderSurfaceContent() { + if (!this._previewDoc || !this.frame) return nothing; + const { width, height } = this.frameViewportWH; + + const _previewSpec = this._previewSpec.value; + return html`<div + class="frame-preview-surface-container" + style=${styleMap({ + width: `${this.surfaceWidth}px`, + height: `${this.surfaceHeight}px`, + })} + > + <div + class="frame-preview-viewport" + style=${styleMap({ + width: `${width}px`, + height: `${height}px`, + })} + > + ${new BlockStdScope({ + doc: this._previewDoc, + extensions: _previewSpec, + }).render()} + </div> + </div>`; + } + + private _setFrameDisposables(frame: FrameBlockModel) { + this._clearFrameDisposables(); + this._frameDisposables = new DisposableGroup(); + this._frameDisposables.add( + frame.propsUpdated.on(debounce(this._updateFrameViewportWH, 10)) + ); + } + + override connectedCallback() { + super.connectedCallback(); + this._initSpec(); + this._initPreviewDoc(); + this._updateFrameViewportWH(); + this._setFrameDisposables(this.frame); + } + + override disconnectedCallback() { + super.disconnectedCallback(); + this._clearFrameDisposables(); + } + + override render() { + const { frame } = this; + const noContent = !frame || !frame.xywh; + + return html`<div class="frame-preview-container"> + ${noContent ? nothing : this._renderSurfaceContent()} + </div>`; + } + + override updated(_changedProperties: PropertyValues) { + if (_changedProperties.has('frame')) { + this._setFrameDisposables(this.frame); + } + if (_changedProperties.has('frameViewportWH')) { + this._refreshViewport(); + } + } + + @state() + accessor fillScreen = false; + + @property({ attribute: false }) + accessor frame!: FrameBlockModel; + + @state() + accessor frameViewportWH = { + width: 0, + height: 0, + }; + + @query('editor-host') + accessor previewEditor: EditorHost | null = null; + + @property({ attribute: false }) + accessor surfaceHeight: number = DEFAULT_PREVIEW_CONTAINER_HEIGHT; + + @property({ attribute: false }) + accessor surfaceWidth: number = DEFAULT_PREVIEW_CONTAINER_WIDTH; +} + +declare global { + interface HTMLElementTagNameMap { + 'frame-preview': FramePreview; + } +} diff --git a/blocksuite/blocks/src/root-block/edgeless/components/note-slicer/index.ts b/blocksuite/blocks/src/root-block/edgeless/components/note-slicer/index.ts new file mode 100644 index 0000000000..558df7bafe --- /dev/null +++ b/blocksuite/blocks/src/root-block/edgeless/components/note-slicer/index.ts @@ -0,0 +1,446 @@ +import { SmallScissorsIcon } from '@blocksuite/affine-components/icons'; +import { DEFAULT_NOTE_HEIGHT } from '@blocksuite/affine-model'; +import { EDGELESS_BLOCK_CHILD_PADDING } from '@blocksuite/affine-shared/consts'; +import { TelemetryProvider } from '@blocksuite/affine-shared/services'; +import { getRectByBlockComponent } from '@blocksuite/affine-shared/utils'; +import { WidgetComponent } from '@blocksuite/block-std'; +import { GfxControllerIdentifier } from '@blocksuite/block-std/gfx'; +import { + deserializeXYWH, + DisposableGroup, + Point, + serializeXYWH, +} from '@blocksuite/global/utils'; +import { css, html, nothing, type PropertyValues } from 'lit'; +import { state } from 'lit/decorators.js'; +import { classMap } from 'lit/directives/class-map.js'; +import { styleMap } from 'lit/directives/style-map.js'; + +import type { + EdgelessRootBlockComponent, + NoteBlockComponent, + NoteBlockModel, + RootBlockModel, +} from '../../../../index.js'; +import { isNoteBlock } from '../../utils/query.js'; + +const DIVIDING_LINE_OFFSET = 4; +const NEW_NOTE_GAP = 40; + +const styles = css` + :host { + display: flex; + } + + .note-slicer-container { + display: flex; + } + + .note-slicer-button { + position: absolute; + top: 0; + right: 0; + display: flex; + box-sizing: border-box; + border-radius: 4px; + justify-content: center; + align-items: center; + color: var(--affine-icon-color); + border: 1px solid var(--affine-border-color); + background-color: var(--affine-background-overlay-panel-color); + box-shadow: var(--affine-menu-shadow); + cursor: pointer; + width: 24px; + height: 24px; + transform-origin: left top; + z-index: var(--affine-z-index-popover); + opacity: 0; + transition: opacity 150ms cubic-bezier(0.25, 0.1, 0.25, 1); + } + + .note-slicer-dividing-line-container { + display: flex; + align-items: center; + position: absolute; + left: 0; + top: 0; + height: 4px; + cursor: pointer; + } + + .note-slicer-dividing-line { + display: block; + height: 1px; + width: 100%; + z-index: var(--affine-z-index-popover); + background-image: linear-gradient( + to right, + var(--affine-black-10) 50%, + transparent 50% + ); + background-size: 4px 100%; + } + .note-slicer-dividing-line-container.active .note-slicer-dividing-line { + background-image: linear-gradient( + to right, + var(--affine-black-60) 50%, + transparent 50% + ); + animation: slide 0.3s linear infinite; + } + @keyframes slide { + 0% { + background-position: 0 0; + } + 100% { + background-position: -4px 0; + } + } +`; + +export const NOTE_SLICER_WIDGET = 'note-slicer'; + +export class NoteSlicer extends WidgetComponent< + RootBlockModel, + EdgelessRootBlockComponent +> { + static override styles = styles; + + private _divingLinePositions: Point[] = []; + + private _hidden = false; + + private _noteBlockIds: string[] = []; + + private _noteDisposables: DisposableGroup | null = null; + + get _editorHost() { + return this.std.host; + } + + get _noteBlock() { + if (!this._editorHost) return null; + const noteBlock = this._editorHost.view.getBlock( + this._anchorNote?.id ?? '' + ); + return noteBlock ? (noteBlock as NoteBlockComponent) : null; + } + + get _selection() { + return this.gfx.selection; + } + + get _viewportOffset() { + const { viewport } = this.gfx; + return { + left: viewport.left ?? 0, + top: viewport.top ?? 0, + }; + } + + get _zoom() { + return this.gfx.viewport.zoom; + } + + get gfx() { + return this.std.get(GfxControllerIdentifier); + } + + get selectedRectEle() { + return this.block.selectedRectWidget; + } + + private _sliceNote() { + if (!this._anchorNote || !this._noteBlockIds.length) return; + const doc = this.doc; + + const { + index: originIndex, + xywh, + background, + children, + displayMode, + } = this._anchorNote; + const { + collapse: _, + collapsedHeight: __, + ...restOfEdgeless + } = this._anchorNote.edgeless; + const anchorBlockId = this._noteBlockIds[this._activeSlicerIndex]; + if (!anchorBlockId) return; + const sliceIndex = children.findIndex(block => block.id === anchorBlockId); + const resetBlocks = children.slice(sliceIndex + 1); + const [x, , width] = deserializeXYWH(xywh); + const sliceVerticalPos = + this._divingLinePositions[this._activeSlicerIndex].y; + const newY = this.gfx.viewport.toModelCoord(x, sliceVerticalPos)[1]; + const newNoteId = this.doc.addBlock( + 'affine:note', + { + background, + displayMode, + xywh: serializeXYWH(x, newY + NEW_NOTE_GAP, width, DEFAULT_NOTE_HEIGHT), + index: originIndex + 1, + edgeless: restOfEdgeless, + }, + doc.root?.id + ); + + doc.moveBlocks(resetBlocks, doc.getBlockById(newNoteId) as NoteBlockModel); + + this._activeSlicerIndex = 0; + this._selection.set({ + elements: [newNoteId], + editing: false, + }); + + this.std.getOptional(TelemetryProvider)?.track('SplitNote', { + control: 'NoteSlicer', + }); + } + + private _updateActiveSlicerIndex(pos: Point) { + const { _divingLinePositions } = this; + const curY = pos.y + DIVIDING_LINE_OFFSET * this._zoom; + let index = -1; + for (let i = 0; i < _divingLinePositions.length; i++) { + const currentY = _divingLinePositions[i].y; + const previousY = i > 0 ? _divingLinePositions[i - 1].y : 0; + const midY = (currentY + previousY) / 2; + if (curY < midY) { + break; + } + index++; + } + + if (index < 0) index = 0; + this._activeSlicerIndex = index; + } + + private _updateDivingLineAndBlockIds() { + if (!this._anchorNote || !this._noteBlock) { + this._divingLinePositions = []; + this._noteBlockIds = []; + return; + } + + const divingLinePositions: Point[] = []; + const noteBlockIds: string[] = []; + const noteRect = this._noteBlock.getBoundingClientRect(); + const noteTop = noteRect.top; + const noteBottom = noteRect.bottom; + + for (let i = 0; i < this._anchorNote.children.length - 1; i++) { + const child = this._anchorNote.children[i]; + const rect = this.host.view.getBlock(child.id)?.getBoundingClientRect(); + + if (rect && rect.bottom > noteTop && rect.bottom < noteBottom) { + const x = rect.x - this._viewportOffset.left; + const y = + rect.bottom + + DIVIDING_LINE_OFFSET * this._zoom - + this._viewportOffset.top; + divingLinePositions.push(new Point(x, y)); + noteBlockIds.push(child.id); + } + } + + this._divingLinePositions = divingLinePositions; + this._noteBlockIds = noteBlockIds; + } + + private _updateSlicedNote() { + const { selectedElements } = this.gfx.selection; + + if ( + !this.gfx.selection.editing && + selectedElements.length === 1 && + isNoteBlock(selectedElements[0]) + ) { + this._anchorNote = selectedElements[0]; + } else { + this._anchorNote = null; + } + } + + override connectedCallback(): void { + super.connectedCallback(); + + const { disposables, std, block, gfx } = this; + + this._updateDivingLineAndBlockIds(); + + disposables.add( + block.slots.elementResizeStart.on(() => { + this._isResizing = true; + }) + ); + + disposables.add( + block.slots.elementResizeEnd.on(() => { + this._isResizing = false; + }) + ); + + disposables.add( + std.event.add('pointerMove', ctx => { + if (this._hidden) this._hidden = false; + + const state = ctx.get('pointerState'); + const pos = new Point(state.x, state.y); + this._updateActiveSlicerIndex(pos); + }) + ); + + disposables.add( + gfx.viewport.viewportUpdated.on(() => { + this._hidden = true; + this.requestUpdate(); + }) + ); + + disposables.add( + gfx.selection.slots.updated.on(() => { + this._enableNoteSlicer = false; + this._updateSlicedNote(); + + if (this.selectedRectEle) { + this.selectedRectEle.autoCompleteOff = false; + } + }) + ); + + disposables.add( + block.slots.toggleNoteSlicer.on(() => { + this._enableNoteSlicer = !this._enableNoteSlicer; + + if (this.selectedRectEle && this._enableNoteSlicer) { + this.selectedRectEle.autoCompleteOff = true; + } + }) + ); + + const { surface } = block; + requestAnimationFrame(() => { + if (surface.isConnected && std.event) { + disposables.add( + std.event.add('click', ctx => { + const event = ctx.get('pointerState'); + const { raw } = event; + const target = raw.target as HTMLElement; + if (!target) return; + + if (target.closest('note-slicer')) { + this._sliceNote(); + } + }) + ); + } + }); + } + + override disconnectedCallback(): void { + super.disconnectedCallback(); + this.disposables.dispose(); + this._noteDisposables?.dispose(); + this._noteDisposables = null; + } + + override firstUpdated() { + if (!this.block.service) return; + this.disposables.add( + this.block.service.uiEventDispatcher.add('wheel', () => { + this._hidden = true; + this.requestUpdate(); + }) + ); + } + + override render() { + if ( + this.doc.readonly || + this._hidden || + this._isResizing || + !this._anchorNote || + !this._enableNoteSlicer + ) { + return nothing; + } + + this._updateDivingLineAndBlockIds(); + + const noteBlock = this._noteBlock; + if (!noteBlock || !this._divingLinePositions.length) return nothing; + + const rect = getRectByBlockComponent(noteBlock); + const width = rect.width - 2 * EDGELESS_BLOCK_CHILD_PADDING; + const buttonPosition = this._divingLinePositions[this._activeSlicerIndex]; + + return html`<div class="note-slicer-container"> + <div + class="note-slicer-button" + style=${styleMap({ + left: `${buttonPosition.x - 66 * this._zoom}px`, + top: `${buttonPosition.y}px`, + opacity: 1, + scale: `${this._zoom}`, + transform: 'translateY(-50%)', + })} + > + ${SmallScissorsIcon} + </div> + ${this._divingLinePositions.map((pos, idx) => { + const dividingLineClasses = classMap({ + 'note-slicer-dividing-line-container': true, + active: idx === this._activeSlicerIndex, + }); + return html`<div + class=${dividingLineClasses} + style=${styleMap({ + left: `${pos.x}px`, + top: `${pos.y}px`, + width: `${width}px`, + })} + > + <span class="note-slicer-dividing-line"></span> + </div>`; + })} + </div> `; + } + + protected override updated(_changedProperties: PropertyValues) { + super.updated(_changedProperties); + if (_changedProperties.has('anchorNote')) { + this._noteDisposables?.dispose(); + this._noteDisposables = null; + if (this._anchorNote) { + this._noteDisposables = new DisposableGroup(); + this._noteDisposables.add( + this._anchorNote.propsUpdated.on(({ key }) => { + if (key === 'children' || key === 'xywh') { + this.requestUpdate(); + } + }) + ); + } + } + } + + @state() + private accessor _activeSlicerIndex = 0; + + @state() + private accessor _anchorNote: NoteBlockModel | null = null; + + @state() + private accessor _enableNoteSlicer = false; + + @state() + private accessor _isResizing = false; +} + +declare global { + interface HTMLElementTagNameMap { + 'note-slicer': NoteSlicer; + } +} diff --git a/blocksuite/blocks/src/root-block/edgeless/components/panel/align-panel.ts b/blocksuite/blocks/src/root-block/edgeless/components/panel/align-panel.ts new file mode 100644 index 0000000000..b70092b02e --- /dev/null +++ b/blocksuite/blocks/src/root-block/edgeless/components/panel/align-panel.ts @@ -0,0 +1,75 @@ +import { + TextAlignCenterIcon, + TextAlignLeftIcon, + TextAlignRightIcon, +} from '@blocksuite/affine-components/icons'; +import { TextAlign } from '@blocksuite/affine-model'; +import { css, html, LitElement } from 'lit'; +import { property } from 'lit/decorators.js'; +import { repeat } from 'lit/directives/repeat.js'; + +const TEXT_ALIGN_LIST = [ + { + name: 'Left', + value: TextAlign.Left, + icon: TextAlignLeftIcon, + }, + { + name: 'Center', + value: TextAlign.Center, + icon: TextAlignCenterIcon, + }, + { + name: 'Right', + value: TextAlign.Right, + icon: TextAlignRightIcon, + }, +]; + +export class EdgelessAlignPanel extends LitElement { + static override styles = css` + :host { + display: flex; + align-items: center; + justify-content: center; + gap: 8px; + } + `; + + private _onSelect(value: TextAlign) { + this.value = value; + if (this.onSelect) { + this.onSelect(value); + } + } + + override render() { + return repeat( + TEXT_ALIGN_LIST, + item => item.name, + ({ name, value, icon }) => html` + <edgeless-tool-icon-button + .activeMode=${'background'} + aria-label=${name} + .tooltip=${name} + .active=${this.value === value} + @click=${() => this._onSelect(value)} + > + ${icon} + </edgeless-tool-icon-button> + ` + ); + } + + @property({ attribute: false }) + accessor onSelect: undefined | ((value: TextAlign) => void) = undefined; + + @property({ attribute: false }) + accessor value: TextAlign = TextAlign.Left; +} + +declare global { + interface HTMLElementTagNameMap { + 'edgeless-align-panel': EdgelessAlignPanel; + } +} diff --git a/blocksuite/blocks/src/root-block/edgeless/components/panel/card-style-panel.ts b/blocksuite/blocks/src/root-block/edgeless/components/panel/card-style-panel.ts new file mode 100644 index 0000000000..2a803b542a --- /dev/null +++ b/blocksuite/blocks/src/root-block/edgeless/components/panel/card-style-panel.ts @@ -0,0 +1,72 @@ +import { WithDisposable } from '@blocksuite/global/utils'; +import { css, html, LitElement, nothing, type TemplateResult } from 'lit'; +import { property } from 'lit/decorators.js'; +import { classMap } from 'lit/directives/class-map.js'; +import { repeat } from 'lit/directives/repeat.js'; + +import type { EmbedCardStyle } from '../../../../_common/types.js'; + +export class CardStylePanel extends WithDisposable(LitElement) { + static override styles = css` + :host { + display: flex; + align-items: center; + justify-content: center; + gap: 8px; + } + + icon-button { + padding: var(--1, 0px); + justify-content: center; + } + + icon-button.selected { + border: 1px solid var(--affine-brand-color); + } + `; + + override render() { + const options = this.options; + if (!options?.length) return nothing; + + return repeat( + options, + options => options.style, + ({ style, Icon, tooltip }) => html` + <icon-button + width="76px" + height="76px" + class=${classMap({ + selected: this.value === style, + })} + @click=${() => { + this.onSelect(style); + this.value = style; + }} + > + ${Icon} + <affine-tooltip .offset=${4}>${tooltip}</affine-tooltip> + </icon-button> + ` + ); + } + + @property({ attribute: false }) + accessor onSelect!: (value: EmbedCardStyle) => void; + + @property({ attribute: false }) + accessor options!: { + style: EmbedCardStyle; + Icon: TemplateResult<1>; + tooltip: string; + }[]; + + @property({ attribute: false }) + accessor value: EmbedCardStyle | undefined = undefined; +} + +declare global { + interface HTMLElementTagNameMap { + 'card-style-panel': CardStylePanel; + } +} diff --git a/blocksuite/blocks/src/root-block/edgeless/components/panel/color-panel.ts b/blocksuite/blocks/src/root-block/edgeless/components/panel/color-panel.ts new file mode 100644 index 0000000000..04f48055fd --- /dev/null +++ b/blocksuite/blocks/src/root-block/edgeless/components/panel/color-panel.ts @@ -0,0 +1,371 @@ +import { TransparentIcon } from '@blocksuite/affine-components/icons'; +import { + ColorScheme, + LINE_COLORS, + LineColor, + NoteBackgroundColor, + ShapeFillColor, +} from '@blocksuite/affine-model'; +import { css, html, LitElement, nothing } from 'lit'; +import { property } from 'lit/decorators.js'; +import { repeat } from 'lit/directives/repeat.js'; +import { styleMap } from 'lit/directives/style-map.js'; + +export class ColorEvent extends Event { + detail: string; + + constructor( + type: string, + { + detail, + composed, + bubbles, + }: { detail: string; composed: boolean; bubbles: boolean } + ) { + super(type, { bubbles, composed }); + this.detail = detail; + } +} + +export const GET_DEFAULT_LINE_COLOR = (theme: ColorScheme) => { + return theme === ColorScheme.Dark ? LineColor.White : LineColor.Black; +}; + +export function isTransparent(color: string) { + return color.toLowerCase().endsWith('transparent'); +} + +function isSameColorWithBackground(color: string) { + const colors: string[] = [ + LineColor.Black, + LineColor.White, + NoteBackgroundColor.Black, + NoteBackgroundColor.White, + ShapeFillColor.Black, + ShapeFillColor.White, + ]; + return colors.includes(color.toLowerCase()); +} + +function TransparentColor(hollowCircle = false) { + const containerStyle = { + position: 'relative', + width: '16px', + height: '16px', + stroke: 'none', + }; + const maskStyle = { + position: 'absolute', + width: '10px', + height: '10px', + left: '3px', + top: '3.5px', + borderRadius: '50%', + background: 'var(--affine-background-overlay-panel-color)', + }; + + const mask = hollowCircle + ? html`<div style=${styleMap(maskStyle)}></div>` + : nothing; + + return html` + <div style=${styleMap(containerStyle)}>${TransparentIcon} ${mask}</div> + `; +} + +function BorderedHollowCircle(color: string) { + const valid = color.startsWith('--'); + const strokeWidth = valid && isSameColorWithBackground(color) ? 1 : 0; + const style = { + fill: valid ? `var(${color})` : color, + stroke: 'var(--affine-border-color)', + }; + return html` + <svg + width="16" + height="16" + viewBox="0 0 16 16" + fill="none" + xmlns="http://www.w3.org/2000/svg" + > + <path + d="M12.3125 8C12.3125 10.3817 10.3817 12.3125 8 12.3125C5.61827 12.3125 3.6875 10.3817 3.6875 8C3.6875 5.61827 5.61827 3.6875 8 3.6875C10.3817 3.6875 12.3125 5.61827 12.3125 8ZM8 15.5C12.1421 15.5 15.5 12.1421 15.5 8C15.5 3.85786 12.1421 0.5 8 0.5C3.85786 0.5 0.5 3.85786 0.5 8C0.5 12.1421 3.85786 15.5 8 15.5Z" + stroke-width="${strokeWidth}" + style=${styleMap(style)} + /> + </svg> + `; +} + +function AdditionIcon(color: string, hollowCircle: boolean) { + if (isTransparent(color)) { + return TransparentColor(hollowCircle); + } + if (hollowCircle) { + return BorderedHollowCircle(color); + } + return nothing; +} + +export function ColorUnit( + color: string, + { + hollowCircle, + letter, + }: { + hollowCircle?: boolean; + letter?: boolean; + } = {} +) { + const additionIcon = AdditionIcon(color, !!hollowCircle); + + const colorStyle = + !hollowCircle && !isTransparent(color) + ? { background: `var(${color})` } + : {}; + + const borderStyle = + isSameColorWithBackground(color) && !hollowCircle + ? { + border: '0.5px solid var(--affine-border-color)', + } + : {}; + + const style = { + width: '16px', + height: '16px', + borderRadius: '50%', + boxSizing: 'border-box', + overflow: 'hidden', + ...borderStyle, + ...colorStyle, + }; + + return html` + <div + class="color-unit" + style=${styleMap(style)} + aria-label=${color.toLowerCase()} + data-letter=${letter ? 'A' : ''} + > + ${additionIcon} + </div> + `; +} + +export class EdgelessColorButton extends LitElement { + static override styles = css` + :host { + display: flex; + justify-content: center; + align-items: center; + width: 20px; + height: 20px; + } + + .color-unit { + width: 16px; + height: 16px; + border-radius: 50%; + box-sizing: border-box; + overflow: hidden; + } + `; + + get preprocessColor() { + const color = this.color; + return color.startsWith('--') ? `var(${color})` : color; + } + + override render() { + const { color, hollowCircle, letter } = this; + const additionIcon = AdditionIcon(color, !!hollowCircle); + const style: Record<string, string> = {}; + if (!hollowCircle) { + style.background = this.preprocessColor; + if (isSameColorWithBackground(color)) { + style.border = '0.5px solid var(--affine-border-color)'; + } + } + return html`<div + class="color-unit" + aria-label=${color.toLowerCase()} + data-letter=${letter ? 'A' : nothing} + style=${styleMap(style)} + > + ${additionIcon} + </div>`; + } + + @property({ attribute: false }) + accessor color!: string; + + @property({ attribute: false }) + accessor hollowCircle: boolean | undefined = undefined; + + @property({ attribute: false }) + accessor letter: boolean | undefined = undefined; +} + +export const colorContainerStyles = css` + .color-container { + position: relative; + display: flex; + align-items: center; + justify-content: center; + width: 24px; + height: 24px; + border-radius: 50%; + box-sizing: border-box; + overflow: hidden; + cursor: pointer; + padding: 2px; + } + + .color-unit::before { + content: attr(data-letter); + display: block; + font-size: 12px; + } + + .color-container[active]:after { + position: absolute; + width: 20px; + height: 20px; + border: 0.5px solid var(--affine-primary-color); + border-radius: 50%; + box-sizing: border-box; + content: attr(data-letter); + } +`; + +export class EdgelessColorPanel extends LitElement { + static override styles = css` + :host { + display: flex; + flex-direction: row; + flex-wrap: wrap; + width: 184px; + gap: 8px; + } + + ${colorContainerStyles} + `; + + get palettes() { + return this.hasTransparent + ? ['--affine-palette-transparent', ...this.options] + : this.options; + } + + onSelect(value: string) { + this.dispatchEvent( + new ColorEvent('select', { + detail: value, + composed: true, + bubbles: true, + }) + ); + this.value = value; + } + + override render() { + return html` + ${repeat( + this.palettes, + color => color, + color => { + const unit = ColorUnit(color, { + hollowCircle: this.hollowCircle, + letter: this.showLetterMark, + }); + + return html` + <div + class="color-container" + ?active=${color === this.value} + @click=${() => this.onSelect(color)} + > + ${unit} + </div> + `; + } + )} + </div> + <slot name="custom"></slot> + `; + } + + @property({ attribute: false }) + accessor hasTransparent: boolean = true; + + @property({ attribute: false }) + accessor hollowCircle = false; + + @property() + accessor openColorPicker!: (e: MouseEvent) => void; + + @property({ type: Array }) + accessor options: readonly string[] = LINE_COLORS; + + @property({ attribute: false }) + accessor showLetterMark = false; + + @property({ attribute: false }) + accessor value: string | null = null; +} + +export class EdgelessTextColorIcon extends LitElement { + static override styles = css` + :host { + display: flex; + justify-content: center; + align-items: center; + width: 20px; + height: 20px; + } + `; + + get preprocessColor() { + const color = this.color; + return color.startsWith('--') ? `var(${color})` : color; + } + + override render() { + return html` + <svg + width="20" + height="20" + viewBox="0 0 20 20" + fill="none" + xmlns="http://www.w3.org/2000/svg" + > + <path + fill-rule="evenodd" + clip-rule="evenodd" + fill="currentColor" + d="M8.71093 3.85123C8.91241 3.31395 9.42603 2.95801 9.99984 2.95801C10.5737 2.95801 11.0873 3.31395 11.2888 3.85123L14.7517 13.0858C14.8729 13.409 14.7092 13.7692 14.386 13.8904C14.0628 14.0116 13.7025 13.8479 13.5813 13.5247L12.5648 10.8141H7.43487L6.41838 13.5247C6.29718 13.8479 5.93693 14.0116 5.61373 13.8904C5.29052 13.7692 5.12677 13.409 5.24797 13.0858L8.71093 3.85123ZM7.90362 9.56405H12.0961L10.1183 4.29013C10.0998 4.24073 10.0526 4.20801 9.99984 4.20801C9.94709 4.20801 9.89986 4.24073 9.88134 4.29013L7.90362 9.56405Z" + /> + <rect + x="3.3335" + y="15" + width="13.3333" + height="2.08333" + rx="1" + fill=${this.preprocessColor} + /> + </svg> + `; + } + + @property({ attribute: false }) + accessor color!: string; +} + +declare global { + interface HTMLElementTagNameMap { + 'edgeless-color-panel': EdgelessColorPanel; + 'edgeless-color-button': EdgelessColorButton; + 'edgeless-text-color-icon': EdgelessTextColorIcon; + } +} diff --git a/blocksuite/blocks/src/root-block/edgeless/components/panel/font-family-panel.ts b/blocksuite/blocks/src/root-block/edgeless/components/panel/font-family-panel.ts new file mode 100644 index 0000000000..246702c11b --- /dev/null +++ b/blocksuite/blocks/src/root-block/edgeless/components/panel/font-family-panel.ts @@ -0,0 +1,62 @@ +import { TextUtils } from '@blocksuite/affine-block-surface'; +import { CheckIcon } from '@blocksuite/affine-components/icons'; +import { FontFamily, FontFamilyList } from '@blocksuite/affine-model'; +import { css, html, LitElement, nothing } from 'lit'; +import { property } from 'lit/decorators.js'; +import { repeat } from 'lit/directives/repeat.js'; + +export class EdgelessFontFamilyPanel extends LitElement { + static override styles = css` + :host { + display: flex; + align-items: start; + flex-direction: column; + min-width: 136px; + } + + edgeless-tool-icon-button { + width: 100%; + } + `; + + private _onSelect(value: FontFamily) { + this.value = value; + if (this.onSelect) { + this.onSelect(value); + } + } + + override render() { + return repeat( + FontFamilyList, + item => item[0], + ([font, name]) => { + const active = this.value === font; + return html` + <edgeless-tool-icon-button + data-font="${name}" + style="font-family: ${TextUtils.wrapFontFamily(font)}" + .iconContainerPadding=${[4, 8]} + .justify=${'space-between'} + .active=${active} + @click=${() => this._onSelect(font)} + > + ${name} ${active ? CheckIcon : nothing} + </edgeless-tool-icon-button> + `; + } + ); + } + + @property({ attribute: false }) + accessor onSelect: ((value: FontFamily) => void) | undefined = undefined; + + @property({ attribute: false }) + accessor value: FontFamily = FontFamily.Inter; +} + +declare global { + interface HTMLElementTagNameMap { + 'edgeless-font-family-panel': EdgelessFontFamilyPanel; + } +} diff --git a/blocksuite/blocks/src/root-block/edgeless/components/panel/font-weight-and-style-panel.ts b/blocksuite/blocks/src/root-block/edgeless/components/panel/font-weight-and-style-panel.ts new file mode 100644 index 0000000000..cb6eeed95b --- /dev/null +++ b/blocksuite/blocks/src/root-block/edgeless/components/panel/font-weight-and-style-panel.ts @@ -0,0 +1,168 @@ +import { TextUtils } from '@blocksuite/affine-block-surface'; +import { CheckIcon } from '@blocksuite/affine-components/icons'; +import { + FontFamily, + FontFamilyMap, + FontStyle, + FontWeight, +} from '@blocksuite/affine-model'; +import { css, html, LitElement, nothing } from 'lit'; +import { property } from 'lit/decorators.js'; +import { choose } from 'lit/directives/choose.js'; +import { join } from 'lit/directives/join.js'; +import { repeat } from 'lit/directives/repeat.js'; + +const FONT_WEIGHT_CHOOSE: [FontWeight, () => string][] = [ + [FontWeight.Light, () => 'Light'], + [FontWeight.Regular, () => 'Regular'], + [FontWeight.SemiBold, () => 'Semibold'], +]; + +export class EdgelessFontWeightAndStylePanel extends LitElement { + static override styles = css` + :host { + display: flex; + align-items: start; + flex-direction: column; + min-width: 124px; + } + + edgeless-tool-icon-button { + width: 100%; + } + `; + + private _isActive( + fontWeight: FontWeight, + fontStyle: FontStyle = FontStyle.Normal + ) { + return this.fontWeight === fontWeight && this.fontStyle === fontStyle; + } + + private _isDisabled( + fontWeight: FontWeight, + fontStyle: FontStyle = FontStyle.Normal + ) { + // Compatible with old data + if (!(this.fontFamily in FontFamilyMap)) return false; + + const fontFace = TextUtils.getFontFaces() + .filter(TextUtils.isSameFontFamily(this.fontFamily)) + .find( + fontFace => + fontFace.weight === fontWeight && fontFace.style === fontStyle + ); + + return !fontFace; + } + + private _onSelect( + fontWeight: FontWeight, + fontStyle: FontStyle = FontStyle.Normal + ) { + this.fontWeight = fontWeight; + this.fontStyle = fontStyle; + if (this.onSelect) { + this.onSelect(fontWeight, fontStyle); + } + } + + override render() { + let fontFaces = TextUtils.getFontFacesByFontFamily(this.fontFamily); + // Compatible with old data + if (fontFaces.length === 0) { + fontFaces = TextUtils.getFontFacesByFontFamily(FontFamily.Inter); + } + const fontFacesWithNormal = fontFaces.filter( + fontFace => fontFace.style === FontStyle.Normal + ); + const fontFacesWithItalic = fontFaces.filter( + fontFace => fontFace.style === FontStyle.Italic + ); + + return join( + [ + fontFacesWithNormal.length > 0 + ? repeat( + fontFacesWithNormal, + fontFace => fontFace.weight, + fontFace => { + const active = this._isActive(fontFace.weight as FontWeight); + return html` + <edgeless-tool-icon-button + data-weight="${fontFace.weight}" + .iconContainerPadding=${[4, 8]} + .justify=${'space-between'} + .disabled=${this._isDisabled(fontFace.weight as FontWeight)} + .active=${active} + @click=${() => + this._onSelect(fontFace.weight as FontWeight)} + > + ${choose(fontFace.weight, FONT_WEIGHT_CHOOSE)} + ${active ? CheckIcon : nothing} + </edgeless-tool-icon-button> + `; + } + ) + : nothing, + fontFacesWithItalic.length > 0 + ? repeat( + fontFacesWithItalic, + fontFace => fontFace.weight, + fontFace => { + const active = this._isActive( + fontFace.weight as FontWeight, + FontStyle.Italic + ); + return html` + <edgeless-tool-icon-button + data-weight="${fontFace.weight} italic" + .iconContainerPadding=${[4, 8]} + .justify=${'space-between'} + .disabled=${this._isDisabled( + fontFace.weight as FontWeight, + FontStyle.Italic + )} + .active=${active} + @click=${() => + this._onSelect( + fontFace.weight as FontWeight, + FontStyle.Italic + )} + > + ${choose(fontFace.weight, FONT_WEIGHT_CHOOSE)} Italic + ${active ? CheckIcon : nothing} + </edgeless-tool-icon-button> + `; + } + ) + : nothing, + ].filter(item => item !== nothing), + () => html` + <edgeless-menu-divider + data-orientation="horizontal" + ></edgeless-menu-divider> + ` + ); + } + + @property({ attribute: false }) + accessor fontFamily = FontFamily.Inter; + + @property({ attribute: false }) + accessor fontStyle = FontStyle.Normal; + + @property({ attribute: false }) + accessor fontWeight = FontWeight.Regular; + + @property({ attribute: false }) + accessor onSelect: + | ((fontWeight: FontWeight, fontStyle: FontStyle) => void) + | undefined; +} + +declare global { + interface HTMLElementTagNameMap { + 'edgeless-font-weight-and-style-panel': EdgelessFontWeightAndStylePanel; + } +} diff --git a/blocksuite/blocks/src/root-block/edgeless/components/panel/line-styles-panel.ts b/blocksuite/blocks/src/root-block/edgeless/components/panel/line-styles-panel.ts new file mode 100644 index 0000000000..7a43bd5064 --- /dev/null +++ b/blocksuite/blocks/src/root-block/edgeless/components/panel/line-styles-panel.ts @@ -0,0 +1,101 @@ +import { + BanIcon, + DashLineIcon, + StraightLineIcon, +} from '@blocksuite/affine-components/icons'; +import { type LineWidth, StrokeStyle } from '@blocksuite/affine-model'; +import { html } from 'lit'; +import { classMap } from 'lit/directives/class-map.js'; +import { repeat } from 'lit/directives/repeat.js'; + +import type { LineWidthEvent } from './line-width-panel.js'; + +export type LineStyleEvent = + | { + type: 'size'; + value: LineWidth; + } + | { + type: 'lineStyle'; + value: StrokeStyle; + }; + +interface LineStylesPanelProps { + onClick?: (e: LineStyleEvent) => void; + selectedLineSize?: LineWidth; + selectedLineStyle?: StrokeStyle; + lineStyles?: StrokeStyle[]; +} + +const LINE_STYLE_LIST = [ + { + name: 'Solid', + value: StrokeStyle.Solid, + icon: StraightLineIcon, + }, + { + name: 'Dash', + value: StrokeStyle.Dash, + icon: DashLineIcon, + }, + { + name: 'None', + value: StrokeStyle.None, + icon: BanIcon, + }, +]; + +export function LineStylesPanel({ + onClick, + selectedLineSize, + selectedLineStyle, + lineStyles = [StrokeStyle.Solid, StrokeStyle.Dash, StrokeStyle.None], +}: LineStylesPanelProps = {}) { + const lineSizePanel = html` + <edgeless-line-width-panel + .selectedSize=${selectedLineSize as LineWidth} + .disable=${selectedLineStyle === StrokeStyle.None} + @select=${(e: LineWidthEvent) => { + onClick?.({ + type: 'size', + value: e.detail, + }); + }} + ></edgeless-line-width-panel> + `; + + const lineStyleButtons = repeat( + LINE_STYLE_LIST.filter(item => lineStyles.includes(item.value)), + item => item.value, + ({ name, icon, value }) => { + const active = selectedLineStyle === value; + const classes: Record<string, boolean> = { + 'line-style-button': true, + [`mode-${value}`]: true, + }; + if (active) classes['active'] = true; + + return html` + <edgeless-tool-icon-button + class=${classMap(classes)} + .active=${active} + .activeMode=${'background'} + .tooltip=${name} + @click=${() => + onClick?.({ + type: 'lineStyle', + value, + })} + > + ${icon} + </edgeless-tool-icon-button> + `; + } + ); + + return html` + ${lineSizePanel} + <editor-toolbar-separator></editor-toolbar-separator> + ${lineStyleButtons} + `; +} diff --git a/blocksuite/blocks/src/root-block/edgeless/components/panel/line-width-panel.ts b/blocksuite/blocks/src/root-block/edgeless/components/panel/line-width-panel.ts new file mode 100644 index 0000000000..b6b4a47737 --- /dev/null +++ b/blocksuite/blocks/src/root-block/edgeless/components/panel/line-width-panel.ts @@ -0,0 +1,368 @@ +import { LineWidth } from '@blocksuite/affine-model'; +import { requestConnectedFrame } from '@blocksuite/affine-shared/utils'; +import { WithDisposable } from '@blocksuite/global/utils'; +import { css, html, LitElement, nothing, type PropertyValues } from 'lit'; +import { property, query, queryAll } from 'lit/decorators.js'; + +type DragConfig = { + stepWidth: number; + boundLeft: number; + containerWidth: number; + bottomLineWidth: number; +}; + +export class LineWidthEvent extends Event { + detail: LineWidth; + + constructor( + type: string, + { + detail, + composed, + bubbles, + }: { detail: LineWidth; composed: boolean; bubbles: boolean } + ) { + super(type, { bubbles, composed }); + this.detail = detail; + } +} + +export class EdgelessLineWidthPanel extends WithDisposable(LitElement) { + static override styles = css` + :host { + display: flex; + align-items: center; + justify-content: center; + align-self: stretch; + } + + .line-width-panel { + width: 108px; + height: 24px; + display: flex; + flex-direction: row; + align-items: center; + justify-content: space-between; + position: relative; + cursor: default; + } + + .line-width-button { + display: flex; + align-items: center; + justify-content: center; + width: 16px; + height: 16px; + z-index: 2; + } + + .line-width-icon { + width: 4px; + height: 4px; + border-radius: 50%; + background-color: var(--affine-border-color); + } + + .line-width-button:nth-child(1) { + margin-right: 0; + } + + .line-width-button:nth-child(6) { + margin-left: 0; + } + + .drag-handle { + position: absolute; + left: 0; + top: 50%; + width: 8px; + height: 8px; + transform: translateY(-50%) translateX(4px); + border-radius: 50%; + background-color: var(--affine-icon-color); + z-index: 3; + } + + .bottom-line, + .line-width-overlay { + left: 8px; + top: 50%; + transform: translateY(-50%); + height: 1px; + background-color: var(--affine-border-color); + position: absolute; + } + + .bottom-line { + width: calc(100% - 16px); + background-color: var(--affine-border-color); + } + + .line-width-overlay { + width: 0; + background-color: var(--affine-icon-color); + z-index: 1; + } + `; + + private _dragConfig: DragConfig | null = null; + + private _getDragHandlePosition = (e: PointerEvent, config: DragConfig) => { + const x = e.clientX; + const { boundLeft, bottomLineWidth, stepWidth, containerWidth } = config; + + let steps: number; + if (x <= boundLeft) { + steps = 0; + } else if (x - boundLeft >= containerWidth) { + steps = 100; + } else { + steps = Math.floor((x - boundLeft) / stepWidth); + } + + // The drag handle should not be dragged to the left of the first icon or right of the last icon. + // Calculate the drag handle position based on the steps. + const bottomLineOffsetX = 4; + const bottomLineStepWidth = (bottomLineWidth - bottomLineOffsetX) / 100; + const dragHandlerPosition = steps * bottomLineStepWidth; + return dragHandlerPosition; + }; + + private _onPointerDown = (e: PointerEvent) => { + e.preventDefault(); + if (this.disable) return; + const { left, width } = this._lineWidthPanel.getBoundingClientRect(); + const bottomLineWidth = this._bottomLine.getBoundingClientRect().width; + this._dragConfig = { + stepWidth: width / 100, + boundLeft: left, + containerWidth: width, + bottomLineWidth, + }; + this._onPointerMove(e); + }; + + private _onPointerMove = (e: PointerEvent) => { + e.preventDefault(); + if (!this._dragConfig) return; + const dragHandlerPosition = this._getDragHandlePosition( + e, + this._dragConfig + ); + this._dragHandle.style.left = `${dragHandlerPosition}%`; + this._lineWidthOverlay.style.width = `${dragHandlerPosition}%`; + this._updateIconsColor(); + }; + + private _onPointerOut = (e: PointerEvent) => { + // If the pointer is out of the line width panel + // Stop dragging and update the selected size by nearest size. + e.preventDefault(); + if (!this._dragConfig) return; + const dragHandlerPosition = this._getDragHandlePosition( + e, + this._dragConfig + ); + this._updateLineWidthPanelByDragHandlePosition(dragHandlerPosition); + this._dragConfig = null; + }; + + private _onPointerUp = (e: PointerEvent) => { + e.preventDefault(); + if (!this._dragConfig) return; + const dragHandlerPosition = this._getDragHandlePosition( + e, + this._dragConfig + ); + this._updateLineWidthPanelByDragHandlePosition(dragHandlerPosition); + this._dragConfig = null; + }; + + private _updateIconsColor = () => { + if (!this._dragHandle.offsetParent) { + requestConnectedFrame(() => this._updateIconsColor(), this); + return; + } + + const dragHandleRect = this._dragHandle.getBoundingClientRect(); + const dragHandleCenterX = dragHandleRect.left + dragHandleRect.width / 2; + // All the icons located at the left of the drag handle should be filled with the icon color. + const leftIcons = []; + // All the icons located at the right of the drag handle should be filled with the border color. + const rightIcons = []; + + for (const icon of this._lineWidthIcons) { + const { left, width } = icon.getBoundingClientRect(); + const centerX = left + width / 2; + if (centerX < dragHandleCenterX) { + leftIcons.push(icon); + } else { + rightIcons.push(icon); + } + } + + leftIcons.forEach( + icon => (icon.style.backgroundColor = 'var(--affine-icon-color)') + ); + rightIcons.forEach( + icon => (icon.style.backgroundColor = 'var(--affine-border-color)') + ); + }; + + private _onSelect(lineWidth: LineWidth) { + // If the selected size is the same as the previous one, do nothing. + if (lineWidth === this.selectedSize) return; + this.dispatchEvent( + new LineWidthEvent('select', { + detail: lineWidth, + composed: true, + bubbles: true, + }) + ); + this.selectedSize = lineWidth; + } + + private _updateLineWidthPanel(selectedSize: LineWidth) { + if (!this._lineWidthOverlay) return; + let width = 0; + let dragHandleOffsetX = 0; + switch (selectedSize) { + case LineWidth.Two: + width = 0; + break; + case LineWidth.Four: + width = 16; + dragHandleOffsetX = 1; + break; + case LineWidth.Six: + width = 32; + dragHandleOffsetX = 2; + break; + case LineWidth.Eight: + width = 48; + dragHandleOffsetX = 3; + break; + case LineWidth.Ten: + width = 64; + dragHandleOffsetX = 4; + break; + default: + width = 80; + dragHandleOffsetX = 4; + } + + dragHandleOffsetX += 4; + this._lineWidthOverlay.style.width = `${width}%`; + this._dragHandle.style.left = `${width}%`; + this._dragHandle.style.transform = `translateY(-50%) translateX(${dragHandleOffsetX}px)`; + this._updateIconsColor(); + } + + private _updateLineWidthPanelByDragHandlePosition( + dragHandlerPosition: number + ) { + // Calculate the selected size based on the drag handle position. + // Need to select the nearest size. + let selectedSize = this.selectedSize; + if (dragHandlerPosition <= 12) { + selectedSize = LineWidth.Two; + } else if (dragHandlerPosition > 12 && dragHandlerPosition <= 26) { + selectedSize = LineWidth.Four; + } else if (dragHandlerPosition > 26 && dragHandlerPosition <= 40) { + selectedSize = LineWidth.Six; + } else if (dragHandlerPosition > 40 && dragHandlerPosition <= 54) { + selectedSize = LineWidth.Eight; + } else if (dragHandlerPosition > 54 && dragHandlerPosition <= 68) { + selectedSize = LineWidth.Ten; + } else { + selectedSize = LineWidth.Twelve; + } + this._updateLineWidthPanel(selectedSize); + this._onSelect(selectedSize); + } + + override disconnectedCallback(): void { + this._disposables.dispose(); + } + + override firstUpdated(): void { + this._updateLineWidthPanel(this.selectedSize); + this._disposables.addFromEvent(this, 'pointerdown', this._onPointerDown); + this._disposables.addFromEvent(this, 'pointermove', this._onPointerMove); + this._disposables.addFromEvent(this, 'pointerup', this._onPointerUp); + this._disposables.addFromEvent(this, 'pointerout', this._onPointerOut); + } + + override render() { + return html`<style> + .line-width-panel { + opacity: ${this.disable ? '0.5' : '1'}; + } + </style> + <div + class="line-width-panel" + @mousedown="${(e: Event) => e.preventDefault()}" + > + <div class="line-width-button"> + <div class="line-width-icon"></div> + </div> + <div class="line-width-button"> + <div class="line-width-icon"></div> + </div> + <div class="line-width-button"> + <div class="line-width-icon"></div> + </div> + <div class="line-width-button"> + <div class="line-width-icon"></div> + </div> + <div class="line-width-button"> + <div class="line-width-icon"></div> + </div> + <div class="line-width-button"> + <div class="line-width-icon"></div> + </div> + <div class="drag-handle"></div> + <div class="bottom-line"></div> + <div class="line-width-overlay"></div> + ${this.hasTooltip + ? html`<affine-tooltip .offset=${8}>Thickness</affine-tooltip>` + : nothing} + </div>`; + } + + override willUpdate(changedProperties: PropertyValues<this>) { + if (changedProperties.has('selectedSize')) { + this._updateLineWidthPanel(this.selectedSize); + } + } + + @query('.bottom-line') + private accessor _bottomLine!: HTMLElement; + + @query('.drag-handle') + private accessor _dragHandle!: HTMLElement; + + @queryAll('.line-width-icon') + private accessor _lineWidthIcons!: NodeListOf<HTMLElement>; + + @query('.line-width-overlay') + private accessor _lineWidthOverlay!: HTMLElement; + + @query('.line-width-panel') + private accessor _lineWidthPanel!: HTMLElement; + + @property({ attribute: false }) + accessor disable = false; + + @property({ attribute: false }) + accessor hasTooltip = true; + + @property({ attribute: false }) + accessor selectedSize: LineWidth = LineWidth.Two; +} + +declare global { + interface HTMLElementTagNameMap { + 'edgeless-line-width-panel': EdgelessLineWidthPanel; + } +} diff --git a/blocksuite/blocks/src/root-block/edgeless/components/panel/note-display-mode-panel.ts b/blocksuite/blocks/src/root-block/edgeless/components/panel/note-display-mode-panel.ts new file mode 100644 index 0000000000..f901161702 --- /dev/null +++ b/blocksuite/blocks/src/root-block/edgeless/components/panel/note-display-mode-panel.ts @@ -0,0 +1,105 @@ +import { EdgelessIcon, PageIcon } from '@blocksuite/affine-components/icons'; +import { NoteDisplayMode } from '@blocksuite/affine-model'; +import { stopPropagation } from '@blocksuite/affine-shared/utils'; +import { WithDisposable } from '@blocksuite/global/utils'; +import { css, html, LitElement } from 'lit'; +import { property } from 'lit/decorators.js'; +import { repeat } from 'lit/directives/repeat.js'; + +export class NoteDisplayModePanel extends WithDisposable(LitElement) { + static override styles = css` + :host { + display: flex; + flex-direction: column; + align-items: flex-start; + min-width: 180px; + width: var(--panel-width); + gap: 4px; + } + .item { + display: flex; + align-items: center; + width: 100%; + height: 30px; + padding: 4px 12px; + border-radius: 4px; + gap: 4px; + box-sizing: border-box; + cursor: pointer; + } + .item-label { + flex: 1 1 0; + } + .item-icon { + display: flex; + justify-content: center; + align-items: center; + gap: 4px; + color: var(--affine-icon-color); + } + .item:hover, + .item.selected { + background-color: var(--affine-hover-color); + } + `; + + private _DisplayModeIcon(mode: NoteDisplayMode) { + switch (mode) { + case NoteDisplayMode.DocAndEdgeless: + return html`${PageIcon} ${EdgelessIcon}`; + case NoteDisplayMode.DocOnly: + return html`${PageIcon}`; + case NoteDisplayMode.EdgelessOnly: + return html`${EdgelessIcon}`; + } + } + + private _DisplayModeLabel(mode: NoteDisplayMode) { + switch (mode) { + case NoteDisplayMode.DocAndEdgeless: + return 'In Both'; + case NoteDisplayMode.DocOnly: + return 'In Page Only'; + case NoteDisplayMode.EdgelessOnly: + return 'In Edgeless Only'; + } + } + + override render() { + this.style.setProperty('--panel-width', `${this.panelWidth}px`); + + return repeat( + Object.keys(NoteDisplayMode), + mode => mode, + mode => { + const displayMode = + NoteDisplayMode[mode as keyof typeof NoteDisplayMode]; + const isSelected = displayMode === this.displayMode; + return html`<div + class="item ${isSelected ? 'selected' : ''} ${displayMode}" + @click=${() => this.onSelect(displayMode)} + @dblclick=${stopPropagation} + @pointerdown=${stopPropagation} + > + <div class="item-label">${this._DisplayModeLabel(displayMode)}</div> + <div class="item-icon">${this._DisplayModeIcon(displayMode)}</div> + </div>`; + } + ); + } + + @property({ attribute: false }) + accessor displayMode!: NoteDisplayMode; + + @property({ attribute: false }) + accessor onSelect!: (displayMode: NoteDisplayMode) => void; + + @property({ attribute: false }) + accessor panelWidth = 240; +} + +declare global { + interface HTMLElementTagNameMap { + 'note-display-mode-panel': NoteDisplayModePanel; + } +} diff --git a/blocksuite/blocks/src/root-block/edgeless/components/panel/note-shadow-panel.ts b/blocksuite/blocks/src/root-block/edgeless/components/panel/note-shadow-panel.ts new file mode 100644 index 0000000000..bec1ddfd81 --- /dev/null +++ b/blocksuite/blocks/src/root-block/edgeless/components/panel/note-shadow-panel.ts @@ -0,0 +1,151 @@ +import { + NoteNoShadowIcon, + NoteShadowSampleIcon, +} from '@blocksuite/affine-components/icons'; +import { ColorScheme, NoteShadow } from '@blocksuite/affine-model'; +import { WithDisposable } from '@blocksuite/global/utils'; +import { css, html, LitElement } from 'lit'; +import { property } from 'lit/decorators.js'; +import { repeat } from 'lit/directives/repeat.js'; +import { styleMap } from 'lit/directives/style-map.js'; + +const SHADOWS = [ + { + type: NoteShadow.None, + styles: { + light: '', + dark: '', + }, + tooltip: 'No shadow', + }, + { + type: NoteShadow.Box, + styles: { + light: + '0px 0.2px 4.8px 0px rgba(66, 65, 73, 0.2), 0px 0px 1.6px 0px rgba(66, 65, 73, 0.2)', + dark: '0px 0.2px 6px 0px rgba(0, 0, 0, 0.44), 0px 0px 2px 0px rgba(0, 0, 0, 0.66)', + }, + tooltip: 'Box shadow', + }, + { + type: NoteShadow.Sticker, + styles: { + light: + '0px 9.6px 10.4px -4px rgba(66, 65, 73, 0.07), 0px 10.4px 7.2px -8px rgba(66, 65, 73, 0.22)', + dark: '0px 9.6px 10.4px -4px rgba(0, 0, 0, 0.66), 0px 10.4px 7.2px -8px rgba(0, 0, 0, 0.44)', + }, + tooltip: 'Sticker shadow', + }, + { + type: NoteShadow.Paper, + styles: { + light: + '0px 0px 0px 4px rgba(255, 255, 255, 1), 0px 1.2px 2.4px 4.8px rgba(66, 65, 73, 0.16)', + dark: '0px 1.2px 2.4px 4.8px rgba(0, 0, 0, 0.36), 0px 0px 0px 3.4px rgba(75, 75, 75, 1)', + }, + tooltip: 'Paper shadow', + }, + { + type: NoteShadow.Float, + styles: { + light: + '0px 5.2px 12px 0px rgba(66, 65, 73, 0.13), 0px 0px 0.4px 1px rgba(0, 0, 0, 0.06)', + dark: '0px 5.2px 12px 0px rgba(0, 0, 0, 0.66), 0px 0px 0.4px 1px rgba(0, 0, 0, 0.44)', + }, + tooltip: 'Floation shadow', + }, + { + type: NoteShadow.Film, + styles: { + light: + '0px 0px 0px 1.4px rgba(0, 0, 0, 1), 2.4px 2.4px 0px 1px rgba(0, 0, 0, 1)', + dark: '0px 0px 0px 1.4px rgba(178, 178, 178, 1), 2.4px 2.4px 0px 1px rgba(178, 178, 178, 1)', + }, + tooltip: 'Film shadow', + }, +]; + +export class EdgelessNoteShadowPanel extends WithDisposable(LitElement) { + static override styles = css` + :host { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + } + + .item { + padding: 8px; + border-radius: 4px; + display: flex; + justify-content: center; + align-items: center; + cursor: pointer; + } + + .item-icon { + display: flex; + justify-content: center; + align-items: center; + } + + .item:hover { + background-color: var(--affine-hover-color); + } + `; + + override render() { + return repeat( + SHADOWS, + shadow => shadow, + (shadow, index) => + html`<style> + .item-icon svg rect:first-of-type { + fill: ${this.background.startsWith('--') + ? `var(${this.background})` + : this.background}; + } + </style> + <div + class="item" + @click=${() => this.onSelect(shadow.type)} + style=${styleMap({ + border: + this.value === shadow.type + ? '1px solid var(--affine-brand-color)' + : 'none', + })} + > + <edgeless-tool-icon-button + class="item-icon" + .tooltip=${shadow.tooltip} + .tipPosition=${'bottom'} + .iconContainerPadding=${0} + style=${styleMap({ + boxShadow: `${this.theme === ColorScheme.Dark ? shadow.styles.dark : shadow.styles.light}`, + })} + > + ${index === 0 ? NoteNoShadowIcon : NoteShadowSampleIcon} + </edgeless-tool-icon-button> + </div>` + ); + } + + @property({ attribute: false }) + accessor background!: string; + + @property({ attribute: false }) + accessor onSelect!: (value: string) => void; + + @property({ attribute: false }) + accessor theme!: ColorScheme; + + @property({ attribute: false }) + accessor value!: string; +} + +declare global { + interface HTMLElementTagNameMap { + 'edgeless-note-shadow-panel': EdgelessNoteShadowPanel; + } +} diff --git a/blocksuite/blocks/src/root-block/edgeless/components/panel/one-row-color-panel.ts b/blocksuite/blocks/src/root-block/edgeless/components/panel/one-row-color-panel.ts new file mode 100644 index 0000000000..3d2bc8edee --- /dev/null +++ b/blocksuite/blocks/src/root-block/edgeless/components/panel/one-row-color-panel.ts @@ -0,0 +1,38 @@ +import { css } from 'lit'; + +import { colorContainerStyles, EdgelessColorPanel } from './color-panel.js'; + +export class EdgelessOneRowColorPanel extends EdgelessColorPanel { + static override styles = css` + :host { + display: flex; + flex-wrap: nowrap; + padding: 0 2px; + gap: 14px; + box-sizing: border-box; + background: var(--affine-background-overlay-panel-color); + } + + ${colorContainerStyles} + + .color-container { + width: 20px; + height: 20px; + } + .color-container::before { + content: ''; + position: absolute; + width: 2px; + right: calc(100% + 7px); + height: 100%; + // FIXME: not working + scroll-snap-align: start; + } + `; +} + +declare global { + interface HTMLElementTagNameMap { + 'edgeless-one-row-color-panel': EdgelessOneRowColorPanel; + } +} diff --git a/blocksuite/blocks/src/root-block/edgeless/components/panel/scale-panel.ts b/blocksuite/blocks/src/root-block/edgeless/components/panel/scale-panel.ts new file mode 100644 index 0000000000..8cedd56957 --- /dev/null +++ b/blocksuite/blocks/src/root-block/edgeless/components/panel/scale-panel.ts @@ -0,0 +1,136 @@ +import { clamp, stopPropagation } from '@blocksuite/affine-shared/utils'; +import { css, html, LitElement } from 'lit'; +import { property } from 'lit/decorators.js'; +import { repeat } from 'lit/directives/repeat.js'; + +const MIN_SCALE = 0; +const MAX_SCALE = 400; + +const SCALE_LIST = [50, 100, 200] as const; + +function format(scale: number) { + return `${scale}%`; +} + +export class EdgelessScalePanel extends LitElement { + static override styles = css` + :host { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 4px; + width: 68px; + } + + edgeless-tool-icon-button { + align-self: stretch; + } + + .scale-input { + display: flx; + align-self: stretch; + border: 0.5px solid var(--affine-border-color); + border-radius: 8px; + padding: 4px 8px; + box-sizing: border-box; + } + + .scale-input::placeholder { + color: var(--affine-placeholder-color); + } + + .scale-input:focus { + outline-color: var(--affine-primary-color); + outline-width: 0.5px; + } + `; + + private _onKeydown = (e: KeyboardEvent) => { + e.stopPropagation(); + + if (e.key === 'Enter' && !e.isComposing) { + e.preventDefault(); + const input = e.target as HTMLInputElement; + const scale = parseInt(input.value.trim()); + // Handle edge case where user enters a non-number + if (isNaN(scale)) { + input.value = ''; + return; + } + + // Handle edge case when user enters a number that is out of range + this._onSelect(clamp(scale, this.minScale, this.maxScale)); + input.value = ''; + this._onPopperClose(); + } + }; + + private _onPopperClose() { + this.onPopperCose?.(); + } + + private _onSelect(scale: number) { + this.onSelect?.(scale / 100); + } + + override render() { + return html` + ${repeat( + this.scaleList, + scale => scale, + scale => { + const classes = `scale-${scale}`; + return html`<edgeless-tool-icon-button + class=${classes} + .iconContainerPadding=${[4, 8]} + .activeMode=${'background'} + .active=${this.scale === scale} + @click=${() => this._onSelect(scale)} + > + ${format(scale)} + </edgeless-tool-icon-button>`; + } + )} + + <input + class="scale-input" + type="text" + inputmode="numeric" + pattern="[0-9]*" + min="0" + placeholder=${format(Math.trunc(this.scale))} + @keydown=${this._onKeydown} + @input=${stopPropagation} + @click=${stopPropagation} + @pointerdown=${stopPropagation} + @cut=${stopPropagation} + @copy=${stopPropagation} + @paste=${stopPropagation} + /> + `; + } + + @property({ attribute: false }) + accessor maxScale: number = MAX_SCALE; + + @property({ attribute: false }) + accessor minScale: number = MIN_SCALE; + + @property({ attribute: false }) + accessor onPopperCose: (() => void) | undefined = undefined; + + @property({ attribute: false }) + accessor onSelect: ((size: number) => void) | undefined = undefined; + + @property({ attribute: false }) + accessor scale!: number; + + @property({ attribute: false }) + accessor scaleList: readonly number[] = SCALE_LIST; +} + +declare global { + interface HTMLElementTagNameMap { + 'edgeless-scale-panel': EdgelessScalePanel; + } +} diff --git a/blocksuite/blocks/src/root-block/edgeless/components/panel/shape-panel.ts b/blocksuite/blocks/src/root-block/edgeless/components/panel/shape-panel.ts new file mode 100644 index 0000000000..2952166535 --- /dev/null +++ b/blocksuite/blocks/src/root-block/edgeless/components/panel/shape-panel.ts @@ -0,0 +1,70 @@ +import { ShapeStyle } from '@blocksuite/affine-model'; +import { Slot } from '@blocksuite/global/utils'; +import { css, html, LitElement } from 'lit'; +import { property } from 'lit/decorators.js'; +import { repeat } from 'lit/directives/repeat.js'; + +import type { ShapeTool } from '../../gfx-tool/shape-tool.js'; +import { ShapeComponentConfig } from '../toolbar/shape/shape-menu-config.js'; + +export class EdgelessShapePanel extends LitElement { + static override styles = css` + :host { + display: flex; + align-items: center; + justify-content: center; + gap: 8px; + } + `; + + slots = { + select: new Slot<ShapeTool['activatedOption']['shapeName']>(), + }; + + private _onSelect(value: ShapeTool['activatedOption']['shapeName']) { + this.selectedShape = value; + this.slots.select.emit(value); + } + + override disconnectedCallback(): void { + this.slots.select.dispose(); + super.disconnectedCallback(); + } + + override render() { + return repeat( + ShapeComponentConfig, + item => item.name, + ({ name, generalIcon, scribbledIcon, tooltip, disabled }) => + html`<edgeless-tool-icon-button + .disabled=${disabled} + .tooltip=${tooltip} + .active=${this.selectedShape === name} + .activeMode=${'background'} + @click=${() => { + if (disabled) return; + this._onSelect(name); + }} + > + ${this.shapeStyle === ShapeStyle.General + ? generalIcon + : scribbledIcon} + </edgeless-tool-icon-button>` + ); + } + + @property({ attribute: false }) + accessor selectedShape: + | ShapeTool['activatedOption']['shapeName'] + | null + | undefined = undefined; + + @property({ attribute: false }) + accessor shapeStyle: ShapeStyle = ShapeStyle.Scribbled; +} + +declare global { + interface HTMLElementTagNameMap { + 'edgeless-shape-panel': EdgelessShapePanel; + } +} diff --git a/blocksuite/blocks/src/root-block/edgeless/components/panel/shape-style-panel.ts b/blocksuite/blocks/src/root-block/edgeless/components/panel/shape-style-panel.ts new file mode 100644 index 0000000000..351a2d88a2 --- /dev/null +++ b/blocksuite/blocks/src/root-block/edgeless/components/panel/shape-style-panel.ts @@ -0,0 +1,67 @@ +import { + GeneralStyleIcon, + ScribbledStyleIcon, +} from '@blocksuite/affine-components/icons'; +import { ShapeStyle } from '@blocksuite/affine-model'; +import { css, html, LitElement } from 'lit'; +import { property } from 'lit/decorators.js'; +import { repeat } from 'lit/directives/repeat.js'; + +const SHAPE_STYLE_LIST = [ + { + value: ShapeStyle.General, + icon: GeneralStyleIcon, + }, + { + value: ShapeStyle.Scribbled, + icon: ScribbledStyleIcon, + }, +]; + +export class EdgelessShapeStylePanel extends LitElement { + static override styles = css` + :host { + display: flex; + align-items: center; + justify-content: center; + gap: 8px; + } + `; + + private _onSelect(value: ShapeStyle) { + this.value = value; + if (this.onSelect) { + this.onSelect(value); + } + } + + override render() { + return repeat( + SHAPE_STYLE_LIST, + item => item.value, + ({ value, icon }) => + html`<edgeless-tool-icon-button + .tipPosition=${'top'} + .activeMode=${'background'} + aria-label=${value} + .tooltip=${value} + .active=${this.value === value} + @click=${() => this._onSelect(value)} + > + ${icon} + </edgeless-tool-icon-button>` + ); + } + + @property({ attribute: false }) + accessor onSelect: undefined | ((value: ShapeStyle) => void) = undefined; + + @property({ attribute: false }) + accessor value!: ShapeStyle; +} + +declare global { + interface HTMLElementTagNameMap { + 'edgeless-shape-style-panel': EdgelessShapeStylePanel; + } +} diff --git a/blocksuite/blocks/src/root-block/edgeless/components/panel/size-panel.ts b/blocksuite/blocks/src/root-block/edgeless/components/panel/size-panel.ts new file mode 100644 index 0000000000..d2dd0becaf --- /dev/null +++ b/blocksuite/blocks/src/root-block/edgeless/components/panel/size-panel.ts @@ -0,0 +1,166 @@ +import { CheckIcon } from '@blocksuite/affine-components/icons'; +import { clamp, stopPropagation } from '@blocksuite/affine-shared/utils'; +import { css, html, LitElement, nothing } from 'lit'; +import { property } from 'lit/decorators.js'; +import { repeat } from 'lit/directives/repeat.js'; + +const MIN_SIZE = 1; +const MAX_SIZE = 200; + +type SizeItem = { + name?: string; + value: number; +}; + +export class EdgelessSizePanel extends LitElement { + static override styles = css` + :host { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 4px; + width: 68px; + } + + edgeless-tool-icon-button { + align-self: stretch; + } + + .size-input { + display: flex; + align-self: stretch; + width: 100%; + border: 0.5px solid var(--affine-border-color); + border-radius: 8px; + padding: 4px 8px; + box-sizing: border-box; + } + + .size-input::placeholder { + color: var(--affine-placeholder-color); + } + + .size-input:focus { + outline-color: var(--affine-primary-color); + outline-width: 0.5px; + } + + :host([data-type='check']) { + gap: 0; + } + + :host([data-type='check']) .size-input { + margin-top: 4px; + } + `; + + private _onKeydown = (e: KeyboardEvent) => { + e.stopPropagation(); + + if (e.key === 'Enter' && !e.isComposing) { + e.preventDefault(); + const input = e.target as HTMLInputElement; + const size = parseInt(input.value.trim()); + // Handle edge case where user enters a non-number + if (isNaN(size)) { + input.value = ''; + return; + } + + // Handle edge case when user enters a number that is out of range + this._onSelect(clamp(size, this.minSize, this.maxSize)); + input.value = ''; + this._onPopperClose(); + } + }; + + renderItemWithCheck = ({ name, value }: SizeItem) => { + const active = this.size === value; + return html` + <edgeless-tool-icon-button + .iconContainerPadding=${[4, 8]} + .justify=${'space-between'} + .active=${active} + @click=${() => this._onSelect(value)} + > + ${name ?? value} ${active ? CheckIcon : nothing} + </edgeless-tool-icon-button> + `; + }; + + renderItemWithNormal = ({ name, value }: SizeItem) => { + return html` + <edgeless-tool-icon-button + .iconContainerPadding=${[4, 8]} + .active=${this.size === value} + .activeMode=${'background'} + @click=${() => this._onSelect(value)} + > + ${name ?? value} + </edgeless-tool-icon-button> + `; + }; + + private _onPopperClose() { + this.onPopperCose?.(); + } + + private _onSelect(size: number) { + this.onSelect?.(size); + } + + override render() { + return html` + ${repeat(this.sizeList, sizeItem => sizeItem.name, this.renderItem())} + + <input + class="size-input" + type="text" + inputmode="numeric" + pattern="[0-9]*" + min="0" + placeholder=${Math.trunc(this.size)} + @keydown=${this._onKeydown} + @input=${stopPropagation} + @click=${stopPropagation} + @pointerdown=${stopPropagation} + @cut=${stopPropagation} + @copy=${stopPropagation} + @paste=${stopPropagation} + /> + `; + } + + renderItem() { + return this.type === 'normal' + ? this.renderItemWithNormal + : this.renderItemWithCheck; + } + + @property({ attribute: false }) + accessor maxSize: number = MAX_SIZE; + + @property({ attribute: false }) + accessor minSize: number = MIN_SIZE; + + @property({ attribute: false }) + accessor onPopperCose: (() => void) | undefined = undefined; + + @property({ attribute: false }) + accessor onSelect: ((size: number) => void) | undefined = undefined; + + @property({ attribute: false }) + accessor size!: number; + + @property({ attribute: false }) + accessor sizeList!: SizeItem[]; + + @property({ attribute: 'data-type' }) + accessor type: 'normal' | 'check' = 'normal'; +} + +declare global { + interface HTMLElementTagNameMap { + 'edgeless-size-panel': EdgelessSizePanel; + } +} diff --git a/blocksuite/blocks/src/root-block/edgeless/components/panel/stroke-style-panel.ts b/blocksuite/blocks/src/root-block/edgeless/components/panel/stroke-style-panel.ts new file mode 100644 index 0000000000..408974622b --- /dev/null +++ b/blocksuite/blocks/src/root-block/edgeless/components/panel/stroke-style-panel.ts @@ -0,0 +1,74 @@ +import { SHAPE_STROKE_COLORS, StrokeStyle } from '@blocksuite/affine-model'; +import { WithDisposable } from '@blocksuite/global/utils'; +import { css, html, LitElement } from 'lit'; +import { property } from 'lit/decorators.js'; + +import type { ColorEvent } from './color-panel.js'; +import { type LineStyleEvent, LineStylesPanel } from './line-styles-panel.js'; + +export class StrokeStylePanel extends WithDisposable(LitElement) { + static override styles = css` + :host { + display: flex; + flex-direction: column; + align-items: center; + gap: 8px; + } + + .line-styles { + display: flex; + flex-direction: row; + gap: 8px; + align-items: center; + } + `; + + override render() { + return html` + <div class="line-styles"> + ${LineStylesPanel({ + selectedLineSize: this.strokeWidth, + selectedLineStyle: this.strokeStyle, + onClick: e => this.setStrokeStyle(e), + lineStyles: [StrokeStyle.Solid, StrokeStyle.Dash], + })} + </div> + <editor-toolbar-separator + data-orientation="horizontal" + ></editor-toolbar-separator> + <edgeless-color-panel + role="listbox" + aria-label="Border colors" + .options=${SHAPE_STROKE_COLORS} + .value=${this.strokeColor} + .hollowCircle=${this.hollowCircle} + @select=${(e: ColorEvent) => this.setStrokeColor(e)} + > + </edgeless-color-panel> + `; + } + + @property({ attribute: false }) + accessor hollowCircle: boolean | undefined = undefined; + + @property({ attribute: false }) + accessor setStrokeColor!: (e: ColorEvent) => void; + + @property({ attribute: false }) + accessor setStrokeStyle!: (e: LineStyleEvent) => void; + + @property({ attribute: false }) + accessor strokeColor!: string; + + @property({ attribute: false }) + accessor strokeStyle!: StrokeStyle; + + @property({ attribute: false }) + accessor strokeWidth!: number; +} + +declare global { + interface HTMLElementTagNameMap { + 'stroke-style-panel': StrokeStylePanel; + } +} diff --git a/blocksuite/blocks/src/root-block/edgeless/components/presentation/edgeless-navigator-black-background.ts b/blocksuite/blocks/src/root-block/edgeless/components/presentation/edgeless-navigator-black-background.ts new file mode 100644 index 0000000000..2f95398fc5 --- /dev/null +++ b/blocksuite/blocks/src/root-block/edgeless/components/presentation/edgeless-navigator-black-background.ts @@ -0,0 +1,122 @@ +import type { FrameBlockModel, RootBlockModel } from '@blocksuite/affine-model'; +import { EditPropsStore } from '@blocksuite/affine-shared/services'; +import { WidgetComponent } from '@blocksuite/block-std'; +import { GfxControllerIdentifier } from '@blocksuite/block-std/gfx'; +import { Bound } from '@blocksuite/global/utils'; +import { effect } from '@preact/signals-core'; +import { css, html, nothing } from 'lit'; +import { state } from 'lit/decorators.js'; + +import type { EdgelessRootBlockComponent } from '../../edgeless-root-block.js'; + +export const EDGELESS_NAVIGATOR_BLACK_BACKGROUND_WIDGET = + 'edgeless-navigator-black-background'; +export class EdgelessNavigatorBlackBackgroundWidget extends WidgetComponent< + RootBlockModel, + EdgelessRootBlockComponent +> { + static override styles = css` + .edgeless-navigator-black-background { + background-color: black; + position: absolute; + z-index: 1; + background-color: transparent; + box-shadow: 0 0 0 5000px black; + } + `; + + private _blackBackground = false; + + get gfx() { + return this.std.get(GfxControllerIdentifier); + } + + private _tryLoadBlackBackground() { + const value = this.std + .get(EditPropsStore) + .getStorage('presentBlackBackground'); + this._blackBackground = value ?? true; + } + + override firstUpdated() { + const { _disposables, gfx, block } = this; + _disposables.add( + block.slots.navigatorFrameChanged.on(frame => { + this.frame = frame; + }) + ); + + _disposables.add( + block.slots.navigatorSettingUpdated.on(({ blackBackground }) => { + if (blackBackground !== undefined) { + this.std + .get(EditPropsStore) + .setStorage('presentBlackBackground', blackBackground); + + this._blackBackground = blackBackground; + + this.show = + blackBackground && + block.gfx.tool.currentToolOption$.peek().type === 'frameNavigator'; + } + }) + ); + + _disposables.add( + effect(() => { + const tool = gfx.tool.currentToolName$.value; + + if (tool !== 'frameNavigator') { + this.show = false; + } else { + this.show = this._blackBackground; + } + }) + ); + + _disposables.add( + block.slots.fullScreenToggled.on( + () => + setTimeout(() => { + this.requestUpdate(); + }, 500) // wait for full screen animation + ) + ); + + this._tryLoadBlackBackground(); + } + + override render() { + const { frame, show, gfx } = this; + + if (!show || !frame) return nothing; + + const bound = Bound.deserialize(frame.xywh); + const zoom = gfx.viewport.zoom; + const width = bound.w * zoom; + const height = bound.h * zoom; + const [x, y] = gfx.viewport.toViewCoord(bound.x, bound.y); + + return html` <style> + .edgeless-navigator-black-background { + width: ${width}px; + height: ${height}px; + top: ${y}px; + left: ${x}px; + } + </style> + <div class="edgeless-navigator-black-background"></div>`; + } + + @state() + private accessor frame: FrameBlockModel | undefined = undefined; + + @state() + private accessor show = false; +} + +declare global { + interface HTMLElementTagNameMap { + 'edgeless-navigator-black-background': EdgelessNavigatorBlackBackgroundWidget; + } +} diff --git a/blocksuite/blocks/src/root-block/edgeless/components/rects/edgeless-dragging-area-rect.ts b/blocksuite/blocks/src/root-block/edgeless/components/rects/edgeless-dragging-area-rect.ts new file mode 100644 index 0000000000..ee7bea3606 --- /dev/null +++ b/blocksuite/blocks/src/root-block/edgeless/components/rects/edgeless-dragging-area-rect.ts @@ -0,0 +1,64 @@ +import type { RootBlockModel } from '@blocksuite/affine-model'; +import { WidgetComponent } from '@blocksuite/block-std'; +import { cssVarV2 } from '@toeverything/theme/v2'; +import { css, html, nothing, unsafeCSS } from 'lit'; +import { styleMap } from 'lit/directives/style-map.js'; + +import type { EdgelessRootBlockComponent } from '../../edgeless-root-block.js'; +import { DefaultTool } from '../../gfx-tool/default-tool.js'; +import { DefaultModeDragType } from '../../gfx-tool/default-tool-ext/ext.js'; + +export const EDGELESS_DRAGGING_AREA_WIDGET = 'edgeless-dragging-area-rect'; + +export class EdgelessDraggingAreaRectWidget extends WidgetComponent< + RootBlockModel, + EdgelessRootBlockComponent +> { + static override styles = css` + .affine-edgeless-dragging-area { + position: absolute; + background: ${unsafeCSS( + cssVarV2('edgeless/selection/selectionMarqueeBackground', '#1E96EB14') + )}; + box-sizing: border-box; + border-width: 1px; + border-style: solid; + border-color: ${unsafeCSS( + cssVarV2('edgeless/selection/selectionMarqueeBorder', '#1E96EB') + )}; + + z-index: 1; + pointer-events: none; + } + `; + + override render() { + const rect = this.block.gfx.tool.draggingViewArea$.value; + const tool = this.block.gfx.tool.currentTool$.value; + + if ( + rect.w === 0 || + rect.h === 0 || + !(tool instanceof DefaultTool) || + tool.dragType !== DefaultModeDragType.Selecting + ) + return nothing; + + const style = { + left: rect.x + 'px', + top: rect.y + 'px', + width: rect.w + 'px', + height: rect.h + 'px', + }; + + return html` + <div class="affine-edgeless-dragging-area" style=${styleMap(style)}></div> + `; + } +} + +declare global { + interface HTMLElementTagNameMap { + 'edgeless-dragging-area-rect': EdgelessDraggingAreaRectWidget; + } +} diff --git a/blocksuite/blocks/src/root-block/edgeless/components/rects/edgeless-selected-rect.ts b/blocksuite/blocks/src/root-block/edgeless/components/rects/edgeless-selected-rect.ts new file mode 100644 index 0000000000..1c87fded91 --- /dev/null +++ b/blocksuite/blocks/src/root-block/edgeless/components/rects/edgeless-selected-rect.ts @@ -0,0 +1,1543 @@ +import { + EMBED_HTML_MIN_HEIGHT, + EMBED_HTML_MIN_WIDTH, + SYNCED_MIN_HEIGHT, + SYNCED_MIN_WIDTH, +} from '@blocksuite/affine-block-embed'; +import { + CanvasElementType, + CommonUtils, + normalizeShapeBound, + OverlayIdentifier, + TextUtils, +} from '@blocksuite/affine-block-surface'; +import { + type BookmarkBlockModel, + ConnectorElementModel, + type EdgelessTextBlockModel, + type EmbedHtmlModel, + type EmbedSyncedDocModel, + FrameBlockModel, + NOTE_MIN_HEIGHT, + NOTE_MIN_WIDTH, + NoteBlockModel, + type RootBlockModel, + ShapeElementModel, + TextElementModel, +} from '@blocksuite/affine-model'; +import { unsafeCSSVarV2 } from '@blocksuite/affine-shared/theme'; +import { + clamp, + requestThrottledConnectedFrame, + stopPropagation, +} from '@blocksuite/affine-shared/utils'; +import { WidgetComponent } from '@blocksuite/block-std'; +import { + type CursorType, + getTopElements, + GfxControllerIdentifier, + GfxExtensionIdentifier, + type GfxModel, +} from '@blocksuite/block-std/gfx'; +import type { + Disposable, + IPoint, + IVec, + PointLocation, +} from '@blocksuite/global/utils'; +import { + assertType, + Bound, + deserializeXYWH, + pickValues, + Slot, +} from '@blocksuite/global/utils'; +import { css, html, nothing } from 'lit'; +import { state } from 'lit/decorators.js'; +import { ifDefined } from 'lit/directives/if-defined.js'; +import { styleMap } from 'lit/directives/style-map.js'; + +import { EMBED_CARD_HEIGHT } from '../../../../_common/consts.js'; +import { isMindmapNode } from '../../../../_common/edgeless/mindmap/index.js'; +import type { EdgelessTextBlockComponent } from '../../../../edgeless-text-block/edgeless-text-block.js'; +import { EDGELESS_TEXT_BLOCK_MIN_WIDTH } from '../../../../edgeless-text-block/edgeless-text-block.js'; +import type { EdgelessRootBlockComponent } from '../../edgeless-root-block.js'; +import type { + EdgelessFrameManager, + FrameOverlay, +} from '../../frame-manager.js'; +import { + AI_CHAT_BLOCK_MAX_HEIGHT, + AI_CHAT_BLOCK_MAX_WIDTH, + AI_CHAT_BLOCK_MIN_HEIGHT, + AI_CHAT_BLOCK_MIN_WIDTH, +} from '../../utils/consts.js'; +import { getElementsWithoutGroup } from '../../utils/group.js'; +import { + getSelectableBounds, + getSelectedRect, + isAIChatBlock, + isAttachmentBlock, + isBookmarkBlock, + isCanvasElement, + isEdgelessTextBlock, + isEmbeddedBlock, + isEmbedFigmaBlock, + isEmbedGithubBlock, + isEmbedHtmlBlock, + isEmbedLinkedDocBlock, + isEmbedLoomBlock, + isEmbedSyncedDocBlock, + isEmbedYoutubeBlock, + isFrameBlock, + isImageBlock, + isNoteBlock, +} from '../../utils/query.js'; +import { + HandleDirection, + ResizeHandles, + type ResizeMode, +} from '../resize/resize-handles.js'; +import { HandleResizeManager } from '../resize/resize-manager.js'; +import { + calcAngle, + calcAngleEdgeWithRotation, + calcAngleWithRotation, + generateCursorUrl, + getResizeLabel, + rotateResizeCursor, +} from '../utils.js'; + +export type SelectedRect = { + left: number; + top: number; + width: number; + height: number; + borderWidth: number; + borderStyle: string; + rotate: number; +}; + +export const EDGELESS_SELECTED_RECT_WIDGET = 'edgeless-selected-rect'; + +export class EdgelessSelectedRectWidget extends WidgetComponent< + RootBlockModel, + EdgelessRootBlockComponent +> { + // disable change-in-update warning + static override enabledWarnings = []; + + static override styles = css` + :host { + display: block; + user-select: none; + contain: size layout; + position: absolute; + top: 0; + left: 0; + z-index: 1; + } + + .affine-edgeless-selected-rect { + position: absolute; + top: 0; + left: 0; + transform-origin: center center; + border-radius: 0; + pointer-events: none; + box-sizing: border-box; + z-index: 1; + border-color: var(--affine-blue); + border-width: 2px; + border-style: solid; + transform: translate(0, 0) rotate(0); + } + + .affine-edgeless-selected-rect[data-locked='true'] { + border-color: ${unsafeCSSVarV2('edgeless/lock/locked', '#00000085')}; + } + + .affine-edgeless-selected-rect .handle { + position: absolute; + user-select: none; + outline: none; + pointer-events: auto; + + /** + * Fix: pointerEvent stops firing after a short time. + * When a gesture is started, the browser intersects the touch-action values of the touched element and its ancestors, + * up to the one that implements the gesture (in other words, the first containing scrolling element) + * https://developer.mozilla.org/en-US/docs/Web/CSS/touch-action + */ + touch-action: none; + } + + .affine-edgeless-selected-rect .handle[aria-label^='top-'], + .affine-edgeless-selected-rect .handle[aria-label^='bottom-'] { + width: 18px; + height: 18px; + box-sizing: border-box; + z-index: 10; + } + + .affine-edgeless-selected-rect .handle[aria-label^='top-'] .resize, + .affine-edgeless-selected-rect .handle[aria-label^='bottom-'] .resize { + position: absolute; + width: 12px; + height: 12px; + box-sizing: border-box; + border-radius: 50%; + border: 2px var(--affine-blue) solid; + background: white; + } + + .affine-edgeless-selected-rect .handle[aria-label^='top-'] .rotate, + .affine-edgeless-selected-rect .handle[aria-label^='bottom-'] .rotate { + position: absolute; + width: 12px; + height: 12px; + box-sizing: border-box; + background: transparent; + } + + /* -18 + 6.5 */ + .affine-edgeless-selected-rect .handle[aria-label='top-left'] { + left: -12px; + top: -12px; + } + .affine-edgeless-selected-rect .handle[aria-label='top-left'] .resize { + right: 0; + bottom: 0; + } + .affine-edgeless-selected-rect .handle[aria-label='top-left'] .rotate { + right: 6px; + bottom: 6px; + } + + .affine-edgeless-selected-rect .handle[aria-label='top-right'] { + top: -12px; + right: -12px; + } + .affine-edgeless-selected-rect .handle[aria-label='top-right'] .resize { + left: 0; + bottom: 0; + } + .affine-edgeless-selected-rect .handle[aria-label='top-right'] .rotate { + left: 6px; + bottom: 6px; + } + + .affine-edgeless-selected-rect .handle[aria-label='bottom-right'] { + right: -12px; + bottom: -12px; + } + .affine-edgeless-selected-rect .handle[aria-label='bottom-right'] .resize { + left: 0; + top: 0; + } + .affine-edgeless-selected-rect .handle[aria-label='bottom-right'] .rotate { + left: 6px; + top: 6px; + } + + .affine-edgeless-selected-rect .handle[aria-label='bottom-left'] { + bottom: -12px; + left: -12px; + } + .affine-edgeless-selected-rect .handle[aria-label='bottom-left'] .resize { + right: 0; + top: 0; + } + .affine-edgeless-selected-rect .handle[aria-label='bottom-left'] .rotate { + right: 6px; + top: 6px; + } + + .affine-edgeless-selected-rect .handle[aria-label='top'], + .affine-edgeless-selected-rect .handle[aria-label='bottom'], + .affine-edgeless-selected-rect .handle[aria-label='left'], + .affine-edgeless-selected-rect .handle[aria-label='right'] { + border: 0; + background: transparent; + border-color: var('--affine-blue'); + } + + .affine-edgeless-selected-rect .handle[aria-label='left'], + .affine-edgeless-selected-rect .handle[aria-label='right'] { + top: 0; + bottom: 0; + height: 100%; + width: 6px; + } + + .affine-edgeless-selected-rect .handle[aria-label='top'], + .affine-edgeless-selected-rect .handle[aria-label='bottom'] { + left: 0; + right: 0; + width: 100%; + height: 6px; + } + + /* calc(-1px - (6px - 1px) / 2) = -3.5px */ + .affine-edgeless-selected-rect .handle[aria-label='left'] { + left: -3.5px; + } + .affine-edgeless-selected-rect .handle[aria-label='right'] { + right: -3.5px; + } + .affine-edgeless-selected-rect .handle[aria-label='top'] { + top: -3.5px; + } + .affine-edgeless-selected-rect .handle[aria-label='bottom'] { + bottom: -3.5px; + } + + .affine-edgeless-selected-rect .handle[aria-label='top'] .resize, + .affine-edgeless-selected-rect .handle[aria-label='bottom'] .resize, + .affine-edgeless-selected-rect .handle[aria-label='left'] .resize, + .affine-edgeless-selected-rect .handle[aria-label='right'] .resize { + width: 100%; + height: 100%; + } + + .affine-edgeless-selected-rect .handle[aria-label='top'] .resize:after, + .affine-edgeless-selected-rect .handle[aria-label='bottom'] .resize:after, + .affine-edgeless-selected-rect .handle[aria-label='left'] .resize:after, + .affine-edgeless-selected-rect .handle[aria-label='right'] .resize:after { + position: absolute; + width: 7px; + height: 7px; + box-sizing: border-box; + border-radius: 6px; + z-index: 10; + content: ''; + background: white; + } + + .affine-edgeless-selected-rect + .handle[aria-label='top'] + .transparent-handle:after, + .affine-edgeless-selected-rect + .handle[aria-label='bottom'] + .transparent-handle:after, + .affine-edgeless-selected-rect + .handle[aria-label='left'] + .transparent-handle:after, + .affine-edgeless-selected-rect + .handle[aria-label='right'] + .transparent-handle:after { + opacity: 0; + } + + .affine-edgeless-selected-rect .handle[aria-label='left'] .resize:after, + .affine-edgeless-selected-rect .handle[aria-label='right'] .resize:after { + top: calc(50% - 6px); + } + + .affine-edgeless-selected-rect .handle[aria-label='top'] .resize:after, + .affine-edgeless-selected-rect .handle[aria-label='bottom'] .resize:after { + left: calc(50% - 6px); + } + + .affine-edgeless-selected-rect .handle[aria-label='left'] .resize:after { + left: -0.5px; + } + .affine-edgeless-selected-rect .handle[aria-label='right'] .resize:after { + right: -0.5px; + } + .affine-edgeless-selected-rect .handle[aria-label='top'] .resize:after { + top: -0.5px; + } + .affine-edgeless-selected-rect .handle[aria-label='bottom'] .resize:after { + bottom: -0.5px; + } + + .affine-edgeless-selected-rect .handle .resize::before { + content: ''; + display: none; + position: absolute; + width: 20px; + height: 20px; + background-image: url("data:image/svg+xml,%3Csvg width='26' height='26' viewBox='0 0 26 26' fill='none' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M23 3H19C10.1634 3 3 10.1634 3 19V23' stroke='black' stroke-opacity='0.3' stroke-width='5' stroke-linecap='round'/%3E%3C/svg%3E"); + background-size: contain; + background-repeat: no-repeat; + } + .affine-edgeless-selected-rect[data-mode='scale'] + .handle[aria-label='top-left'] + .resize:hover::before, + .affine-edgeless-selected-rect[data-scale-direction='top-left'][data-scale-percent] + .handle[aria-label='top-left'] + .resize::before { + display: block; + top: 0px; + left: 0px; + transform: translate(-100%, -100%); + } + .affine-edgeless-selected-rect[data-mode='scale'] + .handle[aria-label='top-right'] + .resize:hover::before, + .affine-edgeless-selected-rect[data-scale-direction='top-right'][data-scale-percent] + .handle[aria-label='top-right'] + .resize::before { + display: block; + top: 0px; + right: 0px; + transform: translate(100%, -100%) rotate(90deg); + } + .affine-edgeless-selected-rect[data-mode='scale'] + .handle[aria-label='bottom-right'] + .resize:hover::before, + .affine-edgeless-selected-rect[data-scale-direction='bottom-right'][data-scale-percent] + .handle[aria-label='bottom-right'] + .resize::before { + display: block; + bottom: 0px; + right: 0px; + transform: translate(100%, 100%) rotate(180deg); + } + .affine-edgeless-selected-rect[data-mode='scale'] + .handle[aria-label='bottom-left'] + .resize:hover::before, + .affine-edgeless-selected-rect[data-scale-direction='bottom-left'][data-scale-percent] + .handle[aria-label='bottom-left'] + .resize::before { + display: block; + bottom: 0px; + left: 0px; + transform: translate(-100%, 100%) rotate(-90deg); + } + + .affine-edgeless-selected-rect::after { + content: attr(data-scale-percent); + display: none; + position: absolute; + color: var(--affine-icon-color); + font-feature-settings: + 'clig' off, + 'liga' off; + font-family: var(--affine-font-family); + font-size: 12px; + font-style: normal; + font-weight: 400; + line-height: 24px; + } + .affine-edgeless-selected-rect[data-scale-direction='top-left']::after { + display: block; + top: -20px; + left: -20px; + transform: translate(-100%, -100%); + } + .affine-edgeless-selected-rect[data-scale-direction='top-right']::after { + display: block; + top: -20px; + right: -20px; + transform: translate(100%, -100%); + } + .affine-edgeless-selected-rect[data-scale-direction='bottom-right']::after { + display: block; + bottom: -20px; + right: -20px; + transform: translate(100%, 100%); + } + .affine-edgeless-selected-rect[data-scale-direction='bottom-left']::after { + display: block; + bottom: -20px; + left: -20px; + transform: translate(-100%, 100%); + } + `; + + private _cursorRotate = 0; + + private _dragEndCallback: (() => void)[] = []; + + private _initSelectedSlot = () => { + this._propDisposables.forEach(disposable => disposable.dispose()); + this._propDisposables = []; + + this.selection.selectedElements.forEach(element => { + if ('flavour' in element) { + this._propDisposables.push( + element.propsUpdated.on(() => { + this._updateOnElementChange(element.id); + }) + ); + } + }); + }; + + private _onDragEnd = () => { + this.slots.dragEnd.emit(); + + this.doc.transact(() => { + this._dragEndCallback.forEach(cb => cb()); + }); + + this._dragEndCallback = []; + this._isWidthLimit = false; + this._isHeightLimit = false; + + this._updateCursor(false); + + this._scalePercent = undefined; + this._scaleDirection = undefined; + this._updateMode(); + + this.block.slots.elementResizeEnd.emit(); + + this.frameOverlay.clear(); + }; + + private _onDragMove = ( + newBounds: Map< + string, + { + bound: Bound; + path?: PointLocation[]; + matrix?: DOMMatrix; + } + >, + direction: HandleDirection + ) => { + this.slots.dragMove.emit(); + + const { gfx } = this; + + newBounds.forEach(({ bound, matrix, path }, id) => { + const element = gfx.getElementById(id) as GfxModel; + if (!element) return; + + if (isNoteBlock(element)) { + this.#adjustNote(element, bound, direction); + return; + } + + if (isEdgelessTextBlock(element)) { + this.#adjustEdgelessText(element, bound, direction); + return; + } + + if (isEmbedSyncedDocBlock(element)) { + this.#adjustEmbedSyncedDoc(element, bound, direction); + return; + } + + if (isEmbedHtmlBlock(element)) { + this.#adjustEmbedHtml(element, bound, direction); + return; + } + + if (isAIChatBlock(element)) { + this.#adjustAIChat(element, bound, direction); + return; + } + + if (this._isProportionalElement(element)) { + this.#adjustProportional(element, bound, direction); + return; + } + + if (element instanceof TextElementModel) { + this.#adjustText(element, bound, direction); + return; + } + + if (element instanceof ShapeElementModel) { + this.#adjustShape(element, bound, direction); + return; + } + + if (element instanceof ConnectorElementModel && matrix && path) { + this.#adjustConnector(element, bound, matrix, path); + return; + } + + if (element instanceof FrameBlockModel) { + this.#adjustFrame(element, bound); + return; + } + + this.#adjustUseFallback(element, bound, direction); + }); + }; + + private _onDragRotate = (center: IPoint, delta: number) => { + this.slots.dragRotate.emit(); + + const { selection } = this; + const m = new DOMMatrix() + .translateSelf(center.x, center.y) + .rotateSelf(delta) + .translateSelf(-center.x, -center.y); + + const elements = selection.selectedElements.filter( + element => + isImageBlock(element) || + isEdgelessTextBlock(element) || + isCanvasElement(element) + ); + + getElementsWithoutGroup(elements).forEach(element => { + const { id, rotate } = element; + const bounds = Bound.deserialize(element.xywh); + const originalCenter = bounds.center; + const point = new DOMPoint(...originalCenter).matrixTransform(m); + bounds.center = [point.x, point.y]; + + if ( + isCanvasElement(element) && + element instanceof ConnectorElementModel + ) { + this.#adjustConnector( + element, + bounds, + m, + element.absolutePath.map(p => p.clone()) + ); + } else { + this.gfx.updateElement(id, { + xywh: bounds.serialize(), + rotate: CommonUtils.normalizeDegAngle(rotate + delta), + }); + } + }); + + this._updateCursor(true, { type: 'rotate', angle: delta }); + this._updateMode(); + }; + + private _onDragStart = () => { + this.slots.dragStart.emit(); + + const rotation = this._resizeManager.rotation; + + this._dragEndCallback = []; + this.block.slots.elementResizeStart.emit(); + this.selection.selectedElements.forEach(el => { + el.stash('xywh'); + + if (el instanceof NoteBlockModel) { + el.stash('edgeless'); + } + + if (rotation) { + el.stash('rotate' as 'xywh'); + } + + if (el instanceof TextElementModel && !rotation) { + el.stash('fontSize'); + el.stash('hasMaxWidth'); + } + + this._dragEndCallback.push(() => { + el.pop('xywh'); + + if (el instanceof NoteBlockModel) { + el.pop('edgeless'); + } + + if (rotation) { + el.pop('rotate' as 'xywh'); + } + + if (el instanceof TextElementModel && !rotation) { + el.pop('fontSize'); + el.pop('hasMaxWidth'); + } + }); + }); + this._updateResizeManagerState(true); + }; + + private _propDisposables: Disposable[] = []; + + private _resizeManager: HandleResizeManager; + + private _updateCursor = ( + dragging: boolean, + options?: { + type: 'resize' | 'rotate'; + angle?: number; + target?: HTMLElement; + point?: IVec; + } + ) => { + let cursor: CursorType = 'default'; + + if (dragging && options) { + const { type, target, point } = options; + let { angle } = options; + if (type === 'rotate') { + if (target && point) { + angle = calcAngle(target, point, 45); + } + this._cursorRotate += angle || 0; + cursor = generateCursorUrl(this._cursorRotate); + } else { + if (this.resizeMode === 'edge') { + cursor = 'ew-resize'; + } else if (target && point) { + const label = getResizeLabel(target); + const { width, height, left, top } = this._selectedRect; + if ( + label === 'top' || + label === 'bottom' || + label === 'left' || + label === 'right' + ) { + angle = calcAngleEdgeWithRotation( + target, + this._selectedRect.rotate + ); + } else { + angle = calcAngleWithRotation( + target, + point, + new DOMRect( + left + this.gfx.viewport.left, + top + this.gfx.viewport.top, + width, + height + ), + this._selectedRect.rotate + ); + } + cursor = rotateResizeCursor((angle * Math.PI) / 180); + } + } + } else { + this._cursorRotate = 0; + } + this.gfx.cursor$.value = cursor; + }; + + private _updateMode = () => { + if (this._cursorRotate) { + this._mode = 'rotate'; + return; + } + + const { selection } = this; + const elements = selection.selectedElements; + + if (elements.length !== 1) this._mode = 'scale'; + + const element = elements[0]; + + if (isNoteBlock(element) || isEmbedSyncedDocBlock(element)) { + this._mode = this._shiftKey ? 'scale' : 'resize'; + } else if (this._isProportionalElement(element)) { + this._mode = 'scale'; + } else { + this._mode = 'resize'; + } + + if (this._mode !== 'scale') { + this._scalePercent = undefined; + this._scaleDirection = undefined; + } + }; + + private _updateOnElementChange = ( + element: string | { id: string }, + fromRemote: boolean = false + ) => { + if ((fromRemote && this._resizeManager.dragging) || !this.isConnected) { + return; + } + + const id = typeof element === 'string' ? element : element.id; + + if (this._resizeManager.bounds.has(id) || this.selection.has(id)) { + this._updateSelectedRect(); + this._updateMode(); + } + }; + + private _updateOnSelectionChange = () => { + this._initSelectedSlot(); + this._updateSelectedRect(); + this._updateResizeManagerState(true); + // Reset the cursor + this._updateCursor(false); + this._updateMode(); + }; + + private _updateOnViewportChange = () => { + if (this.selection.empty) { + return; + } + + this._updateSelectedRect(); + this._updateMode(); + }; + + /** + * @param refresh indicate whether to completely refresh the state of resize manager, otherwise only update the position + */ + private _updateResizeManagerState = (refresh: boolean) => { + const { + _resizeManager, + _selectedRect, + resizeMode, + zoom, + selection: { selectedElements }, + } = this; + + const rect = getSelectedRect(selectedElements); + const proportion = selectedElements.some(element => + this._isProportionalElement(element) + ); + // if there are more than one element, we need to refresh the state of resize manager + if (selectedElements.length > 1) refresh = true; + + _resizeManager.updateState( + resizeMode, + _selectedRect.rotate, + zoom, + refresh ? undefined : rect, + refresh ? rect : undefined, + proportion + ); + _resizeManager.updateBounds(getSelectableBounds(selectedElements)); + }; + + @state() + private accessor _selectedRect: SelectedRect = { + width: 0, + height: 0, + left: 0, + top: 0, + rotate: 0, + borderWidth: 0, + borderStyle: 'solid', + }; + + private _updateSelectedRect = requestThrottledConnectedFrame(() => { + const { zoom, selection, gfx } = this; + + const elements = selection.selectedElements; + // in surface + const rect = getSelectedRect(elements); + + // in viewport + const [left, top] = gfx.viewport.toViewCoord(rect.left, rect.top); + const [width, height] = [rect.width * zoom, rect.height * zoom]; + + let rotate = 0; + if (elements.length === 1 && elements[0].rotate) { + rotate = elements[0].rotate; + } + + this._selectedRect = { + width, + height, + left, + top, + rotate, + borderStyle: 'solid', + borderWidth: 2, + }; + }, this); + + readonly slots = { + dragStart: new Slot(), + dragMove: new Slot(), + dragRotate: new Slot(), + dragEnd: new Slot(), + }; + + get dragDirection() { + return this._resizeManager.dragDirection; + } + + get edgelessSlots() { + return this.block.slots; + } + + get frameOverlay() { + return this.std.get(OverlayIdentifier('frame')) as FrameOverlay; + } + + get gfx() { + return this.std.get(GfxControllerIdentifier); + } + + get resizeMode(): ResizeMode { + const elements = this.selection.selectedElements; + + let areAllConnectors = true; + let areAllIndependentConnectors = elements.length > 1; + let areAllShapes = true; + let areAllTexts = true; + let hasMindmapNode = false; + + for (const element of elements) { + if (isNoteBlock(element) || isEmbedSyncedDocBlock(element)) { + areAllConnectors = false; + if (this._shiftKey) { + areAllShapes = false; + areAllTexts = false; + } + } else if (isEmbedHtmlBlock(element)) { + areAllConnectors = false; + } else if (isFrameBlock(element)) { + areAllConnectors = false; + } else if (this._isProportionalElement(element)) { + areAllConnectors = false; + areAllShapes = false; + areAllTexts = false; + } else if (isEdgelessTextBlock(element)) { + areAllConnectors = false; + areAllShapes = false; + } else { + assertType<BlockSuite.SurfaceElementModel>(element); + if (element.type === CanvasElementType.CONNECTOR) { + const connector = element as ConnectorElementModel; + areAllIndependentConnectors &&= !( + connector.source.id || connector.target.id + ); + } else { + areAllConnectors = false; + } + if ( + element.type !== CanvasElementType.SHAPE && + element.type !== CanvasElementType.GROUP + ) + areAllShapes = false; + if (element.type !== CanvasElementType.TEXT) areAllTexts = false; + + if (isMindmapNode(element)) { + hasMindmapNode = true; + } + } + } + + if (areAllConnectors) { + if (areAllIndependentConnectors) { + return 'all'; + } else { + return 'none'; + } + } + + if (hasMindmapNode) return 'none'; + if (areAllShapes) return 'all'; + if (areAllTexts) return 'edgeAndCorner'; + + return 'corner'; + } + + get selection() { + return this.gfx.selection; + } + + get surface() { + return this.gfx.surface; + } + + get zoom() { + return this.gfx.viewport.zoom; + } + + constructor() { + super(); + this._resizeManager = new HandleResizeManager( + this._onDragStart, + this._onDragMove, + this._onDragRotate, + this._onDragEnd + ); + this.addEventListener('pointerdown', stopPropagation); + } + + /** + * TODO: Remove this function after the edgeless refactor completed + * This function is used to adjust the element bound and scale + * Should not be used in the future + * Related issue: https://linear.app/affine-design/issue/BS-1009/ + * @deprecated + */ + #adjustAIChat( + element: BlockSuite.EdgelessModel, + bound: Bound, + direction: HandleDirection + ) { + const curBound = Bound.deserialize(element.xywh); + + let scale = 1; + if ('scale' in element) { + scale = element.scale as number; + } + let width = curBound.w / scale; + let height = curBound.h / scale; + if (this._shiftKey) { + scale = bound.w / width; + this._scalePercent = `${Math.round(scale * 100)}%`; + this._scaleDirection = direction; + } + + width = bound.w / scale; + width = clamp(width, AI_CHAT_BLOCK_MIN_WIDTH, AI_CHAT_BLOCK_MAX_WIDTH); + bound.w = width * scale; + + height = bound.h / scale; + height = clamp(height, AI_CHAT_BLOCK_MIN_HEIGHT, AI_CHAT_BLOCK_MAX_HEIGHT); + bound.h = height * scale; + + this._isWidthLimit = + width === AI_CHAT_BLOCK_MIN_WIDTH || width === AI_CHAT_BLOCK_MAX_WIDTH; + this._isHeightLimit = + height === AI_CHAT_BLOCK_MIN_HEIGHT || + height === AI_CHAT_BLOCK_MAX_HEIGHT; + + this.gfx.updateElement(element.id, { + scale, + xywh: bound.serialize(), + }); + } + + #adjustConnector( + element: ConnectorElementModel, + bounds: Bound, + matrix: DOMMatrix, + originalPath: PointLocation[] + ) { + const props = element.resize(bounds, originalPath, matrix); + this.gfx.updateElement(element.id, props); + } + + #adjustEdgelessText( + element: EdgelessTextBlockModel, + bound: Bound, + direction: HandleDirection + ) { + const oldXYWH = Bound.deserialize(element.xywh); + if ( + direction === HandleDirection.TopLeft || + direction === HandleDirection.TopRight || + direction === HandleDirection.BottomRight || + direction === HandleDirection.BottomLeft + ) { + const newScale = element.scale * (bound.w / oldXYWH.w); + this._scalePercent = `${Math.round(newScale * 100)}%`; + this._scaleDirection = direction; + + bound.h = bound.w * (oldXYWH.h / oldXYWH.w); + this.gfx.updateElement(element.id, { + scale: newScale, + xywh: bound.serialize(), + }); + } else if ( + direction === HandleDirection.Left || + direction === HandleDirection.Right + ) { + const textPortal = this.host.view.getBlock( + element.id + ) as EdgelessTextBlockComponent | null; + if (!textPortal) return; + + if (!textPortal.checkWidthOverflow(bound.w)) return; + + const newRealWidth = clamp( + bound.w / element.scale, + EDGELESS_TEXT_BLOCK_MIN_WIDTH, + Infinity + ); + bound.w = newRealWidth * element.scale; + this.gfx.updateElement(element.id, { + xywh: Bound.serialize({ + ...bound, + h: oldXYWH.h, + }), + hasMaxWidth: true, + }); + } + } + + #adjustEmbedHtml( + element: EmbedHtmlModel, + bound: Bound, + _direction: HandleDirection + ) { + bound.w = clamp(bound.w, EMBED_HTML_MIN_WIDTH, Infinity); + bound.h = clamp(bound.h, EMBED_HTML_MIN_HEIGHT, Infinity); + + this._isWidthLimit = bound.w === EMBED_HTML_MIN_WIDTH; + this._isHeightLimit = bound.h === EMBED_HTML_MIN_HEIGHT; + + this.gfx.updateElement(element.id, { + xywh: bound.serialize(), + }); + } + + #adjustEmbedSyncedDoc( + element: EmbedSyncedDocModel, + bound: Bound, + direction: HandleDirection + ) { + const curBound = Bound.deserialize(element.xywh); + + let scale = element.scale ?? 1; + let width = curBound.w / scale; + let height = curBound.h / scale; + if (this._shiftKey) { + scale = bound.w / width; + this._scalePercent = `${Math.round(scale * 100)}%`; + this._scaleDirection = direction; + } + + width = bound.w / scale; + width = clamp(width, SYNCED_MIN_WIDTH, Infinity); + bound.w = width * scale; + + height = bound.h / scale; + height = clamp(height, SYNCED_MIN_HEIGHT, Infinity); + bound.h = height * scale; + + this._isWidthLimit = width === SYNCED_MIN_WIDTH; + this._isHeightLimit = height === SYNCED_MIN_HEIGHT; + + this.gfx.updateElement(element.id, { + scale, + xywh: bound.serialize(), + }); + } + + #adjustFrame(frame: FrameBlockModel, bound: Bound) { + const frameManager = this.std.get( + GfxExtensionIdentifier('frame-manager') + ) as EdgelessFrameManager; + + const oldChildren = frameManager.getChildElementsInFrame(frame); + + this.gfx.updateElement(frame.id, { + xywh: bound.serialize(), + }); + + const newChildren = getTopElements( + frameManager.getElementsInFrameBound(frame) + ).concat( + oldChildren.filter(oldChild => { + return frame.intersectsBound(oldChild.elementBound); + }) + ); + + frameManager.removeAllChildrenFromFrame(frame); + frameManager.addElementsToFrame(frame, newChildren); + this.frameOverlay.highlight(frame, true, false); + } + + #adjustNote( + element: NoteBlockModel, + bound: Bound, + direction: HandleDirection + ) { + const curBound = Bound.deserialize(element.xywh); + + let scale = element.edgeless.scale ?? 1; + if (this._shiftKey) { + scale = (bound.w / curBound.w) * scale; + this._scalePercent = `${Math.round(scale * 100)}%`; + this._scaleDirection = direction; + } + + bound.w = clamp(bound.w, NOTE_MIN_WIDTH * scale, Infinity); + bound.h = clamp(bound.h, NOTE_MIN_HEIGHT * scale, Infinity); + + this._isWidthLimit = bound.w === NOTE_MIN_WIDTH * scale; + this._isHeightLimit = bound.h === NOTE_MIN_HEIGHT * scale; + + if (bound.h >= NOTE_MIN_HEIGHT * scale) { + this.doc.updateBlock(element, () => { + element.edgeless.collapse = true; + element.edgeless.collapsedHeight = bound.h / scale; + }); + } + + this.gfx.updateElement(element.id, { + edgeless: { + ...element.edgeless, + scale, + }, + xywh: bound.serialize(), + }); + } + + #adjustProportional( + element: BlockSuite.EdgelessModel, + bound: Bound, + direction: HandleDirection + ) { + const curBound = Bound.deserialize(element.xywh); + + if (isImageBlock(element)) { + const { height } = element; + if (height) { + this._scalePercent = `${Math.round((bound.h / height) * 100)}%`; + this._scaleDirection = direction; + } + } else { + const cardStyle = (element as BookmarkBlockModel).style; + const height = EMBED_CARD_HEIGHT[cardStyle]; + this._scalePercent = `${Math.round((bound.h / height) * 100)}%`; + this._scaleDirection = direction; + } + if ( + direction === HandleDirection.Left || + direction === HandleDirection.Right + ) { + bound.h = (curBound.h / curBound.w) * bound.w; + } else if ( + direction === HandleDirection.Top || + direction === HandleDirection.Bottom + ) { + bound.w = (curBound.w / curBound.h) * bound.h; + } + + this.gfx.updateElement(element.id, { + xywh: bound.serialize(), + }); + } + + #adjustShape( + element: ShapeElementModel, + bound: Bound, + _direction: HandleDirection + ) { + bound = normalizeShapeBound(element, bound); + this.gfx.updateElement(element.id, { + xywh: bound.serialize(), + }); + } + + #adjustText( + element: TextElementModel, + bound: Bound, + direction: HandleDirection + ) { + let p = 1; + if ( + direction === HandleDirection.Left || + direction === HandleDirection.Right + ) { + const { + text: yText, + fontFamily, + fontSize, + fontStyle, + fontWeight, + hasMaxWidth, + } = element; + // If the width of the text element has been changed by dragging, + // We need to set hasMaxWidth to true for wrapping the text + bound = TextUtils.normalizeTextBound( + { + yText, + fontFamily, + fontSize, + fontStyle, + fontWeight, + hasMaxWidth, + }, + bound, + true + ); + // If the width of the text element has been changed by dragging, + // We need to set hasMaxWidth to true for wrapping the text + this.gfx.updateElement(element.id, { + xywh: bound.serialize(), + fontSize: element.fontSize * p, + hasMaxWidth: true, + }); + } else { + p = bound.h / element.h; + // const newFontsize = element.fontSize * p; + // bound = normalizeTextBound(element, bound, false, newFontsize); + + this.gfx.updateElement(element.id, { + xywh: bound.serialize(), + fontSize: element.fontSize * p, + }); + } + } + + #adjustUseFallback( + element: BlockSuite.EdgelessModel, + bound: Bound, + _direction: HandleDirection + ) { + this.gfx.updateElement(element.id, { + xywh: bound.serialize(), + }); + } + + private _canAutoComplete() { + return ( + !this.autoCompleteOff && + !this._isResizing && + this.selection.selectedElements.length === 1 && + (this.selection.selectedElements[0] instanceof ShapeElementModel || + isNoteBlock(this.selection.selectedElements[0])) + ); + } + + private _canRotate() { + return !this.selection.selectedElements.every( + ele => + isNoteBlock(ele) || + isFrameBlock(ele) || + isBookmarkBlock(ele) || + isAttachmentBlock(ele) || + isEmbeddedBlock(ele) + ); + } + + private _isProportionalElement(element: BlockSuite.EdgelessModel) { + return ( + isAttachmentBlock(element) || + isImageBlock(element) || + isBookmarkBlock(element) || + isEmbedFigmaBlock(element) || + isEmbedGithubBlock(element) || + isEmbedYoutubeBlock(element) || + isEmbedLoomBlock(element) || + isEmbedLinkedDocBlock(element) + ); + } + + private _shouldRenderSelection(elements?: BlockSuite.EdgelessModel[]) { + elements = elements ?? this.selection.selectedElements; + return elements.length > 0 && !this.selection.editing; + } + + override firstUpdated() { + const { _disposables, edgelessSlots, block, selection, gfx } = this; + + _disposables.add( + // viewport zooming / scrolling + gfx.viewport.viewportUpdated.on(this._updateOnViewportChange) + ); + + pickValues(gfx.surface!, [ + 'elementAdded', + 'elementRemoved', + 'elementUpdated', + ]).forEach(slot => { + _disposables.add(slot.on(this._updateOnElementChange)); + }); + + _disposables.add( + this.doc.slots.blockUpdated.on(this._updateOnElementChange) + ); + + _disposables.add( + edgelessSlots.pressShiftKeyUpdated.on(pressed => { + this._shiftKey = pressed; + this._resizeManager.onPressShiftKey(pressed); + this._updateSelectedRect(); + this._updateMode(); + }) + ); + + _disposables.add(selection.slots.updated.on(this._updateOnSelectionChange)); + + _disposables.add( + block.slots.readonlyUpdated.on(() => this.requestUpdate()) + ); + + _disposables.add( + block.slots.elementResizeStart.on(() => (this._isResizing = true)) + ); + _disposables.add( + block.slots.elementResizeEnd.on(() => (this._isResizing = false)) + ); + _disposables.add(() => { + this._propDisposables.forEach(disposable => disposable.dispose()); + }); + } + + override render() { + if (!this.isConnected) return nothing; + + const { selection } = this; + const elements = selection.selectedElements; + + if (!this._shouldRenderSelection(elements)) return nothing; + + const { + block, + gfx, + doc, + resizeMode, + _resizeManager, + _selectedRect, + _updateCursor, + } = this; + + const hasResizeHandles = !selection.editing && !doc.readonly; + const inoperable = selection.inoperable; + const hasElementLocked = elements.some(element => element.isLocked()); + const handlers = []; + + if (!inoperable) { + const resizeHandles = + hasResizeHandles && !hasElementLocked + ? ResizeHandles( + resizeMode, + (e: PointerEvent, direction: HandleDirection) => { + const target = e.target as HTMLElement; + if (target.classList.contains('rotate') && !this._canRotate()) { + return; + } + const proportional = elements.some( + el => el instanceof TextElementModel + ); + _resizeManager.onPointerDown(e, direction, proportional); + }, + ( + dragging: boolean, + options?: { + type: 'resize' | 'rotate'; + angle?: number; + target?: HTMLElement; + point?: IVec; + } + ) => { + if (!this._canRotate() && options?.type === 'rotate') return; + _updateCursor(dragging, options); + } + ) + : nothing; + + const connectorHandle = + elements.length === 1 && + elements[0] instanceof ConnectorElementModel && + !elements[0].isLocked() + ? html` + <edgeless-connector-handle + .connector=${elements[0]} + .edgeless=${block} + ></edgeless-connector-handle> + ` + : nothing; + + const elementHandle = + elements.length > 1 && + !elements.reduce( + (p, e) => p && e instanceof ConnectorElementModel, + true + ) + ? elements.map(element => { + const [modelX, modelY, w, h] = deserializeXYWH(element.xywh); + const [x, y] = gfx.viewport.toViewCoord(modelX, modelY); + const { left, top, borderWidth } = this._selectedRect; + const style = { + position: 'absolute', + boxSizing: 'border-box', + left: `${x - left - borderWidth}px`, + top: `${y - top - borderWidth}px`, + width: `${w * this.zoom}px`, + height: `${h * this.zoom}px`, + transform: `rotate(${element.rotate}deg)`, + border: `1px solid var(--affine-primary-color)`, + }; + return html`<div + class="element-handle" + style=${styleMap(style)} + ></div>`; + }) + : nothing; + + handlers.push(resizeHandles, connectorHandle, elementHandle); + } + + const isConnector = + elements.length === 1 && elements[0] instanceof ConnectorElementModel; + + return html` + <style> + .affine-edgeless-selected-rect .handle[aria-label='right']::after { + content: ''; + display: ${this._isWidthLimit ? 'initial' : 'none'}; + position: absolute; + top: 0; + left: 1.5px; + width: 2px; + height: 100%; + background: var(--affine-error-color); + filter: drop-shadow(-6px 0px 12px rgba(235, 67, 53, 0.35)); + } + + .affine-edgeless-selected-rect .handle[aria-label='bottom']::after { + content: ''; + display: ${this._isHeightLimit ? 'initial' : 'none'}; + position: absolute; + top: 1.5px; + left: 0px; + width: 100%; + height: 2px; + background: var(--affine-error-color); + filter: drop-shadow(-6px 0px 12px rgba(235, 67, 53, 0.35)); + } + </style> + + ${!doc.readonly && !inoperable && this._canAutoComplete() + ? html`<edgeless-auto-complete + .current=${this.selection.selectedElements[0]} + .edgeless=${block} + .selectedRect=${_selectedRect} + > + </edgeless-auto-complete>` + : nothing} + + <div + class="affine-edgeless-selected-rect" + style=${styleMap({ + width: `${_selectedRect.width}px`, + height: `${_selectedRect.height}px`, + borderWidth: `${_selectedRect.borderWidth}px`, + borderStyle: isConnector ? 'none' : _selectedRect.borderStyle, + transform: `translate(${_selectedRect.left}px, ${_selectedRect.top}px) rotate(${_selectedRect.rotate}deg)`, + })} + disabled="true" + data-mode=${this._mode} + data-scale-percent=${ifDefined(this._scalePercent)} + data-scale-direction=${ifDefined(this._scaleDirection)} + data-locked=${hasElementLocked} + > + ${handlers} + </div> + `; + } + + @state() + private accessor _isHeightLimit = false; + + @state() + private accessor _isResizing = false; + + @state() + private accessor _isWidthLimit = false; + + @state() + private accessor _mode: 'resize' | 'scale' | 'rotate' = 'resize'; + + @state() + private accessor _scaleDirection: HandleDirection | undefined = undefined; + + @state() + private accessor _scalePercent: string | undefined = undefined; + + @state() + private accessor _shiftKey = false; + + @state() + accessor autoCompleteOff = false; +} + +declare global { + interface HTMLElementTagNameMap { + 'edgeless-selected-rect': EdgelessSelectedRectWidget; + } +} diff --git a/blocksuite/blocks/src/root-block/edgeless/components/resize/resize-handles.ts b/blocksuite/blocks/src/root-block/edgeless/components/resize/resize-handles.ts new file mode 100644 index 0000000000..5e988044a6 --- /dev/null +++ b/blocksuite/blocks/src/root-block/edgeless/components/resize/resize-handles.ts @@ -0,0 +1,219 @@ +import type { IVec } from '@blocksuite/global/utils'; +import { html, nothing } from 'lit'; + +export enum HandleDirection { + Bottom = 'bottom', + BottomLeft = 'bottom-left', + BottomRight = 'bottom-right', + Left = 'left', + Right = 'right', + Top = 'top', + TopLeft = 'top-left', + TopRight = 'top-right', +} + +function ResizeHandle( + handleDirection: HandleDirection, + onPointerDown?: (e: PointerEvent, direction: HandleDirection) => void, + updateCursor?: ( + dragging: boolean, + options?: { + type: 'resize' | 'rotate'; + target?: HTMLElement; + point?: IVec; + } + ) => void, + hideEdgeHandle?: boolean +) { + const handlerPointerDown = (e: PointerEvent) => { + e.stopPropagation(); + onPointerDown && onPointerDown(e, handleDirection); + }; + + const pointerEnter = (type: 'resize' | 'rotate') => (e: PointerEvent) => { + e.stopPropagation(); + if (e.buttons === 1 || !updateCursor) return; + + const { clientX, clientY } = e; + const target = e.target as HTMLElement; + const point: IVec = [clientX, clientY]; + + updateCursor(true, { type, point, target }); + }; + + const pointerLeave = (e: PointerEvent) => { + e.stopPropagation(); + if (e.buttons === 1 || !updateCursor) return; + + updateCursor(false); + }; + + const rotationTpl = + handleDirection === HandleDirection.Top || + handleDirection === HandleDirection.Bottom || + handleDirection === HandleDirection.Left || + handleDirection === HandleDirection.Right + ? nothing + : html`<div + class="rotate" + @pointerover=${pointerEnter('rotate')} + @pointerout=${pointerLeave} + ></div>`; + + return html`<div + class="handle" + aria-label=${handleDirection} + @pointerdown=${handlerPointerDown} + > + ${rotationTpl} + <div + class="resize${hideEdgeHandle && ' transparent-handle'}" + @pointerover=${pointerEnter('resize')} + @pointerout=${pointerLeave} + ></div> + </div>`; +} + +/** + * Indicate how selected elements can be resized. + * + * - edge: The selected elements can only be resized dragging edge, usually when note element is selected + * - all: The selected elements can be resize both dragging edge or corner, usually when all elements are `shape` + * - none: The selected elements can't be resized, usually when all elements are `connector` + * - corner: The selected elements can only be resize dragging corner, this is by default mode + * - edgeAndCorner: The selected elements can be resize both dragging left right edge or corner, usually when all elements are 'text' + */ +export type ResizeMode = 'edge' | 'all' | 'none' | 'corner' | 'edgeAndCorner'; + +export function ResizeHandles( + resizeMode: ResizeMode, + onPointerDown: (e: PointerEvent, direction: HandleDirection) => void, + updateCursor?: ( + dragging: boolean, + options?: { + type: 'resize' | 'rotate'; + target?: HTMLElement; + point?: IVec; + } + ) => void +) { + const getCornerHandles = () => { + const handleTopLeft = ResizeHandle( + HandleDirection.TopLeft, + onPointerDown, + updateCursor + ); + const handleTopRight = ResizeHandle( + HandleDirection.TopRight, + onPointerDown, + updateCursor + ); + const handleBottomLeft = ResizeHandle( + HandleDirection.BottomLeft, + onPointerDown, + updateCursor + ); + const handleBottomRight = ResizeHandle( + HandleDirection.BottomRight, + onPointerDown, + updateCursor + ); + return { + handleTopLeft, + handleTopRight, + handleBottomLeft, + handleBottomRight, + }; + }; + const getEdgeHandles = (hideEdgeHandle?: boolean) => { + const handleLeft = ResizeHandle( + HandleDirection.Left, + onPointerDown, + updateCursor, + hideEdgeHandle + ); + const handleRight = ResizeHandle( + HandleDirection.Right, + onPointerDown, + updateCursor, + hideEdgeHandle + ); + return { handleLeft, handleRight }; + }; + const getEdgeVerticalHandles = (hideEdgeHandle?: boolean) => { + const handleTop = ResizeHandle( + HandleDirection.Top, + onPointerDown, + updateCursor, + hideEdgeHandle + ); + const handleBottom = ResizeHandle( + HandleDirection.Bottom, + onPointerDown, + updateCursor, + hideEdgeHandle + ); + return { handleTop, handleBottom }; + }; + switch (resizeMode) { + case 'corner': { + const { + handleTopLeft, + handleTopRight, + handleBottomLeft, + handleBottomRight, + } = getCornerHandles(); + + // prettier-ignore + return html` + ${handleTopLeft} + ${handleTopRight} + ${handleBottomLeft} + ${handleBottomRight} + `; + } + case 'edge': { + const { handleLeft, handleRight } = getEdgeHandles(); + return html`${handleLeft} ${handleRight}`; + } + case 'all': { + const { + handleTopLeft, + handleTopRight, + handleBottomLeft, + handleBottomRight, + } = getCornerHandles(); + const { handleLeft, handleRight } = getEdgeHandles(true); + const { handleTop, handleBottom } = getEdgeVerticalHandles(true); + + // prettier-ignore + return html` + ${handleTopLeft} + ${handleTop} + ${handleTopRight} + ${handleRight} + ${handleBottomRight} + ${handleBottom} + ${handleBottomLeft} + ${handleLeft} + `; + } + case 'edgeAndCorner': { + const { + handleTopLeft, + handleTopRight, + handleBottomLeft, + handleBottomRight, + } = getCornerHandles(); + const { handleLeft, handleRight } = getEdgeHandles(true); + + return html` + ${handleTopLeft} ${handleTopRight} ${handleRight} ${handleBottomRight} + ${handleBottomLeft} ${handleLeft} + `; + } + case 'none': { + return nothing; + } + } +} diff --git a/blocksuite/blocks/src/root-block/edgeless/components/resize/resize-manager.ts b/blocksuite/blocks/src/root-block/edgeless/components/resize/resize-manager.ts new file mode 100644 index 0000000000..9c0890541a --- /dev/null +++ b/blocksuite/blocks/src/root-block/edgeless/components/resize/resize-manager.ts @@ -0,0 +1,708 @@ +import { CommonUtils } from '@blocksuite/affine-block-surface'; +import { NOTE_MIN_WIDTH } from '@blocksuite/affine-model'; +import { + assertExists, + Bound, + getQuadBoundWithRotation, + type IPoint, + type IVec, + type PointLocation, +} from '@blocksuite/global/utils'; + +import type { SelectableProps } from '../../utils/query.js'; +import { HandleDirection, type ResizeMode } from './resize-handles.js'; + +const { rotatePoints } = CommonUtils; + +// 15deg +const SHIFT_LOCKING_ANGLE = Math.PI / 12; + +type DragStartHandler = () => void; +type DragEndHandler = () => void; + +type ResizeMoveHandler = ( + bounds: Map< + string, + { + bound: Bound; + path?: PointLocation[]; + matrix?: DOMMatrix; + } + >, + direction: HandleDirection +) => void; + +type RotateMoveHandler = (point: IPoint, rotate: number) => void; + +export class HandleResizeManager { + private _aspectRatio = 1; + + private _bounds = new Map< + string, + { + bound: Bound; + rotate: number; + } + >(); + + /** + * Current rect of selected elements, it may change during resizing or moving + */ + private _currentRect = new DOMRect(); + + private _dragDirection: HandleDirection = HandleDirection.Left; + + private _dragging = false; + + private _dragPos: { + start: { x: number; y: number }; + end: { x: number; y: number }; + } = { + start: { x: 0, y: 0 }, + end: { x: 0, y: 0 }, + }; + + private _locked = false; + + private _onDragEnd: DragEndHandler; + + private _onDragStart: DragStartHandler; + + private _onResizeMove: ResizeMoveHandler; + + private _onRotateMove: RotateMoveHandler; + + private _origin: { x: number; y: number } = { x: 0, y: 0 }; + + /** + * Record inital rect of selected elements + */ + private _originalRect = new DOMRect(); + + private _proportion = false; + + private _proportional = false; + + private _resizeMode: ResizeMode = 'none'; + + private _rotate = 0; + + private _rotation = false; + + private _shiftKey = false; + + private _target: HTMLElement | null = null; + + private _zoom = 1; + + onPointerDown = ( + e: PointerEvent, + direction: HandleDirection, + proportional = false + ) => { + // Prevent selection action from being triggered + e.stopPropagation(); + + this._locked = false; + this._target = e.target as HTMLElement; + this._dragDirection = direction; + this._dragPos.start = { x: e.x, y: e.y }; + this._dragPos.end = { x: e.x, y: e.y }; + this._rotation = this._target.classList.contains('rotate'); + this._proportional = proportional; + + if (this._rotation) { + const rect = this._target + .closest('.affine-edgeless-selected-rect') + ?.getBoundingClientRect(); + assertExists(rect); + const { left, top, right, bottom } = rect; + const x = (left + right) / 2; + const y = (top + bottom) / 2; + // center of `selected-rect` in viewport + this._origin = { x, y }; + } + + this._dragging = true; + this._onDragStart(); + + const _onPointerMove = ({ x, y, shiftKey }: PointerEvent) => { + if (this._resizeMode === 'none') return; + + this._shiftKey = shiftKey; + this._dragPos.end = { x, y }; + + const proportional = this._proportional || this._shiftKey; + + if (this._rotation) { + this._onRotate(proportional); + return; + } + + this._onResize(proportional); + }; + + const _onPointerUp = (_: PointerEvent) => { + this._dragging = false; + this._onDragEnd(); + + const { x, y, width, height } = this._currentRect; + this._originalRect = new DOMRect(x, y, width, height); + + this._locked = true; + this._shiftKey = false; + this._rotation = false; + this._dragPos = { + start: { x: 0, y: 0 }, + end: { x: 0, y: 0 }, + }; + + document.removeEventListener('pointermove', _onPointerMove); + document.removeEventListener('pointerup', _onPointerUp); + }; + + document.addEventListener('pointermove', _onPointerMove); + document.addEventListener('pointerup', _onPointerUp); + }; + + get bounds() { + return this._bounds; + } + + get currentRect() { + return this._currentRect; + } + + get dragDirection() { + return this._dragDirection; + } + + get dragging() { + return this._dragging; + } + + get originalRect() { + return this._originalRect; + } + + get rotation() { + return this._rotation; + } + + constructor( + onDragStart: DragStartHandler, + onResizeMove: ResizeMoveHandler, + onRotateMove: RotateMoveHandler, + onDragEnd: DragEndHandler + ) { + this._onDragStart = onDragStart; + this._onResizeMove = onResizeMove; + this._onRotateMove = onRotateMove; + this._onDragEnd = onDragEnd; + } + + private _onResize(proportion: boolean) { + const { + _aspectRatio, + _dragDirection, + _dragPos, + _rotate, + _resizeMode, + _zoom, + _target, + _originalRect, + _currentRect, + } = this; + proportion ||= this._proportion; + assertExists(_target); + + const isAll = _resizeMode === 'all'; + const isCorner = _resizeMode === 'corner'; + const isEdgeAndCorner = _resizeMode === 'edgeAndCorner'; + + const { + start: { x: startX, y: startY }, + end: { x: endX, y: endY }, + } = _dragPos; + + const { left: minX, top: minY, right: maxX, bottom: maxY } = _originalRect; + const original = { + w: maxX - minX, + h: maxY - minY, + cx: (minX + maxX) / 2, + cy: (minY + maxY) / 2, + }; + const rect = { ...original }; + const scale = { x: 1, y: 1 }; + const flip = { x: 1, y: 1 }; + const direction = { x: 1, y: 1 }; + const fixedPoint = new DOMPoint(0, 0); + const draggingPoint = new DOMPoint(0, 0); + + const deltaX = (endX - startX) / _zoom; + const deltaY = (endY - startY) / _zoom; + + const m0 = new DOMMatrix() + .translateSelf(original.cx, original.cy) + .rotateSelf(_rotate) + .translateSelf(-original.cx, -original.cy); + + if (isCorner || isAll || isEdgeAndCorner) { + switch (_dragDirection) { + case HandleDirection.TopLeft: { + direction.x = -1; + direction.y = -1; + fixedPoint.x = maxX; + fixedPoint.y = maxY; + draggingPoint.x = minX; + draggingPoint.y = minY; + break; + } + case HandleDirection.TopRight: { + direction.x = 1; + direction.y = -1; + fixedPoint.x = minX; + fixedPoint.y = maxY; + draggingPoint.x = maxX; + draggingPoint.y = minY; + break; + } + case HandleDirection.BottomRight: { + direction.x = 1; + direction.y = 1; + fixedPoint.x = minX; + fixedPoint.y = minY; + draggingPoint.x = maxX; + draggingPoint.y = maxY; + break; + } + case HandleDirection.BottomLeft: { + direction.x = -1; + direction.y = 1; + fixedPoint.x = maxX; + fixedPoint.y = minY; + draggingPoint.x = minX; + draggingPoint.y = maxY; + break; + } + case HandleDirection.Left: { + direction.x = -1; + direction.y = 1; + fixedPoint.x = maxX; + fixedPoint.y = original.cy; + draggingPoint.x = minX; + draggingPoint.y = original.cy; + break; + } + case HandleDirection.Right: { + direction.x = 1; + direction.y = 1; + fixedPoint.x = minX; + fixedPoint.y = original.cy; + draggingPoint.x = maxX; + draggingPoint.y = original.cy; + break; + } + case HandleDirection.Top: { + const cx = (minX + maxX) / 2; + direction.x = 1; + direction.y = -1; + fixedPoint.x = cx; + fixedPoint.y = maxY; + draggingPoint.x = cx; + draggingPoint.y = minY; + break; + } + case HandleDirection.Bottom: { + const cx = (minX + maxX) / 2; + direction.x = 1; + direction.y = 1; + fixedPoint.x = cx; + fixedPoint.y = minY; + draggingPoint.x = cx; + draggingPoint.y = maxY; + break; + } + } + + // force adjustment by aspect ratio + proportion ||= this._bounds.size > 1; + + const fp = fixedPoint.matrixTransform(m0); + let dp = draggingPoint.matrixTransform(m0); + + dp.x += deltaX; + dp.y += deltaY; + + if ( + _dragDirection === HandleDirection.Left || + _dragDirection === HandleDirection.Right || + _dragDirection === HandleDirection.Top || + _dragDirection === HandleDirection.Bottom + ) { + const dpo = draggingPoint.matrixTransform(m0); + const coorPoint: IVec = [0, 0]; + const [[x1, y1]] = rotatePoints([[dpo.x, dpo.y]], coorPoint, -_rotate); + const [[x2, y2]] = rotatePoints([[dp.x, dp.y]], coorPoint, -_rotate); + const point = { x: 0, y: 0 }; + if ( + _dragDirection === HandleDirection.Left || + _dragDirection === HandleDirection.Right + ) { + point.x = x2; + point.y = y1; + } else { + point.x = x1; + point.y = y2; + } + + const [[x3, y3]] = rotatePoints( + [[point.x, point.y]], + coorPoint, + _rotate + ); + + dp.x = x3; + dp.y = y3; + } + + const cx = (fp.x + dp.x) / 2; + const cy = (fp.y + dp.y) / 2; + + const m1 = new DOMMatrix() + .translateSelf(cx, cy) + .rotateSelf(-_rotate) + .translateSelf(-cx, -cy); + + const f = fp.matrixTransform(m1); + const d = dp.matrixTransform(m1); + + switch (_dragDirection) { + case HandleDirection.TopLeft: { + rect.w = f.x - d.x; + rect.h = f.y - d.y; + break; + } + case HandleDirection.TopRight: { + rect.w = d.x - f.x; + rect.h = f.y - d.y; + break; + } + case HandleDirection.BottomRight: { + rect.w = d.x - f.x; + rect.h = d.y - f.y; + break; + } + case HandleDirection.BottomLeft: { + rect.w = f.x - d.x; + rect.h = d.y - f.y; + break; + } + case HandleDirection.Left: { + rect.w = f.x - d.x; + break; + } + case HandleDirection.Right: { + rect.w = d.x - f.x; + break; + } + case HandleDirection.Top: { + rect.h = f.y - d.y; + break; + } + case HandleDirection.Bottom: { + rect.h = d.y - f.y; + break; + } + } + + rect.cx = (d.x + f.x) / 2; + rect.cy = (d.y + f.y) / 2; + scale.x = rect.w / original.w; + scale.y = rect.h / original.h; + flip.x = scale.x < 0 ? -1 : 1; + flip.y = scale.y < 0 ? -1 : 1; + + const isDraggingCorner = + _dragDirection === HandleDirection.TopLeft || + _dragDirection === HandleDirection.TopRight || + _dragDirection === HandleDirection.BottomRight || + _dragDirection === HandleDirection.BottomLeft; + + // lock aspect ratio + if (proportion && isDraggingCorner) { + const newAspectRatio = Math.abs(rect.w / rect.h); + if (_aspectRatio < newAspectRatio) { + scale.y = Math.abs(scale.x) * flip.y; + rect.h = scale.y * original.h; + } else { + scale.x = Math.abs(scale.y) * flip.x; + rect.w = scale.x * original.w; + } + draggingPoint.x = fixedPoint.x + rect.w * direction.x; + draggingPoint.y = fixedPoint.y + rect.h * direction.y; + + dp = draggingPoint.matrixTransform(m0); + + rect.cx = (fp.x + dp.x) / 2; + rect.cy = (fp.y + dp.y) / 2; + } + } else { + // handle notes + switch (_dragDirection) { + case HandleDirection.Left: { + direction.x = -1; + fixedPoint.x = maxX; + draggingPoint.x = minX + deltaX; + rect.w = fixedPoint.x - draggingPoint.x; + break; + } + case HandleDirection.Right: { + direction.x = 1; + fixedPoint.x = minX; + draggingPoint.x = maxX + deltaX; + rect.w = draggingPoint.x - fixedPoint.x; + break; + } + } + + scale.x = rect.w / original.w; + flip.x = scale.x < 0 ? -1 : 1; + + if (Math.abs(rect.w) < NOTE_MIN_WIDTH) { + rect.w = NOTE_MIN_WIDTH * flip.x; + scale.x = rect.w / original.w; + draggingPoint.x = fixedPoint.x + rect.w * direction.x; + } + + rect.cx = (draggingPoint.x + fixedPoint.x) / 2; + } + + const width = Math.abs(rect.w); + const height = Math.abs(rect.h); + const x = rect.cx - width / 2; + const y = rect.cy - height / 2; + + _currentRect.x = x; + _currentRect.y = y; + _currentRect.width = width; + _currentRect.height = height; + + const newBounds = new Map< + string, + { + bound: Bound; + path?: PointLocation[]; + matrix?: DOMMatrix; + } + >(); + + let process: (value: SelectableProps, key: string) => void; + + if (isCorner || isAll || isEdgeAndCorner) { + if (this._bounds.size === 1) { + process = (_, id) => { + newBounds.set(id, { + bound: new Bound(x, y, width, height), + }); + }; + } else { + const fp = fixedPoint.matrixTransform(m0); + const m2 = new DOMMatrix() + .translateSelf(fp.x, fp.y) + .rotateSelf(_rotate) + .translateSelf(-fp.x, -fp.y) + .scaleSelf(scale.x, scale.y, 1, fp.x, fp.y, 0) + .translateSelf(fp.x, fp.y) + .rotateSelf(-_rotate) + .translateSelf(-fp.x, -fp.y); + + // TODO: on same rotate + process = ({ bound: { x, y, w, h }, path }, id) => { + const cx = x + w / 2; + const cy = y + h / 2; + const center = new DOMPoint(cx, cy).matrixTransform(m2); + const newWidth = Math.abs(w * scale.x); + const newHeight = Math.abs(h * scale.y); + + newBounds.set(id, { + bound: new Bound( + center.x - newWidth / 2, + center.y - newHeight / 2, + newWidth, + newHeight + ), + matrix: m2, + path, + }); + }; + } + } else { + // include notes, <----> + const m2 = new DOMMatrix().scaleSelf( + scale.x, + scale.y, + 1, + fixedPoint.x, + fixedPoint.y, + 0 + ); + process = ({ bound: { x, y, w, h }, rotate = 0, path }, id) => { + const cx = x + w / 2; + const cy = y + h / 2; + + const center = new DOMPoint(cx, cy).matrixTransform(m2); + + let newWidth: number; + let newHeight: number; + + // TODO: determine if it is a note + if (rotate) { + const { width } = getQuadBoundWithRotation({ x, y, w, h, rotate }); + const hrw = width / 2; + + center.y = cy; + + if (_currentRect.width <= width) { + newWidth = w * (_currentRect.width / width); + newHeight = newWidth / (w / h); + center.x = _currentRect.left + _currentRect.width / 2; + } else { + const p = (cx - hrw - _originalRect.left) / _originalRect.width; + const lx = _currentRect.left + p * _currentRect.width + hrw; + center.x = Math.max( + _currentRect.left + hrw, + Math.min(lx, _currentRect.left + _currentRect.width - hrw) + ); + newWidth = w; + newHeight = h; + } + } else { + newWidth = Math.abs(w * scale.x); + newHeight = Math.abs(h * scale.y); + } + + newBounds.set(id, { + bound: new Bound( + center.x - newWidth / 2, + center.y - newHeight / 2, + newWidth, + newHeight + ), + matrix: m2, + path, + }); + }; + } + + this._bounds.forEach(process); + this._onResizeMove(newBounds, this._dragDirection); + } + + private _onRotate(shiftKey = false) { + const { + _originalRect: { left: minX, top: minY, right: maxX, bottom: maxY }, + _dragPos: { + start: { x: startX, y: startY }, + end: { x: endX, y: endY }, + }, + _origin: { x: centerX, y: centerY }, + _rotate, + } = this; + + const startRad = Math.atan2(startY - centerY, startX - centerX); + const endRad = Math.atan2(endY - centerY, endX - centerX); + let deltaRad = endRad - startRad; + + // snap angle + // 15deg * n = 0, 15, 30, 45, ... 360 + if (shiftKey) { + const prevRad = (_rotate * Math.PI) / 180; + let angle = prevRad + deltaRad; + angle += SHIFT_LOCKING_ANGLE / 2; + angle -= angle % SHIFT_LOCKING_ANGLE; + deltaRad = angle - prevRad; + } + + const delta = (deltaRad * 180) / Math.PI; + + let x = endX; + let y = endY; + if (shiftKey) { + const point = new DOMPoint(startX, startY).matrixTransform( + new DOMMatrix() + .translateSelf(centerX, centerY) + .rotateSelf(delta) + .translateSelf(-centerX, -centerY) + ); + x = point.x; + y = point.y; + } + + this._onRotateMove( + // center of element in suface + { x: (minX + maxX) / 2, y: (minY + maxY) / 2 }, + delta + ); + + this._dragPos.start = { x, y }; + this._rotate += delta; + } + + onPressShiftKey(pressed: boolean) { + if (!this._target) return; + if (this._locked) return; + + if (this._shiftKey === pressed) return; + this._shiftKey = pressed; + + const proportional = this._proportional || this._shiftKey; + + if (this._rotation) { + this._onRotate(proportional); + return; + } + + this._onResize(proportional); + } + + updateBounds(bounds: Map<string, SelectableProps>) { + this._bounds = bounds; + } + + updateRectPosition(delta: { x: number; y: number }) { + this._currentRect.x += delta.x; + this._currentRect.y += delta.y; + this._originalRect.x = this._currentRect.x; + this._originalRect.y = this._currentRect.y; + + return this._originalRect; + } + + updateState( + resizeMode: ResizeMode, + rotate: number, + zoom: number, + position?: { x: number; y: number }, + originalRect?: DOMRect, + proportion = false + ) { + this._resizeMode = resizeMode; + this._rotate = rotate; + this._zoom = zoom; + this._proportion = proportion; + + if (position) { + this._currentRect.x = position.x; + this._currentRect.y = position.y; + this._originalRect.x = this._currentRect.x; + this._originalRect.y = this._currentRect.y; + } + + if (originalRect) { + this._originalRect = originalRect; + this._aspectRatio = originalRect.width / originalRect.height; + this._currentRect = DOMRect.fromRect(originalRect); + } + } +} diff --git a/blocksuite/blocks/src/root-block/edgeless/components/text/edgeless-connector-label-editor.ts b/blocksuite/blocks/src/root-block/edgeless/components/text/edgeless-connector-label-editor.ts new file mode 100644 index 0000000000..8a44ca273e --- /dev/null +++ b/blocksuite/blocks/src/root-block/edgeless/components/text/edgeless-connector-label-editor.ts @@ -0,0 +1,324 @@ +/* eslint-disable @typescript-eslint/no-non-null-assertion */ +import { TextUtils } from '@blocksuite/affine-block-surface'; +import type { RichText } from '@blocksuite/affine-components/rich-text'; +import type { ConnectorElementModel } from '@blocksuite/affine-model'; +import { ThemeProvider } from '@blocksuite/affine-shared/services'; +import { almostEqual } from '@blocksuite/affine-shared/utils'; +import { + RANGE_SYNC_EXCLUDE_ATTR, + ShadowlessElement, +} from '@blocksuite/block-std'; +import { + assertExists, + Bound, + Vec, + WithDisposable, +} from '@blocksuite/global/utils'; +import { DocCollection } from '@blocksuite/store'; +import { css, html, nothing } from 'lit'; +import { property, query } from 'lit/decorators.js'; +import { styleMap } from 'lit/directives/style-map.js'; + +import type { EdgelessRootBlockComponent } from '../../edgeless-root-block.js'; + +const HORIZONTAL_PADDING = 2; +const VERTICAL_PADDING = 2; +const BORDER_WIDTH = 1; + +export class EdgelessConnectorLabelEditor extends WithDisposable( + ShadowlessElement +) { + static override styles = css` + .edgeless-connector-label-editor { + position: absolute; + left: 0; + top: 0; + transform-origin: center; + z-index: 10; + padding: ${VERTICAL_PADDING}px ${HORIZONTAL_PADDING}px; + border: ${BORDER_WIDTH}px solid var(--affine-primary-color, #1e96eb); + background: var(--affine-background-primary-color, #fff); + border-radius: 2px; + box-shadow: 0px 0px 0px 2px rgba(30, 150, 235, 0.3); + box-sizing: border-box; + overflow: visible; + + .inline-editor { + white-space: pre-wrap !important; + outline: none; + } + + .inline-editor span { + word-break: normal !important; + overflow-wrap: anywhere !important; + } + + .edgeless-connector-label-editor-placeholder { + pointer-events: none; + color: var(--affine-text-disable-color); + white-space: nowrap; + } + } + `; + + private _isComposition = false; + + private _keeping = false; + + private _resizeObserver: ResizeObserver | null = null; + + private _updateLabelRect = () => { + const { connector, edgeless } = this; + if (!connector || !edgeless) return; + + const newWidth = this.inlineEditorContainer.scrollWidth; + const newHeight = this.inlineEditorContainer.scrollHeight; + const center = connector.getPointByOffsetDistance( + connector.labelOffset.distance + ); + const bounds = Bound.fromCenter(center, newWidth, newHeight); + const labelXYWH = bounds.toXYWH(); + + if ( + !connector.labelXYWH || + labelXYWH.some((p, i) => !almostEqual(p, connector.labelXYWH![i])) + ) { + edgeless.service.updateElement(connector.id, { + labelXYWH, + }); + } + }; + + get inlineEditor() { + assertExists(this.richText.inlineEditor); + return this.richText.inlineEditor; + } + + get inlineEditorContainer() { + return this.inlineEditor.rootElement; + } + + override connectedCallback() { + super.connectedCallback(); + this.setAttribute(RANGE_SYNC_EXCLUDE_ATTR, 'true'); + } + + override disconnectedCallback() { + super.disconnectedCallback(); + this._resizeObserver?.disconnect(); + this._resizeObserver = null; + } + + override firstUpdated() { + const { edgeless, connector } = this; + const { dispatcher } = edgeless; + assertExists(dispatcher); + + this._resizeObserver = new ResizeObserver(() => { + this._updateLabelRect(); + this.requestUpdate(); + }); + this._resizeObserver.observe(this.richText); + + this.updateComplete + .then(() => { + this.inlineEditor.selectAll(); + + this.inlineEditor.slots.renderComplete.on(() => { + this.requestUpdate(); + }); + + this.disposables.add( + dispatcher.add('keyDown', ctx => { + const state = ctx.get('keyboardState'); + const { key, ctrlKey, metaKey, altKey, shiftKey, isComposing } = + state.raw; + const onlyCmd = (ctrlKey || metaKey) && !altKey && !shiftKey; + const isModEnter = onlyCmd && key === 'Enter'; + const isEscape = key === 'Escape'; + if (!isComposing && (isModEnter || isEscape)) { + this.inlineEditorContainer.blur(); + + edgeless.service.selection.set({ + elements: [connector.id], + editing: false, + }); + return true; + } + return false; + }) + ); + + this.disposables.add( + edgeless.service.surface.elementUpdated.on(({ id }) => { + if (id === connector.id) this.requestUpdate(); + }) + ); + + this.disposables.add( + edgeless.service.viewport.viewportUpdated.on(() => { + this.requestUpdate(); + }) + ); + + this.disposables.add(dispatcher.add('click', () => true)); + this.disposables.add(dispatcher.add('doubleClick', () => true)); + + this.disposables.add(() => { + if (connector.text) { + const text = connector.text.toString(); + const trimed = text.trim(); + const len = trimed.length; + if (len === 0) { + // reset + edgeless.service.updateElement(connector.id, { + text: undefined, + labelXYWH: undefined, + labelOffset: undefined, + }); + } else if (len < text.length) { + edgeless.service.updateElement(connector.id, { + // @TODO: trim in Y.Text? + text: new DocCollection.Y.Text(trimed), + }); + } + } + + connector.lableEditing = false; + + edgeless.service.selection.set({ + elements: [], + editing: false, + }); + }); + + this.disposables.addFromEvent( + this.inlineEditorContainer, + 'blur', + () => { + if (this._keeping) return; + this.remove(); + } + ); + + this.disposables.addFromEvent( + this.inlineEditorContainer, + 'compositionstart', + () => { + this._isComposition = true; + this.requestUpdate(); + } + ); + this.disposables.addFromEvent( + this.inlineEditorContainer, + 'compositionend', + () => { + this._isComposition = false; + this.requestUpdate(); + } + ); + + connector.lableEditing = true; + }) + .catch(console.error); + } + + override async getUpdateComplete(): Promise<boolean> { + const result = await super.getUpdateComplete(); + await this.richText?.updateComplete; + return result; + } + + override render() { + const { connector } = this; + const { + labelOffset: { distance }, + labelStyle: { + fontFamily, + fontSize, + fontStyle, + fontWeight, + textAlign, + color: labelColor, + }, + labelConstraints: { hasMaxWidth, maxWidth }, + } = connector; + + const lineHeight = TextUtils.getLineHeight( + fontFamily, + fontSize, + fontWeight + ); + const { translateX, translateY, zoom } = this.edgeless.service.viewport; + const [x, y] = Vec.mul(connector.getPointByOffsetDistance(distance), zoom); + const transformOperation = [ + 'translate(-50%, -50%)', + `translate(${translateX}px, ${translateY}px)`, + `translate(${x}px, ${y}px)`, + `scale(${zoom})`, + ]; + + const isEmpty = !connector.text?.length && !this._isComposition; + const color = this.edgeless.std + .get(ThemeProvider) + .generateColorProperty(labelColor, '#000000'); + + return html` + <div + class="edgeless-connector-label-editor" + style=${styleMap({ + fontFamily: `"${fontFamily}"`, + fontSize: `${fontSize}px`, + fontStyle, + fontWeight, + textAlign, + lineHeight: `${lineHeight}px`, + maxWidth: hasMaxWidth + ? `${maxWidth + BORDER_WIDTH * 2 + HORIZONTAL_PADDING * 2}px` + : 'initial', + color, + transform: transformOperation.join(' '), + })} + > + <rich-text + .yText=${connector.text} + .enableFormat=${false} + style=${isEmpty + ? styleMap({ + position: 'absolute', + left: 0, + top: 0, + padding: `${VERTICAL_PADDING}px ${HORIZONTAL_PADDING}px`, + }) + : nothing} + ></rich-text> + ${isEmpty + ? html` + <span class="edgeless-connector-label-editor-placeholder"> + Add text + </span> + ` + : nothing} + </div> + `; + } + + setKeeping(keeping: boolean) { + this._keeping = keeping; + } + + @property({ attribute: false }) + accessor connector!: ConnectorElementModel; + + @property({ attribute: false }) + accessor edgeless!: EdgelessRootBlockComponent; + + @query('rich-text') + accessor richText!: RichText; +} + +declare global { + interface HTMLElementTagNameMap { + 'edgeless-connector-label-editor': EdgelessConnectorLabelEditor; + } +} diff --git a/blocksuite/blocks/src/root-block/edgeless/components/text/edgeless-frame-title-editor.ts b/blocksuite/blocks/src/root-block/edgeless/components/text/edgeless-frame-title-editor.ts new file mode 100644 index 0000000000..0b2eaaf7cb --- /dev/null +++ b/blocksuite/blocks/src/root-block/edgeless/components/text/edgeless-frame-title-editor.ts @@ -0,0 +1,181 @@ +import type { RichText } from '@blocksuite/affine-components/rich-text'; +import { FrameBlockModel } from '@blocksuite/affine-model'; +import { + RANGE_SYNC_EXCLUDE_ATTR, + ShadowlessElement, +} from '@blocksuite/block-std'; +import { assertExists, Bound, WithDisposable } from '@blocksuite/global/utils'; +import { cssVarV2 } from '@toeverything/theme/v2'; +import { css, html, nothing } from 'lit'; +import { property, query } from 'lit/decorators.js'; +import { styleMap } from 'lit/directives/style-map.js'; + +import { + AFFINE_FRAME_TITLE_WIDGET, + type AffineFrameTitleWidget, +} from '../../../widgets/frame-title/index.js'; +import { frameTitleStyleVars } from '../../../widgets/frame-title/styles.js'; +import type { EdgelessRootBlockComponent } from '../../edgeless-root-block.js'; + +export class EdgelessFrameTitleEditor extends WithDisposable( + ShadowlessElement +) { + static override styles = css` + .frame-title-editor { + display: flex; + align-items: center; + transform-origin: top left; + border-radius: 4px; + width: fit-content; + padding: 0 4px; + outline: none; + z-index: 1; + border: 1px solid var(--affine-primary-color); + box-shadow: 0px 0px 0px 2px rgba(30, 150, 235, 0.3); + overflow: hidden; + font-family: var(--affine-font-family); + } + `; + + get editorHost() { + return this.edgeless.host; + } + + get inlineEditor() { + return this.richText?.inlineEditor; + } + + private _unmount() { + // dispose in advance to avoid execute `this.remove()` twice + this.disposables.dispose(); + this.edgeless.service.selection.set({ + elements: [], + editing: false, + }); + this.remove(); + } + + override connectedCallback() { + super.connectedCallback(); + this.setAttribute(RANGE_SYNC_EXCLUDE_ATTR, 'true'); + } + + override firstUpdated(): void { + const dispatcher = this.edgeless.dispatcher; + assertExists(dispatcher); + this.updateComplete + .then(() => { + if (!this.inlineEditor) return; + + this.inlineEditor.selectAll(); + + this.inlineEditor.slots.renderComplete.on(() => { + this.requestUpdate(); + }); + + this.disposables.add( + dispatcher.add('keyDown', ctx => { + const state = ctx.get('keyboardState'); + if (state.raw.key === 'Enter' && !state.raw.isComposing) { + this._unmount(); + return true; + } + requestAnimationFrame(() => { + this.requestUpdate(); + }); + return false; + }) + ); + this.disposables.add( + this.edgeless.service.viewport.viewportUpdated.on(() => { + this.requestUpdate(); + }) + ); + + this.disposables.add(dispatcher.add('click', () => true)); + this.disposables.add(dispatcher.add('doubleClick', () => true)); + this.disposables.addFromEvent( + this.inlineEditor.rootElement, + 'blur', + () => { + this._unmount(); + } + ); + }) + .catch(console.error); + } + + override async getUpdateComplete(): Promise<boolean> { + const result = await super.getUpdateComplete(); + await this.richText?.updateComplete; + return result; + } + + override render() { + const rootBlockId = this.editorHost.doc.root?.id; + if (!rootBlockId) return nothing; + + const viewport = this.edgeless.service.viewport; + const bound = Bound.deserialize(this.frameModel.xywh); + const [x, y] = viewport.toViewCoord(bound.x, bound.y); + const isInner = this.edgeless.service.gfx.grid.has( + this.frameModel.elementBound, + true, + true, + model => model !== this.frameModel && model instanceof FrameBlockModel + ); + + const frameTitleWidget = this.edgeless.std.view.getWidget( + AFFINE_FRAME_TITLE_WIDGET, + rootBlockId + ) as AffineFrameTitleWidget | null; + + if (!frameTitleWidget) return nothing; + + const frameTitle = frameTitleWidget.getFrameTitle(this.frameModel); + + const colors = frameTitle?.colors ?? { + background: cssVarV2('edgeless/frame/background/white'), + text: 'var(--affine-text-primary-color)', + }; + + const inlineEditorStyle = styleMap({ + fontSize: frameTitleStyleVars.fontSize + 'px', + position: 'absolute', + left: (isInner ? x + 4 : x) + 'px', + top: (isInner ? y + 4 : y - (frameTitleStyleVars.height + 8 / 2)) + 'px', + minWidth: '8px', + height: frameTitleStyleVars.height + 'px', + background: colors.background, + color: colors.text, + }); + + const richTextStyle = styleMap({ + height: 'fit-content', + }); + + return html`<div class="frame-title-editor" style=${inlineEditorStyle}> + <rich-text + .yText=${this.frameModel.title.yText} + .enableFormat=${false} + .enableAutoScrollHorizontally=${false} + style=${richTextStyle} + ></rich-text> + </div>`; + } + + @property({ attribute: false }) + accessor edgeless!: EdgelessRootBlockComponent; + + @property({ attribute: false }) + accessor frameModel!: FrameBlockModel; + + @query('rich-text') + accessor richText: RichText | null = null; +} + +declare global { + interface HTMLElementTagNameMap { + 'edgeless-frame-title-editor': EdgelessFrameTitleEditor; + } +} diff --git a/blocksuite/blocks/src/root-block/edgeless/components/text/edgeless-group-title-editor.ts b/blocksuite/blocks/src/root-block/edgeless/components/text/edgeless-group-title-editor.ts new file mode 100644 index 0000000000..ae8f748a91 --- /dev/null +++ b/blocksuite/blocks/src/root-block/edgeless/components/text/edgeless-group-title-editor.ts @@ -0,0 +1,151 @@ +import { + GROUP_TITLE_FONT_SIZE, + GROUP_TITLE_OFFSET, + GROUP_TITLE_PADDING, +} from '@blocksuite/affine-block-surface'; +import type { RichText } from '@blocksuite/affine-components/rich-text'; +import type { GroupElementModel } from '@blocksuite/affine-model'; +import { + RANGE_SYNC_EXCLUDE_ATTR, + ShadowlessElement, +} from '@blocksuite/block-std'; +import { assertExists, Bound, WithDisposable } from '@blocksuite/global/utils'; +import { html, nothing } from 'lit'; +import { property, query } from 'lit/decorators.js'; +import { styleMap } from 'lit/directives/style-map.js'; + +import type { EdgelessRootBlockComponent } from '../../edgeless-root-block.js'; + +export class EdgelessGroupTitleEditor extends WithDisposable( + ShadowlessElement +) { + get inlineEditor() { + assertExists(this.richText.inlineEditor); + return this.richText.inlineEditor; + } + + get inlineEditorContainer() { + return this.inlineEditor.rootElement; + } + + private _unmount() { + // dispose in advance to avoid execute `this.remove()` twice + this.disposables.dispose(); + this.group.showTitle = true; + this.edgeless.service.selection.set({ + elements: [this.group.id], + editing: false, + }); + this.remove(); + } + + override connectedCallback() { + super.connectedCallback(); + this.setAttribute(RANGE_SYNC_EXCLUDE_ATTR, 'true'); + } + + override firstUpdated(): void { + const dispatcher = this.edgeless.dispatcher; + assertExists(dispatcher); + + this.updateComplete + .then(() => { + this.inlineEditor.selectAll(); + + this.group.showTitle = false; + + this.inlineEditor.slots.renderComplete.on(() => { + this.requestUpdate(); + }); + + this.disposables.add( + dispatcher.add('keyDown', ctx => { + const state = ctx.get('keyboardState'); + if (state.raw.key === 'Enter' && !state.raw.isComposing) { + this._unmount(); + return true; + } + requestAnimationFrame(() => { + this.requestUpdate(); + }); + return false; + }) + ); + this.disposables.add( + this.edgeless.service.viewport.viewportUpdated.on(() => { + this.requestUpdate(); + }) + ); + + this.disposables.add(dispatcher.add('click', () => true)); + this.disposables.add(dispatcher.add('doubleClick', () => true)); + this.disposables.addFromEvent( + this.inlineEditorContainer, + 'blur', + () => { + this._unmount(); + } + ); + }) + .catch(console.error); + } + + override async getUpdateComplete(): Promise<boolean> { + const result = await super.getUpdateComplete(); + await this.richText?.updateComplete; + return result; + } + + override render() { + if (!this.group.externalXYWH) { + console.error('group.externalXYWH is not set'); + return nothing; + } + const viewport = this.edgeless.service.viewport; + const bound = Bound.deserialize(this.group.externalXYWH); + const [x, y] = viewport.toViewCoord(bound.x, bound.y); + + const inlineEditorStyle = styleMap({ + transformOrigin: 'top left', + borderRadius: '2px', + width: 'fit-content', + maxHeight: '30px', + height: 'fit-content', + padding: `${GROUP_TITLE_PADDING[1]}px ${GROUP_TITLE_PADDING[0]}px`, + fontSize: GROUP_TITLE_FONT_SIZE + 'px', + position: 'absolute', + left: x + 'px', + top: `${y - GROUP_TITLE_OFFSET + 2}px`, + minWidth: '8px', + fontFamily: 'var(--affine-font-family)', + color: 'var(--affine-text-primary-color)', + background: 'var(--affine-white-10)', + outline: 'none', + zIndex: '1', + border: `1px solid + var(--affine-primary-color)`, + boxShadow: 'var(--affine-active-shadow)', + }); + return html`<rich-text + .yText=${this.group.title} + .enableFormat=${false} + .enableAutoScrollHorizontally=${false} + style=${inlineEditorStyle} + ></rich-text>`; + } + + @property({ attribute: false }) + accessor edgeless!: EdgelessRootBlockComponent; + + @property({ attribute: false }) + accessor group!: GroupElementModel; + + @query('rich-text') + accessor richText!: RichText; +} + +declare global { + interface HTMLElementTagNameMap { + 'edgeless-group-title-editor': EdgelessGroupTitleEditor; + } +} diff --git a/blocksuite/blocks/src/root-block/edgeless/components/text/edgeless-shape-text-editor.ts b/blocksuite/blocks/src/root-block/edgeless/components/text/edgeless-shape-text-editor.ts new file mode 100644 index 0000000000..d7f04fe9b2 --- /dev/null +++ b/blocksuite/blocks/src/root-block/edgeless/components/text/edgeless-shape-text-editor.ts @@ -0,0 +1,366 @@ +import { CommonUtils, TextUtils } from '@blocksuite/affine-block-surface'; +import type { RichText } from '@blocksuite/affine-components/rich-text'; +import type { ShapeElementModel } from '@blocksuite/affine-model'; +import { MindmapElementModel, TextResizing } from '@blocksuite/affine-model'; +import { ThemeProvider } from '@blocksuite/affine-shared/services'; +import { + RANGE_SYNC_EXCLUDE_ATTR, + ShadowlessElement, +} from '@blocksuite/block-std'; +import { + assertExists, + Bound, + Vec, + WithDisposable, +} from '@blocksuite/global/utils'; +import { DocCollection } from '@blocksuite/store'; +import { html, nothing } from 'lit'; +import { property, query } from 'lit/decorators.js'; +import { styleMap } from 'lit/directives/style-map.js'; + +import type { EdgelessRootBlockComponent } from '../../edgeless-root-block.js'; +import { getSelectedRect } from '../../utils/query.js'; + +const { toRadian } = CommonUtils; + +export class EdgelessShapeTextEditor extends WithDisposable(ShadowlessElement) { + private _keeping = false; + + private _lastXYWH = ''; + + private _resizeObserver: ResizeObserver | null = null; + + get inlineEditor() { + assertExists(this.richText.inlineEditor); + return this.richText.inlineEditor; + } + + get inlineEditorContainer() { + return this.inlineEditor.rootElement; + } + + get isMindMapNode() { + return this.element.group instanceof MindmapElementModel; + } + + private _initMindmapKeyBindings() { + if (!this.isMindMapNode) { + return; + } + + const service = this.edgeless.service; + + this._disposables.addFromEvent(this, 'keydown', evt => { + switch (evt.key) { + case 'Enter': { + evt.stopPropagation(); + if (evt.shiftKey || evt.isComposing) return; + + (this.ownerDocument.activeElement as HTMLElement).blur(); + service.selection.set({ + elements: [this.element.id], + editing: false, + }); + break; + } + case 'Esc': + case 'Tab': { + evt.stopPropagation(); + (this.ownerDocument.activeElement as HTMLElement).blur(); + service.selection.set({ + elements: [this.element.id], + editing: false, + }); + break; + } + } + }); + } + + private _stashMindMapTree() { + if (!this.isMindMapNode) { + return; + } + + const mindmap = this.element.group as MindmapElementModel; + const pop = mindmap.stashTree(mindmap.tree); + + this._disposables.add(() => { + mindmap.layout(); + pop?.(); + }); + } + + private _unmount() { + this._resizeObserver?.disconnect(); + this._resizeObserver = null; + + if (this.element.text) { + const text = this.element.text.toString(); + const trimed = text.trim(); + const len = trimed.length; + if (len === 0) { + this.element.text = undefined; + } else if (len < text.length) { + this.element.text = new DocCollection.Y.Text(trimed); + } + } + + this.element.textDisplay = true; + + this.remove(); + this.edgeless.service.selection.set({ + elements: [], + editing: false, + }); + } + + private _updateElementWH() { + const bcr = this.richText.getBoundingClientRect(); + const containerHeight = this.richText.offsetHeight; + const containerWidth = this.richText.offsetWidth; + const textResizing = this.element.textResizing; + + if ( + (containerHeight !== this.element.h && + textResizing === TextResizing.AUTO_HEIGHT) || + (textResizing === TextResizing.AUTO_WIDTH_AND_HEIGHT && + (containerWidth !== this.element.w || + containerHeight !== this.element.h)) + ) { + const [leftTopX, leftTopY] = Vec.rotWith( + [this.richText.offsetLeft, this.richText.offsetTop], + [bcr.left + bcr.width / 2, bcr.top + bcr.height / 2], + toRadian(-this.element.rotate) + ); + + const [modelLeftTopX, modelLeftTopY] = + this.edgeless.service.viewport.toModelCoord(leftTopX, leftTopY); + + this.edgeless.service.updateElement(this.element.id, { + xywh: new Bound( + modelLeftTopX, + modelLeftTopY, + textResizing === TextResizing.AUTO_WIDTH_AND_HEIGHT + ? containerWidth + : this.element.w, + containerHeight + ).serialize(), + }); + + if (this._lastXYWH !== this.element.xywh) { + this.requestUpdate(); + } + + if (this.isMindMapNode) { + const mindmap = this.element.group as MindmapElementModel; + + mindmap.layout(); + } + + this.richText.style.minHeight = `${containerHeight}px`; + } + + this.edgeless.service.selection.set({ + elements: [this.element.id], + editing: true, + }); + } + + override connectedCallback() { + super.connectedCallback(); + this.setAttribute(RANGE_SYNC_EXCLUDE_ATTR, 'true'); + } + + override firstUpdated(): void { + const dispatcher = this.edgeless.dispatcher; + assertExists(dispatcher); + + this.element.textDisplay = false; + + this.disposables.add( + this.edgeless.service.viewport.viewportUpdated.on(() => { + this.requestUpdate(); + this.updateComplete + .then(() => { + this._updateElementWH(); + }) + .catch(console.error); + }) + ); + this.disposables.add( + dispatcher.add('click', () => { + return true; + }) + ); + this.disposables.add( + dispatcher.add('doubleClick', () => { + return true; + }) + ); + + this.updateComplete + .then(() => { + if (this.element.group instanceof MindmapElementModel) { + this.inlineEditor.selectAll(); + } else { + this.inlineEditor.focusEnd(); + } + + this.disposables.add( + this.inlineEditor.slots.renderComplete.on(() => { + this._updateElementWH(); + }) + ); + this.disposables.addFromEvent( + this.inlineEditorContainer, + 'blur', + () => { + if (this._keeping) return; + this._unmount(); + } + ); + }) + .catch(console.error); + + this.disposables.addFromEvent(this, 'keydown', evt => { + if (evt.key === 'Escape') { + requestAnimationFrame(() => { + this.edgeless.service.selection.set({ + elements: [this.element.id], + editing: false, + }); + }); + + (this.ownerDocument.activeElement as HTMLElement).blur(); + } + }); + + this._initMindmapKeyBindings(); + this._stashMindMapTree(); + } + + override async getUpdateComplete(): Promise<boolean> { + const result = await super.getUpdateComplete(); + await this.richText?.updateComplete; + return result; + } + + override render() { + if (!this.element.text) { + console.error('Failed to mount shape editor because of no text.'); + return nothing; + } + + const [verticalPadding, horiPadding] = this.element.padding; + const textResizing = this.element.textResizing; + const viewport = this.edgeless.service.viewport; + const zoom = viewport.zoom; + const rect = getSelectedRect([this.element]); + const rotate = this.element.rotate; + const [leftTopX, leftTopY] = Vec.rotWith( + [rect.left, rect.top], + [rect.left + rect.width / 2, rect.top + rect.height / 2], + toRadian(rotate) + ); + const [x, y] = this.edgeless.service.viewport.toViewCoord( + leftTopX, + leftTopY + ); + const autoWidth = textResizing === TextResizing.AUTO_WIDTH_AND_HEIGHT; + const color = this.edgeless.std + .get(ThemeProvider) + .generateColorProperty(this.element.color, '#000000'); + + const inlineEditorStyle = styleMap({ + position: 'absolute', + left: x + 'px', + top: y + 'px', + width: + textResizing === TextResizing.AUTO_HEIGHT + ? rect.width + 'px' + : 'fit-content', + // override rich-text style (height: 100%) + height: 'initial', + minHeight: + textResizing === TextResizing.AUTO_WIDTH_AND_HEIGHT + ? '1em' + : `${rect.height}px`, + maxWidth: + textResizing === TextResizing.AUTO_WIDTH_AND_HEIGHT + ? this.element.maxWidth + ? `${this.element.maxWidth}px` + : undefined + : undefined, + boxSizing: 'border-box', + fontSize: this.element.fontSize + 'px', + fontFamily: TextUtils.wrapFontFamily(this.element.fontFamily), + fontWeight: this.element.fontWeight, + lineHeight: 'normal', + outline: 'none', + transform: `scale(${zoom}, ${zoom}) rotate(${rotate}deg)`, + transformOrigin: 'top left', + color, + padding: `${verticalPadding}px ${horiPadding}px`, + textAlign: this.element.textAlign, + display: 'grid', + gridTemplateColumns: '100%', + alignItems: + this.element.textVerticalAlign === 'center' + ? 'center' + : this.element.textVerticalAlign === 'bottom' + ? 'end' + : 'start', + alignContent: 'center', + gap: '0', + zIndex: '1', + }); + + this._lastXYWH = this.element.xywh; + + return html` <style> + edgeless-shape-text-editor v-text [data-v-text] { + overflow-wrap: ${autoWidth ? 'normal' : 'anywhere'}; + word-break: ${autoWidth ? 'normal' : 'break-word'} !important; + white-space: ${autoWidth ? 'pre' : 'pre-wrap'} !important; + } + + edgeless-shape-text-editor .inline-editor { + min-width: 1px; + } + </style> + <rich-text + .yText=${this.element.text} + .enableFormat=${false} + .enableAutoScrollHorizontally=${false} + style=${inlineEditorStyle} + ></rich-text>`; + } + + setKeeping(keeping: boolean) { + this._keeping = keeping; + } + + @property({ attribute: false }) + accessor edgeless!: EdgelessRootBlockComponent; + + @property({ attribute: false }) + accessor element!: ShapeElementModel; + + @property({ attribute: false }) + accessor mountEditor: + | (( + element: ShapeElementModel, + edgeless: EdgelessRootBlockComponent + ) => void) + | undefined = undefined; + + @query('rich-text') + accessor richText!: RichText; +} + +declare global { + interface HTMLElementTagNameMap { + 'edgeless-shape-text-editor': EdgelessShapeTextEditor; + } +} diff --git a/blocksuite/blocks/src/root-block/edgeless/components/text/edgeless-text-editor.ts b/blocksuite/blocks/src/root-block/edgeless/components/text/edgeless-text-editor.ts new file mode 100644 index 0000000000..d2cf079cb4 --- /dev/null +++ b/blocksuite/blocks/src/root-block/edgeless/components/text/edgeless-text-editor.ts @@ -0,0 +1,420 @@ +import { CommonUtils, TextUtils } from '@blocksuite/affine-block-surface'; +import type { RichText } from '@blocksuite/affine-components/rich-text'; +import type { TextElementModel } from '@blocksuite/affine-model'; +import { ThemeProvider } from '@blocksuite/affine-shared/services'; +import { + RANGE_SYNC_EXCLUDE_ATTR, + ShadowlessElement, +} from '@blocksuite/block-std'; +import { + assertExists, + Bound, + Vec, + WithDisposable, +} from '@blocksuite/global/utils'; +import { css, html, nothing } from 'lit'; +import { property, query } from 'lit/decorators.js'; +import { styleMap } from 'lit/directives/style-map.js'; + +import type { EdgelessRootBlockComponent } from '../../edgeless-root-block.js'; +import { deleteElements } from '../../utils/crud.js'; +import { getSelectedRect } from '../../utils/query.js'; + +const { toRadian } = CommonUtils; + +export class EdgelessTextEditor extends WithDisposable(ShadowlessElement) { + static BORDER_WIDTH = 1; + + static PADDING_HORIZONTAL = 10; + + static PADDING_VERTICAL = 6; + + static PLACEHOLDER_TEXT = 'Type from here'; + + static override styles = css` + .edgeless-text-editor { + position: absolute; + left: 0; + top: 0; + z-index: 10; + transform-origin: left top; + font-kerning: none; + border: ${EdgelessTextEditor.BORDER_WIDTH}px solid + var(--affine-primary-color, #1e96eb); + border-radius: 4px; + box-shadow: 0px 0px 0px 2px rgba(30, 150, 235, 0.3); + padding: ${EdgelessTextEditor.PADDING_VERTICAL}px + ${EdgelessTextEditor.PADDING_HORIZONTAL}px; + overflow: visible; + } + + .edgeless-text-editor .inline-editor { + white-space: pre-wrap !important; + outline: none; + } + + .edgeless-text-editor .inline-editor span { + word-break: normal !important; + overflow-wrap: anywhere !important; + } + + .edgeless-text-editor-placeholder { + pointer-events: none; + color: var(--affine-text-disable-color); + white-space: nowrap; + } + `; + + private _isComposition = false; + + private _keeping = false; + + private _updateRect = () => { + const edgeless = this.edgeless; + const element = this.element; + + if (!edgeless || !element) return; + + const newWidth = this.inlineEditorContainer.scrollWidth; + const newHeight = this.inlineEditorContainer.scrollHeight; + const bound = new Bound(element.x, element.y, newWidth, newHeight); + const { x, y, w, h, rotate } = element; + + switch (element.textAlign) { + case 'left': + { + const newPos = this.getCoordsOnLeftAlign( + { + x, + y, + w, + h, + r: toRadian(rotate), + }, + newWidth, + newHeight + ); + + bound.x = newPos.x; + bound.y = newPos.y; + } + break; + case 'center': + { + const newPos = this.getCoordsOnCenterAlign( + { + x, + y, + w, + h, + r: toRadian(rotate), + }, + newWidth, + newHeight + ); + + bound.x = newPos.x; + bound.y = newPos.y; + } + break; + case 'right': + { + const newPos = this.getCoordsOnRightAlign( + { + x, + y, + w, + h, + r: toRadian(rotate), + }, + newWidth, + newHeight + ); + + bound.x = newPos.x; + bound.y = newPos.y; + } + break; + } + + edgeless.service.updateElement(element.id, { + xywh: bound.serialize(), + }); + }; + + get inlineEditor() { + assertExists(this.richText.inlineEditor); + return this.richText.inlineEditor; + } + + get inlineEditorContainer() { + return this.inlineEditor.rootElement; + } + + override connectedCallback(): void { + super.connectedCallback(); + if (!this.edgeless) { + console.error('edgeless is not set.'); + return; + } + if (!this.element) { + console.error('text element is not set.'); + return; + } + + this.setAttribute(RANGE_SYNC_EXCLUDE_ATTR, 'true'); + } + + override firstUpdated(): void { + const edgeless = this.edgeless; + const element = this.element; + const { dispatcher } = this.edgeless; + assertExists(dispatcher); + + this.updateComplete + .then(() => { + this.inlineEditor.slots.renderComplete.on(() => { + this._updateRect(); + this.requestUpdate(); + }); + + this.disposables.add( + edgeless.service.surface.elementUpdated.on(({ id }) => { + if (id === element.id) this.requestUpdate(); + }) + ); + + this.disposables.add( + edgeless.service.viewport.viewportUpdated.on(() => { + this.requestUpdate(); + }) + ); + + this.disposables.add(dispatcher.add('click', () => true)); + this.disposables.add(dispatcher.add('doubleClick', () => true)); + + this.disposables.add(() => { + element.display = true; + + if (element.text.length === 0) { + deleteElements(edgeless, [element]); + } + + edgeless.service.selection.set({ + elements: [], + editing: false, + }); + }); + + this.disposables.addFromEvent( + this.inlineEditorContainer, + 'blur', + () => !this._keeping && this.remove() + ); + + this.disposables.addFromEvent( + this.inlineEditorContainer, + 'compositionstart', + () => { + this._isComposition = true; + this.requestUpdate(); + } + ); + this.disposables.addFromEvent( + this.inlineEditorContainer, + 'compositionend', + () => { + this._isComposition = false; + this.requestUpdate(); + } + ); + + element.display = false; + }) + .catch(console.error); + } + + getContainerOffset() { + const { PADDING_VERTICAL, PADDING_HORIZONTAL, BORDER_WIDTH } = + EdgelessTextEditor; + return `-${PADDING_HORIZONTAL + BORDER_WIDTH}px, -${ + PADDING_VERTICAL + BORDER_WIDTH + }px`; + } + + getCoordsOnCenterAlign( + rect: { w: number; h: number; r: number; x: number; y: number }, + w1: number, + h1: number + ): { x: number; y: number } { + const centerX = rect.x + rect.w / 2; + const centerY = rect.y + rect.h / 2; + + let deltaXPrime = 0; + let deltaYPrime = (-rect.h / 2) * Math.cos(rect.r); + + const vX = centerX + deltaXPrime; + const vY = centerY + deltaYPrime; + + deltaXPrime = 0; + deltaYPrime = (-h1 / 2) * Math.cos(rect.r); + + const newCenterX = vX - deltaXPrime; + const newCenterY = vY - deltaYPrime; + + return { x: newCenterX - w1 / 2, y: newCenterY - h1 / 2 }; + } + + getCoordsOnLeftAlign( + rect: { w: number; h: number; r: number; x: number; y: number }, + w1: number, + h1: number + ): { x: number; y: number } { + const cX = rect.x + rect.w / 2; + const cY = rect.y + rect.h / 2; + + let deltaXPrime = + (-rect.w / 2) * Math.cos(rect.r) + (rect.h / 2) * Math.sin(rect.r); + let deltaYPrime = + (-rect.w / 2) * Math.sin(rect.r) - (rect.h / 2) * Math.cos(rect.r); + + const vX = cX + deltaXPrime; + const vY = cY + deltaYPrime; + + deltaXPrime = (-w1 / 2) * Math.cos(rect.r) + (h1 / 2) * Math.sin(rect.r); + deltaYPrime = (-w1 / 2) * Math.sin(rect.r) - (h1 / 2) * Math.cos(rect.r); + + const newCenterX = vX - deltaXPrime; + const newCenterY = vY - deltaYPrime; + + return { x: newCenterX - w1 / 2, y: newCenterY - h1 / 2 }; + } + + getCoordsOnRightAlign( + rect: { w: number; h: number; r: number; x: number; y: number }, + w1: number, + h1: number + ): { x: number; y: number } { + const centerX = rect.x + rect.w / 2; + const centerY = rect.y + rect.h / 2; + + let deltaXPrime = + (rect.w / 2) * Math.cos(rect.r) - (-rect.h / 2) * Math.sin(rect.r); + let deltaYPrime = + (rect.w / 2) * Math.sin(rect.r) + (-rect.h / 2) * Math.cos(rect.r); + + const vX = centerX + deltaXPrime; + const vY = centerY + deltaYPrime; + + deltaXPrime = (w1 / 2) * Math.cos(rect.r) - (-h1 / 2) * Math.sin(rect.r); + deltaYPrime = (w1 / 2) * Math.sin(rect.r) + (-h1 / 2) * Math.cos(rect.r); + + const newCenterX = vX - deltaXPrime; + const newCenterY = vY - deltaYPrime; + + return { x: newCenterX - w1 / 2, y: newCenterY - h1 / 2 }; + } + + override async getUpdateComplete(): Promise<boolean> { + const result = await super.getUpdateComplete(); + await this.richText?.updateComplete; + return result; + } + + getVisualPosition(element: TextElementModel) { + const { x, y, w, h, rotate } = element; + return Vec.rotWith([x, y], [x + w / 2, y + h / 2], toRadian(rotate)); + } + + override render() { + const { + text, + fontFamily, + fontSize, + fontWeight, + fontStyle, + textAlign, + rotate, + hasMaxWidth, + w, + } = this.element; + const lineHeight = TextUtils.getLineHeight( + fontFamily, + fontSize, + fontWeight + ); + const rect = getSelectedRect([this.element]); + + const { translateX, translateY, zoom } = this.edgeless.service.viewport; + const [visualX, visualY] = this.getVisualPosition(this.element); + const containerOffset = this.getContainerOffset(); + const transformOperation = [ + `translate(${translateX}px, ${translateY}px)`, + `translate(${visualX * zoom}px, ${visualY * zoom}px)`, + `scale(${zoom})`, + `rotate(${rotate}deg)`, + `translate(${containerOffset})`, + ]; + + const isEmpty = !text.length && !this._isComposition; + const color = this.edgeless.std + .get(ThemeProvider) + .generateColorProperty(this.element.color, '#000000'); + + return html`<div + style=${styleMap({ + transform: transformOperation.join(' '), + minWidth: hasMaxWidth ? `${rect.width}px` : 'none', + maxWidth: hasMaxWidth ? `${w}px` : 'none', + fontFamily: TextUtils.wrapFontFamily(fontFamily), + fontSize: `${fontSize}px`, + fontWeight, + fontStyle, + color, + textAlign, + lineHeight: `${lineHeight}px`, + boxSizing: 'content-box', + })} + class="edgeless-text-editor" + > + <rich-text + .yText=${text} + .enableFormat=${false} + .enableAutoScrollHorizontally=${false} + style=${isEmpty + ? styleMap({ + position: 'absolute', + left: 0, + top: 0, + padding: `${EdgelessTextEditor.PADDING_VERTICAL}px + ${EdgelessTextEditor.PADDING_HORIZONTAL}px`, + }) + : nothing} + ></rich-text> + ${isEmpty + ? html`<span class="edgeless-text-editor-placeholder"> + Type from here + </span>` + : nothing} + </div>`; + } + + setKeeping(keeping: boolean) { + this._keeping = keeping; + } + + @property({ attribute: false }) + accessor edgeless!: EdgelessRootBlockComponent; + + @property({ attribute: false }) + accessor element!: TextElementModel; + + @query('rich-text') + accessor richText!: RichText; +} + +declare global { + interface HTMLElementTagNameMap { + 'edgeless-text-editor': EdgelessTextEditor; + } +} diff --git a/blocksuite/blocks/src/root-block/edgeless/components/toolbar/brush/brush-menu.ts b/blocksuite/blocks/src/root-block/edgeless/components/toolbar/brush/brush-menu.ts new file mode 100644 index 0000000000..ef9745e312 --- /dev/null +++ b/blocksuite/blocks/src/root-block/edgeless/components/toolbar/brush/brush-menu.ts @@ -0,0 +1,86 @@ +import { + EditPropsStore, + ThemeProvider, +} from '@blocksuite/affine-shared/services'; +import type { GfxToolsFullOptionValue } from '@blocksuite/block-std/gfx'; +import { SignalWatcher } from '@blocksuite/global/utils'; +import { computed } from '@preact/signals-core'; +import { css, html, LitElement } from 'lit'; +import { property } from 'lit/decorators.js'; + +import { + type ColorEvent, + GET_DEFAULT_LINE_COLOR, +} from '../../panel/color-panel.js'; +import type { LineWidthEvent } from '../../panel/line-width-panel.js'; +import { EdgelessToolbarToolMixin } from '../mixins/tool.mixin.js'; + +export class EdgelessBrushMenu extends EdgelessToolbarToolMixin( + SignalWatcher(LitElement) +) { + static override styles = css` + :host { + display: flex; + position: absolute; + z-index: -1; + } + + .menu-content { + display: flex; + align-items: center; + } + + menu-divider { + height: 24px; + margin: 0 9px; + } + `; + + private _props$ = computed(() => { + const { color, lineWidth } = + this.edgeless.std.get(EditPropsStore).lastProps$.value.brush; + return { + color, + lineWidth, + }; + }); + + type: GfxToolsFullOptionValue['type'] = 'brush'; + + override render() { + const theme = this.edgeless.std.get(ThemeProvider).theme; + const color = this.edgeless.std + .get(ThemeProvider) + .getColorValue(this._props$.value.color, GET_DEFAULT_LINE_COLOR(theme)); + + return html` + <edgeless-slide-menu> + <div class="menu-content"> + <edgeless-line-width-panel + .selectedSize=${this._props$.value.lineWidth} + @select=${(e: LineWidthEvent) => + this.onChange({ lineWidth: e.detail })} + > + </edgeless-line-width-panel> + <menu-divider .vertical=${true}></menu-divider> + <edgeless-one-row-color-panel + .value=${color} + .hasTransparent=${!this.edgeless.doc.awarenessStore.getFlag( + 'enable_color_picker' + )} + @select=${(e: ColorEvent) => this.onChange({ color: e.detail })} + ></edgeless-one-row-color-panel> + </div> + </edgeless-slide-menu> + `; + } + + @property({ attribute: false }) + accessor onChange!: (props: Record<string, unknown>) => void; +} + +declare global { + interface HTMLElementTagNameMap { + 'edgeless-brush-menu': EdgelessBrushMenu; + } +} diff --git a/blocksuite/blocks/src/root-block/edgeless/components/toolbar/brush/brush-tool-button.ts b/blocksuite/blocks/src/root-block/edgeless/components/toolbar/brush/brush-tool-button.ts new file mode 100644 index 0000000000..84624a5135 --- /dev/null +++ b/blocksuite/blocks/src/root-block/edgeless/components/toolbar/brush/brush-tool-button.ts @@ -0,0 +1,100 @@ +import { + EdgelessPenDarkIcon, + EdgelessPenLightIcon, +} from '@blocksuite/affine-components/icons'; +import { + EditPropsStore, + ThemeProvider, +} from '@blocksuite/affine-shared/services'; +import { SignalWatcher } from '@blocksuite/global/utils'; +import { computed } from '@preact/signals-core'; +import { css, html, LitElement } from 'lit'; +import { styleMap } from 'lit/directives/style-map.js'; + +import { getTooltipWithShortcut } from '../../utils.js'; +import { EdgelessToolbarToolMixin } from '../mixins/tool.mixin.js'; + +export class EdgelessBrushToolButton extends EdgelessToolbarToolMixin( + SignalWatcher(LitElement) +) { + static override styles = css` + :host { + display: flex; + height: 100%; + overflow-y: hidden; + } + .edgeless-brush-button { + height: 100%; + } + .pen-wrapper { + width: 35px; + height: 64px; + display: flex; + align-items: flex-end; + justify-content: center; + } + #edgeless-pen-icon { + transition: transform 0.3s ease-in-out; + transform: translateY(8px); + } + .edgeless-brush-button:hover #edgeless-pen-icon, + .pen-wrapper.active #edgeless-pen-icon { + transform: translateY(0); + } + `; + + private _color$ = computed(() => { + const theme = this.edgeless.std.get(ThemeProvider).theme$.value; + return this.edgeless.std + .get(ThemeProvider) + .generateColorProperty( + this.edgeless.std.get(EditPropsStore).lastProps$.value.brush.color, + undefined, + theme + ); + }); + + override enableActiveBackground = true; + + override type = 'brush' as const; + + private _toggleBrushMenu() { + if (this.tryDisposePopper()) return; + !this.active && this.setEdgelessTool(this.type); + const menu = this.createPopper('edgeless-brush-menu', this); + Object.assign(menu.element, { + edgeless: this.edgeless, + onChange: (props: Record<string, unknown>) => { + this.edgeless.std.get(EditPropsStore).recordLastProps('brush', props); + this.setEdgelessTool('brush'); + }, + }); + } + + override render() { + const { active } = this; + const appTheme = this.edgeless.std.get(ThemeProvider).app$.value; + const icon = + appTheme === 'dark' ? EdgelessPenDarkIcon : EdgelessPenLightIcon; + const color = this._color$.value; + + return html` + <edgeless-toolbar-button + class="edgeless-brush-button" + .tooltip=${this.popper ? '' : getTooltipWithShortcut('Pen', 'P')} + .tooltipOffset=${4} + .active=${active} + .withHover=${true} + @click=${() => this._toggleBrushMenu()} + > + <div style=${styleMap({ color })} class="pen-wrapper">${icon}</div> + </edgeless-toolbar-button> + `; + } +} + +declare global { + interface HTMLElementTagNameMap { + 'edgeless-brush-tool-button': EdgelessBrushToolButton; + } +} diff --git a/blocksuite/blocks/src/root-block/edgeless/components/toolbar/common/create-popper.ts b/blocksuite/blocks/src/root-block/edgeless/components/toolbar/common/create-popper.ts new file mode 100644 index 0000000000..26351a54eb --- /dev/null +++ b/blocksuite/blocks/src/root-block/edgeless/components/toolbar/common/create-popper.ts @@ -0,0 +1,98 @@ +import { assertExists } from '@blocksuite/global/utils'; + +// more than 100% due to the shadow +const leaveToPercent = `calc(100% + 10px)`; + +export interface MenuPopper<T extends HTMLElement> { + element: T; + dispose: () => void; + cancel?: () => void; +} + +// store active poppers +const popMap = new WeakMap<HTMLElement, Map<string, MenuPopper<HTMLElement>>>(); + +function animateEnter(el: HTMLElement) { + el.style.transform = 'translateY(0)'; +} +function animateLeave(el: HTMLElement) { + el.style.transform = `translateY(${leaveToPercent})`; +} + +export function createPopper<T extends keyof HTMLElementTagNameMap>( + tagName: T, + reference: HTMLElement, + options?: { + /** transition duration in ms */ + duration?: number; + onDispose?: () => void; + setProps?: (ele: HTMLElementTagNameMap[T]) => void; + } +) { + const duration = options?.duration ?? 230; + + if (!popMap.has(reference)) popMap.set(reference, new Map()); + const elMap = popMap.get(reference); + assertExists(elMap); + // if there is already a popper, cancel leave transition and apply enter transition + if (elMap.has(tagName)) { + const popper = elMap.get(tagName); + assertExists(popper); + popper.cancel?.(); + requestAnimationFrame(() => animateEnter(popper.element)); + return popper as MenuPopper<HTMLElementTagNameMap[T]>; + } + + const clipWrapper = document.createElement('div'); + const menu = document.createElement(tagName); + options?.setProps?.(menu); + assertExists(reference.shadowRoot); + clipWrapper.append(menu); + reference.shadowRoot.append(clipWrapper); + + // apply enter transition + menu.style.transition = `all ${duration}ms ease`; + animateLeave(menu); + requestAnimationFrame(() => animateEnter(menu)); + + Object.assign(clipWrapper.style, { + height: '100px', + pointerEvents: 'none', + position: 'absolute', + overflow: 'hidden', + width: '100%', + maxWidth: '100%', + boxSizing: 'border-box', + left: '0px', + bottom: '100%', + display: 'flex', + alignItems: 'end', + }); + + Object.assign(menu.style, { + width: '100%', + marginLeft: '30px', + maxWidth: 'calc(100% - 60px)', + bottom: '0%', + pointerEvents: 'auto', + }); + const remove = () => { + clipWrapper.remove(); + menu.remove(); + popMap.get(reference)?.delete(tagName); + options?.onDispose?.(); + }; + + const popper: MenuPopper<HTMLElementTagNameMap[T]> = { + element: menu, + dispose: () => { + // apply leave transition + animateLeave(menu); + menu.addEventListener('transitionend', remove, { once: true }); + popper.cancel = () => menu.removeEventListener('transitionend', remove); + }, + }; + + popMap.get(reference)?.set(tagName, popper); + return popper; +} diff --git a/blocksuite/blocks/src/root-block/edgeless/components/toolbar/common/draggable/draggable-element.controller.ts b/blocksuite/blocks/src/root-block/edgeless/components/toolbar/common/draggable/draggable-element.controller.ts new file mode 100644 index 0000000000..67d1a4b4e9 --- /dev/null +++ b/blocksuite/blocks/src/root-block/edgeless/components/toolbar/common/draggable/draggable-element.controller.ts @@ -0,0 +1,447 @@ +import { + EditPropsStore, + ThemeProvider, +} from '@blocksuite/affine-shared/services'; +import { assertExists, Bound } from '@blocksuite/global/utils'; +import { + type ReactiveController, + type ReactiveControllerHost, + render, +} from 'lit'; + +import type { DraggableShape } from '../../shape/utils.js'; +import { + type ElementDragEvent, + mouseResolver, + touchResolver, +} from './event-resolver.js'; +import { + createShapeDraggingOverlay, + defaultInfo, + type DraggingInfo, +} from './overlay-factory.js'; +import { + defaultIsValidMove, + type EdgelessDraggableElementHost, + type EdgelessDraggableElementOptions, + type ElementInfo, + type OverlayLayer, +} from './types.js'; + +interface ReactiveState<T> { + cancelled: boolean; + draggingElement: ElementInfo<T> | null; + dragOut: boolean | null; +} +interface EventCache { + onMouseUp?: (e: MouseEvent) => void; + onMouseMove?: (e: MouseEvent) => void; + onTouchMove?: (e: TouchEvent) => void; + onTouchEnd?: (e: TouchEvent) => void; +} + +export class EdgelessDraggableElementController<T> + implements ReactiveController +{ + clearTimeout: ReturnType<typeof setTimeout> | null = null; + + events: EventCache = {}; + + info = defaultInfo as DraggingInfo<T>; + + overlay: OverlayLayer | null = null; + + states: ReactiveState<T> = { + cancelled: false, + draggingElement: null, + dragOut: null, + }; + + constructor( + public host: EdgelessDraggableElementHost & ReactiveControllerHost, + public options: EdgelessDraggableElementOptions<T> + ) { + this.host = host; + host.addController(this); + } + + /** + * let overlay shape animate back to the original position + */ + private _animateCancelDrop(onFinished?: () => void, duration = 230) { + const { overlay, info } = this; + if (!overlay) return; + this.options?.onCanceled?.(overlay, info.elementInfo); + // unlock pointer events + overlay.mask.style.pointerEvents = 'none'; + // clip bottom + if (info.scopeRect) { + overlay.mask.style.height = + info.scopeRect.bottom - info.edgelessRect.top + 'px'; + } + + const { element, elementRectOriginal } = info; + + const newShapeRect = element.getBoundingClientRect(); + const x = newShapeRect.left - elementRectOriginal.left; + const y = newShapeRect.top - elementRectOriginal.top; + + // apply a transition + overlay.element.style.transition = `transform ${duration}ms ease`; + overlay.element.style.setProperty('--translate-x', `${x}px`); + overlay.element.style.setProperty('--translate-y', `${y}px`); + overlay.transitionWrapper.style.setProperty('--scale', '1'); + + this.clearTimeout = setTimeout(() => { + if (onFinished) return onFinished(); + this.reset(); + this.removeAllEvents(); + this.clearTimeout = null; + }, duration); + } + + private _createOverlay({ x, y }: Pick<ElementDragEvent, 'x' | 'y'>) { + const { edgeless } = this.options; + const { elementInfo, elementRectOriginal, offsetPos, edgelessRect } = + this.info; + + this.reset(); + this._updateState('draggingElement', elementInfo); + this.overlay = createShapeDraggingOverlay(this.info); + + const { overlay } = this; + // init shape position with 'left' and 'top'; + const { width, height, left, top } = elementRectOriginal; + const relativeX = left - edgelessRect.left; + const relativeY = top - edgelessRect.top; + // make sure the transform origin is the same as the mouse position + const ox = `${(((x - left) / width) * 100).toFixed(0)}%`; + const oy = `${(((y - top) / height) * 100).toFixed(0)}%`; + Object.assign(overlay.element.style, { + left: `${relativeX}px`, + top: `${relativeY}px`, + }); + overlay.element.style.setProperty('--translate-x', `${offsetPos.x}px`); + overlay.element.style.setProperty('--translate-y', `${offsetPos.y}px`); + overlay.transitionWrapper.style.transformOrigin = `${ox} ${oy}`; + + const shapeName = (elementInfo as ElementInfo<DraggableShape>).data.name; + const { fillColor, strokeColor } = + edgeless.host.std.get(EditPropsStore).lastProps$.value[ + `shape:${shapeName}` + ] || {}; + const color = edgeless.host.std + .get(ThemeProvider) + .generateColorProperty(fillColor); + const stroke = edgeless.host.std + .get(ThemeProvider) + .generateColorProperty(strokeColor); + overlay.element.style.setProperty('color', color); + overlay.element.style.setProperty('stroke', stroke); + // lifecycle hook + this.options.onOverlayCreated?.(overlay, elementInfo); + } + + private _onDragEnd() { + const { overlay, info, options } = this; + const { startTime, elementInfo, edgelessRect, validMoved } = info; + const { service, clickThreshold = 1500 } = options; + const zoom = service.viewport.zoom; + + if (!validMoved) { + const duration = Date.now() - startTime; + if (duration < clickThreshold) { + options.onElementClick?.(info.elementInfo); + if (options.clickToDrag) { + this._createOverlay(info.startPos); + this.info.moved = true; + setTimeout(() => { + this._updateOverlayScale(zoom); + }, 50); + return false; + } + } + this.reset(); + return true; + } + + if (this.states.dragOut && !this.states.cancelled && overlay) { + const rect = overlay.transitionWrapper.getBoundingClientRect(); + const [modelX, modelY] = this.options.service.viewport.toModelCoord( + rect.left - edgelessRect.left, + rect.top - edgelessRect.top + ); + const bound = new Bound( + modelX, + modelY, + rect.width / zoom, + rect.height / zoom + ); + options?.onDrop?.(elementInfo, bound); + + this.reset(); + return true; + } + + if (!this.states.dragOut) this._animateCancelDrop(); + + return true; + } + + private _onDragMove(e: ElementDragEvent) { + if (this.states.cancelled) return; + const { info, options } = this; + + // first move + if (!info.moved) { + info.moved = true; + this._createOverlay(e); + } + + const { overlay } = this; + assertExists(overlay); + + const { x, y } = e; + const { startPos, scopeRect } = info; + const offsetX = x - startPos.x; + const offsetY = y - startPos.y; + info.offsetPos = { x: offsetX, y: offsetY }; + + if (!info.validMoved) { + const isValidMove = options.isValidMove ?? defaultIsValidMove; + info.validMoved = isValidMove(info.offsetPos); + } + + // check if inside scopeElement + const newDragOut = + !scopeRect || + y < scopeRect.top || + y > scopeRect.bottom || + x < scopeRect.left || + x > scopeRect.right; + if (newDragOut !== this.states.dragOut) + options.onEnterOrLeaveScope?.(overlay, newDragOut); + this._updateState('dragOut', newDragOut); + + // apply transform + // - move shape with translate + overlay.element.style.setProperty('--translate-x', `${offsetX}px`); + overlay.element.style.setProperty('--translate-y', `${offsetY}px`); + // - scale shape with scale + const zoom = options.service.viewport.zoom; + this._updateOverlayScale(zoom); + } + + private _onDragStart(e: ElementDragEvent, elementInfo: ElementInfo<T>) { + const { scopeElement, edgeless } = this.options; + e.originalEvent.stopPropagation(); + e.originalEvent.preventDefault(); + + // Safari compatibility + // Cannot get edgeless.host.getBoundingClientRect().width in Safari (Always 0) + const edgelessRect = edgeless.host.getBoundingClientRect(); + if (edgelessRect.width === 0) { + edgelessRect.width = edgeless.viewport.clientWidth; + } + + this.info = { + startTime: Date.now(), + startPos: { x: e.x, y: e.y }, + offsetPos: { x: 0, y: 0 }, + scopeRect: scopeElement?.getBoundingClientRect() ?? null, + edgelessRect, + elementRectOriginal: e.el.getBoundingClientRect(), + element: e.el, + elementInfo, + moved: false, + validMoved: false, + parentToMount: edgeless.host, + }; + + this.removeAllEvents(); + if (e.inputType === 'mouse') { + const onMouseMove = (e: MouseEvent) => { + this._onDragMove(mouseResolver(e)); + }; + const onMouseUp = (_: MouseEvent) => { + const finished = this._onDragEnd(); + if (finished) { + edgeless.host.removeEventListener('mousemove', onMouseMove); + window.removeEventListener('mouseup', onMouseUp); + } + }; + edgeless.host.addEventListener('mousemove', onMouseMove); + window.addEventListener('mouseup', onMouseUp); + this.events = { onMouseMove, onMouseUp }; + } else { + const onTouchMove = (e: TouchEvent) => { + this._onDragMove(touchResolver(e)); + }; + const onTouchEnd = (_: TouchEvent) => { + const finished = this._onDragEnd(); + if (finished) { + edgeless.host.removeEventListener('touchmove', onTouchMove); + window.removeEventListener('touchend', onTouchEnd); + } + }; + edgeless.host.addEventListener('touchmove', onTouchMove); + window.addEventListener('touchend', onTouchEnd); + this.events = { onTouchMove, onTouchEnd }; + } + } + + /** + * Update overlay shape scale according to the current zoom level + */ + private _updateOverlayScale(zoom: number) { + const transitionWrapper = this.overlay?.transitionWrapper; + if (!transitionWrapper) return; + + const standardWidth = + this.info.elementInfo.standardWidth ?? this.options.standardWidth ?? 100; + + const { elementRectOriginal } = this.info; + const scale = (standardWidth * zoom) / elementRectOriginal.width; + + const clickToDragScale = this.options.clickToDragScale ?? 1.2; + + const finalScale = this.states.dragOut + ? scale + : this.options.clickToDrag + ? clickToDragScale + : 1; + transitionWrapper.style.setProperty('--scale', finalScale.toFixed(2)); + } + + /** + * @internal + */ + private _updateState<Key extends keyof ReactiveState<T>>( + key: Key, + value: ReactiveState<T>[Key] + ) { + this.states[key] = value; + this.host.requestUpdate(); + } + + private _updateStates(states: Partial<ReactiveState<T>>) { + Object.assign(this.states, states); + this.host.requestUpdate(); + } + + /** + * Cancel the current dragging & animate even if dragOut + */ + cancel() { + if (this.states.cancelled) return; + this._updateState('cancelled', true); + this._animateCancelDrop(); + } + + /** + * Same as {@link cancel} but without animation + */ + cancelWithoutAnimation() { + if (this.states.cancelled) return; + this._updateState('cancelled', true); + this.reset(); + this.removeAllEvents(); + } + + /** + * A workaround to apply click event manually + */ + clickToDrag(target: HTMLElement, startPos: { x: number; y: number }) { + if (!this.options.clickToDrag) { + this.options.clickToDrag = true; + console.warn( + 'clickToDrag is not enabled, it will be enabled automatically' + ); + } + const targetRect = target.getBoundingClientRect(); + const targetCenter = { + x: targetRect.left + targetRect.width / 2, + y: targetRect.top + targetRect.height / 2, + }; + + const mouseDownEvent = new MouseEvent('mousedown', { + clientX: targetCenter.x, + clientY: targetCenter.y, + }); + const mouseUpEvent = new MouseEvent('mouseup', { + clientX: targetCenter.x, + clientY: targetCenter.y, + }); + target.dispatchEvent(mouseDownEvent); + window.dispatchEvent(mouseUpEvent); + + const mouseMoveEvent = new MouseEvent('mousemove', { + clientX: startPos.x, + clientY: startPos.y, + }); + + this.options.edgeless.host.dispatchEvent(mouseMoveEvent); + } + + hostConnected() { + this.host.disposables.add( + this.options.service.viewport.viewportUpdated.on(({ zoom }) => { + this._updateOverlayScale(zoom); + }) + ); + + this.host.disposables.addFromEvent( + window, + 'keydown', + (e: KeyboardEvent) => { + if (e.key === 'Escape' && this.states.draggingElement) this.cancel(); + } + ); + } + + hostDisconnected() { + this.removeAllEvents(); + this.reset(); + } + + onMouseDown(e: MouseEvent, elementInfo: ElementInfo<T>) { + this._onDragStart(mouseResolver(e), elementInfo); + } + + onTouchStart(e: TouchEvent, elementInfo: ElementInfo<T>) { + this._onDragStart(touchResolver(e), elementInfo); + } + + removeAllEvents() { + const { events, options } = this; + const host = options.edgeless.host; + const { onMouseUp, onMouseMove, onTouchMove, onTouchEnd } = events; + onMouseUp && window.removeEventListener('mouseup', onMouseUp); + onMouseMove && host && host.removeEventListener('mousemove', onMouseMove); + onTouchMove && host && host.removeEventListener('touchmove', onTouchMove); + onTouchEnd && window.removeEventListener('touchend', onTouchEnd); + this.events = {}; + } + + reset() { + if (this.clearTimeout) clearTimeout(this.clearTimeout); + this.overlay?.mask.remove(); + this.overlay = null; + this._updateStates({ + cancelled: false, + draggingElement: null, + dragOut: null, + }); + } + + updateElementInfo(elementInfo: Partial<ElementInfo<T>>) { + this.info.elementInfo = { + ...this.info.elementInfo, + ...elementInfo, + }; + + if (elementInfo.preview && this.overlay) { + render(elementInfo.preview, this.overlay.transitionWrapper); + } + } +} diff --git a/blocksuite/blocks/src/root-block/edgeless/components/toolbar/common/draggable/event-resolver.ts b/blocksuite/blocks/src/root-block/edgeless/components/toolbar/common/draggable/event-resolver.ts new file mode 100644 index 0000000000..95900fb699 --- /dev/null +++ b/blocksuite/blocks/src/root-block/edgeless/components/toolbar/common/draggable/event-resolver.ts @@ -0,0 +1,25 @@ +export type ElementDragEvent = { + inputType: 'mouse' | 'touch'; + x: number; + y: number; + el: HTMLElement; + originalEvent: MouseEvent | TouchEvent; +}; + +export const touchResolver = (event: TouchEvent) => + ({ + inputType: 'touch', + x: event.touches[0].clientX, + y: event.touches[0].clientY, + el: event.currentTarget as HTMLElement, + originalEvent: event, + }) satisfies ElementDragEvent; + +export const mouseResolver = (event: MouseEvent) => + ({ + inputType: 'mouse', + x: event.clientX, + y: event.clientY, + el: event.currentTarget as HTMLElement, + originalEvent: event, + }) satisfies ElementDragEvent; diff --git a/blocksuite/blocks/src/root-block/edgeless/components/toolbar/common/draggable/overlay-factory.ts b/blocksuite/blocks/src/root-block/edgeless/components/toolbar/common/draggable/overlay-factory.ts new file mode 100644 index 0000000000..c505ef25c0 --- /dev/null +++ b/blocksuite/blocks/src/root-block/edgeless/components/toolbar/common/draggable/overlay-factory.ts @@ -0,0 +1,96 @@ +import { render } from 'lit'; + +import type { ElementInfo, OverlayLayer } from './types.js'; + +export type DraggingInfo<T> = { + startPos: { x: number; y: number }; + offsetPos: { x: number; y: number }; + startTime: number; + scopeRect: DOMRect | null; + edgelessRect: DOMRect; + elementRectOriginal: DOMRect; + element: HTMLElement; + elementInfo: ElementInfo<T>; + parentToMount: HTMLElement; + moved: boolean; + validMoved: boolean; +}; + +export const defaultInfo = { + startPos: { x: 0, y: 0 }, + offsetPos: { x: 0, y: 0 }, + startTime: 0, + scopeRect: {} as DOMRect, + edgelessRect: {} as DOMRect, + elementRectOriginal: {} as DOMRect, + element: null as unknown as HTMLElement, + elementInfo: null as unknown as ElementInfo<unknown>, + parentToMount: null as unknown as HTMLElement, + moved: false, + validMoved: false, +} satisfies DraggingInfo<unknown>; + +const className = (name: string) => + `edgeless-draggable-control-overlay-${name}`; +const addClass = (node: HTMLElement, name: string) => + node.classList.add(className(name)); + +export const createShapeDraggingOverlay = <T>( + info: DraggingInfo<T> +): OverlayLayer => { + const { edgelessRect, parentToMount, element: originalElement } = info; + const elementStyle = getComputedStyle(originalElement); + const mask = document.createElement('div'); + addClass(mask, 'mask'); + Object.assign(mask.style, { + position: 'absolute', + top: '0', + left: '0', + width: edgelessRect.width + 'px', + height: edgelessRect.height + 'px', + overflow: 'hidden', + zIndex: '9999', + + // for debug purpose + // background: 'rgba(255, 0, 0, 0.1)', + }); + + const element = document.createElement('div'); + addClass(element, 'element'); + const transitionWrapper = document.createElement('div'); + addClass(transitionWrapper, 'transition-wrapper'); + Object.assign(transitionWrapper.style, { + transition: 'all 0.18s ease', + transform: 'scale(var(--scale, 1)) rotate(var(--rotate, 0deg))', + width: elementStyle.width, + height: elementStyle.height, + }); + transitionWrapper.style.setProperty('--rotate', '0deg'); + transitionWrapper.style.setProperty('--scale', '1'); + + render(info.elementInfo.preview, transitionWrapper); + + Object.assign(element.style, { + transform: + 'translate(var(--translate-x, 0), var(--translate-y, 0)) rotate(var(--rotate, 0deg)) scale(var(--scale, 1))', + position: 'absolute', + cursor: 'grabbing', + transition: 'inherit', + }); + + const styleTag = document.createElement('style'); + styleTag.textContent = ` + .${className('transition-wrapper')} > * { + display: block; + width: 100%; + height: 100%; + } + `; + mask.append(styleTag); + + element.append(transitionWrapper); + mask.append(element); + parentToMount.append(mask); + + return { mask, element, transitionWrapper }; +}; diff --git a/blocksuite/blocks/src/root-block/edgeless/components/toolbar/common/draggable/types.ts b/blocksuite/blocks/src/root-block/edgeless/components/toolbar/common/draggable/types.ts new file mode 100644 index 0000000000..d8926b9264 --- /dev/null +++ b/blocksuite/blocks/src/root-block/edgeless/components/toolbar/common/draggable/types.ts @@ -0,0 +1,99 @@ +import type { Bound, DisposableClass } from '@blocksuite/global/utils'; +import type { TemplateResult } from 'lit'; + +import type { EdgelessRootBlockComponent } from '../../../../edgeless-root-block.js'; +import type { EdgelessRootService } from '../../../../edgeless-root-service.js'; + +export interface EdgelessDraggableElementHost extends DisposableClass {} + +export interface OverlayLayer { + /** + * The root element of the overlay, + * used to handle clip & prevent pointer events + */ + mask: HTMLElement; + /** + * The real preview element + */ + element: HTMLElement; + /** + * The wrapper that contains the preview element, + * different from the element, this element has transition effect + */ + transitionWrapper: HTMLElement; +} + +export interface EdgelessDraggableElementOptions<T> { + edgeless: EdgelessRootBlockComponent; + service: EdgelessRootService; + /** + * In which element that the target should be dragged out + * If not provided, recognized as the drag-out whenever dragging + */ + scopeElement?: HTMLElement; + /** + * The width of the element when placed to canvas + * @default 100 + */ + standardWidth?: number; + + /** + * the threshold of mousedown and mouseup duration in ms + * if the duration is less than this value, it will be treated as a click + * @default 1500 + */ + clickThreshold?: number; + + /** + * if enabled, when clicked, will trigger drag, press ESC or reclick to cancel + */ + clickToDrag?: boolean; + /** + * the scale of the element inside {@link EdgelessDraggableElementController.scopeElement} + * when {@link EdgelessDraggableElementOptions.clickToDrag} is enabled + * @default 1.2 + */ + clickToDragScale?: number; + + /** + * To verify if the move is valid + */ + isValidMove?: (offset: { x: number; y: number }) => boolean; + + /** + * when element is clicked - mouse down and up without moving + */ + onElementClick?: (element: ElementInfo<T>) => void; + /** + * when mouse down and moved, create overlay, customize overlay here + */ + onOverlayCreated?: (overlay: OverlayLayer, element: ElementInfo<T>) => void; + /** + * trigger when enter/leave the scope element + */ + onEnterOrLeaveScope?: (overlay: OverlayLayer, isOutside?: boolean) => void; + /** + * Drop the element on edgeless canvas + */ + onDrop?: (element: ElementInfo<T>, bound: Bound) => void; + + /** + * - ESC pressed + * - or not dragged out and released + */ + onCanceled?: (overlay: OverlayLayer, element: ElementInfo<T>) => void; +} + +export type ElementInfo<T> = { + // TODO: maybe make it optional, if not provided, clone event target + preview: TemplateResult; + data: T; + /** + * Override the value in {@link EdgelessDraggableElementOptions.standardWidth} + */ + standardWidth?: number; +}; + +export const defaultIsValidMove = (offset: { x: number; y: number }) => { + return Math.abs(offset.x) > 50 || Math.abs(offset.y) > 50; +}; diff --git a/blocksuite/blocks/src/root-block/edgeless/components/toolbar/common/slide-menu.ts b/blocksuite/blocks/src/root-block/edgeless/components/toolbar/common/slide-menu.ts new file mode 100644 index 0000000000..5f140269b9 --- /dev/null +++ b/blocksuite/blocks/src/root-block/edgeless/components/toolbar/common/slide-menu.ts @@ -0,0 +1,180 @@ +import { ArrowRightSmallIcon } from '@blocksuite/affine-components/icons'; +import { WithDisposable } from '@blocksuite/global/utils'; +import { consume } from '@lit/context'; +import { css, html, LitElement } from 'lit'; +import { property, query } from 'lit/decorators.js'; +import { styleMap } from 'lit/directives/style-map.js'; + +import { + type EdgelessToolbarSlots, + edgelessToolbarSlotsContext, +} from '../context.js'; + +export class EdgelessSlideMenu extends WithDisposable(LitElement) { + static override styles = css` + :host { + max-width: 100%; + } + ::-webkit-scrollbar { + display: none; + } + .slide-menu-wrapper { + position: relative; + } + .menu-container { + background: var(--affine-background-overlay-panel-color); + border-radius: 8px 8px 0 0; + border: 1px solid var(--affine-border-color); + border-bottom: none; + display: flex; + align-items: center; + width: fit-content; + max-width: 100%; + overflow-x: auto; + overscroll-behavior: none; + scrollbar-width: none; + position: relative; + height: calc(var(--menu-height) + 1px); + box-sizing: border-box; + padding: 0 10px; + scroll-snap-type: x mandatory; + } + .slide-menu-content { + display: flex; + align-items: center; + justify-content: center; + height: 100%; + transition: left 0.5s ease-in-out; + } + .next-slide-button, + .previous-slide-button { + align-items: center; + justify-content: center; + position: absolute; + width: 32px; + height: 32px; + border-radius: 50%; + border: 1px solid var(--affine-border-color); + background: var(--affine-background-overlay-panel-color); + box-shadow: var(--affine-shadow-2); + color: var(--affine-icon-color); + transition: + transform 0.3s ease-in-out, + opacity 0.5s ease-in-out; + z-index: 12; + } + .next-slide-button { + opacity: 0; + display: flex; + top: 50%; + right: 0; + transform: translate(50%, -50%) scale(0.5); + } + .next-slide-button:hover { + cursor: pointer; + transform: translate(50%, -50%) scale(1); + } + .previous-slide-button { + opacity: 0; + top: 50%; + left: 0; + transform: translate(-50%, -50%) scale(0.5); + } + .previous-slide-button:hover { + cursor: pointer; + transform: translate(-50%, -50%) scale(1); + } + .previous-slide-button svg { + transform: rotate(180deg); + } + `; + + private _handleSlideButtonClick(direction: 'left' | 'right') { + const totalWidth = this._slideMenuContent.clientWidth; + const currentScrollLeft = this._menuContainer.scrollLeft; + const menuWidth = this._menuContainer.clientWidth; + const newLeft = + currentScrollLeft + (direction === 'left' ? -menuWidth : menuWidth); + this._menuContainer.scrollTo({ + left: Math.max(0, Math.min(newLeft, totalWidth)), + behavior: 'smooth', + }); + } + + private _handleWheel(event: WheelEvent) { + event.stopPropagation(); + } + + private _toggleSlideButton() { + const scrollLeft = this._menuContainer.scrollLeft; + const menuWidth = this._menuContainer.clientWidth; + + const leftMin = 0; + const leftMax = this._slideMenuContent.clientWidth - menuWidth + 2; // border is 2 + this.showPrevious = scrollLeft > leftMin; + this.showNext = scrollLeft < leftMax; + } + + override firstUpdated() { + setTimeout(this._toggleSlideButton.bind(this), 0); + this._disposables.addFromEvent(this._menuContainer, 'scrollend', () => { + this._toggleSlideButton(); + }); + this._disposables.add( + this.toolbarSlots.resize.on(() => this._toggleSlideButton()) + ); + } + + override render() { + return html` + <div class="slide-menu-wrapper"> + <div + class="previous-slide-button" + @click=${() => this._handleSlideButtonClick('left')} + style=${styleMap({ opacity: this.showPrevious ? '1' : '0' })} + > + ${ArrowRightSmallIcon} + </div> + <div + class="menu-container" + style=${styleMap({ '--menu-height': this.height })} + > + <div class="slide-menu-content" @wheel=${this._handleWheel}> + <slot></slot> + </div> + </div> + <div + style=${styleMap({ opacity: this.showNext ? '1' : '0' })} + class="next-slide-button" + @click=${() => this._handleSlideButtonClick('right')} + > + ${ArrowRightSmallIcon} + </div> + </div> + `; + } + + @query('.menu-container') + private accessor _menuContainer!: HTMLDivElement; + + @query('.slide-menu-content') + private accessor _slideMenuContent!: HTMLDivElement; + + @property({ attribute: false }) + accessor height = '40px'; + + @property({ attribute: false }) + accessor showNext = false; + + @property({ attribute: false }) + accessor showPrevious = false; + + @consume({ context: edgelessToolbarSlotsContext }) + accessor toolbarSlots!: EdgelessToolbarSlots; +} + +declare global { + interface HTMLElementTagNameMap { + 'edgeless-slide-menu': EdgelessSlideMenu; + } +} diff --git a/blocksuite/blocks/src/root-block/edgeless/components/toolbar/common/type.ts b/blocksuite/blocks/src/root-block/edgeless/components/toolbar/common/type.ts new file mode 100644 index 0000000000..ffa167a366 --- /dev/null +++ b/blocksuite/blocks/src/root-block/edgeless/components/toolbar/common/type.ts @@ -0,0 +1,10 @@ +import type { MenuConfig } from '@blocksuite/affine-components/context-menu'; + +import type { EdgelessRootBlockComponent } from '../../../edgeless-root-block.js'; + +/** + * Helper function to build a menu configuration for a tool in dense mode + */ +export type DenseMenuBuilder = ( + edgeless: EdgelessRootBlockComponent +) => MenuConfig; diff --git a/blocksuite/blocks/src/root-block/edgeless/components/toolbar/connector/connector-dense-menu.ts b/blocksuite/blocks/src/root-block/edgeless/components/toolbar/connector/connector-dense-menu.ts new file mode 100644 index 0000000000..95ce2db99b --- /dev/null +++ b/blocksuite/blocks/src/root-block/edgeless/components/toolbar/connector/connector-dense-menu.ts @@ -0,0 +1,57 @@ +import { menu } from '@blocksuite/affine-components/context-menu'; +import { + ConnectorCWithArrowIcon, + ConnectorIcon, + ConnectorLWithArrowIcon, + ConnectorXWithArrowIcon, +} from '@blocksuite/affine-components/icons'; +import { ConnectorMode } from '@blocksuite/affine-model'; +import { EditPropsStore } from '@blocksuite/affine-shared/services'; + +import type { DenseMenuBuilder } from '../common/type.js'; + +export const buildConnectorDenseMenu: DenseMenuBuilder = edgeless => { + const prevMode = + edgeless.std.get(EditPropsStore).lastProps$.value.connector.mode; + + const isSelected = edgeless.gfx.tool.currentToolName$.peek() === 'connector'; + + const createSelect = + (mode: ConnectorMode, record = true) => + () => { + edgeless.gfx.tool.setTool('connector', { + mode, + }); + record && + edgeless.std.get(EditPropsStore).recordLastProps('connector', { mode }); + }; + + return menu.subMenu({ + name: 'Connector', + prefix: ConnectorIcon, + select: createSelect(prevMode, false), + isSelected, + options: { + items: [ + menu.action({ + name: 'Curve', + prefix: ConnectorCWithArrowIcon, + select: createSelect(ConnectorMode.Curve), + isSelected: isSelected && prevMode === ConnectorMode.Curve, + }), + menu.action({ + name: 'Elbowed', + prefix: ConnectorXWithArrowIcon, + select: createSelect(ConnectorMode.Orthogonal), + isSelected: isSelected && prevMode === ConnectorMode.Orthogonal, + }), + menu.action({ + name: 'Straight', + prefix: ConnectorLWithArrowIcon, + select: createSelect(ConnectorMode.Straight), + isSelected: isSelected && prevMode === ConnectorMode.Straight, + }), + ], + }, + }); +}; diff --git a/blocksuite/blocks/src/root-block/edgeless/components/toolbar/connector/connector-menu.ts b/blocksuite/blocks/src/root-block/edgeless/components/toolbar/connector/connector-menu.ts new file mode 100644 index 0000000000..8cc0d321e3 --- /dev/null +++ b/blocksuite/blocks/src/root-block/edgeless/components/toolbar/connector/connector-menu.ts @@ -0,0 +1,150 @@ +import { + ConnectorCWithArrowIcon, + ConnectorLWithArrowIcon, + ConnectorXWithArrowIcon, +} from '@blocksuite/affine-components/icons'; +import { + ConnectorMode, + DEFAULT_CONNECTOR_COLOR, +} from '@blocksuite/affine-model'; +import { + EditPropsStore, + ThemeProvider, +} from '@blocksuite/affine-shared/services'; +import type { GfxToolsFullOptionValue } from '@blocksuite/block-std/gfx'; +import { SignalWatcher } from '@blocksuite/global/utils'; +import { computed } from '@preact/signals-core'; +import { css, html, LitElement } from 'lit'; +import { property } from 'lit/decorators.js'; + +import type { ColorEvent } from '../../panel/color-panel.js'; +import type { LineWidthEvent } from '../../panel/line-width-panel.js'; +import { EdgelessToolbarToolMixin } from '../mixins/tool.mixin.js'; + +function ConnectorModeButtonGroup( + mode: ConnectorMode, + setConnectorMode: (props: Record<string, unknown>) => void +) { + /** + * There is little hacky on rendering tooltip. + * We don't want either tooltip overlap the top button or tooltip on left. + * So we put the lower button's tooltip as the first element of the button group container + */ + return html` + <div class="connector-mode-button-group"> + <edgeless-tool-icon-button + .active=${mode === ConnectorMode.Curve} + .activeMode=${'background'} + .tooltip=${'Curve'} + @click=${() => setConnectorMode({ mode: ConnectorMode.Curve })} + > + ${ConnectorCWithArrowIcon} + </edgeless-tool-icon-button> + <edgeless-tool-icon-button + .active=${mode === ConnectorMode.Orthogonal} + .activeMode=${'background'} + .tooltip=${'Elbowed'} + @click=${() => setConnectorMode({ mode: ConnectorMode.Orthogonal })} + > + ${ConnectorXWithArrowIcon} + </edgeless-tool-icon-button> + <edgeless-tool-icon-button + .active=${mode === ConnectorMode.Straight} + .activeMode=${'background'} + .tooltip=${'Straight'} + @click=${() => setConnectorMode({ mode: ConnectorMode.Straight })} + > + ${ConnectorLWithArrowIcon} + </edgeless-tool-icon-button> + </div> + `; +} + +export class EdgelessConnectorMenu extends EdgelessToolbarToolMixin( + SignalWatcher(LitElement) +) { + static override styles = css` + :host { + position: absolute; + display: flex; + z-index: -1; + } + + .connector-submenu-content { + display: flex; + height: 24px; + align-items: center; + justify-content: center; + } + + .connector-mode-button-group { + display: flex; + justify-content: center; + align-items: center; + gap: 14px; + } + + .connector-mode-button-group > edgeless-tool-icon-button svg { + fill: var(--affine-icon-color); + } + + .submenu-divider { + width: 1px; + height: 24px; + margin: 0 16px; + background-color: var(--affine-border-color); + display: inline-block; + } + `; + + private _props$ = computed(() => { + const { mode, stroke, strokeWidth } = + this.edgeless.std.get(EditPropsStore).lastProps$.value.connector; + return { mode, stroke, strokeWidth }; + }); + + override type: GfxToolsFullOptionValue['type'] = 'connector'; + + override render() { + const { stroke, strokeWidth, mode } = this._props$.value; + const connectorModeButtonGroup = ConnectorModeButtonGroup( + mode, + this.onChange + ); + const color = this.edgeless.std + .get(ThemeProvider) + .getColorValue(stroke, DEFAULT_CONNECTOR_COLOR); + + return html` + <edgeless-slide-menu> + <div class="connector-submenu-content"> + ${connectorModeButtonGroup} + <div class="submenu-divider"></div> + <edgeless-line-width-panel + .selectedSize=${strokeWidth} + @select=${(e: LineWidthEvent) => + this.onChange({ strokeWidth: e.detail })} + > + </edgeless-line-width-panel> + <div class="submenu-divider"></div> + <edgeless-one-row-color-panel + .value=${color} + .hasTransparent=${!this.edgeless.doc.awarenessStore.getFlag( + 'enable_color_picker' + )} + @select=${(e: ColorEvent) => this.onChange({ stroke: e.detail })} + ></edgeless-one-row-color-panel> + </div> + </edgeless-slide-menu> + `; + } + + @property({ attribute: false }) + accessor onChange!: (props: Record<string, unknown>) => void; +} + +declare global { + interface HTMLElementTagNameMap { + 'edgeless-connector-menu': EdgelessConnectorMenu; + } +} diff --git a/blocksuite/blocks/src/root-block/edgeless/components/toolbar/connector/connector-tool-button.ts b/blocksuite/blocks/src/root-block/edgeless/components/toolbar/connector/connector-tool-button.ts new file mode 100644 index 0000000000..a214968783 --- /dev/null +++ b/blocksuite/blocks/src/root-block/edgeless/components/toolbar/connector/connector-tool-button.ts @@ -0,0 +1,96 @@ +import { + ArrowUpIcon, + ConnectorCWithArrowIcon, + ConnectorLWithArrowIcon, + ConnectorXWithArrowIcon, +} from '@blocksuite/affine-components/icons'; +import { ConnectorMode, getConnectorModeName } from '@blocksuite/affine-model'; +import { EditPropsStore } from '@blocksuite/affine-shared/services'; +import { SignalWatcher } from '@blocksuite/global/utils'; +import { computed } from '@preact/signals-core'; +import { css, html, LitElement } from 'lit'; +import { styleMap } from 'lit/directives/style-map.js'; + +import { getTooltipWithShortcut } from '../../utils.js'; +import { QuickToolMixin } from '../mixins/quick-tool.mixin.js'; + +const IcomMap = { + [ConnectorMode.Straight]: ConnectorLWithArrowIcon, + [ConnectorMode.Orthogonal]: ConnectorXWithArrowIcon, + [ConnectorMode.Curve]: ConnectorCWithArrowIcon, +}; + +export class EdgelessConnectorToolButton extends QuickToolMixin( + SignalWatcher(LitElement) +) { + static override styles = css` + :host { + display: flex; + } + .edgeless-connector-button { + display: flex; + position: relative; + } + .arrow-up-icon { + position: absolute; + top: 4px; + right: 2px; + font-size: 0; + } + `; + + private _mode$ = computed(() => { + return this.edgeless.std.get(EditPropsStore).lastProps$.value.connector + .mode; + }); + + override type = 'connector' as const; + + private _toggleMenu() { + if (this.tryDisposePopper()) return; + + const menu = this.createPopper('edgeless-connector-menu', this); + menu.element.edgeless = this.edgeless; + menu.element.onChange = (props: Record<string, unknown>) => { + this.edgeless.std.get(EditPropsStore).recordLastProps('connector', props); + this.setEdgelessTool(this.type, { + mode: this._mode$.value, + }); + }; + } + + override render() { + const { active } = this; + const mode = this._mode$.value; + const arrowColor = active ? 'currentColor' : 'var(--affine-icon-secondary)'; + return html` + <edgeless-tool-icon-button + .tooltip=${this.popper + ? '' + : getTooltipWithShortcut(getConnectorModeName(mode), 'C')} + .tooltipOffset=${17} + .active=${active} + .iconContainerPadding=${6} + class="edgeless-connector-button" + @click=${() => { + // don't update tool before toggling menu + this._toggleMenu(); + this.edgeless.gfx.tool.setTool('connector', { + mode, + }); + }} + > + ${IcomMap[mode]} + <span class="arrow-up-icon" style=${styleMap({ color: arrowColor })}> + ${ArrowUpIcon} + </span> + </edgeless-tool-icon-button> + `; + } +} + +declare global { + interface HTMLElementTagNameMap { + 'edgeless-connector-tool-button': EdgelessConnectorToolButton; + } +} diff --git a/blocksuite/blocks/src/root-block/edgeless/components/toolbar/context.ts b/blocksuite/blocks/src/root-block/edgeless/components/toolbar/context.ts new file mode 100644 index 0000000000..9350c623ff --- /dev/null +++ b/blocksuite/blocks/src/root-block/edgeless/components/toolbar/context.ts @@ -0,0 +1,21 @@ +import type { ColorScheme } from '@blocksuite/affine-model'; +import type { Slot } from '@blocksuite/store'; +import { createContext } from '@lit/context'; + +import type { EdgelessToolbarWidget } from './edgeless-toolbar.js'; + +export interface EdgelessToolbarSlots { + resize: Slot<{ w: number; h: number }>; +} + +export const edgelessToolbarSlotsContext = createContext<EdgelessToolbarSlots>( + Symbol('edgelessToolbarSlotsContext') +); + +export const edgelessToolbarThemeContext = createContext<ColorScheme>( + Symbol('edgelessToolbarThemeContext') +); + +export const edgelessToolbarContext = createContext<EdgelessToolbarWidget>( + Symbol('edgelessToolbarContext') +); diff --git a/blocksuite/blocks/src/root-block/edgeless/components/toolbar/default/default-tool-button.ts b/blocksuite/blocks/src/root-block/edgeless/components/toolbar/default/default-tool-button.ts new file mode 100644 index 0000000000..18899e185e --- /dev/null +++ b/blocksuite/blocks/src/root-block/edgeless/components/toolbar/default/default-tool-button.ts @@ -0,0 +1,119 @@ +import { + ArrowUpIcon, + HandIcon, + SelectIcon, +} from '@blocksuite/affine-components/icons'; +import type { GfxToolsFullOptionValue } from '@blocksuite/block-std/gfx'; +import { effect } from '@preact/signals-core'; +import { css, html, LitElement } from 'lit'; +import { query } from 'lit/decorators.js'; + +import { getTooltipWithShortcut } from '../../utils.js'; +import { QuickToolMixin } from '../mixins/quick-tool.mixin.js'; + +export class EdgelessDefaultToolButton extends QuickToolMixin(LitElement) { + static override styles = css` + .current-icon { + transition: 100ms; + width: 24px; + height: 24px; + } + .current-icon > svg { + display: block; + } + .arrow-up-icon { + position: absolute; + top: 4px; + right: 2px; + font-size: 0; + color: var(--affine-icon-secondary); + } + .active .arrow-up-icon { + color: inherit; + } + `; + + override type: GfxToolsFullOptionValue['type'][] = ['default', 'pan']; + + private _changeTool() { + if (this.toolbar.activePopper) { + // click manually always closes the popper + this.toolbar.activePopper.dispose(); + } + const type = this.edgelessTool?.type; + if (type !== 'default' && type !== 'pan') { + if (localStorage.defaultTool === 'default') { + this.setEdgelessTool('default'); + } else if (localStorage.defaultTool === 'pan') { + this.setEdgelessTool('pan', { panning: false }); + } + return; + } + this._fadeOut(); + // wait for animation to finish + setTimeout(() => { + if (type === 'default') { + this.setEdgelessTool('pan', { panning: false }); + } else if (type === 'pan') { + this.setEdgelessTool('default'); + } + this._fadeIn(); + }, 100); + } + + private _fadeIn() { + this.currentIcon.style.opacity = '1'; + this.currentIcon.style.transform = `translateY(0px)`; + } + + private _fadeOut() { + this.currentIcon.style.opacity = '0'; + this.currentIcon.style.transform = `translateY(-5px)`; + } + + override connectedCallback(): void { + super.connectedCallback(); + if (!localStorage.defaultTool) { + localStorage.defaultTool = 'default'; + } + this.disposables.add( + effect(() => { + const tool = this.edgeless.gfx.tool.currentToolName$.value; + if (tool === 'default' || tool === 'pan') { + localStorage.defaultTool = tool; + } + }) + ); + } + + override render() { + const type = this.edgelessTool?.type; + const { active } = this; + return html` + <edgeless-tool-icon-button + class="edgeless-default-button ${type} ${active ? 'active' : ''}" + .tooltip=${type === 'pan' + ? getTooltipWithShortcut('Hand', 'H') + : getTooltipWithShortcut('Select', 'V')} + .tooltipOffset=${17} + .active=${active} + .iconContainerPadding=${6} + @click=${this._changeTool} + > + <span class="current-icon"> + ${localStorage.defaultTool === 'default' ? SelectIcon : HandIcon} + </span> + <span class="arrow-up-icon">${ArrowUpIcon}</span> + </edgeless-tool-icon-button> + `; + } + + @query('.current-icon') + accessor currentIcon!: HTMLInputElement; +} + +declare global { + interface HTMLElementTagNameMap { + 'edgeless-default-tool-button': EdgelessDefaultToolButton; + } +} diff --git a/blocksuite/blocks/src/root-block/edgeless/components/toolbar/edgeless-toolbar.ts b/blocksuite/blocks/src/root-block/edgeless/components/toolbar/edgeless-toolbar.ts new file mode 100644 index 0000000000..6a6dd13ba7 --- /dev/null +++ b/blocksuite/blocks/src/root-block/edgeless/components/toolbar/edgeless-toolbar.ts @@ -0,0 +1,689 @@ +/* eslint-disable @typescript-eslint/no-non-null-assertion */ +import { + type MenuHandler, + popMenu, + popupTargetFromElement, +} from '@blocksuite/affine-components/context-menu'; +import { + ArrowLeftSmallIcon, + ArrowRightSmallIcon, + MoreHorizontalIcon, +} from '@blocksuite/affine-components/icons'; +import { + darkToolbarStyles, + lightToolbarStyles, +} from '@blocksuite/affine-components/toolbar'; +import { ColorScheme, type RootBlockModel } from '@blocksuite/affine-model'; +import { + EditPropsStore, + ThemeProvider, +} from '@blocksuite/affine-shared/services'; +import { stopPropagation } from '@blocksuite/affine-shared/utils'; +import { WidgetComponent } from '@blocksuite/block-std'; +import { GfxControllerIdentifier } from '@blocksuite/block-std/gfx'; +import { debounce } from '@blocksuite/global/utils'; +import { Slot } from '@blocksuite/store'; +import { autoPlacement, offset } from '@floating-ui/dom'; +import { ContextProvider } from '@lit/context'; +import { baseTheme, cssVar } from '@toeverything/theme'; +import { css, html, nothing, unsafeCSS } from 'lit'; +import { query, state } from 'lit/decorators.js'; +import { cache } from 'lit/directives/cache.js'; + +import type { EdgelessRootBlockComponent } from '../../edgeless-root-block.js'; +import type { MenuPopper } from './common/create-popper.js'; +import { + edgelessToolbarContext, + type EdgelessToolbarSlots, + edgelessToolbarSlotsContext, + edgelessToolbarThemeContext, +} from './context.js'; +import { getQuickTools, getSeniorTools } from './tools.js'; + +const TOOLBAR_PADDING_X = 12; +const TOOLBAR_HEIGHT = 64; +const QUICK_TOOLS_GAP = 10; +const QUICK_TOOL_SIZE = 36; +const QUICK_TOOL_MORE_SIZE = 20; +const SENIOR_TOOLS_GAP = 0; +const SENIOR_TOOL_WIDTH = 96; +const SENIOR_TOOL_NAV_SIZE = 20; +const DIVIDER_WIDTH = 8; +const DIVIDER_SPACE = 8; +const SAFE_AREA_WIDTH = 64; + +export const EDGELESS_TOOLBAR_WIDGET = 'edgeless-toolbar-widget'; +export class EdgelessToolbarWidget extends WidgetComponent< + RootBlockModel, + EdgelessRootBlockComponent +> { + static override styles = css` + :host { + font-family: ${unsafeCSS(baseTheme.fontSansFamily)}; + position: absolute; + z-index: 1; + left: calc(50%); + transform: translateX(-50%); + bottom: 0; + -webkit-user-select: none; + user-select: none; + width: 100%; + pointer-events: none; + } + .edgeless-toolbar-wrapper { + width: 100%; + display: flex; + justify-content: center; + } + .edgeless-toolbar-wrapper[data-app-theme='light'] { + ${unsafeCSS(lightToolbarStyles.join('\n'))} + } + .edgeless-toolbar-wrapper[data-app-theme='dark'] { + ${unsafeCSS(darkToolbarStyles.join('\n'))} + } + .edgeless-toolbar-toggle-control { + pointer-events: auto; + padding-bottom: 16px; + width: fit-content; + max-width: calc(100% - ${unsafeCSS(SAFE_AREA_WIDTH)}px * 2); + min-width: 264px; + } + .edgeless-toolbar-toggle-control[data-enable='true'] { + transition: 0.23s ease; + padding-top: 100px; + transform: translateY(100px); + } + .edgeless-toolbar-toggle-control[data-enable='true']:hover { + padding-top: 0; + transform: translateY(0); + } + + .edgeless-toolbar-smooth-corner { + display: block; + width: fit-content; + max-width: 100%; + } + .edgeless-toolbar-container { + position: relative; + display: flex; + align-items: center; + padding: 0 ${unsafeCSS(TOOLBAR_PADDING_X)}px; + height: ${unsafeCSS(TOOLBAR_HEIGHT)}px; + } + :host([disabled]) .edgeless-toolbar-container { + pointer-events: none; + } + .edgeless-toolbar-container[level='second'] { + position: absolute; + bottom: 8px; + transform: translateY(-100%); + } + .edgeless-toolbar-container[hidden] { + display: none; + } + .quick-tools { + display: flex; + align-items: center; + justify-content: center; + gap: ${unsafeCSS(QUICK_TOOLS_GAP)}px; + } + .full-divider { + width: ${unsafeCSS(DIVIDER_WIDTH)}px; + height: 100%; + margin: 0 ${unsafeCSS(DIVIDER_SPACE)}px; + flex-shrink: 0; + display: flex; + align-items: center; + justify-content: center; + } + .full-divider::after { + content: ''; + display: block; + width: 1px; + height: 100%; + background-color: var(--affine-border-color); + } + .brush-and-eraser { + display: flex; + height: 100%; + gap: 4px; + justify-content: center; + } + .senior-tools { + display: flex; + align-items: center; + justify-content: flex-start; + gap: ${unsafeCSS(SENIOR_TOOLS_GAP)}px; + height: 100%; + min-width: ${unsafeCSS(SENIOR_TOOL_WIDTH)}px; + } + .quick-tool-item { + width: ${unsafeCSS(QUICK_TOOL_SIZE)}px; + height: ${unsafeCSS(QUICK_TOOL_SIZE)}px; + display: flex; + justify-content: center; + align-items: center; + flex-shrink: 0; + } + .quick-tool-more { + width: 0; + height: ${unsafeCSS(QUICK_TOOL_SIZE)}px; + flex-shrink: 0; + display: flex; + justify-content: center; + align-items: center; + transition: all 0.23s ease; + overflow: hidden; + } + [data-dense-quick='true'] .quick-tool-more { + width: ${unsafeCSS(QUICK_TOOL_MORE_SIZE)}px; + margin-left: ${unsafeCSS(DIVIDER_SPACE)}px; + } + .quick-tool-more-button { + padding: 0; + } + + .senior-tool-item { + width: ${unsafeCSS(SENIOR_TOOL_WIDTH)}px; + height: 100%; + display: flex; + justify-content: center; + align-items: center; + flex-shrink: 0; + } + .senior-nav-button-wrapper { + flex-shrink: 0; + width: 0px; + height: ${unsafeCSS(SENIOR_TOOL_NAV_SIZE)}px; + transition: width 0.23s ease; + overflow: hidden; + } + .senior-nav-button { + padding: 0; + } + .senior-nav-button svg { + width: 20px; + height: 20px; + } + [data-dense-senior='true'] .senior-nav-button-wrapper { + width: ${unsafeCSS(SENIOR_TOOL_NAV_SIZE)}px; + } + [data-dense-senior='true'] .senior-nav-button-wrapper.prev { + margin-right: ${unsafeCSS(DIVIDER_SPACE)}px; + } + [data-dense-senior='true'] .senior-nav-button-wrapper.next { + margin-left: ${unsafeCSS(DIVIDER_SPACE)}px; + } + .transform-button svg { + transition: 0.3s ease-in-out; + } + .transform-button:hover svg { + transform: scale(1.15); + } + `; + + private _moreQuickToolsMenu: MenuHandler | null = null; + + private _moreQuickToolsMenuRef: HTMLElement | null = null; + + @state() + accessor containerWidth = 1920; + + private _onContainerResize = debounce(({ w }: { w: number }) => { + if (!this.isConnected) return; + + this.slots.resize.emit({ w, h: TOOLBAR_HEIGHT }); + this.containerWidth = w; + + if (this._denseSeniorTools) { + this.scrollSeniorToolIndex = Math.min( + this._seniorTools.length - this.scrollSeniorToolSize, + this.scrollSeniorToolIndex + ); + } else { + this.scrollSeniorToolIndex = 0; + } + + if ( + this._denseQuickTools && + this._moreQuickToolsMenu && + this._moreQuickToolsMenuRef + ) { + this._moreQuickToolsMenu.close(); + this._openMoreQuickToolsMenu({ + currentTarget: this._moreQuickToolsMenuRef, + }); + } + if (!this._denseQuickTools && this._moreQuickToolsMenu) { + this._moreQuickToolsMenu.close(); + this._moreQuickToolsMenu = null; + } + }, 300); + + private _resizeObserver: ResizeObserver | null = null; + + private _slotsProvider = new ContextProvider(this, { + context: edgelessToolbarSlotsContext, + initialValue: { resize: new Slot() } satisfies EdgelessToolbarSlots, + }); + + private _themeProvider = new ContextProvider(this, { + context: edgelessToolbarThemeContext, + initialValue: ColorScheme.Light, + }); + + private _toolbarProvider = new ContextProvider(this, { + context: edgelessToolbarContext, + initialValue: this, + }); + + activePopper: MenuPopper<HTMLElement> | null = null; + + // calculate all the width manually + private get _availableWidth() { + return this.containerWidth - 2 * SAFE_AREA_WIDTH; + } + + private get _cachedPresentHideToolbar() { + return !!this.std.get(EditPropsStore).getStorage('presentHideToolbar'); + } + + private get _denseQuickTools() { + return ( + this._availableWidth - + this._seniorToolNavWidth - + 1 * SENIOR_TOOL_WIDTH - + 2 * TOOLBAR_PADDING_X < + this._quickToolsWidthTotal + ); + } + + private get _denseSeniorTools() { + return ( + this._availableWidth - + this._quickToolsWidthTotal - + this._spaceWidthTotal < + this._seniorToolsWidthTotal + ); + } + + /** + * When enabled, the toolbar will auto-hide when the mouse is not over it. + */ + private get _enableAutoHide() { + return ( + this.isPresentMode && + this._cachedPresentHideToolbar && + !this.presentSettingMenuShow && + !this.presentFrameMenuShow + ); + } + + private get _hiddenQuickTools() { + return this._quickTools + .slice(this._visibleQuickToolSize) + .filter(tool => !!tool.menu); + } + + private get _quickTools() { + return getQuickTools({ edgeless: this.block }); + } + + private get _quickToolsWidthTotal() { + return ( + this._quickTools.length * (QUICK_TOOL_SIZE + QUICK_TOOLS_GAP) - + QUICK_TOOLS_GAP + ); + } + + private get _seniorNextTooltip() { + if (this._seniorScrollNextDisabled) return ''; + const nextTool = + this._seniorTools[this.scrollSeniorToolIndex + this.scrollSeniorToolSize]; + return nextTool?.name ?? ''; + } + + private get _seniorPrevTooltip() { + if (this._seniorScrollPrevDisabled) return ''; + const prevTool = this._seniorTools[this.scrollSeniorToolIndex - 1]; + return prevTool?.name ?? ''; + } + + private get _seniorScrollNextDisabled() { + return ( + this.scrollSeniorToolIndex + this.scrollSeniorToolSize >= + this._seniorTools.length + ); + } + + private get _seniorScrollPrevDisabled() { + return this.scrollSeniorToolIndex === 0; + } + + private get _seniorToolNavWidth() { + return this._denseSeniorTools + ? (SENIOR_TOOL_NAV_SIZE + DIVIDER_SPACE) * 2 + : 0; + } + + private get _seniorTools() { + return getSeniorTools({ + edgeless: this.block, + toolbarContainer: this.toolbarContainer, + }); + } + + private get _seniorToolsWidthTotal() { + return ( + this._seniorTools.length * (SENIOR_TOOL_WIDTH + SENIOR_TOOLS_GAP) - + SENIOR_TOOLS_GAP + ); + } + + private get _spaceWidthTotal() { + return DIVIDER_WIDTH + DIVIDER_SPACE * 2 + TOOLBAR_PADDING_X * 2; + } + + private get _visibleQuickToolSize() { + if (!this._denseQuickTools) return this._quickTools.length; + const availableWidth = + this._availableWidth - + this._seniorToolNavWidth - + this._spaceWidthTotal - + SENIOR_TOOL_WIDTH; + return Math.max( + 1, + Math.floor( + (availableWidth - QUICK_TOOL_MORE_SIZE - DIVIDER_SPACE) / + (QUICK_TOOL_SIZE + QUICK_TOOLS_GAP) + ) + ); + } + + get edgelessTool() { + return this.gfx.tool.currentToolOption$.value; + } + + get gfx() { + return this.std.get(GfxControllerIdentifier); + } + + get isPresentMode() { + return this.edgelessTool.type === 'frameNavigator'; + } + + get scrollSeniorToolSize() { + if (this._denseQuickTools) return 1; + const seniorAvailableWidth = + this._availableWidth - this._quickToolsWidthTotal - this._spaceWidthTotal; + if (seniorAvailableWidth >= this._seniorToolsWidthTotal) + return this._seniorTools.length; + return ( + Math.floor( + (seniorAvailableWidth - (SENIOR_TOOL_NAV_SIZE + DIVIDER_SPACE) * 2) / + SENIOR_TOOL_WIDTH + ) || 1 + ); + } + + get slots() { + return this._slotsProvider.value; + } + + constructor() { + super(); + } + + private _onSeniorNavNext() { + if (this._seniorScrollNextDisabled) return; + this.scrollSeniorToolIndex = Math.min( + this._seniorTools.length - this.scrollSeniorToolSize, + this.scrollSeniorToolIndex + this.scrollSeniorToolSize + ); + } + + private _onSeniorNavPrev() { + if (this._seniorScrollPrevDisabled) return; + this.scrollSeniorToolIndex = Math.max( + 0, + this.scrollSeniorToolIndex - this.scrollSeniorToolSize + ); + } + + private _openMoreQuickToolsMenu(e: { currentTarget: HTMLElement }) { + if (!this._hiddenQuickTools.length) return; + + this._moreQuickToolsMenuRef = e.currentTarget; + this._moreQuickToolsMenu = popMenu( + popupTargetFromElement(e.currentTarget as HTMLElement), + { + middleware: [ + autoPlacement({ + allowedPlacements: ['top'], + }), + offset({ + mainAxis: (TOOLBAR_HEIGHT - QUICK_TOOL_MORE_SIZE) / 2 + 8, + }), + ], + options: { + onClose: () => { + this._moreQuickToolsMenu = null; + this._moreQuickToolsMenuRef = null; + }, + items: this._hiddenQuickTools.map(tool => tool.menu!), + }, + } + ); + } + + private _renderContent() { + return html` + <div class="quick-tools"> + ${this._quickTools + .slice(0, this._visibleQuickToolSize) + .map( + tool => html`<div class="quick-tool-item">${tool.content}</div>` + )} + </div> + <div class="quick-tool-more"> + <icon-button + ?disabled=${!this._denseQuickTools} + .size=${20} + class="quick-tool-more-button" + @click=${this._openMoreQuickToolsMenu} + ?active=${this._quickTools + .slice(this._visibleQuickToolSize) + .some(tool => tool.type === this.edgelessTool?.type)} + > + ${MoreHorizontalIcon} + <affine-tooltip tip-position="top" .offset=${25}> + More Tools + </affine-tooltip> + </icon-button> + </div> + <div class="full-divider"></div> + <div class="senior-nav-button-wrapper prev"> + <icon-button + .size=${20} + class="senior-nav-button" + ?disabled=${this._seniorScrollPrevDisabled} + @click=${this._onSeniorNavPrev} + > + ${ArrowLeftSmallIcon} + ${cache( + this._seniorPrevTooltip + ? html` <affine-tooltip tip-position="top" .offset=${4}> + ${this._seniorPrevTooltip} + </affine-tooltip>` + : nothing + )} + </icon-button> + </div> + <div class="senior-tools"> + ${this._seniorTools + .slice( + this.scrollSeniorToolIndex, + this.scrollSeniorToolIndex + this.scrollSeniorToolSize + ) + .map( + tool => html`<div class="senior-tool-item">${tool.content}</div>` + )} + </div> + <div class="senior-nav-button-wrapper next"> + <icon-button + .size=${20} + class="senior-nav-button" + ?disabled=${this._seniorScrollNextDisabled} + @click=${this._onSeniorNavNext} + > + ${ArrowRightSmallIcon} + ${cache( + this._seniorNextTooltip + ? html` <affine-tooltip tip-position="top" .offset=${4}> + ${this._seniorNextTooltip} + </affine-tooltip>` + : nothing + )} + </icon-button> + </div> + `; + } + + override connectedCallback() { + super.connectedCallback(); + this._toolbarProvider.setValue(this); + this._resizeObserver = new ResizeObserver(entries => { + for (const entry of entries) { + const { width } = entry.contentRect; + this._onContainerResize({ w: width }); + } + }); + this._resizeObserver.observe(this); + this.disposables.add( + this.std + .get(ThemeProvider) + .theme$.subscribe(mode => this._themeProvider.setValue(mode)) + ); + this._disposables.add( + this.block.bindHotKey( + { + Escape: () => { + if (this.gfx.selection.editing) return; + if (this.edgelessTool.type === 'frameNavigator') return; + if (this.edgelessTool.type === 'default') { + if (this.activePopper) { + this.activePopper.dispose(); + this.activePopper = null; + } + return; + } + this.gfx.tool.setTool('default'); + }, + }, + { global: true } + ) + ); + } + + override disconnectedCallback() { + super.disconnectedCallback(); + if (this._resizeObserver) { + this._resizeObserver.disconnect(); + } + } + + override firstUpdated() { + const { _disposables, block, gfx } = this; + + _disposables.add( + gfx.viewport.viewportUpdated.on(() => this.requestUpdate()) + ); + _disposables.add( + block.slots.readonlyUpdated.on(() => { + this.requestUpdate(); + }) + ); + _disposables.add( + block.slots.toolbarLocked.on(disabled => { + this.toggleAttribute('disabled', disabled); + }) + ); + // This state from `editPropsStore` is not reactive, + // if the value is updated outside of this component, it will not be reflected. + _disposables.add( + this.std.get(EditPropsStore).slots.storageUpdated.on(({ key }) => { + if (key === 'presentHideToolbar') { + this.requestUpdate(); + } + }) + ); + } + + override render() { + const { type } = this.edgelessTool || {}; + if (this.doc.readonly && type !== 'frameNavigator') { + return nothing; + } + + const appTheme = this.std.get(ThemeProvider).app$.value; + return html` + <div class="edgeless-toolbar-wrapper" data-app-theme=${appTheme}> + <div + class="edgeless-toolbar-toggle-control" + data-enable=${this._enableAutoHide} + > + <smooth-corner + class="edgeless-toolbar-smooth-corner" + .borderRadius=${16} + .smooth=${0.7} + .borderWidth=${1} + .bgColor=${'var(--affine-background-overlay-panel-color)'} + .borderColor=${'var(--affine-border-color)'} + style="filter: drop-shadow(${cssVar('toolbarShadow')})" + > + <div + class="edgeless-toolbar-container" + data-dense-quick=${this._denseQuickTools && + this._hiddenQuickTools.length > 0} + data-dense-senior=${this._denseSeniorTools} + @dblclick=${stopPropagation} + @mousedown=${stopPropagation} + @pointerdown=${stopPropagation} + > + <presentation-toolbar + .visible=${this.isPresentMode} + .edgeless=${this.block} + .settingMenuShow=${this.presentSettingMenuShow} + .frameMenuShow=${this.presentFrameMenuShow} + .setSettingMenuShow=${(show: boolean) => + (this.presentSettingMenuShow = show)} + .setFrameMenuShow=${(show: boolean) => + (this.presentFrameMenuShow = show)} + .containerWidth=${this.containerWidth} + ></presentation-toolbar> + ${this.isPresentMode ? nothing : this._renderContent()} + </div> + </smooth-corner> + </div> + </div> + `; + } + + @state() + accessor presentFrameMenuShow = false; + + @state() + accessor presentSettingMenuShow = false; + + @state() + accessor scrollSeniorToolIndex = 0; + + @query('.edgeless-toolbar-container') + accessor toolbarContainer!: HTMLElement; +} + +declare global { + interface HTMLElementTagNameMap { + 'edgeless-toolbar-widget': EdgelessToolbarWidget; + } +} diff --git a/blocksuite/blocks/src/root-block/edgeless/components/toolbar/eraser/eraser-tool-button.ts b/blocksuite/blocks/src/root-block/edgeless/components/toolbar/eraser/eraser-tool-button.ts new file mode 100644 index 0000000000..c4d8681edd --- /dev/null +++ b/blocksuite/blocks/src/root-block/edgeless/components/toolbar/eraser/eraser-tool-button.ts @@ -0,0 +1,81 @@ +import { + EdgelessEraserDarkIcon, + EdgelessEraserLightIcon, +} from '@blocksuite/affine-components/icons'; +import { ThemeProvider } from '@blocksuite/affine-shared/services'; +import type { GfxToolsFullOptionValue } from '@blocksuite/block-std/gfx'; +import { css, html, LitElement } from 'lit'; + +import { getTooltipWithShortcut } from '../../utils.js'; +import { EdgelessToolbarToolMixin } from '../mixins/tool.mixin.js'; + +export class EdgelessEraserToolButton extends EdgelessToolbarToolMixin( + LitElement +) { + static override styles = css` + :host { + height: 100%; + overflow-y: hidden; + } + .eraser-button { + display: flex; + justify-content: center; + align-items: flex-end; + position: relative; + width: 49px; + height: 64px; + } + #edgeless-eraser-icon { + transition: transform 0.3s ease-in-out; + transform: translateY(8px); + } + .eraser-button:hover #edgeless-eraser-icon, + .eraser-button.active #edgeless-eraser-icon { + transform: translateY(0); + } + `; + + override enableActiveBackground = true; + + override type: GfxToolsFullOptionValue['type'] = 'eraser'; + + override firstUpdated() { + this.disposables.add( + this.edgeless.bindHotKey( + { + Escape: () => { + if (this.edgelessTool.type === 'eraser') { + this.setEdgelessTool({ type: 'default' }); + } + }, + }, + { global: true } + ) + ); + } + + override render() { + const type = this.edgelessTool?.type; + const appTheme = this.edgeless.std.get(ThemeProvider).app$.value; + const icon = + appTheme === 'dark' ? EdgelessEraserDarkIcon : EdgelessEraserLightIcon; + + return html` + <edgeless-toolbar-button + class="edgeless-eraser-button" + .tooltip=${getTooltipWithShortcut('Eraser', 'E')} + .tooltipOffset=${4} + .active=${type === 'eraser'} + @click=${() => this.setEdgelessTool({ type: 'eraser' })} + > + <div class="eraser-button">${icon}</div> + </edgeless-toolbar-button> + `; + } +} + +declare global { + interface HTMLElementTagNameMap { + 'edgeless-eraser-tool-button': EdgelessEraserToolButton; + } +} diff --git a/blocksuite/blocks/src/root-block/edgeless/components/toolbar/frame/config.ts b/blocksuite/blocks/src/root-block/edgeless/components/toolbar/frame/config.ts new file mode 100644 index 0000000000..cf1ac24a6e --- /dev/null +++ b/blocksuite/blocks/src/root-block/edgeless/components/toolbar/frame/config.ts @@ -0,0 +1,9 @@ +export const FrameConfig: { + name: string; + wh: [number, number]; +}[] = [ + { name: '1:1', wh: [1200, 1200] }, + { name: '4:3', wh: [1600, 1200] }, + { name: '16:9', wh: [1600, 900] }, + { name: '2:1', wh: [1600, 800] }, +]; diff --git a/blocksuite/blocks/src/root-block/edgeless/components/toolbar/frame/frame-dense-menu.ts b/blocksuite/blocks/src/root-block/edgeless/components/toolbar/frame/frame-dense-menu.ts new file mode 100644 index 0000000000..936b17a0ea --- /dev/null +++ b/blocksuite/blocks/src/root-block/edgeless/components/toolbar/frame/frame-dense-menu.ts @@ -0,0 +1,30 @@ +import { menu } from '@blocksuite/affine-components/context-menu'; +import { FrameIcon } from '@blocksuite/affine-components/icons'; + +import type { DenseMenuBuilder } from '../common/type.js'; +import { FrameConfig } from './config.js'; + +export const buildFrameDenseMenu: DenseMenuBuilder = edgeless => + menu.subMenu({ + name: 'Frame', + prefix: FrameIcon, + select: () => edgeless.gfx.tool.setTool({ type: 'frame' }), + isSelected: edgeless.gfx.tool.currentToolName$.peek() === 'frame', + options: { + items: [ + menu.action({ + name: 'Custom', + select: () => edgeless.gfx.tool.setTool({ type: 'frame' }), + }), + ...FrameConfig.map(config => + menu.action({ + name: `Slide ${config.name}`, + select: () => { + edgeless.gfx.tool.setTool('default'); + edgeless.service.frame.createFrameOnViewportCenter(config.wh); + }, + }) + ), + ], + }, + }); diff --git a/blocksuite/blocks/src/root-block/edgeless/components/toolbar/frame/frame-menu.ts b/blocksuite/blocks/src/root-block/edgeless/components/toolbar/frame/frame-menu.ts new file mode 100644 index 0000000000..6cca38fd0a --- /dev/null +++ b/blocksuite/blocks/src/root-block/edgeless/components/toolbar/frame/frame-menu.ts @@ -0,0 +1,104 @@ +import type { GfxToolsFullOptionValue } from '@blocksuite/block-std/gfx'; +import { css, html, LitElement } from 'lit'; +import { repeat } from 'lit/directives/repeat.js'; + +import { EdgelessToolbarToolMixin } from '../mixins/tool.mixin.js'; +import { FrameConfig } from './config.js'; + +export class EdgelessFrameMenu extends EdgelessToolbarToolMixin(LitElement) { + static override styles = css` + :host { + position: absolute; + display: flex; + z-index: -1; + } + .menu-content { + display: flex; + align-items: center; + justify-content: center; + gap: 14px; + } + + .frame-add-button { + width: 40px; + height: 24px; + border-radius: 4px; + border: 1px solid var(--affine-border-color); + color: var(--affine-text-primary-color); + line-height: 20px; + font-weight: 400; + font-size: 12px; + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + position: relative; + } + + .frame-add-button::before { + content: ''; + position: absolute; + width: 100%; + height: 100%; + left: 0; + top: 0; + border-radius: 3px; + background: transparent; + transition: background-color 0.23s ease; + pointer-events: none; + } + .frame-add-button:hover::before { + background: var(--affine-hover-color); + } + + .custom { + width: 60px; + background: var(--affine-hover-color); + } + + .divider { + width: 1px; + height: 20px; + background: var(--affine-border-color); + transform: scaleX(0.5); + } + `; + + override type: GfxToolsFullOptionValue['type'] = 'frame'; + + override render() { + const { edgeless } = this; + return html` + <edgeless-slide-menu .showNext=${false}> + <div class="menu-content"> + <div class="frame-add-button custom">Custom</div> + <div class="divider"></div> + ${repeat( + FrameConfig, + item => item.name, + (item, index) => html` + <div + @click=${() => { + edgeless.gfx.tool.setTool('default'); + edgeless.service.frame.createFrameOnViewportCenter(item.wh); + }} + class="frame-add-button ${index}" + data-name="${item.name}" + data-w="${item.wh[0]}" + data-h="${item.wh[1]}" + > + ${item.name} + </div> + ` + )} + </div> + </edgeless-slide-menu> + `; + } +} + +declare global { + interface HTMLElementTagNameMap { + 'edgeless-frame-menu': EdgelessFrameMenu; + } +} diff --git a/blocksuite/blocks/src/root-block/edgeless/components/toolbar/frame/frame-tool-button.ts b/blocksuite/blocks/src/root-block/edgeless/components/toolbar/frame/frame-tool-button.ts new file mode 100644 index 0000000000..354ffdb9cf --- /dev/null +++ b/blocksuite/blocks/src/root-block/edgeless/components/toolbar/frame/frame-tool-button.ts @@ -0,0 +1,65 @@ +import { + ArrowUpIcon, + LargeFrameIcon, +} from '@blocksuite/affine-components/icons'; +import type { GfxToolsFullOptionValue } from '@blocksuite/block-std/gfx'; +import { css, html, LitElement } from 'lit'; +import { styleMap } from 'lit/directives/style-map.js'; + +import { getTooltipWithShortcut } from '../../../components/utils.js'; +import { QuickToolMixin } from '../mixins/quick-tool.mixin.js'; + +export class EdgelessFrameToolButton extends QuickToolMixin(LitElement) { + static override styles = css` + :host { + display: flex; + } + + .arrow-up-icon { + position: absolute; + top: 4px; + right: 2px; + font-size: 0; + } + `; + + override type: GfxToolsFullOptionValue['type'] = 'frame'; + + private _toggleFrameMenu() { + if (this.tryDisposePopper()) return; + + const menu = this.createPopper('edgeless-frame-menu', this); + menu.element.edgeless = this.edgeless; + } + + override render() { + const type = this.edgelessTool?.type; + const arrowColor = + type === 'frame' ? 'currentColor' : 'var(--affine-icon-secondary)'; + return html` + <edgeless-tool-icon-button + class="edgeless-frame-button" + .tooltip=${this.popper ? '' : getTooltipWithShortcut('Frame', 'F')} + .tooltipOffset=${17} + .active=${type === 'frame'} + .iconContainerPadding=${6} + @click=${() => { + // don't update tool before toggling menu + this._toggleFrameMenu(); + this.setEdgelessTool({ type: 'frame' }); + }} + > + ${LargeFrameIcon} + <span class="arrow-up-icon" style=${styleMap({ color: arrowColor })}> + ${ArrowUpIcon} + </span> + </edgeless-tool-icon-button> + `; + } +} + +declare global { + interface HTMLElementTagNameMap { + 'edgeless-frame-tool-button': EdgelessFrameToolButton; + } +} diff --git a/blocksuite/blocks/src/root-block/edgeless/components/toolbar/lasso/lasso-dense-menu.ts b/blocksuite/blocks/src/root-block/edgeless/components/toolbar/lasso/lasso-dense-menu.ts new file mode 100644 index 0000000000..5d07ab53f1 --- /dev/null +++ b/blocksuite/blocks/src/root-block/edgeless/components/toolbar/lasso/lasso-dense-menu.ts @@ -0,0 +1,44 @@ +import { menu } from '@blocksuite/affine-components/context-menu'; +import { + LassoFreeHandIcon, + LassoPolygonalIcon, +} from '@blocksuite/affine-components/icons'; + +import { LassoMode } from '../../../../../_common/types.js'; +import type { DenseMenuBuilder } from '../common/type.js'; + +export const buildLassoDenseMenu: DenseMenuBuilder = edgeless => { + // TODO: active state + // const prevMode = + // edgeless.service.editPropsStore.getLastProps('lasso').mode ?? + // LassoMode.FreeHand; + + const isActive = edgeless.gfx.tool.currentToolName$.peek() === 'lasso'; + + const createSelect = (mode: LassoMode) => () => { + edgeless.gfx.tool.setTool('lasso', { mode }); + }; + + return menu.subMenu({ + name: 'Lasso', + prefix: LassoFreeHandIcon, + select: createSelect(LassoMode.FreeHand), + isSelected: isActive, + options: { + items: [ + menu.action({ + prefix: LassoFreeHandIcon, + name: 'Free', + select: createSelect(LassoMode.FreeHand), + // isSelected: isActive && prevMode === LassoMode.FreeHand, + }), + menu.action({ + prefix: LassoPolygonalIcon, + name: 'Polygonal', + select: createSelect(LassoMode.Polygonal), + // isSelected: isActive && prevMode === LassoMode.Polygonal, + }), + ], + }, + }); +}; diff --git a/blocksuite/blocks/src/root-block/edgeless/components/toolbar/lasso/lasso-tool-button.ts b/blocksuite/blocks/src/root-block/edgeless/components/toolbar/lasso/lasso-tool-button.ts new file mode 100644 index 0000000000..caf7a397fb --- /dev/null +++ b/blocksuite/blocks/src/root-block/edgeless/components/toolbar/lasso/lasso-tool-button.ts @@ -0,0 +1,117 @@ +import { + ArrowUpIcon, + LassoFreeHandIcon, + LassoPolygonalIcon, +} from '@blocksuite/affine-components/icons'; +import { WithDisposable } from '@blocksuite/global/utils'; +import { effect } from '@preact/signals-core'; +import { css, html, LitElement } from 'lit'; +import { query, state } from 'lit/decorators.js'; +import { styleMap } from 'lit/directives/style-map.js'; + +import { LassoMode } from '../../../../../_common/types.js'; +import { getTooltipWithShortcut } from '../../utils.js'; +import { QuickToolMixin } from '../mixins/quick-tool.mixin.js'; + +export class EdgelessLassoToolButton extends QuickToolMixin( + WithDisposable(LitElement) +) { + static override styles = css` + .current-icon { + transition: 100ms; + width: 24px; + height: 24px; + } + .current-icon > svg { + display: block; + } + .arrow-up-icon { + position: absolute; + top: 4px; + right: 2px; + font-size: 0; + } + `; + + private _changeTool = () => { + const tool = this.edgelessTool; + if (tool.type !== 'lasso') { + this.setEdgelessTool({ type: 'lasso', mode: this.curMode }); + return; + } + + this._fadeOut(); + setTimeout(() => { + this.curMode === LassoMode.FreeHand + ? this.setEdgelessTool({ type: 'lasso', mode: LassoMode.Polygonal }) + : this.setEdgelessTool({ type: 'lasso', mode: LassoMode.FreeHand }); + this._fadeIn(); + }, 100); + }; + + override type = 'lasso' as const; + + private _fadeIn() { + this.currentIcon.style.opacity = '1'; + this.currentIcon.style.transform = `translateY(0px)`; + } + + private _fadeOut() { + this.currentIcon.style.opacity = '0'; + this.currentIcon.style.transform = `translateY(-5px)`; + } + + override connectedCallback(): void { + super.connectedCallback(); + + this.disposables.add( + effect(() => { + const tool = this.edgeless.gfx.tool.currentToolOption$.value; + + if (tool?.type === 'lasso') { + const { mode } = tool; + this.curMode = mode; + } + }) + ); + } + + override render() { + const type = this.edgelessTool?.type; + const mode = this.curMode === LassoMode.FreeHand ? 'freehand' : 'polygonal'; + + const arrowColor = + type === 'lasso' ? 'currentColor' : 'var(--affine-icon-secondary)'; + return html` + <edgeless-tool-icon-button + class="edgeless-lasso-button ${mode}" + .tooltip=${getTooltipWithShortcut('Lasso', 'L')} + .tooltipOffset=${17} + .active=${type === 'lasso'} + .iconContainerPadding=${6} + @click=${this._changeTool} + > + <span class="current-icon"> + ${this.curMode === LassoMode.FreeHand + ? LassoFreeHandIcon + : LassoPolygonalIcon} + </span> + <span class="arrow-up-icon" style=${styleMap({ color: arrowColor })}> + ${ArrowUpIcon} + </span> + </edgeless-tool-icon-button> + `; + } + + @state() + accessor curMode: LassoMode = LassoMode.FreeHand; + + @query('.current-icon') + accessor currentIcon!: HTMLInputElement; +} + +declare global { + interface HTMLElementTagNameMap { + 'edgeless-lasso-tool-button': EdgelessLassoToolButton; + } +} diff --git a/blocksuite/blocks/src/root-block/edgeless/components/toolbar/link/link-dense-menu.ts b/blocksuite/blocks/src/root-block/edgeless/components/toolbar/link/link-dense-menu.ts new file mode 100644 index 0000000000..a0247192c5 --- /dev/null +++ b/blocksuite/blocks/src/root-block/edgeless/components/toolbar/link/link-dense-menu.ts @@ -0,0 +1,32 @@ +import { menu } from '@blocksuite/affine-components/context-menu'; +import { LinkIcon } from '@blocksuite/affine-components/icons'; +import { TelemetryProvider } from '@blocksuite/affine-shared/services'; + +import type { DenseMenuBuilder } from '../common/type.js'; + +export const buildLinkDenseMenu: DenseMenuBuilder = edgeless => + menu.action({ + name: 'Link', + prefix: LinkIcon, + select: () => { + const { insertedLinkType } = edgeless.std.command.exec( + 'insertLinkByQuickSearch' + ); + + insertedLinkType + ?.then(type => { + const flavour = type?.flavour; + if (!flavour) return; + + edgeless.std + .getOptional(TelemetryProvider) + ?.track('CanvasElementAdded', { + control: 'toolbar:general', + page: 'whiteboard editor', + module: 'toolbar', + type: flavour.split(':')[1], + }); + }) + .catch(console.error); + }, + }); diff --git a/blocksuite/blocks/src/root-block/edgeless/components/toolbar/link/link-tool-button.ts b/blocksuite/blocks/src/root-block/edgeless/components/toolbar/link/link-tool-button.ts new file mode 100644 index 0000000000..a857e567af --- /dev/null +++ b/blocksuite/blocks/src/root-block/edgeless/components/toolbar/link/link-tool-button.ts @@ -0,0 +1,69 @@ +import { LinkIcon } from '@blocksuite/affine-components/icons'; +import { TelemetryProvider } from '@blocksuite/affine-shared/services'; +import { css, html, LitElement } from 'lit'; + +import { getTooltipWithShortcut } from '../../utils.js'; +import { QuickToolMixin } from '../mixins/quick-tool.mixin.js'; + +export class EdgelessLinkToolButton extends QuickToolMixin(LitElement) { + static override styles = css` + .link-icon, + .link-icon > svg { + width: 24px; + height: 24px; + } + `; + + override type = 'default' as const; + + private _onClick() { + const { insertedLinkType } = this.edgeless.std.command.exec( + 'insertLinkByQuickSearch' + ); + insertedLinkType + ?.then(type => { + const flavour = type?.flavour; + if (!flavour) return; + + this.edgeless.std + .getOptional(TelemetryProvider) + ?.track('CanvasElementAdded', { + control: 'toolbar:general', + page: 'whiteboard editor', + module: 'toolbar', + segment: 'toolbar', + type: flavour.split(':')[1], + }); + + this.edgeless.std + .getOptional(TelemetryProvider) + ?.track('LinkedDocCreated', { + control: 'links', + page: 'whiteboard editor', + module: 'edgeless toolbar', + segment: 'whiteboard', + type: flavour.split(':')[1], + other: 'existing doc', + }); + }) + .catch(console.error); + } + + override render() { + return html`<edgeless-tool-icon-button + .iconContainerPadding="${6}" + .tooltip="${getTooltipWithShortcut('Link', '@')}" + .tooltipOffset=${17} + class="edgeless-link-tool-button" + @click=${this._onClick} + > + <span class="link-icon">${LinkIcon}</span> + </edgeless-tool-icon-button>`; + } +} + +declare global { + interface HTMLElementTagNameMap { + 'edgeless-link-tool-button': EdgelessLinkToolButton; + } +} diff --git a/blocksuite/blocks/src/root-block/edgeless/components/toolbar/mindmap/assets.ts b/blocksuite/blocks/src/root-block/edgeless/components/toolbar/mindmap/assets.ts new file mode 100644 index 0000000000..a2199fb812 --- /dev/null +++ b/blocksuite/blocks/src/root-block/edgeless/components/toolbar/mindmap/assets.ts @@ -0,0 +1,46 @@ +import { ColorScheme, MindmapStyle } from '@blocksuite/affine-model'; +import type { TemplateResult } from 'lit'; + +import { type DraggableTool, getMindmapRender } from './basket-elements.js'; +import { + mindMapStyle1Dark, + mindMapStyle1Light, + mindMapStyle2Dark, + mindMapStyle2Light, + mindMapStyle3, + mindMapStyle4, +} from './icons.js'; + +export type ToolbarMindmapItem = { + type: 'mindmap'; + icon: TemplateResult; + style: MindmapStyle; + render: DraggableTool['render']; +}; + +export const getMindMaps = (theme: ColorScheme): ToolbarMindmapItem[] => [ + { + type: 'mindmap', + icon: theme === ColorScheme.Dark ? mindMapStyle1Dark : mindMapStyle1Light, + style: MindmapStyle.ONE, + render: getMindmapRender(MindmapStyle.ONE), + }, + { + type: 'mindmap', + icon: mindMapStyle4, + style: MindmapStyle.FOUR, + render: getMindmapRender(MindmapStyle.FOUR), + }, + { + type: 'mindmap', + icon: mindMapStyle3, + style: MindmapStyle.THREE, + render: getMindmapRender(MindmapStyle.THREE), + }, + { + type: 'mindmap', + icon: theme === 'light' ? mindMapStyle2Light : mindMapStyle2Dark, + style: MindmapStyle.TWO, + render: getMindmapRender(MindmapStyle.TWO), + }, +]; diff --git a/blocksuite/blocks/src/root-block/edgeless/components/toolbar/mindmap/basket-elements.ts b/blocksuite/blocks/src/root-block/edgeless/components/toolbar/mindmap/basket-elements.ts new file mode 100644 index 0000000000..775c39aa29 --- /dev/null +++ b/blocksuite/blocks/src/root-block/edgeless/components/toolbar/mindmap/basket-elements.ts @@ -0,0 +1,157 @@ +/* eslint-disable @typescript-eslint/no-non-null-assertion */ +import { CanvasElementType } from '@blocksuite/affine-block-surface'; +import { type MindmapStyle, TextElementModel } from '@blocksuite/affine-model'; +import { TelemetryProvider } from '@blocksuite/affine-shared/services'; +import { assertInstanceOf, Bound } from '@blocksuite/global/utils'; +import { DocCollection } from '@blocksuite/store'; +import type { TemplateResult } from 'lit'; + +import type { EdgelessRootBlockComponent } from '../../../edgeless-root-block.js'; +import type { EdgelessRootService } from '../../../edgeless-root-service.js'; +import { mountTextElementEditor } from '../../../utils/text.js'; + +export type ConfigProperty = 'x' | 'y' | 'r' | 's' | 'z' | 'o'; +export type ConfigState = 'default' | 'active' | 'hover' | 'next'; +export type ConfigStyle = Partial<Record<ConfigProperty, number | string>>; +export type ToolConfig = Record<ConfigState, ConfigStyle>; + +export type DraggableTool = { + name: 'text' | 'mindmap'; + icon: TemplateResult; + config: ToolConfig; + standardWidth?: number; + render: ( + bound: Bound, + edgelessService: EdgelessRootService, + edgeless: EdgelessRootBlockComponent + ) => string; +}; + +const unitMap = { x: 'px', y: 'px', r: 'deg', s: '', z: '', o: '' }; +export const textConfig: ToolConfig = { + default: { x: -20, y: -8, r: 7.74, s: 0.92, z: 2 }, + active: { x: -22, y: -9, r: -8, s: 0.92 }, + hover: { x: -22, y: -9, r: -8, s: 1, z: 3 }, + next: { x: -22, y: 64, r: 0 }, +}; +export const mindmapConfig: ToolConfig = { + default: { x: 4, y: -4, s: 1, z: 1, r: -7 }, + active: { x: 11, y: -14, r: 9, s: 1 }, + hover: { x: 11, y: -14, r: 9, s: 1.16, z: 3 }, + next: { y: 64, r: 0 }, +}; + +export const getMindmapRender = + (mindmapStyle: MindmapStyle): DraggableTool['render'] => + (bound, edgelessService) => { + const [x, y, _, h] = bound.toXYWH(); + + const rootW = 145; + const rootH = 50; + + const nodeW = 80; + const nodeH = 35; + + const centerVertical = y + h / 2; + const rootX = x; + const rootY = centerVertical - rootH / 2; + + type MindMapNode = { + children: MindMapNode[]; + text: string; + xywh: string; + }; + + const root: MindMapNode = { + children: [], + text: 'Mind Map', + xywh: `[${rootX},${rootY},${rootW},${rootH}]`, + }; + + for (let i = 0; i < 3; i++) { + const nodeX = x + rootW + 300; + const nodeY = centerVertical - nodeH / 2 + (i - 1) * 50; + root.children.push({ + children: [], + text: 'Text', + xywh: `[${nodeX},${nodeY},${nodeW},${nodeH}]`, + }); + } + + const mindmapId = edgelessService.addElement('mindmap', { + style: mindmapStyle, + children: root, + }) as string; + + edgelessService.std + .getOptional(TelemetryProvider) + ?.track('CanvasElementAdded', { + control: 'toolbar:dnd', // for now we use toolbar:dnd for all mindmap creation here + page: 'whiteboard editor', + module: 'toolbar', + segment: 'toolbar', + type: 'mindmap', + }); + + return mindmapId; + }; +export const textRender: DraggableTool['render'] = ( + bound, + service, + edgeless +) => { + const vCenter = bound.y + bound.h / 2; + const w = 100; + const h = 32; + + const flag = edgeless.doc.awarenessStore.getFlag('enable_edgeless_text'); + let id: string; + if (flag) { + const { textId } = edgeless.std.command.exec('insertEdgelessText', { + x: bound.x, + y: vCenter - h / 2, + }); + id = textId!; + } else { + id = service.addElement(CanvasElementType.TEXT, { + xywh: new Bound(bound.x, vCenter - h / 2, w, h).serialize(), + text: new DocCollection.Y.Text(), + }); + + edgeless.doc.captureSync(); + const textElement = edgeless.service.getElementById(id); + assertInstanceOf(textElement, TextElementModel); + mountTextElementEditor(textElement, edgeless); + } + + service.std.getOptional(TelemetryProvider)?.track('CanvasElementAdded', { + control: 'toolbar:dnd', + page: 'whiteboard editor', + module: 'toolbar', + segment: 'toolbar', + type: 'text', + }); + + return id; +}; + +const toolStyle2StyleObj = (state: ConfigState, style: ConfigStyle = {}) => { + const styleObj = {} as Record<string, string>; + for (const [key, value] of Object.entries(style)) { + styleObj[`--${state}-${key}`] = `${value}${unitMap[key as ConfigProperty]}`; + } + return styleObj; +}; +export const toolConfig2StyleObj = (config: ToolConfig) => { + const styleObj = {} as Record<string, string>; + for (const [state, style] of Object.entries(config)) { + Object.assign( + styleObj, + toolStyle2StyleObj(state as ConfigState, { + ...config.default, + ...style, + }) + ); + } + return styleObj; +}; diff --git a/blocksuite/blocks/src/root-block/edgeless/components/toolbar/mindmap/icons.ts b/blocksuite/blocks/src/root-block/edgeless/components/toolbar/mindmap/icons.ts new file mode 100644 index 0000000000..0827de5024 --- /dev/null +++ b/blocksuite/blocks/src/root-block/edgeless/components/toolbar/mindmap/icons.ts @@ -0,0 +1,576 @@ +import { svg } from 'lit'; + +export const basketIconLight = svg`<svg width="76" height="17" viewBox="0 0 76 17" fill="none" xmlns="http://www.w3.org/2000/svg" +xmlns:xlink="http://www.w3.org/1999/xlink"> +<g filter="url(#filter0_b_5310_64490)"> + <mask id="path-1-inside-1_5310_64490" fill="white"> + <path fill-rule="evenodd" clip-rule="evenodd" + d="M4 1.62744C2.89543 1.62744 2 2.52287 2 3.62744V29.6088H73.943V3.62744C73.943 2.52287 73.0476 1.62744 71.943 1.62744H4ZM25.5294 5.94132C23.97 5.94132 22.7059 7.20546 22.7059 8.76485C22.7059 10.3242 23.97 11.5884 25.5294 11.5884H50.9412C52.5006 11.5884 53.7647 10.3242 53.7647 8.76485C53.7647 7.20546 52.5006 5.94132 50.9412 5.94132H25.5294Z" /> + </mask> + <path fill-rule="evenodd" clip-rule="evenodd" + d="M4 1.62744C2.89543 1.62744 2 2.52287 2 3.62744V29.6088H73.943V3.62744C73.943 2.52287 73.0476 1.62744 71.943 1.62744H4ZM25.5294 5.94132C23.97 5.94132 22.7059 7.20546 22.7059 8.76485C22.7059 10.3242 23.97 11.5884 25.5294 11.5884H50.9412C52.5006 11.5884 53.7647 10.3242 53.7647 8.76485C53.7647 7.20546 52.5006 5.94132 50.9412 5.94132H25.5294Z" + fill="url(#paint0_linear_5310_64490)" fill-opacity="0.2" /> + <path fill-rule="evenodd" clip-rule="evenodd" + d="M4 1.62744C2.89543 1.62744 2 2.52287 2 3.62744V29.6088H73.943V3.62744C73.943 2.52287 73.0476 1.62744 71.943 1.62744H4ZM25.5294 5.94132C23.97 5.94132 22.7059 7.20546 22.7059 8.76485C22.7059 10.3242 23.97 11.5884 25.5294 11.5884H50.9412C52.5006 11.5884 53.7647 10.3242 53.7647 8.76485C53.7647 7.20546 52.5006 5.94132 50.9412 5.94132H25.5294Z" + fill="url(#pattern0_5310_64490)" fill-opacity="0.2" /> + <path + d="M2 29.6088H1V30.6088H2V29.6088ZM73.943 29.6088V30.6088H74.943V29.6088H73.943ZM3 3.62744C3 3.07516 3.44772 2.62744 4 2.62744V0.627441C2.34315 0.627441 1 1.97059 1 3.62744H3ZM3 29.6088V3.62744H1V29.6088H3ZM73.943 28.6088H2V30.6088H73.943V28.6088ZM72.943 3.62744V29.6088H74.943V3.62744H72.943ZM71.943 2.62744C72.4953 2.62744 72.943 3.07515 72.943 3.62744H74.943C74.943 1.97059 73.5999 0.627441 71.943 0.627441V2.62744ZM4 2.62744H71.943V0.627441H4V2.62744ZM23.7059 8.76485C23.7059 7.75774 24.5223 6.94132 25.5294 6.94132V4.94132C23.4177 4.94132 21.7059 6.65317 21.7059 8.76485H23.7059ZM25.5294 10.5884C24.5223 10.5884 23.7059 9.77196 23.7059 8.76485H21.7059C21.7059 10.8765 23.4177 12.5884 25.5294 12.5884V10.5884ZM50.9412 10.5884H25.5294V12.5884H50.9412V10.5884ZM52.7647 8.76485C52.7647 9.77196 51.9483 10.5884 50.9412 10.5884V12.5884C53.0529 12.5884 54.7647 10.8765 54.7647 8.76485H52.7647ZM50.9412 6.94132C51.9483 6.94132 52.7647 7.75774 52.7647 8.76485H54.7647C54.7647 6.65317 53.0529 4.94132 50.9412 4.94132V6.94132ZM25.5294 6.94132H50.9412V4.94132H25.5294V6.94132Z" + fill="#DFDFDF" mask="url(#path-1-inside-1_5310_64490)" /> + <path + d="M2 29.6088H1V30.6088H2V29.6088ZM73.943 29.6088V30.6088H74.943V29.6088H73.943ZM3 3.62744C3 3.07516 3.44772 2.62744 4 2.62744V0.627441C2.34315 0.627441 1 1.97059 1 3.62744H3ZM3 29.6088V3.62744H1V29.6088H3ZM73.943 28.6088H2V30.6088H73.943V28.6088ZM72.943 3.62744V29.6088H74.943V3.62744H72.943ZM71.943 2.62744C72.4953 2.62744 72.943 3.07515 72.943 3.62744H74.943C74.943 1.97059 73.5999 0.627441 71.943 0.627441V2.62744ZM4 2.62744H71.943V0.627441H4V2.62744ZM23.7059 8.76485C23.7059 7.75774 24.5223 6.94132 25.5294 6.94132V4.94132C23.4177 4.94132 21.7059 6.65317 21.7059 8.76485H23.7059ZM25.5294 10.5884C24.5223 10.5884 23.7059 9.77196 23.7059 8.76485H21.7059C21.7059 10.8765 23.4177 12.5884 25.5294 12.5884V10.5884ZM50.9412 10.5884H25.5294V12.5884H50.9412V10.5884ZM52.7647 8.76485C52.7647 9.77196 51.9483 10.5884 50.9412 10.5884V12.5884C53.0529 12.5884 54.7647 10.8765 54.7647 8.76485H52.7647ZM50.9412 6.94132C51.9483 6.94132 52.7647 7.75774 52.7647 8.76485H54.7647C54.7647 6.65317 53.0529 4.94132 50.9412 4.94132V6.94132ZM25.5294 6.94132H50.9412V4.94132H25.5294V6.94132Z" + fill="url(#paint1_linear_5310_64490)" fill-opacity="0.2" mask="url(#path-1-inside-1_5310_64490)" /> +</g> +<g filter="url(#filter1_f_5310_64490)"> + <path + d="M70.2669 3.15204C71.3826 3.15204 72.2871 4.05651 72.2871 5.17224L72.2871 17.0683C72.2871 18.2224 71.3515 19.158 70.1974 19.158C68.779 19.158 67.7727 17.775 68.209 16.4254L69.6661 11.919C69.9708 10.9766 69.9431 9.95821 69.5876 9.03381L68.3813 5.8974C67.8725 4.57417 68.8492 3.15204 70.2669 3.15204Z" + fill="url(#paint2_linear_5310_64490)" /> +</g> +<g filter="url(#filter2_f_5310_64490)"> + <path + d="M6.71501 5.1665C5.1902 5.1665 3.9541 6.40261 3.9541 7.92741L3.9541 13.7517C3.9541 15.4078 5.29667 16.7504 6.9528 16.7504L7.67637 16.7504C9.95388 16.7504 11.1826 14.079 9.70058 12.3497L9.43766 12.0429C8.67279 11.1505 8.73042 9.8179 9.56949 8.9948C10.9975 7.59395 10.0056 5.1665 8.00525 5.1665L6.71501 5.1665Z" + fill="url(#paint3_linear_5310_64490)" /> +</g> +<defs> + <filter id="filter0_b_5310_64490" x="0" y="-0.372559" width="75.9434" height="31.9814" filterUnits="userSpaceOnUse" + color-interpolation-filters="sRGB"> + <feFlood flood-opacity="0" result="BackgroundImageFix" /> + <feGaussianBlur in="BackgroundImageFix" stdDeviation="1" /> + <feComposite in2="SourceAlpha" operator="in" result="effect1_backgroundBlur_5310_64490" /> + <feBlend mode="normal" in="SourceGraphic" in2="effect1_backgroundBlur_5310_64490" result="shape" /> + </filter> + <pattern id="pattern0_5310_64490" patternContentUnits="objectBoundingBox" width="1.49325" height="3.83929"> + <use xlink:href="#image0_5310_64490" transform="scale(0.000729125 0.00187465)" /> + </pattern> + <filter id="filter1_f_5310_64490" x="65.1064" y="0.152039" width="10.1807" height="22.006" + filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB"> + <feFlood flood-opacity="0" result="BackgroundImageFix" /> + <feBlend mode="normal" in="SourceGraphic" in2="BackgroundImageFix" result="shape" /> + <feGaussianBlur stdDeviation="1.5" result="effect1_foregroundBlur_5310_64490" /> + </filter> + <filter id="filter2_f_5310_64490" x="0.954102" y="2.1665" width="12.3936" height="17.5839" + filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB"> + <feFlood flood-opacity="0" result="BackgroundImageFix" /> + <feBlend mode="normal" in="SourceGraphic" in2="BackgroundImageFix" result="shape" /> + <feGaussianBlur stdDeviation="1.5" result="effect1_foregroundBlur_5310_64490" /> + </filter> + <linearGradient id="paint0_linear_5310_64490" x1="3.19554" y1="11.2178" x2="72.9973" y2="11.6556" + gradientUnits="userSpaceOnUse"> + <stop stop-opacity="0" /> + <stop offset="0.848958" stop-opacity="0.66" /> + <stop offset="1" stop-opacity="0" /> + </linearGradient> + <linearGradient id="paint1_linear_5310_64490" x1="2" y1="5.50025" x2="74" y2="7.50025" + gradientUnits="userSpaceOnUse"> + <stop stop-color="white" stop-opacity="0.12" /> + <stop offset="0.71" stop-opacity="0.77" /> + <stop offset="1" stop-opacity="0.12" /> + </linearGradient> + <linearGradient id="paint2_linear_5310_64490" x1="70.1607" y1="3.80093" x2="70.1607" y2="20.4558" + gradientUnits="userSpaceOnUse"> + <stop stop-color="white" /> + <stop offset="1" stop-color="white" stop-opacity="0" /> + </linearGradient> + <linearGradient id="paint3_linear_5310_64490" x1="8.03322" y1="5.63612" x2="8.03322" y2="17.6896" + gradientUnits="userSpaceOnUse"> + <stop stop-color="white" /> + <stop offset="1" stop-color="white" stop-opacity="0" /> + </linearGradient> +</defs> +</svg>`; + +export const basketIconDark = svg`<svg width="75" height="17" viewBox="0 0 75 17" fill="none" xmlns="http://www.w3.org/2000/svg" +xmlns:xlink="http://www.w3.org/1999/xlink"> +<g filter="url(#filter0_b_5310_64515)"> + <mask id="path-1-inside-1_5310_64515" fill="white"> + <path fill-rule="evenodd" clip-rule="evenodd" + d="M3 1.62744C1.89543 1.62744 1 2.52287 1 3.62744V29.6088H72.943V3.62744C72.943 2.52287 72.0476 1.62744 70.943 1.62744H3ZM24.5294 5.94132C22.97 5.94132 21.7059 7.20546 21.7059 8.76485C21.7059 10.3242 22.97 11.5884 24.5294 11.5884H49.9412C51.5006 11.5884 52.7647 10.3242 52.7647 8.76485C52.7647 7.20546 51.5006 5.94132 49.9412 5.94132H24.5294Z" /> + </mask> + <path fill-rule="evenodd" clip-rule="evenodd" + d="M3 1.62744C1.89543 1.62744 1 2.52287 1 3.62744V29.6088H72.943V3.62744C72.943 2.52287 72.0476 1.62744 70.943 1.62744H3ZM24.5294 5.94132C22.97 5.94132 21.7059 7.20546 21.7059 8.76485C21.7059 10.3242 22.97 11.5884 24.5294 11.5884H49.9412C51.5006 11.5884 52.7647 10.3242 52.7647 8.76485C52.7647 7.20546 51.5006 5.94132 49.9412 5.94132H24.5294Z" + fill="url(#paint0_linear_5310_64515)" fill-opacity="0.2" /> + <path fill-rule="evenodd" clip-rule="evenodd" + d="M3 1.62744C1.89543 1.62744 1 2.52287 1 3.62744V29.6088H72.943V3.62744C72.943 2.52287 72.0476 1.62744 70.943 1.62744H3ZM24.5294 5.94132C22.97 5.94132 21.7059 7.20546 21.7059 8.76485C21.7059 10.3242 22.97 11.5884 24.5294 11.5884H49.9412C51.5006 11.5884 52.7647 10.3242 52.7647 8.76485C52.7647 7.20546 51.5006 5.94132 49.9412 5.94132H24.5294Z" + fill="url(#pattern0_5310_64515)" fill-opacity="0.2" /> + <path + d="M1 29.6088H0V30.6088H1V29.6088ZM72.943 29.6088V30.6088H73.943V29.6088H72.943ZM2 3.62744C2 3.07516 2.44772 2.62744 3 2.62744V0.627441C1.34315 0.627441 0 1.97059 0 3.62744H2ZM2 29.6088V3.62744H0V29.6088H2ZM72.943 28.6088H1V30.6088H72.943V28.6088ZM71.943 3.62744V29.6088H73.943V3.62744H71.943ZM70.943 2.62744C71.4953 2.62744 71.943 3.07515 71.943 3.62744H73.943C73.943 1.97059 72.5999 0.627441 70.943 0.627441V2.62744ZM3 2.62744H70.943V0.627441H3V2.62744ZM22.7059 8.76485C22.7059 7.75774 23.5223 6.94132 24.5294 6.94132V4.94132C22.4177 4.94132 20.7059 6.65317 20.7059 8.76485H22.7059ZM24.5294 10.5884C23.5223 10.5884 22.7059 9.77196 22.7059 8.76485H20.7059C20.7059 10.8765 22.4177 12.5884 24.5294 12.5884V10.5884ZM49.9412 10.5884H24.5294V12.5884H49.9412V10.5884ZM51.7647 8.76485C51.7647 9.77196 50.9483 10.5884 49.9412 10.5884V12.5884C52.0529 12.5884 53.7647 10.8765 53.7647 8.76485H51.7647ZM49.9412 6.94132C50.9483 6.94132 51.7647 7.75774 51.7647 8.76485H53.7647C53.7647 6.65317 52.0529 4.94132 49.9412 4.94132V6.94132ZM24.5294 6.94132H49.9412V4.94132H24.5294V6.94132Z" + fill="#7C7C7C" mask="url(#path-1-inside-1_5310_64515)" /> + <path + d="M1 29.6088H0V30.6088H1V29.6088ZM72.943 29.6088V30.6088H73.943V29.6088H72.943ZM2 3.62744C2 3.07516 2.44772 2.62744 3 2.62744V0.627441C1.34315 0.627441 0 1.97059 0 3.62744H2ZM2 29.6088V3.62744H0V29.6088H2ZM72.943 28.6088H1V30.6088H72.943V28.6088ZM71.943 3.62744V29.6088H73.943V3.62744H71.943ZM70.943 2.62744C71.4953 2.62744 71.943 3.07515 71.943 3.62744H73.943C73.943 1.97059 72.5999 0.627441 70.943 0.627441V2.62744ZM3 2.62744H70.943V0.627441H3V2.62744ZM22.7059 8.76485C22.7059 7.75774 23.5223 6.94132 24.5294 6.94132V4.94132C22.4177 4.94132 20.7059 6.65317 20.7059 8.76485H22.7059ZM24.5294 10.5884C23.5223 10.5884 22.7059 9.77196 22.7059 8.76485H20.7059C20.7059 10.8765 22.4177 12.5884 24.5294 12.5884V10.5884ZM49.9412 10.5884H24.5294V12.5884H49.9412V10.5884ZM51.7647 8.76485C51.7647 9.77196 50.9483 10.5884 49.9412 10.5884V12.5884C52.0529 12.5884 53.7647 10.8765 53.7647 8.76485H51.7647ZM49.9412 6.94132C50.9483 6.94132 51.7647 7.75774 51.7647 8.76485H53.7647C53.7647 6.65317 52.0529 4.94132 49.9412 4.94132V6.94132ZM24.5294 6.94132H49.9412V4.94132H24.5294V6.94132Z" + fill="url(#paint1_linear_5310_64515)" fill-opacity="0.2" mask="url(#path-1-inside-1_5310_64515)" /> +</g> +<g filter="url(#filter1_f_5310_64515)"> + <path d="M71.5225 3.15186L71.5225 19.1578L66.5609 19.1578L68.0588 10.647L66.5609 3.15186L71.5225 3.15186Z" + fill="url(#paint2_linear_5310_64515)" fill-opacity="0.6" /> +</g> +<g filter="url(#filter2_f_5310_64515)"> + <path + d="M4.11816 3.11768C3.56588 3.11768 3.11816 3.56539 3.11816 4.11768L3.11816 18.1177C3.11816 18.67 3.56588 19.1177 4.11816 19.1177L10.2348 19.1177C10.9076 19.1177 11.3884 18.4666 11.1906 17.8236L9.77942 13.2373C9.73147 13.0815 9.72235 12.9163 9.75286 12.7561L11.3626 4.30479C11.48 3.68856 11.0076 3.11768 10.3803 3.11768L4.11816 3.11768Z" + fill="url(#paint3_linear_5310_64515)" fill-opacity="0.5" /> +</g> +<defs> + <filter id="filter0_b_5310_64515" x="-1" y="-0.372559" width="75.9434" height="31.9814" filterUnits="userSpaceOnUse" + color-interpolation-filters="sRGB"> + <feFlood flood-opacity="0" result="BackgroundImageFix" /> + <feGaussianBlur in="BackgroundImageFix" stdDeviation="1" /> + <feComposite in2="SourceAlpha" operator="in" result="effect1_backgroundBlur_5310_64515" /> + <feBlend mode="normal" in="SourceGraphic" in2="effect1_backgroundBlur_5310_64515" result="shape" /> + </filter> + <pattern id="pattern0_5310_64515" patternContentUnits="objectBoundingBox" width="1.49325" height="3.83929"> + <use xlink:href="#image0_5310_64515" transform="scale(0.000729125 0.00187465)" /> + </pattern> + <filter id="filter1_f_5310_64515" x="63.5605" y="0.151855" width="10.9619" height="22.006" + filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB"> + <feFlood flood-opacity="0" result="BackgroundImageFix" /> + <feBlend mode="normal" in="SourceGraphic" in2="BackgroundImageFix" result="shape" /> + <feGaussianBlur stdDeviation="1.5" result="effect1_foregroundBlur_5310_64515" /> + </filter> + <filter id="filter2_f_5310_64515" x="0.118164" y="0.117676" width="14.2627" height="22" filterUnits="userSpaceOnUse" + color-interpolation-filters="sRGB"> + <feFlood flood-opacity="0" result="BackgroundImageFix" /> + <feBlend mode="normal" in="SourceGraphic" in2="BackgroundImageFix" result="shape" /> + <feGaussianBlur stdDeviation="1.5" result="effect1_foregroundBlur_5310_64515" /> + </filter> + <linearGradient id="paint0_linear_5310_64515" x1="2.19554" y1="11.2178" x2="71.9973" y2="11.6556" + gradientUnits="userSpaceOnUse"> + <stop stop-opacity="0" /> + <stop offset="0.848958" stop-opacity="0.66" /> + <stop offset="1" stop-opacity="0" /> + </linearGradient> + <linearGradient id="paint1_linear_5310_64515" x1="1" y1="5.50025" x2="73" y2="7.50025" + gradientUnits="userSpaceOnUse"> + <stop stop-color="white" stop-opacity="0.12" /> + <stop offset="0.71" stop-opacity="0.77" /> + <stop offset="1" stop-opacity="0.12" /> + </linearGradient> + <linearGradient id="paint2_linear_5310_64515" x1="69.3961" y1="3.80075" x2="69.3961" y2="20.4556" + gradientUnits="userSpaceOnUse"> + <stop stop-color="white" stop-opacity="0.2" /> + <stop offset="1" stop-color="white" stop-opacity="0.12" /> + </linearGradient> + <linearGradient id="paint3_linear_5310_64515" x1="6.74843" y1="3.76632" x2="6.74843" y2="20.415" + gradientUnits="userSpaceOnUse"> + <stop stop-color="white" stop-opacity="0.2" /> + <stop offset="1" stop-color="white" stop-opacity="0.12" /> + </linearGradient> +</defs> +</svg>`; + +export const textIcon = svg`<svg width="54" height="24" viewBox="0 0 54 24" fill="none" xmlns="http://www.w3.org/2000/svg"> +<g filter="url(#filter0_d_740_3589)"> +<mask id="path-1-outside-1_740_3589" maskUnits="userSpaceOnUse" x="0.875977" y="0.511719" width="52" height="22" fill="black"> +<rect fill="white" x="0.875977" y="0.511719" width="52" height="22"/> +<path d="M7.52094 6.01159C5.67018 6.11441 4.65911 6.16582 4.48774 6.16582C4.07646 6.16582 3.66518 5.91734 3.2539 5.42037C2.84262 4.90627 2.63698 4.37503 2.63698 3.82666C2.63698 2.98696 5.07896 2.56711 9.96291 2.56711C12.4649 2.56711 14.3071 2.82416 15.4895 3.33826C16.6719 3.85236 17.2631 4.47785 17.2631 5.21473C17.2631 5.43751 17.1346 5.65172 16.8776 5.85736C16.6205 6.04586 16.2949 6.14011 15.9008 6.14011C15.5066 6.14011 14.7869 6.11441 13.7416 6.063C12.6962 6.01159 11.7451 5.97731 10.8883 5.96018C10.9568 6.16582 10.9911 6.40573 10.9911 6.67992C10.9911 6.9541 10.8883 7.49391 10.6827 8.29934C10.4942 9.10476 10.22 10.2272 9.8601 11.6667C9.50022 13.089 9.10608 14.8198 8.67766 16.8591C8.24925 18.8812 7.94935 20.0894 7.77799 20.4835C7.62376 20.8605 7.46953 21.049 7.3153 21.049C6.95543 21.049 6.5013 20.7234 5.95293 20.0722C5.42169 19.4039 5.15607 18.8641 5.15607 18.4528C5.15607 18.0415 5.50738 16.345 6.20998 13.3632C6.91259 10.3643 7.34957 7.91376 7.52094 6.01159ZM27.3638 12.7463C27.3638 13.586 26.7897 14.34 25.6416 15.0083C24.5106 15.6767 23.3453 16.0108 22.1457 16.0108C20.9461 16.0108 19.9608 15.9252 19.1896 15.7538C19.0354 16.2165 18.9583 16.6792 18.9583 17.1419C18.9583 17.6046 19.0354 17.9387 19.1896 18.1444C19.3438 18.3329 19.7294 18.4271 20.3463 18.4271C21.6316 18.4271 22.7283 18.3072 23.6366 18.0672C24.562 17.8273 25.0418 17.7074 25.0761 17.7074C25.7787 17.7074 26.13 17.9387 26.13 18.4014C26.13 19.0697 25.573 19.7038 24.4591 20.3036C23.3624 20.9034 22.0857 21.2033 20.6291 21.2033C19.1896 21.2033 18.0329 20.7491 17.1589 19.8409C16.2849 18.9326 15.848 17.8616 15.848 16.6278C15.848 15.3768 16.2078 14.0744 16.9276 12.7206C17.6644 11.3668 18.5813 10.2358 19.678 9.32754C20.7748 8.40215 21.8629 7.93946 22.9425 7.93946C24.0393 7.93946 25.0504 8.47927 25.9757 9.55888C26.9011 10.6214 27.3638 11.6838 27.3638 12.7463ZM24.382 12.335C24.382 12.1637 24.2021 11.9152 23.8422 11.5896C23.4824 11.264 23.1396 11.1012 22.814 11.1012C22.5056 11.1012 22.1029 11.3325 21.6059 11.7952C21.1089 12.2408 20.6377 12.8234 20.1921 13.5432C20.3463 13.5603 20.5691 13.5689 20.8604 13.5689C23.2082 13.5689 24.382 13.1576 24.382 12.335ZM38.1414 20.2522C38.1414 20.8862 37.8587 21.2033 37.2932 21.2033C36.3678 21.2033 35.1511 20.0722 33.6431 17.8102C31.6895 20.0722 30.3785 21.2033 29.7102 21.2033C29.2475 21.2033 28.802 20.9805 28.3735 20.5349C27.9623 20.0722 27.7566 19.6695 27.7566 19.3268C27.7566 19.2411 27.9366 19.0269 28.2964 18.6842C29.7188 17.3304 30.9098 16.0537 31.8694 14.8541C31.1325 13.5175 30.5071 12.2408 29.993 11.0241C29.4789 9.80736 29.2218 9.07049 29.2218 8.81344C29.2218 8.53925 29.2304 8.35931 29.2475 8.27363C29.2989 8.05085 29.4532 7.93946 29.7102 7.93946C30.4299 7.93946 31.184 8.08513 31.9722 8.37645C32.7777 8.65064 33.1804 8.97623 33.1804 9.35324C33.1804 9.95302 33.4374 10.827 33.9515 11.9752C34.9969 10.5014 35.8451 9.45606 36.4963 8.83914C37.1647 8.22222 37.7045 7.91376 38.1157 7.91376C38.5442 7.91376 39.0154 8.16224 39.5295 8.65921C40.0608 9.15617 40.3264 9.60172 40.3264 9.99587C40.3264 10.1844 40.2493 10.3643 40.095 10.5357C39.9408 10.707 39.7095 10.9127 39.401 11.1526C39.0925 11.3754 38.7926 11.6324 38.5013 11.9237C38.2271 12.2151 37.2932 13.3375 35.6995 15.2911L36.4449 16.6021C37.5759 18.5899 38.1414 19.8066 38.1414 20.2522ZM47.2316 10.9984C47.0945 11.4611 46.8889 12.1122 46.6147 12.9519C46.3576 13.7745 46.032 14.8113 45.6379 16.0623C45.2609 17.3132 45.0724 17.9816 45.0724 18.0672C45.0724 18.1358 45.2095 18.1701 45.4836 18.1701C45.775 18.1701 46.4519 17.9816 47.5143 17.6046C48.5768 17.2104 49.2023 17.0133 49.3908 17.0133C49.8192 17.0133 50.0334 17.3047 50.0334 17.8873C50.0334 18.47 49.4594 19.1726 48.3112 19.9951C47.163 20.8005 45.9635 21.2033 44.7125 21.2033C44.1641 21.2033 43.5129 20.9034 42.7589 20.3036C42.0049 19.7038 41.6279 19.0954 41.6279 18.4785C41.6279 17.9644 41.8849 16.8848 42.399 15.2397C42.9303 13.5774 43.2902 12.4378 43.4787 11.8209C42.8103 12.0094 42.3048 12.1037 41.9621 12.1037C41.6193 12.1037 41.2595 11.8723 40.8824 11.4096C40.5054 10.9298 40.3169 10.4757 40.3169 10.0473C40.3169 9.4989 41.645 9.03621 44.3012 8.65921C44.6268 7.06549 44.7896 5.68599 44.7896 4.5207C44.7896 4.00659 45.0467 3.74954 45.5608 3.74954C46.0063 3.74954 46.5718 4.02373 47.2573 4.57211C47.9599 5.10334 48.3112 5.63458 48.3112 6.16582C48.3112 6.67992 48.1998 7.40823 47.977 8.35074C48.7996 8.31647 49.4251 8.29934 49.8535 8.29934C51.1388 8.29934 51.7814 8.59066 51.7814 9.17331C51.7814 9.55031 51.4044 9.87591 50.6504 10.1501C49.9135 10.4071 48.7739 10.6899 47.2316 10.9984Z"/> +</mask> +<path d="M7.52094 6.01159C5.67018 6.11441 4.65911 6.16582 4.48774 6.16582C4.07646 6.16582 3.66518 5.91734 3.2539 5.42037C2.84262 4.90627 2.63698 4.37503 2.63698 3.82666C2.63698 2.98696 5.07896 2.56711 9.96291 2.56711C12.4649 2.56711 14.3071 2.82416 15.4895 3.33826C16.6719 3.85236 17.2631 4.47785 17.2631 5.21473C17.2631 5.43751 17.1346 5.65172 16.8776 5.85736C16.6205 6.04586 16.2949 6.14011 15.9008 6.14011C15.5066 6.14011 14.7869 6.11441 13.7416 6.063C12.6962 6.01159 11.7451 5.97731 10.8883 5.96018C10.9568 6.16582 10.9911 6.40573 10.9911 6.67992C10.9911 6.9541 10.8883 7.49391 10.6827 8.29934C10.4942 9.10476 10.22 10.2272 9.8601 11.6667C9.50022 13.089 9.10608 14.8198 8.67766 16.8591C8.24925 18.8812 7.94935 20.0894 7.77799 20.4835C7.62376 20.8605 7.46953 21.049 7.3153 21.049C6.95543 21.049 6.5013 20.7234 5.95293 20.0722C5.42169 19.4039 5.15607 18.8641 5.15607 18.4528C5.15607 18.0415 5.50738 16.345 6.20998 13.3632C6.91259 10.3643 7.34957 7.91376 7.52094 6.01159ZM27.3638 12.7463C27.3638 13.586 26.7897 14.34 25.6416 15.0083C24.5106 15.6767 23.3453 16.0108 22.1457 16.0108C20.9461 16.0108 19.9608 15.9252 19.1896 15.7538C19.0354 16.2165 18.9583 16.6792 18.9583 17.1419C18.9583 17.6046 19.0354 17.9387 19.1896 18.1444C19.3438 18.3329 19.7294 18.4271 20.3463 18.4271C21.6316 18.4271 22.7283 18.3072 23.6366 18.0672C24.562 17.8273 25.0418 17.7074 25.0761 17.7074C25.7787 17.7074 26.13 17.9387 26.13 18.4014C26.13 19.0697 25.573 19.7038 24.4591 20.3036C23.3624 20.9034 22.0857 21.2033 20.6291 21.2033C19.1896 21.2033 18.0329 20.7491 17.1589 19.8409C16.2849 18.9326 15.848 17.8616 15.848 16.6278C15.848 15.3768 16.2078 14.0744 16.9276 12.7206C17.6644 11.3668 18.5813 10.2358 19.678 9.32754C20.7748 8.40215 21.8629 7.93946 22.9425 7.93946C24.0393 7.93946 25.0504 8.47927 25.9757 9.55888C26.9011 10.6214 27.3638 11.6838 27.3638 12.7463ZM24.382 12.335C24.382 12.1637 24.2021 11.9152 23.8422 11.5896C23.4824 11.264 23.1396 11.1012 22.814 11.1012C22.5056 11.1012 22.1029 11.3325 21.6059 11.7952C21.1089 12.2408 20.6377 12.8234 20.1921 13.5432C20.3463 13.5603 20.5691 13.5689 20.8604 13.5689C23.2082 13.5689 24.382 13.1576 24.382 12.335ZM38.1414 20.2522C38.1414 20.8862 37.8587 21.2033 37.2932 21.2033C36.3678 21.2033 35.1511 20.0722 33.6431 17.8102C31.6895 20.0722 30.3785 21.2033 29.7102 21.2033C29.2475 21.2033 28.802 20.9805 28.3735 20.5349C27.9623 20.0722 27.7566 19.6695 27.7566 19.3268C27.7566 19.2411 27.9366 19.0269 28.2964 18.6842C29.7188 17.3304 30.9098 16.0537 31.8694 14.8541C31.1325 13.5175 30.5071 12.2408 29.993 11.0241C29.4789 9.80736 29.2218 9.07049 29.2218 8.81344C29.2218 8.53925 29.2304 8.35931 29.2475 8.27363C29.2989 8.05085 29.4532 7.93946 29.7102 7.93946C30.4299 7.93946 31.184 8.08513 31.9722 8.37645C32.7777 8.65064 33.1804 8.97623 33.1804 9.35324C33.1804 9.95302 33.4374 10.827 33.9515 11.9752C34.9969 10.5014 35.8451 9.45606 36.4963 8.83914C37.1647 8.22222 37.7045 7.91376 38.1157 7.91376C38.5442 7.91376 39.0154 8.16224 39.5295 8.65921C40.0608 9.15617 40.3264 9.60172 40.3264 9.99587C40.3264 10.1844 40.2493 10.3643 40.095 10.5357C39.9408 10.707 39.7095 10.9127 39.401 11.1526C39.0925 11.3754 38.7926 11.6324 38.5013 11.9237C38.2271 12.2151 37.2932 13.3375 35.6995 15.2911L36.4449 16.6021C37.5759 18.5899 38.1414 19.8066 38.1414 20.2522ZM47.2316 10.9984C47.0945 11.4611 46.8889 12.1122 46.6147 12.9519C46.3576 13.7745 46.032 14.8113 45.6379 16.0623C45.2609 17.3132 45.0724 17.9816 45.0724 18.0672C45.0724 18.1358 45.2095 18.1701 45.4836 18.1701C45.775 18.1701 46.4519 17.9816 47.5143 17.6046C48.5768 17.2104 49.2023 17.0133 49.3908 17.0133C49.8192 17.0133 50.0334 17.3047 50.0334 17.8873C50.0334 18.47 49.4594 19.1726 48.3112 19.9951C47.163 20.8005 45.9635 21.2033 44.7125 21.2033C44.1641 21.2033 43.5129 20.9034 42.7589 20.3036C42.0049 19.7038 41.6279 19.0954 41.6279 18.4785C41.6279 17.9644 41.8849 16.8848 42.399 15.2397C42.9303 13.5774 43.2902 12.4378 43.4787 11.8209C42.8103 12.0094 42.3048 12.1037 41.9621 12.1037C41.6193 12.1037 41.2595 11.8723 40.8824 11.4096C40.5054 10.9298 40.3169 10.4757 40.3169 10.0473C40.3169 9.4989 41.645 9.03621 44.3012 8.65921C44.6268 7.06549 44.7896 5.68599 44.7896 4.5207C44.7896 4.00659 45.0467 3.74954 45.5608 3.74954C46.0063 3.74954 46.5718 4.02373 47.2573 4.57211C47.9599 5.10334 48.3112 5.63458 48.3112 6.16582C48.3112 6.67992 48.1998 7.40823 47.977 8.35074C48.7996 8.31647 49.4251 8.29934 49.8535 8.29934C51.1388 8.29934 51.7814 8.59066 51.7814 9.17331C51.7814 9.55031 51.4044 9.87591 50.6504 10.1501C49.9135 10.4071 48.7739 10.6899 47.2316 10.9984Z" fill="#6E52DF"/> +<path d="M7.52094 6.01159L8.58766 6.10769L8.69885 4.87345L7.46153 4.94219L7.52094 6.01159ZM3.2539 5.42037L2.41756 6.08945L2.42311 6.09639L2.42878 6.10323L3.2539 5.42037ZM15.4895 3.33826L15.0624 4.32049L15.0624 4.32049L15.4895 3.33826ZM16.8776 5.85736L17.5109 6.72105L17.5291 6.70775L17.5466 6.6937L16.8776 5.85736ZM13.7416 6.063L13.6889 7.13275L13.6889 7.13275L13.7416 6.063ZM10.8883 5.96018L10.9097 4.88935L9.39226 4.859L9.87222 6.29887L10.8883 5.96018ZM10.6827 8.29934L9.6449 8.03438L9.64224 8.04479L9.63979 8.05526L10.6827 8.29934ZM9.8601 11.6667L10.8984 11.9294L10.8992 11.9265L9.8601 11.6667ZM8.67766 16.8591L9.72545 17.0811L9.72583 17.0793L8.67766 16.8591ZM7.77799 20.4835L6.79577 20.0565L6.79111 20.0672L6.78669 20.078L7.77799 20.4835ZM5.95293 20.0722L5.11449 20.7387L5.12392 20.7505L5.13368 20.7621L5.95293 20.0722ZM6.20998 13.3632L7.25247 13.6089L7.25279 13.6075L6.20998 13.3632ZM7.46153 4.94219C6.53669 4.99357 5.8232 5.03202 5.32018 5.0576C5.06848 5.0704 4.87118 5.07989 4.72719 5.08615C4.6551 5.08928 4.59812 5.09153 4.55529 5.09297C4.5339 5.09369 4.51726 5.09417 4.50481 5.09446C4.49149 5.09477 4.48672 5.09478 4.48774 5.09478V7.23686C4.69818 7.23686 5.75634 7.18231 7.58035 7.08098L7.46153 4.94219ZM4.48774 5.09478C4.54101 5.09478 4.40254 5.12842 4.07903 4.73751L2.42878 6.10323C2.92782 6.70625 3.61192 7.23686 4.48774 7.23686V5.09478ZM4.09025 4.7513C3.7993 4.38762 3.70802 4.08567 3.70802 3.82666H1.56594C1.56594 4.6644 1.88594 5.42492 2.41756 6.08945L4.09025 4.7513ZM3.70802 3.82666C3.70802 4.23168 3.39464 4.3261 3.58582 4.22749C3.74524 4.14527 4.07544 4.03633 4.64995 3.93755C5.77804 3.7436 7.53527 3.63816 9.96291 3.63816V1.49607C7.5066 1.49607 5.60087 1.60055 4.28698 1.82644C3.64051 1.93759 3.05496 2.09106 2.60388 2.32373C2.18457 2.54 1.56594 3.00179 1.56594 3.82666H3.70802ZM9.96291 3.63816C12.4302 3.63816 14.0894 3.89741 15.0624 4.32049L15.9165 2.35604C14.5248 1.75092 12.4995 1.49607 9.96291 1.49607V3.63816ZM15.0624 4.32049C16.1667 4.80059 16.1921 5.16413 16.1921 5.21473H18.3342C18.3342 3.79158 17.1772 2.90414 15.9165 2.35604L15.0624 4.32049ZM16.1921 5.21473C16.1921 5.16585 16.1994 5.1193 16.2115 5.07799C16.2235 5.03741 16.2382 5.00839 16.2483 4.99142C16.2667 4.96082 16.2674 4.97386 16.2085 5.02101L17.5466 6.6937C17.9326 6.38491 18.3342 5.89499 18.3342 5.21473H16.1921ZM16.2442 4.99366C16.2105 5.01833 16.1218 5.06907 15.9008 5.06907V7.21115C16.468 7.21115 17.0305 7.07339 17.5109 6.72105L16.2442 4.99366ZM15.9008 5.06907C15.5365 5.06907 14.8415 5.04475 13.7942 4.99325L13.6889 7.13275C14.7323 7.18406 15.4768 7.21115 15.9008 7.21115V5.06907ZM13.7942 4.99325C12.7406 4.94143 11.7789 4.90673 10.9097 4.88935L10.8669 7.03101C11.7113 7.04789 12.6518 7.08174 13.6889 7.13275L13.7942 4.99325ZM9.87222 6.29887C9.89618 6.37078 9.92007 6.49179 9.92007 6.67992H12.0622C12.0622 6.31967 12.0175 5.96086 11.9044 5.62148L9.87222 6.29887ZM9.92007 6.67992C9.92007 6.79669 9.8566 7.20523 9.6449 8.03438L11.7204 8.56429C11.92 7.78259 12.0622 7.11152 12.0622 6.67992H9.92007ZM9.63979 8.05526C9.45312 8.85289 9.18044 9.96928 8.82103 11.4069L10.8992 11.9265C11.2595 10.4851 11.5352 9.35663 11.7255 8.54341L9.63979 8.05526ZM8.82177 11.404C8.4572 12.8449 8.05978 14.5908 7.6295 16.6389L9.72583 17.0793C10.1524 15.0489 10.5432 13.3332 10.8984 11.9294L8.82177 11.404ZM7.62988 16.6371C7.4168 17.6429 7.23762 18.4369 7.09194 19.0238C6.9389 19.6403 6.84011 19.9545 6.79577 20.0565L8.76021 20.9106C8.88723 20.6184 9.02408 20.1314 9.17093 19.5398C9.32514 18.9186 9.51011 18.0975 9.72545 17.0811L7.62988 16.6371ZM6.78669 20.078C6.72666 20.2247 6.69734 20.2543 6.7177 20.2294C6.73064 20.2136 6.77951 20.1566 6.87247 20.0998C6.97277 20.0385 7.12505 19.978 7.3153 19.978V22.1201C7.86215 22.1201 8.21279 21.7848 8.37558 21.5859C8.55017 21.3725 8.67508 21.1193 8.76929 20.8891L6.78669 20.078ZM7.3153 19.978C7.45213 19.978 7.4678 20.034 7.32889 19.9344C7.20132 19.8429 7.01561 19.6714 6.77218 19.3823L5.13368 20.7621C5.43863 21.1243 5.75416 21.4411 6.08072 21.6753C6.39593 21.9013 6.81859 22.1201 7.3153 22.1201V19.978ZM6.79137 19.4058C6.55531 19.1088 6.40449 18.8725 6.31629 18.6933C6.22494 18.5076 6.22712 18.4365 6.22712 18.4528H4.08503C4.08503 19.2575 4.56656 20.0494 5.11449 20.7387L6.79137 19.4058ZM6.22712 18.4528C6.22712 18.4648 6.22676 18.4426 6.23597 18.3658C6.24435 18.2959 6.25807 18.2015 6.27841 18.0795C6.31908 17.8355 6.38185 17.5069 6.46834 17.0891C6.64108 16.2549 6.90199 15.0963 7.25247 13.6089L5.16749 13.1176C4.81537 14.612 4.54932 15.7925 4.37076 16.6548C4.28161 17.0853 4.21264 17.4443 4.16547 17.7273C4.12244 17.9855 4.08503 18.2501 4.08503 18.4528H6.22712ZM7.25279 13.6075C7.96081 10.5855 8.40993 8.0805 8.58766 6.10769L6.45421 5.91549C6.28921 7.74702 5.86436 10.1431 5.16718 13.1189L7.25279 13.6075ZM25.6416 15.0083L25.1028 14.0827L25.0967 14.0863L25.6416 15.0083ZM19.1896 15.7538L19.422 14.7083L18.479 14.4987L18.1735 15.4151L19.1896 15.7538ZM19.1896 18.1444L18.3328 18.787L18.3463 18.8051L18.3607 18.8226L19.1896 18.1444ZM23.6366 18.0672L23.3678 17.0305L23.363 17.0317L23.6366 18.0672ZM24.4591 20.3036L23.9514 19.3605L23.9452 19.3639L24.4591 20.3036ZM17.1589 19.8409L17.9307 19.0983L17.9307 19.0983L17.1589 19.8409ZM16.9276 12.7206L15.9868 12.2085L15.9819 12.2178L16.9276 12.7206ZM19.678 9.32754L20.3612 10.1525L20.3687 10.1461L19.678 9.32754ZM25.9757 9.55888L25.1625 10.2559L25.1681 10.2623L25.9757 9.55888ZM23.8422 11.5896L23.1236 12.3838L23.1236 12.3838L23.8422 11.5896ZM21.6059 11.7952L22.3209 12.5927L22.3283 12.586L22.3357 12.5791L21.6059 11.7952ZM20.1921 13.5432L19.2814 12.9794L18.3893 14.4205L20.0738 14.6077L20.1921 13.5432ZM26.2928 12.7463C26.2928 13.0167 26.125 13.4877 25.1028 14.0827L26.1804 15.934C27.4545 15.1923 28.4349 14.1553 28.4349 12.7463H26.2928ZM25.0967 14.0863C24.1145 14.6666 23.1356 14.9398 22.1457 14.9398V17.0819C23.5549 17.0819 24.9066 16.6867 26.1864 15.9304L25.0967 14.0863ZM22.1457 14.9398C20.9885 14.9398 20.0884 14.8564 19.422 14.7083L18.9573 16.7993C19.8331 16.994 20.9038 17.0819 22.1457 17.0819V14.9398ZM18.1735 15.4151C17.9849 15.9811 17.8872 16.558 17.8872 17.1419H20.0293C20.0293 16.8004 20.0859 16.4519 20.2057 16.0925L18.1735 15.4151ZM17.8872 17.1419C17.8872 17.6732 17.9661 18.2981 18.3328 18.787L20.0464 17.5017C20.0835 17.5512 20.0753 17.5664 20.0597 17.4987C20.0445 17.4325 20.0293 17.3183 20.0293 17.1419H17.8872ZM18.3607 18.8226C18.652 19.1786 19.0523 19.3265 19.3401 19.3968C19.6487 19.4723 19.9938 19.4982 20.3463 19.4982V17.3561C20.0819 17.3561 19.9258 17.3348 19.8488 17.316C19.8128 17.3072 19.8171 17.3044 19.8456 17.3193C19.8749 17.3346 19.946 17.3774 20.0186 17.4661L18.3607 18.8226ZM20.3463 19.4982C21.6926 19.4982 22.8867 19.3731 23.9101 19.1028L23.363 17.0317C22.57 17.2412 21.5706 17.3561 20.3463 17.3561V19.4982ZM23.9054 19.104C24.3673 18.9843 24.715 18.8952 24.9503 18.8364C25.0686 18.8068 25.1542 18.786 25.2102 18.7729C25.239 18.7662 25.2537 18.763 25.2583 18.762C25.2613 18.7614 25.2539 18.763 25.2405 18.7653C25.2339 18.7663 25.2193 18.7687 25.1999 18.7711C25.1878 18.7725 25.1393 18.7784 25.0761 18.7784V16.6363C25.0106 16.6363 24.9591 16.6425 24.9432 16.6444C24.9199 16.6472 24.9006 16.6503 24.8883 16.6523C24.8637 16.6564 24.8416 16.6608 24.8263 16.664C24.7946 16.6705 24.7589 16.6786 24.7234 16.6868C24.6509 16.7038 24.5523 16.7279 24.4307 16.7583C24.1862 16.8194 23.8313 16.9103 23.3678 17.0305L23.9054 19.104ZM25.0761 18.7784C25.1963 18.7784 25.2699 18.7886 25.3075 18.7969C25.3457 18.8053 25.3252 18.8068 25.2774 18.7754C25.2225 18.7392 25.1557 18.6747 25.1085 18.5815C25.0634 18.4924 25.0589 18.4221 25.0589 18.4014H27.201C27.201 17.8524 26.966 17.3225 26.4556 16.9864C26.0233 16.7017 25.5109 16.6363 25.0761 16.6363V18.7784ZM25.0589 18.4014C25.0589 18.4203 25.0245 18.7827 23.9514 19.3606L24.9669 21.2466C26.1216 20.6249 27.201 19.7192 27.201 18.4014H25.0589ZM23.9452 19.3639C23.0303 19.8642 21.936 20.1322 20.6291 20.1322V22.2743C22.2355 22.2743 23.6945 21.9425 24.973 21.2433L23.9452 19.3639ZM20.6291 20.1322C19.4356 20.1322 18.5734 19.7662 17.9307 19.0983L16.3872 20.5835C17.4924 21.7321 18.9436 22.2743 20.6291 22.2743V20.1322ZM17.9307 19.0983C17.2441 18.3848 16.919 17.5781 16.919 16.6278H14.7769C14.7769 18.1451 15.3258 19.4805 16.3872 20.5835L17.9307 19.0983ZM16.919 16.6278C16.919 15.5874 17.2175 14.4569 17.8733 13.2234L15.9819 12.2178C15.1982 13.6919 14.7769 15.1662 14.7769 16.6278H16.919ZM17.8683 13.2326C18.5454 11.9886 19.3778 10.9668 20.3611 10.1524L18.9949 8.50263C17.7848 9.50475 16.7835 10.745 15.9869 12.2086L17.8683 13.2326ZM20.3687 10.1461C21.3364 9.32962 22.1886 9.01051 22.9425 9.01051V6.86842C21.5372 6.86842 20.2131 7.47469 18.9873 8.50895L20.3687 10.1461ZM22.9425 9.01051C23.6395 9.01051 24.3761 9.33838 25.1625 10.2559L26.7889 8.86185C25.7246 7.62016 24.4391 6.86842 22.9425 6.86842V9.01051ZM25.1681 10.2623C25.9798 11.1943 26.2928 12.0168 26.2928 12.7463H28.4349C28.4349 11.3509 27.8224 10.0484 26.7834 8.85544L25.1681 10.2623ZM25.4531 12.335C25.4531 11.9059 25.245 11.5721 25.1146 11.392C24.9632 11.183 24.7681 10.9829 24.5608 10.7954L23.1236 12.3838C23.1969 12.4501 23.2543 12.5064 23.2981 12.553C23.3426 12.6003 23.3677 12.6319 23.3796 12.6483C23.3928 12.6666 23.3811 12.6543 23.3638 12.6139C23.3472 12.5751 23.311 12.4769 23.311 12.335H25.4531ZM24.5608 10.7954C24.0983 10.3769 23.5096 10.0301 22.814 10.0301V12.1722C22.7697 12.1722 22.8664 12.151 23.1236 12.3838L24.5608 10.7954ZM22.814 10.0301C22.4011 10.0301 22.035 10.1806 21.7471 10.346C21.4507 10.5162 21.1595 10.7474 20.8761 11.0113L22.3357 12.5791C22.5492 12.3803 22.7078 12.2645 22.8142 12.2034C22.929 12.1374 22.9185 12.1722 22.814 12.1722V10.0301ZM20.8909 10.9978C20.3007 11.5269 19.7671 12.1949 19.2814 12.9794L21.1028 14.1069C21.5083 13.4519 21.9171 12.9547 22.3209 12.5927L20.8909 10.9978ZM20.0738 14.6077C20.2877 14.6314 20.5574 14.6399 20.8604 14.6399V12.4978C20.5808 12.4978 20.405 12.4892 20.3104 12.4787L20.0738 14.6077ZM20.8604 14.6399C22.0677 14.6399 23.0946 14.5379 23.8558 14.2712C24.2411 14.1362 24.6327 13.9338 24.9379 13.6131C25.2667 13.2675 25.4531 12.826 25.4531 12.335H23.311C23.311 12.2553 23.3507 12.1737 23.386 12.1366C23.3976 12.1243 23.3491 12.179 23.1475 12.2496C22.7348 12.3942 22.001 12.4978 20.8604 12.4978V14.6399ZM33.6431 17.8102L34.5342 17.2161L33.753 16.0443L32.8325 17.1101L33.6431 17.8102ZM28.3735 20.5349L27.573 21.2465L27.587 21.2622L27.6015 21.2773L28.3735 20.5349ZM28.2964 18.6842L27.558 17.9084L27.5578 17.9086L28.2964 18.6842ZM31.8694 14.8541L32.7058 15.5232L33.1531 14.9641L32.8074 14.337L31.8694 14.8541ZM29.2475 8.27363L28.2039 8.03279L28.2004 8.04814L28.1973 8.06358L29.2475 8.27363ZM31.9722 8.37645L31.601 9.38108L31.614 9.38589L31.6271 9.39035L31.9722 8.37645ZM33.9515 11.9752L32.974 12.4129L33.7403 14.1242L34.8251 12.5948L33.9515 11.9752ZM36.4963 8.83914L35.7698 8.05207L35.7597 8.06161L36.4963 8.83914ZM39.5295 8.65921L38.7851 9.42927L38.7914 9.43537L38.7978 9.44136L39.5295 8.65921ZM39.401 11.1526L40.0281 12.0209L40.0435 12.0097L40.0586 11.998L39.401 11.1526ZM38.5013 11.9237L37.744 11.1664L37.7325 11.1779L37.7214 11.1897L38.5013 11.9237ZM35.6995 15.2911L34.8696 14.6141L34.4057 15.1827L34.7684 15.8205L35.6995 15.2911ZM36.4449 16.6021L35.5139 17.1315L35.514 17.1317L36.4449 16.6021ZM37.0704 20.2522C37.0704 20.3345 37.0608 20.3663 37.0618 20.3631C37.0638 20.3562 37.0797 20.309 37.1301 20.2526C37.1828 20.1935 37.2435 20.1577 37.2893 20.1406C37.3289 20.1258 37.3372 20.1322 37.2932 20.1322V22.2743C37.7748 22.2743 38.3226 22.1337 38.7287 21.6784C39.114 21.2464 39.2125 20.706 39.2125 20.2522H37.0704ZM37.2932 20.1322C37.2473 20.1322 36.9686 20.0844 36.4158 19.5705C35.895 19.0863 35.2672 18.3156 34.5342 17.2161L32.7519 18.4043C33.5269 19.5669 34.2616 20.4926 34.9574 21.1395C35.6213 21.7566 36.4136 22.2743 37.2932 22.2743V20.1322ZM32.8325 17.1101C31.8692 18.2256 31.0925 19.0286 30.495 19.544C30.1956 19.8024 29.9659 19.9668 29.8006 20.0613C29.6148 20.1674 29.6021 20.1322 29.7102 20.1322V22.2743C30.1525 22.2743 30.5542 22.0977 30.8633 21.9211C31.1928 21.7329 31.5383 21.4731 31.8943 21.1659C32.6078 20.5504 33.4634 19.6569 34.4537 18.5102L32.8325 17.1101ZM29.7102 20.1322C29.6269 20.1322 29.4425 20.1014 29.1456 19.7926L27.6015 21.2773C28.1614 21.8596 28.8681 22.2743 29.7102 22.2743V20.1322ZM29.174 19.8234C29.0083 19.6369 28.9141 19.4955 28.8647 19.3988C28.8153 19.302 28.8277 19.2835 28.8277 19.3268H26.6856C26.6856 20.074 27.1135 20.7295 27.573 21.2465L29.174 19.8234ZM28.8277 19.3268C28.8277 19.4137 28.8164 19.4865 28.8045 19.5404C28.7925 19.5947 28.7778 19.6388 28.7656 19.6707C28.7422 19.7321 28.7184 19.7734 28.7088 19.7895C28.6903 19.8203 28.6842 19.8234 28.7117 19.7908C28.7638 19.7287 28.8655 19.6212 29.0351 19.4597L27.5578 17.9086C27.3675 18.0898 27.1993 18.2608 27.0715 18.413C27.0089 18.4874 26.9354 18.5816 26.8719 18.6874C26.8338 18.7509 26.6856 18.9953 26.6856 19.3268H28.8277ZM29.0348 19.46C30.4831 18.0815 31.709 16.7692 32.7058 15.5232L31.0331 14.185C30.1106 15.3382 28.9544 16.5792 27.558 17.9084L29.0348 19.46ZM32.8074 14.337C32.0858 13.028 31.4772 11.7849 30.9795 10.6072L29.0064 11.4409C29.5369 12.6966 30.1793 14.0069 30.9315 15.3712L32.8074 14.337ZM30.9795 10.6072C30.7262 10.0077 30.5435 9.54322 30.4259 9.20611C30.3669 9.03703 30.3289 8.91314 30.3069 8.82802C30.2797 8.72264 30.2928 8.73648 30.2928 8.81344H28.1508C28.1508 9.01892 28.1961 9.22125 28.2331 9.36436C28.2753 9.52773 28.3338 9.71229 28.4033 9.91165C28.5428 10.3114 28.7456 10.8237 29.0064 11.4409L30.9795 10.6072ZM30.2928 8.81344C30.2928 8.68557 30.2949 8.59048 30.2981 8.52379C30.2996 8.49066 30.3013 8.46977 30.3023 8.45873C30.3037 8.44485 30.3032 8.45653 30.2978 8.48368L28.1973 8.06358C28.1556 8.27176 28.1508 8.56097 28.1508 8.81344H30.2928ZM30.2911 8.51446C30.2709 8.60229 30.2025 8.76584 30.0288 8.89128C29.8691 9.00663 29.729 9.01051 29.7102 9.01051V6.86842C29.4343 6.86842 29.0886 6.928 28.7747 7.15473C28.4467 7.39156 28.2756 7.72219 28.2039 8.03279L30.2911 8.51446ZM29.7102 9.01051C30.2839 9.01051 30.912 9.12646 31.601 9.38108L32.3435 7.37182C31.4559 7.04379 30.5759 6.86842 29.7102 6.86842V9.01051ZM31.6271 9.39035C31.7946 9.44738 31.9253 9.50176 32.0245 9.55106C32.1252 9.60104 32.1802 9.63918 32.205 9.65916C32.23 9.6794 32.2068 9.6671 32.1756 9.61273C32.1402 9.55083 32.1093 9.45965 32.1093 9.35324H34.2514C34.2514 8.73204 33.9051 8.27913 33.5517 7.99341C33.2097 7.71684 32.7728 7.51757 32.3174 7.36255L31.6271 9.39035ZM32.1093 9.35324C32.1093 10.1946 32.4513 11.2454 32.974 12.4129L34.9291 11.5375C34.4236 10.4086 34.2514 9.71144 34.2514 9.35324H32.1093ZM34.8251 12.5948C35.862 11.1329 36.661 10.1585 37.2329 9.61667L35.7597 8.06161C35.0293 8.75364 34.1317 9.86987 33.0779 11.3555L34.8251 12.5948ZM37.2228 9.62615C37.5222 9.34977 37.7583 9.17534 37.9338 9.07503C38.1182 8.96964 38.1682 8.9848 38.1157 8.9848V6.84272C37.652 6.84272 37.2264 7.0121 36.871 7.21518C36.5068 7.42333 36.1388 7.71159 35.7699 8.05213L37.2228 9.62615ZM38.1157 8.9848C38.1305 8.9848 38.3451 9.00392 38.7851 9.42927L40.2739 7.88914C39.6857 7.32056 38.9578 6.84272 38.1157 6.84272V8.9848ZM38.7978 9.44136C39.0171 9.64646 39.1419 9.80501 39.2072 9.91454C39.2716 10.0225 39.2553 10.0437 39.2553 9.99587H41.3974C41.3974 9.13567 40.8421 8.42042 40.2612 7.87705L38.7978 9.44136ZM39.2553 9.99587C39.2553 9.93947 39.2678 9.8884 39.2841 9.85029C39.2991 9.81518 39.3112 9.8056 39.2989 9.81918L40.8911 11.2522C41.181 10.9301 41.3974 10.5049 41.3974 9.99587H39.2553ZM39.2989 9.81918C39.2061 9.92234 39.0312 10.0833 38.7434 10.3072L40.0586 11.998C40.3877 11.742 40.6755 11.4917 40.8911 11.2522L39.2989 9.81918ZM38.7739 10.2843C38.4155 10.5432 38.0724 10.838 37.744 11.1664L39.2587 12.6811C39.5129 12.4269 39.7696 12.2076 40.0281 12.0209L38.7739 10.2843ZM37.7214 11.1897C37.4138 11.5165 36.4476 12.6797 34.8696 14.6141L36.5294 15.9681C38.1388 13.9953 39.0404 12.9137 39.2813 12.6578L37.7214 11.1897ZM34.7684 15.8205L35.5139 17.1315L37.376 16.0726L36.6305 14.7617L34.7684 15.8205ZM35.514 17.1317C36.0736 18.1152 36.4813 18.8873 36.7461 19.4569C36.8788 19.7424 36.9677 19.9604 37.0214 20.1184C37.0836 20.3011 37.0704 20.3235 37.0704 20.2522H39.2125C39.2125 19.9581 39.1286 19.6613 39.0494 19.4285C38.9618 19.1709 38.8386 18.8769 38.6886 18.554C38.3878 17.907 37.9473 17.0768 37.3758 16.0724L35.514 17.1317ZM47.2316 10.9984L47.0215 9.94812L46.3882 10.0748L46.2047 10.6941L47.2316 10.9984ZM46.6147 12.9519L45.5964 12.6195L45.5924 12.6325L46.6147 12.9519ZM45.6379 16.0623L44.6163 15.7404L44.6124 15.7532L45.6379 16.0623ZM47.5143 17.6046L47.8725 18.6139L47.8797 18.6114L47.8869 18.6087L47.5143 17.6046ZM48.3112 19.9951L48.9263 20.872L48.935 20.8658L48.3112 19.9951ZM42.7589 20.3036L43.4257 19.4654L43.4257 19.4654L42.7589 20.3036ZM42.399 15.2397L41.3788 14.9136L41.3768 14.9202L42.399 15.2397ZM43.4787 11.8209L44.5029 12.1339L45.0763 10.2575L43.1879 10.7901L43.4787 11.8209ZM40.8824 11.4096L40.0403 12.0714L40.0461 12.0788L40.0521 12.0862L40.8824 11.4096ZM44.3012 8.65921L44.4517 9.71962L45.1994 9.6135L45.3506 8.87359L44.3012 8.65921ZM47.2573 4.57211L46.5882 5.40845L46.5997 5.4176L46.6113 5.42643L47.2573 4.57211ZM47.977 8.35074L46.9347 8.10438L46.6096 9.47969L48.0216 9.42086L47.977 8.35074ZM50.6504 10.1501L51.0032 11.1615L51.0164 11.1567L50.6504 10.1501ZM46.2047 10.6941C46.0718 11.1426 45.8697 11.7829 45.5965 12.6195L47.6328 13.2844C47.908 12.4416 48.1172 11.7795 48.2585 11.3026L46.2047 10.6941ZM45.5924 12.6325C45.3357 13.4539 45.0103 14.4899 44.6163 15.7404L46.6594 16.3841C47.0537 15.1327 47.3796 14.0951 47.637 13.2714L45.5924 12.6325ZM44.6124 15.7532C44.4233 16.3805 44.2798 16.8675 44.1829 17.211C44.1348 17.3817 44.0961 17.5243 44.0686 17.634C44.0551 17.688 44.0421 17.7431 44.0317 17.794C44.0281 17.8116 44.0013 17.9329 44.0013 18.0672H46.1434C46.1434 18.1801 46.1225 18.2618 46.1307 18.2216C46.1321 18.2147 46.1367 18.1937 46.1468 18.1535C46.1665 18.0746 46.1985 17.9559 46.2446 17.7925C46.3362 17.4676 46.4754 16.995 46.6634 16.3713L44.6124 15.7532ZM44.0013 18.0672C44.0013 18.5051 44.2434 18.81 44.4554 18.9691C44.6373 19.1055 44.8211 19.1598 44.9154 19.1834C45.1151 19.2333 45.3246 19.2411 45.4836 19.2411V17.099C45.3685 17.099 45.3724 17.0897 45.435 17.1053C45.4608 17.1117 45.5931 17.1447 45.7407 17.2554C45.9185 17.3887 46.1434 17.6636 46.1434 18.0672H44.0013ZM45.4836 19.2411C45.791 19.2411 46.1597 19.1544 46.4971 19.0605C46.8693 18.9568 47.33 18.8064 47.8725 18.6139L47.1562 16.5952C46.6362 16.7797 46.2273 16.912 45.9225 16.9969C45.583 17.0914 45.4676 17.099 45.4836 17.099V19.2411ZM47.8869 18.6087C48.4115 18.4141 48.8143 18.2734 49.1022 18.1827C49.2468 18.1371 49.3505 18.108 49.4195 18.0913C49.5139 18.0684 49.4813 18.0844 49.3908 18.0844V15.9423C49.2061 15.9423 49.0246 15.9829 48.9155 16.0093C48.781 16.0419 48.6266 16.0866 48.4585 16.1396C48.1208 16.246 47.6797 16.4009 47.1418 16.6004L47.8869 18.6087ZM49.3908 18.0844C49.3774 18.0844 49.3148 18.082 49.2297 18.0434C49.1386 18.0022 49.0622 17.9374 49.0099 17.8663C48.9622 17.8015 48.9519 17.7553 48.953 17.7599C48.9544 17.7654 48.9624 17.8038 48.9624 17.8873H51.1045C51.1045 17.4963 51.0382 17.0088 50.7357 16.5974C50.3879 16.1244 49.8744 15.9423 49.3908 15.9423V18.0844ZM48.9624 17.8873C48.9624 17.827 48.9866 17.9127 48.7735 18.1736C48.5697 18.423 48.2223 18.7413 47.6874 19.1245L48.935 20.8658C49.5483 20.4264 50.062 19.9821 50.4323 19.5289C50.7932 19.0872 51.1045 18.5303 51.1045 17.8873H48.9624ZM47.6961 19.1183C46.7054 19.8133 45.7171 20.1322 44.7125 20.1322V22.2743C46.2098 22.2743 47.6207 21.7878 48.9263 20.8719L47.6961 19.1183ZM44.7125 20.1322C44.5309 20.1322 44.1186 20.0166 43.4257 19.4654L42.0922 21.1418C42.9072 21.7901 43.7973 22.2743 44.7125 22.2743V20.1322ZM43.4257 19.4654C42.7823 18.9536 42.6989 18.6205 42.6989 18.4785H40.5569C40.5569 19.5704 41.2275 20.454 42.0922 21.1418L43.4257 19.4654ZM42.6989 18.4785C42.6989 18.3555 42.7365 18.0611 42.8626 17.5313C42.9832 17.0248 43.1681 16.3695 43.4213 15.5592L41.3768 14.9202C41.1159 15.755 40.9152 16.4621 40.7788 17.0352C40.6478 17.585 40.5569 18.0874 40.5569 18.4785H42.6989ZM43.4193 15.5657C43.9503 13.9041 44.3122 12.7583 44.5029 12.1339L42.4544 11.5079C42.2681 12.1174 41.9103 13.2508 41.3788 14.9136L43.4193 15.5657ZM43.1879 10.7901C42.5382 10.9734 42.1504 11.0326 41.9621 11.0326V13.1747C42.4592 13.1747 43.0825 13.0455 43.7694 12.8517L43.1879 10.7901ZM41.9621 11.0326C42.0416 11.0326 42.0643 11.0614 42.0143 11.0292C41.9587 10.9935 41.8559 10.9087 41.7128 10.7331L40.0521 12.0862C40.4881 12.6213 41.123 13.1747 41.9621 13.1747V11.0326ZM41.7246 10.7479C41.4345 10.3787 41.388 10.1524 41.388 10.0473H39.2459C39.2459 10.799 39.5763 11.4809 40.0403 12.0714L41.7246 10.7479ZM41.388 10.0473C41.388 10.3904 41.1721 10.5367 41.2147 10.5043C41.2502 10.4772 41.3775 10.4007 41.6654 10.3004C42.2291 10.104 43.1424 9.90546 44.4517 9.71962L44.1507 7.59879C42.8038 7.78996 41.725 8.01127 40.9606 8.27756C40.5845 8.40861 40.2137 8.57414 39.9172 8.79987C39.6278 9.02017 39.2459 9.42997 39.2459 10.0473H41.388ZM45.3506 8.87359C45.6854 7.23476 45.8607 5.77999 45.8607 4.5207H43.7186C43.7186 5.59198 43.5682 6.89623 43.2518 8.44482L45.3506 8.87359ZM45.8607 4.5207C45.8607 4.42874 45.8829 4.5565 45.7397 4.69967C45.5966 4.84285 45.4688 4.82059 45.5608 4.82059V2.6785C45.1386 2.6785 44.6253 2.78476 44.2251 3.18499C43.8248 3.58522 43.7186 4.09855 43.7186 4.5207H45.8607ZM45.5608 4.82059C45.6189 4.82059 45.9362 4.88686 46.5882 5.40845L47.9264 3.73576C47.2074 3.1606 46.3938 2.6785 45.5608 2.6785V4.82059ZM46.6113 5.42643C47.2209 5.88736 47.2402 6.1412 47.2402 6.16582H49.3822C49.3822 5.12796 48.6988 4.31933 47.9033 3.71778L46.6113 5.42643ZM47.2402 6.16582C47.2402 6.55349 47.152 7.18498 46.9347 8.10438L49.0194 8.59711C49.2476 7.63147 49.3822 6.80635 49.3822 6.16582H47.2402ZM48.0216 9.42086C48.8402 9.38675 49.4479 9.37038 49.8535 9.37038V7.22829C49.4023 7.22829 48.759 7.24619 47.9324 7.28063L48.0216 9.42086ZM49.8535 9.37038C50.137 9.37038 50.3625 9.38662 50.5369 9.41298C50.7147 9.43984 50.8131 9.47335 50.8572 9.49331C50.8996 9.51255 50.8543 9.50195 50.798 9.42542C50.7328 9.3368 50.7103 9.23796 50.7103 9.17331H52.8524C52.8524 8.81733 52.7496 8.46358 52.5238 8.15647C52.3068 7.86145 52.0205 7.66876 51.7416 7.54234C51.21 7.30133 50.5436 7.22829 49.8535 7.22829V9.37038ZM50.7103 9.17331C50.7103 9.08064 50.7355 8.99867 50.7669 8.94001C50.7951 8.88759 50.8175 8.87335 50.7986 8.88967C50.7795 8.90617 50.7328 8.94118 50.6434 8.98859C50.5552 9.03539 50.4374 9.0879 50.2843 9.14354L51.0164 11.1567C51.4502 10.9989 51.87 10.7947 52.1987 10.5108C52.5424 10.214 52.8524 9.76551 52.8524 9.17331H50.7103ZM50.2976 9.13882C49.6335 9.37047 48.5536 9.6417 47.0215 9.94812L47.4416 12.0486C48.9941 11.7381 50.1934 11.4438 51.0031 11.1614L50.2976 9.13882Z" fill="white" mask="url(#path-1-outside-1_740_3589)"/> +</g> +<defs> +<filter id="filter0_d_740_3589" x="0.494633" y="0.425297" width="53.4287" height="22.9199" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB"> +<feFlood flood-opacity="0" result="BackgroundImageFix"/> +<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/> +<feOffset/> +<feGaussianBlur stdDeviation="1.07104"/> +<feComposite in2="hardAlpha" operator="out"/> +<feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.15 0"/> +<feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow_740_3589"/> +<feBlend mode="normal" in="SourceGraphic" in2="effect1_dropShadow_740_3589" result="shape"/> +</filter> +</defs> +</svg> +`; + +export const mindMapStyle1Light = svg`<svg width="58" height="44" viewBox="0 0 58 44" fill="none" xmlns="http://www.w3.org/2000/svg"> +<path fill-rule="evenodd" clip-rule="evenodd" d="M40.3337 22.7009L21.0225 22.7009L21.0225 21.2996L40.3337 21.2996L40.3337 22.7009Z" fill="#E660A4"/> +<path fill-rule="evenodd" clip-rule="evenodd" d="M37.8158 8.32903C34.2586 8.32903 31.3749 11.2326 31.3749 14.8143C31.3749 19.1699 27.8681 22.7008 23.5423 22.7008L21.0234 22.7008L21.0234 21.2995L23.5423 21.2995C27.0995 21.2995 29.9832 18.396 29.9832 14.8143C29.9832 10.4587 33.49 6.92773 37.8158 6.92773L40.3346 6.92773L40.3346 8.32903L37.8158 8.32903Z" fill="#6E52DF"/> +<path fill-rule="evenodd" clip-rule="evenodd" d="M23.5413 22.7009L21.0225 22.7009L21.0225 21.2996L23.5413 21.2996C27.8671 21.2996 31.3739 24.8305 31.3739 29.1861C31.3739 32.7678 34.2576 35.6713 37.8148 35.6713L40.3337 35.6713L40.3337 37.0726L37.8148 37.0726C33.489 37.0726 29.9822 33.5417 29.9822 29.1861C29.9822 25.6044 27.0985 22.7009 23.5413 22.7009Z" fill="#FF8C38"/> +<g filter="url(#filter0_d_8326_40611)"> +<path fill-rule="evenodd" clip-rule="evenodd" d="M42.0141 30.7434L52.9291 30.7434C54.6251 30.7434 56 32.1278 56 33.8355L56 38.9079C56 40.6156 54.6251 42 52.9291 42L42.0141 42C40.318 42 38.9431 40.6156 38.9431 38.9079L38.9431 33.8355C38.9431 32.1278 40.318 30.7434 42.0141 30.7434ZM42.0141 32.1447C41.0866 32.1447 40.3348 32.9017 40.3348 33.8355L40.3348 38.9079C40.3348 39.8417 41.0866 40.5987 42.0141 40.5987L52.9291 40.5987C53.8565 40.5987 54.6083 39.8417 54.6083 38.9079L54.6083 33.8355C54.6083 32.9017 53.8565 32.1447 52.9291 32.1447L42.0141 32.1447Z" fill="#FF8C38"/> +</g> +<g filter="url(#filter1_d_8326_40611)"> +<path fill-rule="evenodd" clip-rule="evenodd" d="M42.0131 16.4124L52.9281 16.4124C54.6241 16.4124 55.9991 17.7967 55.9991 19.5044L55.9991 24.5768C55.9991 26.2846 54.6241 27.6689 52.9281 27.6689L42.0131 27.6689C40.317 27.6689 38.9421 26.2846 38.9421 24.5768L38.9421 19.5044C38.9421 17.7967 40.317 16.4124 42.0131 16.4124ZM42.0131 17.8136C41.0857 17.8136 40.3339 18.5706 40.3339 19.5044L40.3339 24.5768C40.3339 25.5106 41.0857 26.2676 42.0131 26.2676L52.9281 26.2676C53.8555 26.2676 54.6073 25.5106 54.6073 24.5768L54.6073 19.5044C54.6073 18.5706 53.8555 17.8136 52.9281 17.8136L42.0131 17.8136Z" fill="#E660A4"/> +</g> +<g filter="url(#filter2_d_8326_40611)"> +<path fill-rule="evenodd" clip-rule="evenodd" d="M42.0131 2L52.9281 2C54.6241 2 55.9991 3.38438 55.9991 5.09209L55.9991 10.1645C55.9991 11.8722 54.6241 13.2566 52.9281 13.2566L42.0131 13.2566C40.317 13.2566 38.9421 11.8722 38.9421 10.1645L38.9421 5.09209C38.9421 3.38438 40.317 2 42.0131 2ZM42.0131 3.4013C41.0857 3.4013 40.3339 4.15829 40.3339 5.09209L40.3339 10.1645C40.3339 11.0983 41.0857 11.8553 42.0131 11.8553L52.9281 11.8553C53.8555 11.8553 54.6073 11.0983 54.6073 10.1645L54.6073 5.09209C54.6073 4.15829 53.8555 3.4013 52.9281 3.4013L42.0131 3.4013Z" fill="#6E52DF"/> +</g> +<g filter="url(#filter3_d_8326_40611)"> +<path fill-rule="evenodd" clip-rule="evenodd" d="M5.45717 14.6809L18.1186 14.6809C20.0279 14.6809 21.5758 16.2394 21.5758 18.1619L21.5758 25.8381C21.5758 27.7606 20.0279 29.3191 18.1186 29.3191L5.45717 29.3191C3.54783 29.3191 2 27.7606 2 25.8381L2 18.1619C2 16.2394 3.54783 14.6809 5.45717 14.6809ZM5.45717 16.0822C4.31645 16.0822 3.39171 17.0133 3.39171 18.1619L3.39171 25.8381C3.39171 26.9867 4.31645 27.9178 5.45717 27.9178L18.1186 27.9178C19.2593 27.9178 20.1841 26.9867 20.1841 25.8381L20.1841 18.1619C20.1841 17.0133 19.2593 16.0822 18.1186 16.0822L5.45717 16.0822Z" fill="#84CFFF"/> +</g> +<path d="M40.3342 33.8356C40.3342 32.9018 41.086 32.1448 42.0135 32.1448L52.9285 32.1448C53.8559 32.1448 54.6077 32.9018 54.6077 33.8356L54.6077 38.908C54.6077 39.8418 53.8559 40.5988 52.9285 40.5988L42.0135 40.5988C41.086 40.5988 40.3342 39.8418 40.3342 38.908L40.3342 33.8356Z" fill="#FBFBFC"/> +<path d="M40.3342 19.5045C40.3342 18.5707 41.086 17.8137 42.0135 17.8137L52.9285 17.8137C53.8559 17.8137 54.6077 18.5707 54.6077 19.5045L54.6077 24.5769C54.6077 25.5107 53.8559 26.2677 52.9285 26.2677L42.0135 26.2677C41.086 26.2677 40.3342 25.5107 40.3342 24.5769L40.3342 19.5045Z" fill="#FBFBFC"/> +<path d="M40.3342 5.09217C40.3342 4.15836 41.086 3.40137 42.0135 3.40137L52.9285 3.40137C53.8559 3.40137 54.6077 4.15836 54.6077 5.09216L54.6077 10.1646C54.6077 11.0984 53.8559 11.8554 52.9285 11.8554L42.0135 11.8554C41.086 11.8554 40.3342 11.0984 40.3342 10.1646L40.3342 5.09217Z" fill="#FBFBFC"/> +<path d="M3.39111 18.1617C3.39111 17.0131 4.31585 16.082 5.45657 16.082L18.118 16.082C19.2587 16.082 20.1835 17.0131 20.1835 18.1617L20.1835 25.8379C20.1835 26.9865 19.2587 27.9176 18.118 27.9176L5.45657 27.9176C4.31585 27.9176 3.39111 26.9865 3.39111 25.8379L3.39111 18.1617Z" fill="#FBFBFC"/> +<defs> +<filter id="filter0_d_8326_40611" x="36.9431" y="28.7434" width="21.0569" height="15.2566" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB"> +<feFlood flood-opacity="0" result="BackgroundImageFix"/> +<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/> +<feOffset/> +<feGaussianBlur stdDeviation="1"/> +<feComposite in2="hardAlpha" operator="out"/> +<feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.15 0"/> +<feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow_8326_40611"/> +<feBlend mode="normal" in="SourceGraphic" in2="effect1_dropShadow_8326_40611" result="shape"/> +</filter> +<filter id="filter1_d_8326_40611" x="36.9421" y="14.4124" width="21.0569" height="15.2566" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB"> +<feFlood flood-opacity="0" result="BackgroundImageFix"/> +<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/> +<feOffset/> +<feGaussianBlur stdDeviation="1"/> +<feComposite in2="hardAlpha" operator="out"/> +<feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.15 0"/> +<feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow_8326_40611"/> +<feBlend mode="normal" in="SourceGraphic" in2="effect1_dropShadow_8326_40611" result="shape"/> +</filter> +<filter id="filter2_d_8326_40611" x="36.9421" y="0" width="21.0569" height="15.2566" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB"> +<feFlood flood-opacity="0" result="BackgroundImageFix"/> +<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/> +<feOffset/> +<feGaussianBlur stdDeviation="1"/> +<feComposite in2="hardAlpha" operator="out"/> +<feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.15 0"/> +<feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow_8326_40611"/> +<feBlend mode="normal" in="SourceGraphic" in2="effect1_dropShadow_8326_40611" result="shape"/> +</filter> +<filter id="filter3_d_8326_40611" x="0" y="12.6809" width="23.5758" height="18.6382" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB"> +<feFlood flood-opacity="0" result="BackgroundImageFix"/> +<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/> +<feOffset/> +<feGaussianBlur stdDeviation="1"/> +<feComposite in2="hardAlpha" operator="out"/> +<feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.15 0"/> +<feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow_8326_40611"/> +<feBlend mode="normal" in="SourceGraphic" in2="effect1_dropShadow_8326_40611" result="shape"/> +</filter> +</defs> +</svg> +`; +export const mindMapStyle1Dark = svg`<svg width="58" height="44" viewBox="0 0 58 44" fill="none" xmlns="http://www.w3.org/2000/svg"> +<path fill-rule="evenodd" clip-rule="evenodd" d="M40.3337 22.7009L21.0225 22.7009L21.0225 21.2996L40.3337 21.2996L40.3337 22.7009Z" fill="#E660A4"/> +<path fill-rule="evenodd" clip-rule="evenodd" d="M37.8158 8.32903C34.2586 8.32903 31.3749 11.2326 31.3749 14.8143C31.3749 19.1699 27.8681 22.7008 23.5423 22.7008L21.0234 22.7008L21.0234 21.2995L23.5423 21.2995C27.0995 21.2995 29.9832 18.396 29.9832 14.8143C29.9832 10.4587 33.49 6.92773 37.8158 6.92773L40.3346 6.92773L40.3346 8.32903L37.8158 8.32903Z" fill="#6E52DF"/> +<path fill-rule="evenodd" clip-rule="evenodd" d="M23.5413 22.7009L21.0225 22.7009L21.0225 21.2996L23.5413 21.2996C27.8671 21.2996 31.3739 24.8305 31.3739 29.1861C31.3739 32.7678 34.2576 35.6713 37.8148 35.6713L40.3337 35.6713L40.3337 37.0726L37.8148 37.0726C33.489 37.0726 29.9822 33.5417 29.9822 29.1861C29.9822 25.6044 27.0985 22.7009 23.5413 22.7009Z" fill="#FF8C38"/> +<g filter="url(#filter0_d_8326_49080)"> +<path fill-rule="evenodd" clip-rule="evenodd" d="M42.0141 30.7434L52.9291 30.7434C54.6251 30.7434 56 32.1278 56 33.8355L56 38.9079C56 40.6156 54.6251 42 52.9291 42L42.0141 42C40.318 42 38.9431 40.6156 38.9431 38.9079L38.9431 33.8355C38.9431 32.1278 40.318 30.7434 42.0141 30.7434ZM42.0141 32.1447C41.0866 32.1447 40.3348 32.9017 40.3348 33.8355L40.3348 38.9079C40.3348 39.8417 41.0866 40.5987 42.0141 40.5987L52.9291 40.5987C53.8565 40.5987 54.6083 39.8417 54.6083 38.9079L54.6083 33.8355C54.6083 32.9017 53.8565 32.1447 52.9291 32.1447L42.0141 32.1447Z" fill="#FF8C38"/> +</g> +<g filter="url(#filter1_d_8326_49080)"> +<path fill-rule="evenodd" clip-rule="evenodd" d="M42.0131 16.4124L52.9281 16.4124C54.6241 16.4124 55.9991 17.7967 55.9991 19.5044L55.9991 24.5768C55.9991 26.2846 54.6241 27.6689 52.9281 27.6689L42.0131 27.6689C40.317 27.6689 38.9421 26.2846 38.9421 24.5768L38.9421 19.5044C38.9421 17.7967 40.317 16.4124 42.0131 16.4124ZM42.0131 17.8136C41.0857 17.8136 40.3339 18.5706 40.3339 19.5044L40.3339 24.5768C40.3339 25.5106 41.0857 26.2676 42.0131 26.2676L52.9281 26.2676C53.8555 26.2676 54.6073 25.5106 54.6073 24.5768L54.6073 19.5044C54.6073 18.5706 53.8555 17.8136 52.9281 17.8136L42.0131 17.8136Z" fill="#E660A4"/> +</g> +<g filter="url(#filter2_d_8326_49080)"> +<path fill-rule="evenodd" clip-rule="evenodd" d="M42.0131 2L52.9281 2C54.6241 2 55.9991 3.38438 55.9991 5.09209L55.9991 10.1645C55.9991 11.8722 54.6241 13.2566 52.9281 13.2566L42.0131 13.2566C40.317 13.2566 38.9421 11.8722 38.9421 10.1645L38.9421 5.09209C38.9421 3.38438 40.317 2 42.0131 2ZM42.0131 3.4013C41.0857 3.4013 40.3339 4.15829 40.3339 5.09209L40.3339 10.1645C40.3339 11.0983 41.0857 11.8553 42.0131 11.8553L52.9281 11.8553C53.8555 11.8553 54.6073 11.0983 54.6073 10.1645L54.6073 5.09209C54.6073 4.15829 53.8555 3.4013 52.9281 3.4013L42.0131 3.4013Z" fill="#6E52DF"/> +</g> +<g filter="url(#filter3_d_8326_49080)"> +<path fill-rule="evenodd" clip-rule="evenodd" d="M5.45717 14.6809L18.1186 14.6809C20.0279 14.6809 21.5758 16.2394 21.5758 18.1619L21.5758 25.8381C21.5758 27.7606 20.0279 29.3191 18.1186 29.3191L5.45717 29.3191C3.54783 29.3191 2 27.7606 2 25.8381L2 18.1619C2 16.2394 3.54783 14.6809 5.45717 14.6809ZM5.45717 16.0822C4.31645 16.0822 3.39171 17.0133 3.39171 18.1619L3.39171 25.8381C3.39171 26.9867 4.31645 27.9178 5.45717 27.9178L18.1186 27.9178C19.2593 27.9178 20.1841 26.9867 20.1841 25.8381L20.1841 18.1619C20.1841 17.0133 19.2593 16.0822 18.1186 16.0822L5.45717 16.0822Z" fill="#84CFFF"/> +</g> +<path d="M40.3342 33.8356C40.3342 32.9018 41.086 32.1448 42.0135 32.1448L52.9285 32.1448C53.8559 32.1448 54.6077 32.9018 54.6077 33.8356L54.6077 38.908C54.6077 39.8418 53.8559 40.5988 52.9285 40.5988L42.0135 40.5988C41.086 40.5988 40.3342 39.8418 40.3342 38.908L40.3342 33.8356Z" fill="#1E1E1E"/> +<path d="M40.3342 19.5045C40.3342 18.5707 41.086 17.8137 42.0135 17.8137L52.9285 17.8137C53.8559 17.8137 54.6077 18.5707 54.6077 19.5045L54.6077 24.5769C54.6077 25.5107 53.8559 26.2677 52.9285 26.2677L42.0135 26.2677C41.086 26.2677 40.3342 25.5107 40.3342 24.5769L40.3342 19.5045Z" fill="#1E1E1E"/> +<path d="M40.3342 5.09217C40.3342 4.15836 41.086 3.40137 42.0135 3.40137L52.9285 3.40137C53.8559 3.40137 54.6077 4.15836 54.6077 5.09216L54.6077 10.1646C54.6077 11.0984 53.8559 11.8554 52.9285 11.8554L42.0135 11.8554C41.086 11.8554 40.3342 11.0984 40.3342 10.1646L40.3342 5.09217Z" fill="#1E1E1E"/> +<path d="M3.39111 18.1617C3.39111 17.0131 4.31585 16.082 5.45657 16.082L18.118 16.082C19.2587 16.082 20.1835 17.0131 20.1835 18.1617L20.1835 25.8379C20.1835 26.9865 19.2587 27.9176 18.118 27.9176L5.45657 27.9176C4.31585 27.9176 3.39111 26.9865 3.39111 25.8379L3.39111 18.1617Z" fill="#1E1E1E"/> +<defs> +<filter id="filter0_d_8326_49080" x="36.9431" y="28.7434" width="21.0569" height="15.2566" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB"> +<feFlood flood-opacity="0" result="BackgroundImageFix"/> +<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/> +<feOffset/> +<feGaussianBlur stdDeviation="1"/> +<feComposite in2="hardAlpha" operator="out"/> +<feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.15 0"/> +<feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow_8326_49080"/> +<feBlend mode="normal" in="SourceGraphic" in2="effect1_dropShadow_8326_49080" result="shape"/> +</filter> +<filter id="filter1_d_8326_49080" x="36.9421" y="14.4124" width="21.0569" height="15.2566" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB"> +<feFlood flood-opacity="0" result="BackgroundImageFix"/> +<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/> +<feOffset/> +<feGaussianBlur stdDeviation="1"/> +<feComposite in2="hardAlpha" operator="out"/> +<feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.15 0"/> +<feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow_8326_49080"/> +<feBlend mode="normal" in="SourceGraphic" in2="effect1_dropShadow_8326_49080" result="shape"/> +</filter> +<filter id="filter2_d_8326_49080" x="36.9421" y="0" width="21.0569" height="15.2566" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB"> +<feFlood flood-opacity="0" result="BackgroundImageFix"/> +<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/> +<feOffset/> +<feGaussianBlur stdDeviation="1"/> +<feComposite in2="hardAlpha" operator="out"/> +<feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.15 0"/> +<feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow_8326_49080"/> +<feBlend mode="normal" in="SourceGraphic" in2="effect1_dropShadow_8326_49080" result="shape"/> +</filter> +<filter id="filter3_d_8326_49080" x="0" y="12.6809" width="23.5758" height="18.6382" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB"> +<feFlood flood-opacity="0" result="BackgroundImageFix"/> +<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/> +<feOffset/> +<feGaussianBlur stdDeviation="1"/> +<feComposite in2="hardAlpha" operator="out"/> +<feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.15 0"/> +<feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow_8326_49080"/> +<feBlend mode="normal" in="SourceGraphic" in2="effect1_dropShadow_8326_49080" result="shape"/> +</filter> +</defs> +</svg> +`; + +export const mindMapStyle2Light = svg`<svg width="64" height="48" viewBox="0 0 64 48" fill="none" xmlns="http://www.w3.org/2000/svg"> +<path d="M23.9766 24L43.4028 24" stroke="black" stroke-width="1.4" stroke-linejoin="round"/> +<path d="M23.9766 24.0001L33.5897 24.0001C33.6449 24.0001 33.6897 23.9553 33.6897 23.9001L33.6897 9.7416C33.6897 9.68637 33.7344 9.6416 33.7897 9.6416L43.4028 9.6416" stroke="black" stroke-width="1.4" stroke-linejoin="round"/> +<path d="M23.9766 24L33.5897 24C33.6449 24 33.6897 24.0448 33.6897 24.1L33.6897 38.2585C33.6897 38.3137 33.7344 38.3585 33.7897 38.3585L43.4028 38.3585" stroke="black" stroke-width="1.4" stroke-linejoin="round"/> +<g filter="url(#filter0_dd_7525_14565)"> +<rect x="43.4023" y="34.1353" width="14.3585" height="8.44617" rx="0.1" fill="#84CFFF"/> +</g> +<g filter="url(#filter1_dd_7525_14565)"> +<rect x="43.4023" y="19.7769" width="14.3585" height="8.44617" rx="0.1" fill="#84CFFF"/> +</g> +<g filter="url(#filter2_dd_7525_14565)"> +<rect x="43.4023" y="5.41846" width="14.3585" height="8.44617" rx="0.1" fill="#84CFFF"/> +</g> +<g filter="url(#filter3_dd_7525_14565)"> +<rect x="6.23926" y="18.0879" width="16.8923" height="11.8246" rx="0.1" fill="#FFC46B"/> +</g> +<defs> +<filter id="filter0_dd_7525_14565" x="42.4023" y="33.1353" width="17.3584" height="11.4462" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB"> +<feFlood flood-opacity="0" result="BackgroundImageFix"/> +<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/> +<feMorphology radius="1" operator="dilate" in="SourceAlpha" result="effect1_dropShadow_7525_14565"/> +<feOffset dx="1" dy="1"/> +<feComposite in2="hardAlpha" operator="out"/> +<feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 0"/> +<feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow_7525_14565"/> +<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/> +<feMorphology radius="1" operator="dilate" in="SourceAlpha" result="effect2_dropShadow_7525_14565"/> +<feOffset/> +<feComposite in2="hardAlpha" operator="out"/> +<feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 0"/> +<feBlend mode="normal" in2="effect1_dropShadow_7525_14565" result="effect2_dropShadow_7525_14565"/> +<feBlend mode="normal" in="SourceGraphic" in2="effect2_dropShadow_7525_14565" result="shape"/> +</filter> +<filter id="filter1_dd_7525_14565" x="42.4023" y="18.7769" width="17.3584" height="11.4462" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB"> +<feFlood flood-opacity="0" result="BackgroundImageFix"/> +<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/> +<feMorphology radius="1" operator="dilate" in="SourceAlpha" result="effect1_dropShadow_7525_14565"/> +<feOffset dx="1" dy="1"/> +<feComposite in2="hardAlpha" operator="out"/> +<feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 0"/> +<feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow_7525_14565"/> +<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/> +<feMorphology radius="1" operator="dilate" in="SourceAlpha" result="effect2_dropShadow_7525_14565"/> +<feOffset/> +<feComposite in2="hardAlpha" operator="out"/> +<feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 0"/> +<feBlend mode="normal" in2="effect1_dropShadow_7525_14565" result="effect2_dropShadow_7525_14565"/> +<feBlend mode="normal" in="SourceGraphic" in2="effect2_dropShadow_7525_14565" result="shape"/> +</filter> +<filter id="filter2_dd_7525_14565" x="42.4023" y="4.41846" width="17.3584" height="11.4462" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB"> +<feFlood flood-opacity="0" result="BackgroundImageFix"/> +<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/> +<feMorphology radius="1" operator="dilate" in="SourceAlpha" result="effect1_dropShadow_7525_14565"/> +<feOffset dx="1" dy="1"/> +<feComposite in2="hardAlpha" operator="out"/> +<feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 0"/> +<feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow_7525_14565"/> +<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/> +<feMorphology radius="1" operator="dilate" in="SourceAlpha" result="effect2_dropShadow_7525_14565"/> +<feOffset/> +<feComposite in2="hardAlpha" operator="out"/> +<feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 0"/> +<feBlend mode="normal" in2="effect1_dropShadow_7525_14565" result="effect2_dropShadow_7525_14565"/> +<feBlend mode="normal" in="SourceGraphic" in2="effect2_dropShadow_7525_14565" result="shape"/> +</filter> +<filter id="filter3_dd_7525_14565" x="5.23926" y="17.0879" width="19.8926" height="14.8246" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB"> +<feFlood flood-opacity="0" result="BackgroundImageFix"/> +<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/> +<feMorphology radius="1" operator="dilate" in="SourceAlpha" result="effect1_dropShadow_7525_14565"/> +<feOffset dx="1" dy="1"/> +<feComposite in2="hardAlpha" operator="out"/> +<feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 0"/> +<feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow_7525_14565"/> +<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/> +<feMorphology radius="1" operator="dilate" in="SourceAlpha" result="effect2_dropShadow_7525_14565"/> +<feOffset/> +<feComposite in2="hardAlpha" operator="out"/> +<feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 0"/> +<feBlend mode="normal" in2="effect1_dropShadow_7525_14565" result="effect2_dropShadow_7525_14565"/> +<feBlend mode="normal" in="SourceGraphic" in2="effect2_dropShadow_7525_14565" result="shape"/> +</filter> +</defs> +</svg> +`; +export const mindMapStyle2Dark = svg`<svg width="64" height="48" viewBox="0 0 64 48" fill="none" xmlns="http://www.w3.org/2000/svg"> +<path d="M23.9761 24L43.4023 24" stroke="white" stroke-width="1.4" stroke-linejoin="round"/> +<path d="M23.9761 24.0001L33.5892 24.0001C33.6444 24.0001 33.6892 23.9553 33.6892 23.9001L33.6892 9.7416C33.6892 9.68637 33.7339 9.6416 33.7892 9.6416L43.4023 9.6416" stroke="white" stroke-width="1.4" stroke-linejoin="round"/> +<path d="M23.9761 24L33.5892 24C33.6444 24 33.6892 24.0448 33.6892 24.1L33.6892 38.2585C33.6892 38.3137 33.7339 38.3585 33.7892 38.3585L43.4023 38.3585" stroke="white" stroke-width="1.4" stroke-linejoin="round"/> +<g filter="url(#filter0_dd_7525_19591)"> +<rect x="43.4023" y="34.1353" width="14.3585" height="8.44617" rx="0.1" fill="#84CFFF"/> +</g> +<g filter="url(#filter1_dd_7525_19591)"> +<rect x="43.4023" y="19.7769" width="14.3585" height="8.44617" rx="0.1" fill="#84CFFF"/> +</g> +<g filter="url(#filter2_dd_7525_19591)"> +<rect x="43.4023" y="5.41846" width="14.3585" height="8.44617" rx="0.1" fill="#84CFFF"/> +</g> +<g filter="url(#filter3_dd_7525_19591)"> +<rect x="6.23926" y="18.0879" width="16.8923" height="11.8246" rx="0.1" fill="#FFC46B"/> +</g> +<defs> +<filter id="filter0_dd_7525_19591" x="42.4023" y="33.1353" width="17.3584" height="11.4462" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB"> +<feFlood flood-opacity="0" result="BackgroundImageFix"/> +<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/> +<feMorphology radius="1" operator="dilate" in="SourceAlpha" result="effect1_dropShadow_7525_19591"/> +<feOffset dx="1" dy="1"/> +<feComposite in2="hardAlpha" operator="out"/> +<feColorMatrix type="matrix" values="0 0 0 0 1 0 0 0 0 1 0 0 0 0 1 0 0 0 1 0"/> +<feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow_7525_19591"/> +<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/> +<feMorphology radius="1" operator="dilate" in="SourceAlpha" result="effect2_dropShadow_7525_19591"/> +<feOffset/> +<feComposite in2="hardAlpha" operator="out"/> +<feColorMatrix type="matrix" values="0 0 0 0 1 0 0 0 0 1 0 0 0 0 1 0 0 0 1 0"/> +<feBlend mode="normal" in2="effect1_dropShadow_7525_19591" result="effect2_dropShadow_7525_19591"/> +<feBlend mode="normal" in="SourceGraphic" in2="effect2_dropShadow_7525_19591" result="shape"/> +</filter> +<filter id="filter1_dd_7525_19591" x="42.4023" y="18.7769" width="17.3584" height="11.4462" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB"> +<feFlood flood-opacity="0" result="BackgroundImageFix"/> +<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/> +<feMorphology radius="1" operator="dilate" in="SourceAlpha" result="effect1_dropShadow_7525_19591"/> +<feOffset dx="1" dy="1"/> +<feComposite in2="hardAlpha" operator="out"/> +<feColorMatrix type="matrix" values="0 0 0 0 1 0 0 0 0 1 0 0 0 0 1 0 0 0 1 0"/> +<feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow_7525_19591"/> +<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/> +<feMorphology radius="1" operator="dilate" in="SourceAlpha" result="effect2_dropShadow_7525_19591"/> +<feOffset/> +<feComposite in2="hardAlpha" operator="out"/> +<feColorMatrix type="matrix" values="0 0 0 0 1 0 0 0 0 1 0 0 0 0 1 0 0 0 1 0"/> +<feBlend mode="normal" in2="effect1_dropShadow_7525_19591" result="effect2_dropShadow_7525_19591"/> +<feBlend mode="normal" in="SourceGraphic" in2="effect2_dropShadow_7525_19591" result="shape"/> +</filter> +<filter id="filter2_dd_7525_19591" x="42.4023" y="4.41846" width="17.3584" height="11.4462" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB"> +<feFlood flood-opacity="0" result="BackgroundImageFix"/> +<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/> +<feMorphology radius="1" operator="dilate" in="SourceAlpha" result="effect1_dropShadow_7525_19591"/> +<feOffset dx="1" dy="1"/> +<feComposite in2="hardAlpha" operator="out"/> +<feColorMatrix type="matrix" values="0 0 0 0 1 0 0 0 0 1 0 0 0 0 1 0 0 0 1 0"/> +<feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow_7525_19591"/> +<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/> +<feMorphology radius="1" operator="dilate" in="SourceAlpha" result="effect2_dropShadow_7525_19591"/> +<feOffset/> +<feComposite in2="hardAlpha" operator="out"/> +<feColorMatrix type="matrix" values="0 0 0 0 1 0 0 0 0 1 0 0 0 0 1 0 0 0 1 0"/> +<feBlend mode="normal" in2="effect1_dropShadow_7525_19591" result="effect2_dropShadow_7525_19591"/> +<feBlend mode="normal" in="SourceGraphic" in2="effect2_dropShadow_7525_19591" result="shape"/> +</filter> +<filter id="filter3_dd_7525_19591" x="5.23926" y="17.0879" width="19.8926" height="14.8246" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB"> +<feFlood flood-opacity="0" result="BackgroundImageFix"/> +<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/> +<feMorphology radius="1" operator="dilate" in="SourceAlpha" result="effect1_dropShadow_7525_19591"/> +<feOffset dx="1" dy="1"/> +<feComposite in2="hardAlpha" operator="out"/> +<feColorMatrix type="matrix" values="0 0 0 0 1 0 0 0 0 1 0 0 0 0 1 0 0 0 1 0"/> +<feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow_7525_19591"/> +<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/> +<feMorphology radius="1" operator="dilate" in="SourceAlpha" result="effect2_dropShadow_7525_19591"/> +<feOffset/> +<feComposite in2="hardAlpha" operator="out"/> +<feColorMatrix type="matrix" values="0 0 0 0 1 0 0 0 0 1 0 0 0 0 1 0 0 0 1 0"/> +<feBlend mode="normal" in2="effect1_dropShadow_7525_19591" result="effect2_dropShadow_7525_19591"/> +<feBlend mode="normal" in="SourceGraphic" in2="effect2_dropShadow_7525_19591" result="shape"/> +</filter> +</defs> +</svg> +`; + +export const mindMapStyle3 = svg`<svg width="64" height="48" viewBox="0 0 64 48" fill="none" xmlns="http://www.w3.org/2000/svg"> +<path d="M23.9766 24L43.4028 24" stroke="#FFD338" stroke-width="1.4"/> +<path d="M23.9766 24.0001L26.5104 24.0001C30.4754 24.0001 33.6897 20.7858 33.6897 16.8208V16.8208C33.6897 12.8559 36.9039 9.6416 40.8689 9.6416L43.4028 9.6416" stroke="#FFD338" stroke-width="1.4"/> +<path d="M23.9766 24L26.5104 24C30.4754 24 33.6897 27.2143 33.6897 31.1792V31.1792C33.6897 35.1442 36.9039 38.3585 40.8689 38.3585L43.4028 38.3585" stroke="#FFD338" stroke-width="1.4"/> +<g filter="url(#filter0_d_7525_14574)"> +<rect x="43.4023" y="34.1353" width="14.3585" height="8.44617" rx="1.68923" fill="white"/> +<rect x="42.7023" y="33.4353" width="15.7585" height="9.84617" rx="2.38923" stroke="#FFD338" stroke-width="1.4"/> +</g> +<g filter="url(#filter1_d_7525_14574)"> +<rect x="43.4023" y="19.7769" width="14.3585" height="8.44617" rx="1.68923" fill="white"/> +<rect x="42.7023" y="19.0769" width="15.7585" height="9.84617" rx="2.38923" stroke="#FFD338" stroke-width="1.4"/> +</g> +<g filter="url(#filter2_d_7525_14574)"> +<rect x="43.4023" y="5.41846" width="14.3585" height="8.44617" rx="1.68923" fill="white"/> +<rect x="42.7023" y="4.71846" width="15.7585" height="9.84617" rx="2.38923" stroke="#FFD338" stroke-width="1.4"/> +</g> +<g filter="url(#filter3_d_7525_14574)"> +<rect x="6.23926" y="18.0879" width="16.8923" height="11.8246" rx="2.07776" fill="#FFD338"/> +<rect x="5.53926" y="17.3879" width="18.2923" height="13.2246" rx="2.77776" stroke="white" stroke-width="1.4"/> +</g> +<defs> +<filter id="filter0_d_7525_14574" x="39.4681" y="30.2014" width="22.2269" height="16.3139" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB"> +<feFlood flood-opacity="0" result="BackgroundImageFix"/> +<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/> +<feOffset/> +<feGaussianBlur stdDeviation="1.26693"/> +<feComposite in2="hardAlpha" operator="out"/> +<feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.12 0"/> +<feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow_7525_14574"/> +<feBlend mode="normal" in="SourceGraphic" in2="effect1_dropShadow_7525_14574" result="shape"/> +</filter> +<filter id="filter1_d_7525_14574" x="39.4681" y="15.843" width="22.2269" height="16.3139" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB"> +<feFlood flood-opacity="0" result="BackgroundImageFix"/> +<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/> +<feOffset/> +<feGaussianBlur stdDeviation="1.26693"/> +<feComposite in2="hardAlpha" operator="out"/> +<feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.12 0"/> +<feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow_7525_14574"/> +<feBlend mode="normal" in="SourceGraphic" in2="effect1_dropShadow_7525_14574" result="shape"/> +</filter> +<filter id="filter2_d_7525_14574" x="39.4681" y="1.48458" width="22.2269" height="16.3139" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB"> +<feFlood flood-opacity="0" result="BackgroundImageFix"/> +<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/> +<feOffset/> +<feGaussianBlur stdDeviation="1.26693"/> +<feComposite in2="hardAlpha" operator="out"/> +<feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.12 0"/> +<feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow_7525_14574"/> +<feBlend mode="normal" in="SourceGraphic" in2="effect1_dropShadow_7525_14574" result="shape"/> +</filter> +<filter id="filter3_d_7525_14574" x="2.30501" y="14.154" width="24.7601" height="19.6923" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB"> +<feFlood flood-opacity="0" result="BackgroundImageFix"/> +<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/> +<feOffset/> +<feGaussianBlur stdDeviation="1.26693"/> +<feComposite in2="hardAlpha" operator="out"/> +<feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.12 0"/> +<feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow_7525_14574"/> +<feBlend mode="normal" in="SourceGraphic" in2="effect1_dropShadow_7525_14574" result="shape"/> +</filter> +</defs> +</svg> +`; + +export const mindMapStyle4 = svg`<svg width="64" height="48" viewBox="0 0 64 48" fill="none" xmlns="http://www.w3.org/2000/svg"> +<path d="M23.9766 24L43.4028 24" stroke="#E660A4" stroke-width="1.4" stroke-linecap="round" /> +<path + d="M23.9766 24.0001L26.5104 24.0001C30.4754 24.0001 33.6897 20.7858 33.6897 16.8208V16.8208C33.6897 12.8559 36.9039 9.6416 40.8689 9.6416L43.4028 9.6416" + stroke="#6E52DF" stroke-width="1.4" stroke-linecap="round" /> +<path + d="M23.9766 24L26.5104 24C30.4754 24 33.6897 27.2143 33.6897 31.1792V31.1792C33.6897 35.1442 36.9039 38.3585 40.8689 38.3585L43.4028 38.3585" + stroke="#FF8C38" stroke-width="1.4" stroke-linecap="round" /> +<path + d="M7 26C9.85124 21.8236 11.8347 18.5607 11.8347 20.649C11.8347 22.7372 10.9669 24.695 11.2149 25.3475C11.4628 26 12.9504 24.0423 13.9421 22.8677C14.9339 21.6931 15.9256 21.0405 15.8017 22.8677C15.6777 24.6949 16.2975 25.739 17.2893 24.8254C18.281 23.9118 19.2727 23.5203 20.0165 23.7813C20.6116 23.9901 21.5868 24.5644 22 24.8254" + stroke="var(--affine-black)" stroke-width="1.4" stroke-linecap="round" /> +<path + d="M44 11C46.4711 7.51964 48.1901 4.80062 48.1901 6.54079C48.1901 8.28097 47.438 9.91246 47.6529 10.4562C47.8678 11 49.157 9.36862 50.0165 8.38977C50.876 7.41092 51.7355 6.86712 51.6281 8.38977C51.5207 9.91243 52.0579 10.7825 52.9174 10.0212C53.7769 9.25986 54.6364 8.93358 55.281 9.1511C55.7967 9.32512 56.6419 9.80367 57 10.0212" + stroke="var(--affine-black)" stroke-width="1.4" stroke-linecap="round" /> +<path + d="M44 25.5C46.4711 22.0196 48.1901 19.3006 48.1901 21.0408C48.1901 22.781 47.438 24.4125 47.6529 24.9562C47.8678 25.5 49.157 23.8686 50.0165 22.8898C50.876 21.9109 51.7355 21.3671 51.6281 22.8898C51.5207 24.4124 52.0579 25.2825 52.9174 24.5212C53.7769 23.7599 54.6364 23.4336 55.281 23.6511C55.7967 23.8251 56.6419 24.3037 57 24.5212" + stroke="var(--affine-black)" stroke-width="1.4" stroke-linecap="round" /> +<path + d="M44 40C46.4711 36.5196 48.1901 33.8006 48.1901 35.5408C48.1901 37.281 47.438 38.9125 47.6529 39.4562C47.8678 40 49.157 38.3686 50.0165 37.3898C50.876 36.4109 51.7355 35.8671 51.6281 37.3898C51.5207 38.9124 52.0579 39.7825 52.9174 39.0212C53.7769 38.2599 54.6364 37.9336 55.281 38.1511C55.7967 38.3251 56.6419 38.8037 57 39.0212" + stroke="var(--affine-black)" stroke-width="1.4" stroke-linecap="round" /> +</svg>`; + +export const importMindMapIcon = svg`<svg width="64" height="48" viewBox="0 0 64 48" fill="none" xmlns="http://www.w3.org/2000/svg"> +<path d="M24.7745 24.0001L42.2623 24.0001" stroke="#929292" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round" stroke-dasharray="2 3"/> +<path d="M24.7745 24.0002L26.1332 24.0002C30.212 24.0002 33.5184 20.6938 33.5184 16.615V16.615C33.5184 12.5362 36.8249 9.22974 40.9037 9.22974L41.2583 9.22974" stroke="#929292" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round" stroke-dasharray="2 3"/> +<path d="M24.7745 24.0001L26.1332 24.0001C30.212 24.0001 33.5184 27.3066 33.5184 31.3854V31.3854C33.5184 35.4641 36.8249 38.7706 40.9037 38.7706L41.2583 38.7706" stroke="#929292" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round" stroke-dasharray="2 3"/> +<rect x="43.7295" y="34.4261" width="14.7705" height="8.68854" rx="2.4" fill="black" fill-opacity="0.03" stroke="#929292" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round" stroke-dasharray="2 3"/> +<rect x="43.7295" y="19.6558" width="14.7705" height="8.68854" rx="2.4" fill="black" fill-opacity="0.03" stroke="#929292" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round" stroke-dasharray="2 3"/> +<rect x="43.7295" y="4.88538" width="14.7705" height="8.68854" rx="2.4" fill="black" fill-opacity="0.03" stroke="#929292" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round" stroke-dasharray="2 3"/> +<rect x="5.5" y="17.9183" width="17.3771" height="12.1639" rx="3" fill="black" fill-opacity="0.03" stroke="#929292" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round" stroke-dasharray="2 3"/> +</svg> +`; diff --git a/blocksuite/blocks/src/root-block/edgeless/components/toolbar/mindmap/mindmap-importing-placeholder.ts b/blocksuite/blocks/src/root-block/edgeless/components/toolbar/mindmap/mindmap-importing-placeholder.ts new file mode 100644 index 0000000000..32635fd544 --- /dev/null +++ b/blocksuite/blocks/src/root-block/edgeless/components/toolbar/mindmap/mindmap-importing-placeholder.ts @@ -0,0 +1,60 @@ +import { LightLoadingIcon } from '@blocksuite/affine-components/icons'; +import { unsafeCSSVarV2 } from '@blocksuite/affine-shared/theme'; +import { ShadowlessElement } from '@blocksuite/block-std'; +import { css, html } from 'lit'; + +import { importMindMapIcon } from './icons.js'; + +export class MindMapPlaceholder extends ShadowlessElement { + static override styles = css` + mindmap-import-placeholder { + display: flex; + flex-direction: column; + + padding: 28px 12px 12px; + box-sizing: border-box; + width: 200px; + height: 122px; + + border-radius: 12px; + gap: 12px; + + background-color: ${unsafeCSSVarV2('layer/background/secondary')}; + border: 1px solid ${unsafeCSSVarV2('layer/insideBorder/border')}; + color: ${unsafeCSSVarV2('text/placeholder')}; + + box-shadow: 0px 0px 4px 0px rgba(66, 65, 73, 0.14); + } + + mindmap-import-placeholder .preview-icon { + text-align: center; + } + + mindmap-import-placeholder .description { + display: flex; + gap: 8px; + + color: ${unsafeCSSVarV2('text/placeholder')}; + font-size: 14px; + line-height: 22px; + + align-items: center; + } + `; + + override render() { + return html`<div class="placeholder-container"> + <div class="preview-icon">${importMindMapIcon}</div> + <div class="description"> + ${LightLoadingIcon} + <span>Importing mind map...</span> + </div> + </div>`; + } +} + +declare global { + interface HTMLElementTagNameMap { + 'mindmap-import-placeholder': MindMapPlaceholder; + } +} diff --git a/blocksuite/blocks/src/root-block/edgeless/components/toolbar/mindmap/mindmap-menu.ts b/blocksuite/blocks/src/root-block/edgeless/components/toolbar/mindmap/mindmap-menu.ts new file mode 100644 index 0000000000..1de0a532fc --- /dev/null +++ b/blocksuite/blocks/src/root-block/edgeless/components/toolbar/mindmap/mindmap-menu.ts @@ -0,0 +1,348 @@ +import { toast } from '@blocksuite/affine-components/toast'; +import type { MindmapStyle } from '@blocksuite/affine-model'; +import { + EditPropsStore, + TelemetryProvider, +} from '@blocksuite/affine-shared/services'; +import type { BlockStdScope } from '@blocksuite/block-std'; +import { modelContext, stdContext } from '@blocksuite/block-std'; +import { ErrorCode } from '@blocksuite/global/exceptions'; +import type { Bound } from '@blocksuite/global/utils'; +import { SignalWatcher } from '@blocksuite/global/utils'; +import type { BlockModel } from '@blocksuite/store'; +import { consume } from '@lit/context'; +import { computed } from '@preact/signals-core'; +import { css, html, LitElement, nothing, type TemplateResult } from 'lit'; +import { property } from 'lit/decorators.js'; +import { repeat } from 'lit/directives/repeat.js'; + +import type { EdgelessRootBlockComponent } from '../../../index.js'; +import { getTooltipWithShortcut } from '../../utils.js'; +import { EdgelessDraggableElementController } from '../common/draggable/draggable-element.controller.js'; +import { EdgelessToolbarToolMixin } from '../mixins/tool.mixin.js'; +import { getMindMaps, type ToolbarMindmapItem } from './assets.js'; +import { textRender } from './basket-elements.js'; +import { importMindMapIcon, textIcon } from './icons.js'; +import { MindMapPlaceholder } from './mindmap-importing-placeholder.js'; + +type TextItem = { + type: 'text'; + icon: TemplateResult; + render: typeof textRender; +}; + +type ImportItem = { + type: 'import'; + icon: TemplateResult; +}; + +const textItem: TextItem = { type: 'text', icon: textIcon, render: textRender }; + +export class EdgelessMindmapMenu extends EdgelessToolbarToolMixin( + SignalWatcher(LitElement) +) { + static override styles = css` + :host { + display: flex; + z-index: -1; + justify-content: flex-end; + } + .text-and-mindmap { + display: flex; + gap: 10px; + padding: 8px 0px; + box-sizing: border-box; + } + .thin-divider { + width: 1px; + transform: scaleX(0.5); + height: 48px; + background: var(--affine-border-color); + } + .text-item { + width: 60px; + } + .mindmap-item { + width: 64px; + } + + .text-item, + .mindmap-item { + border-radius: 4px; + height: 48px; + position: relative; + display: flex; + align-items: center; + justify-content: center; + } + .text-item > button, + .mindmap-item > button { + position: absolute; + border-radius: inherit; + border: none; + background: none; + cursor: grab; + padding: 0; + } + .text-item:hover, + .mindmap-item[data-is-active='true'], + .mindmap-item:hover { + background: var(--affine-hover-color); + } + .text-item > button.next, + .mindmap-item > button.next { + transition: transform 0.3s ease-in-out; + } + `; + + private _style$ = computed(() => { + const { style } = + this.edgeless.std.get(EditPropsStore).lastProps$.value.mindmap; + return style; + }); + + draggableController!: EdgelessDraggableElementController< + ToolbarMindmapItem | TextItem | ImportItem + >; + + override type = 'empty' as const; + + private get _rootBlock(): EdgelessRootBlockComponent { + return this.std.view.getBlock(this.model.id) as EdgelessRootBlockComponent; + } + + get mindMaps() { + return getMindMaps(this.theme); + } + + private _importMindMapEntry() { + const { draggingElement } = this.draggableController?.states || {}; + const isBeingDragged = draggingElement?.data.type === 'import'; + + return html`<div class="mindmap-item"> + <button + style="opacity: ${isBeingDragged ? 0 : 1}" + class="next" + @mousedown=${(e: MouseEvent) => { + this.draggableController.onMouseDown(e, { + preview: importMindMapIcon, + data: { + type: 'import', + icon: importMindMapIcon, + }, + standardWidth: 350, + }); + }} + @touchstart=${(e: TouchEvent) => { + this.draggableController.onTouchStart(e, { + preview: importMindMapIcon, + data: { + type: 'import', + icon: importMindMapIcon, + }, + standardWidth: 350, + }); + }} + @click=${() => { + this.draggableController.cancel(); + const viewportBound = this._rootBlock.service.viewport.viewportBounds; + + viewportBound.x += viewportBound.w / 2; + viewportBound.y += viewportBound.h / 2; + + this._onImportMindMap(viewportBound); + }} + > + ${importMindMapIcon} + </button> + <affine-tooltip tip-position="top" .offset=${12}> + ${getTooltipWithShortcut('Support import of FreeMind,OPML.')} + </affine-tooltip> + </div>`; + } + + private _onImportMindMap(bound: Bound) { + const edgelessBlock = this._rootBlock; + if (!edgelessBlock) return; + + const placeholder = new MindMapPlaceholder(); + + placeholder.style.position = 'absolute'; + placeholder.style.left = `${bound.x}px`; + placeholder.style.top = `${bound.y}px`; + + edgelessBlock.gfxViewportElm.append(placeholder); + + this.onImportMindMap?.(bound) + .then(() => { + this.std.getOptional(TelemetryProvider)?.track('CanvasElementAdded', { + page: 'whiteboard editor', + type: 'imported mind map', + other: 'success', + module: 'toolbar', + }); + }) + .catch(e => { + if (e.code === ErrorCode.UserAbortError) return; + this.std.getOptional(TelemetryProvider)?.track('CanvasElementAdded', { + page: 'whiteboard editor', + type: 'imported mind map', + other: 'failed', + module: 'toolbar', + }); + toast(this.edgeless.host, 'Import failed, please try again'); + console.error(e); + }) + .finally(() => { + placeholder.remove(); + }); + } + + initDragController() { + if (this.draggableController || !this.edgeless) return; + this.draggableController = new EdgelessDraggableElementController(this, { + service: this.edgeless.service, + edgeless: this.edgeless, + scopeElement: this, + clickToDrag: true, + onOverlayCreated: (_layer, element) => { + if (element.data.type === 'mindmap') { + this.onActiveStyleChange?.(element.data.style); + } + // a workaround to active mindmap, so that menu cannot be closed by `Escape` + this.setEdgelessTool({ type: 'empty' }); + }, + onDrop: (element, bound) => { + if ('render' in element.data) { + const id = element.data.render( + bound, + this.edgeless.service, + this.edgeless + ); + if (element.data.type === 'mindmap') { + this.onActiveStyleChange?.(element.data.style); + this.setEdgelessTool({ type: 'default' }); + this.edgeless.gfx.selection.set({ elements: [id], editing: false }); + } else if (element.data.type === 'text') { + this.setEdgelessTool({ type: 'default' }); + } + } + + if (element.data.type === 'import') { + this._onImportMindMap?.(bound); + } + }, + }); + } + + override render() { + const { cancelled, draggingElement, dragOut } = + this.draggableController?.states || {}; + + const isDraggingText = draggingElement?.data?.type === 'text'; + const showNextText = dragOut && !cancelled; + return html`<edgeless-slide-menu .height=${'64px'}> + <div class="text-and-mindmap"> + <div class="text-item"> + ${isDraggingText + ? html`<button + class="next" + style="transform: translateY(${showNextText ? 0 : 64}px)" + > + ${textItem.icon} + </button>` + : nothing} + <button + style="opacity: ${isDraggingText ? 0 : 1}" + @mousedown=${(e: MouseEvent) => + this.draggableController.onMouseDown(e, { + preview: textItem.icon, + data: textItem, + })} + @touchstart=${(e: TouchEvent) => + this.draggableController.onTouchStart(e, { + preview: textItem.icon, + data: textItem, + })} + > + ${textItem.icon} + </button> + <affine-tooltip tip-position="top" .offset=${12}> + ${getTooltipWithShortcut('Edgeless Text', 'T')} + </affine-tooltip> + </div> + <div class="thin-divider"></div> + <!-- mind map --> + ${repeat(this.mindMaps, mindMap => { + const isDraggingMindMap = draggingElement?.data?.type !== 'text'; + const draggingEle = draggingElement?.data as ToolbarMindmapItem; + const isBeingDragged = + isDraggingMindMap && draggingEle?.style === mindMap.style; + const showNext = dragOut && !cancelled; + const isActive = this._style$.value === mindMap.style; + return html` + <div class="mindmap-item" data-is-active=${isActive}> + ${isBeingDragged + ? html`<button + style="transform: translateY(${showNext ? 0 : 64}px)" + class="next" + > + ${mindMap.icon} + </button>` + : nothing} + <button + style="opacity: ${isBeingDragged ? 0 : 1}" + @mousedown=${(e: MouseEvent) => { + this.draggableController.onMouseDown(e, { + preview: mindMap.icon, + data: mindMap, + standardWidth: 350, + }); + }} + @touchstart=${(e: TouchEvent) => { + this.draggableController.onTouchStart(e, { + preview: mindMap.icon, + data: mindMap, + standardWidth: 350, + }); + }} + @click=${() => this.onActiveStyleChange?.(mindMap.style)} + > + ${mindMap.icon} + </button> + <affine-tooltip tip-position="top" .offset=${12}> + ${getTooltipWithShortcut('Mind Map', 'M')} + </affine-tooltip> + </div> + `; + })} + ${this.std.doc.awarenessStore.getFlag('enable_mind_map_import') + ? this._importMindMapEntry() + : nothing} + </div> + </edgeless-slide-menu>`; + } + + override updated(changedProperties: Map<PropertyKey, unknown>) { + if (!changedProperties.has('edgeless')) return; + this.initDragController(); + } + + @consume({ context: modelContext }) + accessor model!: BlockModel; + + @property({ attribute: false }) + accessor onActiveStyleChange!: (style: MindmapStyle) => void; + + @property({ attribute: false }) + accessor onImportMindMap!: (bound: Bound) => Promise<void>; + + @consume({ context: stdContext }) + accessor std!: BlockStdScope; +} + +declare global { + interface HTMLElementTagNameMap { + 'edgeless-mindmap-menu': EdgelessMindmapMenu; + } +} diff --git a/blocksuite/blocks/src/root-block/edgeless/components/toolbar/mindmap/mindmap-tool-button.ts b/blocksuite/blocks/src/root-block/edgeless/components/toolbar/mindmap/mindmap-tool-button.ts new file mode 100644 index 0000000000..dd060e2efa --- /dev/null +++ b/blocksuite/blocks/src/root-block/edgeless/components/toolbar/mindmap/mindmap-tool-button.ts @@ -0,0 +1,423 @@ +import type { + MindmapElementModel, + MindmapStyle, +} from '@blocksuite/affine-model'; +import { + EditPropsStore, + ThemeProvider, +} from '@blocksuite/affine-shared/services'; +import type { GfxToolsFullOptionValue } from '@blocksuite/block-std/gfx'; +import type { Bound } from '@blocksuite/global/utils'; +import { SignalWatcher } from '@blocksuite/global/utils'; +import { computed } from '@preact/signals-core'; +import { css, html, LitElement, nothing } from 'lit'; +import { property, query, state } from 'lit/decorators.js'; +import { classMap } from 'lit/directives/class-map.js'; +import { repeat } from 'lit/directives/repeat.js'; +import { styleMap } from 'lit/directives/style-map.js'; + +import { EdgelessDraggableElementController } from '../common/draggable/draggable-element.controller.js'; +import { EdgelessToolbarToolMixin } from '../mixins/tool.mixin.js'; +import { getMindMaps } from './assets.js'; +import { + type DraggableTool, + getMindmapRender, + mindmapConfig, + textConfig, + textRender, + toolConfig2StyleObj, +} from './basket-elements.js'; +import { basketIconDark, basketIconLight, textIcon } from './icons.js'; +import { importMindmap } from './utils/import-mindmap.js'; + +export class EdgelessMindmapToolButton extends EdgelessToolbarToolMixin( + SignalWatcher(LitElement) +) { + static override styles = css` + :host { + width: 100%; + height: 100%; + display: flex; + justify-content: center; + align-items: center; + } + .partial-clip { + flex-shrink: 0; + box-sizing: border-box; + width: calc(100% + 20px); + pointer-events: none; + padding: 0 10px; + overflow: hidden; + } + .basket-wrapper { + pointer-events: auto; + height: 64px; + width: 96px; + display: flex; + justify-content: center; + align-items: flex-end; + position: relative; + } + .basket, + .basket-tool-item { + transition: transform 0.3s ease-in-out; + position: absolute; + } + + .basket { + bottom: 0; + height: 17px; + width: 76px; + } + .basket > div, + .basket > svg { + position: absolute; + } + .glass { + width: 76px; + height: 17px; + border-radius: 2px; + mask: url(#mindmap-basket-body-mask); + } + .glass.enabled { + backdrop-filter: blur(2px); + } + + .basket { + z-index: 3; + } + .basket-tool-item { + cursor: grab; + } + .basket-tool-item svg { + display: block; + } + .basket-tool-item { + transform: translate(var(--default-x, 0), var(--default-y, 0)) + rotate(var(--default-r, 0)) scale(var(--default-s, 1)); + z-index: var(--default-z, 0); + } + + .basket-tool-item.next { + transform: translate(var(--next-x, 0), var(--next-y, 0)) + rotate(var(--next-r, 0)) scale(var(--next-s, 1)); + z-index: var(--next-z, 0); + } + + /* active & hover */ + .basket-wrapper:hover .basket, + .basket-wrapper.active .basket { + z-index: 0; + } + .basket-wrapper:hover .basket-tool-item.current, + .basket-wrapper.active .basket-tool-item.current { + transform: translate(var(--active-x, 0), var(--active-y, 0)) + rotate(var(--active-r, 0)) scale(var(--active-s, 1)); + z-index: var(--active-z, 0); + } + + .basket-tool-item.next.coming, + .basket-wrapper:hover .basket-tool-item.current:hover { + transform: translate(var(--hover-x, 0), var(--hover-y, 0)) + rotate(var(--hover-r, 0)) scale(var(--hover-s, 1)); + z-index: var(--hover-z, 0); + } + `; + + private _style$ = computed(() => { + const { style } = + this.edgeless.std.get(EditPropsStore).lastProps$.value.mindmap; + return style; + }); + + draggableController!: EdgelessDraggableElementController<DraggableTool>; + + override enableActiveBackground = true; + + override type: GfxToolsFullOptionValue['type'][] = ['empty', 'text']; + + get draggableTools(): DraggableTool[] { + const style = this._style$.value; + const mindmap = + this.mindmaps.find(m => m.style === style) || this.mindmaps[0]; + return [ + { + name: 'text', + icon: textIcon, + config: textConfig, + standardWidth: 100, + render: textRender, + }, + { + name: 'mindmap', + icon: mindmap.icon, + config: mindmapConfig, + standardWidth: 350, + render: getMindmapRender(style), + }, + ]; + } + + get mindmaps() { + return getMindMaps(this.theme); + } + + private _toggleMenu() { + if (this.tryDisposePopper()) return; + this.setEdgelessTool({ type: 'default' }); + + const menu = this.createPopper('edgeless-mindmap-menu', this); + Object.assign(menu.element, { + edgeless: this.edgeless, + onActiveStyleChange: (style: MindmapStyle) => { + this.edgeless.std.get(EditPropsStore).recordLastProps('mindmap', { + style, + }); + }, + onImportMindMap: (bound: Bound) => { + return importMindmap(bound).then(mindmap => { + const id = this.edgeless.service.addElement('mindmap', { + children: mindmap, + layoutType: mindmap?.layoutType === 'left' ? 1 : 0, + }); + const element = this.edgeless.service.getElementById( + id + ) as MindmapElementModel; + + this.tryDisposePopper(); + this.setEdgelessTool({ type: 'default' }); + this.edgeless.gfx.selection.set({ + elements: [element.tree.id], + editing: false, + }); + }); + }, + }); + } + + initDragController() { + if (!this.edgeless || !this.toolbarContainer) return; + if (this.draggableController) return; + this.draggableController = new EdgelessDraggableElementController(this, { + service: this.edgeless.service, + edgeless: this.edgeless, + scopeElement: this.toolbarContainer, + standardWidth: 100, + clickToDrag: true, + onOverlayCreated: (overlay, { data }) => { + const tool = this.draggableTools.find(t => t.name === data.name); + if (!tool) return; + + // recover the rotation + const rotate = tool.config?.hover?.r ?? tool.config?.default?.r ?? 0; + overlay.element.style.setProperty('--rotate', rotate + 'deg'); + setTimeout(() => { + overlay.transitionWrapper.style.setProperty( + '--rotate', + -rotate + 'deg' + ); + }, 50); + + // set the scale (without transition) + const scale = tool.config?.hover?.s ?? tool.config?.default?.s ?? 1; + overlay.element.style.setProperty('--scale', `${scale}`); + + // a workaround to handle getBoundingClientRect() when the element is rotated + const _left = parseInt(overlay.element.style.left); + const _top = parseInt(overlay.element.style.top); + if (data.name === 'mindmap') { + overlay.element.style.left = _left + 3 + 'px'; + overlay.element.style.top = _top + 5 + 'px'; + } else if (data.name === 'text') { + overlay.element.style.left = _left + 0 + 'px'; + overlay.element.style.top = _top + 3 + 'px'; + } + this.readyToDrop = true; + }, + onCanceled: overlay => { + overlay.transitionWrapper.style.transformOrigin = 'unset'; + overlay.transitionWrapper.style.setProperty('--rotate', '0deg'); + this.readyToDrop = false; + }, + onDrop: (el, bound) => { + const id = el.data.render(bound, this.edgeless.service, this.edgeless); + this.readyToDrop = false; + if (el.data.name === 'mindmap') { + this.setEdgelessTool({ type: 'default' }); + this.edgeless.gfx.selection.set({ elements: [id], editing: false }); + } else if (el.data.name === 'text') { + this.setEdgelessTool({ type: 'default' }); + } + }, + }); + + this.edgeless.bindHotKey( + { + m: () => { + const service = this.edgeless.service; + if (service.locked) return; + if (service.selection.editing) return; + + if (this.readyToDrop) { + // change the style + const activeIndex = this.mindmaps.findIndex( + m => m.style === this._style$.value + ); + const nextIndex = (activeIndex + 1) % this.mindmaps.length; + const next = this.mindmaps[nextIndex]; + this.edgeless.std.get(EditPropsStore).recordLastProps('mindmap', { + style: next.style, + }); + const tool = this.draggableTools.find(t => t.name === 'mindmap'); + this.draggableController.updateElementInfo({ + data: tool, + preview: next.icon, + }); + return; + } + this.setEdgelessTool({ type: 'empty' }); + const icon = this.mindmapElement; + const { x, y } = service.gfx.tool.lastMousePos$.peek(); + const { left, top } = this.edgeless.viewport; + const clientPos = { x: x + left, y: y + top }; + this.draggableController.clickToDrag(icon, clientPos); + }, + }, + { global: true } + ); + } + + override render() { + const { popper } = this; + const appTheme = this.edgeless.std.get(ThemeProvider).app$.value; + const basketIcon = appTheme === 'light' ? basketIconLight : basketIconDark; + const glassBg = + appTheme === 'light' ? 'rgba(255,255,255,0.5)' : 'rgba(74, 74, 74, 0.6)'; + + const { cancelled, dragOut, draggingElement } = + this.draggableController?.states || {}; + + const active = popper || draggingElement; + + return html`<edgeless-toolbar-button + class="edgeless-mindmap-button" + ?withHover=${true} + .tooltip=${popper ? '' : 'Others'} + .tooltipOffset=${4} + @click=${this._toggleMenu} + style="width: 100%; height: 100%; display: inline-block" + > + <div class="partial-clip"> + <div class="basket-wrapper ${active ? 'active' : ''}"> + ${repeat( + this.draggableTools, + t => t.name, + tool => { + const isBeingDragged = draggingElement?.data.name === tool.name; + const variables = toolConfig2StyleObj(tool.config); + + const nextStyle = styleMap({ + ...variables, + }); + const currentStyle = styleMap({ + ...variables, + opacity: isBeingDragged ? 0 : 1, + pointerEvents: draggingElement ? 'none' : 'auto', + }); + + return html`${isBeingDragged + ? html`<div + class=${classMap({ + 'basket-tool-item': true, + next: true, + coming: !!dragOut && !cancelled, + })} + style=${nextStyle} + > + ${tool.icon} + </div>` + : nothing} + + <div + style=${currentStyle} + @mousedown=${(e: MouseEvent) => + this.draggableController.onMouseDown(e, { + data: tool, + preview: tool.icon, + standardWidth: tool.standardWidth, + })} + @touchstart=${(e: TouchEvent) => + this.draggableController.onTouchStart(e, { + data: tool, + preview: tool.icon, + standardWidth: tool.standardWidth, + })} + class="basket-tool-item current ${tool.name}" + > + ${tool.icon} + </div>`; + } + )} + + <div class="basket"> + <div + class="glass ${this.enableBlur ? 'enabled' : ''}" + style="background: ${glassBg}" + ></div> + ${basketIcon} + </div> + </div> + </div> + + <svg width="0" height="0" style="opacity: 0; pointer-events: none"> + <defs> + <mask id="mindmap-basket-body-mask"> + <rect + x="2" + width="71.8" + y="2" + height="15" + rx="1.5" + ry="1.5" + fill="white" + /> + <rect + width="32" + height="6" + x="22" + y="5.9" + fill="black" + rx="3" + ry="3" + /> + </mask> + </defs> + </svg> + </edgeless-toolbar-button>`; + } + + override updated(_changedProperties: Map<PropertyKey, unknown>) { + const controllerRequiredProps = ['edgeless', 'toolbarContainer'] as const; + if ( + controllerRequiredProps.some(p => _changedProperties.has(p)) && + !this.draggableController + ) { + this.initDragController(); + } + } + + @property({ type: Boolean }) + accessor enableBlur = true; + + @query('.basket-tool-item.mindmap') + accessor mindmapElement!: HTMLElement; + + @state() + accessor readyToDrop = false; +} + +declare global { + interface HTMLElementTagNameMap { + 'edgeless-mindmap-tool-button': EdgelessMindmapToolButton; + } +} diff --git a/blocksuite/blocks/src/root-block/edgeless/components/toolbar/mindmap/utils/import-mindmap.ts b/blocksuite/blocks/src/root-block/edgeless/components/toolbar/mindmap/utils/import-mindmap.ts new file mode 100644 index 0000000000..5d18d18889 --- /dev/null +++ b/blocksuite/blocks/src/root-block/edgeless/components/toolbar/mindmap/utils/import-mindmap.ts @@ -0,0 +1,145 @@ +import { openFileOrFiles } from '@blocksuite/affine-shared/utils'; +import { BlockSuiteError, ErrorCode } from '@blocksuite/global/exceptions'; +import type { Bound } from '@blocksuite/global/utils'; +import c from 'simple-xml-to-json'; + +type MindMapNode = { + children: MindMapNode[]; + text: string; + xywh?: string; + title?: string; + layoutType?: 'left' | 'right'; +}; + +export async function importMindmap(bound: Bound): Promise<MindMapNode> { + const file = await openFileOrFiles({ + acceptType: 'MindMap', + }); + + if (!file) { + throw new BlockSuiteError(ErrorCode.UserAbortError, 'Aborted by user'); + } + + let result; + + if (file.name.endsWith('.mm')) { + result = await parseMmFile(file); + } else if (file.name.endsWith('.opml') || file.name.endsWith('.xml')) { + result = await parseOPMLFile(file); + } else { + throw new BlockSuiteError(ErrorCode.ParsingError, 'Unsupported file type'); + } + + if (result) { + result.xywh = bound.serialize(); + } + + return result; +} + +function readAsText(file: File) { + return file.text(); +} + +type RawMmNode = { + node?: { + TEXT: string; + POSITION: 'left' | 'right'; + children?: RawMmNode[]; + }; +}; + +async function parseMmFile(file: File): Promise<MindMapNode> { + const content = await readAsText(file); + + try { + const parsed = c.convertXML(content); + const map = parsed.map.children[0]; + + const traverse = (node: RawMmNode): MindMapNode | null => { + if (!node.node) { + return null; + } + + return node.node.POSITION + ? { + layoutType: node.node.POSITION, + text: node.node.TEXT ?? 'MINDMAP', + children: + (node.node.children + ?.map(traverse) + .filter(node => node) as MindMapNode[]) ?? [], + } + : { + text: node.node.TEXT ?? 'MINDMAP', + children: + (node.node.children + ?.map(traverse) + .filter(node => node) as MindMapNode[]) ?? [], + }; + }; + + const result = traverse(map); + + if (!result) { + throw new BlockSuiteError( + ErrorCode.ParsingError, + 'Failed to parse mm file' + ); + } + + return result; + } catch (e) { + console.error(e); + throw new BlockSuiteError( + ErrorCode.ParsingError, + 'Failed to parse mm file' + ); + } +} + +type RawOPMLOutline = { + outline: { + text: string; + children: RawOPMLOutline[]; + }; +}; + +async function parseOPMLFile(file: File): Promise<MindMapNode> { + const content = await readAsText(file); + + try { + const parsed = c.convertXML(content); + const outline = parsed.opml?.children[1].body?.children?.[0]; + + const traverse = (node: RawOPMLOutline): MindMapNode | null => { + if (!node.outline?.text && !node.outline?.children) { + return null; + } + + return { + text: node.outline?.text ?? 'MINDMAP', + children: node.outline.children + ? (node.outline.children.map(traverse) as MindMapNode[]) + : [], + }; + }; + + const result = traverse(outline); + + if (!result) { + throw new BlockSuiteError( + ErrorCode.ParsingError, + 'Failed to parse OPML file' + ); + } + + return result; + } catch (e) { + console.error(e); + throw new BlockSuiteError( + ErrorCode.ParsingError, + 'Failed to parse OPML file' + ); + } +} diff --git a/blocksuite/blocks/src/root-block/edgeless/components/toolbar/mixins/quick-tool.mixin.ts b/blocksuite/blocks/src/root-block/edgeless/components/toolbar/mixins/quick-tool.mixin.ts new file mode 100644 index 0000000000..1442d145c6 --- /dev/null +++ b/blocksuite/blocks/src/root-block/edgeless/components/toolbar/mixins/quick-tool.mixin.ts @@ -0,0 +1,21 @@ +import type { Constructor } from '@blocksuite/global/utils'; +import type { LitElement } from 'lit'; + +import { + // eslint-disable-next-line no-unused-vars + type EdgelessToolbarToolClass, + EdgelessToolbarToolMixin, +} from './tool.mixin.js'; + +export declare abstract class QuickToolMixinClass extends EdgelessToolbarToolClass {} + +/** + * Mixin for quick tool item. + */ +export const QuickToolMixin = <T extends Constructor<LitElement>>( + SuperClass: T +) => { + abstract class DerivedClass extends EdgelessToolbarToolMixin(SuperClass) {} + + return DerivedClass as unknown as T & Constructor<QuickToolMixinClass>; +}; diff --git a/blocksuite/blocks/src/root-block/edgeless/components/toolbar/mixins/tool.mixin.ts b/blocksuite/blocks/src/root-block/edgeless/components/toolbar/mixins/tool.mixin.ts new file mode 100644 index 0000000000..f960c762d8 --- /dev/null +++ b/blocksuite/blocks/src/root-block/edgeless/components/toolbar/mixins/tool.mixin.ts @@ -0,0 +1,174 @@ +import type { ColorScheme } from '@blocksuite/affine-model'; +import type { + GfxToolsFullOption, + GfxToolsFullOptionValue, + ToolController, +} from '@blocksuite/block-std/gfx'; +import { + type Constructor, + // eslint-disable-next-line no-unused-vars + type DisposableClass, + WithDisposable, +} from '@blocksuite/global/utils'; +import { consume } from '@lit/context'; +import { effect } from '@preact/signals-core'; +import { cssVar } from '@toeverything/theme'; +import type { LitElement } from 'lit'; +import { property, state } from 'lit/decorators.js'; + +import type { EdgelessRootBlockComponent } from '../../../edgeless-root-block.js'; +import { createPopper, type MenuPopper } from '../common/create-popper.js'; +import { + edgelessToolbarContext, + type EdgelessToolbarSlots, + edgelessToolbarSlotsContext, + edgelessToolbarThemeContext, +} from '../context.js'; +import type { EdgelessToolbarWidget } from '../edgeless-toolbar.js'; + +type ValueOf<T> = T[keyof T]; + +export declare abstract class EdgelessToolbarToolClass extends DisposableClass { + active: boolean; + + createPopper: typeof createPopper; + + edgeless: EdgelessRootBlockComponent; + + edgelessTool: GfxToolsFullOptionValue; + + enableActiveBackground?: boolean; + + popper: MenuPopper<HTMLElement> | null; + + setEdgelessTool: ToolController['setTool']; + + theme: ColorScheme; + + toolbarContainer: HTMLElement | null; + + toolbarSlots: EdgelessToolbarSlots; + + /** + * @return true if operation was successful + */ + tryDisposePopper: () => boolean; + + abstract type: + | GfxToolsFullOptionValue['type'] + | GfxToolsFullOptionValue['type'][]; + + accessor toolbar: EdgelessToolbarWidget; +} + +export const EdgelessToolbarToolMixin = <T extends Constructor<LitElement>>( + SuperClass: T +) => { + abstract class DerivedClass extends WithDisposable(SuperClass) { + enableActiveBackground = false; + + abstract type: + | GfxToolsFullOptionValue['type'] + | GfxToolsFullOptionValue['type'][]; + + get active() { + const { type } = this; + const activeType = this.edgelessTool?.type; + + return activeType + ? Array.isArray(type) + ? type.includes(activeType) + : activeType === type + : false; + } + + get setEdgelessTool() { + return (...args: Parameters<ToolController['setTool']>) => { + this.edgeless.gfx.tool.setTool( + // @ts-expect-error FIXME: ts error + ...args + ); + }; + } + + private _applyActiveStyle() { + if (!this.enableActiveBackground) return; + this.style.background = this.active + ? cssVar('hoverColor') + : 'transparent'; + } + + private _updateActiveEdgelessTool() { + this.edgelessTool = this.edgeless.gfx.tool.currentToolOption$.value; + this._applyActiveStyle(); + } + + override connectedCallback() { + super.connectedCallback(); + if (!this.edgeless) return; + this._updateActiveEdgelessTool(); + this._applyActiveStyle(); + + this._disposables.add( + effect(() => { + this._updateActiveEdgelessTool(); + }) + ); + } + + // TODO: move to toolbar-tool-with-menu.mixin + createPopper(...args: Parameters<typeof createPopper>) { + if (this.toolbar.activePopper) { + this.toolbar.activePopper.dispose(); + this.toolbar.activePopper = null; + } + this.popper = createPopper(args[0], args[1], { + ...args[2], + onDispose: () => { + args[2]?.onDispose?.(); + this.popper = null; + }, + }) as MenuPopper<HTMLElement>; + this.toolbar.activePopper = this.popper; + return this.popper; + } + + override disconnectedCallback() { + super.disconnectedCallback(); + this.popper?.dispose(); + } + + tryDisposePopper() { + if (!this.active) return false; + if (this.popper) { + this.popper.dispose(); + this.popper = null; + return true; + } + return false; + } + + @property({ attribute: false }) + accessor edgeless!: EdgelessRootBlockComponent; + + @state() + accessor edgelessTool!: ValueOf<GfxToolsFullOption> | null; + + @state() + public accessor popper: MenuPopper<HTMLElement> | null = null; + + @consume({ context: edgelessToolbarThemeContext, subscribe: true }) + accessor theme!: ColorScheme; + + @consume({ context: edgelessToolbarContext }) + accessor toolbar!: EdgelessToolbarWidget; + + @property({ attribute: false }) + accessor toolbarContainer: HTMLElement | null = null; + + @consume({ context: edgelessToolbarSlotsContext }) + accessor toolbarSlots!: EdgelessToolbarSlots; + } + + return DerivedClass as unknown as T & Constructor<EdgelessToolbarToolClass>; +}; diff --git a/blocksuite/blocks/src/root-block/edgeless/components/toolbar/mixins/toolbar-button-with-menu.mixin.ts b/blocksuite/blocks/src/root-block/edgeless/components/toolbar/mixins/toolbar-button-with-menu.mixin.ts new file mode 100644 index 0000000000..56cb1af69f --- /dev/null +++ b/blocksuite/blocks/src/root-block/edgeless/components/toolbar/mixins/toolbar-button-with-menu.mixin.ts @@ -0,0 +1,20 @@ +import type { Constructor } from '@blocksuite/global/utils'; +import type { LitElement } from 'lit'; + +import { + // eslint-disable-next-line no-unused-vars + type EdgelessToolbarToolClass, + EdgelessToolbarToolMixin, +} from './tool.mixin.js'; + +export declare abstract class ToolbarButtonWithMenuClass extends EdgelessToolbarToolClass {} + +export const ToolbarButtonWithMenuMixin = < + T extends Constructor<LitElement> = Constructor<LitElement>, +>( + SuperClass: T +) => { + abstract class DerivedClass extends EdgelessToolbarToolMixin(SuperClass) {} + + return DerivedClass as unknown as T & Constructor<ToolbarButtonWithMenuClass>; +}; diff --git a/blocksuite/blocks/src/root-block/edgeless/components/toolbar/note/icon.ts b/blocksuite/blocks/src/root-block/edgeless/components/toolbar/note/icon.ts new file mode 100644 index 0000000000..791f5d31e4 --- /dev/null +++ b/blocksuite/blocks/src/root-block/edgeless/components/toolbar/note/icon.ts @@ -0,0 +1,23 @@ +import { svg } from 'lit'; + +export const toShapeNotToAdapt = svg`<svg width="44" height="5" viewBox="0 0 44 5" fill="none" xmlns="http://www.w3.org/2000/svg"> +<path d="M43.9013 1.45752V1.94112H42.5034V1.45752H43.9013ZM42.8208 0.901367H43.4646V3.06551C43.4646 3.12496 43.4737 3.1713 43.4918 3.20455C43.5099 3.23679 43.5351 3.25946 43.5674 3.27256C43.6006 3.28565 43.6389 3.2922 43.6822 3.2922C43.7124 3.2922 43.7427 3.28968 43.7729 3.28465C43.8031 3.2786 43.8263 3.27407 43.8424 3.27105L43.9437 3.75012C43.9114 3.76019 43.8661 3.77178 43.8076 3.78488C43.7492 3.79898 43.6782 3.80755 43.5946 3.81057C43.4394 3.81662 43.3034 3.79596 43.1865 3.74861C43.0706 3.70126 42.9805 3.62771 42.916 3.52796C42.8515 3.42822 42.8198 3.30228 42.8208 3.15014V0.901367Z" fill="currentColor"/> +<path d="M39.9741 4.64977V1.45796H40.6089V1.84787H40.6376C40.6658 1.7854 40.7066 1.72193 40.76 1.65745C40.8144 1.59196 40.8849 1.53755 40.9716 1.49423C41.0592 1.4499 41.168 1.42773 41.298 1.42773C41.4673 1.42773 41.6234 1.47207 41.7665 1.56073C41.9096 1.64838 42.0239 1.78087 42.1096 1.95819C42.1952 2.13451 42.238 2.35566 42.238 2.62164C42.238 2.88057 42.1962 3.0992 42.1126 3.27753C42.03 3.45486 41.9171 3.58936 41.774 3.68104C41.632 3.77172 41.4728 3.81706 41.2965 3.81706C41.1716 3.81706 41.0653 3.79641 40.9776 3.7551C40.891 3.71379 40.8199 3.6619 40.7645 3.59944C40.7091 3.53596 40.6668 3.47199 40.6376 3.4075H40.6179V4.64977H39.9741ZM40.6043 2.61862C40.6043 2.75665 40.6235 2.87705 40.6618 2.97981C40.7 3.08258 40.7555 3.16268 40.828 3.22011C40.9005 3.27653 40.9887 3.30474 41.0925 3.30474C41.1972 3.30474 41.2859 3.27602 41.3584 3.21859C41.431 3.16016 41.4859 3.07956 41.5232 2.97679C41.5615 2.87302 41.5806 2.75363 41.5806 2.61862C41.5806 2.48462 41.562 2.36674 41.5247 2.26498C41.4874 2.16322 41.4325 2.08363 41.36 2.0262C41.2874 1.96877 41.1983 1.94006 41.0925 1.94006C40.9877 1.94006 40.899 1.96776 40.8265 2.02318C40.7549 2.07859 40.7 2.15718 40.6618 2.25894C40.6235 2.36069 40.6043 2.48059 40.6043 2.61862Z" fill="currentColor"/> +<path d="M38.1667 3.8231C38.0186 3.8231 37.8867 3.79741 37.7708 3.74603C37.6549 3.69364 37.5632 3.61656 37.4957 3.5148C37.4292 3.41204 37.396 3.28408 37.396 3.13094C37.396 3.00198 37.4197 2.89367 37.467 2.80602C37.5144 2.71836 37.5789 2.64784 37.6605 2.59444C37.7421 2.54104 37.8348 2.50074 37.9385 2.47354C38.0433 2.44633 38.1531 2.42719 38.268 2.41611C38.403 2.402 38.5118 2.38891 38.5944 2.37681C38.6771 2.36372 38.737 2.34457 38.7743 2.31939C38.8116 2.2942 38.8302 2.25692 38.8302 2.20755V2.19848C38.8302 2.10277 38.8 2.02872 38.7395 1.97633C38.6801 1.92394 38.5954 1.89774 38.4856 1.89774C38.3698 1.89774 38.2776 1.92343 38.2091 1.97482C38.1406 2.02519 38.0952 2.08867 38.073 2.16524L37.4776 2.11688C37.5078 1.97582 37.5673 1.85391 37.6559 1.75115C37.7446 1.64737 37.8589 1.56778 37.999 1.51237C38.14 1.45594 38.3033 1.42773 38.4886 1.42773C38.6176 1.42773 38.741 1.44285 38.8589 1.47307C38.9778 1.5033 39.0831 1.55015 39.1748 1.61362C39.2675 1.67709 39.3405 1.7587 39.3939 1.85845C39.4473 1.95718 39.474 2.07557 39.474 2.2136V3.77928H38.8634V3.45738H38.8453C38.808 3.52992 38.7582 3.59389 38.6957 3.64931C38.6332 3.70371 38.5582 3.74653 38.4705 3.77777C38.3829 3.80799 38.2816 3.8231 38.1667 3.8231ZM38.3511 3.37879C38.4458 3.37879 38.5295 3.36015 38.602 3.32287C38.6745 3.28459 38.7315 3.2332 38.7728 3.16872C38.8141 3.10424 38.8347 3.0312 38.8347 2.94959V2.70325C38.8146 2.71635 38.7869 2.72844 38.7516 2.73952C38.7174 2.7496 38.6786 2.75917 38.6352 2.76823C38.5919 2.7763 38.5486 2.78385 38.5053 2.7909C38.4619 2.79695 38.4227 2.80249 38.3874 2.80753C38.3118 2.81861 38.2458 2.83624 38.1894 2.86042C38.133 2.8846 38.0892 2.91735 38.0579 2.95866C38.0267 2.99896 38.0111 3.04933 38.0111 3.10978C38.0111 3.19744 38.0428 3.26444 38.1063 3.31078C38.1708 3.35612 38.2524 3.37879 38.3511 3.37879Z" fill="currentColor"/> +<path d="M35.6544 3.81647C35.4781 3.81647 35.3184 3.77113 35.1753 3.68045C35.0333 3.58877 34.9204 3.45426 34.8368 3.27694C34.7542 3.09861 34.7129 2.87998 34.7129 2.62105C34.7129 2.35506 34.7557 2.13391 34.8413 1.9576C34.927 1.78028 35.0408 1.64779 35.1829 1.56013C35.326 1.47147 35.4826 1.42714 35.6529 1.42714C35.7829 1.42714 35.8912 1.44931 35.9778 1.49364C36.0655 1.53696 36.136 1.59137 36.1894 1.65685C36.2438 1.72134 36.2851 1.78481 36.3133 1.84728H36.333V0.683594H36.9753V3.77868H36.3405V3.40691H36.3133C36.2831 3.47139 36.2403 3.53537 36.1849 3.59884C36.1305 3.66131 36.0594 3.7132 35.9718 3.7545C35.8851 3.79581 35.7793 3.81647 35.6544 3.81647ZM35.8584 3.30414C35.9622 3.30414 36.0499 3.27593 36.1214 3.21951C36.1939 3.16208 36.2494 3.08199 36.2876 2.97922C36.3269 2.87645 36.3466 2.75605 36.3466 2.61803C36.3466 2.48 36.3274 2.3601 36.2891 2.25834C36.2509 2.15658 36.1955 2.078 36.1229 2.02258C36.0504 1.96717 35.9622 1.93946 35.8584 1.93946C35.7526 1.93946 35.6635 1.96818 35.5909 2.02561C35.5184 2.08303 35.4635 2.16263 35.4262 2.26439C35.3889 2.36615 35.3703 2.48403 35.3703 2.61803C35.3703 2.75303 35.3889 2.87242 35.4262 2.9762C35.4645 3.07896 35.5194 3.15957 35.5909 3.218C35.6635 3.27543 35.7526 3.30414 35.8584 3.30414Z" fill="currentColor"/> +<path d="M32.9929 3.82213C32.8448 3.82213 32.7128 3.79644 32.597 3.74505C32.4811 3.69266 32.3894 3.61559 32.3219 3.51383C32.2554 3.41106 32.2222 3.28311 32.2222 3.12996C32.2222 3.001 32.2458 2.89269 32.2932 2.80504C32.3406 2.71739 32.405 2.64686 32.4866 2.59346C32.5682 2.54006 32.6609 2.49976 32.7647 2.47256C32.8695 2.44536 32.9793 2.42621 33.0942 2.41513C33.2292 2.40103 33.338 2.38793 33.4206 2.37584C33.5032 2.36274 33.5632 2.3436 33.6005 2.31841C33.6377 2.29322 33.6564 2.25594 33.6564 2.20658V2.19751C33.6564 2.10179 33.6261 2.02774 33.5657 1.97535C33.5062 1.92296 33.4216 1.89676 33.3118 1.89676C33.1959 1.89676 33.1037 1.92246 33.0352 1.97384C32.9667 2.02422 32.9214 2.08769 32.8992 2.16426L32.3038 2.1159C32.334 1.97485 32.3934 1.85294 32.4821 1.75017C32.5708 1.6464 32.6851 1.5668 32.8252 1.51139C32.9662 1.45497 33.1294 1.42676 33.3148 1.42676C33.4438 1.42676 33.5672 1.44187 33.6851 1.4721C33.804 1.50232 33.9093 1.54917 34.0009 1.61264C34.0936 1.67612 34.1667 1.75773 34.2201 1.85747C34.2735 1.95621 34.3002 2.07459 34.3002 2.21262V3.7783H33.6896V3.4564H33.6715C33.6342 3.52894 33.5843 3.59292 33.5219 3.64833C33.4594 3.70274 33.3843 3.74556 33.2967 3.77679C33.209 3.80702 33.1078 3.82213 32.9929 3.82213ZM33.1773 3.37781C33.272 3.37781 33.3556 3.35917 33.4282 3.3219C33.5007 3.28361 33.5576 3.23223 33.5989 3.16775C33.6402 3.10327 33.6609 3.03022 33.6609 2.94861V2.70227C33.6408 2.71537 33.613 2.72746 33.5778 2.73854C33.5435 2.74862 33.5047 2.75819 33.4614 2.76726C33.4181 2.77532 33.3748 2.78287 33.3314 2.78993C33.2881 2.79597 33.2488 2.80151 33.2136 2.80655C33.138 2.81763 33.072 2.83527 33.0156 2.85945C32.9592 2.88363 32.9153 2.91637 32.8841 2.95768C32.8529 2.99798 32.8373 3.04836 32.8373 3.10881C32.8373 3.19646 32.869 3.26346 32.9325 3.30981C32.9969 3.35514 33.0786 3.37781 33.1773 3.37781Z" fill="currentColor"/> +<path d="M29.7856 3.82364C29.5508 3.82364 29.3478 3.77377 29.1765 3.67402C29.0063 3.57327 28.8748 3.43323 28.7821 3.25389C28.6894 3.07354 28.6431 2.86448 28.6431 2.62671C28.6431 2.38692 28.6894 2.17736 28.7821 1.99802C28.8748 1.81767 29.0063 1.67763 29.1765 1.57789C29.3478 1.47713 29.5508 1.42676 29.7856 1.42676C30.0203 1.42676 30.2229 1.47713 30.3931 1.57789C30.5644 1.67763 30.6964 1.81767 30.7891 1.99802C30.8818 2.17736 30.9281 2.38692 30.9281 2.62671C30.9281 2.86448 30.8818 3.07354 30.7891 3.25389C30.6964 3.43323 30.5644 3.57327 30.3931 3.67402C30.2229 3.77377 30.0203 3.82364 29.7856 3.82364ZM29.7886 3.32492C29.8954 3.32492 29.9846 3.29469 30.0561 3.23424C30.1276 3.17278 30.1815 3.08916 30.2178 2.98337C30.2551 2.87758 30.2737 2.75718 30.2737 2.62218C30.2737 2.48717 30.2551 2.36677 30.2178 2.26098C30.1815 2.15519 30.1276 2.07157 30.0561 2.01011C29.9846 1.94865 29.8954 1.91792 29.7886 1.91792C29.6808 1.91792 29.5901 1.94865 29.5166 2.01011C29.444 2.07157 29.3891 2.15519 29.3519 2.26098C29.3156 2.36677 29.2974 2.48717 29.2974 2.62218C29.2974 2.75718 29.3156 2.87758 29.3519 2.98337C29.3891 3.08916 29.444 3.17278 29.5166 3.23424C29.5901 3.29469 29.6808 3.32492 29.7886 3.32492Z" fill="currentColor"/> +<path d="M28.3413 1.45752V1.94112H26.9434V1.45752H28.3413ZM27.2607 0.901367H27.9045V3.06551C27.9045 3.12496 27.9136 3.1713 27.9317 3.20455C27.9499 3.23679 27.9751 3.25946 28.0073 3.27256C28.0405 3.28565 28.0788 3.2922 28.1222 3.2922C28.1524 3.2922 28.1826 3.28968 28.2128 3.28465C28.2431 3.2786 28.2662 3.27407 28.2823 3.27105L28.3836 3.75012C28.3514 3.76019 28.306 3.77178 28.2476 3.78488C28.1892 3.79898 28.1181 3.80755 28.0345 3.81057C27.8793 3.81662 27.7433 3.79596 27.6265 3.74861C27.5106 3.70126 27.4204 3.62771 27.3559 3.52796C27.2915 3.42822 27.2597 3.30228 27.2607 3.15014V0.901367Z" fill="currentColor"/> +<path d="M25.7007 1.45752V1.94112H24.3027V1.45752H25.7007ZM24.6201 0.901367H25.2639V3.06551C25.2639 3.12496 25.273 3.1713 25.2911 3.20455C25.3092 3.23679 25.3344 3.25946 25.3667 3.27256C25.3999 3.28565 25.4382 3.2922 25.4815 3.2922C25.5118 3.2922 25.542 3.28968 25.5722 3.28465C25.6024 3.2786 25.6256 3.27407 25.6417 3.27105L25.743 3.75012C25.7107 3.76019 25.6654 3.77178 25.607 3.78488C25.5485 3.79898 25.4775 3.80755 25.3939 3.81057C25.2387 3.81662 25.1027 3.79596 24.9858 3.74861C24.87 3.70126 24.7798 3.62771 24.7153 3.52796C24.6508 3.42822 24.6191 3.30228 24.6201 3.15014V0.901367Z" fill="currentColor"/> +<path d="M22.9062 3.82462C22.6714 3.82462 22.4684 3.77474 22.2972 3.675C22.1269 3.57425 21.9954 3.4342 21.9027 3.25487C21.81 3.07452 21.7637 2.86546 21.7637 2.62769C21.7637 2.3879 21.81 2.17833 21.9027 1.999C21.9954 1.81865 22.1269 1.67861 22.2972 1.57886C22.4684 1.47811 22.6714 1.42773 22.9062 1.42773C23.1409 1.42773 23.3435 1.47811 23.5137 1.57886C23.685 1.67861 23.817 1.81865 23.9097 1.999C24.0024 2.17833 24.0487 2.3879 24.0487 2.62769C24.0487 2.86546 24.0024 3.07452 23.9097 3.25487C23.817 3.4342 23.685 3.57425 23.5137 3.675C23.3435 3.77474 23.1409 3.82462 22.9062 3.82462ZM22.9092 3.3259C23.016 3.3259 23.1052 3.29567 23.1767 3.23522C23.2482 3.17376 23.3021 3.09014 23.3384 2.98435C23.3757 2.87856 23.3943 2.75816 23.3943 2.62315C23.3943 2.48815 23.3757 2.36775 23.3384 2.26196C23.3021 2.15617 23.2482 2.07254 23.1767 2.01109C23.1052 1.94963 23.016 1.9189 22.9092 1.9189C22.8014 1.9189 22.7107 1.94963 22.6372 2.01109C22.5646 2.07254 22.5097 2.15617 22.4725 2.26196C22.4362 2.36775 22.4181 2.48815 22.4181 2.62315C22.4181 2.75816 22.4362 2.87856 22.4725 2.98435C22.5097 3.09014 22.5646 3.17376 22.6372 3.23522C22.7107 3.29567 22.8014 3.3259 22.9092 3.3259Z" fill="currentColor"/> +<path d="M19.8538 2.43727V3.77928H19.21V1.45796H19.8235V1.86752H19.8507C19.9021 1.73251 19.9883 1.62571 20.1092 1.54712C20.2301 1.46753 20.3767 1.42773 20.549 1.42773C20.7102 1.42773 20.8507 1.463 20.9706 1.53352C21.0905 1.60405 21.1837 1.7048 21.2502 1.83578C21.3167 1.96575 21.3499 2.12091 21.3499 2.30125V3.77928H20.7061V2.41611C20.7071 2.27405 20.6709 2.16322 20.5973 2.08363C20.5238 2.00303 20.4225 1.96273 20.2935 1.96273C20.2069 1.96273 20.1303 1.98136 20.0638 2.01864C19.9983 2.05592 19.947 2.11033 19.9097 2.18186C19.8734 2.25239 19.8548 2.33752 19.8538 2.43727Z" fill="currentColor"/> +<path d="M16.7385 3.82364C16.4997 3.82364 16.2942 3.77528 16.1219 3.67856C15.9506 3.58083 15.8186 3.4428 15.726 3.26447C15.6333 3.08513 15.5869 2.87305 15.5869 2.62822C15.5869 2.38944 15.6333 2.17988 15.726 1.99953C15.8186 1.81919 15.9491 1.67864 16.1174 1.57789C16.2866 1.47713 16.4851 1.42676 16.7128 1.42676C16.866 1.42676 17.0085 1.45144 17.1405 1.50081C17.2735 1.54917 17.3894 1.62222 17.4881 1.71995C17.5878 1.81767 17.6654 1.94059 17.7208 2.0887C17.7762 2.23579 17.804 2.40808 17.804 2.60555V2.78237H15.8438V2.38339H17.1979C17.1979 2.2907 17.1778 2.20859 17.1375 2.13706C17.0972 2.06552 17.0413 2.00961 16.9697 1.96931C16.8992 1.928 16.8171 1.90734 16.7234 1.90734C16.6257 1.90734 16.539 1.93001 16.4635 1.97535C16.3889 2.01968 16.3305 2.07963 16.2881 2.15519C16.2458 2.22975 16.2242 2.31287 16.2232 2.40455V2.78388C16.2232 2.89874 16.2443 2.99798 16.2866 3.0816C16.33 3.16523 16.3909 3.22971 16.4695 3.27505C16.5481 3.32038 16.6413 3.34305 16.7491 3.34305C16.8206 3.34305 16.8861 3.33298 16.9455 3.31283C17.005 3.29268 17.0559 3.26245 17.0982 3.22215C17.1405 3.18185 17.1727 3.13248 17.1949 3.07405L17.7904 3.11334C17.7601 3.25641 17.6982 3.38134 17.6045 3.48814C17.5118 3.59393 17.3919 3.67654 17.2448 3.73599C17.0987 3.79442 16.9299 3.82364 16.7385 3.82364Z" fill="currentColor"/> +<path d="M12.9878 4.64977V1.45796H13.6225V1.84787H13.6512C13.6795 1.7854 13.7203 1.72193 13.7737 1.65745C13.8281 1.59196 13.8986 1.53755 13.9852 1.49423C14.0729 1.4499 14.1817 1.42773 14.3117 1.42773C14.4809 1.42773 14.6371 1.47207 14.7802 1.56073C14.9232 1.64838 15.0376 1.78087 15.1232 1.95819C15.2089 2.13451 15.2517 2.35566 15.2517 2.62164C15.2517 2.88057 15.2099 3.0992 15.1262 3.27753C15.0436 3.45486 14.9308 3.58936 14.7877 3.68104C14.6457 3.77172 14.4865 3.81706 14.3102 3.81706C14.1852 3.81706 14.0789 3.79641 13.9913 3.7551C13.9046 3.71379 13.8336 3.6619 13.7782 3.59944C13.7228 3.53596 13.6805 3.47199 13.6512 3.4075H13.6316V4.64977H12.9878ZM13.618 2.61862C13.618 2.75665 13.6371 2.87705 13.6754 2.97981C13.7137 3.08258 13.7691 3.16268 13.8417 3.22011C13.9142 3.27653 14.0024 3.30474 14.1061 3.30474C14.2109 3.30474 14.2996 3.27602 14.3721 3.21859C14.4447 3.16016 14.4996 3.07956 14.5368 2.97679C14.5751 2.87302 14.5943 2.75363 14.5943 2.61862C14.5943 2.48462 14.5756 2.36674 14.5384 2.26498C14.5011 2.16322 14.4462 2.08363 14.3736 2.0262C14.3011 1.96877 14.2119 1.94006 14.1061 1.94006C14.0014 1.94006 13.9127 1.96776 13.8402 2.02318C13.7686 2.07859 13.7137 2.15718 13.6754 2.25894C13.6371 2.36069 13.618 2.48059 13.618 2.61862Z" fill="currentColor"/> +<path d="M11.1814 3.82213C11.0333 3.82213 10.9013 3.79644 10.7854 3.74505C10.6696 3.69266 10.5779 3.61559 10.5104 3.51383C10.4439 3.41106 10.4106 3.28311 10.4106 3.12996C10.4106 3.001 10.4343 2.89269 10.4817 2.80504C10.529 2.71739 10.5935 2.64686 10.6751 2.59346C10.7567 2.54006 10.8494 2.49976 10.9532 2.47256C11.058 2.44536 11.1678 2.42621 11.2827 2.41513C11.4177 2.40103 11.5265 2.38793 11.6091 2.37584C11.6917 2.36274 11.7516 2.3436 11.7889 2.31841C11.8262 2.29322 11.8448 2.25594 11.8448 2.20658V2.19751C11.8448 2.10179 11.8146 2.02774 11.7542 1.97535C11.6947 1.92296 11.6101 1.89676 11.5003 1.89676C11.3844 1.89676 11.2922 1.92246 11.2237 1.97384C11.1552 2.02422 11.1099 2.08769 11.0877 2.16426L10.4923 2.1159C10.5225 1.97485 10.5819 1.85294 10.6706 1.75017C10.7592 1.6464 10.8736 1.5668 11.0136 1.51139C11.1547 1.45497 11.3179 1.42676 11.5033 1.42676C11.6323 1.42676 11.7557 1.44187 11.8736 1.4721C11.9924 1.50232 12.0977 1.54917 12.1894 1.61264C12.2821 1.67612 12.3552 1.75773 12.4085 1.85747C12.4619 1.95621 12.4886 2.07459 12.4886 2.21262V3.7783H11.8781V3.4564H11.86C11.8227 3.52894 11.7728 3.59292 11.7103 3.64833C11.6479 3.70274 11.5728 3.74556 11.4852 3.77679C11.3975 3.80702 11.2963 3.82213 11.1814 3.82213ZM11.3658 3.37781C11.4605 3.37781 11.5441 3.35917 11.6166 3.3219C11.6892 3.28361 11.7461 3.23223 11.7874 3.16775C11.8287 3.10327 11.8494 3.03022 11.8494 2.94861V2.70227C11.8292 2.71537 11.8015 2.72746 11.7663 2.73854C11.732 2.74862 11.6932 2.75819 11.6499 2.76726C11.6066 2.77532 11.5632 2.78287 11.5199 2.78993C11.4766 2.79597 11.4373 2.80151 11.402 2.80655C11.3265 2.81763 11.2605 2.83527 11.2041 2.85945C11.1476 2.88363 11.1038 2.91637 11.0726 2.95768C11.0413 2.99798 11.0257 3.04836 11.0257 3.10881C11.0257 3.19646 11.0575 3.26346 11.1209 3.30981C11.1854 3.35514 11.267 3.37781 11.3658 3.37781Z" fill="currentColor"/> +<path d="M8.50757 2.43667V3.77868H7.86377V0.683594H8.48944V1.86692H8.51664C8.56903 1.7299 8.65366 1.6226 8.77053 1.54502C8.88741 1.46643 9.034 1.42714 9.21032 1.42714C9.37152 1.42714 9.51207 1.4624 9.63196 1.53293C9.75286 1.60245 9.84656 1.7027 9.91306 1.83367C9.98056 1.96364 10.0138 2.1193 10.0128 2.30066V3.77868H9.369V2.41551C9.37001 2.27245 9.33374 2.16112 9.26019 2.08152C9.18765 2.00193 9.08589 1.96213 8.95491 1.96213C8.86726 1.96213 8.78968 1.98077 8.72217 2.01805C8.65568 2.05533 8.60329 2.10973 8.565 2.18127C8.52772 2.25179 8.50858 2.33693 8.50757 2.43667Z" fill="currentColor"/> +<path d="M7.40576 2.1199L6.81636 2.15617C6.80629 2.10579 6.78462 2.06045 6.75138 2.02015C6.71813 1.97885 6.6743 1.9461 6.61989 1.92192C6.5665 1.89673 6.50252 1.88414 6.42796 1.88414C6.32822 1.88414 6.24409 1.9053 6.17558 1.94761C6.10707 1.98892 6.07281 2.04433 6.07281 2.11385C6.07281 2.16927 6.09498 2.21612 6.13931 2.2544C6.18364 2.29269 6.25971 2.32342 6.36751 2.34659L6.78765 2.43122C7.01333 2.47757 7.18159 2.55212 7.29241 2.65489C7.40324 2.75766 7.45865 2.89266 7.45865 3.05991C7.45865 3.21205 7.41382 3.34554 7.32415 3.4604C7.23549 3.57526 7.11358 3.66492 6.95842 3.72941C6.80427 3.79288 6.62644 3.82462 6.42494 3.82462C6.11765 3.82462 5.87282 3.76064 5.69046 3.63268C5.50911 3.50372 5.40282 3.32841 5.37158 3.10676L6.00481 3.07351C6.02395 3.16721 6.07029 3.23875 6.14384 3.28811C6.21739 3.33647 6.31159 3.36065 6.42645 3.36065C6.53929 3.36065 6.62997 3.33899 6.69848 3.29567C6.768 3.25134 6.80326 3.19441 6.80427 3.1249C6.80326 3.06646 6.77858 3.0186 6.73022 2.98132C6.68186 2.94304 6.6073 2.91382 6.50655 2.89367L6.10455 2.81357C5.87786 2.76823 5.7091 2.68965 5.59827 2.57781C5.48845 2.46598 5.43354 2.32342 5.43354 2.15012C5.43354 2.00101 5.47384 1.87255 5.55445 1.76475C5.63606 1.65694 5.75041 1.57382 5.89751 1.51539C6.04561 1.45695 6.2189 1.42773 6.41738 1.42773C6.71057 1.42773 6.94129 1.4897 7.10955 1.61362C7.27881 1.73755 7.37755 1.9063 7.40576 2.1199Z" fill="currentColor"/> +<path d="M2.92866 3.82364C2.69391 3.82364 2.49089 3.77377 2.31961 3.67402C2.14934 3.57327 2.01786 3.43323 1.92517 3.25389C1.83248 3.07354 1.78613 2.86448 1.78613 2.62671C1.78613 2.38692 1.83248 2.17736 1.92517 1.99802C2.01786 1.81767 2.14934 1.67763 2.31961 1.57789C2.49089 1.47713 2.69391 1.42676 2.92866 1.42676C3.16341 1.42676 3.36592 1.47713 3.53619 1.57789C3.70747 1.67763 3.83945 1.81767 3.93214 1.99802C4.02483 2.17736 4.07118 2.38692 4.07118 2.62671C4.07118 2.86448 4.02483 3.07354 3.93214 3.25389C3.83945 3.43323 3.70747 3.57327 3.53619 3.67402C3.36592 3.77377 3.16341 3.82364 2.92866 3.82364ZM2.93168 3.32492C3.03848 3.32492 3.12764 3.29469 3.19917 3.23424C3.27071 3.17278 3.32461 3.08916 3.36088 2.98337C3.39816 2.87758 3.4168 2.75718 3.4168 2.62218C3.4168 2.48717 3.39816 2.36677 3.36088 2.26098C3.32461 2.15519 3.27071 2.07157 3.19917 2.01011C3.12764 1.94865 3.03848 1.91792 2.93168 1.91792C2.82387 1.91792 2.7332 1.94865 2.65965 2.01011C2.58711 2.07157 2.5322 2.15519 2.49492 2.26098C2.45865 2.36677 2.44051 2.48717 2.44051 2.62218C2.44051 2.75718 2.45865 2.87758 2.49492 2.98337C2.5322 3.08916 2.58711 3.17278 2.65965 3.23424C2.7332 3.29469 2.82387 3.32492 2.93168 3.32492Z" fill="currentColor"/> +<path d="M1.48533 1.45752V1.94112H0.0874023L0.0874023 1.45752H1.48533ZM0.40477 0.901367H1.04857V3.06551C1.04857 3.12496 1.05764 3.1713 1.07578 3.20455C1.09391 3.23679 1.1191 3.25946 1.15134 3.27256C1.18459 3.28565 1.22287 3.2922 1.2662 3.2922C1.29642 3.2922 1.32665 3.28968 1.35687 3.28465C1.3871 3.2786 1.41027 3.27407 1.42639 3.27105L1.52765 3.75012C1.49541 3.76019 1.45007 3.77178 1.39163 3.78488C1.3332 3.79898 1.26217 3.80755 1.17854 3.81057C1.02339 3.81662 0.88737 3.79596 0.770499 3.74861C0.654634 3.70126 0.564461 3.62771 0.49998 3.52796C0.435499 3.42822 0.403763 3.30228 0.40477 3.15014V0.901367Z" fill="currentColor"/> +</svg> + +`; diff --git a/blocksuite/blocks/src/root-block/edgeless/components/toolbar/note/note-menu-config.ts b/blocksuite/blocks/src/root-block/edgeless/components/toolbar/note/note-menu-config.ts new file mode 100644 index 0000000000..2e8104f2e1 --- /dev/null +++ b/blocksuite/blocks/src/root-block/edgeless/components/toolbar/note/note-menu-config.ts @@ -0,0 +1,152 @@ +import { + BulletedListIcon, + CheckBoxIcon, + CodeBlockIcon, + DividerIcon, + Heading1Icon, + Heading2Icon, + Heading3Icon, + Heading4Icon, + Heading5Icon, + Heading6Icon, + NumberedListIcon, + QuoteIcon, + TextIcon, +} from '@blocksuite/affine-components/icons'; +import type { TemplateResult } from 'lit'; + +import type { NoteChildrenFlavour } from '../../../../../_common/utils/index.js'; + +export const BUTTON_GROUP_LENGTH = 10; + +export type NoteMenuItem = { + icon: TemplateResult<1>; + tooltip: string; + childFlavour: NoteChildrenFlavour; + childType: string | null; +}; + +const LIST_ITEMS = [ + { + flavour: 'affine:list', + type: 'bulleted', + name: 'Bulleted List', + description: 'A simple bulleted list.', + icon: BulletedListIcon, + tooltip: 'Drag/Click to insert Bulleted List', + }, + { + flavour: 'affine:list', + type: 'numbered', + name: 'Numbered List', + description: 'A list with numbering.', + icon: NumberedListIcon, + tooltip: 'Drag/Click to insert Numbered List', + }, + { + flavour: 'affine:list', + type: 'todo', + name: 'To-do List', + description: 'Track tasks with a to-do list.', + icon: CheckBoxIcon, + tooltip: 'Drag/Click to insert To-do List', + }, +]; + +const TEXT_ITEMS = [ + { + flavour: 'affine:paragraph', + type: 'text', + name: 'Text', + description: 'Start typing with plain text.', + icon: TextIcon, + tooltip: 'Drag/Click to insert Text block', + }, + { + flavour: 'affine:paragraph', + type: 'h1', + name: 'Heading 1', + description: 'Headings in the largest font.', + icon: Heading1Icon, + tooltip: 'Drag/Click to insert Heading 1', + }, + { + flavour: 'affine:paragraph', + type: 'h2', + name: 'Heading 2', + description: 'Headings in the 2nd font size.', + icon: Heading2Icon, + tooltip: 'Drag/Click to insert Heading 2', + }, + { + flavour: 'affine:paragraph', + type: 'h3', + name: 'Heading 3', + description: 'Headings in the 3rd font size.', + icon: Heading3Icon, + tooltip: 'Drag/Click to insert Heading 3', + }, + { + flavour: 'affine:paragraph', + type: 'h4', + name: 'Heading 4', + description: 'Heading in the 4th font size.', + icon: Heading4Icon, + tooltip: 'Drag/Click to insert Heading 4', + }, + { + flavour: 'affine:paragraph', + type: 'h5', + name: 'Heading 5', + description: 'Heading in the 5th font size.', + icon: Heading5Icon, + tooltip: 'Drag/Click to insert Heading 5', + }, + { + flavour: 'affine:paragraph', + type: 'h6', + name: 'Heading 6', + description: 'Heading in the 6th font size.', + icon: Heading6Icon, + tooltip: 'Drag/Click to insert Heading 6', + }, + { + flavour: 'affine:code', + type: 'code', + name: 'Code Block', + description: 'Capture a code snippet.', + icon: CodeBlockIcon, + tooltip: 'Drag/Click to insert Code Block', + }, + { + flavour: 'affine:paragraph', + type: 'quote', + name: 'Quote', + description: 'Capture a quote.', + icon: QuoteIcon, + tooltip: 'Drag/Click to insert Quote', + }, + { + flavour: 'affine:divider', + type: null, + name: 'Divider', + description: 'A visual divider.', + icon: DividerIcon, + tooltip: 'A visual divider', + }, +]; + +// TODO: add image, bookmark, database blocks +export const NOTE_MENU_ITEMS = TEXT_ITEMS.concat(LIST_ITEMS) + .filter(item => item.name !== 'Divider') + .map(item => { + return { + icon: item.icon, + tooltip: + item.type !== 'text' + ? item.tooltip.replace('Drag/Click to insert ', '') + : 'Text', + childFlavour: item.flavour as NoteChildrenFlavour, + childType: item.type, + } as NoteMenuItem; + }); diff --git a/blocksuite/blocks/src/root-block/edgeless/components/toolbar/note/note-menu.ts b/blocksuite/blocks/src/root-block/edgeless/components/toolbar/note/note-menu.ts new file mode 100644 index 0000000000..b5ea8a0f6d --- /dev/null +++ b/blocksuite/blocks/src/root-block/edgeless/components/toolbar/note/note-menu.ts @@ -0,0 +1,208 @@ +import { AttachmentIcon, LinkIcon } from '@blocksuite/affine-components/icons'; +import { TelemetryProvider } from '@blocksuite/affine-shared/services'; +import type { GfxToolsFullOptionValue } from '@blocksuite/block-std/gfx'; +import { effect } from '@preact/signals-core'; +import { css, html, LitElement } from 'lit'; +import { property, state } from 'lit/decorators.js'; +import { repeat } from 'lit/directives/repeat.js'; + +import { + getImageFilesFromLocal, + type NoteChildrenFlavour, + openFileOrFiles, +} from '../../../../../_common/utils/index.js'; +import { ImageIcon } from '../../../../../image-block/styles.js'; +import type { NoteToolOption } from '../../../gfx-tool/note-tool.js'; +import { addAttachments, addImages } from '../../../utils/common.js'; +import { getTooltipWithShortcut } from '../../utils.js'; +import { EdgelessToolbarToolMixin } from '../mixins/tool.mixin.js'; +import { NOTE_MENU_ITEMS } from './note-menu-config.js'; + +export class EdgelessNoteMenu extends EdgelessToolbarToolMixin(LitElement) { + static override styles = css` + :host { + position: absolute; + display: flex; + z-index: -1; + } + .menu-content { + display: flex; + align-items: center; + justify-content: center; + } + .button-group-container { + display: flex; + justify-content: center; + align-items: center; + gap: 14px; + fill: var(--affine-icon-color); + } + .button-group-container svg { + width: 20px; + height: 20px; + } + .divider { + width: 1px; + height: 24px; + background: var(--affine-border-color); + transform: scaleX(0.5); + margin: 0 14px; + } + `; + + override type: GfxToolsFullOptionValue['type'] = 'affine:note'; + + private async _addImages() { + this._imageLoading = true; + const imageFiles = await getImageFilesFromLocal(); + const ids = await addImages(this.edgeless.std, imageFiles); + this._imageLoading = false; + this.edgeless.gfx.tool.setTool('default'); + this.edgeless.gfx.selection.set({ elements: ids }); + } + + private _onHandleLinkButtonClick() { + const { insertedLinkType } = this.edgeless.service.std.command.exec( + 'insertLinkByQuickSearch' + ); + + insertedLinkType + ?.then(type => { + const flavour = type?.flavour; + if (!flavour) return; + + this.edgeless.std + .getOptional(TelemetryProvider) + ?.track('CanvasElementAdded', { + control: 'toolbar:general', + page: 'whiteboard editor', + module: 'toolbar', + type: flavour.split(':')[1], + }); + }) + .catch(console.error); + } + + override disconnectedCallback() { + super.disconnectedCallback(); + } + + override firstUpdated() { + this.disposables.add( + effect(() => { + const tool = this.edgeless.gfx.tool.currentToolOption$.value; + + if (tool?.type !== 'affine:note') return; + this.childFlavour = tool.childFlavour; + this.childType = tool.childType; + this.tip = tool.tip; + }) + ); + } + + override render() { + const { childType } = this; + + return html` + <edgeless-slide-menu> + <div class="menu-content"> + <!-- add to edgeless --> + <div class="button-group-container"> + <edgeless-tool-icon-button + .activeMode=${'background'} + .tooltip=${'Image'} + @click=${this._addImages} + .disabled=${this._imageLoading} + > + ${ImageIcon} + </edgeless-tool-icon-button> + + <edgeless-tool-icon-button + .activeMode=${'background'} + .tooltip=${getTooltipWithShortcut('Link', '@')} + @click=${() => { + this._onHandleLinkButtonClick(); + }} + > + ${LinkIcon} + </edgeless-tool-icon-button> + + <edgeless-tool-icon-button + .activeMode=${'background'} + .tooltip=${'File'} + @click=${async () => { + const file = await openFileOrFiles(); + if (!file) return; + await addAttachments(this.edgeless.std, [file]); + this.edgeless.gfx.tool.setTool('default'); + this.edgeless.std + .getOptional(TelemetryProvider) + ?.track('CanvasElementAdded', { + control: 'toolbar:general', + page: 'whiteboard editor', + module: 'toolbar', + segment: 'toolbar', + type: 'attachment', + }); + }} + > + ${AttachmentIcon} + </edgeless-tool-icon-button> + </div> + + <div class="divider"></div> + + <!-- add to note --> + <div class="button-group-container"> + ${repeat( + NOTE_MENU_ITEMS, + item => item.childFlavour, + item => html` + <edgeless-tool-icon-button + .active=${childType === item.childType} + .activeMode=${'background'} + .tooltip=${item.tooltip} + @click=${() => + this.onChange({ + childFlavour: item.childFlavour, + childType: item.childType, + tip: item.tooltip, + })} + > + ${item.icon} + </edgeless-tool-icon-button> + ` + )} + </div> + </div> + </edgeless-slide-menu> + `; + } + + @state() + private accessor _imageLoading = false; + + @property({ attribute: false }) + accessor childFlavour!: NoteChildrenFlavour; + + @property({ attribute: false }) + accessor childType!: string | null; + + @property({ attribute: false }) + accessor onChange!: ( + props: Partial<{ + childFlavour: NoteToolOption['childFlavour']; + childType: string | null; + tip: string; + }> + ) => void; + + @property({ attribute: false }) + accessor tip!: string; +} + +declare global { + interface HTMLElementTagNameMap { + 'edgeless-note-menu': EdgelessNoteMenu; + } +} diff --git a/blocksuite/blocks/src/root-block/edgeless/components/toolbar/note/note-senior-button.ts b/blocksuite/blocks/src/root-block/edgeless/components/toolbar/note/note-senior-button.ts new file mode 100644 index 0000000000..98356b4dd4 --- /dev/null +++ b/blocksuite/blocks/src/root-block/edgeless/components/toolbar/note/note-senior-button.ts @@ -0,0 +1,215 @@ +import { + Heading1Icon, + LinkIcon, + TextIcon, +} from '@blocksuite/affine-components/icons'; +import { + EditPropsStore, + ThemeProvider, +} from '@blocksuite/affine-shared/services'; +import { SignalWatcher } from '@blocksuite/global/utils'; +import { computed } from '@preact/signals-core'; +import { css, html, LitElement } from 'lit'; +import { state } from 'lit/decorators.js'; + +import type { NoteToolOption } from '../../../gfx-tool/note-tool.js'; +import { getTooltipWithShortcut } from '../../utils.js'; +import { EdgelessToolbarToolMixin } from '../mixins/tool.mixin.js'; +import { toShapeNotToAdapt } from './icon.js'; + +export class EdgelessNoteSeniorButton extends EdgelessToolbarToolMixin( + SignalWatcher(LitElement) +) { + static override styles = css` + :host, + .edgeless-note-button { + display: block; + width: 100%; + height: 100%; + } + :host * { + box-sizing: border-box; + } + + .note-root[data-app-theme='light'] { + --paper-border-color: var(--affine-pure-white); + --paper-foriegn-color: rgba(0, 0, 0, 0.1); + --paper-shadow: 0px 2px 4px rgba(0, 0, 0, 0.25); + --icon-card-bg: #fff; + --icon-card-shadow: 0px 2px 4px rgba(0, 0, 0, 0.22), + inset 0px -2px 1px rgba(0, 0, 0, 0.14); + } + .note-root[data-app-theme='dark'] { + --paper-border-color: var(--affine-divider-color); + --paper-foriegn-color: rgba(255, 255, 255, 0.12); + --paper-shadow: 0px 2px 6px rgba(0, 0, 0, 0.8); + --icon-card-bg: #343434; + --icon-card-shadow: 0px 2px 4px rgba(0, 0, 0, 0.6), + inset 0px -2px 1px rgba(255, 255, 255, 0.06); + } + + .note-root { + width: 100%; + height: 64px; + background: transparent; + position: relative; + overflow: hidden; + cursor: pointer; + display: flex; + align-items: flex-end; + justify-content: center; + } + .paper { + --y: 20px; + --r: 4.42deg; + width: 60px; + height: 72px; + background: var(--paper-bg); + border: 1px solid var(--paper-border-color); + position: absolute; + transform: translateY(var(--y)) rotate(var(--r)); + color: var(--paper-foriegn-color); + box-shadow: var(--paper-shadow); + padding-top: 32px; + padding-left: 3px; + transition: transform 0.4s ease; + } + .edgeless-toolbar-note-icon { + position: absolute; + width: 26px; + height: 26px; + border-radius: 2px; + display: flex; + align-items: center; + justify-content: center; + color: var(--affine-icon-secondary); + background: var(--icon-card-bg); + box-shadow: var(--icon-card-shadow); + bottom: 12px; + transition: transform 0.4s ease; + transform: translateX(var(--x)) translateY(var(--y)) rotate(var(--r)); + } + .edgeless-toolbar-note-icon.link { + --x: -22px; + --y: -5px; + --r: -6deg; + transform-origin: 0% 100%; + } + .edgeless-toolbar-note-icon.text { + --r: 4deg; + --x: 0px; + --y: 0px; + } + .edgeless-toolbar-note-icon.heading { + --x: 21px; + --y: -7px; + --r: 8deg; + transform-origin: 0% 100%; + } + + .note-root:hover .paper { + --y: 15px; + } + .note-root:hover .link { + --x: -25px; + --y: -5px; + --r: -9.5deg; + } + .note-root:hover .text { + --y: -10px; + } + .note-root:hover .heading { + --x: 23px; + --y: -8px; + --r: 15deg; + } + `; + + private _noteBg$ = computed(() => { + return this.edgeless.std + .get(ThemeProvider) + .generateColorProperty( + this.edgeless.std.get(EditPropsStore).lastProps$.value['affine:note'] + .background + ); + }); + + private _states = ['childFlavour', 'childType', 'tip'] as const; + + override enableActiveBackground = true; + + override type = 'affine:note' as const; + + private _toggleNoteMenu() { + if (this.tryDisposePopper()) return; + + const { edgeless, childFlavour, childType, tip } = this; + + this.setEdgelessTool({ + type: 'affine:note', + childFlavour, + childType, + tip, + }); + const menu = this.createPopper('edgeless-note-menu', this); + + Object.assign(menu.element, { + edgeless, + childFlavour, + childType, + tip, + onChange: ( + props: Partial<{ + childFlavour: NoteToolOption['childFlavour']; + childType: string | null; + tip: string; + }> + ) => { + this._states.forEach(key => { + // eslint-disable-next-line eqeqeq + if (props[key] != undefined) { + Object.assign(this, { [key]: props[key] }); + } + }); + this.setEdgelessTool({ + type: 'affine:note', + childFlavour: this.childFlavour, + childType: this.childType, + tip: this.tip, + }); + }, + }); + } + + override render() { + const appTheme = this.edgeless.std.get(ThemeProvider).app$.value; + + return html`<edgeless-toolbar-button + class="edgeless-note-button" + .tooltip=${this.popper ? '' : getTooltipWithShortcut('Note', 'N')} + .tooltipOffset=${5} + > + <div + class="note-root" + data-app-theme=${appTheme} + @click=${this._toggleNoteMenu} + style="--paper-bg: ${this._noteBg$.value}" + > + <div class="paper">${toShapeNotToAdapt}</div> + <div class="edgeless-toolbar-note-icon link">${LinkIcon}</div> + <div class="edgeless-toolbar-note-icon heading">${Heading1Icon}</div> + <div class="edgeless-toolbar-note-icon text">${TextIcon}</div> + </div> + </edgeless-toolbar-button>`; + } + + // TODO: better to extract these states outside of component? + @state() + accessor childFlavour: NoteToolOption['childFlavour'] = 'affine:paragraph'; + + @state() + accessor childType = 'text'; + + @state() + accessor tip = 'Note'; +} diff --git a/blocksuite/blocks/src/root-block/edgeless/components/toolbar/note/note-tool-button.ts b/blocksuite/blocks/src/root-block/edgeless/components/toolbar/note/note-tool-button.ts new file mode 100644 index 0000000000..a1cc1fe75c --- /dev/null +++ b/blocksuite/blocks/src/root-block/edgeless/components/toolbar/note/note-tool-button.ts @@ -0,0 +1,130 @@ +import { ArrowUpIcon, NoteIcon } from '@blocksuite/affine-components/icons'; +import type { GfxToolsFullOptionValue } from '@blocksuite/block-std/gfx'; +import { effect } from '@preact/signals-core'; +import { css, html, LitElement } from 'lit'; +import { state } from 'lit/decorators.js'; +import { styleMap } from 'lit/directives/style-map.js'; + +import { getTooltipWithShortcut } from '../../../components/utils.js'; +import type { NoteToolOption } from '../../../gfx-tool/note-tool.js'; +import { createPopper, type MenuPopper } from '../common/create-popper.js'; +import { QuickToolMixin } from '../mixins/quick-tool.mixin.js'; +import type { EdgelessNoteMenu } from './note-menu.js'; + +export class EdgelessNoteToolButton extends QuickToolMixin(LitElement) { + static override styles = css` + :host { + display: flex; + } + + .arrow-up-icon { + position: absolute; + top: 4px; + right: 2px; + font-size: 0; + } + `; + + private _noteMenu: MenuPopper<EdgelessNoteMenu> | null = null; + + private _states = ['childFlavour', 'childType', 'tip'] as const; + + override type: GfxToolsFullOptionValue['type'] = 'affine:note'; + + private _disposeMenu() { + this._noteMenu?.dispose(); + this._noteMenu = null; + } + + private _toggleNoteMenu() { + if (this._noteMenu) { + this._disposeMenu(); + this.requestUpdate(); + } else { + this.edgeless.gfx.tool.setTool('affine:note', { + childFlavour: this.childFlavour, + childType: this.childType, + tip: this.tip, + }); + this._noteMenu = createPopper('edgeless-note-menu', this); + + this._noteMenu.element.edgeless = this.edgeless; + this._noteMenu.element.childFlavour = this.childFlavour; + this._noteMenu.element.childType = this.childType; + this._noteMenu.element.tip = this.tip; + this._noteMenu.element.onChange = ( + props: Partial<{ + childFlavour: NoteToolOption['childFlavour']; + childType: string | null; + tip: string; + }> + ) => { + this._states.forEach(key => { + // eslint-disable-next-line eqeqeq + if (props[key] != undefined) { + Object.assign(this, { [key]: props[key] }); + } + }); + this.edgeless.gfx.tool.setTool('affine:note', { + childFlavour: this.childFlavour, + childType: this.childType, + tip: this.tip, + }); + }; + } + } + + override connectedCallback() { + super.connectedCallback(); + this._disposables.add( + effect(() => { + const value = this.edgeless.gfx.tool.currentToolName$.value; + if (value !== 'affine:note') { + this._disposeMenu(); + } + }) + ); + } + + override disconnectedCallback() { + this._disposeMenu(); + super.disconnectedCallback(); + } + + override render() { + const { active } = this; + const arrowColor = active ? 'currentColor' : 'var(--affine-icon-secondary)'; + return html` + <edgeless-tool-icon-button + class="edgeless-note-button" + .tooltip=${this._noteMenu ? '' : getTooltipWithShortcut('Note', 'N')} + .tooltipOffset=${17} + .active=${active} + .iconContainerPadding=${6} + @click=${() => { + this._toggleNoteMenu(); + }} + > + ${NoteIcon} + <span class="arrow-up-icon" style=${styleMap({ color: arrowColor })}> + ${ArrowUpIcon} + </span> + </edgeless-tool-icon-button> + `; + } + + @state() + accessor childFlavour: NoteToolOption['childFlavour'] = 'affine:paragraph'; + + @state() + accessor childType = 'text'; + + @state() + accessor tip = 'Text'; +} + +declare global { + interface HTMLElementTagNameMap { + 'edgeless-note-tool-button': EdgelessNoteToolButton; + } +} diff --git a/blocksuite/blocks/src/root-block/edgeless/components/toolbar/present/frame-order-button.ts b/blocksuite/blocks/src/root-block/edgeless/components/toolbar/present/frame-order-button.ts new file mode 100644 index 0000000000..8427c34e84 --- /dev/null +++ b/blocksuite/blocks/src/root-block/edgeless/components/toolbar/present/frame-order-button.ts @@ -0,0 +1,83 @@ +import { FrameOrderAdjustmentIcon } from '@blocksuite/affine-components/icons'; +import type { FrameBlockModel } from '@blocksuite/affine-model'; +import { createButtonPopper } from '@blocksuite/affine-shared/utils'; +import { WithDisposable } from '@blocksuite/global/utils'; +import { css, html, LitElement } from 'lit'; +import { property, query } from 'lit/decorators.js'; + +import type { EdgelessRootBlockComponent } from '../../../edgeless-root-block.js'; +import type { EdgelessFrameOrderMenu } from './frame-order-menu.js'; + +export class EdgelessFrameOrderButton extends WithDisposable(LitElement) { + static override styles = css` + edgeless-frame-order-menu { + display: none; + } + + edgeless-frame-order-menu[data-show] { + display: initial; + } + `; + + private _edgelessFrameOrderPopper: ReturnType< + typeof createButtonPopper + > | null = null; + + override disconnectedCallback() { + super.disconnectedCallback(); + this._edgelessFrameOrderPopper?.dispose(); + } + + override firstUpdated() { + this._edgelessFrameOrderPopper = createButtonPopper( + this._edgelessFrameOrderButton, + this._edgelessFrameOrderMenu, + ({ display }) => this.setPopperShow(display === 'show'), + { + mainAxis: 22, + } + ); + } + + protected override render() { + const { readonly } = this.edgeless.doc; + return html` + <style> + .edgeless-frame-order-button svg { + color: ${readonly ? 'var(--affine-text-disable-color)' : 'inherit'}; + } + </style> + <edgeless-tool-icon-button + class="edgeless-frame-order-button" + .tooltip=${this.popperShow ? '' : 'Frame Order'} + @click=${() => { + if (readonly) return; + this._edgelessFrameOrderPopper?.toggle(); + }} + .iconContainerPadding=${0} + > + ${FrameOrderAdjustmentIcon} + </edgeless-tool-icon-button> + <edgeless-frame-order-menu .edgeless=${this.edgeless}> + </edgeless-frame-order-menu> + `; + } + + @query('.edgeless-frame-order-button') + private accessor _edgelessFrameOrderButton!: HTMLElement; + + @query('edgeless-frame-order-menu') + private accessor _edgelessFrameOrderMenu!: EdgelessFrameOrderMenu; + + @property({ attribute: false }) + accessor edgeless!: EdgelessRootBlockComponent; + + @property({ attribute: false }) + accessor frames!: FrameBlockModel[]; + + @property({ attribute: false }) + accessor popperShow = false; + + @property({ attribute: false }) + accessor setPopperShow: (show: boolean) => void = () => {}; +} diff --git a/blocksuite/blocks/src/root-block/edgeless/components/toolbar/present/frame-order-menu.ts b/blocksuite/blocks/src/root-block/edgeless/components/toolbar/present/frame-order-menu.ts new file mode 100644 index 0000000000..69cc7eebeb --- /dev/null +++ b/blocksuite/blocks/src/root-block/edgeless/components/toolbar/present/frame-order-menu.ts @@ -0,0 +1,263 @@ +import { generateKeyBetweenV2 } from '@blocksuite/block-std/gfx'; +import { + DisposableGroup, + SignalWatcher, + WithDisposable, +} from '@blocksuite/global/utils'; +import { css, html, LitElement, nothing } from 'lit'; +import { property, query, state } from 'lit/decorators.js'; +import { repeat } from 'lit/directives/repeat.js'; + +import type { EdgelessRootBlockComponent } from '../../../edgeless-root-block.js'; + +export class EdgelessFrameOrderMenu extends SignalWatcher( + WithDisposable(LitElement) +) { + static override styles = css` + :host { + position: relative; + } + .edgeless-frame-order-items-container { + max-height: 281px; + border-radius: 8px; + padding: 8px; + background: var(--affine-background-overlay-panel-color); + box-shadow: var(--affine-menu-shadow); + overflow: auto; + display: flex; + flex-direction: column; + gap: 4px; + } + .edgeless-frame-order-items-container.embed { + padding: 0; + background: unset; + box-shadow: unset; + border-radius: 0; + } + + .item { + box-sizing: border-box; + width: 256px; + border-radius: 4px; + padding: 4px; + display: flex; + gap: 4px; + align-items: center; + cursor: grab; + } + + .draggable:hover { + background-color: var(--affine-hover-color); + } + + .item:hover .drag-indicator { + opacity: 1; + } + + .drag-indicator { + cursor: pointer; + width: 4px; + height: 12px; + border-radius: 1px; + opacity: 0.2; + background: var(--affine-placeholder-color); + margin-right: 2px; + } + + .title { + font-size: 14px; + font-weight: 400; + height: 22px; + line-height: 22px; + color: var(--affine-text-primary-color); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + + .clone { + visibility: hidden; + position: absolute; + z-index: 1; + left: 8px; + height: 30px; + border: 1px solid var(--affine-border-color); + box-shadow: var(--affine-menu-shadow); + background-color: var(--affine-white); + pointer-events: none; + } + + .indicator-line { + visibility: hidden; + position: absolute; + z-index: 1; + left: 8px; + background-color: var(--affine-primary-color); + height: 1px; + width: 90%; + } + `; + + private get _frames() { + return this.edgeless.service.frames; + } + + private _bindEvent() { + const { _disposables } = this; + + _disposables.addFromEvent(this._container, 'wheel', e => { + e.stopPropagation(); + }); + + _disposables.addFromEvent(this._container, 'pointerdown', e => { + const ele = e.target as HTMLElement; + const draggable = ele.closest('.draggable'); + if (!draggable) return; + const clone = this._clone; + const indicatorLine = this._indicatorLine; + clone.style.visibility = 'visible'; + + const rect = draggable.getBoundingClientRect(); + + const index = Number(draggable.getAttribute('index')); + this._curIndex = index; + let newIndex = -1; + + const containerRect = this._container.getBoundingClientRect(); + const start = containerRect.top + 8; + const end = containerRect.bottom; + + const shiftX = e.clientX - rect.left; + const shiftY = e.clientY - rect.top; + function moveAt(x: number, y: number) { + clone.style.left = x - containerRect.left - shiftX + 'px'; + clone.style.top = y - containerRect.top - shiftY + 'px'; + } + + function isInsideContainer(e: PointerEvent) { + return e.clientY >= start && e.clientY <= end; + } + moveAt(e.clientX, e.clientY); + + this._disposables.addFromEvent(document, 'pointermove', e => { + indicatorLine.style.visibility = 'visible'; + moveAt(e.clientX, e.clientY); + if (isInsideContainer(e)) { + const relativeY = e.pageY + this._container.scrollTop - start; + let top = 0; + if (relativeY < rect.height / 2) { + newIndex = 0; + top = this.embed ? -2 : 4; + } else { + newIndex = Math.ceil( + (relativeY - rect.height / 2) / (rect.height + 10) + ); + top = + (this.embed ? -2 : 7.5) + + newIndex * rect.height + + (newIndex - 0.5) * 4; + } + + indicatorLine.style.top = top - this._container.scrollTop + 'px'; + return; + } + newIndex = -1; + }); + + this._disposables.addFromEvent(document, 'pointerup', () => { + clone.style.visibility = 'hidden'; + indicatorLine.style.visibility = 'hidden'; + if ( + newIndex >= 0 && + newIndex <= this._frames.length && + newIndex !== index && + newIndex !== index + 1 + ) { + const frameMgr = this.edgeless.service.frame; + // Legacy compatibility + frameMgr.refreshLegacyFrameOrder(); + + const before = this._frames[newIndex - 1]?.presentationIndex || null; + const after = this._frames[newIndex]?.presentationIndex || null; + + const frame = this._frames[index]; + + this.edgeless.service.updateElement(frame.id, { + presentationIndex: generateKeyBetweenV2(before, after), + }); + this.edgeless.doc.captureSync(); + + this.requestUpdate(); + } + this._disposables.dispose(); + this._disposables = new DisposableGroup(); + this._bindEvent(); + }); + }); + } + + override disconnectedCallback(): void { + super.disconnectedCallback(); + this._disposables.dispose(); + } + + override firstUpdated() { + this._bindEvent(); + } + + override render() { + const frame = this._frames[this._curIndex]; + + return html` + <div + class="edgeless-frame-order-items-container ${this.embed + ? 'embed' + : ''}" + @click=${(e: MouseEvent) => e.stopPropagation()} + > + ${repeat( + this._frames, + frame => frame.id, + (frame, index) => html` + <div class="item draggable" id=${frame.id} index=${index}> + <div class="drag-indicator"></div> + <div class="title">${frame.title.toString()}</div> + </div> + ` + )} + <div class="indicator-line"></div> + <div class="clone item"> + ${frame + ? html`<div class="drag-indicator"></div> + <div class="index">${this._curIndex + 1}</div> + <div class="title">${frame.title.toString()}</div>` + : nothing} + </div> + </div> + `; + } + + @query('.clone') + private accessor _clone!: HTMLDivElement; + + @query('.edgeless-frame-order-items-container') + private accessor _container!: HTMLDivElement; + + @state() + private accessor _curIndex = -1; + + @query('.indicator-line') + private accessor _indicatorLine!: HTMLDivElement; + + @property({ attribute: false }) + accessor edgeless!: EdgelessRootBlockComponent; + + @property({ attribute: false }) + accessor embed = false; +} + +declare global { + interface HTMLElementTagNameMap { + 'edgeless-frame-order-menu': EdgelessFrameOrderMenu; + } +} diff --git a/blocksuite/blocks/src/root-block/edgeless/components/toolbar/present/navigator-setting-button.ts b/blocksuite/blocks/src/root-block/edgeless/components/toolbar/present/navigator-setting-button.ts new file mode 100644 index 0000000000..2cb5365dde --- /dev/null +++ b/blocksuite/blocks/src/root-block/edgeless/components/toolbar/present/navigator-setting-button.ts @@ -0,0 +1,202 @@ +import { NavigatorSettingsIcon } from '@blocksuite/affine-components/icons'; +import { EditPropsStore } from '@blocksuite/affine-shared/services'; +import { createButtonPopper } from '@blocksuite/affine-shared/utils'; +import { WithDisposable } from '@blocksuite/global/utils'; +import { css, html, LitElement, nothing } from 'lit'; +import { property, query, state } from 'lit/decorators.js'; + +import type { EdgelessRootBlockComponent } from '../../../edgeless-root-block.js'; + +export class EdgelessNavigatorSettingButton extends WithDisposable(LitElement) { + static override styles = css` + .navigator-setting-menu { + display: none; + padding: 8px; + border-radius: 8px; + font-size: 12px; + font-weight: 500; + background-color: var(--affine-background-overlay-panel-color); + box-shadow: var(--affine-menu-shadow); + color: var(--affine-text-primary-color); + } + + .navigator-setting-menu[data-show] { + display: flex; + flex-direction: column; + gap: 4px; + } + + .item-container { + padding: 4px 12px; + display: flex; + justify-content: space-between; + align-items: center; + min-width: 264px; + width: 100%; + box-sizing: border-box; + } + .item-container.header { + height: 34px; + } + + .text { + padding: 0px 4px; + line-height: 22px; + font-size: var(--affine-font-sm); + color: var(--affine-text-primary-color); + } + + .text.title { + font-weight: 500; + line-height: 20px; + font-size: var(--affine-font-xs); + color: var(--affine-text-secondary-color); + } + + .divider { + width: 100%; + height: 16px; + display: flex; + align-items: center; + } + .divider::before { + content: ''; + width: 100%; + height: 1px; + background: var(--affine-border-color); + } + `; + + private _navigatorSettingPopper?: ReturnType< + typeof createButtonPopper + > | null = null; + + private _onBlackBackgroundChange = (checked: boolean) => { + this.blackBackground = checked; + this.edgeless.slots.navigatorSettingUpdated.emit({ + blackBackground: this.blackBackground, + }); + }; + + private _tryRestoreSettings() { + const blackBackground = this.edgeless.std + .get(EditPropsStore) + .getStorage('presentBlackBackground'); + this.blackBackground = blackBackground ?? true; + } + + override connectedCallback() { + super.connectedCallback(); + this._tryRestoreSettings(); + } + + override disconnectedCallback(): void { + this._navigatorSettingPopper?.dispose(); + this._navigatorSettingPopper = null; + } + + override firstUpdated() { + this._navigatorSettingPopper = createButtonPopper( + this._navigatorSettingButton, + this._navigatorSettingMenu, + ({ display }) => this.setPopperShow(display === 'show'), + { + mainAxis: 22, + } + ); + } + + override render() { + return html` + <edgeless-tool-icon-button + class="navigator-setting-button" + .tooltip=${this.popperShow ? '' : 'Settings'} + @click=${() => { + this._navigatorSettingPopper?.toggle(); + }} + .iconContainerPadding=${0} + > + ${NavigatorSettingsIcon} + </edgeless-tool-icon-button> + + <div + class="navigator-setting-menu" + @click=${(e: MouseEvent) => { + e.stopPropagation(); + }} + > + <div class="item-container header"> + <div class="text title">Playback Settings</div> + </div> + + <div class="item-container"> + <div class="text">Black background</div> + + <toggle-switch + .on=${this.blackBackground} + .onChange=${this._onBlackBackgroundChange} + > + </toggle-switch> + </div> + + <div class="item-container"> + <div class="text">Hide toolbar</div> + + <toggle-switch + .on=${this.hideToolbar} + .onChange=${(checked: boolean) => { + this.onHideToolbarChange && this.onHideToolbarChange(checked); + }} + > + </toggle-switch> + </div> + + ${this.includeFrameOrder + ? html` <div class="divider"></div> + <div class="item-container header"> + <div class="text title">Frame Order</div> + </div> + + <edgeless-frame-order-menu + .edgeless=${this.edgeless} + .embed=${true} + ></edgeless-frame-order-menu>` + : nothing} + </div> + `; + } + + @query('.navigator-setting-button') + private accessor _navigatorSettingButton!: HTMLElement; + + @query('.navigator-setting-menu') + private accessor _navigatorSettingMenu!: HTMLElement; + + @state() + accessor blackBackground = true; + + @property({ attribute: false }) + accessor edgeless!: EdgelessRootBlockComponent; + + @property({ attribute: false }) + accessor hideToolbar = false; + + @property({ attribute: false }) + accessor includeFrameOrder = false; + + @property({ attribute: false }) + accessor onHideToolbarChange: undefined | ((hideToolbar: boolean) => void) = + undefined; + + @property({ attribute: false }) + accessor popperShow = false; + + @property({ attribute: false }) + accessor setPopperShow: (show: boolean) => void = () => {}; +} + +declare global { + interface HTMLElementTagNameMap { + 'edgeless-navigator-setting-button': EdgelessNavigatorSettingButton; + } +} diff --git a/blocksuite/blocks/src/root-block/edgeless/components/toolbar/present/present-button.ts b/blocksuite/blocks/src/root-block/edgeless/components/toolbar/present/present-button.ts new file mode 100644 index 0000000000..0e18a14d6e --- /dev/null +++ b/blocksuite/blocks/src/root-block/edgeless/components/toolbar/present/present-button.ts @@ -0,0 +1,51 @@ +import { FrameNavigatorIcon } from '@blocksuite/affine-components/icons'; +import type { GfxToolsFullOptionValue } from '@blocksuite/block-std/gfx'; +import { css, html, LitElement } from 'lit'; + +import { QuickToolMixin } from '../mixins/quick-tool.mixin.js'; +import { EdgelessToolbarToolMixin } from '../mixins/tool.mixin.js'; + +export class EdgelessPresentButton extends QuickToolMixin( + EdgelessToolbarToolMixin(LitElement) +) { + static override styles = css` + :host { + display: flex; + } + .edgeless-note-button { + display: flex; + position: relative; + } + .arrow-up-icon { + position: absolute; + top: 4px; + right: 2px; + font-size: 0; + } + `; + + override type: GfxToolsFullOptionValue['type'] = 'frameNavigator'; + + override render() { + return html`<edgeless-tool-icon-button + class="edgeless-frame-navigator-button" + .tooltip=${'Present'} + .tooltipOffset=${17} + .iconContainerPadding=${6} + @click=${() => { + this.setEdgelessTool({ + type: 'frameNavigator', + }); + }} + > + ${FrameNavigatorIcon} + </edgeless-tool-icon-button> + </div>`; + } +} + +declare global { + interface HTMLElementTagNameMap { + 'edgeless-present-button': EdgelessPresentButton; + } +} diff --git a/blocksuite/blocks/src/root-block/edgeless/components/toolbar/presentation-toolbar.ts b/blocksuite/blocks/src/root-block/edgeless/components/toolbar/presentation-toolbar.ts new file mode 100644 index 0000000000..74a658c83d --- /dev/null +++ b/blocksuite/blocks/src/root-block/edgeless/components/toolbar/presentation-toolbar.ts @@ -0,0 +1,454 @@ +import { CommonUtils } from '@blocksuite/affine-block-surface'; +import { + FrameNavigatorNextIcon, + FrameNavigatorPrevIcon, + NavigatorExitFullScreenIcon, + NavigatorFullScreenIcon, + StopAIIcon, +} from '@blocksuite/affine-components/icons'; +import { toast } from '@blocksuite/affine-components/toast'; +import type { FrameBlockModel } from '@blocksuite/affine-model'; +import { EditPropsStore } from '@blocksuite/affine-shared/services'; +import type { GfxToolsFullOptionValue } from '@blocksuite/block-std/gfx'; +import { Bound, SignalWatcher } from '@blocksuite/global/utils'; +import { effect } from '@preact/signals-core'; +import { cssVar } from '@toeverything/theme'; +import { css, html, LitElement, nothing, type PropertyValues } from 'lit'; +import { property, state } from 'lit/decorators.js'; + +import type { NavigatorMode } from '../../../../_common/edgeless/frame/consts.js'; +import type { EdgelessRootBlockComponent } from '../../edgeless-root-block.js'; +import { isFrameBlock } from '../../utils/query.js'; +import { launchIntoFullscreen } from '../utils.js'; +import { EdgelessToolbarToolMixin } from './mixins/tool.mixin.js'; + +const { clamp } = CommonUtils; + +export class PresentationToolbar extends EdgelessToolbarToolMixin( + SignalWatcher(LitElement) +) { + static override styles = css` + :host { + align-items: inherit; + width: 100%; + height: 100%; + gap: 8px; + padding-right: 2px; + } + .full-divider { + width: 8px; + height: 100%; + display: flex; + align-items: center; + justify-content: center; + } + .full-divider::after { + content: ''; + width: 1px; + height: 100%; + background: var(--affine-border-color); + transform: scaleX(0.5); + } + .config-buttons { + display: flex; + gap: 10px; + } + .edgeless-frame-navigator { + width: 140px; + display: flex; + align-items: center; + justify-content: center; + } + .edgeless-frame-navigator.dense { + width: auto; + } + + .edgeless-frame-navigator-title { + display: inline-block; + cursor: pointer; + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; + padding-right: 8px; + } + + .edgeless-frame-navigator-count { + color: var(--affine-text-secondary-color); + white-space: nowrap; + } + .edgeless-frame-navigator-stop { + border: none; + cursor: pointer; + padding: 4px; + border-radius: 8px; + position: relative; + overflow: hidden; + + svg { + display: block; + } + } + .edgeless-frame-navigator-stop::before { + content: ''; + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + background: transparent; + border-radius: inherit; + } + .edgeless-frame-navigator-stop:hover::before { + background: var(--affine-hover-color); + } + `; + + private _cachedIndex = -1; + + private _timer?: ReturnType<typeof setTimeout>; + + override type: GfxToolsFullOptionValue['type'] = 'frameNavigator'; + + private get _cachedPresentHideToolbar() { + return !!this.edgeless.std + .get(EditPropsStore) + .getStorage('presentHideToolbar'); + } + + private set _cachedPresentHideToolbar(value) { + this.edgeless.std + .get(EditPropsStore) + .setStorage('presentHideToolbar', !!value); + } + + private get _frames(): FrameBlockModel[] { + return this.edgeless.service.frames; + } + + get dense() { + return this.containerWidth < 554; + } + + get host() { + return this.edgeless.host; + } + + constructor(edgeless: EdgelessRootBlockComponent) { + super(); + this.edgeless = edgeless; + } + + private _bindHotKey() { + const handleKeyIfFrameNavigator = (action: () => void) => () => { + if (this.edgelessTool.type === 'frameNavigator') { + action(); + } + }; + + this.edgeless.bindHotKey( + { + ArrowLeft: handleKeyIfFrameNavigator(() => this._previousFrame()), + ArrowRight: handleKeyIfFrameNavigator(() => this._nextFrame()), + Escape: handleKeyIfFrameNavigator(() => this._exitPresentation()), + }, + { + global: true, + } + ); + } + + private _exitPresentation() { + // When exit presentation mode, we need to set the tool to default or pan + // And exit fullscreen + this.setEdgelessTool( + this.edgeless.doc.readonly + ? { type: 'pan', panning: false } + : { type: 'default' } + ); + + if (document.fullscreenElement) { + document.exitFullscreen().catch(console.error); + } + } + + private _moveToCurrentFrame() { + const current = this._currentFrameIndex; + const viewport = this.edgeless.service.viewport; + const frame = this._frames[current]; + + if (frame) { + let bound = Bound.deserialize(frame.xywh); + + if (this._navigatorMode === 'fill') { + const vb = viewport.viewportBounds; + const center = bound.center; + let w, h; + if (vb.w / vb.h > bound.w / bound.h) { + w = bound.w; + h = (w * vb.h) / vb.w; + } else { + h = bound.h; + w = (h * vb.w) / vb.h; + } + bound = Bound.fromCenter(center, w, h); + } + + viewport.setViewportByBound(bound, [0, 0, 0, 0], false); + this.edgeless.slots.navigatorFrameChanged.emit( + this._frames[this._currentFrameIndex] + ); + } + } + + private _nextFrame() { + const frames = this._frames; + const min = 0; + const max = frames.length - 1; + if (this._currentFrameIndex === frames.length - 1) { + toast(this.host, 'You have reached the last frame'); + } else { + this._currentFrameIndex = clamp(this._currentFrameIndex + 1, min, max); + } + } + + private _previousFrame() { + const frames = this._frames; + const min = 0; + const max = frames.length - 1; + if (this._currentFrameIndex === 0) { + toast(this.host, 'You have reached the first frame'); + } else { + this._currentFrameIndex = clamp(this._currentFrameIndex - 1, min, max); + } + } + + /** + * Toggle fullscreen, but keep edgeless tool to frameNavigator + * If already fullscreen, exit fullscreen + * If not fullscreen, enter fullscreen + */ + private _toggleFullScreen() { + if (document.fullscreenElement) { + document.exitFullscreen().catch(console.error); + this._fullScreenMode = false; + } else { + launchIntoFullscreen(this.edgeless.viewportElement); + this._fullScreenMode = true; + } + } + + override connectedCallback(): void { + super.connectedCallback(); + + const { _disposables, edgeless } = this; + + _disposables.add( + effect(() => { + const currentTool = this.edgeless.gfx.tool.currentToolOption$.value; + + if (currentTool?.type === 'frameNavigator') { + this._cachedIndex = this._currentFrameIndex; + this._navigatorMode = currentTool.mode ?? this._navigatorMode; + if (isFrameBlock(edgeless.service.selection.selectedElements[0])) { + this._cachedIndex = this._frames.findIndex( + frame => + frame.id === edgeless.service.selection.selectedElements[0].id + ); + } + if (this._frames.length === 0) + toast( + this.host, + 'The presentation requires at least 1 frame. You can firstly create a frame.', + 5000 + ); + this._toggleFullScreen(); + } + + this.requestUpdate(); + }) + ); + } + + override firstUpdated() { + const { _disposables, edgeless } = this; + + this._bindHotKey(); + + _disposables.add( + edgeless.slots.navigatorSettingUpdated.on(({ fillScreen }) => { + if (fillScreen !== undefined) { + this._navigatorMode = fillScreen ? 'fill' : 'fit'; + } + }) + ); + + _disposables.addFromEvent(document, 'fullscreenchange', () => { + if (document.fullscreenElement) { + // When enter fullscreen, we need to set current frame to the cached index + this._timer = setTimeout(() => { + this._currentFrameIndex = this._cachedIndex; + }, 400); + } else { + // When exit fullscreen, we need to clear the timer + clearTimeout(this._timer); + if ( + this.edgelessTool.type === 'frameNavigator' && + this._fullScreenMode + ) { + this.setEdgelessTool( + this.edgeless.doc.readonly + ? { type: 'pan', panning: false } + : { type: 'default' } + ); + } + } + + setTimeout(() => this._moveToCurrentFrame(), 400); + this.edgeless.slots.fullScreenToggled.emit(); + }); + + this._navigatorMode = + this.edgeless.std.get(EditPropsStore).getStorage('presentFillScreen') === + true + ? 'fill' + : 'fit'; + } + + override render() { + const current = this._currentFrameIndex; + const frames = this._frames; + const frame = frames[current]; + + return html` + <style> + :host { + display: ${this.visible ? 'flex' : 'none'}; + } + </style> + <edgeless-tool-icon-button + .iconContainerPadding=${0} + .tooltip=${'Previous'} + @click=${() => this._previousFrame()} + > + ${FrameNavigatorPrevIcon} + </edgeless-tool-icon-button> + + <div class="edgeless-frame-navigator ${this.dense ? 'dense' : ''}"> + ${this.dense + ? nothing + : html`<span + style="color: ${cssVar('textPrimaryColor')}" + class="edgeless-frame-navigator-title" + @click=${() => this._moveToCurrentFrame()} + > + ${frame?.title ?? 'no frame'} + </span>`} + + <span class="edgeless-frame-navigator-count"> + ${frames.length === 0 ? 0 : current + 1} / ${frames.length} + </span> + </div> + + <edgeless-tool-icon-button + .tooltip=${'Next'} + @click=${() => this._nextFrame()} + .iconContainerPadding=${0} + > + ${FrameNavigatorNextIcon} + </edgeless-tool-icon-button> + + <div class="full-divider"></div> + + <div class="config-buttons"> + <edgeless-tool-icon-button + .tooltip=${document.fullscreenElement + ? 'Exit Full Screen' + : 'Enter Full Screen'} + @click=${() => this._toggleFullScreen()} + .iconContainerPadding=${0} + .iconContainerWidth=${'24px'} + > + ${document.fullscreenElement + ? NavigatorExitFullScreenIcon + : NavigatorFullScreenIcon} + </edgeless-tool-icon-button> + + ${this.dense + ? nothing + : html`<edgeless-frame-order-button + .popperShow=${this.frameMenuShow} + .setPopperShow=${this.setFrameMenuShow} + .edgeless=${this.edgeless} + > + </edgeless-frame-order-button>`} + + <edgeless-navigator-setting-button + .edgeless=${this.edgeless} + .hideToolbar=${this._cachedPresentHideToolbar} + .onHideToolbarChange=${(hideToolbar: boolean) => { + this._cachedPresentHideToolbar = hideToolbar; + }} + .popperShow=${this.settingMenuShow} + .setPopperShow=${this.setSettingMenuShow} + .includeFrameOrder=${this.dense} + > + </edgeless-navigator-setting-button> + </div> + + <div class="full-divider"></div> + + <button + class="edgeless-frame-navigator-stop" + @click=${this._exitPresentation} + style="background: ${cssVar('warningColor')}" + > + ${StopAIIcon} + </button> + `; + } + + protected override updated(changedProperties: PropertyValues) { + if ( + changedProperties.has('_currentFrameIndex') && + this.edgelessTool.type === 'frameNavigator' + ) { + this._moveToCurrentFrame(); + } + } + + @state({ + hasChanged() { + return true; + }, + }) + private accessor _currentFrameIndex = 0; + + private accessor _fullScreenMode = true; + + @state() + private accessor _navigatorMode: NavigatorMode = 'fit'; + + @property({ attribute: false }) + accessor containerWidth = 1920; + + @property({ type: Boolean }) + accessor frameMenuShow = false; + + @property() + accessor setFrameMenuShow: (show: boolean) => void = () => {}; + + @property() + accessor setSettingMenuShow: (show: boolean) => void = () => {}; + + @property({ type: Boolean }) + accessor settingMenuShow = false; + + @property({ attribute: true, type: Boolean }) + accessor visible = true; +} + +declare global { + interface HTMLElementTagNameMap { + 'presentation-toolbar': PresentationToolbar; + } +} diff --git a/blocksuite/blocks/src/root-block/edgeless/components/toolbar/shape/shape-draggable.ts b/blocksuite/blocks/src/root-block/edgeless/components/toolbar/shape/shape-draggable.ts new file mode 100644 index 0000000000..46a86a3f64 --- /dev/null +++ b/blocksuite/blocks/src/root-block/edgeless/components/toolbar/shape/shape-draggable.ts @@ -0,0 +1,352 @@ +import { CanvasElementType } from '@blocksuite/affine-block-surface'; +import { + ellipseSvg, + roundedSvg, + triangleSvg, +} from '@blocksuite/affine-components/icons'; +import { + getShapeRadius, + getShapeType, + ShapeType, +} from '@blocksuite/affine-model'; +import { + EditPropsStore, + TelemetryProvider, + ThemeProvider, +} from '@blocksuite/affine-shared/services'; +import { assertExists, SignalWatcher } from '@blocksuite/global/utils'; +import { css, html, LitElement, nothing } from 'lit'; +import { property, query, state } from 'lit/decorators.js'; +import { classMap } from 'lit/directives/class-map.js'; +import { repeat } from 'lit/directives/repeat.js'; +import { styleMap } from 'lit/directives/style-map.js'; + +import { ShapeTool } from '../../../gfx-tool/shape-tool.js'; +import { EdgelessDraggableElementController } from '../common/draggable/draggable-element.controller.js'; +import { EdgelessToolbarToolMixin } from '../mixins/tool.mixin.js'; +import type { DraggableShape } from './utils.js'; +import { buildVariablesObject } from './utils.js'; + +const shapes: DraggableShape[] = []; +// to move shapes together +const oy = -2; +const ox = 0; +shapes.push({ + name: 'roundedRect', + svg: roundedSvg, + style: { + default: { x: -9, y: 6 }, + hover: { y: -5, z: 1 }, + next: { y: 60 }, + }, +}); +shapes.push({ + name: ShapeType.Ellipse, + svg: ellipseSvg, + style: { + default: { x: -20, y: 31 }, + hover: { y: 15, z: 1 }, + next: { y: 64 }, + }, +}); +shapes.push({ + name: ShapeType.Triangle, + svg: triangleSvg, + style: { + default: { x: 18, y: 25 }, + hover: { y: 7, z: 1 }, + next: { y: 64 }, + }, +}); +shapes.forEach(s => { + Object.values(s.style).forEach(style => { + if (style.y) (style.y as number) += oy; + if (style.x) (style.x as number) += ox; + }); +}); + +export class EdgelessToolbarShapeDraggable extends EdgelessToolbarToolMixin( + SignalWatcher(LitElement) +) { + static override styles = css` + :host { + display: flex; + justify-content: center; + align-items: flex-end; + } + .edgeless-shape-draggable { + /* avoid shadow clipping */ + --shadow-safe-area: 10px; + box-sizing: border-box; + flex-shrink: 0; + width: calc(100% + 2 * var(--shadow-safe-area)); + height: calc(100% + var(--shadow-safe-area)); + padding-top: var(--shadow-safe-area); + padding-left: var(--shadow-safe-area); + padding-right: var(--shadow-safe-area); + overflow: hidden; + display: flex; + align-items: center; + justify-content: center; + position: relative; + pointer-events: none; + } + + .shape { + width: fit-content; + height: fit-content; + position: absolute; + transition: + transform 0.3s, + z-index 0.1s; + transform: translateX(var(--default-x, 0)) translateY(var(--default-y, 0)) + scale(var(--default-s, 1)); + z-index: var(--default-z, 0); + pointer-events: none; + } + .shape svg { + display: block; + } + .shape svg path, + .shape svg circle, + .shape svg rect { + pointer-events: auto; + cursor: grab; + } + .shape:hover, + .shape.cancel { + transform: translateX(var(--hover-x, 0)) translateY(var(--hover-y, 0)) + scale(var(--hover-s, 1)); + z-index: var(--hover-z, 0); + } + .shape.next { + transition: all 0.5s cubic-bezier(0.39, 0.28, 0.09, 0.95); + pointer-events: none; + transform: translateX(var(--next-x, 0)) translateY(var(--next-y, 0)) + scale(var(--next-s, 1)); + } + .shape.next.coming { + transform: translateX(var(--default-x, 0)) translateY(var(--default-y, 0)) + scale(var(--default-s, 1)); + } + `; + + draggableController!: EdgelessDraggableElementController<DraggableShape>; + + draggingShape: DraggableShape['name'] = 'roundedRect'; + + override type = 'shape' as const; + + get shapeShadow() { + return this.theme === 'dark' + ? '0 0 7px rgba(0, 0, 0, .22)' + : '0 0 5px rgba(0, 0, 0, .2)'; + } + + private _setShapeOverlayLock(lock: boolean) { + const controller = this.edgeless.gfx.tool.currentTool$.peek(); + if (controller instanceof ShapeTool) { + controller.setDisableOverlay(lock); + } + } + + initDragController() { + if (!this.edgeless || !this.toolbarContainer) return; + if (this.draggableController) return; + this.draggableController = new EdgelessDraggableElementController(this, { + service: this.edgeless.service, + edgeless: this.edgeless, + scopeElement: this.toolbarContainer, + standardWidth: 100, + clickToDrag: true, + onOverlayCreated: (overlay, element) => { + const shapeName = + this.draggableController.states.draggingElement?.data.name; + if (!shapeName) return; + + this.setEdgelessTool({ + type: 'shape', + shapeName, + }); + const controller = this.edgeless.gfx.tool.currentTool$.peek(); + if (controller instanceof ShapeTool) { + controller.clearOverlay(); + } + overlay.element.style.filter = `drop-shadow(${this.shapeShadow})`; + this.readyToDrop = true; + this.draggingShape = element.data.name; + }, + onDrop: (el, bound) => { + const xywh = bound.serialize(); + const shape = el.data; + const id = this.edgeless.service.addElement(CanvasElementType.SHAPE, { + shapeType: getShapeType(shape.name), + xywh, + radius: getShapeRadius(shape.name), + }); + + this.edgeless.std + .getOptional(TelemetryProvider) + ?.track('CanvasElementAdded', { + control: 'toolbar:dnd', + page: 'whiteboard editor', + module: 'toolbar', + segment: 'toolbar', + type: 'shape', + other: { + shapeType: getShapeType(shape.name), + }, + }); + + this._setShapeOverlayLock(false); + this.readyToDrop = false; + + this.edgeless.gfx.tool.setTool('default'); + this.edgeless.gfx.selection.set({ + elements: [id], + editing: false, + }); + }, + onCanceled: () => { + this._setShapeOverlayLock(false); + this.readyToDrop = false; + }, + onElementClick: el => { + this.onShapeClick?.(el.data); + this._setShapeOverlayLock(true); + }, + onEnterOrLeaveScope: (overlay, isOutside) => { + overlay.element.style.filter = isOutside + ? 'none' + : `drop-shadow(${this.shapeShadow})`; + }, + }); + + this.edgeless.bindHotKey( + { + s: ctx => { + // `page.keyboard.press('Shift+s')` in playwright will also trigger this 's' key event + if (ctx.get('keyboardState').raw.shiftKey) return; + + const service = this.edgeless.service; + if (service.locked || service.selection.editing) return; + + if (this.readyToDrop) { + const activeIndex = shapes.findIndex( + s => s.name === this.draggingShape + ); + const nextIndex = (activeIndex + 1) % shapes.length; + const next = shapes[nextIndex]; + this.draggingShape = next.name; + + this.draggableController.cancelWithoutAnimation(); + } + + const el = this.shapeContainer.querySelector( + `.shape.${this.draggingShape}` + ) as HTMLElement; + assertExists(el, 'Edgeless toolbar Shape element not found'); + const { x, y } = service.gfx.tool.lastMousePos$.peek(); + const { left, top } = this.edgeless.viewport; + const clientPos = { x: x + left, y: y + top }; + this.draggableController.clickToDrag(el, clientPos); + }, + }, + { global: true } + ); + } + + override render() { + const { cancelled, dragOut, draggingElement } = + this.draggableController?.states || {}; + const draggingShape = draggingElement?.data; + return html`<div class="edgeless-shape-draggable"> + ${repeat( + shapes, + s => s.name, + shape => { + const isBeingDragged = draggingShape?.name === shape.name; + const { fillColor, strokeColor } = + this.edgeless.std.get(EditPropsStore).lastProps$.value[ + `shape:${shape.name}` + ] || {}; + const color = this.edgeless.std + .get(ThemeProvider) + .generateColorProperty(fillColor); + const stroke = this.edgeless.std + .get(ThemeProvider) + .generateColorProperty(strokeColor); + const baseStyle = { + ...buildVariablesObject(shape.style), + filter: `drop-shadow(${this.shapeShadow})`, + color, + stroke, + }; + const currStyle = styleMap({ + ...baseStyle, + opacity: isBeingDragged ? 0 : 1, + }); + const nextStyle = styleMap(baseStyle); + return html`${isBeingDragged + ? html`<div + style=${nextStyle} + class=${classMap({ + shape: true, + next: true, + coming: !!dragOut && !cancelled, + })} + > + ${shape.svg} + </div>` + : nothing} + <div + style=${currStyle} + class=${classMap({ + shape: true, + [shape.name]: true, + cancel: isBeingDragged && !dragOut, + })} + @mousedown=${(e: MouseEvent) => + this.draggableController.onMouseDown(e, { + data: shape, + preview: shape.svg, + })} + @touchstart=${(e: TouchEvent) => + this.draggableController.onTouchStart(e, { + data: shape, + preview: shape.svg, + })} + @click=${(e: MouseEvent) => e.stopPropagation()} + > + ${shape.svg} + </div>`; + } + )} + </div>`; + } + + override updated(_changedProperties: Map<PropertyKey, unknown>) { + const controllerRequiredProps = ['edgeless', 'toolbarContainer'] as const; + if ( + controllerRequiredProps.some(p => _changedProperties.has(p)) && + !this.draggableController + ) { + this.initDragController(); + } + } + + @property({ attribute: false }) + accessor onShapeClick: (shape: DraggableShape) => void = () => {}; + + @state() + accessor readyToDrop = false; + + @query('.edgeless-shape-draggable') + accessor shapeContainer!: HTMLDivElement; +} + +declare global { + interface HTMLElementTagNameMap { + 'edgeless-toolbar-shape-draggable': EdgelessToolbarShapeDraggable; + } +} diff --git a/blocksuite/blocks/src/root-block/edgeless/components/toolbar/shape/shape-menu-config.ts b/blocksuite/blocks/src/root-block/edgeless/components/toolbar/shape/shape-menu-config.ts new file mode 100644 index 0000000000..936ad38a0b --- /dev/null +++ b/blocksuite/blocks/src/root-block/edgeless/components/toolbar/shape/shape-menu-config.ts @@ -0,0 +1,73 @@ +import { + DiamondIcon, + EllipseIcon, + RoundedRectangleIcon, + ScribbledDiamondIcon, + ScribbledEllipseIcon, + ScribbledRoundedRectangleIcon, + ScribbledSquareIcon, + ScribbledTriangleIcon, + SquareIcon, + TriangleIcon, +} from '@blocksuite/affine-components/icons'; +import { ShapeType } from '@blocksuite/affine-model'; +import type { TemplateResult } from 'lit'; + +import type { ShapeToolOption } from '../../../gfx-tool/shape-tool.js'; + +type Config = { + name: ShapeToolOption['shapeName']; + generalIcon: TemplateResult<1>; + scribbledIcon: TemplateResult<1>; + tooltip: string; + disabled: boolean; +}; + +export const ShapeComponentConfig: Config[] = [ + { + name: ShapeType.Rect, + generalIcon: SquareIcon, + scribbledIcon: ScribbledSquareIcon, + tooltip: 'Square', + disabled: false, + }, + { + name: ShapeType.Ellipse, + generalIcon: EllipseIcon, + scribbledIcon: ScribbledEllipseIcon, + tooltip: 'Ellipse', + disabled: false, + }, + { + name: ShapeType.Diamond, + generalIcon: DiamondIcon, + scribbledIcon: ScribbledDiamondIcon, + tooltip: 'Diamond', + disabled: false, + }, + { + name: ShapeType.Triangle, + generalIcon: TriangleIcon, + scribbledIcon: ScribbledTriangleIcon, + tooltip: 'Triangle', + disabled: false, + }, + { + name: 'roundedRect', + generalIcon: RoundedRectangleIcon, + scribbledIcon: ScribbledRoundedRectangleIcon, + tooltip: 'Rounded rectangle', + disabled: false, + }, +]; + +export const ShapeComponentConfigMap = ShapeComponentConfig.reduce( + (acc, config) => { + acc[config.name] = config; + return acc; + }, + {} as Record<Config['name'], Config> +); + +export const SHAPE_COLOR_PREFIX = '--affine-palette-shape-'; +export const LINE_COLOR_PREFIX = '--affine-palette-line-'; diff --git a/blocksuite/blocks/src/root-block/edgeless/components/toolbar/shape/shape-menu.ts b/blocksuite/blocks/src/root-block/edgeless/components/toolbar/shape/shape-menu.ts new file mode 100644 index 0000000000..f4a74136f1 --- /dev/null +++ b/blocksuite/blocks/src/root-block/edgeless/components/toolbar/shape/shape-menu.ts @@ -0,0 +1,201 @@ +import { + GeneralStyleIcon, + ScribbledStyleIcon, +} from '@blocksuite/affine-components/icons'; +import { + DEFAULT_SHAPE_FILL_COLOR, + LineColor, + SHAPE_FILL_COLORS, + type ShapeFillColor, + type ShapeName, + ShapeStyle, + ShapeType, +} from '@blocksuite/affine-model'; +import { + EditPropsStore, + ThemeProvider, +} from '@blocksuite/affine-shared/services'; +import { SignalWatcher, WithDisposable } from '@blocksuite/global/utils'; +import type { Signal } from '@preact/signals-core'; +import { computed, effect, signal } from '@preact/signals-core'; +import { css, html, LitElement } from 'lit'; +import { property } from 'lit/decorators.js'; + +import type { EdgelessRootBlockComponent } from '../../../edgeless-root-block.js'; +import { type ColorEvent, isTransparent } from '../../panel/color-panel.js'; +import { + LINE_COLOR_PREFIX, + SHAPE_COLOR_PREFIX, + ShapeComponentConfig, +} from './shape-menu-config.js'; + +export class EdgelessShapeMenu extends SignalWatcher( + WithDisposable(LitElement) +) { + static override styles = css` + :host { + display: flex; + z-index: -1; + } + .menu-content { + display: flex; + align-items: center; + } + .shape-type-container, + .shape-style-container { + display: flex; + align-items: center; + justify-content: center; + gap: 14px; + } + .shape-type-container svg, + .shape-style-container svg { + fill: var(--affine-icon-color); + stroke: none; + } + menu-divider { + height: 24px; + margin: 0 9px; + } + `; + + private _shapeName$: Signal<ShapeName> = signal(ShapeType.Rect); + + @property({ attribute: false }) + accessor edgeless!: EdgelessRootBlockComponent; + + private _props$ = computed(() => { + const shapeName: ShapeName = this._shapeName$.value; + const { shapeStyle, fillColor, strokeColor, radius } = + this.edgeless.std.get(EditPropsStore).lastProps$.value[ + `shape:${shapeName}` + ]; + return { + shapeStyle, + shapeName, + fillColor, + strokeColor, + radius, + }; + }); + + private _setFillColor = (fillColor: ShapeFillColor) => { + const filled = !isTransparent(fillColor); + let strokeColor = fillColor.replace( + SHAPE_COLOR_PREFIX, + LINE_COLOR_PREFIX + ) as LineColor; + + if (strokeColor.endsWith('transparent')) { + strokeColor = LineColor.Grey; + } + + const { shapeName } = this._props$.value; + this.edgeless.std + .get(EditPropsStore) + .recordLastProps(`shape:${shapeName}`, { + filled, + fillColor, + strokeColor, + }); + this.onChange(shapeName); + }; + + private _setShapeStyle = (shapeStyle: ShapeStyle) => { + const { shapeName } = this._props$.value; + this.edgeless.std + .get(EditPropsStore) + .recordLastProps(`shape:${shapeName}`, { + shapeStyle, + }); + this.onChange(shapeName); + }; + + override connectedCallback(): void { + super.connectedCallback(); + + this._disposables.add( + effect(() => { + const value = this.edgeless.gfx.tool.currentToolOption$.value; + + if (value && value.type === 'shape') { + this._shapeName$.value = value.shapeName; + } + }) + ); + } + + override render() { + const { fillColor, shapeStyle, shapeName } = this._props$.value; + const color = this.edgeless.std + .get(ThemeProvider) + .getColorValue(fillColor, DEFAULT_SHAPE_FILL_COLOR); + + return html` + <edgeless-slide-menu> + <div class="menu-content"> + <div class="shape-style-container"> + <edgeless-tool-icon-button + .tooltip=${'General'} + .active=${shapeStyle === ShapeStyle.General} + .activeMode=${'background'} + @click=${() => { + this._setShapeStyle(ShapeStyle.General); + }} + > + ${GeneralStyleIcon} + </edgeless-tool-icon-button> + <edgeless-tool-icon-button + .tooltip=${'Scribbled'} + .active=${shapeStyle === ShapeStyle.Scribbled} + .activeMode=${'background'} + @click=${() => { + this._setShapeStyle(ShapeStyle.Scribbled); + }} + > + ${ScribbledStyleIcon} + </edgeless-tool-icon-button> + </div> + <menu-divider .vertical=${true}></menu-divider> + <div class="shape-type-container"> + ${ShapeComponentConfig.map( + ({ name, generalIcon, scribbledIcon, tooltip }) => { + return html` + <edgeless-tool-icon-button + .tooltip=${tooltip} + .active=${shapeName === name} + .activeMode=${'background'} + @click=${() => this.onChange(name)} + > + ${shapeStyle === ShapeStyle.General + ? generalIcon + : scribbledIcon} + </edgeless-tool-icon-button> + `; + } + )} + </div> + <menu-divider .vertical=${true}></menu-divider> + <edgeless-one-row-color-panel + .value=${color} + .options=${SHAPE_FILL_COLORS} + .hasTransparent=${!this.edgeless.doc.awarenessStore.getFlag( + 'enable_color_picker' + )} + @select=${(e: ColorEvent) => + this._setFillColor(e.detail as ShapeFillColor)} + ></edgeless-one-row-color-panel> + </div> + </edgeless-slide-menu> + `; + } + + @property({ attribute: false }) + accessor onChange!: (name: ShapeName) => void; +} + +declare global { + interface HTMLElementTagNameMap { + 'edgeless-shape-menu': EdgelessShapeMenu; + } +} diff --git a/blocksuite/blocks/src/root-block/edgeless/components/toolbar/shape/shape-tool-button.ts b/blocksuite/blocks/src/root-block/edgeless/components/toolbar/shape/shape-tool-button.ts new file mode 100644 index 0000000000..be79d5e3e6 --- /dev/null +++ b/blocksuite/blocks/src/root-block/edgeless/components/toolbar/shape/shape-tool-button.ts @@ -0,0 +1,92 @@ +import { type ShapeName, ShapeType } from '@blocksuite/affine-model'; +import { SignalWatcher } from '@blocksuite/global/utils'; +import { css, html, LitElement } from 'lit'; + +import { ShapeTool } from '../../../gfx-tool/shape-tool.js'; +import { getTooltipWithShortcut } from '../../utils.js'; +import { EdgelessToolbarToolMixin } from '../mixins/tool.mixin.js'; +import type { DraggableShape } from './utils.js'; + +export class EdgelessShapeToolButton extends EdgelessToolbarToolMixin( + SignalWatcher(LitElement) +) { + static override styles = css` + :host { + display: block; + width: 100%; + height: 100%; + } + edgeless-toolbar-button, + .shapes { + width: 100%; + height: 64px; + } + `; + + private _handleShapeClick = (shape: DraggableShape) => { + this.setEdgelessTool(this.type, { + shapeName: shape.name, + }); + if (!this.popper) this._toggleMenu(); + }; + + private _handleWrapperClick = () => { + if (this.tryDisposePopper()) return; + + this.setEdgelessTool(this.type, { + shapeName: ShapeType.Rect, + }); + if (!this.popper) this._toggleMenu(); + }; + + override type = 'shape' as const; + + private _toggleMenu() { + this.createPopper('edgeless-shape-menu', this, { + setProps: ele => { + ele.edgeless = this.edgeless; + ele.onChange = (shapeName: ShapeName) => { + this.setEdgelessTool(this.type, { + shapeName, + }); + this._updateOverlay(); + }; + }, + }); + } + + private _updateOverlay() { + const controller = this.edgeless.gfx.tool.currentTool$.peek(); + if (controller instanceof ShapeTool) { + controller.createOverlay(); + } + } + + override render() { + const { active } = this; + + return html` + <edgeless-toolbar-button + class="edgeless-shape-button" + .tooltip=${this.popper ? '' : getTooltipWithShortcut('Shape', 'S')} + .tooltipOffset=${5} + .active=${active} + > + <edgeless-toolbar-shape-draggable + .edgeless=${this.edgeless} + .toolbarContainer=${this.toolbarContainer} + class="shapes" + @click=${this._handleWrapperClick} + .onShapeClick=${this._handleShapeClick} + > + </edgeless-toolbar-shape-draggable> + </edgeless-toolbar-button> + `; + } +} + +declare global { + interface HTMLElementTagNameMap { + 'edgeless-shape-tool-button': EdgelessShapeToolButton; + } +} diff --git a/blocksuite/blocks/src/root-block/edgeless/components/toolbar/shape/shape-tool-element.ts b/blocksuite/blocks/src/root-block/edgeless/components/toolbar/shape/shape-tool-element.ts new file mode 100644 index 0000000000..0b7d0719e9 --- /dev/null +++ b/blocksuite/blocks/src/root-block/edgeless/components/toolbar/shape/shape-tool-element.ts @@ -0,0 +1,317 @@ +import { CanvasElementType } from '@blocksuite/affine-block-surface'; +import { + getShapeRadius, + getShapeType, + type ShapeName, + type ShapeStyle, +} from '@blocksuite/affine-model'; +import { Bound, sleep, WithDisposable } from '@blocksuite/global/utils'; +import { + css, + html, + LitElement, + type PropertyValues, + type TemplateResult, +} from 'lit'; +import { property, query, state } from 'lit/decorators.js'; + +import type { EdgelessRootBlockComponent } from '../../../edgeless-root-block.js'; +import { ShapeTool } from '../../../gfx-tool/shape-tool.js'; + +export interface Shape { + name: ShapeName; + svg: TemplateResult<1>; +} + +interface Coord { + x: number; + y: number; +} + +type TransformMap = Record< + string, + { + x: number; + y: number; + scale: number; + origin: string; + } +>; + +export class EdgelessShapeToolElement extends WithDisposable(LitElement) { + static override styles = css` + .shape { + --x: 0px; + --y: 0px; + --offset-x: 0px; + --offset-y: 0px; + --scale: 1; + transform: translateX(calc(var(--offset-x) + var(--x))) + translateY(calc(var(--y) + var(--offset-y))) scale(var(--scale)); + height: 60px; + width: 60px; + display: flex; + justify-content: center; + align-items: center; + position: absolute; + top: 12px; + left: 16px; + transition: all 0.5s cubic-bezier(0, -0.01, 0.01, 1.01); + } + .shape.dragging { + transition: none; + } + .shape svg { + height: 100%; + filter: drop-shadow(0px 2px 8px rgba(0, 0, 0, 0.15)); + } + `; + + private _addShape = (coord: Coord, padding: Coord) => { + const width = 100; + const height = 100; + const { x: edgelessX, y: edgelessY } = + this.edgeless.getBoundingClientRect(); + const zoom = this.edgeless.service.viewport.zoom; + const [modelX, modelY] = this.edgeless.service.viewport.toModelCoord( + coord.x - edgelessX - width * padding.x * zoom, + coord.y - edgelessY - height * padding.y * zoom + ); + const xywh = new Bound(modelX, modelY, width, height).serialize(); + this.edgeless.service.addElement(CanvasElementType.SHAPE, { + shapeType: getShapeType(this.shape.name), + xywh: xywh, + radius: getShapeRadius(this.shape.name), + }); + }; + + private _onDragEnd = async (coord: Coord) => { + if (this._startCoord.x === coord.x && this._startCoord.y === coord.y) { + this.handleClick(); + this._dragging = false; + return; + } + if (!this._dragging) { + return; + } + this._dragging = false; + this.edgeless.gfx.tool.setTool('default'); + if (this._isOutside) { + const rect = this._shapeElement.getBoundingClientRect(); + this._backupShapeElement.style.setProperty('transition', 'none'); + this._backupShapeElement.style.setProperty('--y', '100px'); + this._shapeElement.style.setProperty('--offset-x', `${0}px`); + this._shapeElement.style.setProperty('--offset-y', `${0}px`); + await sleep(0); + this._shapeElement.classList.remove('dragging'); + this._backupShapeElement.style.removeProperty('transition'); + const padding = { + x: (coord.x - rect.left) / rect.width, + y: (coord.y - rect.top) / rect.height, + }; + this._addShape(coord, padding); + } else { + this._shapeElement.classList.remove('dragging'); + this._shapeElement.style.setProperty('--offset-x', `${0}px`); + this._shapeElement.style.setProperty('--offset-y', `${0}px`); + this._backupShapeElement.style.setProperty('--y', '100px'); + } + }; + + private _onDragMove = (coord: Coord) => { + if (!this._dragging) { + return; + } + const controller = this.edgeless.gfx.tool.currentTool$.peek(); + if (controller instanceof ShapeTool) { + controller.clearOverlay(); + } + const { x, y } = coord; + this._shapeElement.style.setProperty( + '--offset-x', + `${x - this._startCoord.x}px` + ); + this._shapeElement.style.setProperty( + '--offset-y', + `${y - this._startCoord.y}px` + ); + const containerRect = this.getContainerRect(); + const isOut = + y < containerRect.top || + x < containerRect.left || + x > containerRect.right; + if (isOut !== this._isOutside) { + this._backupShapeElement.style.setProperty( + '--y', + isOut ? '5px' : '100px' + ); + this._backupShapeElement.style.setProperty( + '--scale', + isOut ? '1' : '0.9' + ); + } + this._isOutside = isOut; + }; + + private _onDragStart = (coord: Coord) => { + this._startCoord = { x: coord.x, y: coord.y }; + if (this.order !== 1) { + return; + } + this._dragging = true; + this._shapeElement.classList.add('dragging'); + }; + + private _onMouseMove = (event: MouseEvent) => { + if (!this._dragging) { + return; + } + this._onDragMove({ x: event.clientX, y: event.clientY }); + }; + + private _onMouseUp = (event: MouseEvent) => { + this._onDragEnd({ x: event.clientX, y: event.clientY }).catch( + console.error + ); + }; + + private _onTouchEnd = (event: TouchEvent) => { + if (!event.changedTouches.length) return; + + this._onDragEnd({ + // https://developer.mozilla.org/en-US/docs/Web/API/TouchEvent#touchend + x: event.changedTouches[0].clientX, + y: event.changedTouches[0].clientY, + }).catch(console.error); + }; + + private _touchMove = (event: TouchEvent) => { + if (!this._dragging) { + return; + } + this._onDragMove({ + x: event.touches[0].clientX, + y: event.touches[0].clientY, + }); + }; + + private _transformMap: TransformMap = { + z1: { x: 0, y: 5, scale: 1.1, origin: '50% 100%' }, + z2: { x: -15, y: 0, scale: 0.75, origin: '20% 20%' }, + z3: { x: 15, y: 0, scale: 0.75, origin: '80% 20%' }, + hidden: { x: 0, y: 120, scale: 0, origin: '50% 50%' }, + }; + + override connectedCallback() { + super.connectedCallback(); + this._disposables.addFromEvent( + this.edgeless.host, + 'mousemove', + this._onMouseMove + ); + this._disposables.addFromEvent( + this.edgeless.host, + 'touchmove', + this._touchMove + ); + this._disposables.addFromEvent( + this.edgeless.host, + 'mouseup', + this._onMouseUp + ); + this._disposables.addFromEvent( + this.edgeless.host, + 'touchend', + this._onTouchEnd + ); + } + + override render() { + return html` + <div + id="shape-tool-element" + class="shape" + @mousedown=${(event: MouseEvent) => + this._onDragStart({ x: event.clientX, y: event.clientY })} + @touchstart=${(event: TouchEvent) => { + event.preventDefault(); + this._onDragStart({ + x: event.touches[0].clientX, + y: event.touches[0].clientY, + }); + }} + > + ${this.shape.svg} + </div> + ${this.order === 1 + ? html`<div id="backup-shape-element" class="shape"> + ${this.shape.svg} + </div>` + : null} + `; + } + + override updated(changedProperties: PropertyValues<this>) { + if (!changedProperties.has('shape') && !changedProperties.has('order')) { + return; + } + const transform = + this._transformMap[this.order <= 3 ? `z${this.order}` : 'hidden']; + this._shapeElement.style.setProperty('--x', `${transform.x}px`); + this._shapeElement.style.setProperty('--y', `${transform.y}px`); + this._shapeElement.style.setProperty( + '--scale', + String(transform.scale || 1) + ); + this._shapeElement.style.zIndex = String(999 - this.order); + this._shapeElement.style.transformOrigin = transform.origin; + + if (this._backupShapeElement) { + this._backupShapeElement.style.setProperty('--y', '100px'); + this._backupShapeElement.style.setProperty('--scale', '0.9'); + this._backupShapeElement.style.zIndex = '999'; + } + } + + @query('#backup-shape-element') + private accessor _backupShapeElement!: HTMLElement; + + @state() + private accessor _dragging: boolean = false; + + @state() + private accessor _isOutside: boolean = false; + + @query('#shape-tool-element') + private accessor _shapeElement!: HTMLElement; + + @state() + private accessor _startCoord: Coord = { x: -1, y: -1 }; + + @property({ attribute: false }) + accessor edgeless!: EdgelessRootBlockComponent; + + @property({ attribute: false }) + accessor getContainerRect!: () => DOMRect; + + @property({ attribute: false }) + accessor handleClick!: () => void; + + @property({ attribute: false }) + accessor order!: number; + + @property({ attribute: false }) + accessor shape!: Shape; + + @property({ attribute: false }) + accessor shapeStyle!: ShapeStyle; + + @property({ attribute: false }) + accessor shapeType!: ShapeName; +} + +declare global { + interface HTMLElementTagNameMap { + 'edgeless-shape-tool-element': EdgelessShapeToolElement; + } +} diff --git a/blocksuite/blocks/src/root-block/edgeless/components/toolbar/shape/utils.ts b/blocksuite/blocks/src/root-block/edgeless/components/toolbar/shape/utils.ts new file mode 100644 index 0000000000..57307d4283 --- /dev/null +++ b/blocksuite/blocks/src/root-block/edgeless/components/toolbar/shape/utils.ts @@ -0,0 +1,151 @@ +import { render, type TemplateResult } from 'lit'; + +import type { ShapeToolOption } from '../../../gfx-tool/shape-tool.js'; + +type TransformState = { + /** horizental offset base on center */ + x?: number | string; + /** vertical offset base on center */ + y?: number | string; + /** scale */ + s?: number; + /** z-index */ + z?: number; +}; + +export type DraggableShape = { + name: ShapeToolOption['shapeName']; + svg: TemplateResult; + style: { + default?: TransformState; + hover?: TransformState; + /** + * The next shape when previous shape is dragged outside toolbar + */ + next?: TransformState; + }; +}; + +/** + * Helper function to build the CSS variables object for the shape + * @returns + */ +export const buildVariablesObject = (style: DraggableShape['style']) => { + const states: Array<keyof DraggableShape['style']> = [ + 'default', + 'hover', + 'next', + ]; + const variables: Array<keyof TransformState> = ['x', 'y', 's', 'z']; + + const resolveValue = ( + variable: keyof TransformState, + value: string | number + ) => { + if (['x', 'y'].includes(variable)) { + return typeof value === 'number' ? `${value}px` : value; + } + return value; + }; + + return states.reduce((acc, state) => { + return { + ...acc, + ...variables.reduce((acc, variable) => { + const defaultValue = style.default?.[variable]; + const value = style[state]?.[variable] ?? defaultValue; + if (value === undefined) return acc; + return { + ...acc, + [`--${state}-${variable}`]: resolveValue(variable, value), + }; + }, {}), + }; + }, {}); +}; + +// drag helper +export type ShapeDragEvent = { + inputType: 'mouse' | 'touch'; + x: number; + y: number; + el: HTMLElement; + originalEvent: MouseEvent | TouchEvent; +}; + +export const touchResolver = (event: TouchEvent) => + ({ + inputType: 'touch', + x: event.touches[0].clientX, + y: event.touches[0].clientY, + el: event.currentTarget as HTMLElement, + originalEvent: event, + }) satisfies ShapeDragEvent; + +export const mouseResolver = (event: MouseEvent) => + ({ + inputType: 'mouse', + x: event.clientX, + y: event.clientY, + el: event.currentTarget as HTMLElement, + originalEvent: event, + }) satisfies ShapeDragEvent; + +// overlay helper +export const defaultDraggingInfo = { + startPos: { x: 0, y: 0 }, + toolbarRect: {} as DOMRect, + edgelessRect: {} as DOMRect, + shapeRectOriginal: {} as DOMRect, + shapeEl: null as unknown as HTMLElement, + parentToMount: null as unknown as HTMLElement, + moved: false, + shape: null as unknown as DraggableShape, + style: {} as CSSStyleDeclaration, +}; +export type DraggingInfo = typeof defaultDraggingInfo; + +export const createShapeDraggingOverlay = (info: DraggingInfo) => { + const { edgelessRect, parentToMount } = info; + const overlay = document.createElement('div'); + Object.assign(overlay.style, { + position: 'absolute', + top: '0', + left: '0', + width: edgelessRect.width + 'px', + // always clip + // height: toolbarRect.bottom - edgelessRect.top + 'px', + height: edgelessRect.height + 'px', + overflow: 'hidden', + zIndex: '9999', + + // for debug purpose + // background: 'rgba(255, 0, 0, 0.1)', + }); + + const shape = document.createElement('div'); + const shapeScaleWrapper = document.createElement('div'); + Object.assign(shapeScaleWrapper.style, { + transform: 'scale(var(--s, 1))', + transition: 'transform 0.1s', + transformOrigin: 'var(--o, center)', + }); + render(info.shape.svg, shapeScaleWrapper); + Object.assign(shape.style, { + position: 'absolute', + color: info.style.color, + stroke: info.style.stroke, + filter: `var(--shape-filter, ${info.style.filter})`, + transform: 'translate(var(--x, 0), var(--y, 0))', + left: 'var(--left, 0)', + top: 'var(--top, 0)', + cursor: 'grabbing', + transition: 'inherit', + }); + + shape.append(shapeScaleWrapper); + overlay.append(shape); + parentToMount.append(overlay); + + return overlay; +}; diff --git a/blocksuite/blocks/src/root-block/edgeless/components/toolbar/template/builtin-templates.ts b/blocksuite/blocks/src/root-block/edgeless/components/toolbar/template/builtin-templates.ts new file mode 100644 index 0000000000..8697368d15 --- /dev/null +++ b/blocksuite/blocks/src/root-block/edgeless/components/toolbar/template/builtin-templates.ts @@ -0,0 +1,116 @@ +import { keys } from '@blocksuite/global/utils'; + +import type { + Template, + TemplateCategory, + TemplateManager, +} from './template-type.js'; + +export const templates: TemplateCategory[] = []; + +function lcs(text1: string, text2: string) { + const dp: number[][] = Array.from( + { + length: text1.length + 1, + }, + () => Array.from({ length: text2.length + 1 }, () => 0) + ); + + for (let i = 1; i <= text1.length; i++) { + for (let j = 1; j <= text2.length; j++) { + if (text1[i - 1] === text2[j - 1]) { + dp[i][j] = dp[i - 1][j - 1] + 1; + } else { + dp[i][j] = Math.max(dp[i - 1][j], dp[i][j - 1]); + } + } + } + + return dp[text1.length][text2.length]; +} +const extendTemplate: TemplateManager[] = []; + +const flat = <T>(arr: T[][]) => + arr.reduce((pre, current) => { + if (current) { + return pre.concat(current); + } + + return pre; + }, []); + +export const builtInTemplates = { + list: async (category: string) => { + const extendTemplates = flat( + await Promise.all(extendTemplate.map(manager => manager.list(category))) + ); + + // eslint-disable-next-line sonarjs/no-empty-collection + const cate = templates.find(cate => cate.name === category); + if (!cate) return extendTemplates; + + const result: Template[] = + cate.templates instanceof Function + ? await cate.templates() + : await Promise.all( + // @ts-expect-error FIXME: ts error + keys(cate.templates).map(key => cate.templates[key]()) + ); + + return result.concat(extendTemplates); + }, + + categories: async () => { + const extendCates = flat( + await Promise.all(extendTemplate.map(manager => manager.categories())) + ); + + // eslint-disable-next-line sonarjs/no-empty-collection + return templates.map(cate => cate.name).concat(extendCates); + }, + + search: async (keyword: string, cateName?: string) => { + const candidates: Template[] = flat( + await Promise.all( + extendTemplate.map(manager => manager.search(keyword, cateName)) + ) + ); + + keyword = keyword.trim().toLocaleLowerCase(); + + await Promise.all( + // eslint-disable-next-line sonarjs/no-empty-collection + templates.map(async categroy => { + if (cateName && cateName !== categroy.name) { + return; + } + + if (categroy.templates instanceof Function) { + return; + } + + return Promise.all( + keys(categroy.templates).map(async name => { + if ( + lcs(keyword, (name as string).toLocaleLowerCase()) === + keyword.length + ) { + // @ts-expect-error FIXME: ts error + const template = await categroy.templates[name](); + + candidates.push(template); + } + }) + ); + }) + ); + + return candidates; + }, + + extend(manager: TemplateManager) { + if (extendTemplate.includes(manager)) return; + + extendTemplate.push(manager); + }, +} satisfies TemplateManager; diff --git a/blocksuite/blocks/src/root-block/edgeless/components/toolbar/template/icon.ts b/blocksuite/blocks/src/root-block/edgeless/components/toolbar/template/icon.ts new file mode 100644 index 0000000000..25d929631f --- /dev/null +++ b/blocksuite/blocks/src/root-block/edgeless/components/toolbar/template/icon.ts @@ -0,0 +1,1411 @@ +import { html, svg } from 'lit'; + +export const ArrowIcon = html` + <svg + width="24" + height="18" + viewBox="0 0 24 18" + fill="none" + xmlns="http://www.w3.org/2000/svg" + > + <path + id="Polygon 19" + d="M12.6809 16.8585C12.2821 17.4176 11.4514 17.4176 11.0526 16.8585L0.754394 2.41961C0.282281 1.75767 0.755483 0.838941 1.56853 0.838941L22.165 0.838943C22.978 0.838943 23.4512 1.75767 22.9791 2.41961L12.6809 16.8585Z" + fill="currentColor" + /> + </svg> +`; + +export const TemplateCard1 = { + light: svg`<svg width="62" height="40" viewBox="0 0 62 40" fill="none" xmlns="http://www.w3.org/2000/svg"> + <g filter="url(#filter0_d_744_3554)"> + <g clip-path="url(#clip0_744_3554)"> + <rect x="4.76465" y="4.82324" width="52.7059" height="30.8021" rx="3" fill="white"/> + <rect x="7.35547" y="7.66992" width="21.7372" height="11.3655" rx="1" fill="#DFF4E8"/> + <rect x="32.832" y="7.6084" width="21.7372" height="11.3655" rx="1" fill="#DFF4F3"/> + <rect x="7.42578" y="21.8467" width="21.7372" height="11.3655" rx="1" fill="#FFEACA"/> + <rect x="32.8408" y="21.7988" width="21.7372" height="11.3655" rx="1" fill="#FFE1E1"/> + <g filter="url(#filter1_d_744_3554)"> + <rect x="8.49219" y="9.00781" width="4.13581" height="3.08021" fill="#9DD194"/> + <rect x="8.54219" y="9.05781" width="4.03581" height="2.98021" stroke="black" stroke-opacity="0.1" stroke-width="0.1"/> + </g> + <g filter="url(#filter2_d_744_3554)"> + <rect x="8.49219" y="14.0635" width="4.13581" height="3.08021" fill="#9DD194"/> + <rect x="8.54219" y="14.1135" width="4.03581" height="2.98021" stroke="black" stroke-opacity="0.1" stroke-width="0.1"/> + </g> + <g filter="url(#filter3_d_744_3554)"> + <rect x="13.4521" y="9.00781" width="3.72042" height="3.08021" fill="#9DD194"/> + <rect x="13.5021" y="9.05781" width="3.62042" height="2.98021" stroke="black" stroke-opacity="0.1" stroke-width="0.1"/> + </g> + <g filter="url(#filter4_d_744_3554)"> + <rect x="37.6836" y="11.4336" width="3.72042" height="3.08021" fill="#84CFFF"/> + <rect x="37.7336" y="11.4836" width="3.62042" height="2.98021" stroke="black" stroke-opacity="0.1" stroke-width="0.1"/> + </g> + <g filter="url(#filter5_d_744_3554)"> + <rect x="43.2646" y="11.4336" width="3.72042" height="3.08021" fill="#84CFFF"/> + <rect x="43.3146" y="11.4836" width="3.62042" height="2.98021" stroke="black" stroke-opacity="0.1" stroke-width="0.1"/> + </g> + <g filter="url(#filter6_d_744_3554)"> + <rect x="48.8447" y="11.4336" width="3.72042" height="3.08021" fill="#84CFFF"/> + <rect x="48.8947" y="11.4836" width="3.62042" height="2.98021" stroke="black" stroke-opacity="0.1" stroke-width="0.1"/> + </g> + <g filter="url(#filter7_d_744_3554)"> + <rect x="34.2461" y="22.8467" width="3.72042" height="3.08021" fill="#F16F6F"/> + <rect x="34.2961" y="22.8967" width="3.62042" height="2.98021" stroke="black" stroke-opacity="0.1" stroke-width="0.1"/> + </g> + <g filter="url(#filter8_d_744_3554)"> + <rect x="39.2061" y="22.8467" width="3.72042" height="3.08021" fill="#F16F6F"/> + <rect x="39.2561" y="22.8967" width="3.62042" height="2.98021" stroke="black" stroke-opacity="0.1" stroke-width="0.1"/> + </g> + <g filter="url(#filter9_d_744_3554)"> + <rect x="44.167" y="22.8467" width="3.72042" height="3.08021" fill="#F16F6F"/> + <rect x="44.217" y="22.8967" width="3.62042" height="2.98021" stroke="black" stroke-opacity="0.1" stroke-width="0.1"/> + </g> + <g filter="url(#filter10_d_744_3554)"> + <rect x="49.127" y="22.8467" width="3.72042" height="3.08021" fill="#F16F6F"/> + <rect x="49.177" y="22.8967" width="3.62042" height="2.98021" stroke="black" stroke-opacity="0.1" stroke-width="0.1"/> + </g> + <g filter="url(#filter11_d_744_3554)"> + <rect x="49.127" y="26.543" width="3.72042" height="3.08021" fill="#F16F6F"/> + <rect x="49.177" y="26.593" width="3.62042" height="2.98021" stroke="black" stroke-opacity="0.1" stroke-width="0.1"/> + </g> + <g filter="url(#filter12_d_744_3554)"> + <rect x="44.167" y="26.543" width="3.72042" height="3.08021" fill="#F16F6F"/> + <rect x="44.217" y="26.593" width="3.62042" height="2.98021" stroke="black" stroke-opacity="0.1" stroke-width="0.1"/> + </g> + <g filter="url(#filter13_d_744_3554)"> + <rect x="39.2061" y="26.543" width="3.72042" height="3.08021" fill="#F16F6F"/> + <rect x="39.2561" y="26.593" width="3.62042" height="2.98021" stroke="black" stroke-opacity="0.1" stroke-width="0.1"/> + </g> + <g filter="url(#filter14_d_744_3554)"> + <rect x="34.2461" y="26.543" width="3.72042" height="3.08021" fill="#F16F6F"/> + <rect x="34.2961" y="26.593" width="3.62042" height="2.98021" stroke="black" stroke-opacity="0.1" stroke-width="0.1"/> + </g> + <g filter="url(#filter15_d_744_3554)"> + <rect x="18.4131" y="9.00781" width="3.72042" height="3.08021" fill="#9DD194"/> + <rect x="18.4631" y="9.05781" width="3.62042" height="2.98021" stroke="black" stroke-opacity="0.1" stroke-width="0.1"/> + </g> + <line x1="7.8916" y1="20.7553" x2="54.2721" y2="20.7553" stroke="#A7A7A7" stroke-width="0.3"/> + <line x1="30.9918" y1="8.72949" x2="30.9918" y2="32.2467" stroke="#A7A7A7" stroke-width="0.3"/> + <g filter="url(#filter16_d_744_3554)"> + <rect x="8.62012" y="23.1807" width="3.72042" height="3.08021" fill="#FFC46B"/> + <rect x="8.67012" y="23.2307" width="3.62042" height="2.98021" stroke="black" stroke-opacity="0.1" stroke-width="0.1"/> + </g> + <g filter="url(#filter17_d_744_3554)"> + <rect x="13.7139" y="25.6592" width="3.72042" height="3.08021" fill="#FFC46B"/> + <rect x="13.7639" y="25.7092" width="3.62042" height="2.98021" stroke="black" stroke-opacity="0.1" stroke-width="0.1"/> + </g> + <g filter="url(#filter18_d_744_3554)"> + <rect x="18.7031" y="23.9033" width="3.72042" height="3.08021" fill="#FFC46B"/> + <rect x="18.7531" y="23.9533" width="3.62042" height="2.98021" stroke="black" stroke-opacity="0.1" stroke-width="0.1"/> + </g> + <g filter="url(#filter19_d_744_3554)"> + <rect x="20.5752" y="28.8613" width="3.72042" height="3.08021" fill="#FFC46B"/> + <rect x="20.6252" y="28.9113" width="3.62042" height="2.98021" stroke="black" stroke-opacity="0.1" stroke-width="0.1"/> + </g> + </g> + <rect x="5.06465" y="5.12324" width="52.1059" height="30.2021" rx="2.7" stroke="#E3E2E4" stroke-width="0.6"/> + </g> + <defs> + <filter id="filter0_d_744_3554" x="0.764648" y="0.823242" width="60.7061" height="38.8018" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB"> + <feFlood flood-opacity="0" result="BackgroundImageFix"/> + <feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/> + <feOffset/> + <feGaussianBlur stdDeviation="2"/> + <feComposite in2="hardAlpha" operator="out"/> + <feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.12 0"/> + <feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow_744_3554"/> + <feBlend mode="normal" in="SourceGraphic" in2="effect1_dropShadow_744_3554" result="shape"/> + </filter> + <filter id="filter1_d_744_3554" x="-10.6457" y="-10.1301" width="42.4116" height="41.3559" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB"> + <feFlood flood-opacity="0" result="BackgroundImageFix"/> + <feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/> + <feOffset/> + <feGaussianBlur stdDeviation="9.56896"/> + <feComposite in2="hardAlpha" operator="out"/> + <feColorMatrix type="matrix" values="0 0 0 0 0.258824 0 0 0 0 0.254902 0 0 0 0 0.286275 0 0 0 0.18 0"/> + <feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow_744_3554"/> + <feBlend mode="normal" in="SourceGraphic" in2="effect1_dropShadow_744_3554" result="shape"/> + </filter> + <filter id="filter2_d_744_3554" x="-10.6457" y="-5.07445" width="42.4116" height="41.3559" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB"> + <feFlood flood-opacity="0" result="BackgroundImageFix"/> + <feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/> + <feOffset/> + <feGaussianBlur stdDeviation="9.56896"/> + <feComposite in2="hardAlpha" operator="out"/> + <feColorMatrix type="matrix" values="0 0 0 0 0.258824 0 0 0 0 0.254902 0 0 0 0 0.286275 0 0 0 0.18 0"/> + <feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow_744_3554"/> + <feBlend mode="normal" in="SourceGraphic" in2="effect1_dropShadow_744_3554" result="shape"/> + </filter> + <filter id="filter3_d_744_3554" x="-5.68578" y="-10.1301" width="41.9966" height="41.3559" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB"> + <feFlood flood-opacity="0" result="BackgroundImageFix"/> + <feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/> + <feOffset/> + <feGaussianBlur stdDeviation="9.56896"/> + <feComposite in2="hardAlpha" operator="out"/> + <feColorMatrix type="matrix" values="0 0 0 0 0.258824 0 0 0 0 0.254902 0 0 0 0 0.286275 0 0 0 0.18 0"/> + <feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow_744_3554"/> + <feBlend mode="normal" in="SourceGraphic" in2="effect1_dropShadow_744_3554" result="shape"/> + </filter> + <filter id="filter4_d_744_3554" x="18.5457" y="-7.70434" width="41.9966" height="41.3559" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB"> + <feFlood flood-opacity="0" result="BackgroundImageFix"/> + <feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/> + <feOffset/> + <feGaussianBlur stdDeviation="9.56896"/> + <feComposite in2="hardAlpha" operator="out"/> + <feColorMatrix type="matrix" values="0 0 0 0 0.258824 0 0 0 0 0.254902 0 0 0 0 0.286275 0 0 0 0.18 0"/> + <feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow_744_3554"/> + <feBlend mode="normal" in="SourceGraphic" in2="effect1_dropShadow_744_3554" result="shape"/> + </filter> + <filter id="filter5_d_744_3554" x="24.1267" y="-7.70434" width="41.9966" height="41.3559" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB"> + <feFlood flood-opacity="0" result="BackgroundImageFix"/> + <feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/> + <feOffset/> + <feGaussianBlur stdDeviation="9.56896"/> + <feComposite in2="hardAlpha" operator="out"/> + <feColorMatrix type="matrix" values="0 0 0 0 0.258824 0 0 0 0 0.254902 0 0 0 0 0.286275 0 0 0 0.18 0"/> + <feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow_744_3554"/> + <feBlend mode="normal" in="SourceGraphic" in2="effect1_dropShadow_744_3554" result="shape"/> + </filter> + <filter id="filter6_d_744_3554" x="29.7068" y="-7.70434" width="41.9966" height="41.3559" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB"> + <feFlood flood-opacity="0" result="BackgroundImageFix"/> + <feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/> + <feOffset/> + <feGaussianBlur stdDeviation="9.56896"/> + <feComposite in2="hardAlpha" operator="out"/> + <feColorMatrix type="matrix" values="0 0 0 0 0.258824 0 0 0 0 0.254902 0 0 0 0 0.286275 0 0 0 0.18 0"/> + <feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow_744_3554"/> + <feBlend mode="normal" in="SourceGraphic" in2="effect1_dropShadow_744_3554" result="shape"/> + </filter> + <filter id="filter7_d_744_3554" x="15.1082" y="3.70875" width="41.9966" height="41.3559" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB"> + <feFlood flood-opacity="0" result="BackgroundImageFix"/> + <feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/> + <feOffset/> + <feGaussianBlur stdDeviation="9.56896"/> + <feComposite in2="hardAlpha" operator="out"/> + <feColorMatrix type="matrix" values="0 0 0 0 0.258824 0 0 0 0 0.254902 0 0 0 0 0.286275 0 0 0 0.18 0"/> + <feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow_744_3554"/> + <feBlend mode="normal" in="SourceGraphic" in2="effect1_dropShadow_744_3554" result="shape"/> + </filter> + <filter id="filter8_d_744_3554" x="20.0681" y="3.70875" width="41.9966" height="41.3559" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB"> + <feFlood flood-opacity="0" result="BackgroundImageFix"/> + <feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/> + <feOffset/> + <feGaussianBlur stdDeviation="9.56896"/> + <feComposite in2="hardAlpha" operator="out"/> + <feColorMatrix type="matrix" values="0 0 0 0 0.258824 0 0 0 0 0.254902 0 0 0 0 0.286275 0 0 0 0.18 0"/> + <feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow_744_3554"/> + <feBlend mode="normal" in="SourceGraphic" in2="effect1_dropShadow_744_3554" result="shape"/> + </filter> + <filter id="filter9_d_744_3554" x="25.0291" y="3.70875" width="41.9966" height="41.3559" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB"> + <feFlood flood-opacity="0" result="BackgroundImageFix"/> + <feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/> + <feOffset/> + <feGaussianBlur stdDeviation="9.56896"/> + <feComposite in2="hardAlpha" operator="out"/> + <feColorMatrix type="matrix" values="0 0 0 0 0.258824 0 0 0 0 0.254902 0 0 0 0 0.286275 0 0 0 0.18 0"/> + <feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow_744_3554"/> + <feBlend mode="normal" in="SourceGraphic" in2="effect1_dropShadow_744_3554" result="shape"/> + </filter> + <filter id="filter10_d_744_3554" x="29.989" y="3.70875" width="41.9966" height="41.3559" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB"> + <feFlood flood-opacity="0" result="BackgroundImageFix"/> + <feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/> + <feOffset/> + <feGaussianBlur stdDeviation="9.56896"/> + <feComposite in2="hardAlpha" operator="out"/> + <feColorMatrix type="matrix" values="0 0 0 0 0.258824 0 0 0 0 0.254902 0 0 0 0 0.286275 0 0 0 0.18 0"/> + <feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow_744_3554"/> + <feBlend mode="normal" in="SourceGraphic" in2="effect1_dropShadow_744_3554" result="shape"/> + </filter> + <filter id="filter11_d_744_3554" x="29.989" y="7.40504" width="41.9966" height="41.3559" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB"> + <feFlood flood-opacity="0" result="BackgroundImageFix"/> + <feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/> + <feOffset/> + <feGaussianBlur stdDeviation="9.56896"/> + <feComposite in2="hardAlpha" operator="out"/> + <feColorMatrix type="matrix" values="0 0 0 0 0.258824 0 0 0 0 0.254902 0 0 0 0 0.286275 0 0 0 0.18 0"/> + <feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow_744_3554"/> + <feBlend mode="normal" in="SourceGraphic" in2="effect1_dropShadow_744_3554" result="shape"/> + </filter> + <filter id="filter12_d_744_3554" x="25.0291" y="7.40504" width="41.9966" height="41.3559" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB"> + <feFlood flood-opacity="0" result="BackgroundImageFix"/> + <feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/> + <feOffset/> + <feGaussianBlur stdDeviation="9.56896"/> + <feComposite in2="hardAlpha" operator="out"/> + <feColorMatrix type="matrix" values="0 0 0 0 0.258824 0 0 0 0 0.254902 0 0 0 0 0.286275 0 0 0 0.18 0"/> + <feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow_744_3554"/> + <feBlend mode="normal" in="SourceGraphic" in2="effect1_dropShadow_744_3554" result="shape"/> + </filter> + <filter id="filter13_d_744_3554" x="20.0681" y="7.40504" width="41.9966" height="41.3559" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB"> + <feFlood flood-opacity="0" result="BackgroundImageFix"/> + <feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/> + <feOffset/> + <feGaussianBlur stdDeviation="9.56896"/> + <feComposite in2="hardAlpha" operator="out"/> + <feColorMatrix type="matrix" values="0 0 0 0 0.258824 0 0 0 0 0.254902 0 0 0 0 0.286275 0 0 0 0.18 0"/> + <feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow_744_3554"/> + <feBlend mode="normal" in="SourceGraphic" in2="effect1_dropShadow_744_3554" result="shape"/> + </filter> + <filter id="filter14_d_744_3554" x="15.1082" y="7.40504" width="41.9966" height="41.3559" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB"> + <feFlood flood-opacity="0" result="BackgroundImageFix"/> + <feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/> + <feOffset/> + <feGaussianBlur stdDeviation="9.56896"/> + <feComposite in2="hardAlpha" operator="out"/> + <feColorMatrix type="matrix" values="0 0 0 0 0.258824 0 0 0 0 0.254902 0 0 0 0 0.286275 0 0 0 0.18 0"/> + <feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow_744_3554"/> + <feBlend mode="normal" in="SourceGraphic" in2="effect1_dropShadow_744_3554" result="shape"/> + </filter> + <filter id="filter15_d_744_3554" x="-0.724844" y="-10.1301" width="41.9966" height="41.3559" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB"> + <feFlood flood-opacity="0" result="BackgroundImageFix"/> + <feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/> + <feOffset/> + <feGaussianBlur stdDeviation="9.56896"/> + <feComposite in2="hardAlpha" operator="out"/> + <feColorMatrix type="matrix" values="0 0 0 0 0.258824 0 0 0 0 0.254902 0 0 0 0 0.286275 0 0 0 0.18 0"/> + <feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow_744_3554"/> + <feBlend mode="normal" in="SourceGraphic" in2="effect1_dropShadow_744_3554" result="shape"/> + </filter> + <filter id="filter16_d_744_3554" x="-10.5178" y="4.04273" width="41.9966" height="41.3559" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB"> + <feFlood flood-opacity="0" result="BackgroundImageFix"/> + <feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/> + <feOffset/> + <feGaussianBlur stdDeviation="9.56896"/> + <feComposite in2="hardAlpha" operator="out"/> + <feColorMatrix type="matrix" values="0 0 0 0 0.258824 0 0 0 0 0.254902 0 0 0 0 0.286275 0 0 0 0.18 0"/> + <feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow_744_3554"/> + <feBlend mode="normal" in="SourceGraphic" in2="effect1_dropShadow_744_3554" result="shape"/> + </filter> + <filter id="filter17_d_744_3554" x="-5.42406" y="6.52125" width="41.9966" height="41.3559" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB"> + <feFlood flood-opacity="0" result="BackgroundImageFix"/> + <feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/> + <feOffset/> + <feGaussianBlur stdDeviation="9.56896"/> + <feComposite in2="hardAlpha" operator="out"/> + <feColorMatrix type="matrix" values="0 0 0 0 0.258824 0 0 0 0 0.254902 0 0 0 0 0.286275 0 0 0 0.18 0"/> + <feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow_744_3554"/> + <feBlend mode="normal" in="SourceGraphic" in2="effect1_dropShadow_744_3554" result="shape"/> + </filter> + <filter id="filter18_d_744_3554" x="-0.434805" y="4.76539" width="41.9966" height="41.3559" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB"> + <feFlood flood-opacity="0" result="BackgroundImageFix"/> + <feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/> + <feOffset/> + <feGaussianBlur stdDeviation="9.56896"/> + <feComposite in2="hardAlpha" operator="out"/> + <feColorMatrix type="matrix" values="0 0 0 0 0.258824 0 0 0 0 0.254902 0 0 0 0 0.286275 0 0 0 0.18 0"/> + <feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow_744_3554"/> + <feBlend mode="normal" in="SourceGraphic" in2="effect1_dropShadow_744_3554" result="shape"/> + </filter> + <filter id="filter19_d_744_3554" x="1.43727" y="9.7234" width="41.9966" height="41.3559" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB"> + <feFlood flood-opacity="0" result="BackgroundImageFix"/> + <feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/> + <feOffset/> + <feGaussianBlur stdDeviation="9.56896"/> + <feComposite in2="hardAlpha" operator="out"/> + <feColorMatrix type="matrix" values="0 0 0 0 0.258824 0 0 0 0 0.254902 0 0 0 0 0.286275 0 0 0 0.18 0"/> + <feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow_744_3554"/> + <feBlend mode="normal" in="SourceGraphic" in2="effect1_dropShadow_744_3554" result="shape"/> + </filter> + <clipPath id="clip0_744_3554"> + <rect x="4.76465" y="4.82324" width="52.7059" height="30.8021" rx="3" fill="white"/> + </clipPath> + </defs> + </svg> + `, + dark: svg`<svg width="62" height="40" viewBox="0 0 62 40" fill="none" xmlns="http://www.w3.org/2000/svg"> + <g filter="url(#filter0_d_744_3621)"> + <g clip-path="url(#clip0_744_3621)"> + <rect x="4.76465" y="4.82324" width="52.7059" height="30.8021" rx="3" fill="black"/> + <rect x="7.35547" y="7.66992" width="21.7372" height="11.3655" rx="1" fill="#2C6C3F"/> + <rect x="32.832" y="7.6084" width="21.7372" height="11.3655" rx="1" fill="#1A736E"/> + <rect x="7.42578" y="21.8467" width="21.7372" height="11.3655" rx="1" fill="#B9812E"/> + <rect x="32.8408" y="21.7988" width="21.7372" height="11.3655" rx="1" fill="#6F3232"/> + <g filter="url(#filter1_d_744_3621)"> + <rect x="8.49219" y="9.00781" width="4.13581" height="3.08021" fill="#9DD194"/> + <rect x="8.54219" y="9.05781" width="4.03581" height="2.98021" stroke="white" stroke-opacity="0.1" stroke-width="0.1"/> + </g> + <g filter="url(#filter2_d_744_3621)"> + <rect x="8.49219" y="14.0635" width="4.13581" height="3.08021" fill="#9DD194"/> + <rect x="8.54219" y="14.1135" width="4.03581" height="2.98021" stroke="white" stroke-opacity="0.1" stroke-width="0.1"/> + </g> + <g filter="url(#filter3_d_744_3621)"> + <rect x="13.4521" y="9.00781" width="3.72042" height="3.08021" fill="#9DD194"/> + <rect x="13.5021" y="9.05781" width="3.62042" height="2.98021" stroke="white" stroke-opacity="0.1" stroke-width="0.1"/> + </g> + <g filter="url(#filter4_d_744_3621)"> + <rect x="37.6836" y="11.4336" width="3.72042" height="3.08021" fill="#84CFFF"/> + <rect x="37.7336" y="11.4836" width="3.62042" height="2.98021" stroke="white" stroke-opacity="0.1" stroke-width="0.1"/> + </g> + <g filter="url(#filter5_d_744_3621)"> + <rect x="43.2646" y="11.4336" width="3.72042" height="3.08021" fill="#84CFFF"/> + <rect x="43.3146" y="11.4836" width="3.62042" height="2.98021" stroke="white" stroke-opacity="0.1" stroke-width="0.1"/> + </g> + <g filter="url(#filter6_d_744_3621)"> + <rect x="48.8447" y="11.4336" width="3.72042" height="3.08021" fill="#84CFFF"/> + <rect x="48.8947" y="11.4836" width="3.62042" height="2.98021" stroke="white" stroke-opacity="0.1" stroke-width="0.1"/> + </g> + <g filter="url(#filter7_d_744_3621)"> + <rect x="34.2461" y="22.8467" width="3.72042" height="3.08021" fill="#F16F6F"/> + <rect x="34.2961" y="22.8967" width="3.62042" height="2.98021" stroke="white" stroke-opacity="0.1" stroke-width="0.1"/> + </g> + <g filter="url(#filter8_d_744_3621)"> + <rect x="39.2061" y="22.8467" width="3.72042" height="3.08021" fill="#F16F6F"/> + <rect x="39.2561" y="22.8967" width="3.62042" height="2.98021" stroke="white" stroke-opacity="0.1" stroke-width="0.1"/> + </g> + <g filter="url(#filter9_d_744_3621)"> + <rect x="44.167" y="22.8467" width="3.72042" height="3.08021" fill="#F16F6F"/> + <rect x="44.217" y="22.8967" width="3.62042" height="2.98021" stroke="white" stroke-opacity="0.1" stroke-width="0.1"/> + </g> + <g filter="url(#filter10_d_744_3621)"> + <rect x="49.127" y="22.8467" width="3.72042" height="3.08021" fill="#F16F6F"/> + <rect x="49.177" y="22.8967" width="3.62042" height="2.98021" stroke="white" stroke-opacity="0.1" stroke-width="0.1"/> + </g> + <g filter="url(#filter11_d_744_3621)"> + <rect x="49.127" y="26.543" width="3.72042" height="3.08021" fill="#F16F6F"/> + <rect x="49.177" y="26.593" width="3.62042" height="2.98021" stroke="white" stroke-opacity="0.1" stroke-width="0.1"/> + </g> + <g filter="url(#filter12_d_744_3621)"> + <rect x="44.167" y="26.543" width="3.72042" height="3.08021" fill="#F16F6F"/> + <rect x="44.217" y="26.593" width="3.62042" height="2.98021" stroke="white" stroke-opacity="0.1" stroke-width="0.1"/> + </g> + <g filter="url(#filter13_d_744_3621)"> + <rect x="39.2061" y="26.543" width="3.72042" height="3.08021" fill="#F16F6F"/> + <rect x="39.2561" y="26.593" width="3.62042" height="2.98021" stroke="white" stroke-opacity="0.1" stroke-width="0.1"/> + </g> + <g filter="url(#filter14_d_744_3621)"> + <rect x="34.2461" y="26.543" width="3.72042" height="3.08021" fill="#F16F6F"/> + <rect x="34.2961" y="26.593" width="3.62042" height="2.98021" stroke="white" stroke-opacity="0.1" stroke-width="0.1"/> + </g> + <g filter="url(#filter15_d_744_3621)"> + <rect x="18.4131" y="9.00781" width="3.72042" height="3.08021" fill="#9DD194"/> + <rect x="18.4631" y="9.05781" width="3.62042" height="2.98021" stroke="white" stroke-opacity="0.1" stroke-width="0.1"/> + </g> + <line x1="7.8916" y1="20.7553" x2="54.2721" y2="20.7553" stroke="#A7A7A7" stroke-width="0.3"/> + <line x1="30.9918" y1="8.72949" x2="30.9918" y2="32.2467" stroke="#A7A7A7" stroke-width="0.3"/> + <g filter="url(#filter16_d_744_3621)"> + <rect x="8.62012" y="23.1807" width="3.72042" height="3.08021" fill="#FFC46B"/> + <rect x="8.67012" y="23.2307" width="3.62042" height="2.98021" stroke="white" stroke-opacity="0.1" stroke-width="0.1"/> + </g> + <g filter="url(#filter17_d_744_3621)"> + <rect x="13.7139" y="25.6592" width="3.72042" height="3.08021" fill="#FFC46B"/> + <rect x="13.7639" y="25.7092" width="3.62042" height="2.98021" stroke="white" stroke-opacity="0.1" stroke-width="0.1"/> + </g> + <g filter="url(#filter18_d_744_3621)"> + <rect x="18.7031" y="23.9033" width="3.72042" height="3.08021" fill="#FFC46B"/> + <rect x="18.7531" y="23.9533" width="3.62042" height="2.98021" stroke="white" stroke-opacity="0.1" stroke-width="0.1"/> + </g> + <g filter="url(#filter19_d_744_3621)"> + <rect x="20.5752" y="28.8613" width="3.72042" height="3.08021" fill="#FFC46B"/> + <rect x="20.6252" y="28.9113" width="3.62042" height="2.98021" stroke="white" stroke-opacity="0.1" stroke-width="0.1"/> + </g> + </g> + <rect x="5.26465" y="5.32324" width="51.7059" height="29.8021" rx="2.5" stroke="#727272"/> + </g> + <defs> + <filter id="filter0_d_744_3621" x="0.764648" y="0.823242" width="60.7061" height="38.8018" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB"> + <feFlood flood-opacity="0" result="BackgroundImageFix"/> + <feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/> + <feOffset/> + <feGaussianBlur stdDeviation="2"/> + <feComposite in2="hardAlpha" operator="out"/> + <feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.6 0"/> + <feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow_744_3621"/> + <feBlend mode="normal" in="SourceGraphic" in2="effect1_dropShadow_744_3621" result="shape"/> + </filter> + <filter id="filter1_d_744_3621" x="-10.6457" y="-10.1301" width="42.4116" height="41.3559" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB"> + <feFlood flood-opacity="0" result="BackgroundImageFix"/> + <feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/> + <feOffset/> + <feGaussianBlur stdDeviation="9.56896"/> + <feComposite in2="hardAlpha" operator="out"/> + <feColorMatrix type="matrix" values="0 0 0 0 0.258824 0 0 0 0 0.254902 0 0 0 0 0.286275 0 0 0 0.18 0"/> + <feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow_744_3621"/> + <feBlend mode="normal" in="SourceGraphic" in2="effect1_dropShadow_744_3621" result="shape"/> + </filter> + <filter id="filter2_d_744_3621" x="-10.6457" y="-5.07445" width="42.4116" height="41.3559" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB"> + <feFlood flood-opacity="0" result="BackgroundImageFix"/> + <feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/> + <feOffset/> + <feGaussianBlur stdDeviation="9.56896"/> + <feComposite in2="hardAlpha" operator="out"/> + <feColorMatrix type="matrix" values="0 0 0 0 0.258824 0 0 0 0 0.254902 0 0 0 0 0.286275 0 0 0 0.18 0"/> + <feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow_744_3621"/> + <feBlend mode="normal" in="SourceGraphic" in2="effect1_dropShadow_744_3621" result="shape"/> + </filter> + <filter id="filter3_d_744_3621" x="-5.68578" y="-10.1301" width="41.9966" height="41.3559" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB"> + <feFlood flood-opacity="0" result="BackgroundImageFix"/> + <feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/> + <feOffset/> + <feGaussianBlur stdDeviation="9.56896"/> + <feComposite in2="hardAlpha" operator="out"/> + <feColorMatrix type="matrix" values="0 0 0 0 0.258824 0 0 0 0 0.254902 0 0 0 0 0.286275 0 0 0 0.18 0"/> + <feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow_744_3621"/> + <feBlend mode="normal" in="SourceGraphic" in2="effect1_dropShadow_744_3621" result="shape"/> + </filter> + <filter id="filter4_d_744_3621" x="18.5457" y="-7.70434" width="41.9966" height="41.3559" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB"> + <feFlood flood-opacity="0" result="BackgroundImageFix"/> + <feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/> + <feOffset/> + <feGaussianBlur stdDeviation="9.56896"/> + <feComposite in2="hardAlpha" operator="out"/> + <feColorMatrix type="matrix" values="0 0 0 0 0.258824 0 0 0 0 0.254902 0 0 0 0 0.286275 0 0 0 0.18 0"/> + <feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow_744_3621"/> + <feBlend mode="normal" in="SourceGraphic" in2="effect1_dropShadow_744_3621" result="shape"/> + </filter> + <filter id="filter5_d_744_3621" x="24.1267" y="-7.70434" width="41.9966" height="41.3559" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB"> + <feFlood flood-opacity="0" result="BackgroundImageFix"/> + <feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/> + <feOffset/> + <feGaussianBlur stdDeviation="9.56896"/> + <feComposite in2="hardAlpha" operator="out"/> + <feColorMatrix type="matrix" values="0 0 0 0 0.258824 0 0 0 0 0.254902 0 0 0 0 0.286275 0 0 0 0.18 0"/> + <feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow_744_3621"/> + <feBlend mode="normal" in="SourceGraphic" in2="effect1_dropShadow_744_3621" result="shape"/> + </filter> + <filter id="filter6_d_744_3621" x="29.7068" y="-7.70434" width="41.9966" height="41.3559" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB"> + <feFlood flood-opacity="0" result="BackgroundImageFix"/> + <feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/> + <feOffset/> + <feGaussianBlur stdDeviation="9.56896"/> + <feComposite in2="hardAlpha" operator="out"/> + <feColorMatrix type="matrix" values="0 0 0 0 0.258824 0 0 0 0 0.254902 0 0 0 0 0.286275 0 0 0 0.18 0"/> + <feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow_744_3621"/> + <feBlend mode="normal" in="SourceGraphic" in2="effect1_dropShadow_744_3621" result="shape"/> + </filter> + <filter id="filter7_d_744_3621" x="15.1082" y="3.70875" width="41.9966" height="41.3559" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB"> + <feFlood flood-opacity="0" result="BackgroundImageFix"/> + <feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/> + <feOffset/> + <feGaussianBlur stdDeviation="9.56896"/> + <feComposite in2="hardAlpha" operator="out"/> + <feColorMatrix type="matrix" values="0 0 0 0 0.258824 0 0 0 0 0.254902 0 0 0 0 0.286275 0 0 0 0.18 0"/> + <feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow_744_3621"/> + <feBlend mode="normal" in="SourceGraphic" in2="effect1_dropShadow_744_3621" result="shape"/> + </filter> + <filter id="filter8_d_744_3621" x="20.0681" y="3.70875" width="41.9966" height="41.3559" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB"> + <feFlood flood-opacity="0" result="BackgroundImageFix"/> + <feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/> + <feOffset/> + <feGaussianBlur stdDeviation="9.56896"/> + <feComposite in2="hardAlpha" operator="out"/> + <feColorMatrix type="matrix" values="0 0 0 0 0.258824 0 0 0 0 0.254902 0 0 0 0 0.286275 0 0 0 0.18 0"/> + <feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow_744_3621"/> + <feBlend mode="normal" in="SourceGraphic" in2="effect1_dropShadow_744_3621" result="shape"/> + </filter> + <filter id="filter9_d_744_3621" x="25.0291" y="3.70875" width="41.9966" height="41.3559" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB"> + <feFlood flood-opacity="0" result="BackgroundImageFix"/> + <feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/> + <feOffset/> + <feGaussianBlur stdDeviation="9.56896"/> + <feComposite in2="hardAlpha" operator="out"/> + <feColorMatrix type="matrix" values="0 0 0 0 0.258824 0 0 0 0 0.254902 0 0 0 0 0.286275 0 0 0 0.18 0"/> + <feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow_744_3621"/> + <feBlend mode="normal" in="SourceGraphic" in2="effect1_dropShadow_744_3621" result="shape"/> + </filter> + <filter id="filter10_d_744_3621" x="29.989" y="3.70875" width="41.9966" height="41.3559" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB"> + <feFlood flood-opacity="0" result="BackgroundImageFix"/> + <feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/> + <feOffset/> + <feGaussianBlur stdDeviation="9.56896"/> + <feComposite in2="hardAlpha" operator="out"/> + <feColorMatrix type="matrix" values="0 0 0 0 0.258824 0 0 0 0 0.254902 0 0 0 0 0.286275 0 0 0 0.18 0"/> + <feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow_744_3621"/> + <feBlend mode="normal" in="SourceGraphic" in2="effect1_dropShadow_744_3621" result="shape"/> + </filter> + <filter id="filter11_d_744_3621" x="29.989" y="7.40504" width="41.9966" height="41.3559" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB"> + <feFlood flood-opacity="0" result="BackgroundImageFix"/> + <feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/> + <feOffset/> + <feGaussianBlur stdDeviation="9.56896"/> + <feComposite in2="hardAlpha" operator="out"/> + <feColorMatrix type="matrix" values="0 0 0 0 0.258824 0 0 0 0 0.254902 0 0 0 0 0.286275 0 0 0 0.18 0"/> + <feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow_744_3621"/> + <feBlend mode="normal" in="SourceGraphic" in2="effect1_dropShadow_744_3621" result="shape"/> + </filter> + <filter id="filter12_d_744_3621" x="25.0291" y="7.40504" width="41.9966" height="41.3559" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB"> + <feFlood flood-opacity="0" result="BackgroundImageFix"/> + <feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/> + <feOffset/> + <feGaussianBlur stdDeviation="9.56896"/> + <feComposite in2="hardAlpha" operator="out"/> + <feColorMatrix type="matrix" values="0 0 0 0 0.258824 0 0 0 0 0.254902 0 0 0 0 0.286275 0 0 0 0.18 0"/> + <feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow_744_3621"/> + <feBlend mode="normal" in="SourceGraphic" in2="effect1_dropShadow_744_3621" result="shape"/> + </filter> + <filter id="filter13_d_744_3621" x="20.0681" y="7.40504" width="41.9966" height="41.3559" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB"> + <feFlood flood-opacity="0" result="BackgroundImageFix"/> + <feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/> + <feOffset/> + <feGaussianBlur stdDeviation="9.56896"/> + <feComposite in2="hardAlpha" operator="out"/> + <feColorMatrix type="matrix" values="0 0 0 0 0.258824 0 0 0 0 0.254902 0 0 0 0 0.286275 0 0 0 0.18 0"/> + <feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow_744_3621"/> + <feBlend mode="normal" in="SourceGraphic" in2="effect1_dropShadow_744_3621" result="shape"/> + </filter> + <filter id="filter14_d_744_3621" x="15.1082" y="7.40504" width="41.9966" height="41.3559" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB"> + <feFlood flood-opacity="0" result="BackgroundImageFix"/> + <feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/> + <feOffset/> + <feGaussianBlur stdDeviation="9.56896"/> + <feComposite in2="hardAlpha" operator="out"/> + <feColorMatrix type="matrix" values="0 0 0 0 0.258824 0 0 0 0 0.254902 0 0 0 0 0.286275 0 0 0 0.18 0"/> + <feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow_744_3621"/> + <feBlend mode="normal" in="SourceGraphic" in2="effect1_dropShadow_744_3621" result="shape"/> + </filter> + <filter id="filter15_d_744_3621" x="-0.724844" y="-10.1301" width="41.9966" height="41.3559" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB"> + <feFlood flood-opacity="0" result="BackgroundImageFix"/> + <feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/> + <feOffset/> + <feGaussianBlur stdDeviation="9.56896"/> + <feComposite in2="hardAlpha" operator="out"/> + <feColorMatrix type="matrix" values="0 0 0 0 0.258824 0 0 0 0 0.254902 0 0 0 0 0.286275 0 0 0 0.18 0"/> + <feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow_744_3621"/> + <feBlend mode="normal" in="SourceGraphic" in2="effect1_dropShadow_744_3621" result="shape"/> + </filter> + <filter id="filter16_d_744_3621" x="-10.5178" y="4.04273" width="41.9966" height="41.3559" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB"> + <feFlood flood-opacity="0" result="BackgroundImageFix"/> + <feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/> + <feOffset/> + <feGaussianBlur stdDeviation="9.56896"/> + <feComposite in2="hardAlpha" operator="out"/> + <feColorMatrix type="matrix" values="0 0 0 0 0.258824 0 0 0 0 0.254902 0 0 0 0 0.286275 0 0 0 0.18 0"/> + <feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow_744_3621"/> + <feBlend mode="normal" in="SourceGraphic" in2="effect1_dropShadow_744_3621" result="shape"/> + </filter> + <filter id="filter17_d_744_3621" x="-5.42406" y="6.52125" width="41.9966" height="41.3559" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB"> + <feFlood flood-opacity="0" result="BackgroundImageFix"/> + <feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/> + <feOffset/> + <feGaussianBlur stdDeviation="9.56896"/> + <feComposite in2="hardAlpha" operator="out"/> + <feColorMatrix type="matrix" values="0 0 0 0 0.258824 0 0 0 0 0.254902 0 0 0 0 0.286275 0 0 0 0.18 0"/> + <feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow_744_3621"/> + <feBlend mode="normal" in="SourceGraphic" in2="effect1_dropShadow_744_3621" result="shape"/> + </filter> + <filter id="filter18_d_744_3621" x="-0.434805" y="4.76539" width="41.9966" height="41.3559" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB"> + <feFlood flood-opacity="0" result="BackgroundImageFix"/> + <feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/> + <feOffset/> + <feGaussianBlur stdDeviation="9.56896"/> + <feComposite in2="hardAlpha" operator="out"/> + <feColorMatrix type="matrix" values="0 0 0 0 0.258824 0 0 0 0 0.254902 0 0 0 0 0.286275 0 0 0 0.18 0"/> + <feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow_744_3621"/> + <feBlend mode="normal" in="SourceGraphic" in2="effect1_dropShadow_744_3621" result="shape"/> + </filter> + <filter id="filter19_d_744_3621" x="1.43727" y="9.7234" width="41.9966" height="41.3559" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB"> + <feFlood flood-opacity="0" result="BackgroundImageFix"/> + <feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/> + <feOffset/> + <feGaussianBlur stdDeviation="9.56896"/> + <feComposite in2="hardAlpha" operator="out"/> + <feColorMatrix type="matrix" values="0 0 0 0 0.258824 0 0 0 0 0.254902 0 0 0 0 0.286275 0 0 0 0.18 0"/> + <feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow_744_3621"/> + <feBlend mode="normal" in="SourceGraphic" in2="effect1_dropShadow_744_3621" result="shape"/> + </filter> + <clipPath id="clip0_744_3621"> + <rect x="4.76465" y="4.82324" width="52.7059" height="30.8021" rx="3" fill="white"/> + </clipPath> + </defs> + </svg> + `, +}; +export const TemplateCard2 = { + light: svg`<svg width="55" height="36" viewBox="0 0 55 36" fill="none" xmlns="http://www.w3.org/2000/svg"> + <g filter="url(#filter0_d_744_3580)"> + <rect width="46.1176" height="27.128" rx="3" transform="matrix(1 0 0 -1 4.5293 31.2461)" fill="white"/> + <rect x="0.3" y="-0.3" width="45.5176" height="26.528" rx="2.7" transform="matrix(1 0 0 -1 4.5293 30.6461)" stroke="#E3E2E4" stroke-width="0.6"/> + <line y1="-0.109324" x2="3.25536" y2="-0.109324" transform="matrix(1 0 0 -1 13.21 13.8838)" stroke="#6B6B6B" stroke-width="0.218649"/> + <line y1="-0.109324" x2="3.25536" y2="-0.109324" transform="matrix(1 0 0 -1 13.21 13.8838)" stroke="#6B6B6B" stroke-width="0.218649"/> + <path d="M22.4336 14.4268H24.1332V18.8259V20.9374L26.2315 20.9375" stroke="#6B6B6B" stroke-width="0.218649"/> + <path d="M32.7422 20.9373H39.3551V24.1926V25.8203L41.4232 25.8203" stroke="#6B6B6B" stroke-width="0.218649"/> + <path d="M20.2637 14.427H23.9576L23.9579 11.2328V9.54401L26.2318 9.54395" stroke="#6B6B6B" stroke-width="0.218649"/> + <path d="M38.168 20.9377H39.6247V17.6824V16.0547L41.4233 16.0547" stroke="#6B6B6B" stroke-width="0.218649"/> + <rect width="6.51073" height="3.79792" rx="0.728829" transform="matrix(1 0 0 -1 26.2314 11.7139)" fill="#9DD194"/> + <rect x="0.0364415" y="-0.0364415" width="6.43784" height="3.72504" rx="0.692388" transform="matrix(1 0 0 -1 26.2314 11.641)" stroke="black" stroke-opacity="0.1" stroke-width="0.0728829"/> + <rect width="7.05329" height="3.25536" rx="0.728829" transform="matrix(1 0 0 -1 6.15723 16.0547)" fill="#FFDE6B"/> + <rect x="0.0364415" y="-0.0364415" width="6.9804" height="3.18248" rx="0.692388" transform="matrix(1 0 0 -1 6.15723 15.9818)" stroke="black" stroke-opacity="0.1" stroke-width="0.0728829"/> + <rect width="3.37861" height="3.37861" transform="matrix(0.707107 0.707107 0.707107 -0.707107 34.2441 20.8691)" fill="#937EE7"/> + <rect x="0.051536" width="3.30572" height="3.30572" transform="matrix(0.707107 0.707107 0.707107 -0.707107 34.2592 20.8327)" stroke="black" stroke-opacity="0.1" stroke-width="0.0728829"/> + <rect width="3.37861" height="3.37861" transform="matrix(0.707107 0.707107 0.707107 -0.707107 16.1426 14.4902)" fill="#937EE7"/> + <rect x="0.051536" width="3.30572" height="3.30572" transform="matrix(0.707107 0.707107 0.707107 -0.707107 16.1577 14.4538)" stroke="black" stroke-opacity="0.1" stroke-width="0.0728829"/> + <rect width="6.51073" height="3.79792" rx="0.728829" transform="matrix(1 0 0 -1 26.2314 22.5654)" fill="#FFDE6B"/> + <rect x="0.0364415" y="-0.0364415" width="6.43784" height="3.72504" rx="0.692388" transform="matrix(1 0 0 -1 26.2314 22.4925)" stroke="black" stroke-opacity="0.1" stroke-width="0.0728829"/> + <rect width="7.05329" height="3.25536" rx="0.728829" transform="matrix(1 0 0 -1 41.4238 26.9053)" fill="#937EE7"/> + <rect x="0.0364415" y="-0.0364415" width="6.9804" height="3.18248" rx="0.692388" transform="matrix(1 0 0 -1 41.4238 26.8324)" stroke="black" stroke-opacity="0.1" stroke-width="0.0728829"/> + <rect width="7.05329" height="3.79792" rx="0.728829" transform="matrix(1 0 0 -1 41.4238 18.2246)" fill="#937EE7"/> + <rect x="0.0364415" y="-0.0364415" width="6.9804" height="3.72504" rx="0.692388" transform="matrix(1 0 0 -1 41.4238 18.1517)" stroke="black" stroke-opacity="0.1" stroke-width="0.0728829"/> + </g> + <defs> + <filter id="filter0_d_744_3580" x="0.529297" y="0.118164" width="54.1172" height="35.1279" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB"> + <feFlood flood-opacity="0" result="BackgroundImageFix"/> + <feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/> + <feOffset/> + <feGaussianBlur stdDeviation="2"/> + <feComposite in2="hardAlpha" operator="out"/> + <feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.12 0"/> + <feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow_744_3580"/> + <feBlend mode="normal" in="SourceGraphic" in2="effect1_dropShadow_744_3580" result="shape"/> + </filter> + </defs> + </svg> + `, + dark: svg`<svg width="55" height="36" viewBox="0 0 55 36" fill="none" xmlns="http://www.w3.org/2000/svg"> + <g filter="url(#filter0_d_744_3647)"> + <rect width="46.1176" height="27.128" rx="3" transform="matrix(1 0 0 -1 4.5293 31.2461)" fill="#1B1B1B"/> + <rect x="0.5" y="-0.5" width="45.1176" height="26.128" rx="2.5" transform="matrix(1 0 0 -1 4.5293 30.2461)" stroke="#727272"/> + <line y1="-0.109324" x2="3.25536" y2="-0.109324" transform="matrix(1 0 0 -1 13.21 13.8838)" stroke="#6B6B6B" stroke-width="0.218649"/> + <line y1="-0.109324" x2="3.25536" y2="-0.109324" transform="matrix(1 0 0 -1 13.21 13.8838)" stroke="#6B6B6B" stroke-width="0.218649"/> + <path d="M22.4336 14.4268H24.1332V18.8259V20.9374L26.2315 20.9375" stroke="#6B6B6B" stroke-width="0.218649"/> + <path d="M32.7422 20.9373H39.3551V24.1926V25.8203L41.4232 25.8203" stroke="#6B6B6B" stroke-width="0.218649"/> + <path d="M20.2637 14.427H23.9576L23.9579 11.2328V9.54401L26.2318 9.54395" stroke="#6B6B6B" stroke-width="0.218649"/> + <path d="M38.168 20.9377H39.6247V17.6824V16.0547L41.4233 16.0547" stroke="#6B6B6B" stroke-width="0.218649"/> + <rect width="6.51073" height="3.79792" rx="0.728829" transform="matrix(1 0 0 -1 26.2314 11.7139)" fill="#9DD194"/> + <rect x="0.0364415" y="-0.0364415" width="6.43784" height="3.72504" rx="0.692388" transform="matrix(1 0 0 -1 26.2314 11.641)" stroke="white" stroke-opacity="0.1" stroke-width="0.0728829"/> + <rect width="7.05329" height="3.25536" rx="0.728829" transform="matrix(1 0 0 -1 6.15723 16.0547)" fill="#FFD338"/> + <rect x="0.0364415" y="-0.0364415" width="6.9804" height="3.18248" rx="0.692388" transform="matrix(1 0 0 -1 6.15723 15.9818)" stroke="white" stroke-opacity="0.1" stroke-width="0.0728829"/> + <rect width="3.37861" height="3.37861" transform="matrix(0.707107 0.707107 0.707107 -0.707107 34.2441 20.8691)" fill="#937EE7"/> + <rect x="0.051536" width="3.30572" height="3.30572" transform="matrix(0.707107 0.707107 0.707107 -0.707107 34.2592 20.8327)" stroke="white" stroke-opacity="0.1" stroke-width="0.0728829"/> + <rect width="3.37861" height="3.37861" transform="matrix(0.707107 0.707107 0.707107 -0.707107 16.1426 14.4902)" fill="#937EE7"/> + <rect x="0.051536" width="3.30572" height="3.30572" transform="matrix(0.707107 0.707107 0.707107 -0.707107 16.1577 14.4538)" stroke="white" stroke-opacity="0.1" stroke-width="0.0728829"/> + <rect width="6.51073" height="3.79792" rx="0.728829" transform="matrix(1 0 0 -1 26.2314 22.5654)" fill="#FFD338"/> + <rect x="0.0364415" y="-0.0364415" width="6.43784" height="3.72504" rx="0.692388" transform="matrix(1 0 0 -1 26.2314 22.4925)" stroke="white" stroke-opacity="0.1" stroke-width="0.0728829"/> + <rect width="7.05329" height="3.25536" rx="0.728829" transform="matrix(1 0 0 -1 41.4238 26.9053)" fill="#937EE7"/> + <rect x="0.0364415" y="-0.0364415" width="6.9804" height="3.18248" rx="0.692388" transform="matrix(1 0 0 -1 41.4238 26.8324)" stroke="white" stroke-opacity="0.1" stroke-width="0.0728829"/> + <rect width="7.05329" height="3.79792" rx="0.728829" transform="matrix(1 0 0 -1 41.4238 18.2246)" fill="#937EE7"/> + <rect x="0.0364415" y="-0.0364415" width="6.9804" height="3.72504" rx="0.692388" transform="matrix(1 0 0 -1 41.4238 18.1517)" stroke="white" stroke-opacity="0.1" stroke-width="0.0728829"/> + </g> + <defs> + <filter id="filter0_d_744_3647" x="0.529297" y="0.118164" width="54.1172" height="35.1279" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB"> + <feFlood flood-opacity="0" result="BackgroundImageFix"/> + <feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/> + <feOffset/> + <feGaussianBlur stdDeviation="2"/> + <feComposite in2="hardAlpha" operator="out"/> + <feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.6 0"/> + <feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow_744_3647"/> + <feBlend mode="normal" in="SourceGraphic" in2="effect1_dropShadow_744_3647" result="shape"/> + </filter> + </defs> + </svg> + `, +}; +export const TemplateCard3 = { + light: svg`<svg width="80" height="51" viewBox="0 0 80 51" fill="none" xmlns="http://www.w3.org/2000/svg"> + <g filter="url(#filter0_d_744_3594)"> + <g clip-path="url(#clip0_744_3594)"> + <rect x="4.29395" y="4" width="71.5294" height="42.0761" rx="4" fill="white"/> + <rect x="7.66016" y="9.89062" width="5.89066" height="1.68304" rx="0.841522" fill="#FFEACA"/> + <rect x="7.66016" y="13.2568" width="20.1965" height="33.6609" rx="1" fill="#FFEACA"/> + <g filter="url(#filter1_d_744_3594)"> + <rect x="8.50195" y="15.7812" width="5.04914" height="4.20761" fill="#FFDE6B"/> + <rect x="8.55195" y="15.8313" width="4.94914" height="4.10761" stroke="black" stroke-opacity="0.1" stroke-width="0.1"/> + </g> + <g filter="url(#filter2_d_744_3594)"> + <rect x="8.50195" y="22.5137" width="5.04914" height="4.20761" fill="#FFDE6B"/> + <rect x="8.55195" y="22.5637" width="4.94914" height="4.10761" stroke="black" stroke-opacity="0.1" stroke-width="0.1"/> + </g> + <g filter="url(#filter3_d_744_3594)"> + <rect x="8.50195" y="28.4043" width="5.04914" height="4.20761" fill="#FFDE6B"/> + <rect x="8.55195" y="28.4543" width="4.94914" height="4.10761" stroke="black" stroke-opacity="0.1" stroke-width="0.1"/> + </g> + <g filter="url(#filter4_d_744_3594)"> + <rect x="15.2334" y="15.7812" width="5.04914" height="4.20761" fill="#FFDE6B"/> + <rect x="15.2834" y="15.8313" width="4.94914" height="4.10761" stroke="black" stroke-opacity="0.1" stroke-width="0.1"/> + </g> + <g filter="url(#filter5_d_744_3594)"> + <rect x="15.2334" y="22.5137" width="5.04914" height="4.20761" fill="#FFDE6B"/> + <rect x="15.2834" y="22.5637" width="4.94914" height="4.10761" stroke="black" stroke-opacity="0.1" stroke-width="0.1"/> + </g> + <g filter="url(#filter6_d_744_3594)"> + <rect x="15.2334" y="28.4043" width="5.04914" height="4.20761" fill="#9DD194"/> + <rect x="15.2834" y="28.4543" width="4.94914" height="4.10761" stroke="black" stroke-opacity="0.1" stroke-width="0.1"/> + </g> + <g filter="url(#filter7_d_744_3594)"> + <rect x="21.9658" y="15.7812" width="5.04914" height="4.20761" fill="#FFDE6B"/> + <rect x="22.0158" y="15.8313" width="4.94914" height="4.10761" stroke="black" stroke-opacity="0.1" stroke-width="0.1"/> + </g> + <g filter="url(#filter8_d_744_3594)"> + <rect x="21.9658" y="22.5137" width="5.04914" height="4.20761" fill="#FFDE6B"/> + <rect x="22.0158" y="22.5637" width="4.94914" height="4.10761" stroke="black" stroke-opacity="0.1" stroke-width="0.1"/> + </g> + <g filter="url(#filter9_d_744_3594)"> + <rect x="21.9658" y="28.4043" width="5.04914" height="4.20761" fill="#F16F6F"/> + <rect x="22.0158" y="28.4543" width="4.94914" height="4.10761" stroke="black" stroke-opacity="0.1" stroke-width="0.1"/> + </g> + <rect x="30.3809" y="9.89062" width="5.89066" height="1.68304" rx="0.841522" fill="#E1EFFF"/> + <rect x="30.3809" y="13.2568" width="20.1965" height="33.6609" rx="1" fill="#E1EFFF"/> + <g filter="url(#filter10_d_744_3594)"> + <rect x="31.2227" y="15.7812" width="5.04914" height="4.20761" fill="#B8E3FF"/> + <rect x="31.2727" y="15.8313" width="4.94914" height="4.10761" stroke="black" stroke-opacity="0.1" stroke-width="0.1"/> + </g> + <g filter="url(#filter11_d_744_3594)"> + <rect x="31.2227" y="21.6719" width="5.04914" height="4.20761" fill="#B8E3FF"/> + <rect x="31.2727" y="21.7219" width="4.94914" height="4.10761" stroke="black" stroke-opacity="0.1" stroke-width="0.1"/> + </g> + <g filter="url(#filter12_d_744_3594)"> + <rect x="37.9551" y="15.7812" width="5.04914" height="4.20761" fill="#B8E3FF"/> + <rect x="38.0051" y="15.8313" width="4.94914" height="4.10761" stroke="black" stroke-opacity="0.1" stroke-width="0.1"/> + </g> + <g filter="url(#filter13_d_744_3594)"> + <rect x="37.9551" y="21.6719" width="5.04914" height="4.20761" fill="#FFC46B"/> + <rect x="38.0051" y="21.7219" width="4.94914" height="4.10761" stroke="black" stroke-opacity="0.1" stroke-width="0.1"/> + </g> + <g filter="url(#filter14_d_744_3594)"> + <rect x="44.6875" y="15.7812" width="5.04914" height="4.20761" fill="#B8E3FF"/> + <rect x="44.7375" y="15.8313" width="4.94914" height="4.10761" stroke="black" stroke-opacity="0.1" stroke-width="0.1"/> + </g> + <rect x="53.1025" y="9.89062" width="5.89066" height="1.68304" rx="0.841522" fill="#DFF4E8"/> + <rect x="53.1025" y="13.2568" width="20.1965" height="33.6609" rx="1" fill="#DFF4E8"/> + <g filter="url(#filter15_d_744_3594)"> + <rect x="53.9434" y="15.7812" width="5.04914" height="4.20761" fill="#9DD194"/> + <rect x="53.9934" y="15.8313" width="4.94914" height="4.10761" stroke="black" stroke-opacity="0.1" stroke-width="0.1"/> + </g> + <g filter="url(#filter16_d_744_3594)"> + <rect x="53.9434" y="21.6719" width="5.04914" height="4.20761" fill="#9DD194"/> + <rect x="53.9934" y="21.7219" width="4.94914" height="4.10761" stroke="black" stroke-opacity="0.1" stroke-width="0.1"/> + </g> + <g filter="url(#filter17_d_744_3594)"> + <rect x="60.6758" y="15.7812" width="5.04914" height="4.20761" fill="#9DD194"/> + <rect x="60.7258" y="15.8313" width="4.94914" height="4.10761" stroke="black" stroke-opacity="0.1" stroke-width="0.1"/> + </g> + <g filter="url(#filter18_d_744_3594)"> + <rect x="60.6758" y="21.6719" width="5.04914" height="4.20761" fill="#FFC46B"/> + <rect x="60.7258" y="21.7219" width="4.94914" height="4.10761" stroke="black" stroke-opacity="0.1" stroke-width="0.1"/> + </g> + <g filter="url(#filter19_d_744_3594)"> + <rect x="67.4082" y="15.7812" width="5.04914" height="4.20761" fill="#9DD194"/> + <rect x="67.4582" y="15.8313" width="4.94914" height="4.10761" stroke="black" stroke-opacity="0.1" stroke-width="0.1"/> + </g> + </g> + <rect x="4.59395" y="4.3" width="70.9294" height="41.4761" rx="3.7" stroke="#E3E2E4" stroke-width="0.6"/> + </g> + <defs> + <filter id="filter0_d_744_3594" x="0.293945" y="0" width="79.5293" height="50.0762" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB"> + <feFlood flood-opacity="0" result="BackgroundImageFix"/> + <feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/> + <feOffset/> + <feGaussianBlur stdDeviation="2"/> + <feComposite in2="hardAlpha" operator="out"/> + <feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.2 0"/> + <feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow_744_3594"/> + <feBlend mode="normal" in="SourceGraphic" in2="effect1_dropShadow_744_3594" result="shape"/> + </filter> + <filter id="filter1_d_744_3594" x="-10.636" y="-3.35668" width="43.3247" height="42.4839" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB"> + <feFlood flood-opacity="0" result="BackgroundImageFix"/> + <feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/> + <feOffset/> + <feGaussianBlur stdDeviation="9.56896"/> + <feComposite in2="hardAlpha" operator="out"/> + <feColorMatrix type="matrix" values="0 0 0 0 0.258824 0 0 0 0 0.254902 0 0 0 0 0.286275 0 0 0 0.18 0"/> + <feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow_744_3594"/> + <feBlend mode="normal" in="SourceGraphic" in2="effect1_dropShadow_744_3594" result="shape"/> + </filter> + <filter id="filter2_d_744_3594" x="-10.636" y="3.37574" width="43.3247" height="42.4839" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB"> + <feFlood flood-opacity="0" result="BackgroundImageFix"/> + <feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/> + <feOffset/> + <feGaussianBlur stdDeviation="9.56896"/> + <feComposite in2="hardAlpha" operator="out"/> + <feColorMatrix type="matrix" values="0 0 0 0 0.258824 0 0 0 0 0.254902 0 0 0 0 0.286275 0 0 0 0.18 0"/> + <feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow_744_3594"/> + <feBlend mode="normal" in="SourceGraphic" in2="effect1_dropShadow_744_3594" result="shape"/> + </filter> + <filter id="filter3_d_744_3594" x="-10.636" y="9.26637" width="43.3247" height="42.4839" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB"> + <feFlood flood-opacity="0" result="BackgroundImageFix"/> + <feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/> + <feOffset/> + <feGaussianBlur stdDeviation="9.56896"/> + <feComposite in2="hardAlpha" operator="out"/> + <feColorMatrix type="matrix" values="0 0 0 0 0.258824 0 0 0 0 0.254902 0 0 0 0 0.286275 0 0 0 0.18 0"/> + <feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow_744_3594"/> + <feBlend mode="normal" in="SourceGraphic" in2="effect1_dropShadow_744_3594" result="shape"/> + </filter> + <filter id="filter4_d_744_3594" x="-3.90453" y="-3.35668" width="43.3247" height="42.4839" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB"> + <feFlood flood-opacity="0" result="BackgroundImageFix"/> + <feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/> + <feOffset/> + <feGaussianBlur stdDeviation="9.56896"/> + <feComposite in2="hardAlpha" operator="out"/> + <feColorMatrix type="matrix" values="0 0 0 0 0.258824 0 0 0 0 0.254902 0 0 0 0 0.286275 0 0 0 0.18 0"/> + <feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow_744_3594"/> + <feBlend mode="normal" in="SourceGraphic" in2="effect1_dropShadow_744_3594" result="shape"/> + </filter> + <filter id="filter5_d_744_3594" x="-3.90453" y="3.37574" width="43.3247" height="42.4839" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB"> + <feFlood flood-opacity="0" result="BackgroundImageFix"/> + <feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/> + <feOffset/> + <feGaussianBlur stdDeviation="9.56896"/> + <feComposite in2="hardAlpha" operator="out"/> + <feColorMatrix type="matrix" values="0 0 0 0 0.258824 0 0 0 0 0.254902 0 0 0 0 0.286275 0 0 0 0.18 0"/> + <feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow_744_3594"/> + <feBlend mode="normal" in="SourceGraphic" in2="effect1_dropShadow_744_3594" result="shape"/> + </filter> + <filter id="filter6_d_744_3594" x="-3.90453" y="9.26637" width="43.3247" height="42.4839" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB"> + <feFlood flood-opacity="0" result="BackgroundImageFix"/> + <feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/> + <feOffset/> + <feGaussianBlur stdDeviation="9.56896"/> + <feComposite in2="hardAlpha" operator="out"/> + <feColorMatrix type="matrix" values="0 0 0 0 0.258824 0 0 0 0 0.254902 0 0 0 0 0.286275 0 0 0 0.18 0"/> + <feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow_744_3594"/> + <feBlend mode="normal" in="SourceGraphic" in2="effect1_dropShadow_744_3594" result="shape"/> + </filter> + <filter id="filter7_d_744_3594" x="2.82789" y="-3.35668" width="43.3247" height="42.4839" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB"> + <feFlood flood-opacity="0" result="BackgroundImageFix"/> + <feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/> + <feOffset/> + <feGaussianBlur stdDeviation="9.56896"/> + <feComposite in2="hardAlpha" operator="out"/> + <feColorMatrix type="matrix" values="0 0 0 0 0.258824 0 0 0 0 0.254902 0 0 0 0 0.286275 0 0 0 0.18 0"/> + <feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow_744_3594"/> + <feBlend mode="normal" in="SourceGraphic" in2="effect1_dropShadow_744_3594" result="shape"/> + </filter> + <filter id="filter8_d_744_3594" x="2.82789" y="3.37574" width="43.3247" height="42.4839" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB"> + <feFlood flood-opacity="0" result="BackgroundImageFix"/> + <feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/> + <feOffset/> + <feGaussianBlur stdDeviation="9.56896"/> + <feComposite in2="hardAlpha" operator="out"/> + <feColorMatrix type="matrix" values="0 0 0 0 0.258824 0 0 0 0 0.254902 0 0 0 0 0.286275 0 0 0 0.18 0"/> + <feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow_744_3594"/> + <feBlend mode="normal" in="SourceGraphic" in2="effect1_dropShadow_744_3594" result="shape"/> + </filter> + <filter id="filter9_d_744_3594" x="2.82789" y="9.26637" width="43.3247" height="42.4839" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB"> + <feFlood flood-opacity="0" result="BackgroundImageFix"/> + <feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/> + <feOffset/> + <feGaussianBlur stdDeviation="9.56896"/> + <feComposite in2="hardAlpha" operator="out"/> + <feColorMatrix type="matrix" values="0 0 0 0 0.258824 0 0 0 0 0.254902 0 0 0 0 0.286275 0 0 0 0.18 0"/> + <feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow_744_3594"/> + <feBlend mode="normal" in="SourceGraphic" in2="effect1_dropShadow_744_3594" result="shape"/> + </filter> + <filter id="filter10_d_744_3594" x="12.0847" y="-3.35668" width="43.3247" height="42.4839" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB"> + <feFlood flood-opacity="0" result="BackgroundImageFix"/> + <feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/> + <feOffset/> + <feGaussianBlur stdDeviation="9.56896"/> + <feComposite in2="hardAlpha" operator="out"/> + <feColorMatrix type="matrix" values="0 0 0 0 0.258824 0 0 0 0 0.254902 0 0 0 0 0.286275 0 0 0 0.18 0"/> + <feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow_744_3594"/> + <feBlend mode="normal" in="SourceGraphic" in2="effect1_dropShadow_744_3594" result="shape"/> + </filter> + <filter id="filter11_d_744_3594" x="12.0847" y="2.53395" width="43.3247" height="42.4839" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB"> + <feFlood flood-opacity="0" result="BackgroundImageFix"/> + <feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/> + <feOffset/> + <feGaussianBlur stdDeviation="9.56896"/> + <feComposite in2="hardAlpha" operator="out"/> + <feColorMatrix type="matrix" values="0 0 0 0 0.258824 0 0 0 0 0.254902 0 0 0 0 0.286275 0 0 0 0.18 0"/> + <feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow_744_3594"/> + <feBlend mode="normal" in="SourceGraphic" in2="effect1_dropShadow_744_3594" result="shape"/> + </filter> + <filter id="filter12_d_744_3594" x="18.8171" y="-3.35668" width="43.3247" height="42.4839" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB"> + <feFlood flood-opacity="0" result="BackgroundImageFix"/> + <feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/> + <feOffset/> + <feGaussianBlur stdDeviation="9.56896"/> + <feComposite in2="hardAlpha" operator="out"/> + <feColorMatrix type="matrix" values="0 0 0 0 0.258824 0 0 0 0 0.254902 0 0 0 0 0.286275 0 0 0 0.18 0"/> + <feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow_744_3594"/> + <feBlend mode="normal" in="SourceGraphic" in2="effect1_dropShadow_744_3594" result="shape"/> + </filter> + <filter id="filter13_d_744_3594" x="18.8171" y="2.53395" width="43.3247" height="42.4839" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB"> + <feFlood flood-opacity="0" result="BackgroundImageFix"/> + <feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/> + <feOffset/> + <feGaussianBlur stdDeviation="9.56896"/> + <feComposite in2="hardAlpha" operator="out"/> + <feColorMatrix type="matrix" values="0 0 0 0 0.258824 0 0 0 0 0.254902 0 0 0 0 0.286275 0 0 0 0.18 0"/> + <feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow_744_3594"/> + <feBlend mode="normal" in="SourceGraphic" in2="effect1_dropShadow_744_3594" result="shape"/> + </filter> + <filter id="filter14_d_744_3594" x="25.5496" y="-3.35668" width="43.3247" height="42.4839" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB"> + <feFlood flood-opacity="0" result="BackgroundImageFix"/> + <feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/> + <feOffset/> + <feGaussianBlur stdDeviation="9.56896"/> + <feComposite in2="hardAlpha" operator="out"/> + <feColorMatrix type="matrix" values="0 0 0 0 0.258824 0 0 0 0 0.254902 0 0 0 0 0.286275 0 0 0 0.18 0"/> + <feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow_744_3594"/> + <feBlend mode="normal" in="SourceGraphic" in2="effect1_dropShadow_744_3594" result="shape"/> + </filter> + <filter id="filter15_d_744_3594" x="34.8054" y="-3.35668" width="43.3247" height="42.4839" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB"> + <feFlood flood-opacity="0" result="BackgroundImageFix"/> + <feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/> + <feOffset/> + <feGaussianBlur stdDeviation="9.56896"/> + <feComposite in2="hardAlpha" operator="out"/> + <feColorMatrix type="matrix" values="0 0 0 0 0.258824 0 0 0 0 0.254902 0 0 0 0 0.286275 0 0 0 0.18 0"/> + <feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow_744_3594"/> + <feBlend mode="normal" in="SourceGraphic" in2="effect1_dropShadow_744_3594" result="shape"/> + </filter> + <filter id="filter16_d_744_3594" x="34.8054" y="2.53395" width="43.3247" height="42.4839" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB"> + <feFlood flood-opacity="0" result="BackgroundImageFix"/> + <feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/> + <feOffset/> + <feGaussianBlur stdDeviation="9.56896"/> + <feComposite in2="hardAlpha" operator="out"/> + <feColorMatrix type="matrix" values="0 0 0 0 0.258824 0 0 0 0 0.254902 0 0 0 0 0.286275 0 0 0 0.18 0"/> + <feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow_744_3594"/> + <feBlend mode="normal" in="SourceGraphic" in2="effect1_dropShadow_744_3594" result="shape"/> + </filter> + <filter id="filter17_d_744_3594" x="41.5379" y="-3.35668" width="43.3247" height="42.4839" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB"> + <feFlood flood-opacity="0" result="BackgroundImageFix"/> + <feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/> + <feOffset/> + <feGaussianBlur stdDeviation="9.56896"/> + <feComposite in2="hardAlpha" operator="out"/> + <feColorMatrix type="matrix" values="0 0 0 0 0.258824 0 0 0 0 0.254902 0 0 0 0 0.286275 0 0 0 0.18 0"/> + <feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow_744_3594"/> + <feBlend mode="normal" in="SourceGraphic" in2="effect1_dropShadow_744_3594" result="shape"/> + </filter> + <filter id="filter18_d_744_3594" x="41.5379" y="2.53395" width="43.3247" height="42.4839" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB"> + <feFlood flood-opacity="0" result="BackgroundImageFix"/> + <feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/> + <feOffset/> + <feGaussianBlur stdDeviation="9.56896"/> + <feComposite in2="hardAlpha" operator="out"/> + <feColorMatrix type="matrix" values="0 0 0 0 0.258824 0 0 0 0 0.254902 0 0 0 0 0.286275 0 0 0 0.18 0"/> + <feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow_744_3594"/> + <feBlend mode="normal" in="SourceGraphic" in2="effect1_dropShadow_744_3594" result="shape"/> + </filter> + <filter id="filter19_d_744_3594" x="48.2703" y="-3.35668" width="43.3247" height="42.4839" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB"> + <feFlood flood-opacity="0" result="BackgroundImageFix"/> + <feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/> + <feOffset/> + <feGaussianBlur stdDeviation="9.56896"/> + <feComposite in2="hardAlpha" operator="out"/> + <feColorMatrix type="matrix" values="0 0 0 0 0.258824 0 0 0 0 0.254902 0 0 0 0 0.286275 0 0 0 0.18 0"/> + <feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow_744_3594"/> + <feBlend mode="normal" in="SourceGraphic" in2="effect1_dropShadow_744_3594" result="shape"/> + </filter> + <clipPath id="clip0_744_3594"> + <rect x="4.29395" y="4" width="71.5294" height="42.0761" rx="4" fill="white"/> + </clipPath> + </defs> + </svg> + `, + dark: svg`<svg width="80" height="51" viewBox="0 0 80 51" fill="none" xmlns="http://www.w3.org/2000/svg"> + <g filter="url(#filter0_d_744_3661)"> + <g clip-path="url(#clip0_744_3661)"> + <rect x="4.29395" y="4" width="71.5294" height="42.0761" rx="4" fill="#1A1A1A"/> + <rect x="7.66016" y="9.89062" width="5.89066" height="1.68304" rx="0.841522" fill="#B9812E"/> + <rect x="7.66016" y="13.2568" width="20.1965" height="30" rx="1" fill="#B9812E"/> + <g filter="url(#filter1_d_744_3661)"> + <rect x="8.50195" y="15.7812" width="5.04914" height="4.20761" fill="#FFDE6B"/> + <rect x="8.55195" y="15.8313" width="4.94914" height="4.10761" stroke="white" stroke-opacity="0.1" stroke-width="0.1"/> + </g> + <g filter="url(#filter2_d_744_3661)"> + <rect x="8.50195" y="22.5137" width="5.04914" height="4.20761" fill="#FFDE6B"/> + <rect x="8.55195" y="22.5637" width="4.94914" height="4.10761" stroke="white" stroke-opacity="0.1" stroke-width="0.1"/> + </g> + <g filter="url(#filter3_d_744_3661)"> + <rect x="8.50195" y="28.4043" width="5.04914" height="4.20761" fill="#FFDE6B"/> + <rect x="8.55195" y="28.4543" width="4.94914" height="4.10761" stroke="white" stroke-opacity="0.1" stroke-width="0.1"/> + </g> + <g filter="url(#filter4_d_744_3661)"> + <rect x="15.2334" y="15.7812" width="5.04914" height="4.20761" fill="#FFDE6B"/> + <rect x="15.2834" y="15.8313" width="4.94914" height="4.10761" stroke="white" stroke-opacity="0.1" stroke-width="0.1"/> + </g> + <g filter="url(#filter5_d_744_3661)"> + <rect x="15.2334" y="22.5137" width="5.04914" height="4.20761" fill="#FFDE6B"/> + <rect x="15.2834" y="22.5637" width="4.94914" height="4.10761" stroke="white" stroke-opacity="0.1" stroke-width="0.1"/> + </g> + <g filter="url(#filter6_d_744_3661)"> + <rect x="15.2334" y="28.4043" width="5.04914" height="4.20761" fill="#9DD194"/> + <rect x="15.2834" y="28.4543" width="4.94914" height="4.10761" stroke="white" stroke-opacity="0.1" stroke-width="0.1"/> + </g> + <g filter="url(#filter7_d_744_3661)"> + <rect x="21.9658" y="15.7812" width="5.04914" height="4.20761" fill="#FFDE6B"/> + <rect x="22.0158" y="15.8313" width="4.94914" height="4.10761" stroke="white" stroke-opacity="0.1" stroke-width="0.1"/> + </g> + <g filter="url(#filter8_d_744_3661)"> + <rect x="21.9658" y="22.5137" width="5.04914" height="4.20761" fill="#FFDE6B"/> + <rect x="22.0158" y="22.5637" width="4.94914" height="4.10761" stroke="white" stroke-opacity="0.1" stroke-width="0.1"/> + </g> + <g filter="url(#filter9_d_744_3661)"> + <rect x="21.9658" y="28.4043" width="5.04914" height="4.20761" fill="#F16F6F"/> + <rect x="22.0158" y="28.4543" width="4.94914" height="4.10761" stroke="white" stroke-opacity="0.1" stroke-width="0.1"/> + </g> + <rect x="30.3809" y="9.89062" width="5.89066" height="1.68304" rx="0.841522" fill="#084388"/> + <rect x="30.3809" y="13.2568" width="20.1965" height="30" rx="1" fill="#084388"/> + <g filter="url(#filter10_d_744_3661)"> + <rect x="31.2227" y="15.7812" width="5.04914" height="4.20761" fill="#B8E3FF"/> + <rect x="31.2727" y="15.8313" width="4.94914" height="4.10761" stroke="white" stroke-opacity="0.1" stroke-width="0.1"/> + </g> + <g filter="url(#filter11_d_744_3661)"> + <rect x="31.2227" y="21.6719" width="5.04914" height="4.20761" fill="#B8E3FF"/> + <rect x="31.2727" y="21.7219" width="4.94914" height="4.10761" stroke="white" stroke-opacity="0.1" stroke-width="0.1"/> + </g> + <g filter="url(#filter12_d_744_3661)"> + <rect x="37.9551" y="15.7812" width="5.04914" height="4.20761" fill="#B8E3FF"/> + <rect x="38.0051" y="15.8313" width="4.94914" height="4.10761" stroke="white" stroke-opacity="0.1" stroke-width="0.1"/> + </g> + <g filter="url(#filter13_d_744_3661)"> + <rect x="37.9551" y="21.6719" width="5.04914" height="4.20761" fill="#FFC46B"/> + <rect x="38.0051" y="21.7219" width="4.94914" height="4.10761" stroke="white" stroke-opacity="0.1" stroke-width="0.1"/> + </g> + <g filter="url(#filter14_d_744_3661)"> + <rect x="44.6875" y="15.7812" width="5.04914" height="4.20761" fill="#B8E3FF"/> + <rect x="44.7375" y="15.8313" width="4.94914" height="4.10761" stroke="white" stroke-opacity="0.1" stroke-width="0.1"/> + </g> + <rect x="53.1025" y="9.89062" width="5.89066" height="1.68304" rx="0.841522" fill="#2C6C3F"/> + <rect x="53.1025" y="13.2568" width="18" height="30" rx="1" fill="#2C6C3F"/> + <g filter="url(#filter15_d_744_3661)"> + <rect x="53.9434" y="15.7812" width="5.04914" height="4.20761" fill="#9DD194"/> + <rect x="53.9934" y="15.8313" width="4.94914" height="4.10761" stroke="white" stroke-opacity="0.1" stroke-width="0.1"/> + </g> + <g filter="url(#filter16_d_744_3661)"> + <rect x="53.9434" y="21.6719" width="5.04914" height="4.20761" fill="#9DD194"/> + <rect x="53.9934" y="21.7219" width="4.94914" height="4.10761" stroke="white" stroke-opacity="0.1" stroke-width="0.1"/> + </g> + <g filter="url(#filter17_d_744_3661)"> + <rect x="60.6758" y="15.7812" width="5.04914" height="4.20761" fill="#9DD194"/> + <rect x="60.7258" y="15.8313" width="4.94914" height="4.10761" stroke="white" stroke-opacity="0.1" stroke-width="0.1"/> + </g> + <g filter="url(#filter18_d_744_3661)"> + <rect x="60.6758" y="21.6719" width="5.04914" height="4.20761" fill="#FFC46B"/> + <rect x="60.7258" y="21.7219" width="4.94914" height="4.10761" stroke="white" stroke-opacity="0.1" stroke-width="0.1"/> + </g> + </g> + <rect x="4.79395" y="4.5" width="70.5294" height="41.0761" rx="3.5" stroke="#727272"/> + </g> + <defs> + <filter id="filter0_d_744_3661" x="0.293945" y="0" width="79.5293" height="50.0762" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB"> + <feFlood flood-opacity="0" result="BackgroundImageFix"/> + <feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/> + <feOffset/> + <feGaussianBlur stdDeviation="2"/> + <feComposite in2="hardAlpha" operator="out"/> + <feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.44 0"/> + <feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow_744_3661"/> + <feBlend mode="normal" in="SourceGraphic" in2="effect1_dropShadow_744_3661" result="shape"/> + </filter> + <filter id="filter1_d_744_3661" x="-10.636" y="-3.35668" width="43.3247" height="42.4839" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB"> + <feFlood flood-opacity="0" result="BackgroundImageFix"/> + <feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/> + <feOffset/> + <feGaussianBlur stdDeviation="9.56896"/> + <feComposite in2="hardAlpha" operator="out"/> + <feColorMatrix type="matrix" values="0 0 0 0 0.258824 0 0 0 0 0.254902 0 0 0 0 0.286275 0 0 0 0.18 0"/> + <feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow_744_3661"/> + <feBlend mode="normal" in="SourceGraphic" in2="effect1_dropShadow_744_3661" result="shape"/> + </filter> + <filter id="filter2_d_744_3661" x="-10.636" y="3.37574" width="43.3247" height="42.4839" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB"> + <feFlood flood-opacity="0" result="BackgroundImageFix"/> + <feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/> + <feOffset/> + <feGaussianBlur stdDeviation="9.56896"/> + <feComposite in2="hardAlpha" operator="out"/> + <feColorMatrix type="matrix" values="0 0 0 0 0.258824 0 0 0 0 0.254902 0 0 0 0 0.286275 0 0 0 0.18 0"/> + <feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow_744_3661"/> + <feBlend mode="normal" in="SourceGraphic" in2="effect1_dropShadow_744_3661" result="shape"/> + </filter> + <filter id="filter3_d_744_3661" x="-10.636" y="9.26637" width="43.3247" height="42.4839" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB"> + <feFlood flood-opacity="0" result="BackgroundImageFix"/> + <feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/> + <feOffset/> + <feGaussianBlur stdDeviation="9.56896"/> + <feComposite in2="hardAlpha" operator="out"/> + <feColorMatrix type="matrix" values="0 0 0 0 0.258824 0 0 0 0 0.254902 0 0 0 0 0.286275 0 0 0 0.18 0"/> + <feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow_744_3661"/> + <feBlend mode="normal" in="SourceGraphic" in2="effect1_dropShadow_744_3661" result="shape"/> + </filter> + <filter id="filter4_d_744_3661" x="-3.90453" y="-3.35668" width="43.3247" height="42.4839" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB"> + <feFlood flood-opacity="0" result="BackgroundImageFix"/> + <feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/> + <feOffset/> + <feGaussianBlur stdDeviation="9.56896"/> + <feComposite in2="hardAlpha" operator="out"/> + <feColorMatrix type="matrix" values="0 0 0 0 0.258824 0 0 0 0 0.254902 0 0 0 0 0.286275 0 0 0 0.18 0"/> + <feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow_744_3661"/> + <feBlend mode="normal" in="SourceGraphic" in2="effect1_dropShadow_744_3661" result="shape"/> + </filter> + <filter id="filter5_d_744_3661" x="-3.90453" y="3.37574" width="43.3247" height="42.4839" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB"> + <feFlood flood-opacity="0" result="BackgroundImageFix"/> + <feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/> + <feOffset/> + <feGaussianBlur stdDeviation="9.56896"/> + <feComposite in2="hardAlpha" operator="out"/> + <feColorMatrix type="matrix" values="0 0 0 0 0.258824 0 0 0 0 0.254902 0 0 0 0 0.286275 0 0 0 0.18 0"/> + <feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow_744_3661"/> + <feBlend mode="normal" in="SourceGraphic" in2="effect1_dropShadow_744_3661" result="shape"/> + </filter> + <filter id="filter6_d_744_3661" x="-3.90453" y="9.26637" width="43.3247" height="42.4839" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB"> + <feFlood flood-opacity="0" result="BackgroundImageFix"/> + <feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/> + <feOffset/> + <feGaussianBlur stdDeviation="9.56896"/> + <feComposite in2="hardAlpha" operator="out"/> + <feColorMatrix type="matrix" values="0 0 0 0 0.258824 0 0 0 0 0.254902 0 0 0 0 0.286275 0 0 0 0.18 0"/> + <feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow_744_3661"/> + <feBlend mode="normal" in="SourceGraphic" in2="effect1_dropShadow_744_3661" result="shape"/> + </filter> + <filter id="filter7_d_744_3661" x="2.82789" y="-3.35668" width="43.3247" height="42.4839" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB"> + <feFlood flood-opacity="0" result="BackgroundImageFix"/> + <feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/> + <feOffset/> + <feGaussianBlur stdDeviation="9.56896"/> + <feComposite in2="hardAlpha" operator="out"/> + <feColorMatrix type="matrix" values="0 0 0 0 0.258824 0 0 0 0 0.254902 0 0 0 0 0.286275 0 0 0 0.18 0"/> + <feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow_744_3661"/> + <feBlend mode="normal" in="SourceGraphic" in2="effect1_dropShadow_744_3661" result="shape"/> + </filter> + <filter id="filter8_d_744_3661" x="2.82789" y="3.37574" width="43.3247" height="42.4839" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB"> + <feFlood flood-opacity="0" result="BackgroundImageFix"/> + <feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/> + <feOffset/> + <feGaussianBlur stdDeviation="9.56896"/> + <feComposite in2="hardAlpha" operator="out"/> + <feColorMatrix type="matrix" values="0 0 0 0 0.258824 0 0 0 0 0.254902 0 0 0 0 0.286275 0 0 0 0.18 0"/> + <feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow_744_3661"/> + <feBlend mode="normal" in="SourceGraphic" in2="effect1_dropShadow_744_3661" result="shape"/> + </filter> + <filter id="filter9_d_744_3661" x="2.82789" y="9.26637" width="43.3247" height="42.4839" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB"> + <feFlood flood-opacity="0" result="BackgroundImageFix"/> + <feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/> + <feOffset/> + <feGaussianBlur stdDeviation="9.56896"/> + <feComposite in2="hardAlpha" operator="out"/> + <feColorMatrix type="matrix" values="0 0 0 0 0.258824 0 0 0 0 0.254902 0 0 0 0 0.286275 0 0 0 0.18 0"/> + <feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow_744_3661"/> + <feBlend mode="normal" in="SourceGraphic" in2="effect1_dropShadow_744_3661" result="shape"/> + </filter> + <filter id="filter10_d_744_3661" x="12.0847" y="-3.35668" width="43.3247" height="42.4839" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB"> + <feFlood flood-opacity="0" result="BackgroundImageFix"/> + <feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/> + <feOffset/> + <feGaussianBlur stdDeviation="9.56896"/> + <feComposite in2="hardAlpha" operator="out"/> + <feColorMatrix type="matrix" values="0 0 0 0 0.258824 0 0 0 0 0.254902 0 0 0 0 0.286275 0 0 0 0.18 0"/> + <feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow_744_3661"/> + <feBlend mode="normal" in="SourceGraphic" in2="effect1_dropShadow_744_3661" result="shape"/> + </filter> + <filter id="filter11_d_744_3661" x="12.0847" y="2.53395" width="43.3247" height="42.4839" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB"> + <feFlood flood-opacity="0" result="BackgroundImageFix"/> + <feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/> + <feOffset/> + <feGaussianBlur stdDeviation="9.56896"/> + <feComposite in2="hardAlpha" operator="out"/> + <feColorMatrix type="matrix" values="0 0 0 0 0.258824 0 0 0 0 0.254902 0 0 0 0 0.286275 0 0 0 0.18 0"/> + <feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow_744_3661"/> + <feBlend mode="normal" in="SourceGraphic" in2="effect1_dropShadow_744_3661" result="shape"/> + </filter> + <filter id="filter12_d_744_3661" x="18.8171" y="-3.35668" width="43.3247" height="42.4839" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB"> + <feFlood flood-opacity="0" result="BackgroundImageFix"/> + <feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/> + <feOffset/> + <feGaussianBlur stdDeviation="9.56896"/> + <feComposite in2="hardAlpha" operator="out"/> + <feColorMatrix type="matrix" values="0 0 0 0 0.258824 0 0 0 0 0.254902 0 0 0 0 0.286275 0 0 0 0.18 0"/> + <feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow_744_3661"/> + <feBlend mode="normal" in="SourceGraphic" in2="effect1_dropShadow_744_3661" result="shape"/> + </filter> + <filter id="filter13_d_744_3661" x="18.8171" y="2.53395" width="43.3247" height="42.4839" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB"> + <feFlood flood-opacity="0" result="BackgroundImageFix"/> + <feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/> + <feOffset/> + <feGaussianBlur stdDeviation="9.56896"/> + <feComposite in2="hardAlpha" operator="out"/> + <feColorMatrix type="matrix" values="0 0 0 0 0.258824 0 0 0 0 0.254902 0 0 0 0 0.286275 0 0 0 0.18 0"/> + <feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow_744_3661"/> + <feBlend mode="normal" in="SourceGraphic" in2="effect1_dropShadow_744_3661" result="shape"/> + </filter> + <filter id="filter14_d_744_3661" x="25.5496" y="-3.35668" width="43.3247" height="42.4839" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB"> + <feFlood flood-opacity="0" result="BackgroundImageFix"/> + <feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/> + <feOffset/> + <feGaussianBlur stdDeviation="9.56896"/> + <feComposite in2="hardAlpha" operator="out"/> + <feColorMatrix type="matrix" values="0 0 0 0 0.258824 0 0 0 0 0.254902 0 0 0 0 0.286275 0 0 0 0.18 0"/> + <feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow_744_3661"/> + <feBlend mode="normal" in="SourceGraphic" in2="effect1_dropShadow_744_3661" result="shape"/> + </filter> + <filter id="filter15_d_744_3661" x="34.8054" y="-3.35668" width="43.3247" height="42.4839" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB"> + <feFlood flood-opacity="0" result="BackgroundImageFix"/> + <feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/> + <feOffset/> + <feGaussianBlur stdDeviation="9.56896"/> + <feComposite in2="hardAlpha" operator="out"/> + <feColorMatrix type="matrix" values="0 0 0 0 0.258824 0 0 0 0 0.254902 0 0 0 0 0.286275 0 0 0 0.18 0"/> + <feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow_744_3661"/> + <feBlend mode="normal" in="SourceGraphic" in2="effect1_dropShadow_744_3661" result="shape"/> + </filter> + <filter id="filter16_d_744_3661" x="34.8054" y="2.53395" width="43.3247" height="42.4839" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB"> + <feFlood flood-opacity="0" result="BackgroundImageFix"/> + <feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/> + <feOffset/> + <feGaussianBlur stdDeviation="9.56896"/> + <feComposite in2="hardAlpha" operator="out"/> + <feColorMatrix type="matrix" values="0 0 0 0 0.258824 0 0 0 0 0.254902 0 0 0 0 0.286275 0 0 0 0.18 0"/> + <feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow_744_3661"/> + <feBlend mode="normal" in="SourceGraphic" in2="effect1_dropShadow_744_3661" result="shape"/> + </filter> + <filter id="filter17_d_744_3661" x="41.5379" y="-3.35668" width="43.3247" height="42.4839" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB"> + <feFlood flood-opacity="0" result="BackgroundImageFix"/> + <feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/> + <feOffset/> + <feGaussianBlur stdDeviation="9.56896"/> + <feComposite in2="hardAlpha" operator="out"/> + <feColorMatrix type="matrix" values="0 0 0 0 0.258824 0 0 0 0 0.254902 0 0 0 0 0.286275 0 0 0 0.18 0"/> + <feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow_744_3661"/> + <feBlend mode="normal" in="SourceGraphic" in2="effect1_dropShadow_744_3661" result="shape"/> + </filter> + <filter id="filter18_d_744_3661" x="41.5379" y="2.53395" width="43.3247" height="42.4839" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB"> + <feFlood flood-opacity="0" result="BackgroundImageFix"/> + <feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/> + <feOffset/> + <feGaussianBlur stdDeviation="9.56896"/> + <feComposite in2="hardAlpha" operator="out"/> + <feColorMatrix type="matrix" values="0 0 0 0 0.258824 0 0 0 0 0.254902 0 0 0 0 0.286275 0 0 0 0.18 0"/> + <feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow_744_3661"/> + <feBlend mode="normal" in="SourceGraphic" in2="effect1_dropShadow_744_3661" result="shape"/> + </filter> + <clipPath id="clip0_744_3661"> + <rect x="4.29395" y="4" width="71.5294" height="42.0761" rx="4" fill="white"/> + </clipPath> + </defs> + </svg> + `, +}; + +export const defaultPreview = html` + <svg + width="85" + height="50" + viewBox="0 0 85 50" + fill="none" + xmlns="http://www.w3.org/2000/svg" + > + <rect width="85" height="50" fill="white" /> + <line + x1="16" + y1="31.8907" + x2="22" + y2="31.8907" + stroke="#6B6B6B" + stroke-width="0.218649" + /> + <line + x1="16" + y1="31.8907" + x2="22" + y2="31.8907" + stroke="#6B6B6B" + stroke-width="0.218649" + /> + <path + d="M33 31H36.1325V22.8918V19.0001L40 19" + stroke="#6B6B6B" + stroke-width="0.218649" + /> + <path + d="M52 19H64.1883V13V10L68 10.0001" + stroke="#6B6B6B" + stroke-width="0.218649" + /> + <path + d="M29 31H35.8083L35.8089 36.8873V39.9999L40 40" + stroke="#6B6B6B" + stroke-width="0.218649" + /> + <path + d="M62 19H64.685V24.9999V27.9999L68 28" + stroke="#6B6B6B" + stroke-width="0.218649" + /> + <rect x="40" y="36" width="12" height="7" rx="0.728829" fill="#9DD194" /> + <rect + x="40.0364" + y="36.0364" + width="11.9271" + height="6.92712" + rx="0.692388" + stroke="black" + stroke-opacity="0.1" + stroke-width="0.0728829" + /> + <rect x="3" y="28" width="13" height="6" rx="0.728829" fill="#FFDE6B" /> + <rect + x="3.03644" + y="28.0364" + width="12.9271" + height="5.92712" + rx="0.692388" + stroke="black" + stroke-opacity="0.1" + stroke-width="0.0728829" + /> + <rect + x="54.7686" + y="19.1265" + width="6.22715" + height="6.22715" + transform="rotate(-45 54.7686 19.1265)" + fill="#937EE7" + /> + <rect + x="54.8201" + y="19.1265" + width="6.15427" + height="6.15427" + transform="rotate(-45 54.8201 19.1265)" + stroke="black" + stroke-opacity="0.1" + stroke-width="0.0728829" + /> + <rect + x="21.4038" + y="30.8835" + width="6.22715" + height="6.22715" + transform="rotate(-45 21.4038 30.8835)" + fill="#937EE7" + /> + <rect + x="21.4553" + y="30.8835" + width="6.15427" + height="6.15427" + transform="rotate(-45 21.4553 30.8835)" + stroke="black" + stroke-opacity="0.1" + stroke-width="0.0728829" + /> + <rect x="40" y="16" width="12" height="7" rx="0.728829" fill="#FFDE6B" /> + <rect + x="40.0364" + y="16.0364" + width="11.9271" + height="6.92712" + rx="0.692388" + stroke="black" + stroke-opacity="0.1" + stroke-width="0.0728829" + /> + <rect x="68" y="8" width="13" height="6" rx="0.728829" fill="#937EE7" /> + <rect + x="68.0364" + y="8.03644" + width="12.9271" + height="5.92712" + rx="0.692388" + stroke="black" + stroke-opacity="0.1" + stroke-width="0.0728829" + /> + <rect x="68" y="24" width="13" height="7" rx="0.728829" fill="#937EE7" /> + <rect + x="68.0364" + y="24.0364" + width="12.9271" + height="6.92712" + rx="0.692388" + stroke="black" + stroke-opacity="0.1" + stroke-width="0.0728829" + /> + </svg> +`; diff --git a/blocksuite/blocks/src/root-block/edgeless/components/toolbar/template/overlay-scrollbar.ts b/blocksuite/blocks/src/root-block/edgeless/components/toolbar/template/overlay-scrollbar.ts new file mode 100644 index 0000000000..0009cdf73f --- /dev/null +++ b/blocksuite/blocks/src/root-block/edgeless/components/toolbar/template/overlay-scrollbar.ts @@ -0,0 +1,181 @@ +/* eslint-disable @typescript-eslint/no-non-null-assertion */ +import { + on, + once, + requestConnectedFrame, +} from '@blocksuite/affine-shared/utils'; +import { DisposableGroup } from '@blocksuite/global/utils'; +import { css, html, LitElement } from 'lit'; +import { query } from 'lit/decorators.js'; + +/** + * A scrollbar that is only visible when the user is interacting with it. + * Append this element to the a container that has a scrollable element. Which means + * the scrollable element should lay on the same level as the overlay-scrollbar. + * + * And the scrollable element should have a `data-scrollable` attribute. + * + * Example: + * ``` + * <div class="container"> + * <div class="scrollable-element-with-fixed-height" data-scrollable> + * <!--.... very long content ....--> + * </div> + * <overlay-scrollbar></overlay-scrollbar> + * </div> + * ``` + * + * Note: + * - It only works with vertical scrollbars. + */ +export class OverlayScrollbar extends LitElement { + static override styles = css` + :host { + position: fixed; + top: 0; + right: 0; + bottom: 0; + width: 10px; + opacity: 0; + transition: opacity 0.3s; + } + + .overlay-handle { + position: absolute; + top: 0; + left: 2px; + background-color: rgba(0, 0, 0, 0.44); + border-radius: 3px; + width: 6px; + } + `; + + private _disposable = new DisposableGroup(); + + private _handleVisible = false; + + private _scrollable: HTMLElement | null = null; + + private _dragHandle(event: PointerEvent) { + let startY = event.clientY; + + this._handleVisible = true; + + const dispose = on(document, 'pointermove', evt => { + this._scroll(evt.clientY - startY); + startY = evt.clientY; + }); + + once(document, 'pointerup', e => { + this._handleVisible = false; + + e.stopPropagation(); + + setTimeout(() => { + this._toggleScrollbarVisible(false); + }, 800); + + dispose(); + }); + } + + private _initWheelHandler() { + const container = this.parentElement as HTMLElement; + + container.style.contain = 'layout'; + container.style.overflow = 'hidden'; + + let hideScrollbarTimeId: null | ReturnType<typeof setTimeout> = null; + const delayHideScrollbar = () => { + if (hideScrollbarTimeId) clearTimeout(hideScrollbarTimeId); + hideScrollbarTimeId = setTimeout(() => { + this._toggleScrollbarVisible(false); + hideScrollbarTimeId = null; + }, 800); + }; + + let scrollable: HTMLElement | null = null; + this._disposable.addFromEvent(container, 'wheel', event => { + scrollable = scrollable?.isConnected + ? scrollable + : (container.querySelector('[data-scrollable]') as HTMLElement); + + this._scrollable = scrollable; + + if (!scrollable) return; + + // firefox may report a wheel event with deltaMode of value other than 0 + // we just simply multiply it by 16 which is common default line height to get the correct value + const scrollDistance = + event.deltaMode === 0 ? event.deltaY : event.deltaY * 16; + + this._scroll(scrollDistance ?? 0); + + delayHideScrollbar(); + }); + } + + private _scroll(scrollDistance: number) { + const scrollable = this._scrollable!; + + if (!scrollable) return; + + scrollable.scrollBy({ + left: 0, + top: scrollDistance, + behavior: 'instant', + }); + + requestConnectedFrame(() => { + this._updateScrollbarRect(scrollable); + this._toggleScrollbarVisible(true); + }, this); + } + + private _toggleScrollbarVisible(visible: boolean) { + const vis = visible || this._handleVisible ? '1' : '0'; + + if (this.style.opacity !== vis) { + this.style.opacity = vis; + } + } + + private _updateScrollbarRect(rect: { + scrollTop?: number; + clientHeight?: number; + scrollHeight?: number; + }) { + if (rect.scrollHeight !== undefined && rect.clientHeight !== undefined) { + this._handle.style.height = `${(rect.clientHeight / rect.scrollHeight) * 100}%`; + } + + if (rect.scrollTop !== undefined && rect.scrollHeight !== undefined) { + this._handle.style.top = `${(rect.scrollTop / rect.scrollHeight) * 100}%`; + } + } + + override connectedCallback(): void { + super.connectedCallback(); + this._disposable.dispose(); + } + + override firstUpdated(): void { + this._initWheelHandler(); + } + + override render() { + return html`<div + class="overlay-handle" + @pointerdown=${this._dragHandle} + ></div>`; + } + + @query('.overlay-handle') + private accessor _handle!: HTMLDivElement; +} + +declare global { + interface HTMLElementTagNameMap { + 'overlay-scrollbar': OverlayScrollbar; + } +} diff --git a/blocksuite/blocks/src/root-block/edgeless/components/toolbar/template/template-loading.ts b/blocksuite/blocks/src/root-block/edgeless/components/toolbar/template/template-loading.ts new file mode 100644 index 0000000000..5a5f222946 --- /dev/null +++ b/blocksuite/blocks/src/root-block/edgeless/components/toolbar/template/template-loading.ts @@ -0,0 +1,56 @@ +import { css, html, LitElement } from 'lit'; + +export class AffineTemplateLoading extends LitElement { + static override styles = css` + @keyframes affine-template-block-rotate { + from { + rotate: 0deg; + } + to { + rotate: 360deg; + } + } + + .affine-template-block-container { + width: 20px; + height: 20px; + overflow: hidden; + } + + .affine-template-block-loading { + display: inline-block; + width: 20px; + height: 20px; + position: relative; + background: conic-gradient( + rgba(30, 150, 235, 1) 90deg, + rgba(0, 0, 0, 0.1) 90deg 360deg + ); + border-radius: 50%; + animation: affine-template-block-rotate 1s infinite ease-in; + } + + .affine-template-block-loading::before { + content: ''; + width: 14px; + height: 14px; + border-radius: 50%; + background-color: white; + position: absolute; + top: 3px; + left: 3px; + } + `; + + override render() { + return html`<div class="affine-template-block-container"> + <div class="affine-template-block-loading"></div> + </div>`; + } +} + +declare global { + interface HTMLElementTagNameMap { + 'affine-template-loading': AffineTemplateLoading; + } +} diff --git a/blocksuite/blocks/src/root-block/edgeless/components/toolbar/template/template-panel.ts b/blocksuite/blocks/src/root-block/edgeless/components/toolbar/template/template-panel.ts new file mode 100644 index 0000000000..2b5cf6de1a --- /dev/null +++ b/blocksuite/blocks/src/root-block/edgeless/components/toolbar/template/template-panel.ts @@ -0,0 +1,519 @@ +import { + darkToolbarStyles, + lightToolbarStyles, +} from '@blocksuite/affine-components/toolbar'; +import { + EditPropsStore, + ThemeProvider, +} from '@blocksuite/affine-shared/services'; +import { + requestConnectedFrame, + stopPropagation, +} from '@blocksuite/affine-shared/utils'; +import { type Bound, WithDisposable } from '@blocksuite/global/utils'; +import { baseTheme } from '@toeverything/theme'; +import { css, html, LitElement, nothing, unsafeCSS } from 'lit'; +import { property, state } from 'lit/decorators.js'; +import { repeat } from 'lit/directives/repeat.js'; +import { styleMap } from 'lit/directives/style-map.js'; +import { unsafeSVG } from 'lit/directives/unsafe-svg.js'; + +import type { EdgelessRootBlockComponent } from '../../../edgeless-root-block.js'; +import { EdgelessDraggableElementController } from '../common/draggable/draggable-element.controller.js'; +import { builtInTemplates } from './builtin-templates.js'; +import { ArrowIcon, defaultPreview } from './icon.js'; +import type { Template } from './template-type.js'; +import { cloneDeep } from './utils.js'; + +export class EdgelessTemplatePanel extends WithDisposable(LitElement) { + static override styles = css` + :host { + position: absolute; + font-family: ${unsafeCSS(baseTheme.fontSansFamily)}; + z-index: 1; + } + + .edgeless-templates-panel { + width: 467px; + height: 568px; + border-radius: 12px; + background-color: var(--affine-background-overlay-panel-color); + box-shadow: 0px 10px 80px 0px rgba(0, 0, 0, 0.2); + + display: flex; + flex-direction: column; + } + .edgeless-templates-panel[data-app-theme='light'] { + ${unsafeCSS(lightToolbarStyles.join('\n'))} + } + .edgeless-templates-panel[data-app-theme='dark'] { + ${unsafeCSS(darkToolbarStyles.join('\n'))} + } + + .search-bar { + padding: 21px 24px; + font-size: 18px; + color: var(--affine-secondary); + border-bottom: 1px solid var(--affine-divider-color); + + flex-shrink: 0; + } + + .search-input { + border: 0; + color: var(--affine-text-primary-color); + font-size: 20px; + background-color: inherit; + outline: none; + width: 100%; + } + + .search-input::placeholder { + color: var(--affine-text-secondary-color); + } + + .template-categories { + display: flex; + padding: 6px 8px; + gap: 4px; + overflow-x: scroll; + + flex-shrink: 0; + } + + .category-entry { + color: var(--affine-text-primary-color); + font-size: 12px; + font-weight: 600; + line-height: 20px; + border-radius: 8px; + flex-shrink: 0; + flex-grow: 0; + width: fit-content; + padding: 4px 9px; + cursor: pointer; + } + + .category-entry.selected, + .category-entry:hover { + color: var(--affine-text-primary-color); + background-color: var(--affine-background-tertiary-color); + } + + .template-viewport { + position: relative; + flex-grow: 1; + } + + .template-scrollcontent { + overflow: hidden; + height: 100%; + width: 100%; + } + + .template-list { + padding: 10px; + display: flex; + align-items: flex-start; + align-content: flex-start; + gap: 10px 20px; + flex-wrap: wrap; + } + + .template-item { + position: relative; + width: 135px; + height: 80px; + box-shadow: 0px 4px 4px 0px rgba(0, 0, 0, 0.02); + background-color: var(--affine-background-primary-color); + border-radius: 4px; + cursor: pointer; + } + + .template-item > svg { + display: block; + margin: 0 auto; + width: 135px; + height: 80px; + color: var(--affine-background-primary-color); + } + + /* .template-item:hover::before { + content: attr(data-hover-text); + position: absolute; + display: block; + left: 50%; + top: 50%; + transform: translate(-50%, -50%); + width: 110px; + border-radius: 8px; + padding: 4px 22px; + box-sizing: border-box; + z-index: 1; + text-align: center; + font-size: 12px; + + background-color: var(--affine-primary-color); + color: var(--affine-white); + } */ + + .template-item:hover::after { + content: ''; + position: absolute; + left: 0; + top: 0; + width: 100%; + height: 100%; + box-sizing: border-box; + border: 1px solid var(--affine-black-10); + border-radius: 4px; + background-color: var(--affine-hover-color); + } + + .template-item.loading::before { + display: none; + } + + .template-item.loading > affine-template-loading { + position: absolute; + left: 50%; + top: 50%; + transform: translate(-50%, -50%); + } + + .template-item img.template-preview { + object-fit: contain; + width: 100%; + height: 100%; + display: block; + } + + .arrow { + bottom: 0; + position: absolute; + transform: translateY(20px); + color: var(--affine-background-overlay-panel-color); + } + `; + + static templates = builtInTemplates; + + private _fetchJob: null | { cancel: () => void } = null; + + draggableController!: EdgelessDraggableElementController<Template>; + + private _closePanel() { + if (this.isDragging) return; + this.dispatchEvent(new CustomEvent('closepanel')); + } + + private _fetch(fn: (state: { canceled: boolean }) => Promise<unknown>) { + if (this._fetchJob) { + this._fetchJob.cancel(); + } + + this._loading = true; + + const state = { canceled: false }; + const job = { + cancel: () => { + state.canceled = true; + }, + }; + + this._fetchJob = job; + + fn(state) + .catch(() => {}) + .finally(() => { + if (!state.canceled && job === this._fetchJob) { + this._loading = false; + this._fetchJob = null; + } + }); + } + + private _getLocalSelectedCategory() { + return this.edgeless.std.get(EditPropsStore).getStorage('templateCache'); + } + + private async _initCategory() { + try { + this._categories = await EdgelessTemplatePanel.templates.categories(); + this._currentCategory = + this._getLocalSelectedCategory() ?? this._categories[0]; + this._updateTemplates(); + } catch (e) { + console.error('Failed to load categories', e); + } + } + + private _initDragController() { + if (this.draggableController) return; + this.draggableController = new EdgelessDraggableElementController(this, { + service: this.edgeless.service, + edgeless: this.edgeless, + clickToDrag: true, + standardWidth: 560, + onOverlayCreated: overlay => { + this.isDragging = true; + overlay.mask.style.color = 'transparent'; + }, + onDrop: (el, bound) => { + this._insertTemplate(el.data, bound) + .finally(() => { + this.isDragging = false; + }) + .catch(console.error); + }, + onCanceled: () => { + this.isDragging = false; + }, + }); + } + + private async _insertTemplate(template: Template, bound: Bound) { + this._loadingTemplate = template; + + template = cloneDeep(template); + + const center = { + x: bound.x + bound.w / 2, + y: bound.y + bound.h / 2, + }; + const templateJob = this.edgeless.service.createTemplateJob( + template.type, + center + ); + const service = this.edgeless.service; + + try { + const { assets } = template; + + if (assets) { + await Promise.all( + Object.entries(assets).map(([key, value]) => + fetch(value) + .then(res => res.blob()) + .then(blob => templateJob.job.assets.set(key, blob)) + ) + ); + } + + const insertedBound = await templateJob.insertTemplate(template.content); + + if (insertedBound && template.type === 'template') { + const padding = 20 / service.viewport.zoom; + service.viewport.setViewportByBound( + insertedBound, + [padding, padding, padding, padding], + true + ); + } + } finally { + this._loadingTemplate = null; + this.edgeless.gfx.tool.setTool('default'); + } + } + + private _updateSearchKeyword(inputEvt: InputEvent) { + this._searchKeyword = (inputEvt.target as HTMLInputElement).value; + this._updateTemplates(); + } + + private _updateTemplates() { + this._fetch(async state => { + try { + const templates = this._searchKeyword + ? await EdgelessTemplatePanel.templates.search(this._searchKeyword) + : await EdgelessTemplatePanel.templates.list(this._currentCategory); + + if (state.canceled) return; + + this._templates = templates; + } catch (e) { + if (state.canceled) return; + + console.error('Failed to load templates', e); + } + }); + } + + override connectedCallback(): void { + super.connectedCallback(); + this._initDragController(); + + this.addEventListener('keydown', stopPropagation, false); + this._disposables.add(() => { + if (this._currentCategory) { + this.edgeless.std + .get(EditPropsStore) + .setStorage('templateCache', this._currentCategory); + } + }); + } + + override firstUpdated() { + requestConnectedFrame(() => { + this._disposables.addFromEvent(document, 'click', evt => { + if (this.contains(evt.target as HTMLElement)) { + return; + } + + this._closePanel(); + }); + }, this); + this._disposables.addFromEvent(this, 'click', stopPropagation); + this._disposables.addFromEvent(this, 'wheel', stopPropagation); + + this._initCategory().catch(() => {}); + } + + override render() { + const { _categories, _currentCategory, _templates } = this; + const { draggingElement } = this.draggableController?.states || {}; + const appTheme = this.edgeless.std.get(ThemeProvider).app$.value; + + return html` + <div + class="edgeless-templates-panel" + data-app-theme=${appTheme} + style=${styleMap({ + opacity: this.isDragging ? '0' : '1', + transition: 'opacity 0.2s', + })} + > + <div class="search-bar"> + <input + class="search-input" + type="text" + placeholder="Search file or anything..." + @input=${this._updateSearchKeyword} + @cut=${stopPropagation} + @copy=${stopPropagation} + @paste=${stopPropagation} + /> + </div> + <div class="template-categories"> + ${repeat( + _categories, + cate => cate, + cate => { + return html`<div + class="category-entry ${_currentCategory === cate + ? 'selected' + : ''}" + @click=${() => { + this._currentCategory = cate; + this._updateTemplates(); + }} + > + ${cate} + </div>`; + } + )} + </div> + <div class="template-viewport"> + <div class="template-scrollcontent" data-scrollable> + <div class="template-list"> + ${this._loading + ? html`<affine-template-loading + style=${styleMap({ + position: 'absolute', + left: '50%', + top: '50%', + })} + ></affine-template-loading>` + : repeat( + _templates, + template => template.name, + template => { + const preview = template.preview + ? template.preview.startsWith('<svg') + ? html`${unsafeSVG(template.preview)}` + : html`<img + src="${template.preview}" + class="template-preview" + loading="lazy" + />` + : defaultPreview; + + const isBeingDragged = + draggingElement && + draggingElement.data.name === template.name; + return html` + <div + class=${`template-item ${ + template === this._loadingTemplate ? 'loading' : '' + }`} + style=${styleMap({ + opacity: isBeingDragged ? '0' : '1', + })} + data-hover-text="Add" + @mousedown=${(e: MouseEvent) => + this.draggableController.onMouseDown(e, { + data: template, + preview, + })} + @touchstart=${(e: TouchEvent) => { + this.draggableController.onTouchStart(e, { + data: template, + preview, + }); + }} + > + ${preview} + ${template === this._loadingTemplate + ? html`<affine-template-loading></affine-template-loading>` + : nothing} + ${template.name + ? html`<affine-tooltip + .offset=${12} + tip-position="top" + > + ${template.name} + </affine-tooltip>` + : nothing} + </div> + `; + } + )} + </div> + </div> + <overlay-scrollbar></overlay-scrollbar> + </div> + <div class="arrow">${ArrowIcon}</div> + </div> + `; + } + + @state() + private accessor _categories: string[] = []; + + @state() + private accessor _currentCategory = ''; + + @state() + private accessor _loading = false; + + @state() + private accessor _loadingTemplate: Template | null = null; + + @state() + private accessor _searchKeyword = ''; + + @state() + private accessor _templates: Template[] = []; + + @property({ attribute: false }) + accessor edgeless!: EdgelessRootBlockComponent; + + @state() + accessor isDragging = false; +} + +declare global { + interface HTMLElementTagNameMap { + 'edgeless-templates-panel': EdgelessTemplatePanel; + } +} diff --git a/blocksuite/blocks/src/root-block/edgeless/components/toolbar/template/template-tool-button.ts b/blocksuite/blocks/src/root-block/edgeless/components/toolbar/template/template-tool-button.ts new file mode 100644 index 0000000000..ff3f5bbf0b --- /dev/null +++ b/blocksuite/blocks/src/root-block/edgeless/components/toolbar/template/template-tool-button.ts @@ -0,0 +1,219 @@ +import { ArrowDownSmallIcon } from '@blocksuite/affine-components/icons'; +import { once } from '@blocksuite/affine-shared/utils'; +import type { GfxToolsFullOptionValue } from '@blocksuite/block-std/gfx'; +import { + arrow, + autoUpdate, + computePosition, + offset, + shift, +} from '@floating-ui/dom'; +import { css, html, LitElement } from 'lit'; +import { state } from 'lit/decorators.js'; +import { classMap } from 'lit/directives/class-map.js'; +import { repeat } from 'lit/directives/repeat.js'; + +import { EdgelessToolbarToolMixin } from '../mixins/tool.mixin.js'; +import { TemplateCard1, TemplateCard2, TemplateCard3 } from './icon.js'; +import type { EdgelessTemplatePanel } from './template-panel.js'; + +export class EdgelessTemplateButton extends EdgelessToolbarToolMixin( + LitElement +) { + static override styles = css` + :host { + position: relative; + width: 100%; + height: 100%; + } + + edgeless-template-button { + cursor: pointer; + } + + .template-cards { + width: 100%; + height: 64px; + overflow: hidden; + display: flex; + justify-content: center; + align-items: center; + position: relative; + } + .template-card, + .arrow-icon { + --x: 0; + --y: 0; + --r: 0; + --s: 1; + position: absolute; + transform: translate(var(--x), var(--y)) rotate(var(--r)) scale(var(--s)); + transition: all 0.3s ease; + } + + .arrow-icon { + --y: 17px; + background: var(--affine-black-10); + width: 24px; + height: 24px; + border-radius: 50%; + display: flex; + justify-content: center; + align-items: center; + } + .arrow-icon > svg { + color: var(--affine-icon-color); + fill: currentColor; + width: 20px; + height: 20px; + } + + .template-card.card1 { + transform-origin: 100% 50%; + --x: 15px; + --y: 8px; + } + .template-card.card2 { + transform-origin: 0% 50%; + --x: -17px; + } + .template-card.card3 { + --y: 27px; + } + + /* hover */ + .template-cards:not(.expanded):hover .card1 { + --r: 8.69deg; + } + .template-cards:not(.expanded):hover .card2 { + --r: -10.93deg; + } + .template-cards:not(.expanded):hover .card3 { + --y: 22px; + --r: 5.19deg; + } + + /* expanded */ + .template-cards.expanded .card1 { + --x: 17px; + --y: -5px; + --r: 8.69deg; + --s: 0.64; + } + .template-cards.expanded .card2 { + --x: -19px; + --y: -6px; + --r: -10.93deg; + --s: 0.64; + } + .template-cards.expanded .card3 { + --y: -10px; + --s: 0.599; + --r: 5.19deg; + } + `; + + private _cleanup: (() => void) | null = null; + + private _prevTool: GfxToolsFullOptionValue | null = null; + + override enableActiveBackground = true; + + override type: GfxToolsFullOptionValue['type'] = 'template'; + + get cards() { + const { theme } = this; + return [TemplateCard1[theme], TemplateCard2[theme], TemplateCard3[theme]]; + } + + private _closePanel() { + if (this._openedPanel) { + this._openedPanel.remove(); + this._openedPanel = null; + this._cleanup?.(); + this._cleanup = null; + this.requestUpdate(); + + if (this._prevTool && this._prevTool.type !== 'template') { + this.setEdgelessTool(this._prevTool); + this._prevTool = null; + } else { + this.setEdgelessTool('default'); + } + } + } + + private _togglePanel() { + if (this._openedPanel) { + this._closePanel(); + if (this._prevTool) { + this.setEdgelessTool(this._prevTool); + this._prevTool = null; + } + return; + } + + this._prevTool = this.edgelessTool ? { ...this.edgelessTool } : null; + + this.setEdgelessTool('template'); + + const panel = document.createElement('edgeless-templates-panel'); + panel.edgeless = this.edgeless; + + this._cleanup = once(panel, 'closepanel', () => { + this._closePanel(); + }); + this._openedPanel = panel; + + this.renderRoot.append(panel); + + requestAnimationFrame(() => { + const arrowEl = panel.renderRoot.querySelector('.arrow') as HTMLElement; + + autoUpdate(this, panel, () => { + computePosition(this, panel, { + placement: 'top', + middleware: [offset(20), arrow({ element: arrowEl }), shift()], + }) + .then(({ x, y, middlewareData }) => { + panel.style.left = `${x}px`; + panel.style.top = `${y}px`; + + arrowEl.style.left = `${ + (middlewareData.arrow?.x ?? 0) - (middlewareData.shift?.x ?? 0) + }px`; + }) + .catch(e => { + console.warn("Can't compute position", e); + }); + }); + }); + } + + override render() { + const { cards, _openedPanel } = this; + const expanded = _openedPanel !== null; + + return html`<edgeless-toolbar-button @click=${this._togglePanel}> + <div class="template-cards ${expanded ? 'expanded' : ''}"> + <div class="arrow-icon">${ArrowDownSmallIcon}</div> + ${repeat( + cards, + (card, n) => html` + <div + class=${classMap({ + 'template-card': true, + [`card${n + 1}`]: true, + })} + > + ${card} + </div> + ` + )} + </div> + </edgeless-toolbar-button>`; + } + + @state() + private accessor _openedPanel: EdgelessTemplatePanel | null = null; +} diff --git a/blocksuite/blocks/src/root-block/edgeless/components/toolbar/template/template-type.ts b/blocksuite/blocks/src/root-block/edgeless/components/toolbar/template/template-type.ts new file mode 100644 index 0000000000..a8eb0453e7 --- /dev/null +++ b/blocksuite/blocks/src/root-block/edgeless/components/toolbar/template/template-type.ts @@ -0,0 +1,42 @@ +export type Template = { + /** + * name of the sticker + * + * if not provided, it cannot be searched + */ + name?: string; + + /** + * template content + */ + content: unknown; + + /** + * external assets + */ + assets?: Record<string, string>; + + preview?: string; + + /** + * type of template + * `template`: normal template, looks like an article + * `sticker`: sticker template, only contains one image block under surface block + */ + type: 'template' | 'sticker'; +}; + +export type TemplateCategory = { + name: string; + templates: Template[] | (() => Promise<Template[]>); +}; + +export interface TemplateManager { + list(category: string): Promise<Template[]> | Template[]; + + categories(): Promise<string[]> | string[]; + + search(keyword: string, category?: string): Promise<Template[]> | Template[]; + + extend?(manager: TemplateManager): void; +} diff --git a/blocksuite/blocks/src/root-block/edgeless/components/toolbar/template/utils.ts b/blocksuite/blocks/src/root-block/edgeless/components/toolbar/template/utils.ts new file mode 100644 index 0000000000..0c4d441460 --- /dev/null +++ b/blocksuite/blocks/src/root-block/edgeless/components/toolbar/template/utils.ts @@ -0,0 +1,33 @@ +import { on } from '@blocksuite/affine-shared/utils'; + +export function onClickOutside(target: HTMLElement, fn: () => void) { + return on(document, 'click', (evt: MouseEvent) => { + if (target.contains(evt.target as Node)) return; + + fn(); + + return; + }); +} + +export function cloneDeep<T>(obj: T): T { + const seen = new WeakMap(); + + const clone = (val: unknown) => { + if (typeof val !== 'object' || val === null) return val; + if (seen.has(val)) return seen.get(val); + + const copy = Array.isArray(val) ? [] : {}; + + seen.set(val, copy); + + Object.keys(val).forEach(key => { + // @ts-expect-error FIXME: ts error + copy[key] = clone(val[key]); + }); + + return copy; + }; + + return clone(obj); +} diff --git a/blocksuite/blocks/src/root-block/edgeless/components/toolbar/text/text-menu.ts b/blocksuite/blocks/src/root-block/edgeless/components/toolbar/text/text-menu.ts new file mode 100644 index 0000000000..3bd3001cff --- /dev/null +++ b/blocksuite/blocks/src/root-block/edgeless/components/toolbar/text/text-menu.ts @@ -0,0 +1,45 @@ +import type { GfxToolsFullOptionValue } from '@blocksuite/block-std/gfx'; +import { css, html, LitElement, nothing } from 'lit'; +import { property } from 'lit/decorators.js'; + +import type { ColorEvent } from '../../panel/color-panel.js'; +import { EdgelessToolbarToolMixin } from '../mixins/tool.mixin.js'; + +export class EdgelessTextMenu extends EdgelessToolbarToolMixin(LitElement) { + static override styles = css` + :host { + display: flex; + position: absolute; + z-index: -1; + } + `; + + override type: GfxToolsFullOptionValue['type'] = 'text'; + + override render() { + if (this.edgelessTool.type !== 'text') return nothing; + + return html` + <edgeless-slide-menu> + <div class="menu-content"> + <edgeless-one-row-color-panel + .value=${this.color} + @select=${(e: ColorEvent) => this.onChange({ color: e.detail })} + ></edgeless-one-row-color-panel> + </div> + </edgeless-slide-menu> + `; + } + + @property({ attribute: false }) + accessor color!: string; + + @property({ attribute: false }) + accessor onChange!: (props: Record<string, unknown>) => void; +} + +declare global { + interface HTMLElementTagNameMap { + 'edgeless-text-menu': EdgelessTextMenu; + } +} diff --git a/blocksuite/blocks/src/root-block/edgeless/components/toolbar/tools.ts b/blocksuite/blocks/src/root-block/edgeless/components/toolbar/tools.ts new file mode 100644 index 0000000000..ca748e62df --- /dev/null +++ b/blocksuite/blocks/src/root-block/edgeless/components/toolbar/tools.ts @@ -0,0 +1,164 @@ +import type { MenuConfig } from '@blocksuite/affine-components/context-menu'; +import type { GfxToolsMap } from '@blocksuite/block-std/gfx'; +import { html, type TemplateResult } from 'lit'; + +import type { EdgelessRootBlockComponent } from '../../edgeless-root-block.js'; +import { buildConnectorDenseMenu } from './connector/connector-dense-menu.js'; +import { buildFrameDenseMenu } from './frame/frame-dense-menu.js'; +import { buildLinkDenseMenu } from './link/link-dense-menu.js'; + +export interface QuickTool { + type?: keyof GfxToolsMap; + content: TemplateResult; + /** + * if not configured, the tool will not be shown in dense mode + */ + menu?: MenuConfig; +} +export interface SeniorTool { + /** + * Used to show in nav-button's tooltip + */ + name: string; + content: TemplateResult; +} + +/** + * Get quick-tool list + */ +export const getQuickTools = ({ + edgeless, +}: { + edgeless: EdgelessRootBlockComponent; +}) => { + const { doc } = edgeless; + const quickTools: QuickTool[] = []; + + // 🔧 Hands / Pointer + quickTools.push({ + type: 'default', + content: html`<edgeless-default-tool-button + .edgeless=${edgeless} + ></edgeless-default-tool-button>`, + // menu: will never show because the first tool will never hide + }); + + // 🔧 Lasso + // if (doc.awarenessStore.getFlag('enable_lasso_tool')) { + // quickTools.push({ + // type: 'lasso', + // content: html`<edgeless-lasso-tool-button + // .edgeless=${edgeless} + // ></edgeless-lasso-tool-button>`, + // menu: buildLassoDenseMenu(edgeless), + // }); + // } + + // 🔧 Frame + if (!doc.readonly) { + quickTools.push({ + type: 'frame', + content: html`<edgeless-frame-tool-button + .edgeless=${edgeless} + ></edgeless-frame-tool-button>`, + menu: buildFrameDenseMenu(edgeless), + }); + } + + // 🔧 Connector + quickTools.push({ + type: 'connector', + content: html`<edgeless-connector-tool-button + .edgeless=${edgeless} + ></edgeless-connector-tool-button>`, + menu: buildConnectorDenseMenu(edgeless), + }); + + // 🔧 Present + // quickTools.push({ + // type: 'frameNavigator', + // content: html`<edgeless-present-button + // .edgeless=${edgeless} + // ></edgeless-present-button>`, + // }); + + // 🔧 Note + // if (!doc.readonly) { + // quickTools.push({ + // type: 'affine:note', + // content: html` + // <edgeless-note-tool-button + // .edgeless=${edgeless} + // ></edgeless-note-tool-button> + // `, + // }); + // } + + // Link + quickTools.push({ + content: html`<edgeless-link-tool-button + .edgeless=${edgeless} + ></edgeless-link-tool-button>`, + menu: buildLinkDenseMenu(edgeless), + }); + return quickTools; +}; + +export const getSeniorTools = ({ + edgeless, + toolbarContainer, +}: { + edgeless: EdgelessRootBlockComponent; + toolbarContainer: HTMLElement; +}): SeniorTool[] => { + const { doc } = edgeless; + const tools: SeniorTool[] = []; + + if (!doc.readonly) { + tools.push({ + name: 'Note', + content: html`<edgeless-note-senior-button .edgeless=${edgeless}> + </edgeless-note-senior-button>`, + }); + } + + // Brush / Eraser + tools.push({ + name: 'Pen', + content: html`<div class="brush-and-eraser"> + <edgeless-brush-tool-button + .edgeless=${edgeless} + ></edgeless-brush-tool-button> + + <edgeless-eraser-tool-button + .edgeless=${edgeless} + ></edgeless-eraser-tool-button> + </div> `, + }); + + // Shape + tools.push({ + name: 'Shape', + content: html`<edgeless-shape-tool-button + .edgeless=${edgeless} + .toolbarContainer=${toolbarContainer} + ></edgeless-shape-tool-button>`, + }); + + tools.push({ + name: 'Mind Map', + content: html`<edgeless-mindmap-tool-button + .edgeless=${edgeless} + .toolbarContainer=${toolbarContainer} + ></edgeless-mindmap-tool-button>`, + }); + + // Template + tools.push({ + name: 'Template', + content: html`<edgeless-template-button .edgeless=${edgeless}> + </edgeless-template-button>`, + }); + + return tools; +}; diff --git a/blocksuite/blocks/src/root-block/edgeless/components/utils.ts b/blocksuite/blocks/src/root-block/edgeless/components/utils.ts new file mode 100644 index 0000000000..3a7da89a0a --- /dev/null +++ b/blocksuite/blocks/src/root-block/edgeless/components/utils.ts @@ -0,0 +1,258 @@ +import { CommonUtils } from '@blocksuite/affine-block-surface'; +import type { CursorType, StandardCursor } from '@blocksuite/block-std/gfx'; +import type { IVec } from '@blocksuite/global/utils'; +import { assertExists, Bound, Vec } from '@blocksuite/global/utils'; +import { css, html } from 'lit'; + +import { + SURFACE_IMAGE_CARD_HEIGHT, + SURFACE_IMAGE_CARD_WIDTH, +} from '../../../image-block/components/image-block-fallback.js'; + +// "<svg width='32' height='32' viewBox='0 0 32 32' xmlns='http://www.w3.org/2000/svg'><g><path fill='white' d='M13.7,18.5h3.9l0-1.5c0-1.4-1.2-2.6-2.6-2.6h-1.5v3.9l-5.8-5.8l5.8-5.8v3.9h2.3c3.1,0,5.6,2.5,5.6,5.6v2.3h3.9l-5.8,5.8L13.7,18.5z'/><path d='M20.4,19.4v-3.2c0-2.6-2.1-4.7-4.7-4.7h-3.2l0,0V9L9,12.6l3.6,3.6v-2.6l0,0H15c1.9,0,3.5,1.6,3.5,3.5v2.4l0,0h-2.6l3.6,3.6l3.6-3.6L20.4,19.4L20.4,19.4z'/></g></svg>"; +export function generateCursorUrl( + angle = 0, + fallback: StandardCursor = 'default' +): CursorType { + return `url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='32' height='32' viewBox='0 0 32 32'%3E%3Cg transform='rotate(${angle} 16 16)'%3E%3Cpath fill='white' d='M13.7,18.5h3.9l0-1.5c0-1.4-1.2-2.6-2.6-2.6h-1.5v3.9l-5.8-5.8l5.8-5.8v3.9h2.3c3.1,0,5.6,2.5,5.6,5.6v2.3h3.9l-5.8,5.8L13.7,18.5z'/%3E%3Cpath d='M20.4,19.4v-3.2c0-2.6-2.1-4.7-4.7-4.7h-3.2l0,0V9L9,12.6l3.6,3.6v-2.6l0,0H15c1.9,0,3.5,1.6,3.5,3.5v2.4l0,0h-2.6l3.6,3.6l3.6-3.6L20.4,19.4L20.4,19.4z'/%3E%3C/g%3E%3C/svg%3E") 16 16, ${fallback}`; +} + +export function getCommonRectStyle( + rect: DOMRect, + active = false, + selected = false, + rotate = 0 +) { + return { + '--affine-border-width': `${active ? 2 : 1}px`, + width: `${rect.width}px`, + height: `${rect.height}px`, + transform: `translate(${rect.x}px, ${rect.y}px) rotate(${rotate}deg)`, + backgroundColor: !active && selected ? 'var(--affine-hover-color)' : '', + }; +} + +export function getTooltipWithShortcut( + tip: string, + shortcut?: string, + postfix?: string +) { + // style for shortcut tooltip + const styles = css` + .tooltip-with-shortcut { + display: flex; + flex-wrap: nowrap; + align-items: center; + gap: 10px; + } + .tooltip__shortcut { + font-size: 12px; + position: relative; + + display: flex; + align-items: center; + justify-content: center; + height: 16px; + min-width: 16px; + } + .tooltip__shortcut::before { + content: ''; + border-radius: 4px; + position: absolute; + inset: 0; + background: currentColor; + opacity: 0.2; + } + .tooltip__label { + white-space: pre; + } + `; + return html`<style> + ${styles} + </style> + <div class="tooltip-with-shortcut"> + <span class="tooltip__label">${tip}</span> + ${shortcut + ? html`<span class="tooltip__shortcut">${shortcut}</span>` + : ''} + ${postfix ? html`<span class="tooltip__postfix">${postfix}</span>` : ''} + </div>`; +} + +export function readImageSize(file: File) { + return new Promise<{ width: number; height: number }>(resolve => { + const size = { width: 0, height: 0 }; + const img = new Image(); + + img.onload = () => { + size.width = img.width; + size.height = img.height; + URL.revokeObjectURL(img.src); + resolve(size); + }; + + img.onerror = () => { + URL.revokeObjectURL(img.src); + resolve(size); + }; + + img.src = URL.createObjectURL(file); + }); +} + +const RESIZE_CURSORS: CursorType[] = [ + 'ew-resize', + 'nwse-resize', + 'ns-resize', + 'nesw-resize', +]; +export function rotateResizeCursor(angle: number): StandardCursor { + const a = Math.round(angle / (Math.PI / 4)); + const cursor = RESIZE_CURSORS[a % RESIZE_CURSORS.length]; + return cursor as StandardCursor; +} + +export function calcAngle(target: HTMLElement, point: IVec, offset = 0) { + const rect = target + .closest('.affine-edgeless-selected-rect') + ?.getBoundingClientRect(); + assertExists(rect); + const { left, top, right, bottom } = rect; + const center = Vec.med([left, top], [right, bottom]); + return CommonUtils.normalizeDegAngle( + ((Vec.angle(center, point) + offset) * 180) / Math.PI + ); +} + +export function calcAngleWithRotation( + target: HTMLElement, + point: IVec, + rect: DOMRect, + rotate: number +) { + const handle = target.parentElement; + assertExists(handle); + const ariaLabel = handle.getAttribute('aria-label'); + assertExists(ariaLabel); + const { left, top, right, bottom, width, height } = rect; + const size = Math.min(width, height); + const sx = size / width; + const sy = size / height; + const center = Vec.med([left, top], [right, bottom]); + const draggingPoint = [0, 0]; + + switch (ariaLabel) { + case 'top-left': { + draggingPoint[0] = left; + draggingPoint[1] = top; + break; + } + case 'top-right': { + draggingPoint[0] = right; + draggingPoint[1] = top; + break; + } + case 'bottom-right': { + draggingPoint[0] = right; + draggingPoint[1] = bottom; + break; + } + case 'bottom-left': { + draggingPoint[0] = left; + draggingPoint[1] = bottom; + break; + } + } + + const dp = new DOMMatrix() + .translateSelf(center[0], center[1]) + .rotateSelf(rotate) + .translateSelf(-center[0], -center[1]) + .transformPoint(new DOMPoint(...draggingPoint)); + + const m = new DOMMatrix() + .translateSelf(dp.x, dp.y) + .rotateSelf(rotate) + .translateSelf(-dp.x, -dp.y) + .scaleSelf(sx, sy, 1, dp.x, dp.y, 0) + .translateSelf(dp.x, dp.y) + .rotateSelf(-rotate) + .translateSelf(-dp.x, -dp.y); + + const c = new DOMPoint(...center).matrixTransform(m); + + return CommonUtils.normalizeDegAngle( + (Vec.angle([c.x, c.y], point) * 180) / Math.PI + ); +} + +export function calcAngleEdgeWithRotation(target: HTMLElement, rotate: number) { + let angleWithEdge = 0; + const handle = target.parentElement; + assertExists(handle); + const ariaLabel = handle.getAttribute('aria-label'); + assertExists(ariaLabel); + switch (ariaLabel) { + case 'top': { + angleWithEdge = 270; + break; + } + case 'bottom': { + angleWithEdge = 90; + break; + } + case 'left': { + angleWithEdge = 180; + break; + } + case 'right': { + angleWithEdge = 0; + break; + } + } + + return angleWithEdge + rotate; +} + +export function getResizeLabel(target: HTMLElement) { + const handle = target.parentElement; + assertExists(handle); + const ariaLabel = handle.getAttribute('aria-label'); + assertExists(ariaLabel); + return ariaLabel; +} + +export function launchIntoFullscreen(element: Element) { + if (element.requestFullscreen) { + element.requestFullscreen().catch(console.error); + } else if ( + 'mozRequestFullScreen' in element && + element.mozRequestFullScreen instanceof Function + ) { + // Firefox + element.mozRequestFullScreen(); + } else if ( + 'webkitRequestFullscreen' in element && + element.webkitRequestFullscreen instanceof Function + ) { + // Chrome, Safari and Opera + element.webkitRequestFullscreen(); + } else if ( + 'msRequestFullscreen' in element && + element.msRequestFullscreen instanceof Function + ) { + // IE/Edge + element.msRequestFullscreen(); + } +} + +export function calcBoundByOrigin( + point: IVec, + inTopLeft = false, + width = SURFACE_IMAGE_CARD_WIDTH, + height = SURFACE_IMAGE_CARD_HEIGHT +) { + return inTopLeft + ? new Bound(point[0], point[1], width, height) + : Bound.fromCenter(point, width, height); +} diff --git a/blocksuite/blocks/src/root-block/edgeless/edgeless-keyboard.ts b/blocksuite/blocks/src/root-block/edgeless/edgeless-keyboard.ts new file mode 100644 index 0000000000..5f8fad0ae1 --- /dev/null +++ b/blocksuite/blocks/src/root-block/edgeless/edgeless-keyboard.ts @@ -0,0 +1,744 @@ +/* eslint-disable @typescript-eslint/no-non-null-assertion */ +import { + ConnectorElementModel, + ConnectorMode, + EdgelessTextBlockModel, + GroupElementModel, + LayoutType, + MindmapElementModel, + NoteDisplayMode, + type ShapeElementModel, +} from '@blocksuite/affine-model'; +import { + EditPropsStore, + TelemetryProvider, +} from '@blocksuite/affine-shared/services'; +import { matchFlavours } from '@blocksuite/affine-shared/utils'; +import { + type GfxToolsMap, + type GfxToolsOption, + isGfxGroupCompatibleModel, +} from '@blocksuite/block-std/gfx'; +import { IS_MAC } from '@blocksuite/global/env'; +import { Bound } from '@blocksuite/global/utils'; + +import { + getNearestTranslation, + isElementOutsideViewport, + isSingleMindMapNode, +} from '../../_common/edgeless/mindmap/index.js'; +import { LassoMode } from '../../_common/types.js'; +import { EdgelessTextBlockComponent } from '../../edgeless-text-block/edgeless-text-block.js'; +import { PageKeyboardManager } from '../keyboard/keyboard-manager.js'; +import { GfxBlockModel } from './block-model.js'; +import type { EdgelessRootBlockComponent } from './edgeless-root-block.js'; +import { CopilotTool } from './gfx-tool/copilot-tool.js'; +import { LassoTool } from './gfx-tool/lasso-tool.js'; +import { ShapeTool } from './gfx-tool/shape-tool.js'; +import { + DEFAULT_NOTE_CHILD_FLAVOUR, + DEFAULT_NOTE_CHILD_TYPE, + DEFAULT_NOTE_TIP, +} from './utils/consts.js'; +import { deleteElements } from './utils/crud.js'; +import { getNextShapeType } from './utils/hotkey-utils.js'; +import { isCanvasElement, isNoteBlock } from './utils/query.js'; +import { + mountConnectorLabelEditor, + mountShapeTextEditor, +} from './utils/text.js'; + +export class EdgelessPageKeyboardManager extends PageKeyboardManager { + constructor(override rootComponent: EdgelessRootBlockComponent) { + super(rootComponent); + this.rootComponent.bindHotKey( + { + v: () => { + this._setEdgelessTool('default'); + }, + t: () => { + this._setEdgelessTool('text'); + }, + c: () => { + const mode = ConnectorMode.Curve; + rootComponent.std.get(EditPropsStore).recordLastProps('connector', { + mode, + }); + this._setEdgelessTool('connector', { mode }); + }, + l: () => { + if (!rootComponent.doc.awarenessStore.getFlag('enable_lasso_tool')) { + return; + } + + this._setEdgelessTool('lasso', { + mode: LassoMode.Polygonal, + }); + }, + 'Shift-l': () => { + if (!rootComponent.doc.awarenessStore.getFlag('enable_lasso_tool')) { + return; + } + // toggle between lasso modes + const edgeless = rootComponent; + const cur = edgeless.gfx.tool.currentTool$.peek(); + + this._setEdgelessTool('lasso', { + mode: + cur?.toolName === 'lasso' + ? (cur as LassoTool).activatedOption.mode === LassoMode.FreeHand + ? LassoMode.Polygonal + : LassoMode.FreeHand + : LassoMode.FreeHand, + }); + }, + h: () => { + this._setEdgelessTool('pan', { + panning: false, + }); + }, + n: () => { + this._setEdgelessTool('affine:note', { + childFlavour: DEFAULT_NOTE_CHILD_FLAVOUR, + childType: DEFAULT_NOTE_CHILD_TYPE, + tip: DEFAULT_NOTE_TIP, + }); + }, + p: () => { + this._setEdgelessTool('brush'); + }, + e: () => { + this._setEdgelessTool('eraser'); + }, + k: () => { + if (this.rootComponent.service.locked) return; + const { selection } = rootComponent.service; + + if ( + selection.selectedElements.length === 1 && + selection.firstElement instanceof GfxBlockModel && + matchFlavours(selection.firstElement as GfxBlockModel, [ + 'affine:note', + ]) + ) { + rootComponent.slots.toggleNoteSlicer.emit(); + } + }, + f: () => { + if (this.rootComponent.service.locked) return; + if ( + this.rootComponent.service.selection.selectedElements.length !== + 0 && + !this.rootComponent.service.selection.editing + ) { + const frame = rootComponent.service.frame.createFrameOnSelected(); + if (!frame) return; + this.rootComponent.std + .getOptional(TelemetryProvider) + ?.track('CanvasElementAdded', { + control: 'shortcut', + page: 'whiteboard editor', + module: 'toolbar', + segment: 'toolbar', + type: 'frame', + }); + rootComponent.surface.fitToViewport(Bound.deserialize(frame.xywh)); + } else if (!this.rootComponent.service.selection.editing) { + this._setEdgelessTool('frame'); + } + }, + '-': () => { + if (this.rootComponent.service.locked) return; + const { selectedElements: elements } = + rootComponent.service.selection; + if ( + !rootComponent.service.selection.editing && + elements.length === 1 && + isNoteBlock(elements[0]) + ) { + rootComponent.slots.toggleNoteSlicer.emit(); + } + }, + '@': () => { + const std = this.rootComponent.std; + if ( + std.selection.getGroup('note').length > 0 || + // eslint-disable-next-line + std.selection.find('text') || + Boolean(std.selection.find('surface')?.editing) + ) { + return; + } + const { insertedLinkType } = std.command.exec( + 'insertLinkByQuickSearch' + ); + + insertedLinkType + ?.then(type => { + const flavour = type?.flavour; + if (!flavour) return; + + rootComponent.std + .getOptional(TelemetryProvider) + ?.track('CanvasElementAdded', { + control: 'shortcut', + page: 'whiteboard editor', + module: 'toolbar', + segment: 'toolbar', + type: flavour.split(':')[1], + }); + }) + .catch(console.error); + }, + 'Shift-s': () => { + if (this.rootComponent.service.locked) return; + const controller = rootComponent.gfx.tool.currentTool$.peek(); + if ( + this.rootComponent.service.selection.editing || + !(controller instanceof ShapeTool) + ) { + return; + } + const { shapeName } = controller.activatedOption; + const nextShapeName = getNextShapeType(shapeName); + this._setEdgelessTool('shape', { + shapeName: nextShapeName, + }); + + controller.createOverlay(); + }, + 'Mod-g': ctx => { + if (this.rootComponent.service.locked) return; + if ( + this.rootComponent.service.selection.selectedElements.length > 1 && + !this.rootComponent.service.selection.editing + ) { + ctx.get('keyboardState').event.preventDefault(); + rootComponent.service.createGroupFromSelected(); + } + }, + 'Shift-Mod-g': ctx => { + if (this.rootComponent.service.locked) return; + const { selection } = this.rootComponent.service; + if ( + selection.selectedElements.length === 1 && + selection.firstElement instanceof GroupElementModel && + !selection.firstElement.isLocked() + ) { + ctx.get('keyboardState').event.preventDefault(); + rootComponent.service.ungroup(selection.firstElement); + } + }, + 'Mod-a': ctx => { + if (this.rootComponent.service.locked) return; + if (this.rootComponent.service.selection.editing) { + return; + } + + ctx.get('defaultState').event.preventDefault(); + const { service } = this.rootComponent; + this.rootComponent.service.selection.set({ + elements: [ + ...service.blocks + .filter( + block => + block.group === null && + !( + matchFlavours(block, ['affine:note']) && + block.displayMode === NoteDisplayMode.DocOnly + ) + ) + .map(block => block.id), + ...service.elements + .filter(el => el.group === null) + .map(el => el.id), + ], + editing: false, + }); + }, + 'Mod-1': ctx => { + ctx.get('defaultState').event.preventDefault(); + this.rootComponent.service.setZoomByAction('fit'); + }, + 'Mod--': ctx => { + ctx.get('defaultState').event.preventDefault(); + this.rootComponent.service.setZoomByAction('out'); + }, + 'Mod-0': ctx => { + ctx.get('defaultState').event.preventDefault(); + this.rootComponent.service.setZoomByAction('reset'); + }, + 'Mod-=': ctx => { + ctx.get('defaultState').event.preventDefault(); + this.rootComponent.service.setZoomByAction('in'); + }, + Backspace: () => { + this._delete(); + }, + Delete: () => { + this._delete(); + }, + 'Control-d': () => { + if (!IS_MAC) return; + this._delete(); + }, + Escape: () => { + const currentTool = this.rootComponent.gfx.tool.currentTool$.peek(); + if (currentTool instanceof LassoTool && currentTool.isSelecting) { + currentTool.abort(); + } + if (currentTool instanceof CopilotTool) { + currentTool.abort(); + } + + if (!this.rootComponent.service.selection.empty) { + rootComponent.selection.clear(); + } + }, + + ArrowUp: () => { + this._move('ArrowUp'); + }, + + ArrowDown: () => { + this._move('ArrowDown'); + }, + + ArrowLeft: () => { + this._move('ArrowLeft'); + }, + + ArrowRight: () => { + this._move('ArrowRight'); + }, + + 'Shift-ArrowUp': () => { + this._move('ArrowUp', true); + }, + + 'Shift-ArrowDown': () => { + this._move('ArrowDown', true); + }, + + 'Shift-ArrowLeft': () => { + this._move('ArrowLeft', true); + }, + + 'Shift-ArrowRight': () => { + this._move('ArrowRight', true); + }, + + Enter: () => { + const { service } = rootComponent; + const selection = service.selection; + const elements = selection.selectedElements; + const onlyOne = elements.length === 1; + + if (onlyOne) { + const element = elements[0]; + const id = element.id; + + if (element.isLocked()) return; + + if (element instanceof ConnectorElementModel) { + selection.set({ + elements: [id], + editing: true, + }); + requestAnimationFrame(() => { + mountConnectorLabelEditor(element, rootComponent); + }); + return; + } + + if (element instanceof EdgelessTextBlockModel) { + selection.set({ + elements: [id], + editing: true, + }); + const textBlock = rootComponent.host.view.getBlock(id); + if (textBlock instanceof EdgelessTextBlockComponent) { + textBlock.tryFocusEnd(); + } + + return; + } + } + + if (!isSingleMindMapNode(elements)) { + return; + } + + const mindmap = elements[0].group as MindmapElementModel; + const currentNode = mindmap.getNode(elements[0].id)!; + const node = mindmap.getNode(elements[0].id)!; + const parent = mindmap.getParentNode(node.id) ?? node; + const id = mindmap.addNode(parent.id, currentNode.id, 'after'); + const target = service.getElementById(id) as ShapeElementModel; + + requestAnimationFrame(() => { + mountShapeTextEditor(target, rootComponent); + + if (isElementOutsideViewport(service.viewport, target, [20, 20])) { + const { elementBound } = target; + + service.viewport.smoothTranslate( + elementBound.x + elementBound.w / 2, + elementBound.y + elementBound.h / 2 + ); + } + }); + }, + Tab: ctx => { + ctx.get('defaultState').event.preventDefault(); + + const { service } = rootComponent; + const selection = service.selection; + const elements = selection.selectedElements; + + if (!isSingleMindMapNode(elements)) { + return; + } + + const mindmap = elements[0].group as MindmapElementModel; + if (mindmap.isLocked()) return; + + const node = mindmap.getNode(elements[0].id)!; + const id = mindmap.addNode(node.id); + const target = service.getElementById(id) as ShapeElementModel; + + if (node.detail.collapsed) { + mindmap.toggleCollapse(node, { layout: true }); + } + + requestAnimationFrame(() => { + mountShapeTextEditor(target, rootComponent); + + if (isElementOutsideViewport(service.viewport, target, [20, 20])) { + const { elementBound } = target; + + service.viewport.smoothTranslate( + elementBound.x + elementBound.w / 2, + elementBound.y + elementBound.h / 2 + ); + } + }); + }, + }, + { + global: true, + } + ); + + this._bindShiftKey(); + this._bindToggleHand(); + } + + private _bindShiftKey() { + this.rootComponent.handleEvent( + 'keyDown', + ctx => { + const event = ctx.get('defaultState').event; + if (event instanceof KeyboardEvent) { + this._shift(event); + } + }, + { global: true } + ); + this.rootComponent.handleEvent( + 'keyUp', + ctx => { + const event = ctx.get('defaultState').event; + if (event instanceof KeyboardEvent) { + this._shift(event); + } + }, + { + global: true, + } + ); + } + + private _bindToggleHand() { + this.rootComponent.handleEvent( + 'keyDown', + ctx => { + const event = ctx.get('keyboardState').raw; + const service = this.rootComponent.service; + const selection = service.selection; + if (event.code === 'Space' && !event.repeat) { + this._space(event); + } else if ( + !selection.editing && + event.key.length === 1 && + !event.shiftKey && + !event.ctrlKey && + !event.altKey && + !event.metaKey + ) { + const elements = selection.selectedElements; + const doc = this.rootComponent.doc; + + if (isSingleMindMapNode(elements)) { + const target = service.getElementById( + elements[0].id + ) as ShapeElementModel; + if (target.text) { + doc.transact(() => { + target.text!.delete(0, target.text!.length); + target.text!.insert(0, event.key); + }); + } + mountShapeTextEditor(target, this.rootComponent); + return true; + } + } + + return false; + }, + { global: true } + ); + this.rootComponent.handleEvent( + 'keyUp', + ctx => { + const event = ctx.get('keyboardState').raw; + if (event.code === 'Space' && !event.repeat) { + this._space(event); + } + }, + { global: true } + ); + } + + private _delete() { + const edgeless = this.rootComponent; + + if (edgeless.service.locked) return; + if (edgeless.service.selection.editing) { + return; + } + + const selectedElements = edgeless.service.selection.selectedElements; + if (selectedElements.some(e => e.isLocked())) return; + + if (isSingleMindMapNode(selectedElements)) { + const node = selectedElements[0]; + const mindmap = node.group as MindmapElementModel; + const focusNode = + mindmap.getSiblingNode(node.id, 'prev') ?? + mindmap.getSiblingNode(node.id, 'next') ?? + mindmap.getParentNode(node.id); + + if (focusNode) { + edgeless.service.selection.set({ + elements: [focusNode.element.id], + editing: false, + }); + } + + deleteElements(edgeless, selectedElements); + } else { + deleteElements(edgeless, selectedElements); + edgeless.service.selection.clear(); + } + } + + private _move(key: string, shift = false) { + const edgeless = this.rootComponent; + + if (edgeless.service.locked) return; + if (edgeless.service.selection.editing) return; + + const { selectedElements } = edgeless.service.selection; + const inc = shift ? 10 : 1; + const mindmapNodes = selectedElements.filter( + el => el.group instanceof MindmapElementModel + ); + + if (mindmapNodes.length > 0) { + const node = mindmapNodes[0]; + const mindmap = node.group as MindmapElementModel; + const nodeDirection = mindmap.getLayoutDir(node.id); + let targetNode: BlockSuite.SurfaceElementModel | null = null; + + switch (key) { + case 'ArrowUp': + case 'ArrowDown': + targetNode = + mindmap.getSiblingNode( + node.id, + key === 'ArrowDown' ? 'next' : 'prev', + nodeDirection === LayoutType.RIGHT + ? 'right' + : nodeDirection === LayoutType.LEFT + ? 'left' + : undefined + )?.element ?? null; + break; + case 'ArrowLeft': + targetNode = + nodeDirection === LayoutType.RIGHT + ? (mindmap.getParentNode(node.id)?.element ?? null) + : (mindmap.getChildNodes(node.id, 'left')[0]?.element ?? null); + + break; + case 'ArrowRight': + targetNode = + nodeDirection === LayoutType.RIGHT || + nodeDirection === LayoutType.BALANCE + ? (mindmap.getChildNodes(node.id, 'right')[0]?.element ?? null) + : (mindmap.getParentNode(node.id)?.element ?? null); + break; + } + + if (targetNode) { + edgeless.service.selection.set({ + elements: [targetNode.id], + editing: false, + }); + + if ( + isElementOutsideViewport( + edgeless.service.viewport, + targetNode, + [90, 20] + ) + ) { + const [dx, dy] = getNearestTranslation( + edgeless.service.viewport, + targetNode, + [100, 20] + ); + + edgeless.service.viewport.smoothTranslate( + edgeless.service.viewport.centerX - dx, + edgeless.service.viewport.centerY + dy + ); + } + } + + return; + } + + if (selectedElements.some(e => e.isLocked())) return; + + const movedElements = new Set([ + ...selectedElements, + ...selectedElements + .map(el => (isGfxGroupCompatibleModel(el) ? el.descendantElements : [])) + .flat(), + ]); + + movedElements.forEach(element => { + const bound = Bound.deserialize(element.xywh).clone(); + + switch (key) { + case 'ArrowUp': + bound.y -= inc; + break; + case 'ArrowLeft': + bound.x -= inc; + break; + case 'ArrowRight': + bound.x += inc; + break; + case 'ArrowDown': + bound.y += inc; + break; + } + + if (isCanvasElement(element)) { + if (element instanceof ConnectorElementModel) { + element.moveTo(bound); + } + element['xywh'] = bound.serialize(); + } else { + element['xywh'] = bound.serialize(); + } + }); + } + + private _setEdgelessTool<K extends keyof GfxToolsMap>( + toolName: K, + ...options: K extends keyof GfxToolsOption + ? [option: GfxToolsOption[K], ignoreActiveState?: boolean] + : [option: void, ignoreActiveState?: boolean] + ) { + const ignoreActiveState = + typeof options === 'boolean' + ? options[0] + : options[1] === undefined + ? false + : options[1]; + + // when editing, should not update mouse mode by shortcut + if (!ignoreActiveState && this.rootComponent.gfx.selection.editing) { + return; + } + + this.rootComponent.gfx.tool.setTool<K>( + toolName, + // @ts-expect-error FIXME: ts error + options[0] !== undefined && typeof options[0] !== 'boolean' + ? options[0] + : undefined + ); + } + + private _shift(event: KeyboardEvent) { + const edgeless = this.rootComponent; + + if (event.repeat) return; + + const shiftKeyPressed = + event.key.toLowerCase() === 'shift' && event.shiftKey; + + if (shiftKeyPressed) { + edgeless.slots.pressShiftKeyUpdated.emit(true); + } else { + edgeless.slots.pressShiftKeyUpdated.emit(false); + } + } + + private _space(event: KeyboardEvent) { + /* + Call this function with a check for !event.repeat to consider only the first keydown (not repeat). This way, you can use onPressSpaceBar in a tool to determine if the space bar is pressed or not. + */ + + const edgeless = this.rootComponent; + const selection = edgeless.service.selection; + const currentTool = edgeless.gfx.tool.currentTool$.peek()!; + const isKeyDown = event.type === 'keydown'; + + if (edgeless.gfx.tool.dragging$.peek()) { + return; // Don't do anything if currently dragging + } + + const revertToPrevTool = (ev: KeyboardEvent) => { + if (ev.code === 'Space') { + this._setEdgelessTool( + // @ts-expect-error FIXME: ts error + currentTool.toolName, + currentTool?.activatedOption + ); + document.removeEventListener('keyup', revertToPrevTool, false); + } + }; + + if (isKeyDown) { + if ( + currentTool.toolName === 'pan' || + (currentTool.toolName === 'default' && selection.editing) + ) { + return; + } + this._setEdgelessTool('pan', { panning: false }); + + edgeless.dispatcher.disposables.addFromEvent( + document, + 'keyup', + revertToPrevTool + ); + } + } +} diff --git a/blocksuite/blocks/src/root-block/edgeless/edgeless-root-block.ts b/blocksuite/blocks/src/root-block/edgeless/edgeless-root-block.ts new file mode 100644 index 0000000000..c7698d4fa5 --- /dev/null +++ b/blocksuite/blocks/src/root-block/edgeless/edgeless-root-block.ts @@ -0,0 +1,576 @@ +/* eslint-disable @typescript-eslint/no-non-null-assertion */ +import type { + SurfaceBlockComponent, + SurfaceBlockModel, +} from '@blocksuite/affine-block-surface'; +import { CommonUtils } from '@blocksuite/affine-block-surface'; +import { toast } from '@blocksuite/affine-components/toast'; +import type { + RootBlockModel, + ShapeElementModel, +} from '@blocksuite/affine-model'; +import { + EditPropsStore, + FontLoaderService, + ThemeProvider, +} from '@blocksuite/affine-shared/services'; +import { + isTouchPadPinchEvent, + requestConnectedFrame, + requestThrottledConnectedFrame, +} from '@blocksuite/affine-shared/utils'; +import type { + GfxBlockComponent, + SurfaceSelection, + UIEventHandler, +} from '@blocksuite/block-std'; +import { BlockComponent } from '@blocksuite/block-std'; +import { + GfxControllerIdentifier, + type GfxViewportElement, +} from '@blocksuite/block-std/gfx'; +import { IS_WINDOWS } from '@blocksuite/global/env'; +import { assertExists, Bound, Point, Vec } from '@blocksuite/global/utils'; +import { effect } from '@preact/signals-core'; +import { css, html } from 'lit'; +import { query } from 'lit/decorators.js'; +import { repeat } from 'lit/directives/repeat.js'; + +import { isSingleMindMapNode } from '../../_common/edgeless/mindmap/index.js'; +import type { Viewport } from '../../_common/utils/index.js'; +import type { EdgelessRootBlockWidgetName } from '../types.js'; +import { EdgelessClipboardController } from './clipboard/clipboard.js'; +import type { EdgelessSelectedRectWidget } from './components/rects/edgeless-selected-rect.js'; +import { EdgelessPageKeyboardManager } from './edgeless-keyboard.js'; +import type { EdgelessRootService } from './edgeless-root-service.js'; +import { getBackgroundGrid, isCanvasElement } from './utils/query.js'; +import { mountShapeTextEditor } from './utils/text.js'; +import { fitToScreen } from './utils/viewport.js'; + +const { normalizeWheelDeltaY } = CommonUtils; + +export class EdgelessRootBlockComponent extends BlockComponent< + RootBlockModel, + EdgelessRootService, + EdgelessRootBlockWidgetName +> { + static override styles = css` + affine-edgeless-root { + -webkit-user-select: none; + user-select: none; + display: block; + height: 100%; + touch-action: none; + } + + .widgets-container { + position: absolute; + left: 0; + top: 0; + pointer-events: none; + contain: size layout; + height: 100%; + width: 100%; + } + + .widgets-container > * { + pointer-events: auto; + } + + .edgeless-background { + height: 100%; + background-color: var(--affine-background-primary-color); + background-image: radial-gradient( + var(--affine-edgeless-grid-color) 1px, + var(--affine-background-primary-color) 1px + ); + } + + .edgeless-container { + color: var(--affine-text-primary-color); + position: relative; + } + + @media print { + .selected { + background-color: transparent !important; + } + } + `; + + private _refreshLayerViewport = requestThrottledConnectedFrame(() => { + const { zoom, translateX, translateY } = this.gfx.viewport; + const { gap } = getBackgroundGrid(zoom, true); + + if (this.backgroundElm) { + this.backgroundElm.style.setProperty( + 'background-position', + `${translateX}px ${translateY}px` + ); + this.backgroundElm.style.setProperty( + 'background-size', + `${gap}px ${gap}px` + ); + } + }, this); + + private _resizeObserver: ResizeObserver | null = null; + + private _viewportElement: HTMLElement | null = null; + + clipboardController = new EdgelessClipboardController(this); + + keyboardManager: EdgelessPageKeyboardManager | null = null; + + get dispatcher() { + return this.std.event; + } + + get gfx() { + return this.std.get(GfxControllerIdentifier); + } + + get selectedRectWidget() { + return this.host.view.getWidget( + 'edgeless-selected-rect', + this.host.id + ) as EdgelessSelectedRectWidget; + } + + get slots() { + return this.service.slots; + } + + get surfaceBlockModel() { + return this.model.children.find( + child => child.flavour === 'affine:surface' + ) as SurfaceBlockModel; + } + + /** + * Don't confuse with `gfx.viewport` which is edgeless-only concept. + * This refers to the wrapper element of the EditorHost. + */ + get viewport(): Viewport { + const { + scrollLeft, + scrollTop, + scrollWidth, + scrollHeight, + clientWidth, + clientHeight, + } = this.viewportElement; + const { top, left } = this.viewportElement.getBoundingClientRect(); + return { + top, + left, + scrollLeft, + scrollTop, + scrollWidth, + scrollHeight, + clientWidth, + clientHeight, + }; + } + + get viewportElement(): HTMLElement { + if (this._viewportElement) return this._viewportElement; + this._viewportElement = this.host.closest( + '.affine-edgeless-viewport' + ) as HTMLElement | null; + assertExists(this._viewportElement); + return this._viewportElement; + } + + private _initFontLoader() { + this.std + .get(FontLoaderService) + .ready.then(() => { + this.surface.refresh(); + }) + .catch(console.error); + } + + private _initLayerUpdateEffect() { + const updateLayers = requestThrottledConnectedFrame(() => { + const blocks = Array.from( + this.gfxViewportElm.children as HTMLCollectionOf<GfxBlockComponent> + ); + + blocks.forEach((block: GfxBlockComponent) => { + block.updateZIndex?.(); + }); + }); + + this._disposables.add( + this.gfx.layer.slots.layerUpdated.on(() => updateLayers()) + ); + } + + private _initPanEvent() { + this.disposables.add( + this.dispatcher.add('pan', ctx => { + const { viewport } = this.gfx; + if (viewport.locked) return; + + const multiPointersState = ctx.get('multiPointerState'); + const [p1, p2] = multiPointersState.pointers; + + const dx = + (0.25 * (p1.delta.x + p2.delta.x)) / viewport.zoom / viewport.scale; + const dy = + (0.25 * (p1.delta.y + p2.delta.y)) / viewport.zoom / viewport.scale; + + // direction is opposite + viewport.applyDeltaCenter(-dx, -dy); + }) + ); + } + + private _initPinchEvent() { + this.disposables.add( + this.dispatcher.add('pinch', ctx => { + const { viewport } = this.gfx; + if (viewport.locked) return; + + const multiPointersState = ctx.get('multiPointerState'); + const [p1, p2] = multiPointersState.pointers; + + const currentCenter = new Point( + 0.5 * (p1.x + p2.x), + 0.5 * (p1.y + p2.y) + ); + + const lastDistance = Vec.dist( + [p1.x - p1.delta.x, p1.y - p1.delta.y], + [p2.x - p2.delta.x, p2.y - p2.delta.y] + ); + const currentDistance = Vec.dist([p1.x, p1.y], [p2.x, p2.y]); + + const zoom = (currentDistance / lastDistance) * viewport.zoom; + + const [baseX, baseY] = viewport.toModelCoord( + currentCenter.x, + currentCenter.y + ); + + viewport.setZoom(zoom, new Point(baseX, baseY)); + + return false; + }) + ); + } + + private _initPixelRatioChangeEffect() { + let media: MediaQueryList; + + const onPixelRatioChange = () => { + if (media) { + this.gfx.viewport.onResize(); + media.removeEventListener('change', onPixelRatioChange); + } + + media = matchMedia(`(resolution: ${window.devicePixelRatio}dppx)`); + media.addEventListener('change', onPixelRatioChange); + }; + + onPixelRatioChange(); + + this._disposables.add(() => { + media?.removeEventListener('change', onPixelRatioChange); + }); + } + + private _initRemoteCursor() { + let rafId: number | null = null; + + const setRemoteCursor = (pos: { x: number; y: number }) => { + if (rafId) cancelAnimationFrame(rafId); + rafId = requestConnectedFrame(() => { + if (!this.gfx.viewport) return; + const cursorPosition = this.gfx.viewport.toModelCoord(pos.x, pos.y); + this.gfx.selection.setCursor({ + x: cursorPosition[0], + y: cursorPosition[1], + }); + rafId = null; + }, this); + }; + + this.handleEvent('pointerMove', e => { + const pointerEvent = e.get('pointerState'); + setRemoteCursor(pointerEvent); + }); + } + + private _initResizeEffect() { + const resizeObserver = new ResizeObserver((_: ResizeObserverEntry[]) => { + this.gfx.selection.set(this.gfx.selection.surfaceSelections); + this.gfx.viewport.onResize(); + }); + + resizeObserver.observe(this.viewportElement); + this._resizeObserver = resizeObserver; + } + + private _initSlotEffects() { + const { disposables, slots } = this; + + this.disposables.add( + this.std.get(ThemeProvider).theme$.subscribe(() => this.surface.refresh()) + ); + + disposables.add( + effect(() => { + this.style.cursor = this.gfx.cursor$.value; + }) + ); + + let canCopyAsPng = true; + disposables.add( + slots.copyAsPng.on(({ blocks, shapes }) => { + if (!canCopyAsPng) return; + canCopyAsPng = false; + + this.clipboardController + .copyAsPng(blocks, shapes) + .then(() => toast(this.host, 'Copied to clipboard')) + .catch(() => toast(this.host, 'Failed to copy as PNG')) + .finally(() => { + canCopyAsPng = true; + }); + }) + ); + } + + private _initViewport() { + const { std, gfx } = this; + + const run = () => { + const storedViewport = std.get(EditPropsStore).getStorage('viewport'); + + if (!storedViewport) { + fitToScreen(this.gfx.gfxElements, gfx.viewport, { + smooth: false, + }); + return; + } + + if ('xywh' in storedViewport) { + const bound = Bound.deserialize(storedViewport.xywh); + gfx.viewport.setViewportByBound(bound, storedViewport.padding); + } else { + const { zoom, centerX, centerY } = storedViewport; + gfx.viewport.setViewport(zoom, [centerX, centerY]); + } + }; + + run(); + + this._disposables.add(() => { + std.get(EditPropsStore).setStorage('viewport', { + centerX: gfx.viewport.centerX, + centerY: gfx.viewport.centerY, + zoom: gfx.viewport.zoom, + }); + }); + } + + private _initWheelEvent() { + this._disposables.add( + this.dispatcher.add('wheel', ctx => { + const state = ctx.get('defaultState'); + const e = state.event as WheelEvent; + + e.preventDefault(); + + const { viewport } = this.gfx; + if (viewport.locked) return; + + // zoom + if (isTouchPadPinchEvent(e)) { + const rect = this.getBoundingClientRect(); + // Perform zooming relative to the mouse position + const [baseX, baseY] = this.gfx.viewport.toModelCoord( + e.clientX - rect.x, + e.clientY - rect.y + ); + + const zoom = normalizeWheelDeltaY(e.deltaY, viewport.zoom); + viewport.setZoom(zoom, new Point(baseX, baseY)); + e.stopPropagation(); + } + // pan + else { + const simulateHorizontalScroll = IS_WINDOWS && e.shiftKey; + const dx = simulateHorizontalScroll + ? e.deltaY / viewport.zoom + : e.deltaX / viewport.zoom; + const dy = simulateHorizontalScroll ? 0 : e.deltaY / viewport.zoom; + + viewport.applyDeltaCenter(dx, dy); + viewport.viewportMoved.emit([dx, dy]); + e.stopPropagation(); + } + }) + ); + } + + override bindHotKey( + keymap: Record<string, UIEventHandler>, + options?: { global?: boolean; flavour?: boolean } + ): () => void { + const { gfx } = this; + const selection = gfx.selection; + + Object.keys(keymap).forEach(key => { + if (key.length === 1 && key >= 'A' && key <= 'z') { + const handler = keymap[key]; + + keymap[key] = ctx => { + const elements = selection.selectedElements; + + if (isSingleMindMapNode(elements) && !selection.editing) { + const target = gfx.getElementById( + elements[0].id + ) as ShapeElementModel; + if (target.text) { + this.doc.transact(() => { + target.text!.delete(0, target.text!.length); + target.text!.insert(0, key); + }); + } + mountShapeTextEditor(target, this); + } else { + handler(ctx); + } + }; + } + }); + + return super.bindHotKey(keymap, options); + } + + override connectedCallback() { + super.connectedCallback(); + + this._initViewport(); + + this.clipboardController.hostConnected(); + this.keyboardManager = new EdgelessPageKeyboardManager(this); + + this.handleEvent('selectionChange', () => { + const surface = this.host.selection.value.find( + (sel): sel is SurfaceSelection => sel.is('surface') + ); + if (!surface) return; + + const el = this.gfx.getElementById(surface.elements[0]); + if (isCanvasElement(el)) { + return true; + } + + return; + }); + } + + override disconnectedCallback() { + super.disconnectedCallback(); + this.clipboardController.hostDisconnected(); + if (this._resizeObserver) { + this._resizeObserver.disconnect(); + this._resizeObserver = null; + } + + this.keyboardManager = null; + } + + override firstUpdated() { + this._initSlotEffects(); + this._initResizeEffect(); + this._initPixelRatioChangeEffect(); + this._initFontLoader(); + this._initRemoteCursor(); + this._initLayerUpdateEffect(); + + this._initWheelEvent(); + this._initPanEvent(); + this._initPinchEvent(); + + if (this.doc.readonly) { + this.gfx.tool.setTool('pan', { panning: true }); + } else { + this.gfx.tool.setTool('default'); + } + + requestConnectedFrame(() => { + this.requestUpdate(); + }, this); + + this._disposables.add( + this.gfx.viewport.viewportUpdated.on(() => { + this._refreshLayerViewport(); + }) + ); + + this._refreshLayerViewport(); + } + + override renderBlock() { + const widgets = repeat( + Object.entries(this.widgets), + ([id]) => id, + ([_, widget]) => widget + ); + + return html` + <div class="edgeless-background edgeless-container"> + <gfx-viewport + .maxConcurrentRenders=${6} + .viewport=${this.gfx.viewport} + .getModelsInViewport=${() => { + const blocks = this.gfx.grid.search( + this.gfx.viewport.viewportBounds, + { + useSet: true, + filter: ['block'], + } + ); + + return blocks; + }} + .host=${this.host} + > + ${this.renderChildren(this.model)} + ${this.renderChildren(this.surfaceBlockModel)} + </gfx-viewport> + </div> + + <!-- + Used to mount component before widgets + Eg., canvas text editor + --> + <div class="edgeless-mount-point"></div> + + <div class="widgets-container">${widgets}</div> + `; + } + + @query('.edgeless-background') + accessor backgroundElm: HTMLDivElement | null = null; + + @query('gfx-viewport') + accessor gfxViewportElm!: GfxViewportElement; + + @query('.edgeless-mount-point') + accessor mountElm: HTMLDivElement | null = null; + + @query('affine-surface') + accessor surface!: SurfaceBlockComponent; +} + +declare global { + interface HTMLElementTagNameMap { + 'affine-edgeless-root': EdgelessRootBlockComponent; + } +} diff --git a/blocksuite/blocks/src/root-block/edgeless/edgeless-root-preview-block.ts b/blocksuite/blocks/src/root-block/edgeless/edgeless-root-preview-block.ts new file mode 100644 index 0000000000..a57acf510f --- /dev/null +++ b/blocksuite/blocks/src/root-block/edgeless/edgeless-root-preview-block.ts @@ -0,0 +1,260 @@ +import type { + SurfaceBlockComponent, + SurfaceBlockModel, +} from '@blocksuite/affine-block-surface'; +import type { RootBlockModel } from '@blocksuite/affine-model'; +import { + FontLoaderService, + ThemeProvider, +} from '@blocksuite/affine-shared/services'; +import type { + GfxBlockComponent, + SurfaceSelection, +} from '@blocksuite/block-std'; +import { BlockComponent } from '@blocksuite/block-std'; +import type { GfxViewportElement } from '@blocksuite/block-std/gfx'; +import { assertExists } from '@blocksuite/global/utils'; +import { css, html } from 'lit'; +import { query, state } from 'lit/decorators.js'; + +import { requestThrottledConnectedFrame } from '../../_common/utils/index.js'; +import type { EdgelessRootBlockWidgetName } from '../types.js'; +import type { EdgelessRootService } from './edgeless-root-service.js'; +import { getBackgroundGrid, isCanvasElement } from './utils/query.js'; + +export class EdgelessRootPreviewBlockComponent extends BlockComponent< + RootBlockModel, + EdgelessRootService, + EdgelessRootBlockWidgetName +> { + static override styles = css` + affine-edgeless-root-preview { + pointer-events: none; + -webkit-user-select: none; + user-select: none; + display: block; + height: 100%; + } + + affine-edgeless-root-preview .widgets-container { + position: absolute; + left: 0; + top: 0; + contain: size layout; + z-index: 1; + height: 100%; + } + + affine-edgeless-root-preview .edgeless-background { + height: 100%; + background-color: var(--affine-background-primary-color); + background-image: radial-gradient( + var(--affine-edgeless-grid-color) 1px, + var(--affine-background-primary-color) 1px + ); + } + + @media print { + .selected { + background-color: transparent !important; + } + } + `; + + @query('.edgeless-background') + accessor background!: HTMLDivElement; + + private _refreshLayerViewport = requestThrottledConnectedFrame(() => { + const { zoom, translateX, translateY } = this.service.viewport; + const { gap } = getBackgroundGrid(zoom, true); + + this.background.style.setProperty( + 'background-position', + `${translateX}px ${translateY}px` + ); + this.background.style.setProperty('background-size', `${gap}px ${gap}px`); + }, this); + + private _resizeObserver: ResizeObserver | null = null; + + private _viewportElement: HTMLElement | null = null; + + get dispatcher() { + return this.service?.uiEventDispatcher; + } + + get surfaceBlockModel() { + return this.model.children.find( + child => child.flavour === 'affine:surface' + ) as SurfaceBlockModel; + } + + get viewportElement(): HTMLElement { + if (this._viewportElement) return this._viewportElement; + this._viewportElement = this.host.closest( + this.editorViewportSelector + ) as HTMLElement | null; + assertExists(this._viewportElement); + return this._viewportElement; + } + + private _initFontLoader() { + this.std + .get(FontLoaderService) + .ready.then(() => { + this.surface.refresh(); + }) + .catch(console.error); + } + + private _initLayerUpdateEffect() { + const updateLayers = requestThrottledConnectedFrame(() => { + const blocks = Array.from( + this.gfxViewportElm.children as HTMLCollectionOf<GfxBlockComponent> + ); + + blocks.forEach((block: GfxBlockComponent) => { + block.updateZIndex?.(); + }); + }); + + this._disposables.add( + this.service.layer.slots.layerUpdated.on(() => updateLayers()) + ); + } + + private _initPixelRatioChangeEffect() { + let media: MediaQueryList; + + const onPixelRatioChange = () => { + if (media) { + this.service.viewport.onResize(); + media.removeEventListener('change', onPixelRatioChange); + } + + media = matchMedia(`(resolution: ${window.devicePixelRatio}dppx)`); + media.addEventListener('change', onPixelRatioChange); + }; + + onPixelRatioChange(); + + this._disposables.add(() => { + media?.removeEventListener('change', onPixelRatioChange); + }); + } + + private _initResizeEffect() { + if (!this._viewportElement) { + return; + } + + const resizeObserver = new ResizeObserver((_: ResizeObserverEntry[]) => { + // FIXME: find a better way to get rid of empty check + if (!this.service || !this.service.selection || !this.service.viewport) { + console.error('Service not ready'); + return; + } + this.service.selection.set(this.service.selection.surfaceSelections); + this.service.viewport.onResize(); + }); + + resizeObserver.observe(this.viewportElement); + this._resizeObserver?.disconnect(); + this._resizeObserver = resizeObserver; + } + + private _initSlotEffects() { + this.disposables.add( + this.std.get(ThemeProvider).theme$.subscribe(() => this.surface.refresh()) + ); + } + + override connectedCallback() { + super.connectedCallback(); + + this.handleEvent('selectionChange', () => { + const surface = this.host.selection.value.find( + (sel): sel is SurfaceSelection => sel.is('surface') + ); + if (!surface) return; + + const el = this.service.getElementById(surface.elements[0]); + if (isCanvasElement(el)) { + return true; + } + + return; + }); + } + + override disconnectedCallback() { + super.disconnectedCallback(); + + if (this._resizeObserver) { + this._resizeObserver.disconnect(); + this._resizeObserver = null; + } + } + + override firstUpdated() { + this._initSlotEffects(); + this._initResizeEffect(); + this._initPixelRatioChangeEffect(); + this._initFontLoader(); + this._initLayerUpdateEffect(); + + this._disposables.add( + this.service.viewport.viewportUpdated.on(() => { + this._refreshLayerViewport(); + }) + ); + + this._refreshLayerViewport(); + } + + override renderBlock() { + return html` + <div class="edgeless-background edgeless-container"> + <gfx-viewport + .viewport=${this.service.viewport} + .getModelsInViewport=${() => { + const blocks = this.service.gfx.grid.search( + this.service.viewport.viewportBounds, + { + useSet: true, + filter: ['block'], + } + ); + return blocks; + }} + .host=${this.host} + > + ${this.renderChildren(this.model)}${this.renderChildren( + this.surfaceBlockModel + )} + </gfx-viewport> + </div> + `; + } + + override willUpdate(_changedProperties: Map<PropertyKey, unknown>): void { + if (_changedProperties.has('editorViewportSelector')) { + this._initResizeEffect(); + } + } + + @state() + accessor editorViewportSelector = '.affine-edgeless-viewport'; + + @query('gfx-viewport') + accessor gfxViewportElm!: GfxViewportElement; + + @query('affine-surface') + accessor surface!: SurfaceBlockComponent; +} + +declare global { + interface HTMLElementTagNameMap { + 'affine-edgeless-root-preview': EdgelessRootPreviewBlockComponent; + } +} diff --git a/blocksuite/blocks/src/root-block/edgeless/edgeless-root-service.ts b/blocksuite/blocks/src/root-block/edgeless/edgeless-root-service.ts new file mode 100644 index 0000000000..d7c2ae115d --- /dev/null +++ b/blocksuite/blocks/src/root-block/edgeless/edgeless-root-service.ts @@ -0,0 +1,532 @@ +import { + type ElementRenderer, + elementRenderers, + type SurfaceBlockModel, + type SurfaceContext, +} from '@blocksuite/affine-block-surface'; +import { + type ConnectorElementModel, + type FrameBlockModel, + type GroupElementModel, + MindmapElementModel, + RootBlockSchema, +} from '@blocksuite/affine-model'; +import { EditPropsStore } from '@blocksuite/affine-shared/services'; +import { clamp } from '@blocksuite/affine-shared/utils'; +import type { BlockStdScope } from '@blocksuite/block-std'; +import type { + GfxController, + GfxModel, + LayerManager, + PointTestOptions, + ReorderingDirection, +} from '@blocksuite/block-std/gfx'; +import { + GfxControllerIdentifier, + GfxExtensionIdentifier, + isGfxGroupCompatibleModel, +} from '@blocksuite/block-std/gfx'; +import { BlockSuiteError, ErrorCode } from '@blocksuite/global/exceptions'; +import { Bound, getCommonBound } from '@blocksuite/global/utils'; +import { type BlockModel, Slot } from '@blocksuite/store'; +import { effect } from '@preact/signals-core'; + +import { getSurfaceBlock } from '../../surface-ref-block/utils.js'; +import { RootService } from '../root-service.js'; +import { GfxBlockModel } from './block-model.js'; +import type { EdgelessFrameManager } from './frame-manager.js'; +import { TemplateJob } from './services/template.js'; +import { + createInsertPlaceMiddleware, + createRegenerateIndexMiddleware, + createStickerMiddleware, + replaceIdMiddleware, +} from './services/template-middlewares.js'; +import { FIT_TO_SCREEN_PADDING } from './utils/consts.js'; +import { getLastPropsKey } from './utils/get-last-props-key.js'; +import { getCursorMode } from './utils/query.js'; +import { + ZOOM_INITIAL, + ZOOM_MAX, + ZOOM_MIN, + ZOOM_STEP, + type ZoomAction, +} from './utils/zoom.js'; + +export class EdgelessRootService extends RootService implements SurfaceContext { + static override readonly flavour = RootBlockSchema.model.flavour; + + private _surface: SurfaceBlockModel; + + elementRenderers: Record<string, ElementRenderer> = elementRenderers; + + slots = { + pressShiftKeyUpdated: new Slot<boolean>(), + copyAsPng: new Slot<{ + blocks: BlockSuite.EdgelessBlockModelType[]; + shapes: BlockSuite.SurfaceModel[]; + }>(), + readonlyUpdated: new Slot<boolean>(), + draggingAreaUpdated: new Slot(), + navigatorSettingUpdated: new Slot<{ + hideToolbar?: boolean; + blackBackground?: boolean; + fillScreen?: boolean; + }>(), + navigatorFrameChanged: new Slot<FrameBlockModel>(), + fullScreenToggled: new Slot(), + + elementResizeStart: new Slot(), + elementResizeEnd: new Slot(), + toggleNoteSlicer: new Slot(), + + toolbarLocked: new Slot<boolean>(), + }; + + TemplateJob = TemplateJob; + + updateElement = (id: string, props: Record<string, unknown>) => { + const element = this._surface.getElementById(id); + if (element) { + const key = getLastPropsKey( + element.type as BlockSuite.EdgelessModelKeys, + { ...element.yMap.toJSON(), ...props } + ); + key && this.std.get(EditPropsStore).recordLastProps(key, props); + this._surface.updateElement(id, props); + return; + } + + const block = this.doc.getBlockById(id); + if (block) { + const key = getLastPropsKey( + block.flavour as BlockSuite.EdgelessModelKeys, + { ...block.yBlock.toJSON(), ...props } + ); + key && this.std.get(EditPropsStore).recordLastProps(key, props); + this.doc.updateBlock(block, props); + } + }; + + get blocks(): GfxBlockModel[] { + return this.layer.blocks; + } + + /** + * sorted edgeless elements + */ + get edgelessElements(): GfxModel[] { + return [...this.layer.canvasElements, ...this.layer.blocks].sort( + this.layer.compare + ); + } + + /** + * sorted canvas elements + */ + get elements() { + return this.layer.canvasElements; + } + + get frame() { + return this.std.get( + GfxExtensionIdentifier('frame-manager') + ) as EdgelessFrameManager; + } + + /** + * Get all sorted frames by presentation orderer, + * the legacy frame that uses `index` as presentation order + * will be put at the beginning of the array. + */ + get frames() { + return this.frame.frames; + } + + get gfx(): GfxController { + return this.std.get(GfxControllerIdentifier); + } + + override get host() { + return this.std.host; + } + + get layer(): LayerManager { + return this.gfx.layer; + } + + get locked() { + return this.viewport.locked; + } + + set locked(locked: boolean) { + this.viewport.locked = locked; + } + + get selection() { + return this.gfx.selection; + } + + get surface() { + return this._surface; + } + + get viewport() { + return this.std.get(GfxControllerIdentifier).viewport; + } + + get zoom() { + return this.viewport.zoom; + } + + constructor(std: BlockStdScope, flavourProvider: { flavour: string }) { + super(std, flavourProvider); + const surface = getSurfaceBlock(this.doc); + if (!surface) { + throw new BlockSuiteError( + ErrorCode.NoSurfaceModelError, + 'This doc is missing surface block in edgeless.' + ); + } + this._surface = surface; + } + + private _initReadonlyListener() { + const doc = this.doc; + + let readonly = doc.readonly; + this.disposables.add( + doc.awarenessStore.slots.update.on(() => { + if (readonly !== doc.readonly) { + readonly = doc.readonly; + this.slots.readonlyUpdated.emit(readonly); + } + }) + ); + } + + private _initSlotEffects() { + const { disposables } = this; + + disposables.add( + effect(() => { + const value = this.gfx.tool.currentToolOption$.value; + this.gfx.cursor$.value = getCursorMode(value); + }) + ); + } + + addBlock( + flavour: string, + props: Record<string, unknown>, + parent?: string | BlockModel, + parentIndex?: number + ) { + const key = getLastPropsKey(flavour as BlockSuite.EdgelessModelKeys, props); + if (key) { + props = this.std.get(EditPropsStore).applyLastProps(key, props); + } + + const nProps = { + ...props, + index: this.generateIndex(), + }; + return this.doc.addBlock(flavour as never, nProps, parent, parentIndex); + } + + addElement<T extends Record<string, unknown>>(type: string, props: T) { + const key = getLastPropsKey(type as BlockSuite.EdgelessModelKeys, props); + if (key) { + props = this.std.get(EditPropsStore).applyLastProps(key, props) as T; + } + + const nProps = { + ...props, + type, + index: props.index ?? this.generateIndex(), + }; + const id = this._surface.addElement(nProps); + return id; + } + + createGroup(elements: BlockSuite.EdgelessModel[] | string[]) { + const groups = this.elements.filter( + el => el.type === 'group' + ) as GroupElementModel[]; + const groupId = this.addElement('group', { + children: elements.reduce( + (pre, el) => { + const id = typeof el === 'string' ? el : el.id; + pre[id] = true; + return pre; + }, + {} as Record<string, true> + ), + title: `Group ${groups.length + 1}`, + }); + + return groupId; + } + + /** + * Create a group from selected elements, if the selected elements are in the same group + * @returns the id of the created group + */ + createGroupFromSelected() { + const { selection } = this; + + if ( + selection.selectedElements.length === 0 || + !selection.selectedElements.every( + element => + element.group === selection.firstElement.group && + !(element.group instanceof MindmapElementModel) + ) + ) { + return; + } + + const parent = selection.firstElement.group as GroupElementModel; + + if (parent !== null) { + selection.selectedElements.forEach(element => { + // eslint-disable-next-line unicorn/prefer-dom-node-remove + parent.removeChild(element); + }); + } + + const groupId = this.createGroup(selection.selectedElements); + const group = this.surface.getElementById(groupId); + + if (parent !== null && group) { + parent.addChild(group); + } + + selection.set({ + editing: false, + elements: [groupId], + }); + + return groupId; + } + + createTemplateJob( + type: 'template' | 'sticker', + center?: { x: number; y: number } + ) { + const middlewares: ((job: TemplateJob) => void)[] = []; + + if (type === 'template') { + const bounds = [...this.blocks, ...this.elements].map(i => + Bound.deserialize(i.xywh) + ); + const currentContentBound = getCommonBound(bounds); + + if (currentContentBound) { + currentContentBound.x += + currentContentBound.w + 20 / this.viewport.zoom; + middlewares.push(createInsertPlaceMiddleware(currentContentBound)); + } + + const idxGenerator = this.layer.createIndexGenerator(); + + middlewares.push(createRegenerateIndexMiddleware(() => idxGenerator())); + } + + if (type === 'sticker') { + middlewares.push( + createStickerMiddleware(center || this.viewport.center, () => + this.layer.generateIndex() + ) + ); + } + + middlewares.push(replaceIdMiddleware); + + return TemplateJob.create({ + model: this.surface, + type, + middlewares, + }); + } + + generateIndex() { + return this.layer.generateIndex(); + } + + getConnectors(element: BlockSuite.EdgelessModel | string) { + const id = typeof element === 'string' ? element : element.id; + + return this.surface.getConnectors(id) as ConnectorElementModel[]; + } + + getElementById(id: string): BlockSuite.EdgelessModel | null { + const el = + this._surface.getElementById(id) ?? + (this.doc.getBlockById(id) as BlockSuite.EdgelessBlockModelType | null); + return el; + } + + getElementsByType<K extends keyof BlockSuite.SurfaceElementModelMap>( + type: K + ): BlockSuite.SurfaceElementModelMap[K][] { + return this.surface.getElementsByType(type); + } + + getFitToScreenData( + padding: [number, number, number, number] = [0, 0, 0, 0], + inputBounds?: Bound[] + ) { + let bounds = []; + if (inputBounds && inputBounds.length) { + bounds = inputBounds; + } else { + this.blocks.forEach(block => { + bounds.push(Bound.deserialize(block.xywh)); + }); + + const surfaceElementsBound = getCommonBound(this.elements); + if (surfaceElementsBound) { + bounds.push(surfaceElementsBound); + } + } + + const bound = getCommonBound(bounds); + + return this.viewport.getFitToScreenData( + bound, + padding, + ZOOM_INITIAL, + FIT_TO_SCREEN_PADDING + ); + } + + override mounted() { + super.mounted(); + this._initSlotEffects(); + this._initReadonlyListener(); + } + + /** + * This method is used to pick element in group, if the picked element is in a + * group, we will pick the group instead. If that picked group is currently selected, then + * we will pick the element itself. + */ + pickElementInGroup( + x: number, + y: number, + options?: PointTestOptions + ): BlockSuite.EdgelessModel | null { + return this.gfx.getElementInGroup(x, y, options); + } + + removeElement(id: string | BlockSuite.EdgelessModel) { + id = typeof id === 'string' ? id : id.id; + + const el = this.getElementById(id); + if (isGfxGroupCompatibleModel(el)) { + el.childIds.forEach(childId => { + this.removeElement(childId); + }); + } + + if (el instanceof GfxBlockModel) { + this.doc.deleteBlock(el); + return; + } + + if (this._surface.hasElementById(id)) { + this._surface.deleteElement(id); + return; + } + } + + reorderElement( + element: BlockSuite.EdgelessModel, + direction: ReorderingDirection + ) { + const index = this.layer.getReorderedIndex(element, direction); + + // block should be updated in transaction + if (element instanceof GfxBlockModel) { + this.doc.transact(() => { + element.index = index; + }); + } else { + element.index = index; + } + } + + setZoomByAction(action: ZoomAction) { + if (this.locked) return; + + switch (action) { + case 'fit': + this.zoomToFit(); + break; + case 'reset': + this.viewport.smoothZoom(1.0); + break; + case 'in': + case 'out': + this.setZoomByStep(ZOOM_STEP * (action === 'in' ? 1 : -1)); + } + } + + setZoomByStep(step: number) { + this.viewport.smoothZoom(clamp(this.zoom + step, ZOOM_MIN, ZOOM_MAX)); + } + + ungroup(group: GroupElementModel) { + const { selection } = this; + const elements = group.childElements; + const parent = group.group as GroupElementModel; + + if (group instanceof MindmapElementModel) { + return; + } + + if (parent !== null) { + // eslint-disable-next-line unicorn/prefer-dom-node-remove + parent.removeChild(group); + } + + elements.forEach(element => { + // eslint-disable-next-line unicorn/prefer-dom-node-remove + group.removeChild(element); + }); + + // keep relative index order of group children after ungroup + elements + .sort((a, b) => this.layer.compare(a, b)) + .forEach(element => { + this.doc.transact(() => { + element.index = this.layer.generateIndex(); + }); + }); + + if (parent !== null) { + elements.forEach(element => { + parent.addChild(element); + }); + } + + selection.set({ + editing: false, + elements: elements.map(ele => ele.id), + }); + } + + override unmounted() { + super.unmounted(); + + this.viewport?.dispose(); + this.selectionManager.set([]); + this.disposables.dispose(); + } + + zoomToFit() { + const { centerX, centerY, zoom } = this.getFitToScreenData(); + this.viewport.setViewport(zoom, [centerX, centerY], true); + } +} diff --git a/blocksuite/blocks/src/root-block/edgeless/edgeless-root-spec.ts b/blocksuite/blocks/src/root-block/edgeless/edgeless-root-spec.ts new file mode 100644 index 0000000000..c901ed9447 --- /dev/null +++ b/blocksuite/blocks/src/root-block/edgeless/edgeless-root-spec.ts @@ -0,0 +1,125 @@ +import { + DNDAPIExtension, + DocDisplayMetaService, + DocModeService, + EmbedOptionService, + ThemeService, +} from '@blocksuite/affine-shared/services'; +import { AFFINE_SCROLL_ANCHORING_WIDGET } from '@blocksuite/affine-widget-scroll-anchoring'; +import { + BlockServiceWatcher, + BlockViewExtension, + CommandExtension, + type ExtensionType, + FlavourExtension, + WidgetViewMapExtension, +} from '@blocksuite/block-std'; +import { ToolController } from '@blocksuite/block-std/gfx'; +import { literal, unsafeStatic } from 'lit/static-html.js'; + +import { ExportManagerExtension } from '../../_common/export-manager/export-manager.js'; +import { commands } from '../commands/index.js'; +import { AFFINE_DOC_REMOTE_SELECTION_WIDGET } from '../widgets/doc-remote-selection/doc-remote-selection.js'; +import { AFFINE_DRAG_HANDLE_WIDGET } from '../widgets/drag-handle/consts.js'; +import { AFFINE_EDGELESS_AUTO_CONNECT_WIDGET } from '../widgets/edgeless-auto-connect/edgeless-auto-connect.js'; +import { AFFINE_EDGELESS_REMOTE_SELECTION_WIDGET } from '../widgets/edgeless-remote-selection/index.js'; +import { AFFINE_EDGELESS_ZOOM_TOOLBAR_WIDGET } from '../widgets/edgeless-zoom-toolbar/index.js'; +import { EDGELESS_ELEMENT_TOOLBAR_WIDGET } from '../widgets/element-toolbar/index.js'; +import { AFFINE_EMBED_CARD_TOOLBAR_WIDGET } from '../widgets/embed-card-toolbar/embed-card-toolbar.js'; +import { AFFINE_FORMAT_BAR_WIDGET } from '../widgets/format-bar/format-bar.js'; +import { AFFINE_FRAME_TITLE_WIDGET } from '../widgets/frame-title/index.js'; +import { AFFINE_INNER_MODAL_WIDGET } from '../widgets/inner-modal/inner-modal.js'; +import { AFFINE_LINKED_DOC_WIDGET } from '../widgets/linked-doc/index.js'; +import { AFFINE_MODAL_WIDGET } from '../widgets/modal/modal.js'; +import { AFFINE_PIE_MENU_WIDGET } from '../widgets/pie-menu/index.js'; +import { AFFINE_SLASH_MENU_WIDGET } from '../widgets/slash-menu/index.js'; +import { AFFINE_VIEWPORT_OVERLAY_WIDGET } from '../widgets/viewport-overlay/viewport-overlay.js'; +import { NOTE_SLICER_WIDGET } from './components/note-slicer/index.js'; +import { EDGELESS_NAVIGATOR_BLACK_BACKGROUND_WIDGET } from './components/presentation/edgeless-navigator-black-background.js'; +import { EDGELESS_DRAGGING_AREA_WIDGET } from './components/rects/edgeless-dragging-area-rect.js'; +import { EDGELESS_SELECTED_RECT_WIDGET } from './components/rects/edgeless-selected-rect.js'; +import { EDGELESS_TOOLBAR_WIDGET } from './components/toolbar/edgeless-toolbar.js'; +import { EdgelessRootService } from './edgeless-root-service.js'; + +export const edgelessRootWidgetViewMap = { + [AFFINE_MODAL_WIDGET]: literal`${unsafeStatic(AFFINE_MODAL_WIDGET)}`, + [AFFINE_INNER_MODAL_WIDGET]: literal`${unsafeStatic(AFFINE_INNER_MODAL_WIDGET)}`, + [AFFINE_PIE_MENU_WIDGET]: literal`${unsafeStatic(AFFINE_PIE_MENU_WIDGET)}`, + [AFFINE_SLASH_MENU_WIDGET]: literal`${unsafeStatic( + AFFINE_SLASH_MENU_WIDGET + )}`, + [AFFINE_LINKED_DOC_WIDGET]: literal`${unsafeStatic( + AFFINE_LINKED_DOC_WIDGET + )}`, + [AFFINE_DRAG_HANDLE_WIDGET]: literal`${unsafeStatic( + AFFINE_DRAG_HANDLE_WIDGET + )}`, + [AFFINE_EMBED_CARD_TOOLBAR_WIDGET]: literal`${unsafeStatic( + AFFINE_EMBED_CARD_TOOLBAR_WIDGET + )}`, + [AFFINE_FORMAT_BAR_WIDGET]: literal`${unsafeStatic( + AFFINE_FORMAT_BAR_WIDGET + )}`, + [AFFINE_DOC_REMOTE_SELECTION_WIDGET]: literal`${unsafeStatic( + AFFINE_DOC_REMOTE_SELECTION_WIDGET + )}`, + [AFFINE_EDGELESS_REMOTE_SELECTION_WIDGET]: literal`${unsafeStatic( + AFFINE_EDGELESS_REMOTE_SELECTION_WIDGET + )}`, + [AFFINE_EDGELESS_ZOOM_TOOLBAR_WIDGET]: literal`${unsafeStatic( + AFFINE_EDGELESS_ZOOM_TOOLBAR_WIDGET + )}`, + [AFFINE_FRAME_TITLE_WIDGET]: literal`${unsafeStatic(AFFINE_FRAME_TITLE_WIDGET)}`, + [EDGELESS_ELEMENT_TOOLBAR_WIDGET]: literal`${unsafeStatic(EDGELESS_ELEMENT_TOOLBAR_WIDGET)}`, + [AFFINE_VIEWPORT_OVERLAY_WIDGET]: literal`${unsafeStatic( + AFFINE_VIEWPORT_OVERLAY_WIDGET + )}`, + [AFFINE_EDGELESS_AUTO_CONNECT_WIDGET]: literal`${unsafeStatic( + AFFINE_EDGELESS_AUTO_CONNECT_WIDGET + )}`, + [AFFINE_SCROLL_ANCHORING_WIDGET]: literal`${unsafeStatic(AFFINE_SCROLL_ANCHORING_WIDGET)}`, + [EDGELESS_DRAGGING_AREA_WIDGET]: literal`${unsafeStatic(EDGELESS_DRAGGING_AREA_WIDGET)}`, + [NOTE_SLICER_WIDGET]: literal`${unsafeStatic(NOTE_SLICER_WIDGET)}`, + [EDGELESS_NAVIGATOR_BLACK_BACKGROUND_WIDGET]: literal`${unsafeStatic(EDGELESS_NAVIGATOR_BLACK_BACKGROUND_WIDGET)}`, + [EDGELESS_SELECTED_RECT_WIDGET]: literal`${unsafeStatic(EDGELESS_SELECTED_RECT_WIDGET)}`, + [EDGELESS_TOOLBAR_WIDGET]: literal`${unsafeStatic(EDGELESS_TOOLBAR_WIDGET)}`, +}; + +const EdgelessCommonExtension: ExtensionType[] = [ + FlavourExtension('affine:page'), + EdgelessRootService, + DocModeService, + ThemeService, + EmbedOptionService, + CommandExtension(commands), + ExportManagerExtension, + ToolController, + DNDAPIExtension, + DocDisplayMetaService, +]; + +export const EdgelessRootBlockSpec: ExtensionType[] = [ + ...EdgelessCommonExtension, + BlockViewExtension('affine:page', literal`affine-edgeless-root`), + WidgetViewMapExtension('affine:page', edgelessRootWidgetViewMap), +]; + +class EdgelessLocker extends BlockServiceWatcher { + static override readonly flavour = 'affine:page'; + + override mounted() { + const service = this.blockService; + service.disposables.add( + service.specSlots.viewConnected.on(({ service }) => { + // Does not allow the user to move and zoom. + (service as EdgelessRootService).locked = true; + }) + ); + } +} + +export const PreviewEdgelessRootBlockSpec: ExtensionType[] = [ + ...EdgelessCommonExtension, + BlockViewExtension('affine:page', literal`affine-edgeless-root-preview`), + EdgelessLocker, +]; diff --git a/blocksuite/blocks/src/root-block/edgeless/frame-manager.ts b/blocksuite/blocks/src/root-block/edgeless/frame-manager.ts new file mode 100644 index 0000000000..e0183a694f --- /dev/null +++ b/blocksuite/blocks/src/root-block/edgeless/frame-manager.ts @@ -0,0 +1,512 @@ +import type { SurfaceBlockModel } from '@blocksuite/affine-block-surface'; +import { Overlay } from '@blocksuite/affine-block-surface'; +import { + generateKeyBetweenV2, + getTopElements, + type GfxController, + GfxExtension, + GfxExtensionIdentifier, + type GfxModel, + isGfxGroupCompatibleModel, + renderableInEdgeless, +} from '@blocksuite/block-std/gfx'; +import { BlockSuiteError, ErrorCode } from '@blocksuite/global/exceptions'; +import { + Bound, + deserializeXYWH, + DisposableGroup, + type IVec, + type SerializedXYWH, +} from '@blocksuite/global/utils'; +import type { Doc } from '@blocksuite/store'; +import { DocCollection, Text } from '@blocksuite/store'; + +import type { FrameBlockModel, NoteBlockModel } from '../../index.js'; +import { GfxBlockModel } from './block-model.js'; +import { areSetsEqual } from './utils/misc.js'; +import { isFrameBlock } from './utils/query.js'; + +const FRAME_PADDING = 40; + +export class FrameOverlay extends Overlay { + static override overlayName: string = 'frame'; + + private _disposable = new DisposableGroup(); + + private _frame: FrameBlockModel | null = null; + + private _innerElements = new Set<GfxModel>(); + + private _prevXYWH: SerializedXYWH | null = null; + + private get _frameManager() { + return this.gfx.std.get( + GfxExtensionIdentifier('frame-manager') + ) as EdgelessFrameManager; + } + + constructor(gfx: GfxController) { + super(gfx); + } + + private _reset() { + this._disposable.dispose(); + this._disposable = new DisposableGroup(); + + this._frame = null; + this._innerElements.clear(); + } + + override clear() { + if (this._frame === null && this._innerElements.size === 0) return; + this._reset(); + this._renderer?.refresh(); + } + + highlight( + frame: FrameBlockModel, + highlightElementsInBound = false, + highlightOutline = true + ) { + if (!highlightElementsInBound && !highlightOutline) return; + + let needRefresh = false; + + if (highlightOutline && this._prevXYWH !== frame.xywh) { + needRefresh = true; + } + + let innerElements = new Set<GfxModel>(); + if (highlightElementsInBound) { + innerElements = new Set( + getTopElements( + this._frameManager.getElementsInFrameBound(frame) + ).concat( + this._frameManager.getChildElementsInFrame(frame).filter(child => { + return frame.intersectsBound(child.elementBound); + }) + ) + ); + if (!areSetsEqual(this._innerElements, innerElements)) { + needRefresh = true; + } + } + + if (!needRefresh) return; + + this._reset(); + if (highlightOutline) this._frame = frame; + if (highlightElementsInBound) this._innerElements = innerElements; + + this._disposable.add( + frame.deleted.once(() => { + this.clear(); + }) + ); + this._renderer?.refresh(); + } + + override render(ctx: CanvasRenderingContext2D): void { + ctx.beginPath(); + ctx.strokeStyle = '#1E96EB'; + ctx.lineWidth = 2 / this.gfx.viewport.zoom; + const radius = 2 / this.gfx.viewport.zoom; + + if (this._frame) { + const { x, y, w, h } = this._frame.elementBound; + ctx.roundRect(x, y, w, h, radius); + ctx.stroke(); + } + + this._innerElements.forEach(element => { + const [x, y, w, h] = deserializeXYWH(element.xywh); + ctx.translate(x + w / 2, y + h / 2); + ctx.rotate(element.rotate); + ctx.roundRect(-w / 2, -h / 2, w, h, radius); + ctx.translate(-x - w / 2, -y - h / 2); + ctx.stroke(); + }); + } +} + +export class EdgelessFrameManager extends GfxExtension { + static override key = 'frame-manager'; + + private _disposable = new DisposableGroup(); + + /** + * Get all sorted frames by presentation orderer, + * the legacy frame that uses `index` as presentation order + * will be put at the beginning of the array. + */ + get frames() { + return Object.values(this.gfx.doc.blocks.value) + .map(({ model }) => model) + .filter(isFrameBlock) + .sort(EdgelessFrameManager.framePresentationComparator); + } + + constructor(gfx: GfxController) { + super(gfx); + this._watchElementAdded(); + } + + static framePresentationComparator< + T extends FrameBlockModel | { index: string; presentationIndex?: string }, + >(a: T, b: T) { + function stringCompare(a: string, b: string) { + if (a < b) return -1; + if (a > b) return 1; + return 0; + } + + if ( + 'presentationIndex$' in a && + 'presentationIndex$' in b && + a.presentationIndex$.value && + b.presentationIndex$.value + ) { + return stringCompare( + a.presentationIndex$.value, + b.presentationIndex$.value + ); + } else if (a.presentationIndex && b.presentationIndex) { + return stringCompare(a.presentationIndex, b.presentationIndex); + } else if (a.presentationIndex) { + return -1; + } else if (b.presentationIndex) { + return 1; + } else { + return stringCompare(a.index, b.index); + } + } + + private _addChildrenToLegacyFrame(frame: FrameBlockModel) { + if (frame.childElementIds !== undefined) return; + const elements = this.getElementsInFrameBound(frame); + const childElements = elements.filter( + element => this.getParentFrame(element) === null && element !== frame + ); + + frame.addChildren(childElements); + } + + private _addFrameBlock(bound: Bound) { + const surfaceModel = this.gfx.surface as SurfaceBlockModel; + const id = this.gfx.doc.addBlock( + 'affine:frame', + { + title: new Text( + new DocCollection.Y.Text(`Frame ${this.frames.length + 1}`) + ), + xywh: bound.serialize(), + index: this.gfx.layer.generateIndex(true), + presentationIndex: this.generatePresentationIndex(), + }, + surfaceModel + ); + const frameModel = this.gfx.getElementById(id); + + if (!frameModel || !isFrameBlock(frameModel)) { + throw new BlockSuiteError( + ErrorCode.GfxBlockElementError, + 'Frame model is not found' + ); + } + + return frameModel; + } + + private _watchElementAdded() { + if (!this.gfx.surface) { + return; + } + + const { surface: surfaceModel, doc } = this.gfx; + + this._disposable.add( + surfaceModel.elementAdded.on(({ id, local }) => { + const element = surfaceModel.getElementById(id); + if (element && local) { + const frame = this.getFrameFromPoint(element.elementBound.center); + + // if the container created with a frame, skip it. + if ( + isGfxGroupCompatibleModel(element) && + frame && + element.hasChild(frame) + ) { + return; + } + + // new element may intended to be added to other group + // so we need to wait for the next microtask to check if the element can be added to the frame + queueMicrotask(() => { + if (!element.group && frame) { + this.addElementsToFrame(frame, [element]); + } + }); + } + }) + ); + + this._disposable.add( + doc.slots.blockUpdated.on(payload => { + if ( + payload.type === 'add' && + payload.model instanceof GfxBlockModel && + renderableInEdgeless(doc, surfaceModel, payload.model) + ) { + const frame = this.getFrameFromPoint( + payload.model.elementBound.center, + isFrameBlock(payload.model) ? [payload.model] : [] + ); + if (!frame) return; + + if ( + isFrameBlock(payload.model) && + payload.model.containsBound(frame.elementBound) + ) { + return; + } + this.addElementsToFrame(frame, [payload.model]); + } + }) + ); + } + + /** + * Reset parent of elements to the frame + */ + addElementsToFrame(frame: FrameBlockModel, elements: GfxModel[]) { + if (frame.isLocked()) return; + + if (frame.childElementIds === undefined) { + this._addChildrenToLegacyFrame(frame); + } + + elements = elements.filter( + el => el !== frame && !frame.childElements.includes(el) + ); + + if (elements.length === 0) return; + + frame.addChildren(elements); + } + + createFrameOnBound(bound: Bound) { + const frameModel = this._addFrameBlock(bound); + + this.addElementsToFrame( + frameModel, + getTopElements(this.getElementsInFrameBound(frameModel)) + ); + + this.gfx.doc.captureSync(); + + this.gfx.selection.set({ + elements: [frameModel.id], + editing: false, + }); + + return frameModel; + } + + createFrameOnElements(elements: GfxModel[]) { + // make sure all elements are in the same level + for (const element of elements) { + if (element.group !== elements[0].group) return; + } + + const parentFrameBound = this.getParentFrame(elements[0])?.elementBound; + + let bound = this.gfx.selection.selectedBound; + + if (parentFrameBound?.contains(bound)) { + bound.x -= Math.min(0.5 * (bound.x - parentFrameBound.x), FRAME_PADDING); + bound.y -= Math.min(0.5 * (bound.y - parentFrameBound.y), FRAME_PADDING); + bound.w += Math.min( + 0.5 * (parentFrameBound.x + parentFrameBound.w - bound.x - bound.w), + FRAME_PADDING + ); + bound.h += Math.min( + 0.5 * (parentFrameBound.y + parentFrameBound.h - bound.y - bound.h), + FRAME_PADDING + ); + } else { + bound = bound.expand(FRAME_PADDING); + } + + const frameModel = this._addFrameBlock(bound); + + this.addElementsToFrame(frameModel, getTopElements(elements)); + + this.gfx.doc.captureSync(); + + this.gfx.selection.set({ + elements: [frameModel.id], + editing: false, + }); + + return frameModel; + } + + createFrameOnSelected() { + return this.createFrameOnElements(this.gfx.selection.selectedElements); + } + + createFrameOnViewportCenter(wh: [number, number]) { + const center = this.gfx.viewport.center; + const bound = new Bound( + center.x - wh[0] / 2, + center.y - wh[1] / 2, + wh[0], + wh[1] + ); + + this.createFrameOnBound(bound); + } + + generatePresentationIndex() { + const before = + this.frames[this.frames.length - 1]?.presentationIndex ?? null; + + return generateKeyBetweenV2(before, null); + } + + /** + * Get all elements in the frame, there are three cases: + * 1. The frame doesn't have `childElements`, return all elements in the frame bound but not owned by another frame. + * 2. Return all child elements of the frame if `childElements` exists. + */ + getChildElementsInFrame(frame: FrameBlockModel): GfxModel[] { + if (frame.childElementIds === undefined) { + return this.getElementsInFrameBound(frame).filter( + element => this.getParentFrame(element) !== null + ); + } + + const childElements = frame.childIds + .map(id => this.gfx.getElementById(id)) + .filter(element => element !== null); + + return childElements as BlockSuite.EdgelessModel[]; + } + + /** + * Get all elements in the frame bound, + * whatever the element already has another parent frame or not. + */ + getElementsInFrameBound(frame: FrameBlockModel, fullyContained = true) { + const bound = Bound.deserialize(frame.xywh); + const elements: GfxModel[] = this.gfx.grid + .search(bound, { strict: fullyContained }) + .filter(element => element !== frame); + + return elements; + } + + /** + * Get most top frame from the point. + */ + getFrameFromPoint([x, y]: IVec, ignoreFrames: FrameBlockModel[] = []) { + for (let i = this.frames.length - 1; i >= 0; i--) { + const frame = this.frames[i]; + if (frame.includesPoint(x, y, {}) && !ignoreFrames.includes(frame)) { + return frame; + } + } + return null; + } + + getParentFrame(element: GfxModel) { + const container = element.group; + return container && isFrameBlock(container) ? container : null; + } + + /** + * This method will populate `presentationIndex` for all legacy frames, + * and keep the orderer of the legacy frames. + */ + refreshLegacyFrameOrder() { + const frames = this.frames.splice(0, this.frames.length); + + let splitIndex = frames.findIndex(frame => frame.presentationIndex); + if (splitIndex === 0) return; + + if (splitIndex === -1) splitIndex = frames.length; + + let afterPreIndex = + frames[splitIndex]?.presentationIndex || generateKeyBetweenV2(null, null); + + for (let index = splitIndex - 1; index >= 0; index--) { + const preIndex = generateKeyBetweenV2(null, afterPreIndex); + frames[index].presentationIndex = preIndex; + afterPreIndex = preIndex; + } + } + + removeAllChildrenFromFrame(frame: FrameBlockModel) { + this.gfx.doc.transact(() => { + frame.childElementIds = {}; + }); + } + + removeFromParentFrame(element: GfxModel) { + const parentFrame = this.getParentFrame(element); + // eslint-disable-next-line unicorn/prefer-dom-node-remove + parentFrame?.removeChild(element); + } + + override unmounted(): void { + this._disposable.dispose(); + } +} + +export function getNotesInFrameBound( + doc: Doc, + frame: FrameBlockModel, + fullyContained: boolean = true +) { + const bound = Bound.deserialize(frame.xywh); + + return (doc.getBlockByFlavour('affine:note') as NoteBlockModel[]).filter( + ele => { + const xywh = Bound.deserialize(ele.xywh); + + return fullyContained + ? bound.contains(xywh) + : bound.isPointInBound([xywh.x, xywh.y]); + } + ) as NoteBlockModel[]; +} + +export function getBlocksInFrameBound( + doc: Doc, + model: FrameBlockModel, + fullyContained: boolean = true +) { + const bound = Bound.deserialize(model.xywh); + const surface = model.surface; + if (!surface) return []; + + return ( + getNotesInFrameBound( + doc, + model, + fullyContained + ) as BlockSuite.EdgelessBlockModelType[] + ).concat( + surface.children.filter(ele => { + if (ele.id === model.id) return false; + if (ele instanceof GfxBlockModel) { + const blockBound = Bound.deserialize(ele.xywh); + return fullyContained + ? bound.contains(blockBound) + : bound.containsPoint([blockBound.x, blockBound.y]); + } + + return false; + }) as BlockSuite.EdgelessBlockModelType[] + ); +} diff --git a/blocksuite/blocks/src/root-block/edgeless/gfx-tool/brush-tool.ts b/blocksuite/blocks/src/root-block/edgeless/gfx-tool/brush-tool.ts new file mode 100644 index 0000000000..83345dc2f4 --- /dev/null +++ b/blocksuite/blocks/src/root-block/edgeless/gfx-tool/brush-tool.ts @@ -0,0 +1,178 @@ +import { CanvasElementType } from '@blocksuite/affine-block-surface'; +import type { BrushElementModel } from '@blocksuite/affine-model'; +import { TelemetryProvider } from '@blocksuite/affine-shared/services'; +import type { PointerEventState } from '@blocksuite/block-std'; +import { BaseTool } from '@blocksuite/block-std/gfx'; +import type { IVec } from '@blocksuite/global/utils'; +import { assertExists } from '@blocksuite/global/utils'; + +export class BrushTool extends BaseTool { + static BRUSH_POP_GAP = 20; + + static override toolName: string = 'brush'; + + private _draggingElement: BrushElementModel | null = null; + + private _draggingElementId: string | null = null; + + private _lastPoint: IVec | null = null; + + private _lastPopLength = 0; + + private _pressureSupportedPointerIds = new Set<number>(); + + private _straightLineType: 'horizontal' | 'vertical' | null = null; + + protected _draggingPathPoints: number[][] | null = null; + + protected _draggingPathPressures: number[] | null = null; + + private _getStraightLineType(currentPoint: IVec) { + const lastPoint = this._lastPoint; + if (!lastPoint) return null; + + // check angle to determine if the line is horizontal or vertical + const dx = currentPoint[0] - lastPoint[0]; + const dy = currentPoint[1] - lastPoint[1]; + const absAngleRadius = Math.abs(Math.atan2(dy, dx)); + return absAngleRadius < Math.PI / 4 || absAngleRadius > 3 * (Math.PI / 4) + ? 'horizontal' + : 'vertical'; + } + + private _tryGetPressurePoints(e: PointerEventState) { + assertExists(this._draggingPathPressures); + const pressures = [...this._draggingPathPressures, e.pressure]; + this._draggingPathPressures = pressures; + + // we do not use the `e.raw.pointerType` to detect because it is not reliable, + // such as some digital pens do not support pressure even thought the `e.raw.pointerType` is equal to `'pen'` + const pointerId = e.raw.pointerId; + const pressureChanged = pressures.some( + pressure => pressure !== pressures[0] + ); + + if (pressureChanged) { + this._pressureSupportedPointerIds.add(pointerId); + } + + assertExists(this._draggingPathPoints); + const points = this._draggingPathPoints; + if (this._pressureSupportedPointerIds.has(pointerId)) { + return points.map(([x, y], i) => [x, y, pressures[i]]); + } else { + return points; + } + } + + override dragEnd() { + if (this._draggingElement) { + const { _draggingElement } = this; + this.doc.withoutTransact(() => { + _draggingElement.pop('points'); + _draggingElement.pop('xywh'); + }); + } + this._draggingElement = null; + this._draggingElementId = null; + this._draggingPathPoints = null; + this._draggingPathPressures = null; + this._lastPoint = null; + this._straightLineType = null; + this.doc.captureSync(); + } + + override dragMove(e: PointerEventState) { + if (!this._draggingElementId || !this._draggingElement || !this.gfx.surface) + return; + + assertExists(this._draggingElementId); + assertExists(this._draggingPathPoints); + + let pointX = e.point.x; + let pointY = e.point.y; + const holdingShiftKey = e.keys.shift || this.gfx.keyboard.shiftKey$.peek(); + if (holdingShiftKey) { + if (!this._straightLineType) { + this._straightLineType = this._getStraightLineType([pointX, pointY]); + } + + if (this._straightLineType === 'horizontal') { + pointY = this._lastPoint?.[1] ?? pointY; + } else if (this._straightLineType === 'vertical') { + pointX = this._lastPoint?.[0] ?? pointX; + } + } else if (this._straightLineType) { + this._straightLineType = null; + } + + const [modelX, modelY] = this.gfx.viewport.toModelCoord(pointX, pointY); + + const points = [...this._draggingPathPoints, [modelX, modelY]]; + + this._lastPoint = [pointX, pointY]; + this._draggingPathPoints = points; + + this.gfx.updateElement(this._draggingElement!, { + points: this._tryGetPressurePoints(e), + }); + + if ( + this._lastPopLength + BrushTool.BRUSH_POP_GAP < + this._draggingElement!.points.length + ) { + this._lastPopLength = this._draggingElement!.points.length; + this.doc.withoutTransact(() => { + this._draggingElement!.pop('points'); + this._draggingElement!.pop('xywh'); + }); + + this._draggingElement!.stash('points'); + this._draggingElement!.stash('xywh'); + } + } + + override dragStart(e: PointerEventState) { + if (!this.gfx.surface) { + return; + } + + this.doc.captureSync(); + + const { viewport } = this.gfx; + + // create a shape block when drag start + const [modelX, modelY] = viewport.toModelCoord(e.point.x, e.point.y); + const points = [[modelX, modelY]]; + const id = this.gfx.surface.addElement({ + type: CanvasElementType.BRUSH, + points, + }); + + this.std.getOptional(TelemetryProvider)?.track('CanvasElementAdded', { + control: 'canvas:draw', + page: 'whiteboard editor', + module: 'toolbar', + segment: 'toolbar', + type: CanvasElementType.BRUSH, + }); + + const element = this.gfx.getElementById(id) as BrushElementModel; + + element.stash('points'); + element.stash('xywh'); + + this._lastPoint = [e.point.x, e.point.y]; + this._draggingElementId = id; + this._draggingElement = element; + this._draggingPathPoints = points; + this._draggingPathPressures = [e.pressure]; + this._lastPopLength = 0; + } +} + +declare module '@blocksuite/block-std/gfx' { + interface GfxToolsMap { + brush: BrushTool; + } +} diff --git a/blocksuite/blocks/src/root-block/edgeless/gfx-tool/connector-tool.ts b/blocksuite/blocks/src/root-block/edgeless/gfx-tool/connector-tool.ts new file mode 100644 index 0000000000..f594e58e60 --- /dev/null +++ b/blocksuite/blocks/src/root-block/edgeless/gfx-tool/connector-tool.ts @@ -0,0 +1,231 @@ +import { + calculateNearestLocation, + CanvasElementType, + type ConnectionOverlay, + ConnectorEndpointLocations, + ConnectorEndpointLocationsOnTriangle, + OverlayIdentifier, +} from '@blocksuite/affine-block-surface'; +import type { + Connection, + ConnectorElementModel, + ConnectorMode, +} from '@blocksuite/affine-model'; +import { + GroupElementModel, + ShapeElementModel, + ShapeType, +} from '@blocksuite/affine-model'; +import { TelemetryProvider } from '@blocksuite/affine-shared/services'; +import type { PointerEventState } from '@blocksuite/block-std'; +import { BaseTool } from '@blocksuite/block-std/gfx'; +import type { IBound, IVec } from '@blocksuite/global/utils'; +import { Bound } from '@blocksuite/global/utils'; + +enum ConnectorToolMode { + // Dragging connect + Dragging, + // Quick connect + Quick, +} + +export type ConnectorToolOptions = { + mode: ConnectorMode; +}; + +export class ConnectorTool extends BaseTool<ConnectorToolOptions> { + static override toolName: string = 'connector'; + + // Likes pressing `ESC` + private _allowCancel = false; + + private _connector: ConnectorElementModel | null = null; + + private _mode: ConnectorToolMode = ConnectorToolMode.Dragging; + + private _source: Connection | null = null; + + private _sourceBounds: IBound | null = null; + + private _sourceLocations: IVec[] = ConnectorEndpointLocations; + + private _startPoint: IVec | null = null; + + private get _overlay() { + return this.std.get(OverlayIdentifier('connection')) as ConnectionOverlay; + } + + private _createConnector() { + if (!(this._source && this._startPoint) || !this.gfx.surface) { + this._source = null; + this._startPoint = null; + return; + } + + this.doc.captureSync(); + const id = this.gfx.surface.addElement({ + type: CanvasElementType.CONNECTOR, + mode: this.activatedOption.mode, + controllers: [], + source: this._source, + target: { position: this._startPoint }, + }); + + this.gfx.std.getOptional(TelemetryProvider)?.track('CanvasElementAdded', { + control: 'canvas:draw', + page: 'whiteboard editor', + module: 'toolbar', + segment: 'toolbar', + type: CanvasElementType.CONNECTOR, + }); + + const connector = this.gfx.getElementById(id); + if (!connector) { + this._source = null; + this._startPoint = null; + return; + } + + this._connector = connector as ConnectorElementModel; + } + + override click() { + if (this._mode === ConnectorToolMode.Dragging) return; + if (!this._connector) return; + + const { id, source, target } = this._connector; + let focusedId = id; + + if (source?.id && !target?.id) { + focusedId = source.id; + this._allowCancel = true; + } + + this.gfx.tool.setTool('default'); + this.gfx.selection.set({ elements: [focusedId] }); + } + + override deactivate() { + const id = this._connector?.id; + + if (this._allowCancel && id) { + this.gfx.surface?.deleteElement(id); + } + + this._overlay?.clear(); + this._mode = ConnectorToolMode.Dragging; + this._connector = null; + this._source = null; + this._sourceBounds = null; + this._startPoint = null; + this._allowCancel = false; + } + + override dragEnd() { + if (this._mode === ConnectorToolMode.Quick) return; + if (!this._connector) return; + + const connector = this._connector; + + this.doc.captureSync(); + this.gfx.tool.setTool('default'); + this.gfx.selection.set({ elements: [connector.id] }); + } + + override dragMove(e: PointerEventState) { + this.findTargetByPoint([e.x, e.y]); + } + + override dragStart() { + if (this._mode === ConnectorToolMode.Quick) return; + + this._createConnector(); + } + + findTargetByPoint(point: IVec) { + if (!this._connector || !this.gfx.surface) return; + + const { _connector } = this; + + point = this.gfx.viewport.toModelCoord(point[0], point[1]); + + const excludedIds = []; + if (_connector.source?.id) { + excludedIds.push(_connector.source.id); + } + + const target = this._overlay?.renderConnector(point, excludedIds); + this.gfx.updateElement(_connector, { target }); + } + + override pointerDown(e: PointerEventState) { + this._startPoint = this.gfx.viewport.toModelCoord(e.x, e.y); + this._source = this._overlay?.renderConnector(this._startPoint) ?? null; + } + + override pointerMove(e: PointerEventState) { + if (this._mode === ConnectorToolMode.Dragging) return; + if (!this._sourceBounds) return; + if (!this._connector) return; + const sourceId = this._connector.source?.id; + if (!sourceId) return; + + const point = this.gfx.viewport.toModelCoord(e.x, e.y); + const target = this._overlay!.renderConnector(point, [sourceId]); + + this._allowCancel = !target.id; + this._connector.source.position = calculateNearestLocation( + point, + this._sourceBounds, + this._sourceLocations + ); + this.gfx.updateElement(this._connector, { + target, + source: this._connector.source, + }); + } + + override pointerUp(_: PointerEventState): void { + this._overlay?.clear(); + } + + quickConnect(point: IVec, element: BlockSuite.EdgelessModel) { + this._startPoint = this.gfx.viewport.toModelCoord(point[0], point[1]); + this._mode = ConnectorToolMode.Quick; + this._sourceBounds = Bound.deserialize(element.xywh); + this._sourceBounds.rotate = element.rotate; + this._sourceLocations = + element instanceof ShapeElementModel && + element.shapeType === ShapeType.Triangle + ? ConnectorEndpointLocationsOnTriangle + : ConnectorEndpointLocations; + + this._source = { + id: element.id, + position: calculateNearestLocation( + this._startPoint, + this._sourceBounds, + this._sourceLocations + ), + }; + this._allowCancel = true; + + this._createConnector(); + + if (element instanceof GroupElementModel && this._overlay) { + this._overlay.sourceBounds = this._sourceBounds; + } + + this.findTargetByPoint(point); + } +} + +declare module '@blocksuite/block-std/gfx' { + interface GfxToolsMap { + connector: ConnectorTool; + } + + interface GfxToolsOption { + connector: ConnectorToolOptions; + } +} diff --git a/blocksuite/blocks/src/root-block/edgeless/gfx-tool/copilot-tool.ts b/blocksuite/blocks/src/root-block/edgeless/gfx-tool/copilot-tool.ts new file mode 100644 index 0000000000..689846e521 --- /dev/null +++ b/blocksuite/blocks/src/root-block/edgeless/gfx-tool/copilot-tool.ts @@ -0,0 +1,177 @@ +/* eslint-disable @typescript-eslint/no-non-null-assertion */ +import type { PointerEventState } from '@blocksuite/block-std'; +import { BaseTool, MouseButton } from '@blocksuite/block-std/gfx'; +import { IS_MAC } from '@blocksuite/global/env'; +import { Bound, getCommonBoundWithRotation } from '@blocksuite/global/utils'; +import { Slot } from '@blocksuite/store'; + +import { + AFFINE_AI_PANEL_WIDGET, + type AffineAIPanelWidget, +} from '../../widgets/ai-panel/ai-panel.js'; + +export class CopilotTool extends BaseTool { + static override toolName: string = 'copilot'; + + private _dragging = false; + + draggingAreaUpdated = new Slot<boolean | void>(); + + dragLastPoint: [number, number] = [0, 0]; + + dragStartPoint: [number, number] = [0, 0]; + + override get allowDragWithRightButton() { + return true; + } + + get area() { + const start = new DOMPoint(this.dragStartPoint[0], this.dragStartPoint[1]); + const end = new DOMPoint(this.dragLastPoint[0], this.dragLastPoint[1]); + + const minX = Math.min(start.x, end.x); + const minY = Math.min(start.y, end.y); + const maxX = Math.max(start.x, end.x); + const maxY = Math.max(start.y, end.y); + + return new DOMRect(minX, minY, maxX - minX, maxY - minY); + } + + // AI processing + get processing() { + const aiPanel = this.gfx.std.view.getWidget( + AFFINE_AI_PANEL_WIDGET, + this.doc.root!.id + ) as AffineAIPanelWidget; + return aiPanel && aiPanel.state !== 'hidden'; + } + + get selectedElements() { + return this.gfx.selection.selectedElements; + } + + private _initDragState(e: PointerEventState) { + this.dragStartPoint = this.gfx.viewport.toModelCoord(e.x, e.y); + this.dragLastPoint = this.dragStartPoint; + } + + abort() { + this._dragging = false; + this.dragStartPoint = [0, 0]; + this.dragLastPoint = [0, 0]; + this.gfx.tool.setTool('default'); + } + + override activate(): void { + this.gfx.viewport.locked = true; + + if (this.gfx.selection.lastSurfaceSelections) { + this.gfx.selection.set(this.gfx.selection.lastSurfaceSelections); + } + } + + override deactivate(): void { + this.gfx.viewport.locked = false; + } + + override dragEnd(): void { + if (!this._dragging) return; + + this._dragging = false; + this.draggingAreaUpdated.emit(true); + } + + override dragMove(e: PointerEventState): void { + if (!this._dragging) return; + + this.dragLastPoint = this.gfx.viewport.toModelCoord(e.x, e.y); + + const area = this.area; + const bound = new Bound(area.x, area.y, area.width, area.height); + + if (area.width & area.height) { + const elements = this.gfx.getElementsByBound(bound); + + const set = new Set(elements); + + this.gfx.selection.set({ + elements: Array.from(set).map(element => element.id), + editing: false, + inoperable: true, + }); + } + + this.draggingAreaUpdated.emit(); + } + + override dragStart(e: PointerEventState): void { + if (this.processing) return; + + this._initDragState(e); + this._dragging = true; + this.draggingAreaUpdated.emit(); + } + + override mounted(): void { + this.addHook('pointerDown', evt => { + const useCopilot = + evt.raw.button === MouseButton.SECONDARY || + (evt.raw.button === MouseButton.MAIN && IS_MAC + ? evt.raw.metaKey + : evt.raw.ctrlKey); + + if (useCopilot) { + this.controller.setTool('copilot'); + return false; + } + + return; + }); + } + + override pointerDown(e: PointerEventState): void { + if (this.processing) { + e.raw.stopPropagation(); + return; + } + + this.gfx.tool.setTool('default'); + } + + updateDragPointsWith( + selectedElements: BlockSuite.EdgelessModel[], + padding = 0 + ) { + const bounds = getCommonBoundWithRotation(selectedElements).expand( + padding / this.gfx.viewport.zoom + ); + + this.dragStartPoint = bounds.tl as [number, number]; + this.dragLastPoint = bounds.br as [number, number]; + } + + updateSelectionWith( + selectedElements: BlockSuite.EdgelessModel[], + padding = 0 + ) { + const { selection } = this.gfx; + + selection.clear(); + + this.updateDragPointsWith(selectedElements, padding); + + selection.set({ + elements: selectedElements.map(e => e.id), + editing: false, + inoperable: true, + }); + + this.draggingAreaUpdated.emit(true); + } +} + +declare module '@blocksuite/block-std/gfx' { + interface GfxToolsMap { + copilot: CopilotTool; + } +} diff --git a/blocksuite/blocks/src/root-block/edgeless/gfx-tool/default-tool-ext/event-ext.ts b/blocksuite/blocks/src/root-block/edgeless/gfx-tool/default-tool-ext/event-ext.ts new file mode 100644 index 0000000000..882d905d9d --- /dev/null +++ b/blocksuite/blocks/src/root-block/edgeless/gfx-tool/default-tool-ext/event-ext.ts @@ -0,0 +1,64 @@ +import type { PointerEventState } from '@blocksuite/block-std'; +import type { GfxElementModelView } from '@blocksuite/block-std/gfx'; +import { Bound, last } from '@blocksuite/global/utils'; + +import { DefaultModeDragType, DefaultToolExt } from './ext.js'; + +export class CanvasElementEventExt extends DefaultToolExt { + private _currentStackedElm: GfxElementModelView[] = []; + + override supportedDragTypes: DefaultModeDragType[] = [ + DefaultModeDragType.None, + ]; + + private _callInReverseOrder( + callback: (view: GfxElementModelView) => void, + arr = this._currentStackedElm + ) { + for (let i = arr.length - 1; i >= 0; i--) { + const view = arr[i]; + + callback(view); + } + } + + override click(_evt: PointerEventState): void { + last(this._currentStackedElm)?.dispatch('click', _evt); + } + + override dblClick(_evt: PointerEventState): void { + last(this._currentStackedElm)?.dispatch('dblclick', _evt); + } + + override pointerDown(_evt: PointerEventState): void { + last(this._currentStackedElm)?.dispatch('pointerdown', _evt); + } + + override pointerMove(_evt: PointerEventState): void { + const [x, y] = this.gfx.viewport.toModelCoord(_evt.x, _evt.y); + const hoveredElmViews = this.gfx.grid + .search(new Bound(x, y, 1, 1), { + filter: ['canvas', 'local'], + }) + .map(model => this.gfx.view.get(model)) as GfxElementModelView[]; + const currentStackedViews = new Set(this._currentStackedElm); + const visited = new Set<GfxElementModelView>(); + + this._callInReverseOrder(view => { + if (currentStackedViews.has(view)) { + visited.add(view); + view.dispatch('pointermove', _evt); + } else { + view.dispatch('pointerenter', _evt); + } + }, hoveredElmViews); + this._callInReverseOrder( + view => !visited.has(view) && view.dispatch('pointerleave', _evt) + ); + this._currentStackedElm = hoveredElmViews; + } + + override pointerUp(_evt: PointerEventState): void { + last(this._currentStackedElm)?.dispatch('pointerup', _evt); + } +} diff --git a/blocksuite/blocks/src/root-block/edgeless/gfx-tool/default-tool-ext/ext.ts b/blocksuite/blocks/src/root-block/edgeless/gfx-tool/default-tool-ext/ext.ts new file mode 100644 index 0000000000..707ce18e43 --- /dev/null +++ b/blocksuite/blocks/src/root-block/edgeless/gfx-tool/default-tool-ext/ext.ts @@ -0,0 +1,63 @@ +import type { PointerEventState } from '@blocksuite/block-std'; +import type { GfxModel } from '@blocksuite/block-std/gfx'; + +import type { DefaultTool } from '../default-tool.js'; + +export enum DefaultModeDragType { + /** press alt/option key to clone selected */ + AltCloning = 'alt-cloning', + /** Moving connector label */ + ConnectorLabelMoving = 'connector-label-moving', + /** Moving selected contents */ + ContentMoving = 'content-moving', + /** Native range dragging inside active note block */ + NativeEditing = 'native-editing', + /** Default void state */ + None = 'none', + /** Dragging preview */ + PreviewDragging = 'preview-dragging', + /** Expanding the dragging area, select the content covered inside */ + Selecting = 'selecting', +} + +export type DragState = { + movedElements: GfxModel[]; + dragType: DefaultModeDragType; + event: PointerEventState; +}; + +export class DefaultToolExt { + readonly supportedDragTypes: DefaultModeDragType[] = []; + + get gfx() { + return this.defaultTool.gfx; + } + + get std() { + return this.defaultTool.std; + } + + constructor(protected defaultTool: DefaultTool) {} + + click(_evt: PointerEventState) {} + + dblClick(_evt: PointerEventState) {} + + initDrag(_: DragState): { + dragStart?: (evt: PointerEventState) => void; + dragMove?: (evt: PointerEventState) => void; + dragEnd?: (evt: PointerEventState) => void; + } { + return {}; + } + + mounted() {} + + pointerDown(_evt: PointerEventState) {} + + pointerMove(_evt: PointerEventState) {} + + pointerUp(_evt: PointerEventState) {} + + unmounted() {} +} diff --git a/blocksuite/blocks/src/root-block/edgeless/gfx-tool/default-tool-ext/mind-map-ext/drag-utils.ts b/blocksuite/blocks/src/root-block/edgeless/gfx-tool/default-tool-ext/mind-map-ext/drag-utils.ts new file mode 100644 index 0000000000..51625170f1 --- /dev/null +++ b/blocksuite/blocks/src/root-block/edgeless/gfx-tool/default-tool-ext/mind-map-ext/drag-utils.ts @@ -0,0 +1,192 @@ +/* eslint-disable @typescript-eslint/no-non-null-assertion */ +import { + NODE_HORIZONTAL_SPACING, + NODE_VERTICAL_SPACING, +} from '@blocksuite/affine-block-surface'; +import { + LayoutType, + type MindmapElementModel, + type MindmapNode, + type MindmapRoot, +} from '@blocksuite/affine-model'; +import { Bound, last } from '@blocksuite/global/utils'; + +const isOnEdge = (node: MindmapNode, direction: 'tail' | 'head') => { + let current = node; + + while (current) { + if (!current.parent) return true; + + if (direction === 'tail' && last(current.parent.children) === current) { + current = current.parent; + } else if (direction === 'head' && current.parent.children[0] === current) { + current = current.parent; + } else { + return false; + } + } + + return true; +}; + +const TAIL_RESPONSE_AREA = NODE_HORIZONTAL_SPACING; + +const fillResponseArea = ( + node: MindmapNode, + layoutType: LayoutType, + parent: MindmapNode | null +) => { + // root node + if (!parent) { + const rootElmBound = node.element.elementBound; + const width = + layoutType === LayoutType.BALANCE + ? rootElmBound.w + TAIL_RESPONSE_AREA * 2 + : rootElmBound.w + TAIL_RESPONSE_AREA; + + node.responseArea = new Bound( + layoutType === LayoutType.BALANCE || layoutType === LayoutType.LEFT + ? rootElmBound.x - TAIL_RESPONSE_AREA + : rootElmBound.x, + rootElmBound.y, + width, + rootElmBound.h + ); + + if (node.detail.collapsed) { + return; + } + + if (layoutType === LayoutType.BALANCE) { + (node as MindmapRoot).right.forEach(child => { + fillResponseArea(child, LayoutType.RIGHT, node); + }); + (node as MindmapRoot).left.forEach(child => { + fillResponseArea(child, LayoutType.LEFT, node); + }); + } else { + node.children.forEach(child => { + fillResponseArea(child, layoutType, node); + }); + } + return; + } else { + const nodeBound = node.element.elementBound; + const idx = parent.children.indexOf(node) ?? -1; + const isLast = + idx === (parent.children.length || -1) - 1 && isOnEdge(node, 'tail'); + const isFirst = idx === 0 && isOnEdge(node, 'head'); + const upperSpacing = isFirst + ? NODE_VERTICAL_SPACING * 2 + : NODE_VERTICAL_SPACING / 2; + const lowerSpacing = isLast + ? NODE_VERTICAL_SPACING * 2 + : NODE_VERTICAL_SPACING / 2; + + const h = nodeBound.h + upperSpacing + lowerSpacing; + const w = + (layoutType === LayoutType.RIGHT + ? node.element.x + + node.element.w - + (parent.element.x + parent.element.w) + : parent.element.x - node.element.x) + TAIL_RESPONSE_AREA; + + node.responseArea = new Bound( + layoutType === LayoutType.RIGHT + ? parent.element.x + parent.element.w + : parent.element.x - w, + node.element.y - upperSpacing, + w, + h + ); + + if (node.children.length > 0 && !node.detail.collapsed) { + let responseArea: Bound; + + node.children.forEach(child => { + fillResponseArea(child, layoutType, node); + + if (responseArea) { + responseArea = responseArea.unite(child.responseArea!); + } else { + responseArea = child.responseArea!; + } + }); + + node.responseArea.h = responseArea!.h; + node.responseArea.y = responseArea!.y; + } + } +}; + +export const balanceLeftRightResponseArea = (tree: MindmapRoot) => { + const leftTreeArea = tree.left.reduce((pre: Bound | null, node) => { + if (pre) { + return pre.unite(node.responseArea!); + } + return node.responseArea!; + }, null); + const rightTreeArea = tree.right.reduce((pre: Bound | null, node) => { + if (pre) { + return pre.unite(node.responseArea!); + } + return node.responseArea!; + }, null); + + if (!leftTreeArea || !rightTreeArea) { + return; + } + + // if the height of the left tree and right tree are not equal + // expand the response area of lower tree to match the height of the higher tree + if (leftTreeArea.h !== rightTreeArea.h) { + const isLeftHigher = leftTreeArea.h > rightTreeArea.h; + const upperBoundary = isLeftHigher ? leftTreeArea.y : rightTreeArea.y; + const bottomBoundary = isLeftHigher + ? leftTreeArea.y + leftTreeArea.h + : rightTreeArea.y + rightTreeArea.h; + const targetChildren = isLeftHigher ? tree.right : tree.left; + + const expandEdge = (children: MindmapNode[]) => { + const expand = (direction: 'up' | 'down') => { + const expandUpperEdge = direction === 'up'; + const node = direction === 'up' ? children[0] : last(children)!; + + if (!node) return; + + if (node.responseArea) { + node.responseArea.h = expandUpperEdge + ? node.responseArea.h + (node.responseArea.y - upperBoundary) + : node.responseArea.h + + (bottomBoundary - node.responseArea.y - node.responseArea.h); + expandUpperEdge && (node.responseArea.y = upperBoundary); + } + }; + + expand('up'); + expand('down'); + }; + + expandEdge(targetChildren); + } +}; + +export const calculateResponseArea = (mindmap: MindmapElementModel) => { + const layoutDir = mindmap.layoutType; + const tree = mindmap.tree; + + switch (layoutDir) { + case LayoutType.RIGHT: + case LayoutType.LEFT: + { + fillResponseArea(tree, layoutDir, null); + } + break; + case LayoutType.BALANCE: + { + fillResponseArea(tree, LayoutType.BALANCE, null); + balanceLeftRightResponseArea(tree); + } + break; + } +}; diff --git a/blocksuite/blocks/src/root-block/edgeless/gfx-tool/default-tool-ext/mind-map-ext/indicator-overlay.ts b/blocksuite/blocks/src/root-block/edgeless/gfx-tool/default-tool-ext/mind-map-ext/indicator-overlay.ts new file mode 100644 index 0000000000..6835308381 --- /dev/null +++ b/blocksuite/blocks/src/root-block/edgeless/gfx-tool/default-tool-ext/mind-map-ext/indicator-overlay.ts @@ -0,0 +1,303 @@ +/* eslint-disable @typescript-eslint/no-non-null-assertion */ +import { + NODE_HORIZONTAL_SPACING, + NODE_VERTICAL_SPACING, + Overlay, + PathGenerator, +} from '@blocksuite/affine-block-surface'; +import { + ConnectorMode, + LayoutType, + type MindmapElementModel, + type MindmapNode, +} from '@blocksuite/affine-model'; +import { ThemeProvider } from '@blocksuite/affine-shared/services'; +import { + type Bound, + isVecZero, + type IVec, + last, + PointLocation, + toRadian, + Vec, +} from '@blocksuite/global/utils'; + +export class MindMapIndicatorOverlay extends Overlay { + static INDICATOR_SIZE = [48, 22]; + + static override overlayName: string = 'mindmap-indicator'; + + currentDragPos: IVec | null = null; + + direction: LayoutType.LEFT | LayoutType.RIGHT = LayoutType.RIGHT; + + dragNodeImage: HTMLCanvasElement | null = null; + + dragNodePos: IVec = [0, 0]; + + mode: ConnectorMode = ConnectorMode.Straight; + + parentBound: Bound | null = null; + + pathGen = new PathGenerator(); + + targetBound: Bound | null = null; + + get themeService() { + return this.gfx.std.get(ThemeProvider); + } + + private _generatePath() { + const startRelativePos = + this.direction === LayoutType.RIGHT + ? PointLocation.fromVec([1, 0.5]) + : PointLocation.fromVec([0, 0.5]); + const endRelativePos = + this.direction === LayoutType.RIGHT + ? PointLocation.fromVec([0, 0.5]) + : PointLocation.fromVec([1, 0.5]); + const { parentBound, targetBound: newPosBound } = this; + + if (this.mode === ConnectorMode.Orthogonal) { + return this.pathGen + .generateOrthogonalConnectorPath({ + startPoint: this._getRelativePoint(parentBound!, startRelativePos), + endPoint: this._getRelativePoint(newPosBound!, endRelativePos), + startBound: parentBound, + endBound: newPosBound, + }) + .map(p => new PointLocation(p)); + } else if (this.mode === ConnectorMode.Curve) { + const startPoint = this._getRelativePoint( + this.parentBound!, + startRelativePos + ); + const endPoint = this._getRelativePoint( + this.targetBound!, + endRelativePos + ); + + const startTangentVertical = Vec.rot(startPoint.tangent, -Math.PI / 2); + startPoint.out = Vec.mul( + startTangentVertical, + Math.max( + 100, + Math.abs( + Vec.pry(Vec.sub(endPoint, startPoint), startTangentVertical) + ) / 3 + ) + ); + + const endTangentVertical = Vec.rot(endPoint.tangent, -Math.PI / 2); + endPoint.in = Vec.mul( + endTangentVertical, + Math.max( + 100, + Math.abs(Vec.pry(Vec.sub(startPoint, endPoint), endTangentVertical)) / + 3 + ) + ); + + return [startPoint, endPoint]; + } else { + const startPoint = new PointLocation( + this.parentBound!.getRelativePoint(startRelativePos) + ); + const endPoint = new PointLocation( + this.targetBound!.getRelativePoint(endRelativePos) + ); + + return [startPoint, endPoint]; + } + } + + private _getRelativePoint(bound: Bound, position: IVec) { + const location = new PointLocation( + bound.getRelativePoint(position as IVec) + ); + + if (isVecZero(Vec.sub(position, [0, 0.5]))) + location.tangent = Vec.rot([0, -1], toRadian(0)); + else if (isVecZero(Vec.sub(position, [1, 0.5]))) + location.tangent = Vec.rot([0, 1], toRadian(0)); + else if (isVecZero(Vec.sub(position, [0.5, 0]))) + location.tangent = Vec.rot([1, 0], toRadian(0)); + else if (isVecZero(Vec.sub(position, [0.5, 1]))) + location.tangent = Vec.rot([-1, 0], toRadian(0)); + + return location; + } + + /** + * Use to calculate the position of the indicator given its sibling's bound + * @param siblingBound + * @param direction + */ + private _moveRelativeToBound( + siblingBound: Bound, + direction: 'up' | 'down', + layoutDir: Exclude<LayoutType, LayoutType.BALANCE> + ) { + const isLeftLayout = layoutDir === LayoutType.LEFT; + const isUpDirection = direction === 'up'; + + return siblingBound.moveDelta( + isLeftLayout + ? siblingBound.w - MindMapIndicatorOverlay.INDICATOR_SIZE[0] + : 0, + isUpDirection + ? -( + NODE_VERTICAL_SPACING / 2 + + MindMapIndicatorOverlay.INDICATOR_SIZE[1] / 2 + ) + : siblingBound.h + + NODE_VERTICAL_SPACING / 2 - + MindMapIndicatorOverlay.INDICATOR_SIZE[1] / 2 + ); + } + + override clear() { + this.targetBound = null; + this.parentBound = null; + } + + override render(ctx: CanvasRenderingContext2D): void { + if (this.currentDragPos && this.dragNodeImage) { + ctx.save(); + ctx.globalAlpha = 0.3; + ctx.drawImage( + this.dragNodeImage, + this.currentDragPos[0] + this.dragNodePos[0], + this.currentDragPos[1] + this.dragNodePos[1], + this.dragNodeImage.width / 2, + this.dragNodeImage.height / 2 + ); + ctx.restore(); + } + + if (!this.parentBound || !this.targetBound) { + return; + } + + const targetPos = this.targetBound; + const points = this._generatePath(); + const color = this.themeService.getColorValue( + '--affine-primary-color', + '#1E96EB', + true + ); + + ctx.strokeStyle = color; + ctx.fillStyle = color; + ctx.lineWidth = 3; + + ctx.roundRect(targetPos.x, targetPos.y, targetPos.w, targetPos.h, 4); + ctx.fill(); + + ctx.beginPath(); + ctx.moveTo(points[0][0], points[0][1]); + + if (this.mode === ConnectorMode.Curve) { + points.forEach((point, idx) => { + if (idx === 0) return; + const last = points[idx - 1]; + ctx.bezierCurveTo( + last.absOut[0], + last.absOut[1], + point.absIn[0], + point.absIn[1], + point[0], + point[1] + ); + }); + } else { + points.forEach((point, idx) => { + if (idx === 0) return; + ctx.lineTo(point[0], point[1]); + }); + } + + ctx.stroke(); + ctx.closePath(); + } + + setIndicatorInfo(options: { + targetMindMap: MindmapElementModel; + target: MindmapNode; + parent: MindmapNode; + parentChildren: MindmapNode[]; + insertPosition: + | { + type: 'sibling'; + layoutDir: Exclude<LayoutType, LayoutType.BALANCE>; + position: 'prev' | 'next'; + } + | { type: 'child'; layoutDir: Exclude<LayoutType, LayoutType.BALANCE> }; + path: number[]; + }) { + const { + insertPosition, + parent, + parentChildren, + targetMindMap, + target, + path, + } = options; + + const parentBound = parent.element.elementBound; + const isBalancedMindMap = targetMindMap.layoutType === LayoutType.BALANCE; + const isLeftLayout = insertPosition.layoutDir === LayoutType.LEFT; + const isFirstLevel = path.length === 2; + + this.direction = insertPosition.layoutDir; + this.parentBound = parentBound; + + if (insertPosition.type === 'sibling') { + const targetBound = target.element.elementBound; + + this.targetBound = + isBalancedMindMap && isFirstLevel && isLeftLayout + ? this._moveRelativeToBound( + targetBound, + insertPosition.position === 'next' ? 'up' : 'down', + insertPosition.layoutDir + ) + : this._moveRelativeToBound( + targetBound, + insertPosition.position === 'next' ? 'down' : 'up', + insertPosition.layoutDir + ); + } else { + if (parentChildren.length === 0 || parent.detail.collapsed) { + this.targetBound = parentBound.moveDelta( + (isLeftLayout ? -1 : 1) * + (NODE_HORIZONTAL_SPACING / 2 + parentBound.w), + parentBound.h / 2 - MindMapIndicatorOverlay.INDICATOR_SIZE[1] / 2 + ); + } else { + const lastChildBound = last(parentChildren)!.element.elementBound; + + this.targetBound = + isBalancedMindMap && isFirstLevel && isLeftLayout + ? this._moveRelativeToBound( + lastChildBound, + 'up', + insertPosition.layoutDir + ) + : this._moveRelativeToBound( + lastChildBound, + 'down', + insertPosition.layoutDir + ); + } + } + + this.targetBound.w = MindMapIndicatorOverlay.INDICATOR_SIZE[0]; + this.targetBound.h = MindMapIndicatorOverlay.INDICATOR_SIZE[1]; + + this.mode = targetMindMap.styleGetter.getNodeStyle( + target, + options.path + ).connector.mode; + } +} diff --git a/blocksuite/blocks/src/root-block/edgeless/gfx-tool/default-tool-ext/mind-map-ext/mind-map-ext.ts b/blocksuite/blocks/src/root-block/edgeless/gfx-tool/default-tool-ext/mind-map-ext/mind-map-ext.ts new file mode 100644 index 0000000000..6ad04ebb68 --- /dev/null +++ b/blocksuite/blocks/src/root-block/edgeless/gfx-tool/default-tool-ext/mind-map-ext/mind-map-ext.ts @@ -0,0 +1,459 @@ +/* eslint-disable @typescript-eslint/no-non-null-assertion */ +import { + MindmapUtils, + NODE_HORIZONTAL_SPACING, + NODE_VERTICAL_SPACING, + OverlayIdentifier, + type SurfaceBlockComponent, +} from '@blocksuite/affine-block-surface'; +import { + type LayoutType, + type LocalConnectorElementModel, + MindmapElementModel, + type MindmapNode, +} from '@blocksuite/affine-model'; +import type { PointerEventState } from '@blocksuite/block-std'; +import { + type GfxModel, + isGfxGroupCompatibleModel, +} from '@blocksuite/block-std/gfx'; +import type { Bound, IVec } from '@blocksuite/global/utils'; + +import { + isMindmapNode, + isSingleMindMapNode, +} from '../../../../../_common/edgeless/mindmap/index.js'; +import { DefaultModeDragType, DefaultToolExt, type DragState } from '../ext.js'; +import { calculateResponseArea } from './drag-utils.js'; +import type { MindMapIndicatorOverlay } from './indicator-overlay.js'; + +type DragMindMapCtx = { + mindmap: MindmapElementModel; + node: MindmapNode; + clear?: () => void; + originalMindMapBound: Bound; + startPoint: PointerEventState; +}; + +export class MindMapExt extends DefaultToolExt { + private _responseAreaUpdated = new Set<MindmapElementModel>(); + + override supportedDragTypes: DefaultModeDragType[] = [ + DefaultModeDragType.ContentMoving, + ]; + + private get _indicatorOverlay() { + return this.std.getOptional( + OverlayIdentifier('mindmap-indicator') + ) as MindMapIndicatorOverlay | null; + } + + private _calcDragResponseArea(mindmap: MindmapElementModel) { + calculateResponseArea(mindmap); + this._responseAreaUpdated.add(mindmap); + } + + /** + * Create handlers that can drag and drop mind map nodes + * @param dragMindMapCtx + * @param dragState + * @returns + */ + private _createManipulationHandlers(dragMindMapCtx: DragMindMapCtx): { + dragStart?: (evt: PointerEventState) => void; + dragMove?: (evt: PointerEventState) => void; + dragEnd?: (evt: PointerEventState) => void; + } { + let hoveredCtx: { + mindmap: MindmapElementModel | null; + node: MindmapNode | null; + detach?: boolean; + abort?: () => void; + merge?: () => void; + } | null = null; + + return { + dragMove: (_: PointerEventState) => { + const [x, y] = this.defaultTool.dragLastPos; + const hoveredMindMap = this._getHoveredMindMap([x, y], dragMindMapCtx); + const indicator = this._indicatorOverlay; + + if (indicator) { + indicator.currentDragPos = [x, y]; + indicator.refresh(); + } + + hoveredCtx?.abort?.(); + + const hoveredNode = hoveredMindMap + ? MindmapUtils.findTargetNode(hoveredMindMap, [x, y]) + : null; + + hoveredCtx = { + mindmap: hoveredMindMap, + node: hoveredNode, + }; + + // 1. not hovered on any mind map or + // 2. hovered on the other mind map but not on any node + // then consider user is trying to detach the node + if ( + !hoveredMindMap || + (hoveredMindMap !== dragMindMapCtx.mindmap && !hoveredNode) + ) { + hoveredCtx.detach = true; + + const reset = (hoveredCtx.abort = MindmapUtils.hideNodeConnector( + dragMindMapCtx.mindmap, + dragMindMapCtx.node + )); + + hoveredCtx.abort = () => { + reset?.(); + }; + } else { + // hovered on the currently dragging mind map but + // 1. not hovered on any node or + // 2. hovered on the node that is itself or its children (which is not allowed) + // then consider user is trying to drop the node to its original position + if ( + !hoveredNode || + MindmapUtils.containsNode( + hoveredMindMap, + hoveredNode, + dragMindMapCtx.node + ) + ) { + const { mindmap, node } = dragMindMapCtx; + + // if the node is the root node, then do nothing + if (node === mindmap.tree) { + return; + } + + const nodeBound = node.element.elementBound; + + hoveredCtx.abort = this._drawIndicator({ + targetMindMap: mindmap, + target: node, + sourceMindMap: mindmap, + source: node, + newParent: node.parent!, + insertPosition: { + type: 'sibling', + layoutDir: mindmap.getLayoutDir(node) as Exclude< + LayoutType, + LayoutType.BALANCE + >, + position: y > nodeBound.y + nodeBound.h / 2 ? 'next' : 'prev', + }, + path: mindmap.getPath(node), + }); + } else { + const operation = MindmapUtils.tryMoveNode( + hoveredMindMap, + hoveredNode, + dragMindMapCtx.mindmap, + dragMindMapCtx.node, + [x, y], + options => this._drawIndicator(options) + ); + + if (operation) { + hoveredCtx.abort = operation.abort; + hoveredCtx.merge = operation.merge; + } + } + } + }, + dragEnd: (e: PointerEventState) => { + if (hoveredCtx?.merge) { + hoveredCtx.merge(); + } else { + hoveredCtx?.abort?.(); + + if (hoveredCtx?.detach) { + const [startX, startY] = this.gfx.viewport.toModelCoord( + dragMindMapCtx.startPoint.x, + dragMindMapCtx.startPoint.y + ); + const [endX, endY] = this.gfx.viewport.toModelCoord(e.x, e.y); + + dragMindMapCtx.node.element.xywh = + dragMindMapCtx.node.element.elementBound + .moveDelta(endX - startX, endY - startY) + .serialize(); + + if (dragMindMapCtx.node !== dragMindMapCtx.mindmap.tree) { + MindmapUtils.detachMindmap( + dragMindMapCtx.mindmap, + dragMindMapCtx.node + ); + const mindmap = MindmapUtils.createFromTree( + dragMindMapCtx.node, + dragMindMapCtx.mindmap.style, + dragMindMapCtx.mindmap.layoutType, + this.gfx.surface! + ); + + mindmap.layout(); + } else { + dragMindMapCtx.mindmap.layout(); + } + } + } + + hoveredCtx = null; + dragMindMapCtx.clear?.(); + this._responseAreaUpdated.clear(); + }, + }; + } + + /** + * Create handlers that can translate entire mind map + */ + private _createTranslationHandlers( + _: DragState, + ctx: { + mindmaps: Set<MindmapElementModel>; + nodes: Set<GfxModel>; + } + ): { + dragStart?: (evt: PointerEventState) => void; + dragMove?: (evt: PointerEventState) => void; + dragEnd?: (evt: PointerEventState) => void; + } { + return { + dragStart: (_: PointerEventState) => { + ctx.nodes.forEach(node => { + node.stash('xywh'); + }); + }, + dragEnd: (_: PointerEventState) => { + ctx.mindmaps.forEach(mindmap => { + mindmap.layout(); + }); + }, + }; + } + + private _drawIndicator(options: { + targetMindMap: MindmapElementModel; + target: MindmapNode; + sourceMindMap: MindmapElementModel; + source: MindmapNode; + newParent: MindmapNode; + insertPosition: + | { + type: 'sibling'; + layoutDir: Exclude<LayoutType, LayoutType.BALANCE>; + position: 'prev' | 'next'; + } + | { type: 'child'; layoutDir: Exclude<LayoutType, LayoutType.BALANCE> }; + path: number[]; + }) { + const indicatorOverlay = this._indicatorOverlay; + + if (!indicatorOverlay) { + return () => {}; + } + + // draw the indicator at given position + const { newParent, insertPosition, targetMindMap, target, source, path } = + options; + const children = newParent.children.filter( + node => node.element.id !== source.id + ); + + indicatorOverlay.setIndicatorInfo({ + targetMindMap, + target, + parent: newParent, + insertPosition, + parentChildren: children, + path, + }); + + return () => { + indicatorOverlay.clear(); + }; + } + + private _getHoveredMindMap( + position: IVec, + dragMindMapCtx: DragMindMapCtx + ): MindmapElementModel | null { + const mindmap = + (this.gfx + .getElementByPoint(position[0], position[1], { + all: true, + responsePadding: [NODE_HORIZONTAL_SPACING, NODE_VERTICAL_SPACING * 2], + }) + .find(el => { + if (!(el instanceof MindmapElementModel)) { + return false; + } + + if ( + el === dragMindMapCtx.mindmap && + !dragMindMapCtx.originalMindMapBound.containsPoint(position) + ) { + return false; + } + + return true; + }) as MindmapElementModel) ?? null; + + if ( + mindmap && + (!this._responseAreaUpdated.has(mindmap) || !mindmap.tree.responseArea) + ) { + this._calcDragResponseArea(mindmap); + } + + return mindmap; + } + + private _setupDragNodeImage( + mindmapNode: MindmapNode, + event: PointerEventState + ) { + const surfaceBlock = this.gfx.surfaceComponent as SurfaceBlockComponent; + const renderer = surfaceBlock?.renderer; + const indicatorOverlay = this._indicatorOverlay; + + if (!renderer || !indicatorOverlay) { + return; + } + + const nodeBound = mindmapNode.element.elementBound; + + const pos = this.gfx.viewport.toModelCoord(event.x, event.y); + const canvas = renderer.getCanvasByBound( + mindmapNode.element.elementBound, + [mindmapNode.element], + undefined, + undefined, + false + ); + + indicatorOverlay.dragNodePos = [nodeBound.x - pos[0], nodeBound.y - pos[1]]; + indicatorOverlay.dragNodeImage = canvas; + + return () => { + indicatorOverlay.dragNodeImage = null; + indicatorOverlay.currentDragPos = null; + }; + } + + private _updateNodeOpacity( + mindmap: MindmapElementModel, + mindNode: MindmapNode + ) { + const OPACITY = 0.3; + const updatedNodes = new Set< + BlockSuite.SurfaceElementModel | LocalConnectorElementModel + >(); + const traverse = (node: MindmapNode, parent: MindmapNode | null) => { + node.element.opacity = OPACITY; + updatedNodes.add(node.element); + + if (parent) { + const connectorId = `#${parent.element.id}-${node.element.id}`; + const connector = mindmap.connectors.get(connectorId); + + if (connector) { + connector.opacity = OPACITY; + updatedNodes.add(connector); + } + } + + if (node.children.length) { + node.children.forEach(child => traverse(child, node)); + } + }; + + const parentNode = mindmap.getParentNode(mindNode.element.id) ?? null; + + traverse(mindNode, parentNode); + + return () => { + updatedNodes.forEach(el => { + el.opacity = 1; + }); + }; + } + + override initDrag(dragState: DragState) { + if (dragState.dragType !== DefaultModeDragType.ContentMoving) { + return {}; + } + + if (isSingleMindMapNode(dragState.movedElements)) { + const mindmap = dragState.movedElements[0].group as MindmapElementModel; + const mindmapNode = mindmap.getNode(dragState.movedElements[0].id)!; + const mindmapBound = mindmap.elementBound; + + dragState.movedElements.splice(0, 1); + + mindmapBound.x -= NODE_HORIZONTAL_SPACING; + mindmapBound.y -= NODE_VERTICAL_SPACING * 2; + mindmapBound.w += NODE_HORIZONTAL_SPACING * 2; + mindmapBound.h += NODE_VERTICAL_SPACING * 4; + + this._calcDragResponseArea(mindmap); + + const clearDragImage = this._setupDragNodeImage( + mindmapNode, + dragState.event + ); + const clearOpacity = this._updateNodeOpacity(mindmap, mindmapNode); + + const mindMapDragCtx: DragMindMapCtx = { + mindmap, + node: mindmapNode, + clear: () => { + clearOpacity(); + clearDragImage?.(); + dragState.movedElements.push(mindmapNode.element); + }, + originalMindMapBound: mindmapBound, + startPoint: dragState.event, + }; + + return this._createManipulationHandlers(mindMapDragCtx); + } + + const mindmapNodes = new Set<GfxModel>(); + const mindmaps = new Set<MindmapElementModel>(); + dragState.movedElements.forEach(el => { + if (isMindmapNode(el)) { + const mindmap = + el.group instanceof MindmapElementModel + ? el.group + : (el as MindmapElementModel); + + mindmaps.add(mindmap); + mindmap.childElements.forEach(child => mindmapNodes.add(child)); + } else if (isGfxGroupCompatibleModel(el)) { + el.descendantElements.forEach(desc => { + if (desc.group instanceof MindmapElementModel) { + mindmaps.add(desc.group); + desc.group.childElements.forEach(_el => mindmapNodes.add(_el)); + } + }); + } + }); + + if (mindmapNodes.size > 1) { + mindmapNodes.forEach(node => dragState.movedElements.push(node)); + return this._createTranslationHandlers(dragState, { + mindmaps, + nodes: mindmapNodes, + }); + } + + return {}; + } +} diff --git a/blocksuite/blocks/src/root-block/edgeless/gfx-tool/default-tool.ts b/blocksuite/blocks/src/root-block/edgeless/gfx-tool/default-tool.ts new file mode 100644 index 0000000000..35408569d7 --- /dev/null +++ b/blocksuite/blocks/src/root-block/edgeless/gfx-tool/default-tool.ts @@ -0,0 +1,1061 @@ +/* eslint-disable @typescript-eslint/no-non-null-assertion */ +import { + ConnectorUtils, + OverlayIdentifier, +} from '@blocksuite/affine-block-surface'; +import { focusTextModel } from '@blocksuite/affine-components/rich-text'; +import type { + EdgelessTextBlockModel, + FrameBlockModel, + NoteBlockModel, +} from '@blocksuite/affine-model'; +import { + ConnectorElementModel, + GroupElementModel, + MindmapElementModel, + ShapeElementModel, + TextElementModel, +} from '@blocksuite/affine-model'; +import { TelemetryProvider } from '@blocksuite/affine-shared/services'; +import { + clamp, + handleNativeRangeAtPoint, + resetNativeSelection, +} from '@blocksuite/affine-shared/utils'; +import type { PointerEventState } from '@blocksuite/block-std'; +import { + BaseTool, + getTopElements, + GfxExtensionIdentifier, + type GfxModel, + type GfxPrimitiveElementModel, + isGfxGroupCompatibleModel, + type PointTestOptions, +} from '@blocksuite/block-std/gfx'; +import type { IVec } from '@blocksuite/global/utils'; +import { + Bound, + DisposableGroup, + getCommonBoundWithRotation, + last, + noop, + Vec, +} from '@blocksuite/global/utils'; +import { effect } from '@preact/signals-core'; + +import { isSingleMindMapNode } from '../../../_common/edgeless/mindmap/index.js'; +import type { GfxBlockModel } from '../block-model.js'; +import type { EdgelessRootBlockComponent } from '../edgeless-root-block.js'; +import type { EdgelessFrameManager, FrameOverlay } from '../frame-manager.js'; +import { prepareCloneData } from '../utils/clone-utils.js'; +import { calPanDelta } from '../utils/panning-utils.js'; +import { + isCanvasElement, + isEdgelessTextBlock, + isFrameBlock, + isNoteBlock, +} from '../utils/query.js'; +import type { EdgelessSnapManager } from '../utils/snap-manager.js'; +import { + addText, + mountConnectorLabelEditor, + mountFrameTitleEditor, + mountGroupTitleEditor, + mountShapeTextEditor, + mountTextElementEditor, +} from '../utils/text.js'; +import { fitToScreen } from '../utils/viewport.js'; +import { CanvasElementEventExt } from './default-tool-ext/event-ext.js'; +import type { DefaultToolExt } from './default-tool-ext/ext.js'; +import { DefaultModeDragType } from './default-tool-ext/ext.js'; +import { MindMapExt } from './default-tool-ext/mind-map-ext/mind-map-ext.js'; + +export class DefaultTool extends BaseTool { + static override toolName: string = 'default'; + + private _accumulateDelta: IVec = [0, 0]; + + private _alignBound = new Bound(); + + private _autoPanTimer: number | null = null; + + private _clearDisposable = () => { + if (this._disposables) { + this._disposables.dispose(); + this._disposables = null; + } + }; + + private _clearSelectingState = () => { + this._stopAutoPanning(); + this._clearDisposable(); + + this._wheeling = false; + }; + + private _disposables: DisposableGroup | null = null; + + private _extHandlers: { + dragStart?: (evt: PointerEventState) => void; + dragMove?: (evt: PointerEventState) => void; + dragEnd?: (evt: PointerEventState) => void; + }[] = []; + + private _exts: DefaultToolExt[] = []; + + private _hoveredFrame: FrameBlockModel | null = null; + + // Do not select the text, when click again after activating the note. + private _isDoubleClickedOnMask = false; + + private _lock = false; + + private _panViewport = (delta: IVec) => { + this._accumulateDelta[0] += delta[0]; + this._accumulateDelta[1] += delta[1]; + this.gfx.viewport.applyDeltaCenter(delta[0], delta[1]); + }; + + private _pendingUpdates = new Map< + GfxBlockModel | GfxPrimitiveElementModel, + Partial<GfxBlockModel> + >(); + + private _rafId: number | null = null; + + private _selectedBounds: Bound[] = []; + + // For moving the connector label + private _selectedConnector: ConnectorElementModel | null = null; + + private _selectedConnectorLabelBounds: Bound | null = null; + + private _selectionRectTransition: null | { + w: number; + h: number; + startX: number; + startY: number; + endX: number; + endY: number; + } = null; + + private _startAutoPanning = (delta: IVec) => { + this._panViewport(delta); + this._updateSelectingState(delta); + this._stopAutoPanning(); + + this._autoPanTimer = window.setInterval(() => { + this._panViewport(delta); + this._updateSelectingState(delta); + }, 30); + }; + + private _stopAutoPanning = () => { + if (this._autoPanTimer) { + clearTimeout(this._autoPanTimer); + this._autoPanTimer = null; + } + }; + + private _toBeMoved: GfxModel[] = []; + + private _updateSelectingState = (delta: IVec = [0, 0]) => { + const { gfx } = this; + + if (gfx.keyboard.spaceKey$.peek() && this._selectionRectTransition) { + /* Move the selection if space is pressed */ + const curDraggingViewArea = this.controller.draggingViewArea$.peek(); + const { w, h, startX, startY, endX, endY } = + this._selectionRectTransition; + const { endX: lastX, endY: lastY } = curDraggingViewArea; + + const dx = lastX + delta[0] - endX + this._accumulateDelta[0]; + const dy = lastY + delta[1] - endY + this._accumulateDelta[1]; + + this.controller.draggingViewArea$.value = { + ...curDraggingViewArea, + x: Math.min(startX + dx, lastX), + y: Math.min(startY + dy, lastY), + w, + h, + startX: startX + dx, + startY: startY + dy, + }; + } else { + const curDraggingArea = this.controller.draggingViewArea$.peek(); + const newStartX = curDraggingArea.startX - delta[0]; + const newStartY = curDraggingArea.startY - delta[1]; + + this.controller.draggingViewArea$.value = { + ...curDraggingArea, + startX: newStartX, + startY: newStartY, + x: Math.min(newStartX, curDraggingArea.endX), + y: Math.min(newStartY, curDraggingArea.endY), + w: Math.abs(curDraggingArea.endX - newStartX), + h: Math.abs(curDraggingArea.endY - newStartY), + }; + } + + const { x, y, w, h } = this.controller.draggingArea$.peek(); + const bound = new Bound(x, y, w, h); + + let elements = gfx.getElementsByBound(bound).filter(el => { + if (isFrameBlock(el)) { + return el.childElements.length === 0 || bound.contains(el.elementBound); + } + if (el instanceof MindmapElementModel) { + return bound.contains(el.elementBound); + } + return true; + }); + + elements = getTopElements(elements).filter(el => !el.isLocked()); + + const set = new Set( + gfx.keyboard.shiftKey$.peek() + ? [...elements, ...gfx.selection.selectedElements] + : elements + ); + + this.edgelessSelectionManager.set({ + elements: Array.from(set).map(element => element.id), + editing: false, + }); + }; + + private _wheeling = false; + + dragType = DefaultModeDragType.None; + + enableHover = true; + + private get _edgeless(): EdgelessRootBlockComponent | null { + const block = this.std.view.getBlock(this.doc.root!.id); + + return (block as EdgelessRootBlockComponent) ?? null; + } + + private get _frameMgr() { + return this.std.get( + GfxExtensionIdentifier('frame-manager') + ) as EdgelessFrameManager; + } + + private get _supportedExts() { + return this._exts.filter(ext => + ext.supportedDragTypes.includes(this.dragType) + ); + } + + /** + * Get the end position of the dragging area in the model coordinate + */ + get dragLastPos() { + const { endX, endY } = this.controller.draggingArea$.peek(); + + return [endX, endY] as IVec; + } + + /** + * Get the start position of the dragging area in the model coordinate + */ + get dragStartPos() { + const { startX, startY } = this.controller.draggingArea$.peek(); + + return [startX, startY] as IVec; + } + + get edgelessSelectionManager() { + return this.gfx.selection; + } + + private get frameOverlay() { + return this.std.get(OverlayIdentifier('frame')) as FrameOverlay; + } + + get snapOverlay() { + return this.std.get( + OverlayIdentifier('snap-manager') + ) as EdgelessSnapManager; + } + + private _addEmptyParagraphBlock( + block: NoteBlockModel | EdgelessTextBlockModel + ) { + const blockId = this.doc.addBlock( + 'affine:paragraph', + { type: 'text' }, + block.id + ); + if (blockId) { + focusTextModel(this.std, blockId); + } + } + + private async _cloneContent() { + this._lock = true; + + if (!this._edgeless) return; + + const clipboardController = this._edgeless?.clipboardController; + const snapshot = prepareCloneData(this._toBeMoved, this.std); + + const bound = getCommonBoundWithRotation(this._toBeMoved); + const { canvasElements, blockModels } = + await clipboardController.createElementsFromClipboardData( + snapshot, + bound.center + ); + + this._toBeMoved = [...canvasElements, ...blockModels]; + this.edgelessSelectionManager.set({ + elements: this._toBeMoved.map(e => e.id), + editing: false, + }); + } + + private _determineDragType(e: PointerEventState): DefaultModeDragType { + const { x, y } = e; + // Is dragging started from current selected rect + if (this.edgelessSelectionManager.isInSelectedRect(x, y)) { + if (this.edgelessSelectionManager.selectedElements.length === 1) { + let selected = this.edgelessSelectionManager.selectedElements[0]; + // double check + const currentSelected = this._pick(x, y); + if ( + !isFrameBlock(selected) && + !(selected instanceof GroupElementModel) && + currentSelected && + currentSelected !== selected + ) { + selected = currentSelected; + this.edgelessSelectionManager.set({ + elements: [selected.id], + editing: false, + }); + } + + if ( + isCanvasElement(selected) && + ConnectorUtils.isConnectorWithLabel(selected) && + (selected as ConnectorElementModel).labelIncludesPoint( + this.gfx.viewport.toModelCoord(x, y) + ) + ) { + this._selectedConnector = selected as ConnectorElementModel; + this._selectedConnectorLabelBounds = Bound.fromXYWH( + this._selectedConnector.labelXYWH! + ); + return DefaultModeDragType.ConnectorLabelMoving; + } + } + + return this.edgelessSelectionManager.editing + ? DefaultModeDragType.NativeEditing + : DefaultModeDragType.ContentMoving; + } else { + const selected = this._pick(x, y); + if (selected) { + this.edgelessSelectionManager.set({ + elements: [selected.id], + editing: false, + }); + + if ( + isCanvasElement(selected) && + ConnectorUtils.isConnectorWithLabel(selected) && + (selected as ConnectorElementModel).labelIncludesPoint( + this.gfx.viewport.toModelCoord(x, y) + ) + ) { + this._selectedConnector = selected as ConnectorElementModel; + this._selectedConnectorLabelBounds = Bound.fromXYWH( + this._selectedConnector.labelXYWH! + ); + return DefaultModeDragType.ConnectorLabelMoving; + } + + return DefaultModeDragType.ContentMoving; + } else { + return DefaultModeDragType.Selecting; + } + } + } + + private _filterConnectedConnector() { + this._toBeMoved = this._toBeMoved.filter(ele => { + // eslint-disable-next-line sonarjs/no-collapsible-if + if ( + ele instanceof ConnectorElementModel && + ele.source?.id && + ele.target?.id + ) { + if ( + this._toBeMoved.some(e => e.id === ele.source.id) && + this._toBeMoved.some(e => e.id === ele.target.id) + ) { + return false; + } + } + return true; + }); + } + + private _isDraggable(element: GfxModel) { + return !( + element instanceof ConnectorElementModel && + !ConnectorUtils.isConnectorAndBindingsAllSelected( + element, + this._toBeMoved + ) + ); + } + + private _moveContent( + [dx, dy]: IVec, + alignBound: Bound, + shifted?: boolean, + shouldClone?: boolean + ) { + alignBound.x += dx; + alignBound.y += dy; + + const alignRst = this.snapOverlay.align(alignBound); + const delta = [dx + alignRst.dx, dy + alignRst.dy]; + + if (shifted) { + const angle = Math.abs(Math.atan2(delta[1], delta[0])); + const direction = + angle < Math.PI / 4 || angle > 3 * (Math.PI / 4) ? 'x' : 'y'; + delta[direction === 'x' ? 1 : 0] = 0; + } + + this._toBeMoved.forEach((element, index) => { + const isGraphicElement = isCanvasElement(element); + + if (isGraphicElement && !this._isDraggable(element)) return; + + let bound = this._selectedBounds[index]; + if (shouldClone) bound = bound.clone(); + + bound.x += delta[0]; + bound.y += delta[1]; + + if (isGraphicElement) { + if (!this._lock) { + this._lock = true; + this.doc.captureSync(); + } + + if (element instanceof ConnectorElementModel) { + element.moveTo(bound); + } + } + + this._scheduleUpdate(element, { + xywh: bound.serialize(), + }); + }); + + this._hoveredFrame = this._frameMgr.getFrameFromPoint( + this.dragLastPos, + this._toBeMoved.filter(ele => isFrameBlock(ele)) + ); + + this._hoveredFrame && !this._hoveredFrame.isLocked() + ? this.frameOverlay.highlight(this._hoveredFrame) + : this.frameOverlay.clear(); + } + + private _moveLabel(delta: IVec) { + const connector = this._selectedConnector; + let bounds = this._selectedConnectorLabelBounds; + if (!connector || !bounds) return; + bounds = bounds.clone(); + const center = connector.getNearestPoint( + Vec.add(bounds.center, delta) as IVec + ); + const distance = connector.getOffsetDistanceByPoint(center as IVec); + bounds.center = center; + this.gfx.updateElement(connector, { + labelXYWH: bounds.toXYWH(), + labelOffset: { + distance, + }, + }); + } + + private _pick(x: number, y: number, options?: PointTestOptions) { + const modelPos = this.gfx.viewport.toModelCoord(x, y); + + const tryGetLockedAncestor = (e: GfxModel | null) => { + if (e?.isLockedByAncestor()) { + return e.groups.findLast(group => group.isLocked()); + } + return e; + }; + + const frameByPickingTitle = last( + this.gfx + .getElementByPoint(modelPos[0], modelPos[1], { + ...options, + all: true, + }) + .filter( + el => isFrameBlock(el) && el.externalBound?.isPointInBound(modelPos) + ) + ); + + if (frameByPickingTitle) return tryGetLockedAncestor(frameByPickingTitle); + + const result = this.gfx.getElementInGroup( + modelPos[0], + modelPos[1], + options + ); + + if (result instanceof MindmapElementModel) { + const picked = this.gfx.getElementByPoint(modelPos[0], modelPos[1], { + ...((options ?? {}) as PointTestOptions), + all: true, + }); + + let pickedIdx = picked.length - 1; + + while (pickedIdx >= 0) { + const element = picked[pickedIdx]; + if (element === result) { + pickedIdx -= 1; + continue; + } + + break; + } + + return tryGetLockedAncestor(picked[pickedIdx]) ?? null; + } + + // if the frame has title, it only can be picked by clicking the title + if (isFrameBlock(result) && result.externalXYWH) { + return null; + } + + return tryGetLockedAncestor(result); + } + + private _scheduleUpdate( + element: GfxBlockModel | GfxPrimitiveElementModel, + updates: Partial<GfxBlockModel> + ) { + this._pendingUpdates.set(element, updates); + + if (this._rafId !== null) return; + + this._rafId = requestAnimationFrame(() => { + this._pendingUpdates.forEach((updates, element) => { + this.gfx.updateElement(element, updates); + }); + this._pendingUpdates.clear(); + this._rafId = null; + }); + } + + private initializeDragState( + dragType: DefaultModeDragType, + event: PointerEventState + ) { + this.dragType = dragType; + + if ( + (this._toBeMoved.length && + this._toBeMoved.every( + ele => !(ele.group instanceof MindmapElementModel) + )) || + (isSingleMindMapNode(this._toBeMoved) && + this._toBeMoved[0].id === + (this._toBeMoved[0].group as MindmapElementModel).tree.id) + ) { + const mindmap = this._toBeMoved[0].group as MindmapElementModel; + + this._alignBound = this.snapOverlay.setupAlignables(this._toBeMoved, [ + mindmap, + ...(mindmap?.childElements || []), + ]); + } + + this._clearDisposable(); + this._disposables = new DisposableGroup(); + + const ctx = { + movedElements: this._toBeMoved, + dragType, + event, + }; + + this._extHandlers = this._supportedExts.map(ext => ext.initDrag(ctx)); + this._selectedBounds = this._toBeMoved.map(element => + Bound.deserialize(element.xywh) + ); + + // If the drag type is selecting, set up the dragging area disposable group + // If the viewport updates when dragging, should update the dragging area and selection + if (this.dragType === DefaultModeDragType.Selecting) { + this._disposables.add( + this.gfx.viewport.viewportUpdated.on(() => { + if ( + this.dragType === DefaultModeDragType.Selecting && + this.controller.dragging$.peek() && + !this._autoPanTimer + ) { + this._updateSelectingState(); + } + }) + ); + return; + } + + if (this.dragType === DefaultModeDragType.ContentMoving) { + this._disposables.add( + this.gfx.viewport.viewportMoved.on(delta => { + if ( + this.dragType === DefaultModeDragType.ContentMoving && + this.controller.dragging$.peek() && + !this._autoPanTimer + ) { + if ( + this._toBeMoved.every(ele => { + return !this._isDraggable(ele); + }) + ) { + return; + } + + if (!this._wheeling) { + this._wheeling = true; + this._selectedBounds = this._toBeMoved.map(element => + Bound.deserialize(element.xywh) + ); + } + + this._alignBound = this.snapOverlay.setupAlignables( + this._toBeMoved + ); + + this._moveContent(delta, this._alignBound); + } + }) + ); + return; + } + } + + override activate(_: Record<string, unknown>): void { + if (this.gfx.selection.lastSurfaceSelections.length) { + this.gfx.selection.set(this.gfx.selection.lastSurfaceSelections); + } + } + + override click(e: PointerEventState) { + if (this.doc.readonly) return; + + const selected = this._pick(e.x, e.y, { + ignoreTransparent: true, + }); + + if (selected) { + const { selectedIds, surfaceSelections } = this.edgelessSelectionManager; + const editing = surfaceSelections[0]?.editing ?? false; + + // click active canvas text, edgeless text block and note block + if ( + selectedIds.length === 1 && + selectedIds[0] === selected.id && + editing + ) { + // edgeless text block and note block + if ( + (isNoteBlock(selected) || isEdgelessTextBlock(selected)) && + selected.children.length === 0 + ) { + this._addEmptyParagraphBlock(selected); + } + // canvas text + return; + } + + // click non-active edgeless text block and note block, and then enter editing + if ( + !selected.isLocked() && + !e.keys.shift && + selectedIds.length === 1 && + (isNoteBlock(selected) || isEdgelessTextBlock(selected)) && + ((selectedIds[0] === selected.id && !editing) || + (editing && selectedIds[0] !== selected.id)) + ) { + // issue #1809 + // If the previously selected element is a noteBlock and is in an active state, + // then the currently clicked noteBlock should also be in an active state when selected. + this.edgelessSelectionManager.set({ + elements: [selected.id], + editing: true, + }); + this._edgeless?.updateComplete + .then(() => { + // check if block has children blocks, if not, add a paragraph block and focus on it + if (selected.children.length === 0) { + this._addEmptyParagraphBlock(selected); + } else { + const block = this.std.host.view.getBlock(selected.id); + if (block) { + const rect = block + .querySelector('.affine-block-children-container')! + .getBoundingClientRect(); + + const offsetY = 8 * this.gfx.viewport.zoom; + const offsetX = 2 * this.gfx.viewport.zoom; + const x = clamp( + e.raw.clientX, + rect.left + offsetX, + rect.right - offsetX + ); + const y = clamp( + e.raw.clientY, + rect.top + offsetY, + rect.bottom - offsetY + ); + handleNativeRangeAtPoint(x, y); + } else { + handleNativeRangeAtPoint(e.raw.clientX, e.raw.clientY); + } + } + }) + .catch(console.error); + return; + } + + this.edgelessSelectionManager.set({ + // hold shift key to multi select or de-select element + elements: e.keys.shift + ? this.edgelessSelectionManager.has(selected.id) + ? selectedIds.filter(id => id !== selected.id) + : [...selectedIds, selected.id] + : [selected.id], + editing: false, + }); + } else if (!e.keys.shift) { + this.edgelessSelectionManager.clear(); + resetNativeSelection(null); + } + + this._isDoubleClickedOnMask = false; + this._supportedExts.forEach(ext => ext.click?.(e)); + } + + override deactivate() { + this._stopAutoPanning(); + this._clearDisposable(); + this._accumulateDelta = [0, 0]; + noop(); + } + + override doubleClick(e: PointerEventState) { + if (this.doc.readonly) { + const viewport = this.gfx.viewport; + if (viewport.zoom === 1) { + // Fit to Screen + fitToScreen( + [...this.gfx.layer.blocks, ...this.gfx.layer.canvasElements], + this.gfx.viewport + ); + } else { + // Zoom to 100% and Center + const [x, y] = viewport.toModelCoord(e.x, e.y); + viewport.setViewport(1, [x, y], true); + } + return; + } + + const selected = this._pick(e.x, e.y, { + hitThreshold: 10, + }); + if (!this._edgeless) { + return; + } + + if (!selected) { + const textFlag = this.doc.awarenessStore.getFlag('enable_edgeless_text'); + + if (textFlag) { + const [x, y] = this.gfx.viewport.toModelCoord(e.x, e.y); + this.std.command.exec('insertEdgelessText', { x, y }); + } else { + addText(this._edgeless, e); + } + this.std.getOptional(TelemetryProvider)?.track('CanvasElementAdded', { + control: 'canvas:dbclick', + page: 'whiteboard editor', + module: 'toolbar', + segment: 'toolbar', + type: 'text', + }); + return; + } else { + if (selected.isLocked()) return; + const [x, y] = this.gfx.viewport.toModelCoord(e.x, e.y); + if (selected instanceof TextElementModel) { + mountTextElementEditor(selected, this._edgeless, { + x, + y, + }); + return; + } + if (selected instanceof ShapeElementModel) { + mountShapeTextEditor(selected, this._edgeless); + return; + } + if (selected instanceof ConnectorElementModel) { + mountConnectorLabelEditor(selected, this._edgeless, [x, y]); + return; + } + if (isFrameBlock(selected)) { + mountFrameTitleEditor(selected, this._edgeless); + return; + } + if (selected instanceof GroupElementModel) { + mountGroupTitleEditor(selected, this._edgeless); + return; + } + } + + this._supportedExts.forEach(ext => ext.click?.(e)); + + if ( + e.raw.target && + e.raw.target instanceof HTMLElement && + e.raw.target.classList.contains('affine-note-mask') + ) { + this.click(e); + this._isDoubleClickedOnMask = true; + return; + } + } + + override dragEnd(e: PointerEventState) { + this._extHandlers.forEach(handler => handler.dragEnd?.(e)); + + this._toBeMoved.forEach(el => { + this.doc.transact(() => { + el.pop('xywh'); + }); + + if (el instanceof ConnectorElementModel) { + el.pop('labelXYWH'); + } + }); + + { + const frameManager = this._frameMgr; + const toBeMovedTopElements = getTopElements( + this._toBeMoved.map(el => + el.group instanceof MindmapElementModel ? el.group : el + ) + ); + if (this._hoveredFrame) { + frameManager.addElementsToFrame( + this._hoveredFrame, + toBeMovedTopElements + ); + } else { + // only apply to root nodes of trees + toBeMovedTopElements.forEach(element => + frameManager.removeFromParentFrame(element) + ); + } + } + + if (this._lock) { + this.doc.captureSync(); + this._lock = false; + } + + if (this.edgelessSelectionManager.editing) return; + + this._selectedBounds = []; + this.snapOverlay.cleanupAlignables(); + this.frameOverlay.clear(); + this._toBeMoved = []; + this._selectedConnector = null; + this._selectedConnectorLabelBounds = null; + this._clearSelectingState(); + this.dragType = DefaultModeDragType.None; + } + + override dragMove(e: PointerEventState) { + const { viewport } = this.gfx; + switch (this.dragType) { + case DefaultModeDragType.Selecting: { + // Record the last drag pointer position for auto panning and view port updating + + this._updateSelectingState(); + const moveDelta = calPanDelta(viewport, e); + if (moveDelta) { + this._startAutoPanning(moveDelta); + } else { + this._stopAutoPanning(); + } + break; + } + case DefaultModeDragType.AltCloning: + case DefaultModeDragType.ContentMoving: { + if ( + this._toBeMoved.length && + this._toBeMoved.every(ele => { + return !this._isDraggable(ele); + }) + ) { + return; + } + + if (this._wheeling) { + this._wheeling = false; + } + + const dx = this.dragLastPos[0] - this.dragStartPos[0]; + const dy = this.dragLastPos[1] - this.dragStartPos[1]; + const alignBound = this._alignBound.clone(); + const shifted = e.keys.shift || this.gfx.keyboard.shiftKey$.peek(); + + this._moveContent([dx, dy], alignBound, shifted, true); + this._extHandlers.forEach(handler => handler.dragMove?.(e)); + break; + } + case DefaultModeDragType.ConnectorLabelMoving: { + const dx = this.dragLastPos[0] - this.dragStartPos[0]; + const dy = this.dragLastPos[1] - this.dragStartPos[1]; + this._moveLabel([dx, dy]); + break; + } + case DefaultModeDragType.NativeEditing: { + // TODO reset if drag out of note + break; + } + } + } + + override async dragStart(e: PointerEventState) { + if (this.edgelessSelectionManager.editing) return; + // Determine the drag type based on the current state and event + let dragType = this._determineDragType(e); + + const elements = this.edgelessSelectionManager.selectedElements; + if (elements.some(e => e.isLocked())) return; + + const toBeMoved = new Set(elements); + + elements.forEach(element => { + if (isGfxGroupCompatibleModel(element)) { + element.descendantElements.forEach(ele => { + toBeMoved.add(ele); + }); + } + }); + + this._toBeMoved = Array.from(toBeMoved); + + // If alt key is pressed and content is moving, clone the content + if (e.keys.alt && dragType === DefaultModeDragType.ContentMoving) { + dragType = DefaultModeDragType.AltCloning; + await this._cloneContent(); + } + this._filterConnectedConnector(); + + // Connector needs to be updated first + this._toBeMoved.sort((a, _) => + a instanceof ConnectorElementModel ? -1 : 1 + ); + + // Set up drag state + this.initializeDragState(dragType, e); + + // stash the state + this._toBeMoved.forEach(ele => { + ele.stash('xywh'); + + if (ele instanceof ConnectorElementModel) { + ele.stash('labelXYWH'); + } + }); + + this._extHandlers.forEach(handler => handler.dragStart?.(e)); + } + + override mounted() { + this.disposable.add( + effect(() => { + const pressed = this.gfx.keyboard.spaceKey$.value; + + if (pressed) { + const currentDraggingArea = this.controller.draggingViewArea$.peek(); + + this._selectionRectTransition = { + w: currentDraggingArea.w, + h: currentDraggingArea.h, + startX: currentDraggingArea.startX, + startY: currentDraggingArea.startY, + endX: currentDraggingArea.endX, + endY: currentDraggingArea.endY, + }; + } else { + this._selectionRectTransition = null; + } + }) + ); + + this._exts = [MindMapExt, CanvasElementEventExt].map( + constructor => new constructor(this) + ); + this._exts.forEach(ext => ext.mounted()); + } + + override pointerDown(e: PointerEventState): void { + this._supportedExts.forEach(ext => ext.pointerDown(e)); + } + + override pointerMove(e: PointerEventState) { + const hovered = this._pick(e.x, e.y, { + hitThreshold: 10, + }); + + if ( + isFrameBlock(hovered) && + hovered.externalBound?.isPointInBound( + this.gfx.viewport.toModelCoord(e.x, e.y) + ) + ) { + this.frameOverlay.highlight(hovered); + } else { + this.frameOverlay.clear(); + } + + this._supportedExts.forEach(ext => ext.pointerMove(e)); + } + + override pointerUp(e: PointerEventState) { + this._supportedExts.forEach(ext => ext.pointerUp(e)); + } + + override tripleClick() { + if (this._isDoubleClickedOnMask) return; + } + + override unmounted(): void { + this._exts.forEach(ext => ext.unmounted()); + } +} + +declare module '@blocksuite/block-std/gfx' { + interface GfxToolsMap { + default: DefaultTool; + } +} diff --git a/blocksuite/blocks/src/root-block/edgeless/gfx-tool/empty-tool.ts b/blocksuite/blocks/src/root-block/edgeless/gfx-tool/empty-tool.ts new file mode 100644 index 0000000000..6fb6d2c874 --- /dev/null +++ b/blocksuite/blocks/src/root-block/edgeless/gfx-tool/empty-tool.ts @@ -0,0 +1,14 @@ +import { BaseTool } from '@blocksuite/block-std/gfx'; + +/** + * Empty tool that does nothing. + */ +export class EmptyTool extends BaseTool { + static override toolName: string = 'empty'; +} + +declare module '@blocksuite/block-std/gfx' { + interface GfxToolsMap { + empty: EmptyTool; + } +} diff --git a/blocksuite/blocks/src/root-block/edgeless/gfx-tool/eraser-tool.ts b/blocksuite/blocks/src/root-block/edgeless/gfx-tool/eraser-tool.ts new file mode 100644 index 0000000000..82edd249b7 --- /dev/null +++ b/blocksuite/blocks/src/root-block/edgeless/gfx-tool/eraser-tool.ts @@ -0,0 +1,159 @@ +import { + CommonUtils, + Overlay, + type SurfaceBlockComponent, +} from '@blocksuite/affine-block-surface'; +import type { PointerEventState } from '@blocksuite/block-std'; +import { BaseTool } from '@blocksuite/block-std/gfx'; +import { Bound, type IVec } from '@blocksuite/global/utils'; + +import { deleteElementsV2 } from '../utils/crud.js'; +import { isTopLevelBlock } from '../utils/query.js'; + +const { getSvgPathFromStroke, getStroke, linePolygonIntersects } = CommonUtils; + +class EraserOverlay extends Overlay { + d = ''; + + override render(ctx: CanvasRenderingContext2D): void { + ctx.globalAlpha = 0.33; + const path = new Path2D(this.d); + ctx.fillStyle = '#aaa'; + ctx.fill(path); + } +} + +export class EraserTool extends BaseTool { + static override toolName = 'eraser'; + + private _erasable = new Set<BlockSuite.EdgelessModel>(); + + private _eraserPoints: IVec[] = []; + + private _eraseTargets = new Set<BlockSuite.EdgelessModel>(); + + private _loop = () => { + const now = Date.now(); + const elapsed = now - this._timestamp; + + let didUpdate = false; + + if (this._prevEraserPoint !== this._prevPoint) { + didUpdate = true; + this._eraserPoints.push(this._prevPoint); + this._prevEraserPoint = this._prevPoint; + } + if (elapsed > 32 && this._eraserPoints.length > 1) { + didUpdate = true; + this._eraserPoints.splice(0, Math.ceil(this._eraserPoints.length * 0.1)); + this._timestamp = now; + } + if (didUpdate) { + const zoom = this.gfx.viewport.zoom; + const d = getSvgPathFromStroke( + getStroke(this._eraserPoints, { + size: 16 / zoom, + start: { taper: true }, + }) + ); + this._overlay.d = d; + (this.gfx.surfaceComponent as SurfaceBlockComponent)?.refresh(); + } + this._timer = requestAnimationFrame(this._loop); + }; + + private _overlay = new EraserOverlay(this.gfx); + + private _prevEraserPoint: IVec = [0, 0]; + + private _prevPoint: IVec = [0, 0]; + + private _timer = 0; + + private _timestamp = 0; + + private _reset() { + cancelAnimationFrame(this._timer); + + if (!this.gfx.surface) { + return; + } + + ( + this.gfx.surfaceComponent as SurfaceBlockComponent + )?.renderer.removeOverlay(this._overlay); + this._erasable.clear(); + this._eraseTargets.clear(); + } + + override activate(): void { + this._eraseTargets.forEach(erasable => { + if (isTopLevelBlock(erasable)) { + const ele = this.std.view.getBlock(erasable.id); + ele && ((ele as HTMLElement).style.opacity = '1'); + } else { + erasable.opacity = 1; + } + }); + this._reset(); + } + + override dragEnd(_: PointerEventState): void { + deleteElementsV2(this.gfx, Array.from(this._eraseTargets)); + this._reset(); + this.doc.captureSync(); + } + + override dragMove(e: PointerEventState): void { + const currentPoint = this.gfx.viewport.toModelCoord(e.point.x, e.point.y); + this._erasable.forEach(erasable => { + if (erasable.isLocked()) return; + if (this._eraseTargets.has(erasable)) return; + if (isTopLevelBlock(erasable)) { + const bound = Bound.deserialize(erasable.xywh); + if ( + linePolygonIntersects(this._prevPoint, currentPoint, bound.points) + ) { + this._eraseTargets.add(erasable); + const ele = this.std.view.getBlock(erasable.id); + ele && ((ele as HTMLElement).style.opacity = '0.3'); + } + } else { + if ( + erasable.getLineIntersections( + this._prevPoint as IVec, + currentPoint as IVec + ) + ) { + this._eraseTargets.add(erasable); + erasable.opacity = 0.3; + } + } + }); + + this._prevPoint = currentPoint; + } + + override dragStart(e: PointerEventState): void { + this.doc.captureSync(); + + const { point } = e; + const [x, y] = this.gfx.viewport.toModelCoord(point.x, point.y); + this._eraserPoints = [[x, y]]; + this._prevPoint = [x, y]; + this._erasable = new Set([ + ...this.gfx.layer.canvasElements, + ...this.gfx.layer.blocks, + ]); + this._loop(); + (this.gfx.surfaceComponent as SurfaceBlockComponent)?.renderer.addOverlay( + this._overlay + ); + } +} + +declare module '@blocksuite/block-std/gfx' { + interface GfxToolsMap { + eraser: EraserTool; + } +} diff --git a/blocksuite/blocks/src/root-block/edgeless/gfx-tool/frame-navigator-tool.ts b/blocksuite/blocks/src/root-block/edgeless/gfx-tool/frame-navigator-tool.ts new file mode 100644 index 0000000000..705a9f18a5 --- /dev/null +++ b/blocksuite/blocks/src/root-block/edgeless/gfx-tool/frame-navigator-tool.ts @@ -0,0 +1,21 @@ +import { BaseTool } from '@blocksuite/block-std/gfx'; + +import type { NavigatorMode } from '../../../_common/edgeless/frame/consts.js'; + +type PresentToolOption = { + mode?: NavigatorMode; +}; + +export class PresentTool extends BaseTool<PresentToolOption> { + static override toolName: string = 'frameNavigator'; +} + +declare module '@blocksuite/block-std/gfx' { + interface GfxToolsMap { + frameNavigator: PresentTool; + } + + interface GfxToolsOption { + frameNavigator: PresentToolOption; + } +} diff --git a/blocksuite/blocks/src/root-block/edgeless/gfx-tool/frame-tool.ts b/blocksuite/blocks/src/root-block/edgeless/gfx-tool/frame-tool.ts new file mode 100644 index 0000000000..a16a54a91e --- /dev/null +++ b/blocksuite/blocks/src/root-block/edgeless/gfx-tool/frame-tool.ts @@ -0,0 +1,115 @@ +import { OverlayIdentifier } from '@blocksuite/affine-block-surface'; +import type { FrameBlockModel } from '@blocksuite/affine-model'; +import { TelemetryProvider } from '@blocksuite/affine-shared/services'; +import type { PointerEventState } from '@blocksuite/block-std'; +import { + BaseTool, + getTopElements, + GfxExtensionIdentifier, +} from '@blocksuite/block-std/gfx'; +import type { IPoint, IVec } from '@blocksuite/global/utils'; +import { Bound, Vec } from '@blocksuite/global/utils'; +import { DocCollection, Text } from '@blocksuite/store'; + +import type { EdgelessFrameManager, FrameOverlay } from '../frame-manager.js'; + +export class FrameTool extends BaseTool { + static override toolName = 'frame'; + + private _frame: FrameBlockModel | null = null; + + private _startPoint: IVec | null = null; + + get frameManager() { + return this.std.get( + GfxExtensionIdentifier('frame-manager') + ) as EdgelessFrameManager; + } + + get frameOverlay() { + return this.std.get(OverlayIdentifier('frame')) as FrameOverlay; + } + + private _toModelCoord(p: IPoint): IVec { + return this.gfx.viewport.toModelCoord(p.x, p.y); + } + + override dragEnd(): void { + if (this._frame) { + const frame = this._frame; + this.doc.transact(() => { + frame.pop('xywh'); + }); + this.gfx.tool.setTool('default'); + this.gfx.selection.set({ + elements: [frame.id], + editing: false, + }); + + this.frameManager.addElementsToFrame( + frame, + getTopElements(this.frameManager.getElementsInFrameBound(frame)) + ); + + this.doc.captureSync(); + } + + this._frame = null; + this._startPoint = null; + this.frameOverlay.clear(); + } + + override dragMove(e: PointerEventState): void { + if (!this._startPoint) return; + + const currentPoint = this._toModelCoord(e.point); + if (Vec.dist(this._startPoint, currentPoint) < 8 && !this._frame) return; + + if (!this._frame) { + const frames = this.gfx.layer.blocks.filter( + block => block.flavour === 'affine:frame' + ) as FrameBlockModel[]; + const id = this.doc.addBlock( + 'affine:frame', + { + title: new Text( + new DocCollection.Y.Text(`Frame ${frames.length + 1}`) + ), + xywh: Bound.fromPoints([this._startPoint, currentPoint]).serialize(), + index: this.gfx.layer.generateIndex(true), + presentationIndex: this.frameManager.generatePresentationIndex(), + }, + this.gfx.surface + ); + + this.std.getOptional(TelemetryProvider)?.track('CanvasElementAdded', { + control: 'canvas:draw', + page: 'whiteboard editor', + module: 'toolbar', + segment: 'toolbar', + type: 'frame', + }); + this._frame = this.gfx.getElementById(id) as FrameBlockModel; + this._frame.stash('xywh'); + return; + } + + this.gfx.doc.updateBlock(this._frame, { + xywh: Bound.fromPoints([this._startPoint, currentPoint]).serialize(), + }); + + this.frameOverlay.highlight(this._frame, true); + } + + override dragStart(e: PointerEventState): void { + this.doc.captureSync(); + const { point } = e; + this._startPoint = this._toModelCoord(point); + } +} + +declare module '@blocksuite/block-std/gfx' { + interface GfxToolsMap { + frame: FrameTool; + } +} diff --git a/blocksuite/blocks/src/root-block/edgeless/gfx-tool/index.ts b/blocksuite/blocks/src/root-block/edgeless/gfx-tool/index.ts new file mode 100644 index 0000000000..7704a86f9e --- /dev/null +++ b/blocksuite/blocks/src/root-block/edgeless/gfx-tool/index.ts @@ -0,0 +1,14 @@ +export { BrushTool } from './brush-tool.js'; +export { ConnectorTool, type ConnectorToolOptions } from './connector-tool.js'; +export { CopilotTool } from './copilot-tool.js'; +export { DefaultTool } from './default-tool.js'; +export { EmptyTool } from './empty-tool.js'; +export { EraserTool } from './eraser-tool.js'; +export { PresentTool } from './frame-navigator-tool.js'; +export { FrameTool } from './frame-tool.js'; +export { LassoTool, type LassoToolOption } from './lasso-tool.js'; +export { NoteTool, type NoteToolOption } from './note-tool.js'; +export { PanTool, type PanToolOption } from './pan-tool.js'; +export { ShapeTool, type ShapeToolOption } from './shape-tool.js'; +export { TemplateTool } from './template-tool.js'; +export { TextTool } from './text-tool.js'; diff --git a/blocksuite/blocks/src/root-block/edgeless/gfx-tool/lasso-tool.ts b/blocksuite/blocks/src/root-block/edgeless/gfx-tool/lasso-tool.ts new file mode 100644 index 0000000000..084b304893 --- /dev/null +++ b/blocksuite/blocks/src/root-block/edgeless/gfx-tool/lasso-tool.ts @@ -0,0 +1,327 @@ +import { + CommonUtils, + Overlay, + type SurfaceBlockComponent, +} from '@blocksuite/affine-block-surface'; +import { ThemeProvider } from '@blocksuite/affine-shared/services'; +import type { PointerEventState } from '@blocksuite/block-std'; +import { BaseTool } from '@blocksuite/block-std/gfx'; +import type { IPoint, IVec } from '@blocksuite/global/utils'; +import { + Bound, + getBoundFromPoints, + getPolygonPathFromPoints, + linePolygonIntersects, + pointInPolygon, + rotatePoints, + Vec, +} from '@blocksuite/global/utils'; + +import { LassoMode } from '../../../_common/types.js'; + +class LassoOverlay extends Overlay { + d = ''; + + startPoint: IVec | null = null; + + render(ctx: CanvasRenderingContext2D): void { + const path = new Path2D(this.d); + const zoom = this._renderer?.viewport.zoom ?? 1.0; + ctx.save(); + const primaryColor = this.gfx.std + .get(ThemeProvider) + .getCssVariableColor('--affine-primary-color'); + const strokeColor = this.gfx.std + .get(ThemeProvider) + .getCssVariableColor('--affine-secondary-color'); + if (this.startPoint) { + const [x, y] = this.startPoint; + ctx.beginPath(); + ctx.arc(x, y, 2 / zoom, 0, Math.PI * 2); + ctx.fillStyle = primaryColor; + ctx.fill(); + } + + ctx.strokeStyle = strokeColor; + ctx.fillStyle = 'rgba(255, 255, 255, 0.1)'; + ctx.lineWidth = 2 / zoom; + ctx.lineJoin = 'round'; + ctx.lineCap = 'round'; + ctx.setLineDash([2, 5]); + ctx.fill(path); + ctx.stroke(path); + ctx.restore(); + } +} + +export type LassoToolOption = { + mode: LassoMode; +}; +export class LassoTool extends BaseTool<LassoToolOption> { + static override toolName: string = 'lasso'; + + private _currentSelectionState = new Set<string>(); + + private _isSelecting = false; + + private _lassoPoints: IVec[] = []; + + private _lastPoint: IVec = [0, 0]; + + private _loop = () => { + const path = + this.activatedOption.mode === LassoMode.FreeHand + ? CommonUtils.getSvgPathFromStroke(this._lassoPoints) + : getPolygonPathFromPoints(this._lassoPoints); + + this._overlay.d = path; + (this.gfx.surfaceComponent as SurfaceBlockComponent)?.refresh?.(); + this._raf = requestAnimationFrame(this._loop); + }; + + private _overlay = new LassoOverlay(this.gfx); + + private _raf = 0; + + get isSelecting() { + return this._isSelecting; + } + + get selection() { + return this.gfx.selection; + } + + get surfaceComponent() { + return this.gfx.surfaceComponent as SurfaceBlockComponent; + } + + private _clearLastSelection() { + if (this.selection.empty) { + this.selection.clearLast(); + } + } + + private _getElementsInsideLasso() { + const lassoBounds = getBoundFromPoints(this._lassoPoints); + return this.gfx + .getElementsByBound(lassoBounds) + .filter(e => + this.isInsideLassoSelection(Bound.deserialize(e.xywh), e.rotate) + ); + } + + private _getSelectionMode(ev: PointerEventState): 'add' | 'sub' | 'set' { + const shiftKey = ev.keys.shift ?? this.gfx.keyboard.shiftKey$.peek(); + const altKey = ev.keys.alt ?? false; + if (shiftKey) return 'add'; + else if (altKey) return 'sub'; + else { + return 'set'; + } + } + + private _reset() { + cancelAnimationFrame(this._raf); + ( + this.gfx.surfaceComponent as SurfaceBlockComponent + )?.renderer.removeOverlay(this._overlay); + this._overlay.d = ''; + this._overlay.startPoint = null; + + const elements = this._getElementsInsideLasso(); + + this._currentSelectionState = new Set([ + ...Array.from(this._currentSelectionState), + ...elements.map(el => el.id), + ]); + + this._lassoPoints = []; + this._isSelecting = false; + } + + private _setSelectionState(elements: string[], editing: boolean) { + this.selection.set({ + elements, + editing, + }); + } + + private _updateSelection(e: PointerEventState) { + // elements inside the lasso selection + const elements = this._getElementsInsideLasso() + .filter(el => !el.isLocked()) + .map(el => el.id); + + // current selections + const selection = this.selection.selectedElements.map(el => el.id); + + const selectionMode = this._getSelectionMode(e); + let set!: Set<string>; + switch (selectionMode) { + case 'add': + set = new Set([ + ...elements, + ...selection.filter(elId => this._currentSelectionState.has(elId)), + ]); + break; + case 'sub': { + const toRemove = new Set(elements); + set = new Set( + Array.from(this._currentSelectionState).filter( + el => !toRemove.has(el) + ) + ); + break; + } + case 'set': + set = new Set(elements); + break; + } + + this._setSelectionState(Array.from(set), false); + } + + private isInsideLassoSelection(bound: Bound, rotate: number): boolean { + const { points, center } = bound; + + const firstPoint = this._lassoPoints[0]; + const lassoPoints = this._lassoPoints.concat( + firstPoint ? [firstPoint] : [] + ); + + const elPoly = rotatePoints(points, center, rotate); + const lassoLen = lassoPoints.length; + return ( + elPoly.some(point => pointInPolygon(point, lassoPoints)) || + lassoPoints.some((point, i, points) => { + return linePolygonIntersects(point, points[(i + 1) % lassoLen], elPoly); + }) + ); + } + + private toModelCoord(p: IPoint): IVec { + return this.gfx.viewport.toModelCoord(p.x, p.y); + } + + abort() { + this._reset(); + } + + override activate(): void { + this._currentSelectionState = new Set( + this.selection.selectedElements.map(el => el.id) + ); + this._reset(); + } + + override deactivate() { + this._clearLastSelection(); + } + + override dragEnd(e: PointerEventState): void { + if (this.activatedOption.mode !== LassoMode.FreeHand) return; + + this._updateSelection(e); + + this._reset(); + } + + override dragMove(e: PointerEventState): void { + if (this.activatedOption.mode !== LassoMode.FreeHand) return; + + const { point } = e; + const [x, y] = this.toModelCoord(point); + this._lassoPoints.push([x, y]); + + this._updateSelection(e); + } + + // For Freehand Mode = + override dragStart(e: PointerEventState): void { + if (this.activatedOption.mode !== LassoMode.FreeHand) return; + const { alt, shift } = e.keys; + + if (!shift && !alt) { + this._currentSelectionState.clear(); + this.selection.clear(); + } + + this._currentSelectionState = new Set( + this.selection.selectedElements.map(el => el.id) + ); + + this._isSelecting = true; + + const { point } = e; + const [x, y] = this.toModelCoord(point); + this._lassoPoints = [[x, y]]; + this._raf = requestAnimationFrame(this._loop); + this._overlay.startPoint = this._lassoPoints[0]; + this.surfaceComponent.renderer.addOverlay(this._overlay); + } + + override pointerDown(e: PointerEventState): void { + const { mode } = this.activatedOption; + if (mode !== LassoMode.Polygonal) return; + + const { alt, shift } = e.keys; + if (!shift && !alt) { + this._currentSelectionState.clear(); + this.selection.clear(); + } + + this._isSelecting = true; + + const { point } = e; + const [x, y] = this.toModelCoord(point); + if (this._lassoPoints.length < 2) { + this._currentSelectionState = new Set( + this.selection.selectedElements.map(el => el.id) + ); + + const a: IVec = [x, y]; + const b: IVec = [x, y]; + this._lassoPoints = [a, b]; + this._lastPoint = b; + this._overlay.startPoint = a; + this._raf = requestAnimationFrame(this._loop); + this.surfaceComponent.renderer.addOverlay(this._overlay); + } else { + const firstPoint = this._lassoPoints[0]; + const lastPoint = this._lastPoint; + const dx = lastPoint[0] - firstPoint[0]; + const dy = lastPoint[1] - firstPoint[1]; + if (Vec.len2([dx, dy]) < 20 ** 2) { + this._updateSelection(e); + + return this._reset(); + } + + this._lastPoint = [x, y]; + this._lassoPoints.push(this._lastPoint); + } + } + + override pointerMove(e: PointerEventState): void { + if (this.activatedOption.mode !== LassoMode.Polygonal || !this._isSelecting) + return; + + const lastPoint = this._lastPoint; + const [x, y] = this.toModelCoord(e.point); + if (lastPoint) { + lastPoint[0] = x; + lastPoint[1] = y; + } + this._updateSelection(e); + } +} + +declare module '@blocksuite/block-std/gfx' { + interface GfxToolsMap { + lasso: LassoTool; + } + + interface GfxToolsOption { + lasso: LassoToolOption; + } +} diff --git a/blocksuite/blocks/src/root-block/edgeless/gfx-tool/note-tool.ts b/blocksuite/blocks/src/root-block/edgeless/gfx-tool/note-tool.ts new file mode 100644 index 0000000000..96f462a3ca --- /dev/null +++ b/blocksuite/blocks/src/root-block/edgeless/gfx-tool/note-tool.ts @@ -0,0 +1,212 @@ +import type { SurfaceBlockComponent } from '@blocksuite/affine-block-surface'; +import { + DEFAULT_NOTE_HEIGHT, + DEFAULT_NOTE_WIDTH, +} from '@blocksuite/affine-model'; +import { EditPropsStore } from '@blocksuite/affine-shared/services'; +import type { PointerEventState } from '@blocksuite/block-std'; +import { BaseTool } from '@blocksuite/block-std/gfx'; +import { Point } from '@blocksuite/global/utils'; +import { effect } from '@preact/signals-core'; + +import { + hasClassNameInList, + type NoteChildrenFlavour, +} from '../../../_common/utils/index.js'; +import { addNote } from '../utils/common.js'; +import { EXCLUDING_MOUSE_OUT_CLASS_LIST } from '../utils/consts.js'; +import { DraggingNoteOverlay, NoteOverlay } from '../utils/tool-overlay.js'; + +export type NoteToolOption = { + childFlavour: NoteChildrenFlavour; + childType: string | null; + tip: string; +}; + +export class NoteTool extends BaseTool<NoteToolOption> { + static override toolName = 'affine:note'; + + private _draggingNoteOverlay: DraggingNoteOverlay | null = null; + + private _noteOverlay: NoteOverlay | null = null; + + // Ensure clear overlay before adding a new note + private _clearOverlay() { + this._noteOverlay = this._disposeOverlay(this._noteOverlay); + this._draggingNoteOverlay = this._disposeOverlay(this._draggingNoteOverlay); + (this.gfx.surfaceComponent as SurfaceBlockComponent).refresh(); + } + + private _disposeOverlay(overlay: NoteOverlay | null) { + if (!overlay) return null; + + overlay.dispose(); + ( + this.gfx.surfaceComponent as SurfaceBlockComponent + )?.renderer.removeOverlay(overlay); + return null; + } + + // Should hide overlay when mouse is out of viewport or on menu and toolbar + private _hideOverlay() { + if (!this._noteOverlay) return; + + this._noteOverlay.globalAlpha = 0; + (this.gfx.surfaceComponent as SurfaceBlockComponent)?.refresh(); + } + + private _resize(shift = false) { + const { _draggingNoteOverlay } = this; + if (!_draggingNoteOverlay) return; + + const draggingArea = this.controller.draggingArea$.peek(); + const { startX, startY } = draggingArea; + let { endX, endY } = this.controller.draggingArea$.peek(); + + if (shift) { + const w = Math.abs(endX - startX); + const h = Math.abs(endY - startY); + const m = Math.max(w, h); + endX = startX + (endX > startX ? m : -m); + endY = startY + (endY > startY ? m : -m); + } + + _draggingNoteOverlay.slots.draggingNoteUpdated.emit({ + xywh: [ + Math.min(startX, endX), + Math.min(startY, endY), + Math.abs(startX - endX), + Math.abs(startY - endY), + ], + }); + } + + private _updateOverlayPosition(x: number, y: number) { + if (!this._noteOverlay) return; + this._noteOverlay.x = x; + this._noteOverlay.y = y; + (this.gfx.surfaceComponent as SurfaceBlockComponent).refresh(); + } + + override activate() { + const attributes = + this.std.get(EditPropsStore).lastProps$.value['affine:note']; + const background = attributes.background; + this._noteOverlay = new NoteOverlay(this.gfx, background); + this._noteOverlay.text = this.activatedOption.tip; + (this.gfx.surfaceComponent as SurfaceBlockComponent).renderer.addOverlay( + this._noteOverlay + ); + } + + override click(e: PointerEventState): void { + this._clearOverlay(); + + const { childFlavour, childType } = this.activatedOption; + const options = { + childFlavour, + childType, + collapse: false, + }; + const point = new Point(e.point.x, e.point.y); + addNote(this.std, point, options); + } + + override deactivate() { + this._clearOverlay(); + } + + override dragEnd() { + if (!this._draggingNoteOverlay) return; + + const { x, y, width, height } = this._draggingNoteOverlay; + + this._disposeOverlay(this._draggingNoteOverlay); + + const { childFlavour, childType } = this.activatedOption; + + const options = { + childFlavour, + childType, + collapse: true, + }; + + const [viewX, viewY] = this.gfx.viewport.toViewCoord(x, y); + + const point = new Point(viewX, viewY); + + this.doc.captureSync(); + + addNote( + this.std, + point, + options, + Math.max(width, DEFAULT_NOTE_WIDTH), + Math.max(height, DEFAULT_NOTE_HEIGHT) + ); + } + + override dragMove(e: PointerEventState) { + if (!this._draggingNoteOverlay) return; + + this._resize(e.keys.shift || this.gfx.keyboard.shiftKey$.peek()); + } + + override dragStart() { + this._clearOverlay(); + + const attributes = + this.std.get(EditPropsStore).lastProps$.value['affine:note']; + const background = attributes.background; + this._draggingNoteOverlay = new DraggingNoteOverlay(this.gfx, background); + (this.gfx.surfaceComponent as SurfaceBlockComponent).renderer.addOverlay( + this._draggingNoteOverlay + ); + } + + override mounted() { + this.disposable.add( + effect(() => { + const shiftPressed = this.gfx.keyboard.shiftKey$.value; + + if (!this._draggingNoteOverlay) { + return; + } + + this._resize(shiftPressed); + }) + ); + } + + override pointerMove(e: PointerEventState) { + if (!this._noteOverlay) return; + + // if mouse is in viewport and move, update overlay pointion and show overlay + if (this._noteOverlay.globalAlpha === 0) this._noteOverlay.globalAlpha = 1; + const [x, y] = this.gfx.viewport.toModelCoord(e.x, e.y); + this._updateOverlayPosition(x, y); + } + + override pointerOut(e: PointerEventState) { + // should not hide the overlay when pointer on the area of other notes + if ( + e.raw.relatedTarget && + hasClassNameInList( + e.raw.relatedTarget as Element, + EXCLUDING_MOUSE_OUT_CLASS_LIST + ) + ) + return; + this._hideOverlay(); + } +} + +declare module '@blocksuite/block-std/gfx' { + interface GfxToolsMap { + 'affine:note': NoteTool; + } + + interface GfxToolsOption { + 'affine:note': NoteToolOption; + } +} diff --git a/blocksuite/blocks/src/root-block/edgeless/gfx-tool/pan-tool.ts b/blocksuite/blocks/src/root-block/edgeless/gfx-tool/pan-tool.ts new file mode 100644 index 0000000000..c9c99f84ab --- /dev/null +++ b/blocksuite/blocks/src/root-block/edgeless/gfx-tool/pan-tool.ts @@ -0,0 +1,85 @@ +import { on } from '@blocksuite/affine-shared/utils'; +import type { PointerEventState } from '@blocksuite/block-std'; +import { BaseTool, MouseButton } from '@blocksuite/block-std/gfx'; +import { Signal } from '@preact/signals-core'; + +export type PanToolOption = { + panning: boolean; +}; + +export class PanTool extends BaseTool<PanToolOption> { + static override toolName = 'pan'; + + private _lastPoint: [number, number] | null = null; + + readonly panning$ = new Signal<boolean>(false); + + override get allowDragWithRightButton(): boolean { + return true; + } + + override dragEnd(_: PointerEventState): void { + this._lastPoint = null; + this.panning$.value = false; + } + + override dragMove(e: PointerEventState): void { + if (!this._lastPoint) return; + + const { viewport } = this.gfx; + const { zoom } = viewport; + + const [lastX, lastY] = this._lastPoint; + const deltaX = lastX - e.x; + const deltaY = lastY - e.y; + + this._lastPoint = [e.x, e.y]; + + viewport.applyDeltaCenter(deltaX / zoom, deltaY / zoom); + } + + override dragStart(e: PointerEventState): void { + this._lastPoint = [e.x, e.y]; + this.panning$.value = true; + } + + override mounted(): void { + this.addHook('pointerDown', evt => { + const shouldPanWithMiddle = evt.raw.button === MouseButton.MIDDLE; + + if (!shouldPanWithMiddle) { + return; + } + + evt.raw.preventDefault(); + + const currentTool = this.controller.currentToolOption$.peek(); + const restoreToPrevious = () => { + this.controller.setTool(currentTool); + }; + + this.controller.setTool('pan', { + panning: true, + }); + + const dispose = on(document, 'pointerup', evt => { + if (evt.button === MouseButton.MIDDLE) { + restoreToPrevious(); + dispose(); + } + }); + + return false; + }); + } +} + +declare module '@blocksuite/block-std/gfx' { + interface GfxToolsMap { + pan: PanTool; + } + + interface GfxToolsOption { + pan: PanToolOption; + } +} diff --git a/blocksuite/blocks/src/root-block/edgeless/gfx-tool/shape-tool.ts b/blocksuite/blocks/src/root-block/edgeless/gfx-tool/shape-tool.ts new file mode 100644 index 0000000000..625b0843d8 --- /dev/null +++ b/blocksuite/blocks/src/root-block/edgeless/gfx-tool/shape-tool.ts @@ -0,0 +1,348 @@ +import { + CanvasElementType, + type SurfaceBlockComponent, +} from '@blocksuite/affine-block-surface'; +import type { ShapeElementModel, ShapeName } from '@blocksuite/affine-model'; +import { + DEFAULT_SHAPE_FILL_COLOR, + DEFAULT_SHAPE_STROKE_COLOR, + getShapeType, +} from '@blocksuite/affine-model'; +import { + EditPropsStore, + TelemetryProvider, + ThemeProvider, +} from '@blocksuite/affine-shared/services'; +import type { PointerEventState } from '@blocksuite/block-std'; +import { BaseTool } from '@blocksuite/block-std/gfx'; +import type { IBound } from '@blocksuite/global/utils'; +import { Bound } from '@blocksuite/global/utils'; +import { effect } from '@preact/signals-core'; + +import { hasClassNameInList } from '../../../_common/utils/index.js'; +import { + EXCLUDING_MOUSE_OUT_CLASS_LIST, + SHAPE_OVERLAY_HEIGHT, + SHAPE_OVERLAY_OPTIONS, + SHAPE_OVERLAY_WIDTH, +} from '../utils/consts.js'; +import { ShapeOverlay } from '../utils/tool-overlay.js'; + +export type ShapeToolOption = { + shapeName: ShapeName; +}; + +export class ShapeTool extends BaseTool<ShapeToolOption> { + static override toolName: string = 'shape'; + + private _disableOverlay = false; + + private _draggingElement: ShapeElementModel | null = null; + + private _draggingElementId: string | null = null; + + // shape overlay + private _shapeOverlay: ShapeOverlay | null = null; + + private _spacePressedCtx: { + draggingArea: IBound & { + endX: number; + endY: number; + startX: number; + startY: number; + }; + } | null = null; + + private _addNewShape( + e: PointerEventState, + width: number, + height: number + ): string { + const { viewport } = this.gfx; + const { shapeName } = this.activatedOption; + const attributes = + this.std.get(EditPropsStore).lastProps$.value[`shape:${shapeName}`]; + + if (shapeName === 'roundedRect') { + width += 40; + } + // create a shape block when drag start + const [modelX, modelY] = viewport.toModelCoord(e.point.x, e.point.y); + const bound = new Bound(modelX, modelY, width, height); + + const id = this.gfx.surface!.addElement({ + type: CanvasElementType.SHAPE, + shapeType: getShapeType(shapeName), + xywh: bound.serialize(), + radius: attributes.radius, + }); + + this.std.getOptional(TelemetryProvider)?.track('CanvasElementAdded', { + control: 'canvas:draw', + page: 'whiteboard editor', + module: 'toolbar', + segment: 'toolbar', + type: CanvasElementType.SHAPE, + other: { + shapeName, + }, + }); + + return id; + } + + private _hideOverlay() { + if (!this._shapeOverlay) return; + + this._shapeOverlay.globalAlpha = 0; + (this.gfx.surfaceComponent as SurfaceBlockComponent)?.refresh(); + } + + private _resize(shiftPressed = false, spacePressed = false) { + const { _draggingElement, _draggingElementId: id, controller } = this; + if (!id || !_draggingElement) return; + + const draggingArea = this.controller.draggingArea$.peek(); + const { startX, startY } = draggingArea; + let { endX, endY } = draggingArea; + + if (shiftPressed) { + const w = Math.abs(endX - startX); + const h = Math.abs(endY - startY); + const m = Math.max(w, h); + + endX = startX + (endX > startX ? m : -m); + endY = startY + (endY > startY ? m : -m); + } + + if (spacePressed && this._spacePressedCtx) { + const { + startX, + startY, + w, + h, + endX: pressedX, + endY: pressedY, + } = this._spacePressedCtx.draggingArea; + const curDraggingArea = controller.draggingViewArea$.peek(); + const { endX: lastX, endY: lastY } = curDraggingArea; + const dx = lastX - pressedX; + const dy = lastY - pressedY; + + this.controller.draggingViewArea$.value = { + ...curDraggingArea, + x: Math.min(startX + dx, lastX), + y: Math.min(startY + dy, lastY), + w, + h, + startX: startX + dx, + startY: startY + dy, + }; + } + + const bound = new Bound( + Math.min(startX, endX), + Math.min(startY, endY), + Math.abs(startX - endX), + Math.abs(startY - endY) + ); + + this.gfx.updateElement(_draggingElement, { + xywh: bound.serialize(), + }); + } + + private _updateOverlayPosition(x: number, y: number) { + if (!this._shapeOverlay) return; + this._shapeOverlay.x = x; + this._shapeOverlay.y = y; + (this.gfx.surfaceComponent as SurfaceBlockComponent)?.refresh(); + } + + override activate() { + this.createOverlay(); + } + + clearOverlay() { + if (!this._shapeOverlay) return; + + this._shapeOverlay.dispose(); + ( + this.gfx.surfaceComponent as SurfaceBlockComponent + )?.renderer.removeOverlay(this._shapeOverlay); + this._shapeOverlay = null; + (this.gfx.surfaceComponent as SurfaceBlockComponent)?.renderer.refresh(); + } + + override click(e: PointerEventState): void { + this.clearOverlay(); + if (this._disableOverlay) return; + + this.doc.captureSync(); + + const id = this._addNewShape(e, SHAPE_OVERLAY_WIDTH, SHAPE_OVERLAY_HEIGHT); + + const element = this.gfx.getElementById(id); + if (!element) return; + + this.gfx.tool.setTool('default'); + this.gfx.selection.set({ + elements: [element.id], + editing: false, + }); + } + + createOverlay() { + this.clearOverlay(); + if (this._disableOverlay) return; + const options = SHAPE_OVERLAY_OPTIONS; + const { shapeName } = this.activatedOption; + const attributes = + this.std.get(EditPropsStore).lastProps$.value[`shape:${shapeName}`]; + + options.stroke = this.std + .get(ThemeProvider) + .getColorValue(attributes.strokeColor, DEFAULT_SHAPE_STROKE_COLOR, true); + options.fill = this.std + .get(ThemeProvider) + .getColorValue(attributes.fillColor, DEFAULT_SHAPE_FILL_COLOR, true); + + switch (attributes.strokeStyle!) { + case 'dash': + options.strokeLineDash = [12, 12]; + break; + case 'none': + options.strokeLineDash = []; + options.stroke = 'transparent'; + break; + default: + options.strokeLineDash = []; + } + this._shapeOverlay = new ShapeOverlay(this.gfx, shapeName, options, { + shapeStyle: attributes.shapeStyle, + fillColor: attributes.fillColor, + strokeColor: attributes.strokeColor, + }); + (this.gfx.surfaceComponent as SurfaceBlockComponent)?.renderer.addOverlay( + this._shapeOverlay + ); + } + + override deactivate() { + this.clearOverlay(); + } + + override dragEnd() { + if (this._disableOverlay) return; + if (this._draggingElement) { + const draggingElement = this._draggingElement; + + draggingElement.pop('xywh'); + } + + const id = this._draggingElementId; + if (!id) return; + const draggingArea = this.controller.draggingArea$.peek(); + + if (draggingArea.w < 20 && draggingArea.h < 20) { + this.gfx.deleteElement(id); + return; + } + + this._draggingElement = null; + this._draggingElementId = null; + + this.doc.captureSync(); + + const element = this.gfx.getElementById(id); + if (!element) return; + + this.controller.setTool('default'); + this.gfx.selection.set({ + elements: [element.id], + }); + } + + override dragMove(e: PointerEventState) { + if (this._disableOverlay || !this._draggingElementId) return; + + this._resize( + e.keys.shift || this.gfx.keyboard.shiftKey$.peek(), + this.gfx.keyboard.spaceKey$.peek() + ); + } + + override dragStart(e: PointerEventState) { + if (this._disableOverlay) return; + this.clearOverlay(); + + this.doc.captureSync(); + + const id = this._addNewShape(e, 0, 0); + + this._spacePressedCtx = null; + this._draggingElementId = id; + this._draggingElement = this.gfx.getElementById(id) as ShapeElementModel; + this._draggingElement.stash('xywh'); + } + + override mounted() { + this.disposable.add( + effect(() => { + const pressed = this.gfx.keyboard.shiftKey$.value; + if (!this._draggingElementId || !this.activate) { + return; + } + + this._resize(pressed); + }) + ); + + this.disposable.add( + effect(() => { + const spacePressed = this.gfx.keyboard.spaceKey$.value; + + if (spacePressed && this._draggingElementId) { + this._spacePressedCtx = { + draggingArea: this.controller.draggingViewArea$.peek(), + }; + } + }) + ); + } + + override pointerMove(e: PointerEventState) { + if (!this._shapeOverlay) return; + // shape options, like stroke color, fill color, etc. + if (this._shapeOverlay.globalAlpha === 0) + this._shapeOverlay.globalAlpha = 1; + const [x, y] = this.gfx.viewport.toModelCoord(e.x, e.y); + this._updateOverlayPosition(x, y); + } + + override pointerOut(e: PointerEventState) { + if ( + e.raw.relatedTarget && + hasClassNameInList( + e.raw.relatedTarget as Element, + EXCLUDING_MOUSE_OUT_CLASS_LIST + ) + ) + return; + this._hideOverlay(); + } + + setDisableOverlay(disable: boolean) { + this._disableOverlay = disable; + } +} + +declare module '@blocksuite/block-std/gfx' { + interface GfxToolsMap { + shape: ShapeTool; + } + + interface GfxToolsOption { + shape: ShapeToolOption; + } +} diff --git a/blocksuite/blocks/src/root-block/edgeless/gfx-tool/template-tool.ts b/blocksuite/blocks/src/root-block/edgeless/gfx-tool/template-tool.ts new file mode 100644 index 0000000000..c239be181d --- /dev/null +++ b/blocksuite/blocks/src/root-block/edgeless/gfx-tool/template-tool.ts @@ -0,0 +1,11 @@ +import { BaseTool } from '@blocksuite/block-std/gfx'; + +export class TemplateTool extends BaseTool { + static override toolName: string = 'template'; +} + +declare module '@blocksuite/block-std/gfx' { + interface GfxToolsMap { + template: TemplateTool; + } +} diff --git a/blocksuite/blocks/src/root-block/edgeless/gfx-tool/text-tool.ts b/blocksuite/blocks/src/root-block/edgeless/gfx-tool/text-tool.ts new file mode 100644 index 0000000000..7abbeec403 --- /dev/null +++ b/blocksuite/blocks/src/root-block/edgeless/gfx-tool/text-tool.ts @@ -0,0 +1,67 @@ +import type { TextElementModel } from '@blocksuite/affine-model'; +import { TelemetryProvider } from '@blocksuite/affine-shared/services'; +import type { PointerEventState } from '@blocksuite/block-std'; +import { BaseTool, type GfxController } from '@blocksuite/block-std/gfx'; +import { Bound } from '@blocksuite/global/utils'; +import { DocCollection } from '@blocksuite/store'; + +import type { EdgelessRootBlockComponent } from '../edgeless-root-block.js'; +import { mountTextElementEditor } from '../utils/text.js'; + +export function addText(gfx: GfxController, event: PointerEventState) { + const [x, y] = gfx.viewport.toModelCoord(event.x, event.y); + const selected = gfx.getElementByPoint(x, y); + + if (!selected) { + const [modelX, modelY] = gfx.viewport.toModelCoord(event.x, event.y); + + if (!gfx.surface) { + return; + } + + const id = gfx.surface.addElement({ + type: 'text', + xywh: new Bound(modelX, modelY, 32, 32).serialize(), + text: new DocCollection.Y.Text(), + }); + gfx.doc.captureSync(); + const textElement = gfx.getElementById(id) as TextElementModel; + const edgelessView = gfx.std.view.getBlock(gfx.std.doc.root!.id); + mountTextElementEditor( + textElement, + edgelessView as EdgelessRootBlockComponent + ); + } +} + +export class TextTool extends BaseTool { + static override toolName: string = 'text'; + + override click(e: PointerEventState): void { + const textFlag = this.gfx.doc.awarenessStore.getFlag( + 'enable_edgeless_text' + ); + + if (textFlag) { + const [x, y] = this.gfx.viewport.toModelCoord(e.x, e.y); + this.gfx.std.command.exec('insertEdgelessText', { x, y }); + this.gfx.tool.setTool('default'); + } else { + addText(this.gfx, e); + } + + this.gfx.std.getOptional(TelemetryProvider)?.track('CanvasElementAdded', { + control: 'canvas:draw', + page: 'whiteboard editor', + module: 'toolbar', + segment: 'toolbar', + type: 'text', + }); + } +} + +declare module '@blocksuite/block-std/gfx' { + interface GfxToolsMap { + text: TextTool; + } +} diff --git a/blocksuite/blocks/src/root-block/edgeless/index.ts b/blocksuite/blocks/src/root-block/edgeless/index.ts new file mode 100644 index 0000000000..2317b93b3a --- /dev/null +++ b/blocksuite/blocks/src/root-block/edgeless/index.ts @@ -0,0 +1,4 @@ +export { GfxBlockModel as EdgelessBlockModel } from './block-model.js'; +export { FramePreview } from './components/frame/frame-preview.js'; +export * from './edgeless-root-block.js'; +export { EdgelessRootService } from './edgeless-root-service.js'; diff --git a/blocksuite/blocks/src/root-block/edgeless/middlewares/base.ts b/blocksuite/blocks/src/root-block/edgeless/middlewares/base.ts new file mode 100644 index 0000000000..58a63de12c --- /dev/null +++ b/blocksuite/blocks/src/root-block/edgeless/middlewares/base.ts @@ -0,0 +1,26 @@ +import { EditPropsStore } from '@blocksuite/affine-shared/services'; +import { + type SurfaceMiddleware, + SurfaceMiddlewareBuilder, +} from '@blocksuite/block-std/gfx'; + +import { getLastPropsKey } from '../utils/get-last-props-key.js'; + +export class EditPropsMiddlewareBuilder extends SurfaceMiddlewareBuilder { + static override key = 'editProps'; + + middleware: SurfaceMiddleware = ctx => { + if (ctx.type === 'beforeAdd') { + const { type, props } = ctx.payload; + const key = getLastPropsKey(type as BlockSuite.EdgelessModelKeys, props); + const nProps = key + ? this.std.get(EditPropsStore).applyLastProps(key, ctx.payload.props) + : null; + + ctx.payload.props = { + ...(nProps ?? props), + index: props.index ?? this.gfx.layer.generateIndex(), + }; + } + }; +} diff --git a/blocksuite/blocks/src/root-block/edgeless/services/template-middlewares.ts b/blocksuite/blocks/src/root-block/edgeless/services/template-middlewares.ts new file mode 100644 index 0000000000..a12c8644e3 --- /dev/null +++ b/blocksuite/blocks/src/root-block/edgeless/services/template-middlewares.ts @@ -0,0 +1,335 @@ +import { CommonUtils, sortIndex } from '@blocksuite/affine-block-surface'; +import type { ConnectorElementModel } from '@blocksuite/affine-model'; +import { assertExists, assertType, Bound } from '@blocksuite/global/utils'; +import type { BlockSnapshot, SnapshotNode } from '@blocksuite/store'; + +import type { SlotBlockPayload, TemplateJob } from './template.js'; + +export const replaceIdMiddleware = (job: TemplateJob) => { + const regeneratedIdMap = new Map<string, string>(); + + job.slots.beforeInsert.on(payload => { + switch (payload.type) { + case 'block': + regenerateBlockId(payload.data); + break; + } + }); + + const regenerateBlockId = (data: SlotBlockPayload['data']) => { + const { blockJson } = data; + const newId = regeneratedIdMap.has(blockJson.id) + ? regeneratedIdMap.get(blockJson.id)! + : job.model.doc.collection.idGenerator(); + + if (!regeneratedIdMap.has(blockJson.id)) { + regeneratedIdMap.set(blockJson.id, newId); + } + + blockJson.id = newId; + + data.parent = data.parent + ? (regeneratedIdMap.get(data.parent) ?? data.parent) + : undefined; + + if (blockJson.flavour === 'affine:surface-ref') { + assertType< + SnapshotNode<{ + reference: string; + }> + >(blockJson); + + blockJson.props['reference'] = + regeneratedIdMap.get(blockJson.props['reference']) ?? ''; + } + + if (blockJson.flavour === 'affine:surface') { + const elements: Record<string, Record<string, unknown>> = {}; + const defered: string[] = []; + + Object.entries( + blockJson.props.elements as Record<string, Record<string, unknown>> + ).forEach(([id, val]) => { + const newId = CommonUtils.generateElementId(); + + regeneratedIdMap.set(id, newId); + val.id = newId; + elements[newId] = val; + + if (['connector', 'group'].includes(val['type'] as string)) { + defered.push(newId); + } + }); + + blockJson.children.forEach(block => { + regeneratedIdMap.set(block.id, job.model.doc.collection.idGenerator()); + }); + + defered.forEach(id => { + const element = elements[id]!; + + switch (element['type'] as string) { + case 'group': + { + const children = element['children'] as { + json: Record<string, boolean>; + }; + const newChildrenJson: Record<string, boolean> = {}; + + Object.entries(children.json).forEach(([key, val]) => { + newChildrenJson[regeneratedIdMap.get(key) ?? key] = val; + }); + + children.json = newChildrenJson; + } + + break; + case 'connector': + { + const target = element['target'] as { id?: string }; + + if (target.id) { + element['target'] = { + ...target, + id: regeneratedIdMap.get(target.id), + }; + } + + const source = element['source'] as { id?: string }; + + if (source.id) { + element['source'] = { + ...source, + id: regeneratedIdMap.get(source.id), + }; + } + } + break; + } + }); + + blockJson.props.elements = elements; + } + }; +}; + +export const createInsertPlaceMiddleware = (targetPlace: Bound) => { + return (job: TemplateJob) => { + if (job.type !== 'template') return; + + let templateBound: Bound | null = null; + let offset: { + x: number; + y: number; + }; + + job.slots.beforeInsert.on(blockData => { + if (blockData.type === 'template') { + templateBound = blockData.bound; + + if (templateBound) { + offset = { + x: targetPlace.x - templateBound.x, + y: targetPlace.y - templateBound.y, + }; + + templateBound.x = targetPlace.x; + templateBound.y = targetPlace.y; + } + } else { + if (templateBound && offset) changePosition(blockData.data.blockJson); + } + }); + + const ignoreType = new Set(['group', 'connector']); + const changePosition = (blockJson: BlockSnapshot) => { + assertExists(templateBound); + + if (blockJson.props.xywh) { + const bound = Bound.deserialize(blockJson.props['xywh'] as string); + + blockJson.props['xywh'] = new Bound( + bound.x + offset.x, + bound.y + offset.y, + bound.w, + bound.h + ).serialize(); + } + + if (blockJson.flavour === 'affine:surface') { + Object.entries( + blockJson.props.elements as Record<string, Record<string, unknown>> + ).forEach(([_, val]) => { + const type = val['type'] as string; + + if (ignoreType.has(type) && val['xywh']) { + delete val['xywh']; + } + + if (val['xywh']) { + const bound = Bound.deserialize(val['xywh'] as string); + + val['xywh'] = new Bound( + bound.x + offset.x, + bound.y + offset.y, + bound.w, + bound.h + ).serialize(); + } + + if (type === 'connector') { + (['target', 'source'] as const).forEach(prop => { + const propVal = val[prop]; + assertType<ConnectorElementModel['target']>(propVal); + + if (propVal['id'] || !propVal['position']) return; + const pos = propVal['position']; + + propVal['position'] = [pos[0] + offset.x, pos[1] + offset.y]; + }); + } + }); + } + }; + }; +}; + +export const createStickerMiddleware = ( + center: { + x: number; + y: number; + }, + getIndex: () => string +) => { + return (job: TemplateJob) => { + job.slots.beforeInsert.on(blockData => { + if (blockData.type === 'block') { + changeInserPosition(blockData.data.blockJson); + } + }); + + const changeInserPosition = (blockJson: BlockSnapshot) => { + if (blockJson.flavour === 'affine:image' && blockJson.props.xywh) { + const bound = Bound.deserialize(blockJson.props['xywh'] as string); + + blockJson.props['xywh'] = new Bound( + center.x - bound.w / 2, + center.y - bound.h / 2, + bound.w, + bound.h + ).serialize(); + + blockJson.props.index = getIndex(); + } + }; + }; +}; + +export const createRegenerateIndexMiddleware = ( + generateIndex: () => string +) => { + return (job: TemplateJob) => { + job.slots.beforeInsert.on(blockData => { + if (blockData.type === 'template') { + generateIndexMap(); + } + + if (blockData.type === 'block') { + resetIndex(blockData.data.blockJson); + } + }); + + const indexMap = new Map<string, string>(); + + const generateIndexMap = () => { + const indexList: { + id: string; + index: string; + flavour: string; + element?: boolean; + }[] = []; + const frameList: { + id: string; + index: string; + }[] = []; + const groupIndexMap = new Map< + string, + { + index: string; + id: string; + } + >(); + + job.walk(block => { + if (block.props.index) { + if (block.flavour === 'affine:frame') { + frameList.push({ + id: block.id, + index: block.props.index as string, + }); + } else { + indexList.push({ + id: block.id, + index: block.props.index as string, + flavour: block.flavour, + }); + } + } + + if (block.flavour === 'affine:surface') { + Object.entries( + block.props.elements as Record<string, Record<string, unknown>> + ).forEach(([_, element]) => { + indexList.push({ + index: element['index'] as string, + flavour: element['type'] as string, + id: element['id'] as string, + element: true, + }); + + if (element['type'] === 'group') { + const children = element['children'] as { + json: Record<string, boolean>; + }; + const groupIndex = { + index: element['index'] as string, + id: element['id'] as string, + }; + + Object.keys(children.json).forEach(key => { + groupIndexMap.set(key, groupIndex); + }); + } + }); + } + }); + + indexList.sort((a, b) => sortIndex(a, b, groupIndexMap)); + frameList.sort((a, b) => sortIndex(a, b, groupIndexMap)); + + frameList.forEach(index => { + indexMap.set(index.id, generateIndex()); + }); + + indexList.forEach(index => { + indexMap.set(index.id, generateIndex()); + }); + }; + const resetIndex = (blockJson: BlockSnapshot) => { + if (blockJson.props.index) { + blockJson.props.index = + indexMap.get(blockJson.id) ?? blockJson.props.index; + } + + if (blockJson.flavour === 'affine:surface') { + Object.entries( + blockJson.props.elements as Record<string, Record<string, unknown>> + ).forEach(([_, element]) => { + if (element['index']) { + element['index'] = indexMap.get(element['id'] as string); + } + }); + } + }; + }; +}; diff --git a/blocksuite/blocks/src/root-block/edgeless/services/template.ts b/blocksuite/blocks/src/root-block/edgeless/services/template.ts new file mode 100644 index 0000000000..1423b594fc --- /dev/null +++ b/blocksuite/blocks/src/root-block/edgeless/services/template.ts @@ -0,0 +1,370 @@ +import type { + SurfaceBlockModel, + SurfaceBlockTransformer, +} from '@blocksuite/affine-block-surface'; +import type { ConnectorElementModel } from '@blocksuite/affine-model'; +import { + assertExists, + assertType, + Bound, + getCommonBound, + Slot, +} from '@blocksuite/global/utils'; +import { + type BlockModel, + type BlockSnapshot, + type DocSnapshot, + DocSnapshotSchema, + Job, + type SnapshotNode, + type Y, +} from '@blocksuite/store'; + +/** + * Those block contains other block's id + * should defer the loading + */ +const DEFERED_BLOCK = ['affine:surface', 'affine:surface-ref'] as const; + +/** + * Those block should not be inserted directly + * it should be merged with current existing block + */ +const MERGE_BLOCK = ['affine:surface', 'affine:page'] as const; + +type MergeBlockFlavour = (typeof MERGE_BLOCK)[number]; + +/** + * Template type will affect the inserting behaviour + */ +const TEMPLATE_TYPES = ['template', 'sticker'] as const; + +type TemplateType = (typeof TEMPLATE_TYPES)[number]; + +export type SlotBlockPayload = { + type: 'block'; + data: { + blockJson: BlockSnapshot; + parent?: string; + index?: number; + }; +}; + +export type SlotPayload = + | SlotBlockPayload + | { + type: 'template'; + template: DocSnapshot; + bound: Bound | null; + }; + +export type TemplateJobConfig = { + model: SurfaceBlockModel; + type: string; + middlewares: ((job: TemplateJob) => void)[]; +}; + +export class TemplateJob { + static middlewares: ((job: TemplateJob) => void)[] = []; + + private _template: DocSnapshot | null = null; + + job: Job; + + model: SurfaceBlockModel; + + slots = { + beforeInsert: new Slot< + | SlotBlockPayload + | { + type: 'template'; + template: DocSnapshot; + bound: Bound | null; + } + >(), + }; + + type: TemplateType; + + constructor({ model, type, middlewares }: TemplateJobConfig) { + this.job = new Job({ collection: model.doc.collection, middlewares: [] }); + this.model = model; + this.type = TEMPLATE_TYPES.includes(type as TemplateType) + ? (type as TemplateType) + : 'template'; + + middlewares.forEach(middleware => middleware(this)); + TemplateJob.middlewares.forEach(middleware => middleware(this)); + } + + static create(options: { + model: SurfaceBlockModel; + type: string; + middlewares: ((job: TemplateJob) => void)[]; + }) { + return new TemplateJob(options); + } + + private _getMergeBlockId(modelData: BlockSnapshot) { + switch (modelData.flavour as MergeBlockFlavour) { + case 'affine:page': + return this.model.doc.root!.id; + case 'affine:surface': + return this.model.id; + } + } + + private _getTemplateBound() { + const bounds: Bound[] = []; + + this.walk(block => { + if (block.props.xywh) { + bounds.push(Bound.deserialize(block.props['xywh'] as string)); + } + + if (block.flavour === 'affine:surface') { + const ignoreType = new Set(['connector', 'group']); + + Object.entries( + block.props.elements as Record<string, Record<string, unknown>> + ).forEach(([_, val]) => { + const type = val['type'] as string; + + if (val['xywh'] && !ignoreType.has(type)) { + bounds.push(Bound.deserialize(val['xywh'] as string)); + } + + if (type === 'connector') { + (['target', 'source'] as const).forEach(prop => { + const propVal = val[prop]; + assertType<ConnectorElementModel['source']>(propVal); + + if (propVal['id'] || !propVal['position']) return; + + const pos = propVal['position']; + + if (pos) { + bounds.push(new Bound(pos[0], pos[1], 0, 0)); + } + }); + } + }); + } + }); + + return getCommonBound(bounds); + } + + private _insertToDoc( + modelDataList: { + flavour: string; + json: BlockSnapshot; + modelData: SnapshotNode<object> | null; + parent?: string; + index?: number; + }[] + ) { + const doc = this.model.doc; + const mergeIdMapping = new Map<string, string>(); + const deferInserting: typeof modelDataList = []; + + const insert = ( + data: (typeof modelDataList)[number], + defered: boolean = true + ) => { + const { flavour, json, modelData, parent, index } = data; + const isMergeBlock = MERGE_BLOCK.includes(flavour as MergeBlockFlavour); + + if (isMergeBlock) { + mergeIdMapping.set(json.id, this._getMergeBlockId(json)); + } + + if ( + defered && + DEFERED_BLOCK.includes(flavour as (typeof DEFERED_BLOCK)[number]) + ) { + deferInserting.push(data); + return; + } else { + if (isMergeBlock) { + this._mergeProps( + json, + this.model.doc.getBlockById( + this._getMergeBlockId(json) + ) as BlockModel + ); + return; + } + + assertExists(modelData); + + doc.addBlock( + modelData.flavour as BlockSuite.Flavour, + { + ...modelData.props, + id: modelData.id, + }, + parent ? (mergeIdMapping.get(parent) ?? parent) : undefined, + index + ); + } + }; + + modelDataList.forEach(data => insert(data)); + deferInserting.forEach(data => insert(data, false)); + } + + private async _jsonToModelData(json: BlockSnapshot) { + const job = this.job; + const defered: { + snapshot: BlockSnapshot; + parent?: string; + index?: number; + }[] = []; + const modelDataList: { + flavour: string; + json: BlockSnapshot; + modelData: SnapshotNode<object> | null; + parent?: string; + index?: number; + }[] = []; + const toModel = async ( + snapshot: BlockSnapshot, + parent?: string, + index?: number, + defer: boolean = true + ) => { + if ( + defer && + DEFERED_BLOCK.includes( + snapshot.flavour as (typeof DEFERED_BLOCK)[number] + ) + ) { + defered.push({ + snapshot, + parent, + index, + }); + return; + } + + const slotData = { + blockJson: snapshot, + parent, + index, + }; + + this.slots.beforeInsert.emit({ type: 'block', data: slotData }); + + /** + * merge block should not be converted to model data + */ + const modelData = MERGE_BLOCK.includes( + snapshot.flavour as MergeBlockFlavour + ) + ? null + : ((await job.snapshotToModelData(snapshot)) ?? null); + + modelDataList.push({ + flavour: snapshot.flavour, + json: snapshot, + modelData, + parent, + index, + }); + + if (snapshot.children) { + let index = 0; + for (const child of snapshot.children) { + await toModel(child, snapshot.id, index); + ++index; + } + } + }; + + await toModel(json); + + for (const json of defered) { + await toModel(json.snapshot, json.parent, json.index, false); + } + + return modelDataList; + } + + private _mergeProps(from: BlockSnapshot, to: BlockModel) { + switch (from.flavour as MergeBlockFlavour) { + case 'affine:page': + break; + case 'affine:surface': + this._mergeSurfaceElements( + from.props.elements as Record<string, Record<string, unknown>>, + (to as SurfaceBlockModel).elements.getValue()! + ); + break; + } + } + + private _mergeSurfaceElements( + from: Record<string, Record<string, unknown>>, + to: Y.Map<Y.Map<unknown>> + ) { + const schema = + this.model.doc.collection.schema.flavourSchemaMap.get('affine:surface'); + const surfaceTransformer = + schema?.transformer?.() as SurfaceBlockTransformer; + + this.model.doc.transact(() => { + const defered: [string, Record<string, unknown>][] = []; + + Object.entries(from).forEach(([id, val]) => { + if (['connector', 'group'].includes(val.type as string)) { + defered.push([id, val]); + } else { + to.set(id, surfaceTransformer.elementFromJSON(val)); + } + }); + + defered.forEach(([key, val]) => { + to.set(key, surfaceTransformer.elementFromJSON(val)); + }); + }); + } + + async insertTemplate(template: unknown) { + DocSnapshotSchema.parse(template); + + assertType<DocSnapshot>(template); + + this._template = template; + + const templateBound = this._getTemplateBound(); + + this.slots.beforeInsert.emit({ + type: 'template', + template: template, + bound: templateBound, + }); + + const modelDataList = await this._jsonToModelData(template.blocks); + + this._insertToDoc(modelDataList); + + return templateBound; + } + + walk(callback: (block: BlockSnapshot, template: DocSnapshot) => void) { + if (!this._template) { + throw new Error('Template not loaded, please call insertTemplate first'); + } + + const iterate = (block: BlockSnapshot, template: DocSnapshot) => { + callback(block, template); + + if (block.children) { + block.children.forEach(child => iterate(child, template)); + } + }; + + iterate(this._template.blocks, this._template); + } +} diff --git a/blocksuite/blocks/src/root-block/edgeless/utils/bound-utils.ts b/blocksuite/blocks/src/root-block/edgeless/utils/bound-utils.ts new file mode 100644 index 0000000000..ce5d4e40dd --- /dev/null +++ b/blocksuite/blocks/src/root-block/edgeless/utils/bound-utils.ts @@ -0,0 +1,38 @@ +import type { SerializedElement } from '@blocksuite/block-std/gfx'; +import { Bound, getBoundWithRotation } from '@blocksuite/global/utils'; +import { type BlockSnapshot, BlockSnapshotSchema } from '@blocksuite/store'; + +export function getBoundFromSerializedElement(element: SerializedElement) { + return Bound.from( + getBoundWithRotation({ + ...Bound.deserialize(element.xywh), + rotate: typeof element.rotate === 'number' ? element.rotate : 0, + }) + ); +} + +export function getBoundFromGfxBlockSnapshot(snapshot: BlockSnapshot) { + if (typeof snapshot.props.xywh !== 'string') return null; + return Bound.deserialize(snapshot.props.xywh); +} + +export function edgelessElementsBoundFromRawData( + elementsRawData: (SerializedElement | BlockSnapshot)[] +) { + if (elementsRawData.length === 0) return new Bound(); + + let prev: Bound | null = null; + + for (const data of elementsRawData) { + const { data: blockSnapshot } = BlockSnapshotSchema.safeParse(data); + const bound = blockSnapshot + ? getBoundFromGfxBlockSnapshot(blockSnapshot) + : getBoundFromSerializedElement(data as SerializedElement); + + if (!bound) continue; + if (!prev) prev = bound; + else prev = prev.unite(bound); + } + + return prev ?? new Bound(); +} diff --git a/blocksuite/blocks/src/root-block/edgeless/utils/clipboard-utils.ts b/blocksuite/blocks/src/root-block/edgeless/utils/clipboard-utils.ts new file mode 100644 index 0000000000..e6beab304d --- /dev/null +++ b/blocksuite/blocks/src/root-block/edgeless/utils/clipboard-utils.ts @@ -0,0 +1,117 @@ +import type { + EdgelessTextBlockModel, + EmbedSyncedDocModel, + FrameBlockModel, + FrameBlockProps, + ImageBlockModel, + NoteBlockModel, +} from '@blocksuite/affine-model'; +import { + generateKeyBetweenV2, + type SerializedElement, +} from '@blocksuite/block-std/gfx'; +import { getCommonBoundWithRotation, groupBy } from '@blocksuite/global/utils'; +import { type BlockSnapshot, BlockSnapshotSchema } from '@blocksuite/store'; + +import type { EdgelessRootBlockComponent } from '../edgeless-root-block.js'; +import { EdgelessFrameManager } from '../frame-manager.js'; +import { getSortedCloneElements, prepareCloneData } from './clone-utils.js'; +import { getElementsWithoutGroup } from './group.js'; +import { + isEdgelessTextBlock, + isEmbedSyncedDocBlock, + isFrameBlock, + isImageBlock, + isNoteBlock, +} from './query.js'; + +const offset = 10; +export async function duplicate( + edgeless: EdgelessRootBlockComponent, + elements: BlockSuite.EdgelessModel[], + select = true +) { + const { clipboardController } = edgeless; + const copyElements = getSortedCloneElements(elements); + const totalBound = getCommonBoundWithRotation(copyElements); + totalBound.x += totalBound.w + offset; + + const snapshot = prepareCloneData(copyElements, edgeless.std); + const { canvasElements, blockModels } = + await clipboardController.createElementsFromClipboardData( + snapshot, + totalBound.center + ); + + const newElements = [...canvasElements, ...blockModels]; + + edgeless.surface.fitToViewport(totalBound); + + if (select) { + edgeless.service.selection.set({ + elements: newElements.map(e => e.id), + editing: false, + }); + } +} +export const splitElements = (elements: BlockSuite.EdgelessModel[]) => { + const { notes, frames, shapes, images, edgelessTexts, embedSyncedDocs } = + groupBy(getElementsWithoutGroup(elements), element => { + if (isNoteBlock(element)) { + return 'notes'; + } else if (isFrameBlock(element)) { + return 'frames'; + } else if (isImageBlock(element)) { + return 'images'; + } else if (isEdgelessTextBlock(element)) { + return 'edgelessTexts'; + } else if (isEmbedSyncedDocBlock(element)) { + return 'embedSyncedDocs'; + } + return 'shapes'; + }) as { + notes: NoteBlockModel[]; + shapes: BlockSuite.SurfaceModel[]; + frames: FrameBlockModel[]; + images: ImageBlockModel[]; + edgelessTexts: EdgelessTextBlockModel[]; + embedSyncedDocs: EmbedSyncedDocModel[]; + }; + + return { + notes: notes ?? [], + shapes: shapes ?? [], + frames: frames ?? [], + images: images ?? [], + edgelessTexts: edgelessTexts ?? [], + embedSyncedDocs: embedSyncedDocs ?? [], + }; +}; + +type FrameSnapshot = BlockSnapshot & { + props: FrameBlockProps; +}; + +export function createNewPresentationIndexes( + raw: (SerializedElement | BlockSnapshot)[], + edgeless: EdgelessRootBlockComponent +) { + const frames = raw + .filter((block): block is FrameSnapshot => { + const { data } = BlockSnapshotSchema.safeParse(block); + return data?.flavour === 'affine:frame'; + }) + .sort((a, b) => + EdgelessFrameManager.framePresentationComparator(a.props, b.props) + ); + + const frameMgr = edgeless.service.frame; + let before = frameMgr.generatePresentationIndex(); + const result = new Map<string, string>(); + frames.forEach(frame => { + result.set(frame.id, before); + before = generateKeyBetweenV2(before, null); + }); + + return result; +} diff --git a/blocksuite/blocks/src/root-block/edgeless/utils/clone-utils.ts b/blocksuite/blocks/src/root-block/edgeless/utils/clone-utils.ts new file mode 100644 index 0000000000..13e5c9b4d0 --- /dev/null +++ b/blocksuite/blocks/src/root-block/edgeless/utils/clone-utils.ts @@ -0,0 +1,239 @@ +import type { + FrameBlockProps, + NodeDetail, + SerializedConnectorElement, + SerializedGroupElement, + SerializedMindmapElement, +} from '@blocksuite/affine-model'; +import { + ConnectorElementModel, + GroupElementModel, + MindmapElementModel, +} from '@blocksuite/affine-model'; +import type { BlockStdScope } from '@blocksuite/block-std'; +import { + getTopElements, + type GfxModel, + isGfxGroupCompatibleModel, + type SerializedElement, +} from '@blocksuite/block-std/gfx'; +import { type BlockSnapshot, Job } from '@blocksuite/store'; + +import { GfxBlockModel } from '../block-model.js'; + +/** + * return all elements in the tree of the elements + */ +export function getSortedCloneElements(elements: GfxModel[]) { + const set = new Set<GfxModel>(); + elements.forEach(element => { + // this element subtree has been added + if (set.has(element)) return; + + set.add(element); + if (isGfxGroupCompatibleModel(element)) { + element.descendantElements.forEach(descendant => set.add(descendant)); + } + }); + return sortEdgelessElements([...set]); +} + +export function prepareCloneData(elements: GfxModel[], std: BlockStdScope) { + elements = sortEdgelessElements(elements); + const job = new Job({ + collection: std.collection, + }); + const res = elements.map(element => { + const data = serializeElement(element, elements, job); + return data; + }); + return res.filter((d): d is SerializedElement | BlockSnapshot => !!d); +} + +export function serializeElement( + element: GfxModel, + elements: GfxModel[], + job: Job +) { + if (element instanceof GfxBlockModel) { + const snapshot = job.blockToSnapshot(element); + if (!snapshot) { + return; + } + return { ...snapshot }; + } else if (element instanceof ConnectorElementModel) { + return serializeConnector(element, elements); + } else { + return element.serialize(); + } +} + +export function serializeConnector( + connector: ConnectorElementModel, + elements: GfxModel[] +) { + const sourceId = connector.source?.id; + const targetId = connector.target?.id; + const serialized = connector.serialize(); + // if the source or target element not to be cloned + // transfer connector position to absolute path + if (sourceId && elements.every(s => s.id !== sourceId)) { + serialized.source = { position: connector.absolutePath[0] }; + } + if (targetId && elements.every(s => s.id !== targetId)) { + serialized.target = { + position: connector.absolutePath[connector.absolutePath.length - 1], + }; + } + return serialized; +} + +/** + * There are interdependencies between elements, + * so they must be added in a certain order + * @param elements edgeless model list + * @returns sorted edgeless model list + */ +export function sortEdgelessElements(elements: GfxModel[]) { + // Since each element has a parent-child relationship, and from-to connector relationship + // the child element must be added before the parent element + // and the connected elements must be added before the connector element + // To achieve this, we do a post-order traversal of the tree + + if (elements.length === 0) return []; + const result: GfxModel[] = []; + + const topElements = getTopElements(elements); + + // the connector element must be added after the connected elements + const moveConnectorToEnd = (elements: GfxModel[]) => { + const connectors = elements.filter( + element => element instanceof ConnectorElementModel + ); + const rest = elements.filter( + element => !(element instanceof ConnectorElementModel) + ); + return [...rest, ...connectors]; + }; + + const traverse = (element: GfxModel) => { + if (isGfxGroupCompatibleModel(element)) { + moveConnectorToEnd(element.childElements).forEach(child => + traverse(child) + ); + } + result.push(element); + }; + + moveConnectorToEnd(topElements).forEach(element => traverse(element)); + + return result; +} + +/** + * map connector source & target ids + * @param props serialized element props + * @param ids old element id to new element id map + * @returns updated element props + */ +export function mapConnectorIds( + props: SerializedConnectorElement, + ids: Map<string, string> +) { + if (props.source.id) { + props.source.id = ids.get(props.source.id); + } + if (props.target.id) { + props.target.id = ids.get(props.target.id); + } + return props; +} + +/** + * map group children ids + * @param props serialized element props + * @param ids old element id to new element id map + * @returns updated element props + */ +export function mapGroupIds( + props: SerializedGroupElement, + ids: Map<string, string> +) { + if (props.children) { + const newMap: Record<string, boolean> = {}; + for (const [key, value] of Object.entries(props.children)) { + const newKey = ids.get(key); + if (newKey) { + newMap[newKey] = value; + } + } + props.children = newMap; + } + return props; +} + +/** + * map frame children ids + * @param props frame block props + * @param ids old element id to new element id map + * @returns updated frame block props + */ +export function mapFrameIds(props: FrameBlockProps, ids: Map<string, string>) { + const oldChildIds = props.childElementIds + ? Object.keys(props.childElementIds) + : []; + const newChildIds: Record<string, boolean> = {}; + oldChildIds.forEach(oldId => { + const newIds = ids.get(oldId); + if (newIds) newChildIds[newIds] = true; + }); + props.childElementIds = newChildIds; + + return props; +} + +/** + * map mindmap children & parent ids + * @param props serialized element props + * @param ids old element id to new element id map + * @returns updated element props + */ +export function mapMindmapIds( + props: SerializedMindmapElement, + ids: Map<string, string> +) { + if (props.children) { + const newMap: Record<string, NodeDetail> = {}; + for (const [key, value] of Object.entries(props.children)) { + const newKey = ids.get(key); + if (value.parent) { + const newParent = ids.get(value.parent); + value.parent = newParent; + } + if (newKey) { + newMap[newKey] = value; + } + } + props.children = newMap; + } + return props; +} + +export function getElementProps( + element: BlockSuite.SurfaceModel, + ids: Map<string, string> +) { + if (element instanceof ConnectorElementModel) { + const props = element.serialize(); + return mapConnectorIds(props, ids); + } + if (element instanceof GroupElementModel) { + const props = element.serialize(); + return mapGroupIds(props, ids); + } + if (element instanceof MindmapElementModel) { + const props = element.serialize(); + return mapMindmapIds(props, ids); + } + return element.serialize(); +} diff --git a/blocksuite/blocks/src/root-block/edgeless/utils/common.ts b/blocksuite/blocks/src/root-block/edgeless/utils/common.ts new file mode 100644 index 0000000000..084ef886a0 --- /dev/null +++ b/blocksuite/blocks/src/root-block/edgeless/utils/common.ts @@ -0,0 +1,323 @@ +import { focusTextModel } from '@blocksuite/affine-components/rich-text'; +import { toast } from '@blocksuite/affine-components/toast'; +import { + type AttachmentBlockProps, + DEFAULT_NOTE_HEIGHT, + DEFAULT_NOTE_WIDTH, + type ImageBlockProps, + NOTE_MIN_HEIGHT, + type NoteBlockModel, + NoteDisplayMode, +} from '@blocksuite/affine-model'; +import { + EMBED_CARD_HEIGHT, + EMBED_CARD_WIDTH, +} from '@blocksuite/affine-shared/consts'; +import { TelemetryProvider } from '@blocksuite/affine-shared/services'; +import type { NoteChildrenFlavour } from '@blocksuite/affine-shared/types'; +import { + handleNativeRangeAtPoint, + humanFileSize, +} from '@blocksuite/affine-shared/utils'; +import type { BlockStdScope } from '@blocksuite/block-std'; +import { GfxControllerIdentifier } from '@blocksuite/block-std/gfx'; +import { + Bound, + type IPoint, + type IVec, + Point, + serializeXYWH, + Vec, +} from '@blocksuite/global/utils'; + +import { + getFileType, + uploadAttachmentBlob, +} from '../../../attachment-block/utils.js'; +import { calcBoundByOrigin, readImageSize } from '../components/utils.js'; +import { DEFAULT_NOTE_OFFSET_X, DEFAULT_NOTE_OFFSET_Y } from './consts.js'; +import { addBlock } from './crud.js'; + +export async function addAttachments( + std: BlockStdScope, + files: File[], + point?: IVec +): Promise<string[]> { + if (!files.length) return []; + + const attachmentService = std.getService('affine:attachment'); + const gfx = std.get(GfxControllerIdentifier); + + if (!attachmentService) { + console.error('Attachment service not found'); + return []; + } + const maxFileSize = attachmentService.maxFileSize; + const isSizeExceeded = files.some(file => file.size > maxFileSize); + if (isSizeExceeded) { + toast( + std.host, + `You can only upload files less than ${humanFileSize( + maxFileSize, + true, + 0 + )}` + ); + return []; + } + + let { x, y } = gfx.viewport.center; + if (point) [x, y] = gfx.viewport.toModelCoord(...point); + + const CARD_STACK_GAP = 32; + + const dropInfos: { blockId: string; file: File }[] = files.map( + (file, index) => { + const point = new Point( + x + index * CARD_STACK_GAP, + y + index * CARD_STACK_GAP + ); + const center = Vec.toVec(point); + const bound = Bound.fromCenter( + center, + EMBED_CARD_WIDTH.cubeThick, + EMBED_CARD_HEIGHT.cubeThick + ); + const blockId = std.doc.addBlock( + 'affine:attachment', + { + name: file.name, + size: file.size, + type: file.type, + style: 'cubeThick', + xywh: bound.serialize(), + } satisfies Partial<AttachmentBlockProps>, + gfx.surface + ); + + return { blockId, file }; + } + ); + + // upload file and update the attachment model + const uploadPromises = dropInfos.map(async ({ blockId, file }) => { + const filetype = await getFileType(file); + await uploadAttachmentBlob(std.host, blockId, file, filetype, true); + return blockId; + }); + const blockIds = await Promise.all(uploadPromises); + + gfx.selection.set({ + elements: blockIds, + editing: false, + }); + + return blockIds; +} + +export async function addImages( + std: BlockStdScope, + files: File[], + point?: IVec +): Promise<string[]> { + const imageFiles = [...files].filter(file => file.type.startsWith('image/')); + if (!imageFiles.length) return []; + + const imageService = std.getService('affine:image'); + const gfx = std.get(GfxControllerIdentifier); + + if (!imageService) { + console.error('Image service not found'); + return []; + } + + const maxFileSize = imageService.maxFileSize; + const isSizeExceeded = imageFiles.some(file => file.size > maxFileSize); + if (isSizeExceeded) { + toast( + std.host, + `You can only upload files less than ${humanFileSize( + maxFileSize, + true, + 0 + )}` + ); + return []; + } + + let { x, y } = gfx.viewport.center; + if (point) [x, y] = gfx.viewport.toModelCoord(...point); + + const dropInfos: { point: Point; blockId: string }[] = []; + const IMAGE_STACK_GAP = 32; + const isMultipleFiles = imageFiles.length > 1; + const inTopLeft = isMultipleFiles ? true : false; + + // create image cards without image data + imageFiles.forEach((file, index) => { + const point = new Point( + x + index * IMAGE_STACK_GAP, + y + index * IMAGE_STACK_GAP + ); + const center = Vec.toVec(point); + const bound = calcBoundByOrigin(center, inTopLeft); + const blockId = std.doc.addBlock( + 'affine:image', + { + size: file.size, + xywh: bound.serialize(), + }, + gfx.surface + ); + dropInfos.push({ point, blockId }); + }); + + // upload image data and update the image model + const uploadPromises = imageFiles.map(async (file, index) => { + const { point, blockId } = dropInfos[index]; + + const sourceId = await std.doc.blobSync.set(file); + const imageSize = await readImageSize(file); + + const center = Vec.toVec(point); + const bound = calcBoundByOrigin( + center, + inTopLeft, + imageSize.width, + imageSize.height + ); + + std.doc.withoutTransact(() => { + gfx.updateElement(blockId, { + sourceId, + ...imageSize, + xywh: bound.serialize(), + } satisfies Partial<ImageBlockProps>); + }); + }); + await Promise.all(uploadPromises); + + const blockIds = dropInfos.map(info => info.blockId); + gfx.selection.set({ + elements: blockIds, + editing: false, + }); + if (isMultipleFiles) { + std.command.exec('autoResizeElements'); + } + return blockIds; +} + +export function addNoteAtPoint( + std: BlockStdScope, + /** + * The point is in browser coordinate + */ + point: IPoint, + options: { + width?: number; + height?: number; + parentId?: string; + noteIndex?: number; + offsetX?: number; + offsetY?: number; + scale?: number; + } = {} +) { + const gfx = std.get(GfxControllerIdentifier); + const { + width = DEFAULT_NOTE_WIDTH, + height = DEFAULT_NOTE_HEIGHT, + offsetX = DEFAULT_NOTE_OFFSET_X, + offsetY = DEFAULT_NOTE_OFFSET_Y, + parentId = gfx.doc.root?.id, + noteIndex, + scale = 1, + } = options; + const [x, y] = gfx.viewport.toModelCoord(point.x, point.y); + const blockId = addBlock( + std, + 'affine:note', + { + xywh: serializeXYWH( + x - offsetX * scale, + y - offsetY * scale, + width, + height + ), + displayMode: NoteDisplayMode.EdgelessOnly, + }, + parentId, + noteIndex + ); + + gfx.std.getOptional(TelemetryProvider)?.track('CanvasElementAdded', { + control: 'canvas:draw', + page: 'whiteboard editor', + module: 'toolbar', + segment: 'toolbar', + type: 'note', + }); + + return blockId; +} + +type NoteOptions = { + childFlavour: NoteChildrenFlavour; + childType: string | null; + collapse: boolean; +}; + +export function addNote( + std: BlockStdScope, + point: Point, + options: NoteOptions, + width = DEFAULT_NOTE_WIDTH, + height = DEFAULT_NOTE_HEIGHT +) { + const noteId = addNoteAtPoint(std, point, { + width, + height, + }); + + const gfx = std.get(GfxControllerIdentifier); + const doc = std.doc; + + const blockId = doc.addBlock( + options.childFlavour, + { type: options.childType }, + noteId + ); + if (options.collapse && height > NOTE_MIN_HEIGHT) { + const note = doc.getBlockById(noteId) as NoteBlockModel; + doc.updateBlock(note, () => { + note.edgeless.collapse = true; + note.edgeless.collapsedHeight = height; + }); + } + gfx.tool.setTool('default'); + + // Wait for edgelessTool updated + requestAnimationFrame(() => { + const blocks = + (doc.root?.children.filter( + child => child.flavour === 'affine:note' + ) as BlockSuite.EdgelessBlockModelType[]) ?? []; + const element = blocks.find(b => b.id === noteId); + if (element) { + gfx.selection.set({ + elements: [element.id], + editing: true, + }); + + // Waiting dom updated, `note mask` is removed + if (blockId) { + focusTextModel(gfx.std, blockId); + } else { + // Cannot reuse `handleNativeRangeClick` directly here, + // since `retargetClick` will re-target to pervious editor + handleNativeRangeAtPoint(point.x, point.y); + } + } + }); +} diff --git a/blocksuite/blocks/src/root-block/edgeless/utils/connector.ts b/blocksuite/blocks/src/root-block/edgeless/utils/connector.ts new file mode 100644 index 0000000000..ec504ab43e --- /dev/null +++ b/blocksuite/blocks/src/root-block/edgeless/utils/connector.ts @@ -0,0 +1,27 @@ +import type { EdgelessRootService } from '../edgeless-root-service.js'; + +/** + * move connectors from origin to target + * @param originId origin element id + * @param targetId target element id + * @param service edgeless root service + */ +export function moveConnectors( + originId: string, + targetId: string, + service: EdgelessRootService +) { + const connectors = service.surface.getConnectors(originId); + connectors.forEach(connector => { + if (connector.source.id === originId) { + service.updateElement(connector.id, { + source: { ...connector.source, id: targetId }, + }); + } + if (connector.target.id === originId) { + service.updateElement(connector.id, { + target: { ...connector.target, id: targetId }, + }); + } + }); +} diff --git a/blocksuite/blocks/src/root-block/edgeless/utils/consts.ts b/blocksuite/blocks/src/root-block/edgeless/utils/consts.ts new file mode 100644 index 0000000000..b1cd9f5040 --- /dev/null +++ b/blocksuite/blocks/src/root-block/edgeless/utils/consts.ts @@ -0,0 +1,62 @@ +import { + DEFAULT_ROUGHNESS, + LineColor, + LineWidth, + ShapeFillColor, + StrokeStyle, +} from '@blocksuite/affine-model'; + +export const BOOKMARK_MIN_WIDTH = 450; + +export const DEFAULT_NOTE_OFFSET_X = 30; +export const DEFAULT_NOTE_OFFSET_Y = 40; +export const NOTE_OVERLAY_OFFSET_X = 6; +export const NOTE_OVERLAY_OFFSET_Y = 6; +export const NOTE_OVERLAY_WIDTH = 100; +export const NOTE_OVERLAY_HEIGHT = 50; +export const NOTE_OVERLAY_CORNER_RADIUS = 6; +export const NOTE_OVERLAY_STOKE_COLOR = '--affine-border-color'; +export const NOTE_OVERLAY_TEXT_COLOR = '--affine-icon-color'; +export const NOTE_OVERLAY_LIGHT_BACKGROUND_COLOR = 'rgba(252, 252, 253, 1)'; +export const NOTE_OVERLAY_DARK_BACKGROUND_COLOR = 'rgb(32, 32, 32)'; + +export const SHAPE_OVERLAY_WIDTH = 100; +export const SHAPE_OVERLAY_HEIGHT = 100; +export const SHAPE_OVERLAY_OFFSET_X = 6; +export const SHAPE_OVERLAY_OFFSET_Y = 6; +export const SHAPE_OVERLAY_OPTIONS = { + seed: 666, + roughness: DEFAULT_ROUGHNESS, + strokeStyle: StrokeStyle.Solid, + strokeLineDash: [] as number[], + stroke: 'black', + strokeWidth: LineWidth.Two, + fill: 'transparent', +}; + +export const DEFAULT_NOTE_CHILD_FLAVOUR = 'affine:paragraph'; +export const DEFAULT_NOTE_CHILD_TYPE = 'text'; +export const DEFAULT_NOTE_TIP = 'Text'; + +export const FIT_TO_SCREEN_PADDING = 100; + +export const ATTACHED_DISTANCE = 20; + +export const EXCLUDING_MOUSE_OUT_CLASS_LIST = [ + 'affine-note-mask', + 'edgeless-block-portal-note', + 'affine-block-children-container', +]; + +export const SurfaceColor = '#6046FE'; +export const NoteColor = '#1E96EB'; +export const BlendColor = '#7D91FF'; + +export const SHAPE_TEXT_COLOR_PURE_WHITE = LineColor.White; +export const SHAPE_TEXT_COLOR_PURE_BLACK = LineColor.Black; +export const SHAPE_FILL_COLOR_BLACK = ShapeFillColor.Black; + +export const AI_CHAT_BLOCK_MIN_WIDTH = 260; +export const AI_CHAT_BLOCK_MIN_HEIGHT = 160; +export const AI_CHAT_BLOCK_MAX_WIDTH = 320; +export const AI_CHAT_BLOCK_MAX_HEIGHT = 300; diff --git a/blocksuite/blocks/src/root-block/edgeless/utils/convert.ts b/blocksuite/blocks/src/root-block/edgeless/utils/convert.ts new file mode 100644 index 0000000000..fbceda3ce8 --- /dev/null +++ b/blocksuite/blocks/src/root-block/edgeless/utils/convert.ts @@ -0,0 +1,8 @@ +import { deserializeXYWH } from '@blocksuite/global/utils'; + +import type { GfxBlockModel } from '../block-model.js'; + +export function xywhArrayToObject(element: GfxBlockModel) { + const [x, y, w, h] = deserializeXYWH(element.xywh); + return { x, y, w, h }; +} diff --git a/blocksuite/blocks/src/root-block/edgeless/utils/crud.ts b/blocksuite/blocks/src/root-block/edgeless/utils/crud.ts new file mode 100644 index 0000000000..a63bd9c960 --- /dev/null +++ b/blocksuite/blocks/src/root-block/edgeless/utils/crud.ts @@ -0,0 +1,92 @@ +import type { SurfaceBlockModel } from '@blocksuite/affine-block-surface'; +import { EditPropsStore } from '@blocksuite/affine-shared/services'; +import type { BlockStdScope } from '@blocksuite/block-std'; +import { + type GfxController, + GfxControllerIdentifier, +} from '@blocksuite/block-std/gfx'; +import type { BlockModel } from '@blocksuite/store'; + +import type { Connectable } from '../../../_common/utils/index.js'; +import type { EdgelessRootBlockComponent } from '../index.js'; +import { getLastPropsKey } from './get-last-props-key.js'; +import { isConnectable, isNoteBlock } from './query.js'; + +/** + * Use deleteElementsV2 instead. + * @deprecated + */ +export function deleteElements( + edgeless: EdgelessRootBlockComponent, + elements: BlockSuite.EdgelessModel[] +) { + const set = new Set(elements); + const { service } = edgeless; + + elements.forEach(element => { + if (isConnectable(element)) { + const connectors = service.getConnectors(element as Connectable); + connectors.forEach(connector => set.add(connector)); + } + }); + + set.forEach(element => { + if (isNoteBlock(element)) { + const children = edgeless.doc.root?.children ?? []; + // FIXME: should always keep at least 1 note + if (children.length > 1) { + edgeless.doc.deleteBlock(element); + } + } else { + service.removeElement(element.id); + } + }); +} + +export function deleteElementsV2( + gfx: GfxController, + elements: BlockSuite.EdgelessModel[] +) { + const set = new Set(elements); + + elements.forEach(element => { + if (isConnectable(element)) { + const connectors = (gfx.surface as SurfaceBlockModel).getConnectors( + element.id + ); + connectors.forEach(connector => set.add(connector)); + } + }); + + set.forEach(element => { + if (isNoteBlock(element)) { + const children = gfx.doc.root?.children ?? []; + if (children.length > 1) { + gfx.doc.deleteBlock(element); + } + } else { + gfx.deleteElement(element.id); + } + }); +} + +export function addBlock( + std: BlockStdScope, + flavour: BlockSuite.EdgelessModelKeys, + props: Record<string, unknown>, + parentId?: string | BlockModel, + parentIndex?: number +) { + const gfx = std.get(GfxControllerIdentifier); + const key = getLastPropsKey(flavour as BlockSuite.EdgelessModelKeys, props); + if (key) { + props = std.get(EditPropsStore).applyLastProps(key, props); + } + + const nProps = { + ...props, + index: gfx.layer.generateIndex(), + }; + + return std.doc.addBlock(flavour as never, nProps, parentId, parentIndex); +} diff --git a/blocksuite/blocks/src/root-block/edgeless/utils/get-last-props-key.ts b/blocksuite/blocks/src/root-block/edgeless/utils/get-last-props-key.ts new file mode 100644 index 0000000000..18f4a495bd --- /dev/null +++ b/blocksuite/blocks/src/root-block/edgeless/utils/get-last-props-key.ts @@ -0,0 +1,29 @@ +import { getShapeName, type ShapeProps } from '@blocksuite/affine-model'; +import type { + LastProps, + LastPropsKey, +} from '@blocksuite/affine-shared/services'; +import { NodePropsSchema } from '@blocksuite/affine-shared/utils'; + +const LastPropsSchema = NodePropsSchema; + +export function getLastPropsKey( + modelType: BlockSuite.EdgelessModelKeys, + modelProps: Partial<LastProps[LastPropsKey]> +): LastPropsKey | null { + if (modelType === 'shape') { + const { shapeType, radius } = modelProps as ShapeProps; + const shapeName = getShapeName(shapeType, radius); + return `${modelType}:${shapeName}`; + } + + if (isLastPropsKey(modelType)) { + return modelType; + } + + return null; +} + +function isLastPropsKey(key: string): key is LastPropsKey { + return Object.keys(LastPropsSchema.shape).includes(key); +} diff --git a/blocksuite/blocks/src/root-block/edgeless/utils/group.ts b/blocksuite/blocks/src/root-block/edgeless/utils/group.ts new file mode 100644 index 0000000000..3dd461da70 --- /dev/null +++ b/blocksuite/blocks/src/root-block/edgeless/utils/group.ts @@ -0,0 +1,15 @@ +import { GroupElementModel } from '@blocksuite/affine-model'; +export function getElementsWithoutGroup(elements: BlockSuite.EdgelessModel[]) { + const set = new Set<BlockSuite.EdgelessModel>(); + + elements.forEach(element => { + if (element instanceof GroupElementModel) { + element.descendantElements + .filter(descendant => !(descendant instanceof GroupElementModel)) + .forEach(descendant => set.add(descendant)); + } else { + set.add(element); + } + }); + return Array.from(set); +} diff --git a/blocksuite/blocks/src/root-block/edgeless/utils/hotkey-utils.ts b/blocksuite/blocks/src/root-block/edgeless/utils/hotkey-utils.ts new file mode 100644 index 0000000000..4718275caa --- /dev/null +++ b/blocksuite/blocks/src/root-block/edgeless/utils/hotkey-utils.ts @@ -0,0 +1,16 @@ +import { ShapeType } from '@blocksuite/affine-model'; + +import type { ShapeToolOption } from '../gfx-tool/shape-tool.js'; + +const shapeMap: Record<ShapeToolOption['shapeName'], number> = { + [ShapeType.Rect]: 0, + [ShapeType.Ellipse]: 1, + [ShapeType.Diamond]: 2, + [ShapeType.Triangle]: 3, + roundedRect: 4, +}; +const shapes = Object.keys(shapeMap) as ShapeToolOption['shapeName'][]; + +export function getNextShapeType(cur: ShapeToolOption['shapeName']) { + return shapes[(shapeMap[cur] + 1) % shapes.length]; +} diff --git a/blocksuite/blocks/src/root-block/edgeless/utils/misc.ts b/blocksuite/blocks/src/root-block/edgeless/utils/misc.ts new file mode 100644 index 0000000000..9601f3804b --- /dev/null +++ b/blocksuite/blocks/src/root-block/edgeless/utils/misc.ts @@ -0,0 +1,5 @@ +export function areSetsEqual<T>(setA: Set<T>, setB: Set<T>) { + if (setA.size !== setB.size) return false; + for (const a of setA) if (!setB.has(a)) return false; + return true; +} diff --git a/blocksuite/blocks/src/root-block/edgeless/utils/panning-utils.ts b/blocksuite/blocks/src/root-block/edgeless/utils/panning-utils.ts new file mode 100644 index 0000000000..0272d42571 --- /dev/null +++ b/blocksuite/blocks/src/root-block/edgeless/utils/panning-utils.ts @@ -0,0 +1,46 @@ +import type { PointerEventState } from '@blocksuite/block-std'; +import type { Viewport } from '@blocksuite/block-std/gfx'; +import type { IVec } from '@blocksuite/global/utils'; + +const PANNING_DISTANCE = 30; + +export function calPanDelta( + viewport: Viewport, + e: PointerEventState, + edgeDistance = 20 +): IVec | null { + // Get viewport edge + const { left, top } = viewport; + const { width, height } = viewport; + // Get pointer position + let { x, y } = e; + const { containerOffset } = e; + x += containerOffset.x; + y += containerOffset.y; + // Check if pointer is near viewport edge + const nearLeft = x < left + edgeDistance; + const nearRight = x > left + width - edgeDistance; + const nearTop = y < top + edgeDistance; + const nearBottom = y > top + height - edgeDistance; + // If pointer is not near viewport edge, return false + if (!(nearLeft || nearRight || nearTop || nearBottom)) return null; + + // Calculate move delta + let deltaX = 0; + let deltaY = 0; + + // Use PANNING_DISTANCE to limit the max delta, avoid panning too fast + if (nearLeft) { + deltaX = Math.max(-PANNING_DISTANCE, x - (left + edgeDistance)); + } else if (nearRight) { + deltaX = Math.min(PANNING_DISTANCE, x - (left + width - edgeDistance)); + } + + if (nearTop) { + deltaY = Math.max(-PANNING_DISTANCE, y - (top + edgeDistance)); + } else if (nearBottom) { + deltaY = Math.min(PANNING_DISTANCE, y - (top + height - edgeDistance)); + } + + return [deltaX, deltaY]; +} diff --git a/blocksuite/blocks/src/root-block/edgeless/utils/query.ts b/blocksuite/blocks/src/root-block/edgeless/utils/query.ts new file mode 100644 index 0000000000..f3bdd7266a --- /dev/null +++ b/blocksuite/blocks/src/root-block/edgeless/utils/query.ts @@ -0,0 +1,342 @@ +import { + type CanvasElementWithText, + CommonUtils, + GRID_GAP_MAX, + GRID_GAP_MIN, +} from '@blocksuite/affine-block-surface'; +import { + type AttachmentBlockModel, + type BookmarkBlockModel, + ConnectorElementModel, + type EdgelessTextBlockModel, + type EmbedBlockModel, + type EmbedFigmaModel, + type EmbedGithubModel, + type EmbedHtmlModel, + type EmbedLinkedDocModel, + type EmbedLoomModel, + type EmbedSyncedDocModel, + type EmbedYoutubeModel, + FrameBlockModel, + type ImageBlockModel, + MindmapElementModel, + type NoteBlockModel, + ShapeElementModel, + TextElementModel, +} from '@blocksuite/affine-model'; +import type { + GfxModel, + GfxPrimitiveElementModel, + GfxToolsFullOptionValue, + Viewport, +} from '@blocksuite/block-std/gfx'; +import type { PointLocation } from '@blocksuite/global/utils'; +import { + Bound, + deserializeXYWH, + getQuadBoundWithRotation, +} from '@blocksuite/global/utils'; +import type { BlockModel } from '@blocksuite/store'; + +import type { Connectable } from '../../../_common/utils/index.js'; +import type { GfxBlockModel } from '../block-model.js'; +import { getElementsWithoutGroup } from './group.js'; + +const { clamp } = CommonUtils; + +export function isMindmapNode( + element: GfxBlockModel | BlockSuite.EdgelessModel | null +) { + return element?.group instanceof MindmapElementModel; +} + +export function isTopLevelBlock( + selectable: BlockModel | BlockSuite.EdgelessModel | null +): selectable is GfxBlockModel { + return !!selectable && 'flavour' in selectable; +} + +export function isNoteBlock( + element: BlockModel | BlockSuite.EdgelessModel | null +): element is NoteBlockModel { + return !!element && 'flavour' in element && element.flavour === 'affine:note'; +} + +export function isEdgelessTextBlock( + element: BlockModel | BlockSuite.EdgelessModel | null +): element is EdgelessTextBlockModel { + return ( + !!element && + 'flavour' in element && + element.flavour === 'affine:edgeless-text' + ); +} + +export function isFrameBlock(element: unknown): element is FrameBlockModel { + return !!element && (element as BlockModel).flavour === 'affine:frame'; +} + +export function isImageBlock( + element: BlockModel | BlockSuite.EdgelessModel | null +): element is ImageBlockModel { + return ( + !!element && 'flavour' in element && element.flavour === 'affine:image' + ); +} + +export function isAttachmentBlock( + element: BlockModel | BlockSuite.EdgelessModel | null +): element is AttachmentBlockModel { + return ( + !!element && 'flavour' in element && element.flavour === 'affine:attachment' + ); +} + +export function isBookmarkBlock( + element: BlockModel | BlockSuite.EdgelessModel | null +): element is BookmarkBlockModel { + return ( + !!element && 'flavour' in element && element.flavour === 'affine:bookmark' + ); +} + +export function isEmbeddedBlock( + element: BlockModel | BlockSuite.EdgelessModel | null +): element is EmbedBlockModel { + return ( + !!element && 'flavour' in element && /affine:embed-*/.test(element.flavour) + ); +} + +/** + * TODO: Remove this function after the edgeless refactor completed + * This function is used to check if the block is an AI chat block for edgeless selected rect + * Should not be used in the future + * Related issue: https://linear.app/affine-design/issue/BS-1009/ + * @deprecated + */ +export function isAIChatBlock( + element: BlockModel | BlockSuite.EdgelessModel | null +) { + return ( + !!element && + 'flavour' in element && + element.flavour === 'affine:embed-ai-chat' + ); +} + +export function isEmbeddedLinkBlock( + element: BlockModel | BlockSuite.EdgelessModel | null +) { + return ( + isEmbeddedBlock(element) && + !isEmbedSyncedDocBlock(element) && + !isEmbedLinkedDocBlock(element) + ); +} + +export function isEmbedGithubBlock( + element: BlockModel | BlockSuite.EdgelessModel | null +): element is EmbedGithubModel { + return ( + !!element && + 'flavour' in element && + element.flavour === 'affine:embed-github' + ); +} + +export function isEmbedYoutubeBlock( + element: BlockModel | BlockSuite.EdgelessModel | null +): element is EmbedYoutubeModel { + return ( + !!element && + 'flavour' in element && + element.flavour === 'affine:embed-youtube' + ); +} + +export function isEmbedLoomBlock( + element: BlockModel | BlockSuite.EdgelessModel | null +): element is EmbedLoomModel { + return ( + !!element && 'flavour' in element && element.flavour === 'affine:embed-loom' + ); +} + +export function isEmbedFigmaBlock( + element: BlockModel | BlockSuite.EdgelessModel | null +): element is EmbedFigmaModel { + return ( + !!element && + 'flavour' in element && + element.flavour === 'affine:embed-figma' + ); +} + +export function isEmbedLinkedDocBlock( + element: BlockModel | BlockSuite.EdgelessModel | null +): element is EmbedLinkedDocModel { + return ( + !!element && + 'flavour' in element && + element.flavour === 'affine:embed-linked-doc' + ); +} + +export function isEmbedSyncedDocBlock( + element: BlockModel | BlockSuite.EdgelessModel | null +): element is EmbedSyncedDocModel { + return ( + !!element && + 'flavour' in element && + element.flavour === 'affine:embed-synced-doc' + ); +} + +export function isEmbedHtmlBlock( + element: BlockModel | BlockSuite.EdgelessModel | null +): element is EmbedHtmlModel { + return ( + !!element && 'flavour' in element && element.flavour === 'affine:embed-html' + ); +} + +export function isCanvasElement( + selectable: GfxModel | BlockModel | null +): selectable is GfxPrimitiveElementModel { + return !isTopLevelBlock(selectable); +} + +export function isCanvasElementWithText( + element: BlockSuite.EdgelessModel +): element is CanvasElementWithText { + return ( + element instanceof TextElementModel || element instanceof ShapeElementModel + ); +} + +export function isConnectable( + element: BlockSuite.EdgelessModel | null +): element is Connectable { + return !!element && element.connectable; +} + +export function getSelectionBoxBound(viewport: Viewport, bound: Bound) { + const { w, h } = bound; + const [x, y] = viewport.toViewCoord(bound.x, bound.y); + return new DOMRect(x, y, w * viewport.zoom, h * viewport.zoom); +} + +// https://developer.mozilla.org/en-US/docs/Web/CSS/cursor +export function getCursorMode(edgelessTool: GfxToolsFullOptionValue | null) { + if (!edgelessTool) { + return 'default'; + } + switch (edgelessTool.type) { + case 'default': + return 'default'; + case 'pan': + return edgelessTool.panning ? 'grabbing' : 'grab'; + case 'brush': + case 'eraser': + case 'shape': + case 'connector': + case 'frame': + case 'lasso': + return 'crosshair'; + case 'text': + return 'text'; + default: + return 'default'; + } +} + +export function getBackgroundGrid(zoom: number, showGrid: boolean) { + const step = zoom < 0.5 ? 2 : 1 / (Math.floor(zoom) || 1); + const gap = clamp(20 * step * zoom, GRID_GAP_MIN, GRID_GAP_MAX); + + return { + gap, + grid: showGrid + ? 'radial-gradient(var(--affine-edgeless-grid-color) 1px, var(--affine-background-primary-color) 1px)' + : 'unset', + }; +} + +export function getSelectedRect(selected: BlockSuite.EdgelessModel[]): DOMRect { + if (selected.length === 0) { + return new DOMRect(); + } + + const lockedElementsByFrame = selected + .map(selectable => { + if (selectable instanceof FrameBlockModel && selectable.isLocked()) { + return selectable.descendantElements; + } + return []; + }) + .flat(); + + selected = [...new Set([...selected, ...lockedElementsByFrame])]; + + if (selected.length === 1) { + const [x, y, w, h] = deserializeXYWH(selected[0].xywh); + return new DOMRect(x, y, w, h); + } + + return getElementsWithoutGroup(selected).reduce( + (bounds, selectable, index) => { + const rotate = isTopLevelBlock(selectable) ? 0 : selectable.rotate; + const [x, y, w, h] = deserializeXYWH(selectable.xywh); + let { left, top, right, bottom } = getQuadBoundWithRotation({ + x, + y, + w, + h, + rotate, + }); + + if (index !== 0) { + left = Math.min(left, bounds.left); + top = Math.min(top, bounds.top); + right = Math.max(right, bounds.right); + bottom = Math.max(bottom, bounds.bottom); + } + + bounds.x = left; + bounds.y = top; + bounds.width = right - left; + bounds.height = bottom - top; + + return bounds; + }, + new DOMRect() + ); +} + +export type SelectableProps = { + bound: Bound; + rotate: number; + path?: PointLocation[]; +}; + +export function getSelectableBounds( + selected: BlockSuite.EdgelessModel[] +): Map<string, SelectableProps> { + const bounds = new Map(); + getElementsWithoutGroup(selected).forEach(ele => { + const bound = Bound.deserialize(ele.xywh); + const props: SelectableProps = { + bound, + rotate: ele.rotate, + }; + + if (isCanvasElement(ele) && ele instanceof ConnectorElementModel) { + props.path = ele.absolutePath.map(p => p.clone()); + } + + bounds.set(ele.id, props); + }); + + return bounds; +} diff --git a/blocksuite/blocks/src/root-block/edgeless/utils/snap-manager.ts b/blocksuite/blocks/src/root-block/edgeless/utils/snap-manager.ts new file mode 100644 index 0000000000..86ccc214a1 --- /dev/null +++ b/blocksuite/blocks/src/root-block/edgeless/utils/snap-manager.ts @@ -0,0 +1,455 @@ +import type { + SurfaceBlockComponent, + SurfaceBlockModel, +} from '@blocksuite/affine-block-surface'; +import { Overlay } from '@blocksuite/affine-block-surface'; +import type { ConnectorElementModel } from '@blocksuite/affine-model'; +import type { GfxController } from '@blocksuite/block-std/gfx'; +import { Bound, Point } from '@blocksuite/global/utils'; + +import { isConnectable } from '../utils/query.js'; + +interface Distance { + absXDistance: number; + absYDistance: number; + xDistance: number; + yDistance: number; + indexX: number; + indexY: number; +} + +const ALIGN_THRESHOLD = 5; + +export class EdgelessSnapManager extends Overlay { + static override overlayName: string = 'snap-manager'; + + private _alignableBounds: Bound[] = []; + + /** + * This variable contains reference lines that are + * generated by the 'Distribute Alignment' function. This alignment is achieved + * by evenly distributing elements based on specified alignment rules. + * These lines serve as a guide for achieving equal spacing or distribution + * among multiple graphics or design elements. + */ + private _distributedAlignLines: [Point, Point][] = []; + + /** + * This variable holds reference lines that are calculated + * based on the self-alignment of the graphics. This alignment is determined + * according to various aspects of the graphic itself, such as the center, edges, + * corners, etc. It essentially represents the guidelines for the positioning + * and alignment within the individual graphic elements. + */ + private _intraGraphicAlignLines: [Point, Point][] = []; + + cleanupAlignables = () => { + this._alignableBounds = []; + this._intraGraphicAlignLines = []; + this._distributedAlignLines = []; + // FIXME: not sure why renderer can be undefined sometimes + this._surface.renderer?.removeOverlay(this); + }; + + private get _surface() { + const surfaceModel = this.gfx.doc.getBlockByFlavour( + 'affine:surface' + )[0] as SurfaceBlockModel; + + return this.gfx.std.view.getBlock(surfaceModel.id) as SurfaceBlockComponent; + } + + constructor(gfx: GfxController) { + super(gfx); + } + + private _alignDistributeHorizontally( + rst: { dx: number; dy: number }, + bound: Bound, + threshold: number, + viewport: { zoom: number } + ) { + const wBoxes: Bound[] = []; + this._alignableBounds.forEach(box => { + if (box.isHorizontalCross(bound)) { + wBoxes.push(box); + } + }); + let dif = Infinity; + let min = Infinity; + for (let i = 0; i < wBoxes.length; i++) { + for (let j = i + 1; j < wBoxes.length; j++) { + let lb = wBoxes[i], + rb = wBoxes[j]; + // it means these bound need to be horizontally across + if (!lb.isHorizontalCross(rb)) continue; + if (lb.isIntersectWithBound(rb)) continue; + if (rb.maxX < lb.minX) { + const temp = rb; + rb = lb; + lb = temp; + } + /** align middle */ + let _centerX = 0; + const updateDif = () => { + dif = Math.abs(bound.center[0] - _centerX); + if (dif <= threshold && dif < min) { + min = dif; + rst.dx = _centerX - bound.center[0]; + /** + * calculate points to draw + */ + const ys = [lb.minY, lb.maxY, rb.minY, rb.maxY].sort( + (a, b) => a - b + ); + const y = (ys[1] + ys[2]) / 2; + const offset = 2 / viewport.zoom; + const xs = [ + _centerX - bound.w / 2 - offset, + _centerX + bound.w / 2 + offset, + rb.minX, + rb.maxX, + lb.minX, + lb.maxX, + ].sort((a, b) => a - b); + this._distributedAlignLines[0] = [ + new Point(xs[1], y), + new Point(xs[2], y), + ]; + this._distributedAlignLines[1] = [ + new Point(xs[3], y), + new Point(xs[4], y), + ]; + } + }; + if (lb.horizontalDistance(rb) > bound.w) { + _centerX = (lb.maxX + rb.minX) / 2; + updateDif(); + } + /** align left */ + _centerX = lb.minX - (rb.minX - lb.maxX) - bound.w / 2; + updateDif(); + /** align right */ + _centerX = rb.minX - lb.maxX + rb.maxX + bound.w / 2; + updateDif(); + } + } + } + + private _alignDistributeVertically( + rst: { dx: number; dy: number }, + bound: Bound, + threshold: number, + viewport: { zoom: number } + ) { + const hBoxes: Bound[] = []; + this._alignableBounds.forEach(box => { + if (box.isVerticalCross(bound)) { + hBoxes.push(box); + } + }); + let dif = Infinity; + let min = Infinity; + for (let i = 0; i < hBoxes.length; i++) { + for (let j = i + 1; j < hBoxes.length; j++) { + let ub = hBoxes[i], + db = hBoxes[j]; + if (!ub.isVerticalCross(db)) continue; + if (ub.isIntersectWithBound(db)) continue; + if (db.maxY < ub.minX) { + const temp = ub; + ub = db; + db = temp; + } + /** align middle */ + let _centerY = 0; + const updateDiff = () => { + dif = Math.abs(bound.center[1] - _centerY); + if (dif <= threshold && dif < min) { + min = dif; + rst.dy = _centerY - bound.center[1]; + /** + * calculate points to draw + */ + const xs = [ub.minX, ub.maxX, db.minX, db.maxX].sort( + (a, b) => a - b + ); + const x = (xs[1] + xs[2]) / 2; + const offset = 2 / viewport.zoom; + const ys = [ + _centerY - bound.h / 2 - offset, + _centerY + bound.h / 2 + offset, + db.minY, + db.maxY, + ub.minY, + ub.maxY, + ].sort((a, b) => a - b); + this._distributedAlignLines[3] = [ + new Point(x, ys[1]), + new Point(x, ys[2]), + ]; + this._distributedAlignLines[4] = [ + new Point(x, ys[3]), + new Point(x, ys[4]), + ]; + } + }; + if (ub.verticalDistance(db) > bound.h) { + _centerY = (ub.maxY + db.minY) / 2; + updateDiff(); + } + /** align upper */ + _centerY = ub.minY - (db.minY - ub.maxY) - bound.h / 2; + updateDiff(); + /** align lower */ + _centerY = db.minY - ub.maxY + db.maxY + bound.h / 2; + updateDiff(); + } + } + } + + private _calculateClosestDistances(bound: Bound, other: Bound): Distance { + // Calculate center-to-center and center-to-side distances + const centerXDistance = other.center[0] - bound.center[0]; + const centerYDistance = other.center[1] - bound.center[1]; + + // Calculate center-to-side distances + const leftDistance = other.minX - bound.center[0]; + const rightDistance = other.maxX - bound.center[0]; + const topDistance = other.minY - bound.center[1]; + const bottomDistance = other.maxY - bound.center[1]; + + // Calculate side-to-side distances + const leftToLeft = other.minX - bound.minX; + const leftToRight = other.maxX - bound.minX; + const rightToLeft = other.minX - bound.maxX; + const rightToRight = other.maxX - bound.maxX; + + const topToTop = other.minY - bound.minY; + const topToBottom = other.maxY - bound.minY; + const bottomToTop = other.minY - bound.maxY; + const bottomToBottom = other.maxY - bound.maxY; + + const xDistances = [ + centerXDistance, + leftDistance, + rightDistance, + leftToLeft, + leftToRight, + rightToLeft, + rightToRight, + ]; + + const yDistances = [ + centerYDistance, + topDistance, + bottomDistance, + topToTop, + topToBottom, + bottomToTop, + bottomToBottom, + ]; + + // Get absolute distances + const xDistancesAbs = xDistances.map(Math.abs); + const yDistancesAbs = yDistances.map(Math.abs); + + // Get closest distances + const closestX = Math.min(...xDistancesAbs); + const closestY = Math.min(...yDistancesAbs); + + const indexX = xDistancesAbs.indexOf(closestX); + const indexY = yDistancesAbs.indexOf(closestY); + + // the x and y distances will be useful for locating the align point + return { + absXDistance: closestX, + absYDistance: closestY, + xDistance: xDistances[indexX], + yDistance: yDistances[indexY], + indexX, + indexY, + }; + } + + private _draw() { + this._surface.refresh(); + } + + // Update X align point + private _updateXAlignPoint( + rst: { dx: number; dy: number }, + bound: Bound, + other: Bound, + distance: Distance + ) { + const index = distance.indexX; + rst.dx = distance.xDistance; + const alignPointX = [ + other.center[0], + other.minX, + other.maxX, + bound.minX + rst.dx, + bound.minX + rst.dx, + bound.maxX + rst.dx, + bound.maxX + rst.dx, + ][index]; + this._intraGraphicAlignLines[0] = [ + new Point(alignPointX, bound.center[1]), + new Point(alignPointX, other.center[1]), + ]; + } + + // Update Y align point + private _updateYAlignPoint( + rst: { dx: number; dy: number }, + bound: Bound, + other: Bound, + distance: Distance + ) { + const index = distance.indexY; + rst.dy = distance.yDistance; + const alignPointY = [ + other.center[1], + other.minY, + other.maxY, + bound.minY + rst.dy, + bound.minY + rst.dy, + bound.maxY + rst.dy, + bound.maxY + rst.dy, + ][index]; + this._intraGraphicAlignLines[1] = [ + new Point(bound.center[0], alignPointY), + new Point(other.center[0], alignPointY), + ]; + } + + align(bound: Bound): { dx: number; dy: number } { + const rst = { dx: 0, dy: 0 }; + const threshold = ALIGN_THRESHOLD; + + const { viewport } = this.gfx; + + this._intraGraphicAlignLines = []; + this._distributedAlignLines = []; + + for (const other of this._alignableBounds) { + const closestDistances = this._calculateClosestDistances(bound, other); + + if (closestDistances.absXDistance < threshold) { + this._updateXAlignPoint(rst, bound, other, closestDistances); + } + + if (closestDistances.absYDistance < threshold) { + this._updateYAlignPoint(rst, bound, other, closestDistances); + } + } + + // point align prority is higher than distribute align + if (rst.dx === 0) { + this._alignDistributeHorizontally(rst, bound, threshold, viewport); + } + + if (rst.dy === 0) { + this._alignDistributeVertically(rst, bound, threshold, viewport); + } + this._draw(); + return rst; + } + + override render(ctx: CanvasRenderingContext2D) { + if ( + this._intraGraphicAlignLines.length === 0 && + this._distributedAlignLines.length === 0 + ) + return; + const { viewport } = this.gfx; + const strokeWidth = 1 / viewport.zoom; + const offset = 5 / viewport.zoom; + ctx.strokeStyle = '#1672F3'; + ctx.lineWidth = strokeWidth; + ctx.beginPath(); + + this._intraGraphicAlignLines.forEach(line => { + let d = ''; + if (line[0].x === line[1].x) { + const x = line[0].x; + const minY = Math.min(line[0].y, line[1].y); + const maxY = Math.max(line[0].y, line[1].y); + d = `M${x},${minY - offset}L${x},${maxY}`; + } else { + const y = line[0].y; + const minX = Math.min(line[0].x, line[1].x); + const maxX = Math.max(line[0].x, line[1].x); + d = `M${minX - offset},${y}L${maxX + offset},${y}`; + } + ctx.stroke(new Path2D(d)); + }); + + this._distributedAlignLines.forEach(line => { + const bar = 10 / viewport.zoom; + let d = ''; + if (line[0].x === line[1].x) { + const x = line[0].x; + const minY = Math.min(line[0].y, line[1].y) + offset; + const maxY = Math.max(line[0].y, line[1].y) - offset; + d = `M${x},${minY}L${x},${maxY} + M${x - bar},${minY}L${x + bar},${minY} + M${x - bar},${maxY}L${x + bar},${maxY} `; + } else { + const y = line[0].y; + const minX = Math.min(line[0].x, line[1].x) + offset; + const maxX = Math.max(line[0].x, line[1].x) - offset; + d = `M${minX},${y}L${maxX},${y} + M${minX},${y - bar}L${minX},${y + bar} + M${maxX},${y - bar}L${maxX},${y + bar}`; + } + ctx.stroke(new Path2D(d)); + }); + } + + setupAlignables( + alignables: BlockSuite.EdgelessModel[], + exclude: BlockSuite.EdgelessModel[] = [] + ): Bound { + if (alignables.length === 0) return new Bound(); + + const connectors = alignables.filter(isConnectable).reduce((prev, el) => { + const connectors = (this.gfx.surface as SurfaceBlockModel).getConnectors( + el.id + ); + + if (connectors.length > 0) { + prev = prev.concat(connectors); + } + + return prev; + }, [] as ConnectorElementModel[]); + + const { viewport } = this.gfx; + const viewportBounds = Bound.from(viewport.viewportBounds); + this._surface.renderer.addOverlay(this); + const canvasElements = this.gfx.layer.canvasElements; + const excludes = new Set([...alignables, ...exclude, ...connectors]); + this._alignableBounds = []; + ( + [ + ...this.gfx.layer.blocks, + ...canvasElements, + ] as BlockSuite.EdgelessModel[] + ).forEach(alignable => { + const bounds = alignable.elementBound; + if ( + viewportBounds.isOverlapWithBound(bounds) && + !excludes.has(alignable) + ) { + this._alignableBounds.push(bounds); + } + }); + + return alignables.reduce((prev, element) => { + const bounds = element.elementBound; + return prev.unite(bounds); + }, Bound.deserialize(alignables[0].xywh)); + } +} diff --git a/blocksuite/blocks/src/root-block/edgeless/utils/text.ts b/blocksuite/blocks/src/root-block/edgeless/utils/text.ts new file mode 100644 index 0000000000..b9219b7569 --- /dev/null +++ b/blocksuite/blocks/src/root-block/edgeless/utils/text.ts @@ -0,0 +1,228 @@ +import { + CanvasElementType, + type IModelCoord, + TextUtils, +} from '@blocksuite/affine-block-surface'; +import type { + ConnectorElementModel, + FrameBlockModel, + GroupElementModel, +} from '@blocksuite/affine-model'; +import { ShapeElementModel, TextElementModel } from '@blocksuite/affine-model'; +import type { PointerEventState } from '@blocksuite/block-std'; +import { BlockSuiteError, ErrorCode } from '@blocksuite/global/exceptions'; +import type { IVec } from '@blocksuite/global/utils'; +import { + assertExists, + assertInstanceOf, + Bound, +} from '@blocksuite/global/utils'; +import { DocCollection } from '@blocksuite/store'; + +import { EdgelessConnectorLabelEditor } from '../components/text/edgeless-connector-label-editor.js'; +import { EdgelessFrameTitleEditor } from '../components/text/edgeless-frame-title-editor.js'; +import { EdgelessGroupTitleEditor } from '../components/text/edgeless-group-title-editor.js'; +import { EdgelessShapeTextEditor } from '../components/text/edgeless-shape-text-editor.js'; +import { EdgelessTextEditor } from '../components/text/edgeless-text-editor.js'; +import type { EdgelessRootBlockComponent } from '../edgeless-root-block.js'; + +export function mountTextElementEditor( + textElement: TextElementModel, + edgeless: EdgelessRootBlockComponent, + focusCoord?: IModelCoord +) { + if (!edgeless.mountElm) { + throw new BlockSuiteError( + ErrorCode.ValueNotExists, + "edgeless block's mount point does not exist" + ); + } + + let cursorIndex = textElement.text.length; + if (focusCoord) { + cursorIndex = Math.min( + TextUtils.getCursorByCoord(textElement, focusCoord), + cursorIndex + ); + } + + const textEditor = new EdgelessTextEditor(); + textEditor.edgeless = edgeless; + textEditor.element = textElement; + + edgeless.append(textEditor); + textEditor.updateComplete + .then(() => { + textEditor.inlineEditor?.focusIndex(cursorIndex); + }) + .catch(console.error); + + edgeless.gfx.tool.setTool('default'); + edgeless.gfx.selection.set({ + elements: [textElement.id], + editing: true, + }); +} + +export function mountShapeTextEditor( + shapeElement: ShapeElementModel, + edgeless: EdgelessRootBlockComponent +) { + if (!edgeless.mountElm) { + throw new BlockSuiteError( + ErrorCode.ValueNotExists, + "edgeless block's mount point does not exist" + ); + } + + if (!shapeElement.text) { + const text = new DocCollection.Y.Text(); + edgeless.service.updateElement(shapeElement.id, { text }); + } + + const updatedElement = edgeless.service.getElementById(shapeElement.id); + + assertInstanceOf( + updatedElement, + ShapeElementModel, + 'Cannot mount text editor on a non-shape element' + ); + + const shapeEditor = new EdgelessShapeTextEditor(); + shapeEditor.element = updatedElement; + shapeEditor.edgeless = edgeless; + shapeEditor.mountEditor = mountShapeTextEditor; + + edgeless.mountElm.append(shapeEditor); + edgeless.gfx.tool.setTool('default'); + edgeless.gfx.selection.set({ + elements: [shapeElement.id], + editing: true, + }); +} + +export function mountFrameTitleEditor( + frame: FrameBlockModel, + edgeless: EdgelessRootBlockComponent +) { + if (!edgeless.mountElm) { + throw new BlockSuiteError( + ErrorCode.ValueNotExists, + "edgeless block's mount point does not exist" + ); + } + + const frameEditor = new EdgelessFrameTitleEditor(); + frameEditor.frameModel = frame; + frameEditor.edgeless = edgeless; + + edgeless.mountElm.append(frameEditor); + edgeless.gfx.tool.setTool('default'); + edgeless.gfx.selection.set({ + elements: [frame.id], + editing: true, + }); +} + +export function mountGroupTitleEditor( + group: GroupElementModel, + edgeless: EdgelessRootBlockComponent +) { + if (!edgeless.mountElm) { + throw new BlockSuiteError( + ErrorCode.ValueNotExists, + "edgeless block's mount point does not exist" + ); + } + + const groupEditor = new EdgelessGroupTitleEditor(); + groupEditor.group = group; + groupEditor.edgeless = edgeless; + + edgeless.mountElm.append(groupEditor); + edgeless.gfx.tool.setTool('default'); + edgeless.gfx.selection.set({ + elements: [group.id], + editing: true, + }); +} + +/** + * @deprecated + * + * Canvas Text has been deprecated + */ +export function addText( + edgeless: EdgelessRootBlockComponent, + event: PointerEventState +) { + const [x, y] = edgeless.service.viewport.toModelCoord(event.x, event.y); + const selected = edgeless.service.gfx.getElementByPoint(x, y); + + if (!selected) { + const [modelX, modelY] = edgeless.service.viewport.toModelCoord( + event.x, + event.y + ); + const id = edgeless.service.addElement(CanvasElementType.TEXT, { + xywh: new Bound(modelX, modelY, 32, 32).serialize(), + text: new DocCollection.Y.Text(), + }); + edgeless.doc.captureSync(); + const textElement = edgeless.service.getElementById(id); + assertExists(textElement); + if (textElement instanceof TextElementModel) { + mountTextElementEditor(textElement, edgeless); + } + } +} + +export function mountConnectorLabelEditor( + connector: ConnectorElementModel, + edgeless: EdgelessRootBlockComponent, + point?: IVec +) { + if (!edgeless.mountElm) { + throw new BlockSuiteError( + ErrorCode.ValueNotExists, + "edgeless block's mount point does not exist" + ); + } + + if (!connector.text) { + const text = new DocCollection.Y.Text(); + const labelOffset = connector.labelOffset; + let labelXYWH = connector.labelXYWH ?? [0, 0, 16, 16]; + + if (point) { + const center = connector.getNearestPoint(point); + const distance = connector.getOffsetDistanceByPoint(center as IVec); + const bounds = Bound.fromXYWH(labelXYWH); + bounds.center = center; + labelOffset.distance = distance; + labelXYWH = bounds.toXYWH(); + } + + edgeless.service.updateElement(connector.id, { + text, + labelXYWH, + labelOffset: { ...labelOffset }, + }); + } + + const editor = new EdgelessConnectorLabelEditor(); + editor.connector = connector; + editor.edgeless = edgeless; + + edgeless.mountElm.append(editor); + editor.updateComplete + .then(() => { + editor.inlineEditor?.focusEnd(); + }) + .catch(console.error); + edgeless.gfx.tool.setTool('default'); + edgeless.gfx.selection.set({ + elements: [connector.id], + editing: true, + }); +} diff --git a/blocksuite/blocks/src/root-block/edgeless/utils/tool-overlay.ts b/blocksuite/blocks/src/root-block/edgeless/utils/tool-overlay.ts new file mode 100644 index 0000000000..05ffd5f702 --- /dev/null +++ b/blocksuite/blocks/src/root-block/edgeless/utils/tool-overlay.ts @@ -0,0 +1,487 @@ +import { + type Options, + Overlay, + type RoughCanvas, + type SurfaceBlockComponent, +} from '@blocksuite/affine-block-surface'; +import { + type Color, + DEFAULT_NOTE_BACKGROUND_COLOR, + DEFAULT_SHAPE_FILL_COLOR, + DEFAULT_SHAPE_STROKE_COLOR, + shapeMethods, + type ShapeStyle, +} from '@blocksuite/affine-model'; +import { ThemeProvider } from '@blocksuite/affine-shared/services'; +import type { GfxController, GfxToolsMap } from '@blocksuite/block-std/gfx'; +import type { XYWH } from '@blocksuite/global/utils'; +import { + assertType, + Bound, + DisposableGroup, + noop, + Slot, +} from '@blocksuite/global/utils'; +import { effect } from '@preact/signals-core'; + +import type { ShapeTool } from '../gfx-tool/shape-tool.js'; +import { + NOTE_OVERLAY_CORNER_RADIUS, + NOTE_OVERLAY_HEIGHT, + NOTE_OVERLAY_OFFSET_X, + NOTE_OVERLAY_OFFSET_Y, + NOTE_OVERLAY_STOKE_COLOR, + NOTE_OVERLAY_TEXT_COLOR, + NOTE_OVERLAY_WIDTH, + SHAPE_OVERLAY_HEIGHT, + SHAPE_OVERLAY_OFFSET_X, + SHAPE_OVERLAY_OFFSET_Y, + SHAPE_OVERLAY_WIDTH, +} from '../utils/consts.js'; + +const drawRoundedRect = (ctx: CanvasRenderingContext2D, xywh: XYWH) => { + const [x, y, w, h] = xywh; + const width = w; + const height = h; + const radius = 0.1; + const cornerRadius = Math.min(width * radius, height * radius); + ctx.moveTo(x + cornerRadius, y); + ctx.arcTo(x + width, y, x + width, y + height, cornerRadius); + ctx.arcTo(x + width, y + height, x, y + height, cornerRadius); + ctx.arcTo(x, y + height, x, y, cornerRadius); + ctx.arcTo(x, y, x + width, y, cornerRadius); +}; + +const drawGeneralShape = ( + ctx: CanvasRenderingContext2D, + type: string, + xywh: XYWH, + options: Options +) => { + ctx.setLineDash(options.strokeLineDash ?? []); + ctx.strokeStyle = options.stroke ?? 'transparent'; + ctx.lineWidth = options.strokeWidth ?? 2; + ctx.fillStyle = options.fill ?? 'transparent'; + + ctx.beginPath(); + + const bound = Bound.fromXYWH(xywh); + switch (type) { + case 'rect': + shapeMethods.rect.draw(ctx, bound); + break; + case 'triangle': + shapeMethods.triangle.draw(ctx, bound); + break; + case 'diamond': + shapeMethods.diamond.draw(ctx, bound); + break; + case 'ellipse': + shapeMethods.ellipse.draw(ctx, bound); + break; + case 'roundedRect': + drawRoundedRect(ctx, xywh); + break; + default: + throw new Error(`Unknown shape type: ${type}`); + } + + ctx.closePath(); + + ctx.fill(); + ctx.stroke(); +}; + +export abstract class Shape { + options: Options; + + shapeStyle: ShapeStyle; + + type: string; + + xywh: XYWH; + + constructor( + xywh: XYWH, + type: string, + options: Options, + shapeStyle: ShapeStyle + ) { + this.xywh = xywh; + this.type = type; + this.options = options; + this.shapeStyle = shapeStyle; + } + + abstract draw(ctx: CanvasRenderingContext2D, rc: RoughCanvas): void; +} + +export class RectShape extends Shape { + draw(ctx: CanvasRenderingContext2D, rc: RoughCanvas): void { + if (this.shapeStyle === 'Scribbled') { + const [x, y, w, h] = this.xywh; + rc.rectangle(x, y, w, h, this.options); + } else { + drawGeneralShape(ctx, 'rect', this.xywh, this.options); + } + } +} + +export class TriangleShape extends Shape { + draw(ctx: CanvasRenderingContext2D, rc: RoughCanvas): void { + if (this.shapeStyle === 'Scribbled') { + const [x, y, w, h] = this.xywh; + rc.polygon( + [ + [x + w / 2, y], + [x, y + h], + [x + w, y + h], + ], + this.options + ); + } else { + drawGeneralShape(ctx, 'triangle', this.xywh, this.options); + } + } +} + +export class DiamondShape extends Shape { + draw(ctx: CanvasRenderingContext2D, rc: RoughCanvas): void { + if (this.shapeStyle === 'Scribbled') { + const [x, y, w, h] = this.xywh; + rc.polygon( + [ + [x + w / 2, y], + [x + w, y + h / 2], + [x + w / 2, y + h], + [x, y + h / 2], + ], + this.options + ); + } else { + drawGeneralShape(ctx, 'diamond', this.xywh, this.options); + } + } +} + +export class EllipseShape extends Shape { + draw(ctx: CanvasRenderingContext2D, rc: RoughCanvas): void { + if (this.shapeStyle === 'Scribbled') { + const [x, y, w, h] = this.xywh; + rc.ellipse(x + w / 2, y + h / 2, w, h, this.options); + } else { + drawGeneralShape(ctx, 'ellipse', this.xywh, this.options); + } + } +} + +export class RoundedRectShape extends Shape { + draw(ctx: CanvasRenderingContext2D, rc: RoughCanvas): void { + if (this.shapeStyle === 'Scribbled') { + const [x, y, w, h] = this.xywh; + const radius = 0.1; + const r = Math.min(w * radius, h * radius); + const x0 = x + r; + const x1 = x + w - r; + const y0 = y + r; + const y1 = y + h - r; + const path = ` + M${x0},${y} L${x1},${y} + A${r},${r} 0 0 1 ${x1},${y0} + L${x1},${y1} + A${r},${r} 0 0 1 ${x1 - r},${y1} + L${x0 + r},${y1} + A${r},${r} 0 0 1 ${x0},${y1 - r} + L${x0},${y0} + A${r},${r} 0 0 1 ${x0 + r},${y} + `; + + rc.path(path, this.options); + } else { + drawGeneralShape(ctx, 'roundedRect', this.xywh, this.options); + } + } +} + +export class ShapeFactory { + static createShape( + xywh: XYWH, + type: string, + options: Options, + shapeStyle: ShapeStyle + ): Shape { + switch (type) { + case 'rect': + return new RectShape(xywh, type, options, shapeStyle); + case 'triangle': + return new TriangleShape(xywh, type, options, shapeStyle); + case 'diamond': + return new DiamondShape(xywh, type, options, shapeStyle); + case 'ellipse': + return new EllipseShape(xywh, type, options, shapeStyle); + case 'roundedRect': + return new RoundedRectShape(xywh, type, options, shapeStyle); + default: + throw new Error(`Unknown shape type: ${type}`); + } + } +} + +class ToolOverlay extends Overlay { + protected disposables = new DisposableGroup(); + + globalAlpha: number; + + x: number; + + y: number; + + constructor(gfx: GfxController) { + super(gfx); + this.x = 0; + this.y = 0; + this.globalAlpha = 0; + this.gfx = gfx; + this.disposables.add( + this.gfx.viewport.viewportUpdated.on(() => { + // when viewport is updated, we should keep the overlay in the same position + // to get last mouse position and convert it to model coordinates + const pos = this.gfx.tool.lastMousePos$.value; + const [x, y] = this.gfx.viewport.toModelCoord(pos.x, pos.y); + this.x = x; + this.y = y; + }) + ); + } + + override dispose(): void { + this.disposables.dispose(); + } + + render(_ctx: CanvasRenderingContext2D, _rc: RoughCanvas): void { + noop(); + } +} + +export class ShapeOverlay extends ToolOverlay { + shape: Shape; + + constructor( + gfx: GfxController, + type: string, + options: Options, + style: { + shapeStyle: ShapeStyle; + fillColor: Color; + strokeColor: Color; + } + ) { + super(gfx); + const xywh = [ + this.x, + this.y, + SHAPE_OVERLAY_WIDTH, + SHAPE_OVERLAY_HEIGHT, + ] as XYWH; + const { shapeStyle, fillColor, strokeColor } = style; + const fill = this.gfx.std + .get(ThemeProvider) + .getColorValue(fillColor, DEFAULT_SHAPE_FILL_COLOR, true); + const stroke = this.gfx.std + .get(ThemeProvider) + .getColorValue(strokeColor, DEFAULT_SHAPE_STROKE_COLOR, true); + + options.fill = fill; + options.stroke = stroke; + + this.shape = ShapeFactory.createShape(xywh, type, options, shapeStyle); + this.disposables.add( + effect(() => { + const currentTool = this.gfx.tool.currentTool$.value; + + if (currentTool?.toolName !== 'shape') return; + + assertType<ShapeTool>(currentTool); + + const { shapeName } = currentTool.activatedOption; + const newOptions = { + ...options, + }; + + let { x, y } = this; + if (shapeName === 'roundedRect' || shapeName === 'rect') { + x += SHAPE_OVERLAY_OFFSET_X; + y += SHAPE_OVERLAY_OFFSET_Y; + } + const w = + shapeName === 'roundedRect' + ? SHAPE_OVERLAY_WIDTH + 40 + : SHAPE_OVERLAY_WIDTH; + const xywh = [x, y, w, SHAPE_OVERLAY_HEIGHT] as XYWH; + this.shape = ShapeFactory.createShape( + xywh, + shapeName, + newOptions, + shapeStyle + ); + + (this.gfx.surfaceComponent as SurfaceBlockComponent).refresh(); + }) + ); + } + + override render(ctx: CanvasRenderingContext2D, rc: RoughCanvas): void { + ctx.globalAlpha = this.globalAlpha; + let { x, y } = this; + const { type } = this.shape; + if (type === 'roundedRect' || type === 'rect') { + x += SHAPE_OVERLAY_OFFSET_X; + y += SHAPE_OVERLAY_OFFSET_Y; + } + const w = + type === 'roundedRect' ? SHAPE_OVERLAY_WIDTH + 40 : SHAPE_OVERLAY_WIDTH; + const xywh = [x, y, w, SHAPE_OVERLAY_HEIGHT] as XYWH; + this.shape.xywh = xywh; + this.shape.draw(ctx, rc); + } +} + +export class NoteOverlay extends ToolOverlay { + backgroundColor = 'transparent'; + + text = ''; + + constructor(gfx: GfxController, background: Color) { + super(gfx); + this.globalAlpha = 0; + this.backgroundColor = gfx.std + .get(ThemeProvider) + .getColorValue(background, DEFAULT_NOTE_BACKGROUND_COLOR, true); + this.disposables.add( + effect(() => { + // when change note child type, update overlay text + if (this.gfx.tool.currentToolName$.value !== 'affine:note') return; + const tool = + this.gfx.tool.currentTool$.peek() as GfxToolsMap['affine:note']; + this.text = this._getOverlayText(tool.activatedOption.tip); + (this.gfx.surfaceComponent as SurfaceBlockComponent).refresh(); + }) + ); + } + + private _getOverlayText(text: string): string { + return text[0].toUpperCase() + text.slice(1); + } + + override render(ctx: CanvasRenderingContext2D): void { + ctx.globalAlpha = this.globalAlpha; + const overlayX = this.x + NOTE_OVERLAY_OFFSET_X; + const overlayY = this.y + NOTE_OVERLAY_OFFSET_Y; + ctx.strokeStyle = this.gfx.std + .get(ThemeProvider) + .getCssVariableColor(NOTE_OVERLAY_STOKE_COLOR); + // Draw the overlay rectangle + ctx.fillStyle = this.backgroundColor; + ctx.lineWidth = 4; + ctx.beginPath(); + ctx.moveTo(overlayX + NOTE_OVERLAY_CORNER_RADIUS, overlayY); + ctx.lineTo( + overlayX + NOTE_OVERLAY_WIDTH - NOTE_OVERLAY_CORNER_RADIUS, + overlayY + ); + ctx.quadraticCurveTo( + overlayX + NOTE_OVERLAY_WIDTH, + overlayY, + overlayX + NOTE_OVERLAY_WIDTH, + overlayY + NOTE_OVERLAY_CORNER_RADIUS + ); + ctx.lineTo( + overlayX + NOTE_OVERLAY_WIDTH, + overlayY + NOTE_OVERLAY_HEIGHT - NOTE_OVERLAY_CORNER_RADIUS + ); + ctx.quadraticCurveTo( + overlayX + NOTE_OVERLAY_WIDTH, + overlayY + NOTE_OVERLAY_HEIGHT, + overlayX + NOTE_OVERLAY_WIDTH - NOTE_OVERLAY_CORNER_RADIUS, + overlayY + NOTE_OVERLAY_HEIGHT + ); + ctx.lineTo( + overlayX + NOTE_OVERLAY_CORNER_RADIUS, + overlayY + NOTE_OVERLAY_HEIGHT + ); + ctx.quadraticCurveTo( + overlayX, + overlayY + NOTE_OVERLAY_HEIGHT, + overlayX, + overlayY + NOTE_OVERLAY_HEIGHT - NOTE_OVERLAY_CORNER_RADIUS + ); + ctx.lineTo(overlayX, overlayY + NOTE_OVERLAY_CORNER_RADIUS); + ctx.quadraticCurveTo( + overlayX, + overlayY, + overlayX + NOTE_OVERLAY_CORNER_RADIUS, + overlayY + ); + ctx.closePath(); + ctx.stroke(); + ctx.fill(); + + // Draw the overlay text + ctx.fillStyle = this.gfx.std + .get(ThemeProvider) + .getCssVariableColor(NOTE_OVERLAY_TEXT_COLOR); + let fontSize = 16; + ctx.font = `${fontSize}px Arial`; + ctx.textAlign = 'left'; + ctx.textBaseline = 'middle'; + + // measure the width of the text + // if the text is wider than the rectangle, reduce the maximum width of the text + while (ctx.measureText(this.text).width > NOTE_OVERLAY_WIDTH - 20) { + fontSize -= 1; + ctx.font = `${fontSize}px Arial`; + } + + ctx.fillText(this.text, overlayX + 10, overlayY + NOTE_OVERLAY_HEIGHT / 2); + } +} + +export class DraggingNoteOverlay extends NoteOverlay { + height: number; + + slots: { + draggingNoteUpdated: Slot<{ xywh: XYWH }>; + }; + + width: number; + + constructor(gfx: GfxController, background: Color) { + super(gfx, background); + this.slots = { + draggingNoteUpdated: new Slot<{ + xywh: XYWH; + }>(), + }; + this.width = 0; + this.height = 0; + this.disposables.add( + this.slots.draggingNoteUpdated.on(({ xywh }) => { + [this.x, this.y, this.width, this.height] = xywh; + (this.gfx.surfaceComponent as SurfaceBlockComponent).refresh(); + }) + ); + } + + override render(ctx: CanvasRenderingContext2D): void { + // draw a rounded rectangle with provided background color and xywh + ctx.globalAlpha = 0.8; + ctx.fillStyle = this.backgroundColor; + ctx.strokeStyle = 'rgba(0, 0, 0, 0.10)'; + ctx.lineWidth = 2; + ctx.beginPath(); + ctx.roundRect(this.x, this.y, this.width, this.height, 4); + ctx.closePath(); + ctx.fill(); + ctx.stroke(); + } +} diff --git a/blocksuite/blocks/src/root-block/edgeless/utils/viewport.ts b/blocksuite/blocks/src/root-block/edgeless/utils/viewport.ts new file mode 100644 index 0000000000..324fa4d58f --- /dev/null +++ b/blocksuite/blocks/src/root-block/edgeless/utils/viewport.ts @@ -0,0 +1,28 @@ +import type { GfxModel, Viewport } from '@blocksuite/block-std/gfx'; +import { Bound, getCommonBound } from '@blocksuite/global/utils'; + +import { FIT_TO_SCREEN_PADDING } from './consts.js'; +import { ZOOM_INITIAL } from './zoom.js'; + +export function fitToScreen( + elements: GfxModel[], + viewport: Viewport, + options: { + padding?: [number, number, number, number]; + smooth?: boolean; + } = { + padding: [0, 0, 0, 0], + smooth: true, + } +) { + const elemBounds = elements.map(element => Bound.deserialize(element.xywh)); + const commonBound = getCommonBound(elemBounds); + const { zoom, centerX, centerY } = viewport.getFitToScreenData( + commonBound, + options.padding, + ZOOM_INITIAL, + FIT_TO_SCREEN_PADDING + ); + + viewport.setViewport(zoom, [centerX, centerY], options.smooth); +} diff --git a/blocksuite/blocks/src/root-block/edgeless/utils/zoom.ts b/blocksuite/blocks/src/root-block/edgeless/utils/zoom.ts new file mode 100644 index 0000000000..03e0fdc88d --- /dev/null +++ b/blocksuite/blocks/src/root-block/edgeless/utils/zoom.ts @@ -0,0 +1,5 @@ +export type ZoomAction = 'fit' | 'out' | 'reset' | 'in'; +export const ZOOM_MAX = 6.0; +export const ZOOM_MIN = 0.1; +export const ZOOM_STEP = 0.25; +export const ZOOM_INITIAL = 1.0; diff --git a/blocksuite/blocks/src/root-block/index.ts b/blocksuite/blocks/src/root-block/index.ts new file mode 100644 index 0000000000..bd946ea5f4 --- /dev/null +++ b/blocksuite/blocks/src/root-block/index.ts @@ -0,0 +1,16 @@ +export * from './adapters/markdown.js'; +export * from './clipboard/index.js'; +export * from './configs/index.js'; +export * from './edgeless/edgeless-root-spec.js'; +export * from './edgeless/index.js'; +export { TemplateJob } from './edgeless/services/template.js'; +export * as TemplateMiddlewares from './edgeless/services/template-middlewares.js'; +export * from './page/page-root-block.js'; +export { PageRootService } from './page/page-root-service.js'; +export * from './page/page-root-spec.js'; +export * from './preview/preview-root-block.js'; +export * from './root-config.js'; +export { RootService } from './root-service.js'; +export * from './types.js'; +export * from './utils/index.js'; +export * from './widgets/index.js'; diff --git a/blocksuite/blocks/src/root-block/keyboard/keyboard-manager.ts b/blocksuite/blocks/src/root-block/keyboard/keyboard-manager.ts new file mode 100644 index 0000000000..04e4fcb84a --- /dev/null +++ b/blocksuite/blocks/src/root-block/keyboard/keyboard-manager.ts @@ -0,0 +1,171 @@ +import { matchFlavours } from '@blocksuite/affine-shared/utils'; +import type { BlockComponent, BlockSelection } from '@blocksuite/block-std'; +import { IS_MAC, IS_WINDOWS } from '@blocksuite/global/env'; +import { assertExists } from '@blocksuite/global/utils'; + +import { + convertSelectedBlocksToLinkedDoc, + getTitleFromSelectedModels, + notifyDocCreated, + promptDocTitle, +} from '../../_common/utils/render-linked-doc.js'; + +export class PageKeyboardManager { + private _handleDelete = () => { + const blockSelections = this._currentSelection.filter(sel => + sel.is('block') + ); + if (blockSelections.length === 0) { + return; + } + + this._doc.transact(() => { + const selection = this._replaceBlocksBySelection( + blockSelections, + 'affine:paragraph', + {} + ); + + if (selection) { + this._selection.setGroup('note', [ + this._selection.create('text', { + from: { + index: 0, + length: 0, + blockId: selection.blockId, + }, + to: null, + }), + ]); + } + }); + }; + + private get _currentSelection() { + return this._selection.value; + } + + private get _doc() { + return this.rootComponent.doc; + } + + private get _selection() { + return this.rootComponent.host.selection; + } + + constructor(public rootComponent: BlockComponent) { + this.rootComponent.bindHotKey( + { + 'Mod-z': ctx => { + ctx.get('defaultState').event.preventDefault(); + + if (this._doc.canUndo) { + this._doc.undo(); + } + }, + 'Shift-Mod-z': ctx => { + ctx.get('defaultState').event.preventDefault(); + if (this._doc.canRedo) { + this._doc.redo(); + } + }, + 'Control-y': ctx => { + if (!IS_WINDOWS) return; + + ctx.get('defaultState').event.preventDefault(); + if (this._doc.canRedo) { + this._doc.redo(); + } + }, + 'Mod-Backspace': () => true, + Backspace: this._handleDelete, + Delete: this._handleDelete, + 'Control-d': () => { + if (!IS_MAC) return; + this._handleDelete(); + }, + 'Mod-Shift-l': () => { + this._createEmbedBlock(); + }, + }, + { + global: true, + } + ); + } + + private _createEmbedBlock() { + const rootComponent = this.rootComponent; + const [_, ctx] = this.rootComponent.std.command + .chain() + .getSelectedModels({ + types: ['block'], + mode: 'highest', + }) + .draftSelectedModels() + .run(); + const selectedModels = ctx.selectedModels?.filter( + block => + !block.flavour.startsWith('affine:embed-') && + matchFlavours(doc.getParent(block), ['affine:note']) + ); + + const draftedModels = ctx.draftedModels; + if (!selectedModels?.length || !draftedModels) { + return; + } + + const doc = rootComponent.host.doc; + const autofill = getTitleFromSelectedModels(selectedModels); + void promptDocTitle(rootComponent.host, autofill).then(title => { + if (title === null) return; + convertSelectedBlocksToLinkedDoc( + this.rootComponent.std, + doc, + draftedModels, + title + ).catch(console.error); + notifyDocCreated(rootComponent.host, doc); + }); + } + + private _deleteBlocksBySelection(selections: BlockSelection[]) { + selections.forEach(selection => { + const block = this._doc.getBlockById(selection.blockId); + if (block) { + this._doc.deleteBlock(block); + } + }); + } + + private _replaceBlocksBySelection( + selections: BlockSelection[], + flavour: string, + props: Record<string, unknown> + ) { + const current = selections[0]; + const first = this._doc.getBlockById(current.blockId); + const firstElement = this.rootComponent.host.view.getBlock(current.blockId); + + assertExists(first, `Cannot find block ${current.blockId}`); + assertExists(firstElement, `Cannot find block view ${current.blockId}`); + + const parent = this._doc.getParent(first); + const index = parent?.children.indexOf(first); + + this._deleteBlocksBySelection(selections); + + try { + this._doc.schema.validate(flavour, parent?.flavour); + } catch { + return null; + } + + const blockId = this._doc.addBlock(flavour as never, props, parent, index); + + return { + blockId, + path: blockId, + }; + } +} diff --git a/blocksuite/blocks/src/root-block/page/page-root-block.ts b/blocksuite/blocks/src/root-block/page/page-root-block.ts new file mode 100644 index 0000000000..ff9487d82b --- /dev/null +++ b/blocksuite/blocks/src/root-block/page/page-root-block.ts @@ -0,0 +1,431 @@ +import { focusTextModel } from '@blocksuite/affine-components/rich-text'; +import type { NoteBlockModel, RootBlockModel } from '@blocksuite/affine-model'; +import { NoteDisplayMode } from '@blocksuite/affine-model'; +import type { Viewport } from '@blocksuite/affine-shared/types'; +import { + focusTitle, + getDocTitleInlineEditor, + getScrollContainer, + matchFlavours, +} from '@blocksuite/affine-shared/utils'; +import type { PointerEventState } from '@blocksuite/block-std'; +import { BlockComponent } from '@blocksuite/block-std'; +import type { BlockModel, Text } from '@blocksuite/store'; +import { css, html } from 'lit'; +import { query } from 'lit/decorators.js'; +import { repeat } from 'lit/directives/repeat.js'; + +import { buildPath } from '../../_common/utils/index.js'; +import { PageClipboard } from '../clipboard/index.js'; +import type { PageRootBlockWidgetName } from '../index.js'; +import { PageKeyboardManager } from '../keyboard/keyboard-manager.js'; +import type { PageRootService } from './page-root-service.js'; + +const DOC_BLOCK_CHILD_PADDING = 24; +const DOC_BOTTOM_PADDING = 32; + +function testClickOnBlankArea( + state: PointerEventState, + viewportLeft: number, + viewportWidth: number, + pageWidth: number, + paddingLeft: number, + paddingRight: number +) { + const blankLeft = + viewportLeft + (viewportWidth - pageWidth) / 2 + paddingLeft; + const blankRight = + viewportLeft + (viewportWidth - pageWidth) / 2 + pageWidth - paddingRight; + + if (state.raw.clientX < blankLeft || state.raw.clientX > blankRight) { + return true; + } + + return false; +} + +export class PageRootBlockComponent extends BlockComponent< + RootBlockModel, + PageRootService, + PageRootBlockWidgetName +> { + static override styles = css` + editor-host:has(> affine-page-root, * > affine-page-root) { + display: block; + height: 100%; + } + + affine-page-root { + display: block; + height: 100%; + cursor: default; + } + + .affine-page-root-block-container { + display: flex; + flex-direction: column; + width: 100%; + height: 100%; + font-family: var(--affine-font-family); + font-size: var(--affine-font-base); + line-height: var(--affine-line-height); + color: var(--affine-text-primary-color); + font-weight: 400; + max-width: var(--affine-editor-width); + margin: 0 auto; + + /* Leave a place for drag-handle */ + /* Do not use prettier format this style, or it will be broken */ + /* prettier-ignore */ + padding-left: var(--affine-editor-side-padding, ${DOC_BLOCK_CHILD_PADDING}px); + /* prettier-ignore */ + padding-right: var(--affine-editor-side-padding, ${DOC_BLOCK_CHILD_PADDING}px); + /* prettier-ignore */ + padding-bottom: var(--affine-editor-bottom-padding, ${DOC_BOTTOM_PADDING}px); + } + + /* Extra small devices (phones, 640px and down) */ + @container viewport (width <= 640px) { + .affine-page-root-block-container { + padding-left: ${DOC_BLOCK_CHILD_PADDING}px; + padding-right: ${DOC_BLOCK_CHILD_PADDING}px; + } + } + + .affine-block-element { + display: block; + } + + @media print { + .selected { + background-color: transparent !important; + } + } + `; + + private _viewportElement: HTMLDivElement | null = null; + + clipboardController = new PageClipboard(this); + + focusFirstParagraph = () => { + const defaultNote = this._getDefaultNoteBlock(); + const firstText = defaultNote?.children.find(block => + matchFlavours(block, ['affine:paragraph', 'affine:list', 'affine:code']) + ); + if (firstText) { + focusTextModel(this.std, firstText.id); + } else { + const newFirstParagraphId = this.doc.addBlock( + 'affine:paragraph', + {}, + defaultNote, + 0 + ); + focusTextModel(this.std, newFirstParagraphId); + } + }; + + keyboardManager: PageKeyboardManager | null = null; + + prependParagraphWithText = (text: Text) => { + const newFirstParagraphId = this.doc.addBlock( + 'affine:paragraph', + { text }, + this._getDefaultNoteBlock(), + 0 + ); + focusTextModel(this.std, newFirstParagraphId); + }; + + get rootScrollContainer() { + return getScrollContainer(this); + } + + get slots() { + return this.service.slots; + } + + get viewport(): Viewport | null { + if (!this.viewportElement) { + return null; + } + const { + scrollLeft, + scrollTop, + scrollWidth, + scrollHeight, + clientWidth, + clientHeight, + } = this.viewportElement; + const { top, left } = this.viewportElement.getBoundingClientRect(); + return { + top, + left, + scrollLeft, + scrollTop, + scrollWidth, + scrollHeight, + clientWidth, + clientHeight, + }; + } + + get viewportElement(): HTMLDivElement | null { + if (this._viewportElement) return this._viewportElement; + this._viewportElement = this.host.closest( + '.affine-page-viewport' + ) as HTMLDivElement | null; + return this._viewportElement; + } + + private _createDefaultNoteBlock() { + const { doc } = this; + + const noteId = doc.addBlock('affine:note', {}, doc.root?.id); + return doc.getBlockById(noteId) as NoteBlockModel; + } + + private _getDefaultNoteBlock() { + return ( + this.doc.root?.children.find(child => child.flavour === 'affine:note') ?? + this._createDefaultNoteBlock() + ); + } + + private _initViewportResizeEffect() { + const viewport = this.viewport; + const viewportElement = this.viewportElement; + if (!viewport || !viewportElement) { + return; + } + // when observe viewportElement resize, emit viewport update event + const resizeObserver = new ResizeObserver( + (entries: ResizeObserverEntry[]) => { + for (const { target } of entries) { + if (target === viewportElement) { + this.slots.viewportUpdated.emit(viewport); + break; + } + } + } + ); + resizeObserver.observe(viewportElement); + this.disposables.add(() => { + resizeObserver.unobserve(viewportElement); + resizeObserver.disconnect(); + }); + } + + override connectedCallback() { + super.connectedCallback(); + this.clipboardController.hostConnected(); + + this.keyboardManager = new PageKeyboardManager(this); + + this.bindHotKey({ + 'Mod-a': () => { + const blocks = this.model.children + .filter(model => { + if (matchFlavours(model, ['affine:note'])) { + const note = model as NoteBlockModel; + if (note.displayMode === NoteDisplayMode.EdgelessOnly) + return false; + + return true; + } + return false; + }) + .flatMap(model => { + return model.children.map(child => { + return this.std.selection.create('block', { + blockId: child.id, + }); + }); + }); + this.std.selection.setGroup('note', blocks); + return true; + }, + ArrowUp: () => { + const selection = this.host.selection; + const sel = selection.value.find( + sel => sel.is('text') || sel.is('block') + ); + if (!sel) return; + let model: BlockModel | null = null; + let path: string[] = buildPath(this.doc.getBlockById(sel.blockId)); + while (path.length > 0 && !model) { + const m = this.doc.getBlockById(path[path.length - 1]); + if (m && m.flavour === 'affine:note') { + model = m; + } + path = path.slice(0, -1); + } + if (!model) return; + const prevNote = this.doc.getPrev(model); + if (!prevNote || prevNote.flavour !== 'affine:note') { + const isFirstText = sel.is('text') && sel.start.index === 0; + const isBlock = sel.is('block'); + if (isBlock || isFirstText) { + focusTitle(this.host); + } + return; + } + const notes = this.doc.getBlockByFlavour('affine:note'); + const index = notes.indexOf(prevNote); + if (index !== 0) return; + + const range = this.std.range.value; + requestAnimationFrame(() => { + const currentRange = this.std.range.value; + + if (!range || !currentRange) return; + + // If the range has not changed, it means we need to manually move the cursor to the title. + if ( + range.startContainer === currentRange.startContainer && + range.startOffset === currentRange.startOffset && + range.endContainer === currentRange.endContainer && + range.endOffset === currentRange.endOffset + ) { + const titleInlineEditor = getDocTitleInlineEditor(this.host); + if (titleInlineEditor) { + titleInlineEditor.focusEnd(); + } + } + }); + }, + }); + + this.handleEvent('click', ctx => { + const event = ctx.get('pointerState'); + if ( + event.raw.target !== this && + event.raw.target !== this.viewportElement && + event.raw.target !== this.rootElementContainer + ) { + return; + } + + const { paddingLeft, paddingRight } = window.getComputedStyle( + this.rootElementContainer + ); + if (!this.viewport) return; + const isClickOnBlankArea = testClickOnBlankArea( + event, + this.viewport.left, + this.viewport.clientWidth, + this.rootElementContainer.clientWidth, + parseFloat(paddingLeft), + parseFloat(paddingRight) + ); + if (isClickOnBlankArea) { + this.host.selection.clear(['block']); + return; + } + + let newTextSelectionId: string | null = null; + const readonly = this.doc.readonly; + const lastNote = this.model.children + .slice() + .reverse() + .find(child => { + const isNote = matchFlavours(child, ['affine:note']); + if (!isNote) return false; + const note = child as NoteBlockModel; + const displayOnDoc = + !!note.displayMode && + note.displayMode !== NoteDisplayMode.EdgelessOnly; + return displayOnDoc; + }); + if (!lastNote) { + if (readonly) return; + const noteId = this.doc.addBlock('affine:note', {}, this.model.id); + const paragraphId = this.doc.addBlock('affine:paragraph', {}, noteId); + newTextSelectionId = paragraphId; + } else { + const last = lastNote.children.at(-1); + if ( + !last || + !(matchFlavours(last, ['affine:paragraph']) && last.text.length === 0) + ) { + if (readonly) return; + const paragraphId = this.doc.addBlock( + 'affine:paragraph', + {}, + lastNote.id + ); + newTextSelectionId = paragraphId; + } + } + + this.updateComplete + .then(() => { + if (!newTextSelectionId) return; + this.host.selection.setGroup('note', [ + this.host.selection.create('text', { + from: { + blockId: newTextSelectionId, + index: 0, + length: 0, + }, + to: null, + }), + ]); + }) + .catch(console.error); + }); + } + + override disconnectedCallback() { + super.disconnectedCallback(); + this.clipboardController.hostDisconnected(); + this._disposables.dispose(); + this.keyboardManager = null; + } + + override firstUpdated() { + this._initViewportResizeEffect(); + const noteModels = this.model.children.filter(model => + matchFlavours(model, ['affine:note']) + ); + noteModels.forEach(note => { + this.disposables.add( + note.propsUpdated.on(({ key }) => { + if (key === 'displayMode') { + this.requestUpdate(); + } + }) + ); + }); + } + + override renderBlock() { + const widgets = html`${repeat( + Object.entries(this.widgets), + ([id]) => id, + ([_, widget]) => widget + )}`; + + const children = this.renderChildren(this.model, child => { + const isNote = matchFlavours(child, ['affine:note']); + const note = child as NoteBlockModel; + const displayOnEdgeless = + !!note.displayMode && note.displayMode === NoteDisplayMode.EdgelessOnly; + // Should remove deprecated `hidden` property in the future + return !(isNote && displayOnEdgeless); + }); + + return html` + <div class="affine-page-root-block-container">${children} ${widgets}</div> + `; + } + + @query('.affine-page-root-block-container') + accessor rootElementContainer!: HTMLDivElement; +} + +declare global { + interface HTMLElementTagNameMap { + 'affine-page-root': PageRootBlockComponent; + } +} diff --git a/blocksuite/blocks/src/root-block/page/page-root-service.ts b/blocksuite/blocks/src/root-block/page/page-root-service.ts new file mode 100644 index 0000000000..6b70c343d6 --- /dev/null +++ b/blocksuite/blocks/src/root-block/page/page-root-service.ts @@ -0,0 +1,13 @@ +import { RootBlockSchema } from '@blocksuite/affine-model'; +import { Slot } from '@blocksuite/store'; + +import type { Viewport } from '../../_common/utils/index.js'; +import { RootService } from '../root-service.js'; + +export class PageRootService extends RootService { + static override readonly flavour = RootBlockSchema.model.flavour; + + slots = { + viewportUpdated: new Slot<Viewport>(), + }; +} diff --git a/blocksuite/blocks/src/root-block/page/page-root-spec.ts b/blocksuite/blocks/src/root-block/page/page-root-spec.ts new file mode 100644 index 0000000000..565c5915dc --- /dev/null +++ b/blocksuite/blocks/src/root-block/page/page-root-spec.ts @@ -0,0 +1,76 @@ +import { + DNDAPIExtension, + DocDisplayMetaService, + DocModeService, + EmbedOptionService, + ThemeService, +} from '@blocksuite/affine-shared/services'; +import { AFFINE_SCROLL_ANCHORING_WIDGET } from '@blocksuite/affine-widget-scroll-anchoring'; +import { + BlockViewExtension, + CommandExtension, + type ExtensionType, + FlavourExtension, + WidgetViewMapExtension, +} from '@blocksuite/block-std'; +import { literal, unsafeStatic } from 'lit/static-html.js'; + +import { ExportManagerExtension } from '../../_common/export-manager/export-manager.js'; +import { commands } from '../commands/index.js'; +import { AFFINE_DOC_REMOTE_SELECTION_WIDGET } from '../widgets/doc-remote-selection/doc-remote-selection.js'; +import { AFFINE_DRAG_HANDLE_WIDGET } from '../widgets/drag-handle/consts.js'; +import { AFFINE_EMBED_CARD_TOOLBAR_WIDGET } from '../widgets/embed-card-toolbar/embed-card-toolbar.js'; +import { AFFINE_FORMAT_BAR_WIDGET } from '../widgets/format-bar/format-bar.js'; +import { AFFINE_INNER_MODAL_WIDGET } from '../widgets/inner-modal/inner-modal.js'; +import { AFFINE_KEYBOARD_TOOLBAR_WIDGET } from '../widgets/keyboard-toolbar/index.js'; +import { AFFINE_LINKED_DOC_WIDGET } from '../widgets/linked-doc/index.js'; +import { AFFINE_MODAL_WIDGET } from '../widgets/modal/modal.js'; +import { AFFINE_PAGE_DRAGGING_AREA_WIDGET } from '../widgets/page-dragging-area/page-dragging-area.js'; +import { AFFINE_SLASH_MENU_WIDGET } from '../widgets/slash-menu/index.js'; +import { AFFINE_VIEWPORT_OVERLAY_WIDGET } from '../widgets/viewport-overlay/viewport-overlay.js'; +import { PageRootService } from './page-root-service.js'; + +export const pageRootWidgetViewMap = { + [AFFINE_KEYBOARD_TOOLBAR_WIDGET]: literal`${unsafeStatic(AFFINE_KEYBOARD_TOOLBAR_WIDGET)}`, + [AFFINE_MODAL_WIDGET]: literal`${unsafeStatic(AFFINE_MODAL_WIDGET)}`, + [AFFINE_INNER_MODAL_WIDGET]: literal`${unsafeStatic(AFFINE_INNER_MODAL_WIDGET)}`, + [AFFINE_SLASH_MENU_WIDGET]: literal`${unsafeStatic( + AFFINE_SLASH_MENU_WIDGET + )}`, + [AFFINE_LINKED_DOC_WIDGET]: literal`${unsafeStatic( + AFFINE_LINKED_DOC_WIDGET + )}`, + [AFFINE_DRAG_HANDLE_WIDGET]: literal`${unsafeStatic( + AFFINE_DRAG_HANDLE_WIDGET + )}`, + [AFFINE_EMBED_CARD_TOOLBAR_WIDGET]: literal`${unsafeStatic( + AFFINE_EMBED_CARD_TOOLBAR_WIDGET + )}`, + [AFFINE_FORMAT_BAR_WIDGET]: literal`${unsafeStatic( + AFFINE_FORMAT_BAR_WIDGET + )}`, + [AFFINE_DOC_REMOTE_SELECTION_WIDGET]: literal`${unsafeStatic( + AFFINE_DOC_REMOTE_SELECTION_WIDGET + )}`, + [AFFINE_PAGE_DRAGGING_AREA_WIDGET]: literal`${unsafeStatic( + AFFINE_PAGE_DRAGGING_AREA_WIDGET + )}`, + [AFFINE_VIEWPORT_OVERLAY_WIDGET]: literal`${unsafeStatic( + AFFINE_VIEWPORT_OVERLAY_WIDGET + )}`, + [AFFINE_SCROLL_ANCHORING_WIDGET]: literal`${unsafeStatic(AFFINE_SCROLL_ANCHORING_WIDGET)}`, +}; + +export const PageRootBlockSpec: ExtensionType[] = [ + FlavourExtension('affine:page'), + PageRootService, + DocModeService, + ThemeService, + EmbedOptionService, + CommandExtension(commands), + BlockViewExtension('affine:page', literal`affine-page-root`), + WidgetViewMapExtension('affine:page', pageRootWidgetViewMap), + ExportManagerExtension, + DNDAPIExtension, + DocDisplayMetaService, +]; diff --git a/blocksuite/blocks/src/root-block/preview/preview-root-block.ts b/blocksuite/blocks/src/root-block/preview/preview-root-block.ts new file mode 100644 index 0000000000..5db1df59f0 --- /dev/null +++ b/blocksuite/blocks/src/root-block/preview/preview-root-block.ts @@ -0,0 +1,17 @@ +// import { PageRootBlockComponent } from '../page/page-root-block.js'; +import { BlockComponent } from '@blocksuite/block-std'; +import { css, html } from 'lit'; + +export class PreviewRootBlockComponent extends BlockComponent { + static override styles = css` + affine-preview-root { + display: block; + } + `; + + override renderBlock() { + return html`<div class="affine-preview-root"> + ${this.host.renderChildren(this.model)} + </div>`; + } +} diff --git a/blocksuite/blocks/src/root-block/preview/preview-root-spec.ts b/blocksuite/blocks/src/root-block/preview/preview-root-spec.ts new file mode 100644 index 0000000000..aef6221a41 --- /dev/null +++ b/blocksuite/blocks/src/root-block/preview/preview-root-spec.ts @@ -0,0 +1,14 @@ +import { + BlockViewExtension, + type ExtensionType, + FlavourExtension, +} from '@blocksuite/block-std'; +import { literal } from 'lit/static-html.js'; + +import { PageRootService } from '../page/page-root-service.js'; + +export const PreviewPageSpec: ExtensionType[] = [ + FlavourExtension('affine:page'), + PageRootService, + BlockViewExtension('affine:page', literal`affine-preview-root`), +]; diff --git a/blocksuite/blocks/src/root-block/remote-color-manager/color-picker.ts b/blocksuite/blocks/src/root-block/remote-color-manager/color-picker.ts new file mode 100644 index 0000000000..e20e8f7307 --- /dev/null +++ b/blocksuite/blocks/src/root-block/remote-color-manager/color-picker.ts @@ -0,0 +1,36 @@ +class RandomPicker<T> { + private _copyArray: T[]; + + private _originalArray: T[]; + + constructor(array: T[]) { + this._originalArray = [...array]; + this._copyArray = [...array]; + } + + private randomIndex(max: number): number { + return Math.floor(Math.random() * max); + } + + pick(): T { + if (this._copyArray.length === 0) { + this._copyArray = [...this._originalArray]; + } + + const index = this.randomIndex(this._copyArray.length); + const item = this._copyArray[index]; + this._copyArray.splice(index, 1); + return item; + } +} + +export const multiPlayersColor = new RandomPicker([ + 'var(--affine-multi-players-purple)', + 'var(--affine-multi-players-magenta)', + 'var(--affine-multi-players-red)', + 'var(--affine-multi-players-orange)', + 'var(--affine-multi-players-green)', + 'var(--affine-multi-players-blue)', + 'var(--affine-multi-players-brown)', + 'var(--affine-multi-players-grey)', +]); diff --git a/blocksuite/blocks/src/root-block/remote-color-manager/remote-color-manager.ts b/blocksuite/blocks/src/root-block/remote-color-manager/remote-color-manager.ts new file mode 100644 index 0000000000..41cfd03635 --- /dev/null +++ b/blocksuite/blocks/src/root-block/remote-color-manager/remote-color-manager.ts @@ -0,0 +1,42 @@ +import { EditPropsStore } from '@blocksuite/affine-shared/services'; +import type { BlockStdScope } from '@blocksuite/block-std'; + +import { multiPlayersColor } from './color-picker.js'; + +export class RemoteColorManager { + private get awarenessStore() { + return this.std.doc.collection.awarenessStore; + } + + constructor(readonly std: BlockStdScope) { + const sessionColor = this.std.get(EditPropsStore).getStorage('remoteColor'); + if (sessionColor) { + this.awarenessStore.awareness.setLocalStateField('color', sessionColor); + return; + } + + const pickColor = multiPlayersColor.pick(); + this.awarenessStore.awareness.setLocalStateField('color', pickColor); + this.std.get(EditPropsStore).setStorage('remoteColor', pickColor); + } + + get(id: number) { + const awarenessColor = this.awarenessStore.getStates().get(id)?.color; + if (awarenessColor) { + return awarenessColor; + } + + if (id !== this.awarenessStore.awareness.clientID) return null; + + const sessionColor = this.std.get(EditPropsStore).getStorage('remoteColor'); + if (sessionColor) { + this.awarenessStore.awareness.setLocalStateField('color', sessionColor); + return sessionColor; + } + + const pickColor = multiPlayersColor.pick(); + this.awarenessStore.awareness.setLocalStateField('color', pickColor); + this.std.get(EditPropsStore).setStorage('remoteColor', pickColor); + return pickColor; + } +} diff --git a/blocksuite/blocks/src/root-block/root-config.ts b/blocksuite/blocks/src/root-block/root-config.ts new file mode 100644 index 0000000000..813535c38e --- /dev/null +++ b/blocksuite/blocks/src/root-block/root-config.ts @@ -0,0 +1,13 @@ +import type { DatabaseOptionsConfig } from '../database-block/config.js'; +import type { ToolbarMoreMenuConfig } from './configs/index.js'; +import type { DocRemoteSelectionConfig } from './widgets/doc-remote-selection/config.js'; +import type { KeyboardToolbarConfig } from './widgets/keyboard-toolbar/config.js'; +import type { LinkedWidgetConfig } from './widgets/linked-doc/index.js'; + +export interface RootBlockConfig { + linkedWidget?: Partial<LinkedWidgetConfig>; + docRemoteSelectionWidget?: Partial<DocRemoteSelectionConfig>; + toolbarMoreMenu?: Partial<ToolbarMoreMenuConfig>; + databaseOptions?: Partial<DatabaseOptionsConfig>; + keyboardToolbar?: Partial<KeyboardToolbarConfig>; +} diff --git a/blocksuite/blocks/src/root-block/root-service.ts b/blocksuite/blocks/src/root-block/root-service.ts new file mode 100644 index 0000000000..df6bc81f02 --- /dev/null +++ b/blocksuite/blocks/src/root-block/root-service.ts @@ -0,0 +1,86 @@ +import { RootBlockSchema } from '@blocksuite/affine-model'; +import type { BlockComponent } from '@blocksuite/block-std'; +import { BlockService } from '@blocksuite/block-std'; + +import { + FileDropManager, + type FileDropOptions, +} from '../_common/components/file-drop-manager.js'; +import { + HtmlTransformer, + MarkdownTransformer, + ZipTransformer, +} from '../_common/transformers/index.js'; +import type { RootBlockComponent } from './types.js'; + +export abstract class RootService extends BlockService { + static override readonly flavour = RootBlockSchema.model.flavour; + + private _fileDropOptions: FileDropOptions = { + flavour: this.flavour, + }; + + readonly fileDropManager = new FileDropManager(this, this._fileDropOptions); + + transformers = { + markdown: MarkdownTransformer, + html: HtmlTransformer, + zip: ZipTransformer, + }; + + get selectedBlocks() { + let result: BlockComponent[] = []; + this.std.command + .chain() + .tryAll(chain => [ + chain.getTextSelection(), + chain.getImageSelections(), + chain.getBlockSelections(), + ]) + .getSelectedBlocks() + .inline(({ selectedBlocks }) => { + if (!selectedBlocks) return; + result = selectedBlocks; + }) + .run(); + return result; + } + + get selectedModels() { + return this.selectedBlocks.map(block => block.model); + } + + get viewportElement() { + const rootId = this.std.doc.root?.id; + if (!rootId) return null; + const rootComponent = this.std.view.getBlock( + rootId + ) as RootBlockComponent | null; + if (!rootComponent) return null; + const viewportElement = rootComponent.viewportElement; + return viewportElement; + } + + override mounted() { + super.mounted(); + + this.disposables.addFromEvent( + this.host, + 'dragover', + this.fileDropManager.onDragOver + ); + + this.disposables.addFromEvent( + this.host, + 'dragleave', + this.fileDropManager.onDragLeave + ); + + this.disposables.add( + this.std.event.add('pointerDown', ctx => { + const state = ctx.get('pointerState'); + state.raw.stopPropagation(); + }) + ); + } +} diff --git a/blocksuite/blocks/src/root-block/text-selection/utils.ts b/blocksuite/blocks/src/root-block/text-selection/utils.ts new file mode 100644 index 0000000000..256fef74ab --- /dev/null +++ b/blocksuite/blocks/src/root-block/text-selection/utils.ts @@ -0,0 +1,30 @@ +export function autoScroll( + viewportElement: HTMLElement, + y: number, + threshold = 50 +): boolean { + const { scrollHeight, clientHeight, scrollTop } = viewportElement; + let _scrollTop = scrollTop; + const max = scrollHeight - clientHeight; + + let d = 0; + let flag = false; + + if (Math.ceil(scrollTop) < max && clientHeight - y < threshold) { + // ↓ + d = threshold - (clientHeight - y); + flag = Math.ceil(_scrollTop) < max; + } else if (scrollTop > 0 && y < threshold) { + // ↑ + d = y - threshold; + flag = _scrollTop > 0; + } + + _scrollTop += d * 0.25; + + if (flag && scrollTop !== _scrollTop) { + viewportElement.scrollTop = _scrollTop; + return true; + } + return false; +} diff --git a/blocksuite/blocks/src/root-block/types.ts b/blocksuite/blocks/src/root-block/types.ts new file mode 100644 index 0000000000..e5da01892a --- /dev/null +++ b/blocksuite/blocks/src/root-block/types.ts @@ -0,0 +1,54 @@ +import type { EdgelessRootBlockComponent } from './edgeless/edgeless-root-block.js'; +import type { PageRootBlockComponent } from './page/page-root-block.js'; +import type { AFFINE_DOC_REMOTE_SELECTION_WIDGET } from './widgets/doc-remote-selection/index.js'; +import type { AFFINE_DRAG_HANDLE_WIDGET } from './widgets/drag-handle/consts.js'; +import type { AFFINE_EDGELESS_REMOTE_SELECTION_WIDGET } from './widgets/edgeless-remote-selection/index.js'; +import type { AFFINE_EDGELESS_ZOOM_TOOLBAR_WIDGET } from './widgets/edgeless-zoom-toolbar/index.js'; +import type { EDGELESS_ELEMENT_TOOLBAR_WIDGET } from './widgets/element-toolbar/index.js'; +import type { AFFINE_EMBED_CARD_TOOLBAR_WIDGET } from './widgets/embed-card-toolbar/embed-card-toolbar.js'; +import type { AFFINE_FORMAT_BAR_WIDGET } from './widgets/format-bar/format-bar.js'; +import type { AFFINE_FRAME_TITLE_WIDGET } from './widgets/frame-title/index.js'; +import type { AFFINE_KEYBOARD_TOOLBAR_WIDGET } from './widgets/index.js'; +import type { AFFINE_INNER_MODAL_WIDGET } from './widgets/inner-modal/inner-modal.js'; +import type { AFFINE_LINKED_DOC_WIDGET } from './widgets/linked-doc/index.js'; +import type { AFFINE_MODAL_WIDGET } from './widgets/modal/modal.js'; +import type { AFFINE_PAGE_DRAGGING_AREA_WIDGET } from './widgets/page-dragging-area/page-dragging-area.js'; +import type { AFFINE_PIE_MENU_ID_EDGELESS_TOOLS } from './widgets/pie-menu/config.js'; +import type { AFFINE_PIE_MENU_WIDGET } from './widgets/pie-menu/index.js'; +import type { AFFINE_SLASH_MENU_WIDGET } from './widgets/slash-menu/index.js'; +import type { AFFINE_VIEWPORT_OVERLAY_WIDGET } from './widgets/viewport-overlay/viewport-overlay.js'; + +export type PageRootBlockWidgetName = + | typeof AFFINE_KEYBOARD_TOOLBAR_WIDGET + | typeof AFFINE_MODAL_WIDGET + | typeof AFFINE_INNER_MODAL_WIDGET + | typeof AFFINE_SLASH_MENU_WIDGET + | typeof AFFINE_LINKED_DOC_WIDGET + | typeof AFFINE_PAGE_DRAGGING_AREA_WIDGET + | typeof AFFINE_DRAG_HANDLE_WIDGET + | typeof AFFINE_EMBED_CARD_TOOLBAR_WIDGET + | typeof AFFINE_FORMAT_BAR_WIDGET + | typeof AFFINE_DOC_REMOTE_SELECTION_WIDGET + | typeof AFFINE_VIEWPORT_OVERLAY_WIDGET; + +export type EdgelessRootBlockWidgetName = + | typeof AFFINE_MODAL_WIDGET + | typeof AFFINE_INNER_MODAL_WIDGET + | typeof AFFINE_PIE_MENU_WIDGET + | typeof AFFINE_SLASH_MENU_WIDGET + | typeof AFFINE_LINKED_DOC_WIDGET + | typeof AFFINE_DRAG_HANDLE_WIDGET + | typeof AFFINE_EMBED_CARD_TOOLBAR_WIDGET + | typeof AFFINE_FORMAT_BAR_WIDGET + | typeof AFFINE_DOC_REMOTE_SELECTION_WIDGET + | typeof AFFINE_EDGELESS_REMOTE_SELECTION_WIDGET + | typeof AFFINE_EDGELESS_ZOOM_TOOLBAR_WIDGET + | typeof EDGELESS_ELEMENT_TOOLBAR_WIDGET + | typeof AFFINE_VIEWPORT_OVERLAY_WIDGET + | typeof AFFINE_FRAME_TITLE_WIDGET; + +export type RootBlockComponent = + | PageRootBlockComponent + | EdgelessRootBlockComponent; + +export type PieMenuId = typeof AFFINE_PIE_MENU_ID_EDGELESS_TOOLS; diff --git a/blocksuite/blocks/src/root-block/utils/callback.ts b/blocksuite/blocks/src/root-block/utils/callback.ts new file mode 100644 index 0000000000..78af6e7e66 --- /dev/null +++ b/blocksuite/blocks/src/root-block/utils/callback.ts @@ -0,0 +1,48 @@ +import type { RichText } from '@blocksuite/affine-components/rich-text'; +import { asyncGetRichText } from '@blocksuite/affine-components/rich-text'; +import type { BlockComponent, EditorHost } from '@blocksuite/block-std'; +import type { BlockModel } from '@blocksuite/store'; + +export async function onModelTextUpdated( + editorHost: EditorHost, + model: BlockModel, + callback?: (text: RichText) => void +) { + const richText = await asyncGetRichText(editorHost, model.id); + if (!richText) { + console.error('RichText is not ready yet.'); + return; + } + await richText.updateComplete; + const inlineEditor = richText.inlineEditor; + if (!inlineEditor) { + console.error('Inline editor is not ready yet.'); + return; + } + inlineEditor.slots.renderComplete.once(() => { + if (callback) { + callback(richText); + } + }); +} + +// Run the callback until a model's element updated. +// Please notice that the callback will be called **once the element itself is ready**. +// The children may be not updated. +// If you want to wait for the text elements, +// please use `onModelTextUpdated`. +export async function onModelElementUpdated( + editorHost: EditorHost, + model: BlockModel, + callback: (block: BlockComponent) => void +) { + const page = model.doc; + if (!page.root) return; + + const rootComponent = editorHost.view.getBlock(page.root.id); + if (!rootComponent) return; + await rootComponent.updateComplete; + + const element = editorHost.view.getBlock(model.id); + if (element) callback(element); +} diff --git a/blocksuite/blocks/src/root-block/utils/index.ts b/blocksuite/blocks/src/root-block/utils/index.ts new file mode 100644 index 0000000000..e066355e3f --- /dev/null +++ b/blocksuite/blocks/src/root-block/utils/index.ts @@ -0,0 +1 @@ +export * from './callback.js'; diff --git a/blocksuite/blocks/src/root-block/utils/misc.ts b/blocksuite/blocks/src/root-block/utils/misc.ts new file mode 100644 index 0000000000..c17b9182a6 --- /dev/null +++ b/blocksuite/blocks/src/root-block/utils/misc.ts @@ -0,0 +1,18 @@ +export function formatDate(date: Date) { + // yyyy-mm-dd + const year = date.getFullYear(); + const month = (date.getMonth() + 1).toString().padStart(2, '0'); + const day = date.getDate().toString().padStart(2, '0'); + const strTime = `${year}-${month}-${day}`; + return strTime; +} + +export function formatTime(date: Date) { + // mm-dd hh:mm + const month = (date.getMonth() + 1).toString().padStart(2, '0'); + const day = date.getDate().toString().padStart(2, '0'); + const hours = date.getHours().toString().padStart(2, '0'); + const minutes = date.getMinutes().toString().padStart(2, '0'); + const strTime = `${month}-${day} ${hours}:${minutes}`; + return strTime; +} diff --git a/blocksuite/blocks/src/root-block/utils/operations/model.ts b/blocksuite/blocks/src/root-block/utils/operations/model.ts new file mode 100644 index 0000000000..d3a40483ed --- /dev/null +++ b/blocksuite/blocks/src/root-block/utils/operations/model.ts @@ -0,0 +1,67 @@ +import { assertExists } from '@blocksuite/global/utils'; +import { type BlockModel, type Doc, Text } from '@blocksuite/store'; + +/** + * This file should only contain functions that are used to + * operate on block models in store, which means that this operations + * just operate on data and will not involve in something about ui like selection reset. + */ + +export function mergeToCodeModel(models: BlockModel[]) { + if (models.length === 0) { + return null; + } + const doc = models[0].doc; + + const parent = doc.getParent(models[0]); + if (!parent) { + return null; + } + const index = parent.children.indexOf(models[0]); + const text = models + .map(model => { + if (model.text instanceof Text) { + return model.text.toString(); + } + return null; + }) + .filter(Boolean) + .join('\n'); + models.forEach(model => doc.deleteBlock(model)); + + const id = doc.addBlock( + 'affine:code', + { text: new Text(text) }, + parent, + index + ); + return id; +} + +export function transformModel( + model: BlockModel, + flavour: BlockSuite.Flavour, + props?: Parameters<Doc['addBlock']>[1] +) { + const doc = model.doc; + const parent = doc.getParent(model); + assertExists(parent); + const blockProps: { + type?: string; + text?: Text; + children?: BlockModel[]; + } = { + text: model?.text?.clone(), // should clone before `deleteBlock` + children: model.children, + ...props, + }; + const index = parent.children.indexOf(model); + + // Sometimes the new block can not be added due to some reason, e.g. invalid schema check. + // So we need to try to add the new block first, and if it fails, we will not delete the old block. + const id = doc.addBlock(flavour, blockProps, parent, index); + doc.deleteBlock(model, { + deleteChildren: false, + }); + return id; +} diff --git a/blocksuite/blocks/src/root-block/utils/position.ts b/blocksuite/blocks/src/root-block/utils/position.ts new file mode 100644 index 0000000000..965e8a277e --- /dev/null +++ b/blocksuite/blocks/src/root-block/utils/position.ts @@ -0,0 +1,130 @@ +import { clamp } from '@blocksuite/affine-shared/utils'; + +type CollisionBox = { + /** + * The point that the objRect is positioned to. + */ + positioningPoint: { x: number; y: number }; + /** + * The boundary rect of the obj that is being positioned. + */ + objRect?: { height: number; width: number }; + /** + * The boundary rect of the container that the obj is in. + */ + boundaryRect?: DOMRect; + offsetX?: number; + offsetY?: number; + edgeGap?: number; +}; + +export function calcSafeCoordinate({ + positioningPoint, + objRect = { width: 0, height: 0 }, + boundaryRect = document.body.getBoundingClientRect(), + offsetX = 0, + offsetY = 0, + edgeGap = 20, +}: CollisionBox) { + const safeX = clamp( + positioningPoint.x + offsetX, + edgeGap, + boundaryRect.width - objRect.width - edgeGap + ); + const y = positioningPoint.y + offsetY; + // Not use clamp for y coordinate to avoid the quick bar always showing after scrolling + // const safeY = clamp( + // positioningPoint.y + offsetY, + // edgeGap, + // boundaryRect.height - objRect.height - edgeGap + // ); + return { + x: safeX, + y, + }; +} + +/** + * Used to compare the space available + * at the top and bottom of an element within a container. + * + * Please give preference to {@link getPopperPosition} + */ +export function compareTopAndBottomSpace( + obj: { getBoundingClientRect: () => DOMRect }, + container = document.body, + gap = 20 +) { + const objRect = obj.getBoundingClientRect(); + const spaceRect = container.getBoundingClientRect(); + const topSpace = objRect.top - spaceRect.top; + const bottomSpace = spaceRect.bottom - objRect.bottom; + const topOrBottom: 'top' | 'bottom' = + topSpace > bottomSpace ? 'top' : 'bottom'; + return { + placement: topOrBottom, + // the height is the available space. + height: (topOrBottom === 'top' ? topSpace : bottomSpace) - gap, + }; +} + +/** + * Get the position of the popper element with flip. + */ +export function getPopperPosition( + popper: { + getBoundingClientRect: () => DOMRect; + }, + reference: { + getBoundingClientRect: () => DOMRect; + }, + { gap = 12, offsetY = 5 }: { gap?: number; offsetY?: number } = {} +) { + if (!popper) { + // foolproof, someone may use element with non-null assertion + console.warn( + 'The popper element is not exist. Popper position maybe incorrect' + ); + } + const { placement, height } = compareTopAndBottomSpace( + reference, + document.body, + gap + offsetY + ); + + const referenceRect = reference.getBoundingClientRect(); + const positioningPoint = { + x: referenceRect.x, + y: referenceRect.y + (placement === 'bottom' ? referenceRect.height : 0), + }; + + // TODO maybe use the editor container as the boundary rect to avoid the format bar being covered by other elements + const boundaryRect = document.body.getBoundingClientRect(); + // Note: the popperRect.height maybe incorrect + // because we are calculated its correct height + const popperRect = popper?.getBoundingClientRect(); + + const safeCoordinate = calcSafeCoordinate({ + positioningPoint, + objRect: popperRect, + boundaryRect, + offsetY: placement === 'bottom' ? offsetY : -offsetY, + }); + + return { + placement, + /** + * The height is the available space height. + * + * Note: it's a max height, not the real height, + * because sometimes the popper's height is smaller than the available space. + */ + height, + x: `${safeCoordinate.x}px`, + y: + placement === 'bottom' + ? `${safeCoordinate.y}px` + : // We need to use `calc(-100%)` since the height of popper maybe incorrect + `calc(${safeCoordinate.y}px - 100%)`, + }; +} diff --git a/blocksuite/blocks/src/root-block/utils/query.ts b/blocksuite/blocks/src/root-block/utils/query.ts new file mode 100644 index 0000000000..535275d5fa --- /dev/null +++ b/blocksuite/blocks/src/root-block/utils/query.ts @@ -0,0 +1,7 @@ +import type { RootBlockComponent } from '../types.js'; + +export function getClosestRootBlockComponent( + el: HTMLElement +): RootBlockComponent | null { + return el.closest('affine-edgeless-root, affine-page-root'); +} diff --git a/blocksuite/blocks/src/root-block/widgets/ai-panel/ai-panel.ts b/blocksuite/blocks/src/root-block/widgets/ai-panel/ai-panel.ts new file mode 100644 index 0000000000..0fe64e061d --- /dev/null +++ b/blocksuite/blocks/src/root-block/widgets/ai-panel/ai-panel.ts @@ -0,0 +1,568 @@ +import { + NotificationProvider, + ThemeProvider, +} from '@blocksuite/affine-shared/services'; +import { + getPageRootByElement, + stopPropagation, +} from '@blocksuite/affine-shared/utils'; +import type { BaseSelection } from '@blocksuite/block-std'; +import { WidgetComponent } from '@blocksuite/block-std'; +import { assertExists } from '@blocksuite/global/utils'; +import { + autoPlacement, + autoUpdate, + computePosition, + type ComputePositionConfig, + flip, + offset, + type Rect, + shift, +} from '@floating-ui/dom'; +import { css, html, nothing, type PropertyValues } from 'lit'; +import { property, query } from 'lit/decorators.js'; +import { choose } from 'lit/directives/choose.js'; + +import type { AIError } from '../../../_common/components/index.js'; +import type { EdgelessRootService } from '../../edgeless/edgeless-root-service.js'; +import { PageRootService } from '../../page/page-root-service.js'; +import { AFFINE_FORMAT_BAR_WIDGET } from '../format-bar/format-bar.js'; +import { + AFFINE_VIEWPORT_OVERLAY_WIDGET, + type AffineViewportOverlayWidget, +} from '../viewport-overlay/viewport-overlay.js'; +import type { AIPanelGenerating } from './components/index.js'; +import type { AffineAIPanelState, AffineAIPanelWidgetConfig } from './type.js'; + +export const AFFINE_AI_PANEL_WIDGET = 'affine-ai-panel-widget'; + +export class AffineAIPanelWidget extends WidgetComponent { + static override styles = css` + :host { + display: flex; + outline: none; + border-radius: var(--8, 8px); + border: 1px solid var(--affine-border-color); + background: var(--affine-background-overlay-panel-color); + box-shadow: var(--affine-overlay-shadow); + + position: absolute; + width: max-content; + height: auto; + top: 0; + left: 0; + overflow-y: auto; + scrollbar-width: none !important; + z-index: var(--affine-z-index-popover); + } + + .ai-panel-container { + display: flex; + flex-direction: column; + box-sizing: border-box; + width: 100%; + height: fit-content; + padding: 8px 0; + } + + .ai-panel-container:not(:has(ai-panel-generating)) { + gap: 8px; + } + + .ai-panel-container:has(ai-panel-answer), + .ai-panel-container:has(ai-panel-error), + .ai-panel-container:has(ai-panel-generating:has(generating-placeholder)) { + padding: 12px 0; + } + + :host([data-state='hidden']) { + display: none; + } + `; + + private _abortController = new AbortController(); + + private _answer: string | null = null; + + private _cancelCallback = () => { + this.focus(); + }; + + private _clearDiscardModal = () => { + if (this._discardModalAbort) { + this._discardModalAbort.abort(); + this._discardModalAbort = null; + } + }; + + private _clickOutside = () => { + switch (this.state) { + case 'hidden': + return; + case 'error': + case 'finished': + if (!this._answer) { + this.hide(); + } else { + this.discard(); + } + break; + default: + this.discard(); + } + }; + + private _discardCallback = () => { + this.hide(); + this.config?.discardCallback?.(); + }; + + private _discardModalAbort: AbortController | null = null; + + private _inputFinish = (text: string) => { + this._inputText = text; + this.generate(); + }; + + private _inputText: string | null = null; + + private _onDocumentClick = (e: MouseEvent) => { + if ( + this.state !== 'hidden' && + e.target !== this && + !this.contains(e.target as Node) + ) { + this._clickOutside(); + return true; + } + + return false; + }; + + private _onKeyDown = (event: KeyboardEvent) => { + event.stopPropagation(); + const { state } = this; + if (state !== 'generating' && state !== 'input') { + return; + } + + const { key } = event; + if (key === 'Escape') { + if (state === 'generating') { + this.stopGenerating(); + } else { + this.hide(); + } + return; + } + }; + + private _resetAbortController = () => { + if (this.state === 'generating') { + this._abortController.abort(); + } + this._abortController = new AbortController(); + }; + + private _selection?: BaseSelection[]; + + private _stopAutoUpdate?: undefined | (() => void); + + ctx: unknown = null; + + discard = () => { + if ((this.state === 'finished' || this.state === 'error') && !this.answer) { + this._discardCallback(); + return; + } + if (this.state === 'input') { + this.hide(); + return; + } + this.showDiscardModal() + .then(discard => { + if (discard) { + this._discardCallback(); + } else { + this._cancelCallback(); + } + this.restoreSelection(); + }) + .catch(console.error); + }; + + /** + * You can evaluate this method multiple times to regenerate the answer. + */ + generate = () => { + this.restoreSelection(); + + assertExists(this.config); + const text = this._inputText; + assertExists(text); + assertExists(this.config.generateAnswer); + + this._resetAbortController(); + + // reset answer + this._answer = null; + + const update = (answer: string) => { + this._answer = answer; + this.requestUpdate(); + }; + const finish = (type: 'success' | 'error' | 'aborted', err?: AIError) => { + if (type === 'aborted') return; + + assertExists(this.config); + if (type === 'error') { + this.state = 'error'; + this.config.errorStateConfig.error = err; + } else { + this.state = 'finished'; + this.config.errorStateConfig.error = undefined; + } + + this._resetAbortController(); + }; + + this.scrollTop = 0; // reset scroll top + this.state = 'generating'; + this.config.generateAnswer({ + input: text, + update, + finish, + signal: this._abortController.signal, + }); + }; + + hide = (shouldTriggerCallback: boolean = true) => { + this._resetAbortController(); + this.state = 'hidden'; + this._stopAutoUpdate?.(); + this._inputText = null; + this._answer = null; + this._stopAutoUpdate = undefined; + this.viewportOverlayWidget?.unlock(); + if (shouldTriggerCallback) { + this.config?.hideCallback?.(); + } + }; + + onInput = (text: string) => { + this._inputText = text; + this.config?.inputCallback?.(text); + }; + + restoreSelection = () => { + if (this._selection) { + this.host.selection.set([...this._selection]); + if (this.state === 'hidden') { + this._selection = undefined; + } + } + }; + + setState = (state: AffineAIPanelState, reference: Element) => { + this.state = state; + this._autoUpdatePosition(reference); + }; + + showDiscardModal = () => { + const notification = this.host.std.getOptional(NotificationProvider); + if (!notification) { + return Promise.resolve(true); + } + this._clearDiscardModal(); + this._discardModalAbort = new AbortController(); + return notification + .confirm({ + title: 'Discard the AI result', + message: 'Do you want to discard the results the AI just generated?', + cancelText: 'Cancel', + confirmText: 'Discard', + abort: this._abortController.signal, + }) + .finally(() => (this._discardModalAbort = null)); + }; + + stopGenerating = () => { + this._abortController.abort(); + this.state = 'finished'; + if (!this.answer) { + this.hide(); + } + }; + + toggle = ( + reference: Element, + input?: string, + shouldTriggerCallback?: boolean + ) => { + if (input) { + this._inputText = input; + this.generate(); + } else { + // reset state + this.hide(shouldTriggerCallback); + this.state = 'input'; + } + + this._autoUpdatePosition(reference); + }; + + get answer() { + return this._answer; + } + + get inputText() { + return this._inputText; + } + + get viewportOverlayWidget() { + const rootId = this.host.doc.root?.id; + return rootId + ? (this.host.view.getWidget( + AFFINE_VIEWPORT_OVERLAY_WIDGET, + rootId + ) as AffineViewportOverlayWidget) + : null; + } + + private _autoUpdatePosition(reference: Element) { + // workaround for the case that the reference contains children block elements, like: + // paragraph + // child paragraph + { + const childrenContainer = reference.querySelector( + '.affine-block-children-container' + ); + if (childrenContainer && childrenContainer.previousElementSibling) { + reference = childrenContainer.previousElementSibling; + } + } + + this._stopAutoUpdate?.(); + this._stopAutoUpdate = autoUpdate(reference, this, () => { + computePosition(reference, this, this._calcPositionOptions(reference)) + .then(({ x, y }) => { + this.style.left = `${x}px`; + this.style.top = `${y}px`; + setTimeout(() => { + const input = this.shadowRoot?.querySelector('ai-panel-input'); + input?.textarea?.focus(); + }, 0); + }) + .catch(console.error); + }); + } + + private _calcPositionOptions( + reference: Element + ): Partial<ComputePositionConfig> { + let rootBoundary: Rect | undefined; + { + const rootService = this.host.std.getService('affine:page'); + if (rootService instanceof PageRootService) { + rootBoundary = undefined; + } else { + // TODO circular dependency: instanceof EdgelessRootService + const viewport = (rootService as EdgelessRootService).viewport; + rootBoundary = { + x: viewport.left, + y: viewport.top, + width: viewport.width, + height: viewport.height - 100, // 100 for edgeless toolbar + }; + } + } + + const overflowOptions = { + padding: 20, + rootBoundary: rootBoundary, + }; + + // block element in page editor + if (getPageRootByElement(reference)) { + return { + placement: 'bottom-start', + middleware: [offset(8), shift(overflowOptions)], + }; + } + // block element in doc in edgeless editor + else if (reference.closest('edgeless-block-portal-note')) { + return { + middleware: [ + offset(8), + shift(overflowOptions), + autoPlacement({ + ...overflowOptions, + allowedPlacements: ['top-start', 'bottom-start'], + }), + ], + }; + } + // edgeless element + else { + return { + placement: 'right-start', + middleware: [ + offset({ mainAxis: 16 }), + flip({ + mainAxis: true, + crossAxis: true, + flipAlignment: true, + ...overflowOptions, + }), + shift({ + crossAxis: true, + ...overflowOptions, + }), + ], + }; + } + } + + override connectedCallback() { + super.connectedCallback(); + + this.tabIndex = -1; + this.disposables.addFromEvent( + document, + 'pointerdown', + this._onDocumentClick + ); + this.disposables.add( + this.block.host.event.add('pointerDown', evtState => + this._onDocumentClick( + evtState.get('pointerState').event as PointerEvent + ) + ) + ); + this.disposables.add( + this.block.host.event.add('click', () => { + return this.state !== 'hidden' ? true : false; + }) + ); + this.disposables.addFromEvent(this, 'wheel', stopPropagation); + this.disposables.addFromEvent(this, 'pointerdown', stopPropagation); + this.disposables.addFromEvent(this, 'pointerup', stopPropagation); + this.disposables.addFromEvent(this, 'keydown', this._onKeyDown); + } + + override disconnectedCallback() { + super.disconnectedCallback(); + this._clearDiscardModal(); + this._stopAutoUpdate?.(); + } + + override render() { + if (this.state === 'hidden') { + return nothing; + } + + if (!this.config) return nothing; + const config = this.config; + + const theme = this.std.get(ThemeProvider).theme; + const mainTemplate = choose(this.state, [ + [ + 'input', + () => + html`<ai-panel-input + .onBlur=${this.discard} + .onFinish=${this._inputFinish} + .onInput=${this.onInput} + ></ai-panel-input>`, + ], + [ + 'generating', + () => html` + ${this.answer + ? html` + <ai-panel-answer + .finish=${false} + .config=${config.finishStateConfig} + .host=${this.host} + > + ${this.answer && + config.answerRenderer(this.answer, this.state)} + </ai-panel-answer> + ` + : nothing} + <ai-panel-generating + .config=${config.generatingStateConfig} + .theme=${theme} + .stopGenerating=${this.stopGenerating} + .withAnswer=${!!this.answer} + ></ai-panel-generating> + `, + ], + [ + 'finished', + () => html` + <ai-panel-answer + .config=${config.finishStateConfig} + .copy=${config.copy} + .host=${this.host} + > + ${this.answer && config.answerRenderer(this.answer, this.state)} + </ai-panel-answer> + `, + ], + [ + 'error', + () => html` + <ai-panel-error + .config=${config.errorStateConfig} + .copy=${config.copy} + .withAnswer=${!!this.answer} + .host=${this.host} + > + ${this.answer && config.answerRenderer(this.answer, this.state)} + </ai-panel-error> + `, + ], + ]); + + return html`<div class="ai-panel-container">${mainTemplate}</div>`; + } + + protected override willUpdate(changed: PropertyValues): void { + const prevState = changed.get('state'); + if (prevState) { + if (prevState === 'hidden') { + this._selection = this.host.selection.value; + } else { + this.restoreSelection(); + } + + // tell format bar to show or hide + const rootBlockId = this.host.doc.root?.id; + const formatBar = rootBlockId + ? this.host.view.getWidget(AFFINE_FORMAT_BAR_WIDGET, rootBlockId) + : null; + + if (formatBar) { + formatBar.requestUpdate(); + } + } + + if (this.state !== 'hidden') { + this.viewportOverlayWidget?.lock(); + } else { + this.viewportOverlayWidget?.unlock(); + } + + this.dataset.state = this.state; + } + + @property({ attribute: false }) + accessor config: AffineAIPanelWidgetConfig | null = null; + + @query('ai-panel-generating') + accessor generatingElement: AIPanelGenerating | null = null; + + @property() + accessor state: AffineAIPanelState = 'hidden'; +} diff --git a/blocksuite/blocks/src/root-block/widgets/ai-panel/components/divider.ts b/blocksuite/blocks/src/root-block/widgets/ai-panel/components/divider.ts new file mode 100644 index 0000000000..38fe7b35d6 --- /dev/null +++ b/blocksuite/blocks/src/root-block/widgets/ai-panel/components/divider.ts @@ -0,0 +1,29 @@ +import { WithDisposable } from '@blocksuite/global/utils'; +import { css, html, LitElement } from 'lit'; + +export class AIPanelDivider extends WithDisposable(LitElement) { + static override styles = css` + :host { + display: flex; + flex-direction: column; + align-items: flex-start; + align-self: stretch; + width: 100%; + } + .divider { + height: 0.5px; + background: var(--affine-border-color); + width: 100%; + } + `; + + override render() { + return html`<div class="divider"></div>`; + } +} + +declare global { + interface HTMLElementTagNameMap { + 'ai-panel-divider': AIPanelDivider; + } +} diff --git a/blocksuite/blocks/src/root-block/widgets/ai-panel/components/finish-tip.ts b/blocksuite/blocks/src/root-block/widgets/ai-panel/components/finish-tip.ts new file mode 100644 index 0000000000..0dcefeb2ea --- /dev/null +++ b/blocksuite/blocks/src/root-block/widgets/ai-panel/components/finish-tip.ts @@ -0,0 +1,107 @@ +import { + AIDoneIcon, + CopyIcon, + WarningIcon, +} from '@blocksuite/affine-components/icons'; +import { NotificationProvider } from '@blocksuite/affine-shared/services'; +import type { EditorHost } from '@blocksuite/block-std'; +import { WithDisposable } from '@blocksuite/global/utils'; +import { css, html, LitElement, nothing } from 'lit'; +import { property, state } from 'lit/decorators.js'; + +import type { CopyConfig } from '../type.js'; + +export class AIFinishTip extends WithDisposable(LitElement) { + static override styles = css` + .finish-tip { + display: flex; + box-sizing: border-box; + width: 100%; + height: 22px; + align-items: center; + justify-content: space-between; + padding: 0 12px; + gap: 4px; + + color: var(--affine-text-secondary-color); + + .text { + display: flex; + align-items: flex-start; + flex: 1 0 0; + + /* light/xs */ + font-size: var(--affine-font-xs); + font-style: normal; + font-weight: 400; + line-height: 20px; /* 166.667% */ + } + + .right { + display: flex; + align-items: center; + + .copy, + .copied { + display: flex; + width: 20px; + height: 20px; + justify-content: center; + align-items: center; + border-radius: 8px; + user-select: none; + } + .copy:hover { + color: var(--affine-icon-color); + background: var(--affine-hover-color); + cursor: pointer; + } + .copied { + color: var(--affine-brand-color); + } + } + } + `; + + override render() { + return html`<div class="finish-tip"> + ${WarningIcon} + <div class="text">AI outputs can be misleading or wrong</div> + ${this.copy?.allowed + ? html`<div class="right"> + ${this.copied + ? html`<div class="copied">${AIDoneIcon}</div>` + : html`<div + class="copy" + @click=${async () => { + this.copied = !!(await this.copy?.onCopy()); + if (this.copied) { + this.host.std + .getOptional(NotificationProvider) + ?.toast('Copied to clipboard'); + } + }} + > + ${CopyIcon} + <affine-tooltip>Copy</affine-tooltip> + </div>`} + </div>` + : nothing} + </div>`; + } + + @state() + accessor copied = false; + + @property({ attribute: false }) + accessor copy: CopyConfig | undefined = undefined; + + @property({ attribute: false }) + accessor host!: EditorHost; +} + +declare global { + interface HTMLElementTagNameMap { + 'ai-finish-tip': AIFinishTip; + } +} diff --git a/blocksuite/blocks/src/root-block/widgets/ai-panel/components/generating-placeholder.ts b/blocksuite/blocks/src/root-block/widgets/ai-panel/components/generating-placeholder.ts new file mode 100644 index 0000000000..fa5f7f4709 --- /dev/null +++ b/blocksuite/blocks/src/root-block/widgets/ai-panel/components/generating-placeholder.ts @@ -0,0 +1,147 @@ +import { + DarkLoadingIcon, + LightLoadingIcon, +} from '@blocksuite/affine-components/icons'; +import { ColorScheme } from '@blocksuite/affine-model'; +import { unsafeCSSVar } from '@blocksuite/affine-shared/theme'; +import { WithDisposable } from '@blocksuite/global/utils'; +import { baseTheme } from '@toeverything/theme'; +import { + css, + html, + LitElement, + nothing, + type PropertyValues, + unsafeCSS, +} from 'lit'; +import { property } from 'lit/decorators.js'; + +export class GeneratingPlaceholder extends WithDisposable(LitElement) { + static override styles = css` + :host { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 4px; + margin-bottom: 8px; + } + + .generating-header { + width: 100%; + font-size: ${unsafeCSSVar('fontXs')}; + font-style: normal; + font-weight: 500; + line-height: 20px; + height: 20px; + } + + .generating-header, + .loading-progress { + color: ${unsafeCSSVar('textSecondaryColor')}; + font-family: ${unsafeCSS(baseTheme.fontSansFamily)}; + } + + .generating-body { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + box-sizing: border-box; + width: 100%; + border-radius: 4px; + border: 2px solid ${unsafeCSSVar('primaryColor')}; + background: ${unsafeCSSVar('blue50')}; + color: ${unsafeCSSVar('brandColor')}; + gap: 4px; + } + + .generating-icon { + display: flex; + justify-content: center; + align-items: center; + height: 24px; + } + + .generating-icon svg { + scale: 1.5; + } + + .loading-progress { + display: flex; + flex-direction: column; + font-style: normal; + font-weight: 400; + text-align: center; + gap: 4px; + } + + .loading-text { + font-size: ${unsafeCSSVar('fontBase')}; + height: 24px; + line-height: 24px; + } + + .loading-stage { + font-size: ${unsafeCSSVar('fontXs')}; + height: 20px; + line-height: 20px; + } + `; + + protected override render() { + const loadingText = this.stages[this.loadingProgress - 1] || ''; + + return html`<style> + .generating-body { + height: ${this.height}px; + } + </style> + ${this.showHeader + ? html`<div class="generating-header">Answer</div>` + : nothing} + <div class="generating-body"> + <div class="generating-icon"> + ${this.theme === ColorScheme.Light + ? DarkLoadingIcon + : LightLoadingIcon} + </div> + <div class="loading-progress"> + <div class="loading-text">${loadingText}</div> + <div class="loading-stage"> + ${this.loadingProgress} / ${this.stages.length} + </div> + </div> + </div>`; + } + + override willUpdate(changed: PropertyValues) { + if (changed.has('loadingProgress')) { + this.loadingProgress = Math.max( + 1, + Math.min(this.loadingProgress, this.stages.length) + ); + } + } + + @property({ attribute: false }) + accessor height: number = 300; + + @property({ attribute: false }) + accessor loadingProgress!: number; + + @property({ attribute: false }) + accessor showHeader!: boolean; + + @property({ attribute: false }) + accessor stages!: string[]; + + @property({ attribute: false }) + accessor theme!: ColorScheme; +} + +declare global { + interface HTMLElementTagNameMap { + 'generating-placeholder': GeneratingPlaceholder; + } +} diff --git a/blocksuite/blocks/src/root-block/widgets/ai-panel/components/index.ts b/blocksuite/blocks/src/root-block/widgets/ai-panel/components/index.ts new file mode 100644 index 0000000000..db753a0896 --- /dev/null +++ b/blocksuite/blocks/src/root-block/widgets/ai-panel/components/index.ts @@ -0,0 +1,2 @@ +export * from './divider.js'; +export * from './state/index.js'; diff --git a/blocksuite/blocks/src/root-block/widgets/ai-panel/components/state/answer.ts b/blocksuite/blocks/src/root-block/widgets/ai-panel/components/state/answer.ts new file mode 100644 index 0000000000..dfb7dd9317 --- /dev/null +++ b/blocksuite/blocks/src/root-block/widgets/ai-panel/components/state/answer.ts @@ -0,0 +1,152 @@ +import type { EditorHost } from '@blocksuite/block-std'; +import { WithDisposable } from '@blocksuite/global/utils'; +import { baseTheme } from '@toeverything/theme'; +import { css, html, LitElement, nothing, unsafeCSS } from 'lit'; +import { property } from 'lit/decorators.js'; + +import type { AIPanelAnswerConfig, CopyConfig } from '../../type.js'; +import { filterAIItemGroup } from '../../utils.js'; + +export class AIPanelAnswer extends WithDisposable(LitElement) { + static override styles = css` + :host { + width: 100%; + display: flex; + box-sizing: border-box; + flex-direction: column; + gap: 8px; + padding: 0; + } + + .answer { + display: flex; + flex-direction: column; + justify-content: center; + align-items: flex-start; + gap: 4px; + align-self: stretch; + font-family: ${unsafeCSS(baseTheme.fontSansFamily)}; + padding: 0 12px; + } + + .answer-head { + align-self: stretch; + + color: var(--affine-text-secondary-color); + + /* light/xsMedium */ + font-size: var(--affine-font-xs); + font-style: normal; + font-weight: 500; + line-height: 20px; /* 166.667% */ + height: 20px; + } + + .answer-body { + align-self: stretch; + + color: var(--affine-text-primary-color); + font-feature-settings: + 'clig' off, + 'liga' off; + + /* light/sm */ + font-size: var(--affine-font-xs); + font-style: normal; + font-weight: 400; + line-height: 22px; /* 157.143% */ + } + + .response-list-container { + display: flex; + flex-direction: column; + gap: 4px; + } + + .response-list-container, + .action-list-container { + padding: 0 8px; + user-select: none; + } + + /* set item style outside ai-item */ + .response-list-container ai-item-list, + .action-list-container ai-item-list { + --item-padding: 4px; + } + + .response-list-container ai-item-list { + --item-icon-color: var(--affine-icon-secondary); + --item-icon-hover-color: var(--affine-icon-color); + } + `; + + override render() { + const responseGroup = filterAIItemGroup(this.host, this.config.responses); + return html` + <div class="answer"> + <div class="answer-head">Answer</div> + <div class="answer-body"> + <slot></slot> + </div> + </div> + ${this.finish + ? html` + <ai-finish-tip + .copy=${this.copy} + .host=${this.host} + ></ai-finish-tip> + ${responseGroup.length > 0 + ? html` + <ai-panel-divider></ai-panel-divider> + ${responseGroup.map( + (group, index) => html` + ${index !== 0 + ? html`<ai-panel-divider></ai-panel-divider>` + : nothing} + <div class="response-list-container"> + <ai-item-list + .host=${this.host} + .groups=${[group]} + ></ai-item-list> + </div> + ` + )} + ` + : nothing} + ${responseGroup.length > 0 && this.config.actions.length > 0 + ? html`<ai-panel-divider></ai-panel-divider>` + : nothing} + ${this.config.actions.length > 0 + ? html` + <div class="action-list-container"> + <ai-item-list + .host=${this.host} + .groups=${this.config.actions} + ></ai-item-list> + </div> + ` + : nothing} + ` + : nothing} + `; + } + + @property({ attribute: false }) + accessor config!: AIPanelAnswerConfig; + + @property({ attribute: false }) + accessor copy: CopyConfig | undefined = undefined; + + @property({ attribute: false }) + accessor finish = true; + + @property({ attribute: false }) + accessor host!: EditorHost; +} + +declare global { + interface HTMLElementTagNameMap { + 'ai-panel-answer': AIPanelAnswer; + } +} diff --git a/blocksuite/blocks/src/root-block/widgets/ai-panel/components/state/error.ts b/blocksuite/blocks/src/root-block/widgets/ai-panel/components/state/error.ts new file mode 100644 index 0000000000..7dbd1e3556 --- /dev/null +++ b/blocksuite/blocks/src/root-block/widgets/ai-panel/components/state/error.ts @@ -0,0 +1,263 @@ +import type { EditorHost } from '@blocksuite/block-std'; +import { WithDisposable } from '@blocksuite/global/utils'; +import { baseTheme } from '@toeverything/theme'; +import { css, html, LitElement, nothing, unsafeCSS } from 'lit'; +import { property } from 'lit/decorators.js'; +import { choose } from 'lit/directives/choose.js'; + +import { + AIErrorType, + type AIItemGroupConfig, +} from '../../../../../_common/components/index.js'; +import type { AIPanelErrorConfig, CopyConfig } from '../../type.js'; +import { filterAIItemGroup } from '../../utils.js'; + +export class AIPanelError extends WithDisposable(LitElement) { + static override styles = css` + :host { + width: 100%; + display: flex; + flex-direction: column; + gap: 8px; + padding: 0; + font-family: ${unsafeCSS(baseTheme.fontSansFamily)}; + } + + .error { + display: flex; + flex-direction: column; + justify-content: center; + align-items: flex-start; + align-self: stretch; + padding: 0px 12px; + gap: 4px; + .answer-tip { + display: flex; + flex-direction: column; + justify-content: center; + align-items: flex-start; + gap: 4px; + align-self: stretch; + .answer-label { + align-self: stretch; + color: var(--affine-text-secondary-color); + /* light/xsMedium */ + font-size: var(--affine-font-xs); + font-style: normal; + font-weight: 500; + line-height: 20px; /* 166.667% */ + } + } + .error-info { + align-self: stretch; + color: var(--affine-error-color, #eb4335); + font-feature-settings: + 'clig' off, + 'liga' off; + /* light/sm */ + font-size: var(--affine-font-sm); + font-style: normal; + font-weight: 400; + line-height: 22px; /* 157.143% */ + + a { + color: inherit; + } + } + .action-button-group { + display: flex; + width: 100%; + gap: 16px; + align-items: center; + justify-content: end; + margin-top: 4px; + } + .action-button { + display: flex; + box-sizing: border-box; + padding: 4px 12px; + justify-content: center; + align-items: center; + gap: 4px; + border-radius: 8px; + border: 1px solid var(--affine-border-color); + background: var(--affine-white); + color: var(--affine-text-primary-color); + /* light/xsMedium */ + font-size: var(--affine-font-xs); + font-style: normal; + font-weight: 500; + line-height: 20px; /* 166.667% */ + } + .action-button:hover { + cursor: pointer; + } + .action-button.primary { + border: 1px solid var(--affine-black-10); + background: var(--affine-primary-color); + color: var(--affine-pure-white); + } + .action-button > span { + display: flex; + align-items: center; + justify-content: center; + padding: 0 4px; + } + .action-button:not(.primary):hover { + background: var(--affine-hover-color); + } + } + + ai-panel-divider { + margin-top: 4px; + } + + .response-list-container { + display: flex; + flex-direction: column; + gap: 4px; + padding: 0 8px; + user-select: none; + } + + .response-list-container ai-item-list { + --item-padding: 4px; + --item-icon-color: var(--affine-icon-secondary); + --item-icon-hover-color: var(--affine-icon-color); + } + `; + + private _getResponseGroup = () => { + let responseGroup: AIItemGroupConfig[] = []; + const errorType = this.config.error?.type; + if (errorType && errorType !== AIErrorType.GeneralNetworkError) { + return responseGroup; + } + + responseGroup = filterAIItemGroup(this.host, this.config.responses); + + return responseGroup; + }; + + override render() { + const responseGroup = this._getResponseGroup(); + const errorTemplate = choose( + this.config.error?.type, + [ + [ + AIErrorType.Unauthorized, + () => + html` <div class="error-info"> + You need to login to AFFiNE Cloud to continue using AFFiNE AI. + </div> + <div class="action-button-group"> + <div @click=${this.config.cancel} class="action-button"> + <span>Cancel</span> + </div> + <div @click=${this.config.login} class="action-button primary"> + <span>login</span> + </div> + </div>`, + ], + [ + AIErrorType.PaymentRequired, + () => + html` <div class="error-info"> + You've reached the current usage cap for AFFiNE AI. You can + subscribe to AFFiNE AI to continue the AI experience! + </div> + <div class="action-button-group"> + <div @click=${this.config.cancel} class="action-button"> + <span>Cancel</span> + </div> + <div + @click=${this.config.upgrade} + class="action-button primary" + > + <span>Upgrade</span> + </div> + </div>`, + ], + ], + // default error handler + () => { + const tip = this.config.error?.message; + const error = tip + ? html`<span class="error-tip" + >An error occurred<affine-tooltip + tip-position="bottom-start" + .arrow=${false} + >${tip}</affine-tooltip + ></span + >` + : 'An error occurred'; + return html` + <style> + .error-tip { + text-decoration: underline; + } + </style> + <div class="error-info"> + ${error}. Please try again later. If this issue persists, please let + us know at + <a href="mailto:support@toeverything.info"> + support@toeverything.info + </a> + </div> + `; + } + ); + + return html` + <div class="error"> + <div class="answer-tip"> + <div class="answer-label">Answer</div> + <slot></slot> + </div> + ${errorTemplate} + </div> + ${this.withAnswer + ? html`<ai-finish-tip + .copy=${this.copy} + .host=${this.host} + ></ai-finish-tip>` + : nothing} + ${responseGroup.length > 0 + ? html` + <ai-panel-divider></ai-panel-divider> + ${responseGroup.map( + (group, index) => html` + ${index !== 0 + ? html`<ai-panel-divider></ai-panel-divider>` + : nothing} + <div class="response-list-container"> + <ai-item-list + .host=${this.host} + .groups=${[group]} + ></ai-item-list> + </div> + ` + )} + ` + : nothing} + `; + } + + @property({ attribute: false }) + accessor config!: AIPanelErrorConfig; + + @property({ attribute: false }) + accessor copy: CopyConfig | undefined = undefined; + + @property({ attribute: false }) + accessor host!: EditorHost; + + @property({ attribute: false }) + accessor withAnswer = false; +} + +declare global { + interface HTMLElementTagNameMap { + 'ai-panel-error': AIPanelError; + } +} diff --git a/blocksuite/blocks/src/root-block/widgets/ai-panel/components/state/generating.ts b/blocksuite/blocks/src/root-block/widgets/ai-panel/components/state/generating.ts new file mode 100644 index 0000000000..7f11324f1b --- /dev/null +++ b/blocksuite/blocks/src/root-block/widgets/ai-panel/components/state/generating.ts @@ -0,0 +1,123 @@ +import { + AIStarIconWithAnimation, + AIStopIcon, +} from '@blocksuite/affine-components/icons'; +import type { ColorScheme } from '@blocksuite/affine-model'; +import { WithDisposable } from '@blocksuite/global/utils'; +import { baseTheme } from '@toeverything/theme'; +import { css, html, LitElement, nothing, unsafeCSS } from 'lit'; +import { property } from 'lit/decorators.js'; + +import type { AIPanelGeneratingConfig } from '../../type.js'; + +export class AIPanelGenerating extends WithDisposable(LitElement) { + static override styles = css` + :host { + width: 100%; + padding: 0 12px; + box-sizing: border-box; + font-family: ${unsafeCSS(baseTheme.fontSansFamily)}; + } + + .generating-tip { + display: flex; + width: 100%; + height: 22px; + align-items: center; + gap: 8px; + + color: var(--affine-brand-color); + + .text { + display: flex; + align-items: flex-start; + gap: 10px; + flex: 1 0 0; + + /* light/smMedium */ + font-size: var(--affine-font-sm); + font-style: normal; + font-weight: 500; + line-height: 22px; /* 157.143% */ + } + + .left, + .right { + display: flex; + height: 20px; + justify-content: center; + align-items: center; + } + .left { + width: 20px; + } + .right { + gap: 6px; + } + .right:hover { + cursor: pointer; + } + .stop-icon { + height: 20px; + width: 20px; + } + .esc-label { + font-size: var(--affine-font-xs); + font-weight: 500; + line-height: 20px; + } + } + `; + + override render() { + const { + generatingIcon = AIStarIconWithAnimation, + stages, + height = 300, + } = this.config; + return html` + ${stages && stages.length > 0 + ? html`<generating-placeholder + .height=${height} + .theme=${this.theme} + .loadingProgress=${this.loadingProgress} + .stages=${stages} + .showHeader=${!this.withAnswer} + ></generating-placeholder>` + : nothing} + <div class="generating-tip"> + <div class="left">${generatingIcon}</div> + <div class="text">AI is generating...</div> + <div @click=${this.stopGenerating} class="right"> + <span class="stop-icon">${AIStopIcon}</span> + <span class="esc-label">ESC</span> + </div> + </div> + `; + } + + updateLoadingProgress(progress: number) { + this.loadingProgress = progress; + } + + @property({ attribute: false }) + accessor config!: AIPanelGeneratingConfig; + + @property({ attribute: false }) + accessor loadingProgress: number = 1; + + @property({ attribute: false }) + accessor stopGenerating!: () => void; + + @property({ attribute: false }) + accessor theme!: ColorScheme; + + @property({ attribute: false }) + accessor withAnswer!: boolean; +} + +declare global { + interface HTMLElementTagNameMap { + 'ai-panel-generating': AIPanelGenerating; + } +} diff --git a/blocksuite/blocks/src/root-block/widgets/ai-panel/components/state/index.ts b/blocksuite/blocks/src/root-block/widgets/ai-panel/components/state/index.ts new file mode 100644 index 0000000000..2b6728a04c --- /dev/null +++ b/blocksuite/blocks/src/root-block/widgets/ai-panel/components/state/index.ts @@ -0,0 +1,4 @@ +export * from './answer.js'; +export * from './error.js'; +export * from './generating.js'; +export * from './input.js'; diff --git a/blocksuite/blocks/src/root-block/widgets/ai-panel/components/state/input.ts b/blocksuite/blocks/src/root-block/widgets/ai-panel/components/state/input.ts new file mode 100644 index 0000000000..0e57647890 --- /dev/null +++ b/blocksuite/blocks/src/root-block/widgets/ai-panel/components/state/input.ts @@ -0,0 +1,174 @@ +import { AIStarIcon } from '@blocksuite/affine-components/icons'; +import { stopPropagation } from '@blocksuite/affine-shared/utils'; +import { WithDisposable } from '@blocksuite/global/utils'; +import { SendIcon } from '@blocksuite/icons/lit'; +import { css, html, LitElement, nothing } from 'lit'; +import { property, query, state } from 'lit/decorators.js'; + +export class AIPanelInput extends WithDisposable(LitElement) { + static override styles = css` + :host { + width: 100%; + padding: 0 12px; + box-sizing: border-box; + } + + .root { + display: flex; + align-items: flex-start; + gap: 8px; + background: var(--affine-background-overlay-panel-color); + } + + .icon { + display: flex; + align-items: center; + } + + .textarea-container { + display: flex; + align-items: flex-end; + gap: 8px; + flex: 1 0 0; + + textarea { + flex: 1 0 0; + border: none; + outline: none; + -webkit-box-shadow: none; + -moz-box-shadow: none; + box-shadow: none; + background-color: transparent; + resize: none; + overflow: hidden; + padding: 0px; + + color: var(--affine-text-primary-color); + + /* light/sm */ + font-family: var(--affine-font-family); + font-size: var(--affine-font-sm); + font-style: normal; + font-weight: 400; + line-height: 22px; /* 157.143% */ + } + + textarea::placeholder { + color: var(--affine-placeholder-color); + } + + textarea::-moz-placeholder { + color: var(--affine-placeholder-color); + } + } + + .arrow { + display: flex; + align-items: center; + padding: 2px; + gap: 10px; + border-radius: 4px; + background: var(--affine-black-10, rgba(0, 0, 0, 0.1)); + + svg { + width: 16px; + height: 16px; + color: var(--affine-pure-white, #fff); + } + } + .arrow[data-active] { + background: var(--affine-brand-color, #1e96eb); + } + .arrow[data-active]:hover { + cursor: pointer; + } + `; + + private _onInput = () => { + this.textarea.style.height = 'auto'; + this.textarea.style.height = this.textarea.scrollHeight + 'px'; + + this.onInput?.(this.textarea.value); + const value = this.textarea.value.trim(); + if (value.length > 0) { + this._arrow.dataset.active = ''; + this._hasContent = true; + } else { + delete this._arrow.dataset.active; + this._hasContent = false; + } + }; + + private _onKeyDown = (e: KeyboardEvent) => { + if (e.key === 'Enter' && !e.shiftKey && !e.isComposing) { + e.preventDefault(); + this._sendToAI(); + } + }; + + private _sendToAI = () => { + const value = this.textarea.value.trim(); + if (value.length === 0) return; + + this.onFinish?.(value); + this.remove(); + }; + + override render() { + return html`<div class="root"> + <div class="icon">${AIStarIcon}</div> + <div class="textarea-container"> + <textarea + placeholder="What are your thoughts?" + rows="1" + @keydown=${this._onKeyDown} + @input=${this._onInput} + @pointerdown=${stopPropagation} + @click=${stopPropagation} + @dblclick=${stopPropagation} + @cut=${stopPropagation} + @copy=${stopPropagation} + @paste=${stopPropagation} + @keyup=${stopPropagation} + ></textarea> + <div + class="arrow" + @click=${this._sendToAI} + @pointerdown=${stopPropagation} + > + ${SendIcon()} + ${this._hasContent + ? html`<affine-tooltip .offset=${12}>Send to AI</affine-tooltip>` + : nothing} + </div> + </div> + </div>`; + } + + override updated(_changedProperties: Map<PropertyKey, unknown>): void { + const result = super.updated(_changedProperties); + this.textarea.style.height = this.textarea.scrollHeight + 'px'; + return result; + } + + @query('.arrow') + private accessor _arrow!: HTMLDivElement; + + @state() + private accessor _hasContent = false; + + @property({ attribute: false }) + accessor onFinish: ((input: string) => void) | undefined = undefined; + + @property({ attribute: false }) + accessor onInput: ((input: string) => void) | undefined = undefined; + + @query('textarea') + accessor textarea!: HTMLTextAreaElement; +} + +declare global { + interface HTMLElementTagNameMap { + 'ai-panel-input': AIPanelInput; + } +} diff --git a/blocksuite/blocks/src/root-block/widgets/ai-panel/type.ts b/blocksuite/blocks/src/root-block/widgets/ai-panel/type.ts new file mode 100644 index 0000000000..45be2e8f3b --- /dev/null +++ b/blocksuite/blocks/src/root-block/widgets/ai-panel/type.ts @@ -0,0 +1,60 @@ +import type { nothing, TemplateResult } from 'lit'; + +import type { + AIError, + AIItemGroupConfig, +} from '../../../_common/components/ai-item/types.js'; + +export interface CopyConfig { + allowed: boolean; + onCopy: () => boolean | Promise<boolean>; +} + +export interface AIPanelAnswerConfig { + responses: AIItemGroupConfig[]; + actions: AIItemGroupConfig[]; +} + +export interface AIPanelErrorConfig { + login: () => void; + upgrade: () => void; + cancel: () => void; + responses: AIItemGroupConfig[]; + error?: AIError; +} + +export interface AIPanelGeneratingConfig { + generatingIcon: TemplateResult<1>; + height?: number; + stages?: string[]; +} + +export interface AffineAIPanelWidgetConfig { + answerRenderer: ( + answer: string, + state?: AffineAIPanelState + ) => TemplateResult<1> | typeof nothing; + generateAnswer?: (props: { + input: string; + update: (answer: string) => void; + finish: (type: 'success' | 'error' | 'aborted', err?: AIError) => void; + // Used to allow users to stop actively when generating + signal: AbortSignal; + }) => void; + + finishStateConfig: AIPanelAnswerConfig; + generatingStateConfig: AIPanelGeneratingConfig; + errorStateConfig: AIPanelErrorConfig; + hideCallback?: () => void; + discardCallback?: () => void; + inputCallback?: (input: string) => void; + + copy?: CopyConfig; +} + +export type AffineAIPanelState = + | 'hidden' + | 'input' + | 'generating' + | 'finished' + | 'error'; diff --git a/blocksuite/blocks/src/root-block/widgets/ai-panel/utils.ts b/blocksuite/blocks/src/root-block/widgets/ai-panel/utils.ts new file mode 100644 index 0000000000..c6550e7d58 --- /dev/null +++ b/blocksuite/blocks/src/root-block/widgets/ai-panel/utils.ts @@ -0,0 +1,21 @@ +import { isInsidePageEditor } from '@blocksuite/affine-shared/utils'; +import type { EditorHost } from '@blocksuite/block-std'; + +import type { AIItemGroupConfig } from '../../../_common/components/ai-item/types.js'; + +export function filterAIItemGroup( + host: EditorHost, + configs: AIItemGroupConfig[] +): AIItemGroupConfig[] { + const editorMode = isInsidePageEditor(host) ? 'page' : 'edgeless'; + return configs + .map(group => ({ + ...group, + items: group.items.filter(item => + item.showWhen + ? item.showWhen(host.command.chain(), editorMode, host) + : true + ), + })) + .filter(group => group.items.length > 0); +} diff --git a/blocksuite/blocks/src/root-block/widgets/code-toolbar/components/code-toolbar.ts b/blocksuite/blocks/src/root-block/widgets/code-toolbar/components/code-toolbar.ts new file mode 100644 index 0000000000..0d1c536ef2 --- /dev/null +++ b/blocksuite/blocks/src/root-block/widgets/code-toolbar/components/code-toolbar.ts @@ -0,0 +1,149 @@ +import { MoreVerticalIcon } from '@blocksuite/affine-components/icons'; +import { createLitPortal } from '@blocksuite/affine-components/portal'; +import type { + EditorIconButton, + MenuItemGroup, +} from '@blocksuite/affine-components/toolbar'; +import { renderGroups } from '@blocksuite/affine-components/toolbar'; +import { assertExists, noop, WithDisposable } from '@blocksuite/global/utils'; +import { flip, offset } from '@floating-ui/dom'; +import { css, html, LitElement } from 'lit'; +import { property, query, state } from 'lit/decorators.js'; +import { styleMap } from 'lit/directives/style-map.js'; + +import type { CodeBlockToolbarContext } from '../context.js'; + +export class AffineCodeToolbar extends WithDisposable(LitElement) { + static override styles = css` + :host { + position: absolute; + top: 0; + right: 0; + } + + .code-toolbar-container { + height: 24px; + gap: 4px; + padding: 4px; + margin: 0; + } + + .code-toolbar-button { + color: var(--affine-icon-color); + background-color: var(--affine-background-primary-color); + box-shadow: var(--affine-shadow-1); + border-radius: 4px; + } + `; + + private _currentOpenMenu: AbortController | null = null; + + private _popMenuAbortController: AbortController | null = null; + + closeCurrentMenu = () => { + if (this._currentOpenMenu && !this._currentOpenMenu.signal.aborted) { + this._currentOpenMenu.abort(); + this._currentOpenMenu = null; + } + }; + + private _toggleMoreMenu() { + if ( + this._currentOpenMenu && + !this._currentOpenMenu.signal.aborted && + this._currentOpenMenu === this._popMenuAbortController + ) { + this.closeCurrentMenu(); + this._moreMenuOpen = false; + return; + } + + this.closeCurrentMenu(); + this._popMenuAbortController = new AbortController(); + this._popMenuAbortController.signal.addEventListener('abort', () => { + this._moreMenuOpen = false; + this.onActiveStatusChange(false); + }); + this.onActiveStatusChange(true); + + this._currentOpenMenu = this._popMenuAbortController; + + assertExists(this._moreButton); + + createLitPortal({ + template: html` + <editor-menu-content + data-show + class="more-popup-menu" + style=${styleMap({ + '--content-padding': '8px', + '--packed-height': '4px', + })} + > + <div data-size="large" data-orientation="vertical"> + ${renderGroups(this.moreGroups, this.context)} + </div> + </editor-menu-content> + `, + // should be greater than block-selection z-index as selection and popover wil share the same stacking context(editor-host) + portalStyles: { + zIndex: 'var(--affine-z-index-popover)', + }, + container: this.context.host, + computePosition: { + referenceElement: this._moreButton, + placement: 'bottom-start', + middleware: [flip(), offset(4)], + autoUpdate: { animationFrame: true }, + }, + abortController: this._popMenuAbortController, + closeOnClickAway: true, + }); + this._moreMenuOpen = true; + } + + override disconnectedCallback() { + super.disconnectedCallback(); + this.closeCurrentMenu(); + } + + override render() { + return html` + <editor-toolbar class="code-toolbar-container" data-without-bg> + ${renderGroups(this.primaryGroups, this.context)} + <editor-icon-button + class="code-toolbar-button more" + data-testid="more" + aria-label="More" + .tooltip=${'More'} + .tooltipOffset=${4} + .iconSize=${'16px'} + .iconContainerPadding=${4} + .showTooltip=${!this._moreMenuOpen} + ?disabled=${this.context.doc.readonly} + @click=${() => this._toggleMoreMenu()} + > + ${MoreVerticalIcon} + </editor-icon-button> + </editor-toolbar> + `; + } + + @query('.code-toolbar-button.more') + private accessor _moreButton!: EditorIconButton; + + @state() + private accessor _moreMenuOpen = false; + + @property({ attribute: false }) + accessor context!: CodeBlockToolbarContext; + + @property({ attribute: false }) + accessor moreGroups!: MenuItemGroup<CodeBlockToolbarContext>[]; + + @property({ attribute: false }) + accessor onActiveStatusChange: (active: boolean) => void = noop; + + @property({ attribute: false }) + accessor primaryGroups!: MenuItemGroup<CodeBlockToolbarContext>[]; +} diff --git a/blocksuite/blocks/src/root-block/widgets/code-toolbar/components/lang-button.ts b/blocksuite/blocks/src/root-block/widgets/code-toolbar/components/lang-button.ts new file mode 100644 index 0000000000..147c1f4c8c --- /dev/null +++ b/blocksuite/blocks/src/root-block/widgets/code-toolbar/components/lang-button.ts @@ -0,0 +1,154 @@ +import { ArrowDownIcon } from '@blocksuite/affine-components/icons'; +import { unsafeCSSVarV2 } from '@blocksuite/affine-shared/theme'; +import { noop, SignalWatcher, WithDisposable } from '@blocksuite/global/utils'; +import { css, LitElement, nothing } from 'lit'; +import { property, query } from 'lit/decorators.js'; +import { styleMap } from 'lit/directives/style-map.js'; +import { html } from 'lit/static-html.js'; + +import { + type FilterableListItem, + type FilterableListOptions, + showPopFilterableList, +} from '../../../../_common/components/filterable-list/index.js'; +import type { CodeBlockComponent } from '../../../../code-block/code-block.js'; + +export class LanguageListButton extends WithDisposable( + SignalWatcher(LitElement) +) { + static override styles = css` + .lang-button { + background-color: var(--affine-background-primary-color); + box-shadow: var(--affine-shadow-1); + display: flex; + gap: 4px; + padding: 2px 4px; + } + + .lang-button:hover { + background: var(--affine-hover-color-filled); + } + + .lang-button[hover] { + background: var(--affine-hover-color-filled); + } + + .lang-button-icon { + display: flex; + align-items: center; + color: ${unsafeCSSVarV2('icon/primary')}; + + svg { + height: 16px; + width: 16px; + } + } + `; + + private _abortController?: AbortController; + + private _clickLangBtn = () => { + if (this.blockComponent.doc.readonly) return; + if (this._abortController) { + // Close the language list if it's already opened. + this._abortController.abort(); + return; + } + this._abortController = new AbortController(); + this._abortController.signal.addEventListener('abort', () => { + this.onActiveStatusChange(false); + this._abortController = undefined; + }); + this.onActiveStatusChange(true); + + const options: FilterableListOptions = { + placeholder: 'Search for a language', + onSelect: item => { + const sortedBundledLanguages = this._sortedBundledLanguages; + const index = sortedBundledLanguages.indexOf(item); + if (index !== -1) { + sortedBundledLanguages.splice(index, 1); + sortedBundledLanguages.unshift(item); + } + this.blockComponent.doc.transact(() => { + this.blockComponent.model.language$.value = item.name; + }); + }, + active: item => item.name === this.blockComponent.model.language, + items: this._sortedBundledLanguages, + }; + + showPopFilterableList({ + options, + referenceElement: this._langButton, + container: this.blockComponent.host, + abortController: this._abortController, + // stacking-context(editor-host) + portalStyles: { + zIndex: 'var(--affine-z-index-popover)', + }, + }); + }; + + private _sortedBundledLanguages: FilterableListItem[] = []; + + override connectedCallback(): void { + super.connectedCallback(); + + const langList = localStorage.getItem('blocksuite:code-block:lang-list'); + if (langList) { + this._sortedBundledLanguages = JSON.parse(langList); + } else { + this._sortedBundledLanguages = this.blockComponent.service.langs.map( + lang => ({ + label: lang.name, + name: lang.id, + aliases: lang.aliases, + }) + ); + } + + this.disposables.add(() => { + localStorage.setItem( + 'blocksuite:code-block:lang-list', + JSON.stringify(this._sortedBundledLanguages) + ); + }); + } + + override render() { + const textStyles = styleMap({ + fontFamily: 'Inter', + fontSize: 'var(--affine-font-xs)', + fontStyle: 'normal', + fontWeight: '500', + lineHeight: '20px', + padding: '0 4px', + }); + + return html`<icon-button + class="lang-button" + data-testid="lang-button" + width="auto" + .text=${html`<div style=${textStyles}> + ${this.blockComponent.languageName$.value} + </div>`} + height="24px" + @click=${this._clickLangBtn} + ?disabled=${this.blockComponent.doc.readonly} + > + <span class="lang-button-icon" slot="suffix"> + ${!this.blockComponent.doc.readonly ? ArrowDownIcon : nothing} + </span> + </icon-button> `; + } + + @query('.lang-button') + private accessor _langButton!: HTMLElement; + + @property({ attribute: false }) + accessor blockComponent!: CodeBlockComponent; + + @property({ attribute: false }) + accessor onActiveStatusChange: (active: boolean) => void = noop; +} diff --git a/blocksuite/blocks/src/root-block/widgets/code-toolbar/config.ts b/blocksuite/blocks/src/root-block/widgets/code-toolbar/config.ts new file mode 100644 index 0000000000..2a916b044a --- /dev/null +++ b/blocksuite/blocks/src/root-block/widgets/code-toolbar/config.ts @@ -0,0 +1,177 @@ +import { + CancelWrapIcon, + CaptionIcon, + CopyIcon, + DeleteIcon, + DuplicateIcon, + WrapIcon, +} from '@blocksuite/affine-components/icons'; +import type { MenuItemGroup } from '@blocksuite/affine-components/toolbar'; +import { isInsidePageEditor } from '@blocksuite/affine-shared/utils'; +import { noop, sleep } from '@blocksuite/global/utils'; +import { html } from 'lit'; +import { ifDefined } from 'lit/directives/if-defined.js'; + +import type { CodeBlockToolbarContext } from './context.js'; +import { duplicateCodeBlock } from './utils.js'; + +export const PRIMARY_GROUPS: MenuItemGroup<CodeBlockToolbarContext>[] = [ + { + type: 'primary', + items: [ + { + type: 'change-lang', + generate: ({ blockComponent, setActive }) => { + const state = { active: false }; + return { + action: noop, + render: () => + html`<language-list-button + .blockComponent=${blockComponent} + .onActiveStatusChange=${async (active: boolean) => { + state.active = active; + if (!active) { + await sleep(1000); + if (state.active) return; + } + setActive(active); + }} + > + </language-list-button>`, + }; + }, + }, + { + type: 'copy-code', + label: 'Copy code', + icon: CopyIcon, + generate: ({ blockComponent }) => { + return { + action: () => { + blockComponent.copyCode(); + }, + render: item => html` + <editor-icon-button + class="code-toolbar-button copy-code" + aria-label=${ifDefined(item.label)} + .tooltip=${item.label} + .tooltipOffset=${4} + .iconSize=${'16px'} + .iconContainerPadding=${4} + @click=${(e: MouseEvent) => { + e.stopPropagation(); + item.action(); + }} + > + ${item.icon} + </editor-icon-button> + `, + }; + }, + }, + { + type: 'caption', + label: 'Caption', + icon: CaptionIcon, + when: ({ doc }) => !doc.readonly, + generate: ({ blockComponent }) => { + return { + action: () => { + blockComponent.captionEditor?.show(); + }, + render: item => html` + <editor-icon-button + class="code-toolbar-button caption" + aria-label=${ifDefined(item.label)} + .tooltip=${item.label} + .tooltipOffset=${4} + .iconSize=${'16px'} + .iconContainerPadding=${4} + @click=${(e: MouseEvent) => { + e.stopPropagation(); + item.action(); + }} + > + ${item.icon} + </editor-icon-button> + `, + }; + }, + }, + ], + }, +]; + +// Clipboard Group +export const clipboardGroup: MenuItemGroup<CodeBlockToolbarContext> = { + type: 'clipboard', + items: [ + { + type: 'wrap', + generate: ({ blockComponent, close }) => { + const wrapped = blockComponent.model.wrap; + const label = wrapped ? 'Cancel wrap' : 'Wrap'; + const icon = wrapped ? CancelWrapIcon : WrapIcon; + + return { + label, + icon, + action: () => { + blockComponent.setWrap(!wrapped); + close(); + }, + }; + }, + }, + { + type: 'duplicate', + label: 'Duplicate', + icon: DuplicateIcon, + when: ({ doc }) => !doc.readonly, + action: ({ host, blockComponent, close }) => { + const codeId = duplicateCodeBlock(blockComponent.model); + + host.updateComplete + .then(() => { + host.selection.setGroup('note', [ + host.selection.create('block', { + blockId: codeId, + }), + ]); + + if (isInsidePageEditor(host)) { + const duplicateElement = host.view.getBlock(codeId); + if (duplicateElement) { + duplicateElement.scrollIntoView({ block: 'nearest' }); + } + } + }) + .catch(console.error); + + close(); + }, + }, + ], +}; + +// Delete Group +export const deleteGroup: MenuItemGroup<CodeBlockToolbarContext> = { + type: 'delete', + items: [ + { + type: 'delete', + label: 'Delete', + icon: DeleteIcon, + when: ({ doc }) => !doc.readonly, + action: ({ doc, blockComponent, close }) => { + doc.deleteBlock(blockComponent.model); + close(); + }, + }, + ], +}; + +export const MORE_GROUPS: MenuItemGroup<CodeBlockToolbarContext>[] = [ + clipboardGroup, + deleteGroup, +]; diff --git a/blocksuite/blocks/src/root-block/widgets/code-toolbar/context.ts b/blocksuite/blocks/src/root-block/widgets/code-toolbar/context.ts new file mode 100644 index 0000000000..f15fceb686 --- /dev/null +++ b/blocksuite/blocks/src/root-block/widgets/code-toolbar/context.ts @@ -0,0 +1,45 @@ +import type { CodeBlockComponent } from '../../../code-block/code-block.js'; +import { MenuContext } from '../../configs/toolbar.js'; + +export class CodeBlockToolbarContext extends MenuContext { + override close = () => { + this.abortController.abort(); + }; + + get doc() { + return this.blockComponent.doc; + } + + get host() { + return this.blockComponent.host; + } + + get selectedBlockModels() { + if (this.blockComponent.model) return [this.blockComponent.model]; + return []; + } + + get std() { + return this.blockComponent.std; + } + + constructor( + public blockComponent: CodeBlockComponent, + public abortController: AbortController, + public setActive: (active: boolean) => void + ) { + super(); + } + + isEmpty() { + return false; + } + + isMultiple() { + return false; + } + + isSingle() { + return true; + } +} diff --git a/blocksuite/blocks/src/root-block/widgets/code-toolbar/effects.ts b/blocksuite/blocks/src/root-block/widgets/code-toolbar/effects.ts new file mode 100644 index 0000000000..aaa381830f --- /dev/null +++ b/blocksuite/blocks/src/root-block/widgets/code-toolbar/effects.ts @@ -0,0 +1,20 @@ +import { AffineCodeToolbar } from './components/code-toolbar.js'; +import { LanguageListButton } from './components/lang-button.js'; +import { + AFFINE_CODE_TOOLBAR_WIDGET, + AffineCodeToolbarWidget, +} from './index.js'; + +export function effects() { + customElements.define('language-list-button', LanguageListButton); + customElements.define('affine-code-toolbar', AffineCodeToolbar); + customElements.define(AFFINE_CODE_TOOLBAR_WIDGET, AffineCodeToolbarWidget); +} + +declare global { + interface HTMLElementTagNameMap { + 'language-list-button': LanguageListButton; + 'affine-code-toolbar': AffineCodeToolbar; + [AFFINE_CODE_TOOLBAR_WIDGET]: AffineCodeToolbarWidget; + } +} diff --git a/blocksuite/blocks/src/root-block/widgets/code-toolbar/index.ts b/blocksuite/blocks/src/root-block/widgets/code-toolbar/index.ts new file mode 100644 index 0000000000..eda4c8b359 --- /dev/null +++ b/blocksuite/blocks/src/root-block/widgets/code-toolbar/index.ts @@ -0,0 +1,157 @@ +import { HoverController } from '@blocksuite/affine-components/hover'; +import type { + AdvancedMenuItem, + MenuItemGroup, +} from '@blocksuite/affine-components/toolbar'; +import { cloneGroups } from '@blocksuite/affine-components/toolbar'; +import type { CodeBlockModel } from '@blocksuite/affine-model'; +import { WidgetComponent } from '@blocksuite/block-std'; +import { limitShift, shift } from '@floating-ui/dom'; +import { html } from 'lit'; + +import { PAGE_HEADER_HEIGHT } from '../../../_common/consts.js'; +import type { CodeBlockComponent } from '../../../code-block/code-block.js'; +import { getMoreMenuConfig } from '../../configs/toolbar.js'; +import { MORE_GROUPS, PRIMARY_GROUPS } from './config.js'; +import { CodeBlockToolbarContext } from './context.js'; + +export const AFFINE_CODE_TOOLBAR_WIDGET = 'affine-code-toolbar-widget'; +export class AffineCodeToolbarWidget extends WidgetComponent< + CodeBlockModel, + CodeBlockComponent +> { + private _hoverController: HoverController | null = null; + + private _isActivated = false; + + private _setHoverController = () => { + this._hoverController = null; + this._hoverController = new HoverController( + this, + ({ abortController }) => { + const codeBlock = this.block; + const selection = this.host.selection; + + const textSelection = selection.find('text'); + if ( + !!textSelection && + (!!textSelection.to || !!textSelection.from.length) + ) { + return null; + } + + const blockSelections = selection.filter('block'); + if ( + blockSelections.length > 1 || + (blockSelections.length === 1 && + blockSelections[0].blockId !== codeBlock.blockId) + ) { + return null; + } + + const setActive = (active: boolean) => { + this._isActivated = active; + if (!active && !this._hoverController?.isHovering) { + this._hoverController?.abort(); + } + }; + + const context = new CodeBlockToolbarContext( + codeBlock, + abortController, + setActive + ); + + return { + template: html`<affine-code-toolbar + .context=${context} + .primaryGroups=${this.primaryGroups} + .moreGroups=${this.moreGroups} + .onActiveStatusChange=${setActive} + ></affine-code-toolbar>`, + container: this.block, + // stacking-context(editor-host) + portalStyles: { + zIndex: 'var(--affine-z-index-popover)', + }, + computePosition: { + referenceElement: codeBlock, + placement: 'right-start', + middleware: [ + shift({ + crossAxis: true, + padding: { + top: PAGE_HEADER_HEIGHT + 12, + bottom: 12, + right: 12, + }, + limiter: limitShift(), + }), + ], + autoUpdate: true, + }, + }; + }, + { allowMultiple: true } + ); + + const codeBlock = this.block; + this._hoverController.setReference(codeBlock); + this._hoverController.onAbort = () => { + // If the more menu is opened, don't close it. + if (this._isActivated) return; + this._hoverController?.abort(); + return; + }; + }; + + addMoretems = ( + items: AdvancedMenuItem<CodeBlockToolbarContext>[], + index?: number, + type?: string + ) => { + let group; + if (type) { + group = this.moreGroups.find(g => g.type === type); + } + if (!group) { + group = this.moreGroups[0]; + } + + if (index === undefined) { + group.items.push(...items); + return this; + } + + group.items.splice(index, 0, ...items); + return this; + }; + + addPrimaryItems = ( + items: AdvancedMenuItem<CodeBlockToolbarContext>[], + index?: number + ) => { + if (index === undefined) { + this.primaryGroups[0].items.push(...items); + return this; + } + + this.primaryGroups[0].items.splice(index, 0, ...items); + return this; + }; + + /* + * Caches the more menu items. + * Currently only supports configuring more menu. + */ + protected moreGroups: MenuItemGroup<CodeBlockToolbarContext>[] = + cloneGroups(MORE_GROUPS); + + protected primaryGroups: MenuItemGroup<CodeBlockToolbarContext>[] = + cloneGroups(PRIMARY_GROUPS); + + override firstUpdated() { + this.moreGroups = getMoreMenuConfig(this.std).configure(this.moreGroups); + this._setHoverController(); + } +} diff --git a/blocksuite/blocks/src/root-block/widgets/code-toolbar/utils.ts b/blocksuite/blocks/src/root-block/widgets/code-toolbar/utils.ts new file mode 100644 index 0000000000..ede0e40851 --- /dev/null +++ b/blocksuite/blocks/src/root-block/widgets/code-toolbar/utils.ts @@ -0,0 +1,16 @@ +import type { CodeBlockModel } from '@blocksuite/affine-model'; + +export const duplicateCodeBlock = (model: CodeBlockModel) => { + const keys = model.keys as (keyof typeof model)[]; + const values = keys.map(key => model[key]); + const blockProps = Object.fromEntries(keys.map((key, i) => [key, values[i]])); + const { text: _text, ...duplicateProps } = blockProps; + + const newProps = { + flavour: model.flavour, + text: model.text.clone(), + ...duplicateProps, + }; + + return model.doc.addSiblingBlocks(model, [newProps])[0]; +}; diff --git a/blocksuite/blocks/src/root-block/widgets/doc-remote-selection/config.ts b/blocksuite/blocks/src/root-block/widgets/doc-remote-selection/config.ts new file mode 100644 index 0000000000..8c27917b99 --- /dev/null +++ b/blocksuite/blocks/src/root-block/widgets/doc-remote-selection/config.ts @@ -0,0 +1,5 @@ +import type { BlockModel } from '@blocksuite/store'; + +export type DocRemoteSelectionConfig = { + blockSelectionBackgroundTransparent: (block: BlockModel) => boolean; +}; diff --git a/blocksuite/blocks/src/root-block/widgets/doc-remote-selection/doc-remote-selection.ts b/blocksuite/blocks/src/root-block/widgets/doc-remote-selection/doc-remote-selection.ts new file mode 100644 index 0000000000..fd57a054aa --- /dev/null +++ b/blocksuite/blocks/src/root-block/widgets/doc-remote-selection/doc-remote-selection.ts @@ -0,0 +1,316 @@ +import { matchFlavours } from '@blocksuite/affine-shared/utils'; +import { + type BaseSelection, + BlockSelection, + TextSelection, + WidgetComponent, +} from '@blocksuite/block-std'; +import { assertExists } from '@blocksuite/global/utils'; +import type { UserInfo } from '@blocksuite/store'; +import { computed } from '@preact/signals-core'; +import { css, html, nothing } from 'lit'; +import { styleMap } from 'lit/directives/style-map.js'; + +import { RemoteColorManager } from '../../../root-block/remote-color-manager/remote-color-manager.js'; +import type { DocRemoteSelectionConfig } from './config.js'; +import { cursorStyle, selectionStyle } from './utils.js'; + +export interface SelectionRect { + width: number; + height: number; + top: number; + left: number; + transparent?: boolean; +} + +export const AFFINE_DOC_REMOTE_SELECTION_WIDGET = + 'affine-doc-remote-selection-widget'; + +export class AffineDocRemoteSelectionWidget extends WidgetComponent { + // avoid being unable to select text by mouse click or drag + static override styles = css` + :host { + pointer-events: none; + } + `; + + private _abortController = new AbortController(); + + private _remoteColorManager: RemoteColorManager | null = null; + + private _remoteSelections = computed(() => { + const status = this.doc.awarenessStore.getStates(); + return [...this.std.selection.remoteSelections.entries()].map( + ([id, selections]) => { + return { + id, + selections, + user: status.get(id)?.user, + }; + } + ); + }); + + private _resizeObserver: ResizeObserver = new ResizeObserver(() => { + this.requestUpdate(); + }); + + private get _config(): DocRemoteSelectionConfig { + const config = + this.std.getConfig('affine:page')?.docRemoteSelectionWidget ?? {}; + + return { + blockSelectionBackgroundTransparent: block => { + return ( + matchFlavours(block, [ + 'affine:code', + 'affine:database', + 'affine:image', + 'affine:attachment', + 'affine:bookmark', + 'affine:surface-ref', + ]) || /affine:embed-*/.test(block.flavour) + ); + }, + ...config, + }; + } + + private get _container() { + return this.offsetParent; + } + + private get _containerRect() { + return this.offsetParent?.getBoundingClientRect(); + } + + private get _selectionManager() { + return this.host.selection; + } + + private _getCursorRect(selections: BaseSelection[]): SelectionRect | null { + if (this.block.model.flavour !== 'affine:page') { + console.error('remote selection widget must be used in page component'); + return null; + } + + const textSelection = selections.find( + selection => selection instanceof TextSelection + ) as TextSelection | undefined; + const blockSelections = selections.filter( + selection => selection instanceof BlockSelection + ); + const container = this._container; + const containerRect = this._containerRect; + + if (textSelection) { + const range = this.std.range.textSelectionToRange( + this._selectionManager.create('text', { + from: { + blockId: textSelection.to + ? textSelection.to.blockId + : textSelection.from.blockId, + index: textSelection.to + ? textSelection.to.index + textSelection.to.length + : textSelection.from.index + textSelection.from.length, + length: 0, + }, + to: null, + }) + ); + + if (!range) { + return null; + } + + const container = this._container; + const containerRect = this._containerRect; + const rangeRects = Array.from(range.getClientRects()); + if (rangeRects.length > 0) { + const rect = + rangeRects.length === 1 + ? rangeRects[0] + : rangeRects[rangeRects.length - 1]; + return { + width: 2, + height: rect.height, + top: + rect.top - (containerRect?.top ?? 0) + (container?.scrollTop ?? 0), + left: + rect.left - + (containerRect?.left ?? 0) + + (container?.scrollLeft ?? 0), + }; + } + } else if (blockSelections.length > 0) { + const lastBlockSelection = blockSelections[blockSelections.length - 1]; + + const block = this.host.view.getBlock(lastBlockSelection.blockId); + if (block) { + const rect = block.getBoundingClientRect(); + + return { + width: 2, + height: rect.height, + top: + rect.top - (containerRect?.top ?? 0) + (container?.scrollTop ?? 0), + left: + rect.left + + rect.width - + (containerRect?.left ?? 0) + + (container?.scrollLeft ?? 0), + }; + } + } + + return null; + } + + private _getSelectionRect(selections: BaseSelection[]): SelectionRect[] { + if (this.block.model.flavour !== 'affine:page') { + console.error('remote selection widget must be used in page component'); + return []; + } + + const textSelection = selections.find( + selection => selection instanceof TextSelection + ) as TextSelection | undefined; + const blockSelections = selections.filter( + selection => selection instanceof BlockSelection + ); + + if (!textSelection && !blockSelections.length) return []; + + const { selectionRects } = this.std.command.exec('getSelectionRects', { + textSelection, + blockSelections, + }); + + if (!selectionRects) return []; + + return selectionRects.map(({ blockId, ...rect }) => { + if (!blockId) return rect; + + const block = this.host.view.getBlock(blockId); + if (!block) return rect; + + const isTransparent = this._config.blockSelectionBackgroundTransparent( + block.model + ); + + return { + ...rect, + transparent: isTransparent, + }; + }); + } + + override connectedCallback() { + super.connectedCallback(); + + this.handleEvent('wheel', () => { + this.requestUpdate(); + }); + + this.disposables.addFromEvent(window, 'resize', () => { + this.requestUpdate(); + }); + + this._remoteColorManager = new RemoteColorManager(this.std); + } + + override disconnectedCallback() { + super.disconnectedCallback(); + this._resizeObserver.disconnect(); + this._abortController.abort(); + } + + override render() { + if (this._remoteSelections.value.length === 0) { + return nothing; + } + + const remoteUsers = new Set<number>(); + const selections: Array<{ + id: number; + selections: BaseSelection[]; + rects: SelectionRect[]; + user?: UserInfo; + }> = this._remoteSelections.value.flatMap(({ selections, id, user }) => { + if (remoteUsers.has(id)) { + return []; + } else { + remoteUsers.add(id); + } + + return { + id, + selections, + rects: this._getSelectionRect(selections), + user, + }; + }); + + const remoteColorManager = this._remoteColorManager; + assertExists(remoteColorManager); + return html`<div> + ${selections.flatMap(selection => { + const color = remoteColorManager.get(selection.id); + if (!color) return []; + const cursorRect = this._getCursorRect(selection.selections); + + return selection.rects + .map(r => html`<div style="${selectionStyle(r, color)}"></div>`) + .concat([ + html` + <div + style="${cursorRect + ? cursorStyle(cursorRect, color) + : styleMap({ + display: 'none', + })}" + > + <div + style="${styleMap({ + position: 'relative', + height: '100%', + })}" + > + <div + style="${styleMap({ + position: 'absolute', + left: '-4px', + bottom: `${ + cursorRect?.height ? cursorRect.height - 4 : 0 + }px`, + backgroundColor: color, + color: 'white', + maxWidth: '160px', + padding: '0 3px', + border: '1px solid var(--affine-pure-black-20)', + boxShadow: '0px 1px 6px 0px rgba(0, 0, 0, 0.16)', + borderRadius: '4px', + fontSize: '12px', + lineHeight: '18px', + overflow: 'hidden', + textOverflow: 'ellipsis', + whiteSpace: 'nowrap', + display: selection.user ? 'block' : 'none', + })}" + > + ${selection.user?.name} + </div> + </div> + </div> + `, + ]); + })} + </div>`; + } +} + +declare global { + interface HTMLElementTagNameMap { + [AFFINE_DOC_REMOTE_SELECTION_WIDGET]: AffineDocRemoteSelectionWidget; + } +} diff --git a/blocksuite/blocks/src/root-block/widgets/doc-remote-selection/index.ts b/blocksuite/blocks/src/root-block/widgets/doc-remote-selection/index.ts new file mode 100644 index 0000000000..542720550c --- /dev/null +++ b/blocksuite/blocks/src/root-block/widgets/doc-remote-selection/index.ts @@ -0,0 +1,2 @@ +export * from './config.js'; +export * from './doc-remote-selection.js'; diff --git a/blocksuite/blocks/src/root-block/widgets/doc-remote-selection/utils.ts b/blocksuite/blocks/src/root-block/widgets/doc-remote-selection/utils.ts new file mode 100644 index 0000000000..e5e83aeec2 --- /dev/null +++ b/blocksuite/blocks/src/root-block/widgets/doc-remote-selection/utils.ts @@ -0,0 +1,36 @@ +import type { DirectiveResult } from 'lit/directive.js'; +import { styleMap, type StyleMapDirective } from 'lit/directives/style-map.js'; + +import type { SelectionRect } from './doc-remote-selection.js'; + +export function selectionStyle( + rect: SelectionRect, + color: string +): DirectiveResult<typeof StyleMapDirective> { + return styleMap({ + position: 'absolute', + width: `${rect.width}px`, + height: `${rect.height}px`, + top: `${rect.top}px`, + left: `${rect.left}px`, + backgroundColor: rect.transparent ? 'transparent' : color, + pointerEvent: 'none', + opacity: '20%', + borderRadius: '3px', + }); +} + +export function cursorStyle( + rect: SelectionRect, + color: string +): DirectiveResult<typeof StyleMapDirective> { + return styleMap({ + position: 'absolute', + width: `${rect.width}px`, + height: `${rect.height}px`, + top: `${rect.top}px`, + left: `${rect.left}px`, + backgroundColor: color, + pointerEvent: 'none', + }); +} diff --git a/blocksuite/blocks/src/root-block/widgets/drag-handle/components/drag-preview.ts b/blocksuite/blocks/src/root-block/widgets/drag-handle/components/drag-preview.ts new file mode 100644 index 0000000000..c135e25ab5 --- /dev/null +++ b/blocksuite/blocks/src/root-block/widgets/drag-handle/components/drag-preview.ts @@ -0,0 +1,63 @@ +import type { EditorHost } from '@blocksuite/block-std'; +import { ShadowlessElement } from '@blocksuite/block-std'; +import { Point } from '@blocksuite/global/utils'; +import { baseTheme } from '@toeverything/theme'; +import type { TemplateResult } from 'lit'; +import { html } from 'lit'; +import { property } from 'lit/decorators.js'; + +export class DragPreview extends ShadowlessElement { + offset: Point; + + constructor(offset?: Point) { + super(); + this.offset = offset ?? new Point(0, 0); + } + + override disconnectedCallback() { + if (this.onRemove) { + this.onRemove(); + } + super.disconnectedCallback(); + } + + override render() { + return html`<style> + affine-drag-preview { + box-sizing: border-box; + position: absolute; + display: block; + height: auto; + font-family: ${baseTheme.fontSansFamily}; + font-size: var(--affine-font-base); + line-height: var(--affine-line-height); + color: var(--affine-text-primary-color); + font-weight: 400; + top: 0; + left: 0; + transform-origin: 0 0; + opacity: 0.5; + user-select: none; + pointer-events: none; + caret-color: transparent; + z-index: 3; + } + + .affine-drag-preview-grabbing * { + cursor: grabbing !important; + }</style + >${this.template}`; + } + + @property({ attribute: false }) + accessor onRemove: (() => void) | null = null; + + @property({ attribute: false }) + accessor template: TemplateResult | EditorHost | null = null; +} + +declare global { + interface HTMLElementTagNameMap { + 'affine-drag-preview': DragPreview; + } +} diff --git a/blocksuite/blocks/src/root-block/widgets/drag-handle/components/drop-indicator.ts b/blocksuite/blocks/src/root-block/widgets/drag-handle/components/drop-indicator.ts new file mode 100644 index 0000000000..691357985d --- /dev/null +++ b/blocksuite/blocks/src/root-block/widgets/drag-handle/components/drop-indicator.ts @@ -0,0 +1,45 @@ +import type { Rect } from '@blocksuite/global/utils'; +import { css, html, LitElement } from 'lit'; +import { property } from 'lit/decorators.js'; +import { styleMap } from 'lit/directives/style-map.js'; + +export class DropIndicator extends LitElement { + static override styles = css` + .affine-drop-indicator { + position: absolute; + top: 0; + left: 0; + background: var(--affine-primary-color); + transition-property: height, transform; + transition-duration: 100ms; + transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1); + transition-delay: 0s; + transform-origin: 0 0; + pointer-events: none; + z-index: 2; + } + `; + + override render() { + if (!this.rect) { + return null; + } + const { left, top, width, height } = this.rect; + const style = styleMap({ + width: `${width}px`, + height: `${height}px`, + top: `${top}px`, + left: `${left}px`, + }); + return html`<div class="affine-drop-indicator" style=${style}></div>`; + } + + @property({ attribute: false }) + accessor rect: Rect | null = null; +} + +declare global { + interface HTMLElementTagNameMap { + 'affine-drop-indicator': DropIndicator; + } +} diff --git a/blocksuite/blocks/src/root-block/widgets/drag-handle/config.ts b/blocksuite/blocks/src/root-block/widgets/drag-handle/config.ts new file mode 100644 index 0000000000..745b6ecb8d --- /dev/null +++ b/blocksuite/blocks/src/root-block/widgets/drag-handle/config.ts @@ -0,0 +1,79 @@ +import type { + DragHandleOption, + DropType, +} from '@blocksuite/affine-shared/services'; +import type { Disposable, Rect } from '@blocksuite/global/utils'; + +export const DRAG_HANDLE_CONTAINER_HEIGHT = 24; +export const DRAG_HANDLE_CONTAINER_WIDTH = 16; +export const DRAG_HANDLE_CONTAINER_WIDTH_TOP_LEVEL = 8; +export const DRAG_HANDLE_CONTAINER_OFFSET_LEFT = 2; +export const DRAG_HANDLE_CONTAINER_OFFSET_LEFT_LIST = 18; +export const DRAG_HANDLE_CONTAINER_OFFSET_LEFT_TOP_LEVEL = 5; +export const DRAG_HANDLE_CONTAINER_PADDING = 8; + +export const DRAG_HANDLE_GRABBER_HEIGHT = 12; +export const DRAG_HANDLE_GRABBER_WIDTH = 4; +export const DRAG_HANDLE_GRABBER_WIDTH_HOVERED = 2; +export const DRAG_HANDLE_GRABBER_BORDER_RADIUS = 4; +export const DRAG_HANDLE_GRABBER_MARGIN = 4; + +export const HOVER_AREA_RECT_PADDING_TOP_LEVEL = 6; + +export const NOTE_CONTAINER_PADDING = 24; +export const EDGELESS_NOTE_EXTRA_PADDING = 20; +export const DRAG_HOVER_RECT_PADDING = 4; + +export type DropResult = { + rect: Rect | null; + dropBlockId: string; + dropType: DropType; +}; + +export class DragHandleOptionsRunner { + private optionMap = new Map<DragHandleOption, number>(); + + get options(): DragHandleOption[] { + return Array.from(this.optionMap.keys()); + } + + private _decreaseOptionCount(option: DragHandleOption) { + const count = this.optionMap.get(option) || 0; + if (count > 1) { + this.optionMap.set(option, count - 1); + } else { + this.optionMap.delete(option); + } + } + + private _getExistingOptionWithSameFlavour( + option: DragHandleOption + ): DragHandleOption | undefined { + return Array.from(this.optionMap.keys()).find( + op => op.flavour === option.flavour + ); + } + + getOption(flavour: string): DragHandleOption | undefined { + return this.options.find(option => { + if (typeof option.flavour === 'string') { + return option.flavour === flavour; + } else { + return option.flavour.test(flavour); + } + }); + } + + register(option: DragHandleOption): Disposable { + const currentOption = + this._getExistingOptionWithSameFlavour(option) || option; + const count = this.optionMap.get(currentOption) || 0; + this.optionMap.set(currentOption, count + 1); + + return { + dispose: () => { + this._decreaseOptionCount(currentOption); + }, + }; + } +} diff --git a/blocksuite/blocks/src/root-block/widgets/drag-handle/consts.ts b/blocksuite/blocks/src/root-block/widgets/drag-handle/consts.ts new file mode 100644 index 0000000000..b623e0ed92 --- /dev/null +++ b/blocksuite/blocks/src/root-block/widgets/drag-handle/consts.ts @@ -0,0 +1 @@ +export const AFFINE_DRAG_HANDLE_WIDGET = 'affine-drag-handle-widget'; diff --git a/blocksuite/blocks/src/root-block/widgets/drag-handle/drag-handle.ts b/blocksuite/blocks/src/root-block/widgets/drag-handle/drag-handle.ts new file mode 100644 index 0000000000..b2d23cd67c --- /dev/null +++ b/blocksuite/blocks/src/root-block/widgets/drag-handle/drag-handle.ts @@ -0,0 +1,464 @@ +/* eslint-disable @typescript-eslint/no-non-null-assertion */ +import type { RootBlockModel } from '@blocksuite/affine-model'; +import { + DocModeProvider, + DragHandleConfigIdentifier, + type DropType, +} from '@blocksuite/affine-shared/services'; +import { + getScrollContainer, + isInsideEdgelessEditor, + isInsidePageEditor, + matchFlavours, +} from '@blocksuite/affine-shared/utils'; +import { + type BlockComponent, + type DndEventState, + WidgetComponent, +} from '@blocksuite/block-std'; +import type { GfxBlockElementModel } from '@blocksuite/block-std/gfx'; +import type { IVec } from '@blocksuite/global/utils'; +import { DisposableGroup, Point, Rect } from '@blocksuite/global/utils'; +import { computed, type ReadonlySignal, signal } from '@preact/signals-core'; +import { html } from 'lit'; +import { query, state } from 'lit/decorators.js'; +import { styleMap } from 'lit/directives/style-map.js'; + +import { isTopLevelBlock } from '../../../root-block/edgeless/utils/query.js'; +import { autoScroll } from '../../../root-block/text-selection/utils.js'; +import type { EdgelessRootService } from '../../edgeless/index.js'; +import type { DragPreview } from './components/drag-preview.js'; +import type { DropIndicator } from './components/drop-indicator.js'; +import type { DropResult } from './config.js'; +import { DragHandleOptionsRunner } from './config.js'; +import type { AFFINE_DRAG_HANDLE_WIDGET } from './consts.js'; +import { PreviewHelper } from './helpers/preview-helper.js'; +import { RectHelper } from './helpers/rect-helper.js'; +import { SelectionHelper } from './helpers/selection-helper.js'; +import { styles } from './styles.js'; +import { + calcDropTarget, + containBlock, + containChildBlock, + getClosestBlockByPoint, + getClosestNoteBlock, + isOutOfNoteBlock, + updateDragHandleClassName, +} from './utils.js'; +import { DragEventWatcher } from './watchers/drag-event-watcher.js'; +import { EdgelessWatcher } from './watchers/edgeless-watcher.js'; +import { HandleEventWatcher } from './watchers/handle-event-watcher.js'; +import { KeyboardEventWatcher } from './watchers/keyboard-event-watcher.js'; +import { LegacyDragEventWatcher } from './watchers/legacy-drag-event-watcher.js'; +import { PageWatcher } from './watchers/page-watcher.js'; +import { PointerEventWatcher } from './watchers/pointer-event-watcher.js'; + +export class AffineDragHandleWidget extends WidgetComponent<RootBlockModel> { + static override styles = styles; + + private _anchorModelDisposables: DisposableGroup | null = null; + + private _dragEventWatcher = new DragEventWatcher(this); + + private _getBlockView = (blockId: string) => { + return this.host.view.getBlock(blockId); + }; + + /** + * When dragging, should update indicator position and target drop block id + */ + private _getDropResult = (state: DndEventState): DropResult | null => { + const point = new Point(state.raw.x, state.raw.y); + const closestBlock = getClosestBlockByPoint( + this.host, + this.rootComponent, + point + ); + if (!closestBlock) return null; + + const blockId = closestBlock.model.id; + const model = closestBlock.model; + + const isDatabase = matchFlavours(model, ['affine:database']); + if (isDatabase) return null; + + // note block can only be dropped into another note block + // prevent note block from being dropped into other blocks + const isDraggedElementNote = + this.draggingElements.length === 1 && + matchFlavours(this.draggingElements[0].model, ['affine:note']); + + if (isDraggedElementNote) { + const parent = this.std.doc.getParent(closestBlock.model); + if (!parent) return null; + const parentElement = this._getBlockView(parent.id); + if (!parentElement) return null; + if (!matchFlavours(parentElement.model, ['affine:note'])) return null; + } + + // Should make sure that target drop block is + // neither within the dragging elements + // nor a child-block of any dragging elements + if ( + containBlock( + this.draggingElements.map(block => block.model.id), + blockId + ) || + containChildBlock(this.draggingElements, model) + ) { + return null; + } + + let rect = null; + let dropType: DropType = 'before'; + + const result = calcDropTarget( + point, + model, + closestBlock, + this.draggingElements, + this.scale.peek(), + isDraggedElementNote === false + ); + + if (result) { + rect = result.rect; + dropType = result.dropType; + } + + if (isDraggedElementNote && dropType === 'in') return null; + + const dropIndicator = { + rect, + dropBlockId: blockId, + dropType, + }; + + return dropIndicator; + }; + + private _handleEventWatcher = new HandleEventWatcher(this); + + private _keyboardEventWatcher = new KeyboardEventWatcher(this); + + private _legacyDragEventWatcher = new LegacyDragEventWatcher(this); + + private _pageWatcher = new PageWatcher(this); + + private _removeDropIndicator = () => { + if (this.dropIndicator) { + this.dropIndicator.remove(); + this.dropIndicator = null; + } + }; + + private _reset = () => { + this.draggingElements = []; + this.dropBlockId = ''; + this.dropType = null; + this.lastDragPointerState = null; + this.rafID = 0; + this.dragging = false; + + this.dragHoverRect = null; + this.anchorBlockId.value = null; + this.isDragHandleHovered = false; + this.isHoverDragHandleVisible = false; + this.isTopLevelDragHandleVisible = false; + + this.pointerEventWatcher.reset(); + + this.previewHelper.removeDragPreview(); + this._removeDropIndicator(); + this._resetCursor(); + }; + + private _resetCursor = () => { + document.documentElement.classList.remove('affine-drag-preview-grabbing'); + }; + + private _resetDropResult = () => { + this.dropBlockId = ''; + this.dropType = null; + if (this.dropIndicator) this.dropIndicator.rect = null; + }; + + private _updateDropResult = (dropResult: DropResult | null) => { + if (!this.dropIndicator) return; + this.dropBlockId = dropResult?.dropBlockId ?? ''; + this.dropType = dropResult?.dropType ?? null; + if (dropResult?.rect) { + const offsetParentRect = + this.dragHandleContainerOffsetParent.getBoundingClientRect(); + let { left, top } = dropResult.rect; + left -= offsetParentRect.left; + top -= offsetParentRect.top; + + const { width, height } = dropResult.rect; + + const rect = Rect.fromLWTH(left, width, top, height); + this.dropIndicator.rect = rect; + } else { + this.dropIndicator.rect = dropResult?.rect ?? null; + } + }; + + anchorBlockId = signal<string | null>(null); + + anchorBlockComponent = computed<BlockComponent | null>(() => { + if (!this.anchorBlockId.value) return null; + + return this.std.view.getBlock(this.anchorBlockId.value); + }); + + anchorEdgelessElement: ReadonlySignal<GfxBlockElementModel | null> = computed( + () => { + if (!this.anchorBlockId.value) return null; + if (this.mode === 'page') return null; + + const service = this.std.getService('affine:page') as EdgelessRootService; + const edgelessElement = service.getElementById(this.anchorBlockId.value); + return isTopLevelBlock(edgelessElement) ? edgelessElement : null; + } + ); + + // Single block: drag handle should show on the vertical middle of the first line of element + center: IVec = [0, 0]; + + dragging = false; + + rectHelper = new RectHelper(this); + + draggingAreaRect: ReadonlySignal<Rect | null> = computed( + this.rectHelper.getDraggingAreaRect + ); + + draggingElements: BlockComponent[] = []; + + dragPreview: DragPreview | null = null; + + dropBlockId = ''; + + dropIndicator: DropIndicator | null = null; + + dropType: DropType | null = null; + + edgelessWatcher = new EdgelessWatcher(this); + + handleAnchorModelDisposables = () => { + const block = this.anchorBlockComponent.peek(); + if (!block) return; + const blockModel = block.model; + + if (this._anchorModelDisposables) { + this._anchorModelDisposables.dispose(); + this._anchorModelDisposables = null; + } + + this._anchorModelDisposables = new DisposableGroup(); + this._anchorModelDisposables.add( + blockModel.propsUpdated.on(() => this.hide()) + ); + + this._anchorModelDisposables.add(blockModel.deleted.on(() => this.hide())); + }; + + hide = (force = false) => { + if (this.dragging && !force) return; + updateDragHandleClassName(); + + this.isHoverDragHandleVisible = false; + this.isTopLevelDragHandleVisible = false; + this.isDragHandleHovered = false; + + this.anchorBlockId.value = null; + + if (this.dragHandleContainer) { + this.dragHandleContainer.style.display = 'none'; + } + + if (force) { + this._reset(); + } + }; + + isDragHandleHovered = false; + + isHoverDragHandleVisible = false; + + isTopLevelDragHandleVisible = false; + + lastDragPointerState: DndEventState | null = null; + + noteScale = signal(1); + + readonly optionRunner = new DragHandleOptionsRunner(); + + pointerEventWatcher = new PointerEventWatcher(this); + + previewHelper = new PreviewHelper(this); + + rafID = 0; + + scale = signal(1); + + scaleInNote = computed(() => this.scale.value * this.noteScale.value); + + selectionHelper = new SelectionHelper(this); + + updateDropIndicator = ( + state: DndEventState, + shouldAutoScroll: boolean = false + ) => { + const point = new Point(state.raw.x, state.raw.y); + const closestNoteBlock = getClosestNoteBlock( + this.host, + this.rootComponent, + point + ); + if ( + !closestNoteBlock || + isOutOfNoteBlock(this.host, closestNoteBlock, point, this.scale.peek()) + ) { + this._resetDropResult(); + } else { + const dropResult = this._getDropResult(state); + this._updateDropResult(dropResult); + } + + this.lastDragPointerState = state; + if (this.mode === 'page') { + if (!shouldAutoScroll) return; + + const scrollContainer = getScrollContainer(this.rootComponent); + const result = autoScroll(scrollContainer, state.raw.y); + if (!result) { + this.clearRaf(); + return; + } + this.rafID = requestAnimationFrame(() => + this.updateDropIndicator(state, true) + ); + } else { + this.clearRaf(); + } + }; + + updateDropIndicatorOnScroll = () => { + if ( + !this.dragging || + this.draggingElements.length === 0 || + !this.lastDragPointerState + ) + return; + + const state = this.lastDragPointerState; + this.rafID = requestAnimationFrame(() => + this.updateDropIndicator(state, false) + ); + }; + + private get _enableNewDnd() { + return this.std.doc.awarenessStore.getFlag('enable_new_dnd') ?? true; + } + + get dragHandleContainerOffsetParent() { + return this.dragHandleContainer.parentElement!; + } + + get mode() { + return this.std.get(DocModeProvider).getEditorMode(); + } + + get rootComponent() { + return this.block; + } + + clearRaf() { + if (this.rafID) { + cancelAnimationFrame(this.rafID); + this.rafID = 0; + } + } + + override connectedCallback() { + super.connectedCallback(); + this.std.provider.getAll(DragHandleConfigIdentifier).forEach(config => { + this.optionRunner.register(config); + }); + + this.pointerEventWatcher.watch(); + this._keyboardEventWatcher.watch(); + if (this._enableNewDnd) { + this._dragEventWatcher.watch(); + } else { + this._legacyDragEventWatcher.watch(); + } + } + + override disconnectedCallback() { + this.hide(true); + this._disposables.dispose(); + this._anchorModelDisposables?.dispose(); + super.disconnectedCallback(); + } + + override firstUpdated() { + this.hide(true); + this._disposables.addFromEvent(this.host, 'pointerleave', () => { + this.hide(); + }); + + this._handleEventWatcher.watch(); + + if (isInsidePageEditor(this.host)) { + this._pageWatcher.watch(); + } else if (isInsideEdgelessEditor(this.host)) { + this.edgelessWatcher.watch(); + } + } + + override render() { + const hoverRectStyle = styleMap( + this.dragHoverRect + ? { + width: `${this.dragHoverRect.width}px`, + height: `${this.dragHoverRect.height}px`, + top: `${this.dragHoverRect.top}px`, + left: `${this.dragHoverRect.left}px`, + } + : { + display: 'none', + } + ); + + return html` + <div class="affine-drag-handle-widget"> + <div class="affine-drag-handle-container" draggable="true"> + <div class="affine-drag-handle-grabber"></div> + </div> + <div class="affine-drag-hover-rect" style=${hoverRectStyle}></div> + </div> + `; + } + + @query('.affine-drag-handle-container') + accessor dragHandleContainer!: HTMLDivElement; + + @query('.affine-drag-handle-grabber') + accessor dragHandleGrabber!: HTMLDivElement; + + @state() + accessor dragHoverRect: { + width: number; + height: number; + left: number; + top: number; + } | null = null; +} + +declare global { + interface HTMLElementTagNameMap { + [AFFINE_DRAG_HANDLE_WIDGET]: AffineDragHandleWidget; + } +} diff --git a/blocksuite/blocks/src/root-block/widgets/drag-handle/helpers/preview-helper.ts b/blocksuite/blocks/src/root-block/widgets/drag-handle/helpers/preview-helper.ts new file mode 100644 index 0000000000..bc90963c54 --- /dev/null +++ b/blocksuite/blocks/src/root-block/widgets/drag-handle/helpers/preview-helper.ts @@ -0,0 +1,118 @@ +import { SpecProvider } from '@blocksuite/affine-shared/utils'; +import { + type BlockComponent, + BlockStdScope, + type DndEventState, +} from '@blocksuite/block-std'; +import { Point } from '@blocksuite/global/utils'; +import { BlockViewType, type Query } from '@blocksuite/store'; + +import { DragPreview } from '../components/drag-preview.js'; +import type { AffineDragHandleWidget } from '../drag-handle.js'; + +export class PreviewHelper { + private _calculatePreviewOffset = ( + blocks: BlockComponent[], + state: DndEventState + ) => { + const { top, left } = blocks[0].getBoundingClientRect(); + const previewOffset = new Point(state.raw.x - left, state.raw.y - top); + return previewOffset; + }; + + private _calculateQuery = (selectedIds: string[]): Query => { + const ids: Array<{ id: string; viewType: BlockViewType }> = selectedIds.map( + id => ({ + id, + viewType: BlockViewType.Display, + }) + ); + + // The ancestors of the selected blocks should be rendered as Bypass + selectedIds.forEach(block => { + let parent: string | null = block; + do { + if (!selectedIds.includes(parent)) { + ids.push({ viewType: BlockViewType.Bypass, id: parent }); + } + parent = this.widget.doc.blockCollection.crud.getParent(parent); + } while (parent && !ids.map(({ id }) => id).includes(parent)); + }); + + // The children of the selected blocks should be rendered as Display + const addChildren = (id: string) => { + const children = this.widget.doc.getBlock(id)?.model.children ?? []; + children.forEach(child => { + ids.push({ viewType: BlockViewType.Display, id: child.id }); + addChildren(child.id); + }); + }; + selectedIds.forEach(addChildren); + + return { + match: ids, + mode: 'strict', + }; + }; + + createDragPreview = ( + blocks: BlockComponent[], + state: DndEventState, + dragPreviewEl?: HTMLElement, + dragPreviewOffset?: Point + ): DragPreview => { + if (this.widget.dragPreview) { + this.widget.dragPreview.remove(); + } + + let dragPreview: DragPreview; + if (dragPreviewEl) { + dragPreview = new DragPreview(dragPreviewOffset); + dragPreview.append(dragPreviewEl); + } else { + let width = 0; + blocks.forEach(element => { + width = Math.max(width, element.getBoundingClientRect().width); + }); + + const selectedIds = blocks.map(block => block.model.id); + + const query = this._calculateQuery(selectedIds); + + const doc = this.widget.doc.blockCollection.getDoc({ query }); + + const previewSpec = SpecProvider.getInstance().getSpec('page:preview'); + const previewStd = new BlockStdScope({ + doc, + extensions: previewSpec.value, + }); + const previewTemplate = previewStd.render(); + + const offset = this._calculatePreviewOffset(blocks, state); + const posX = state.raw.x - offset.x; + const posY = state.raw.y - offset.y; + const altKey = state.raw.altKey; + + dragPreview = new DragPreview(offset); + dragPreview.template = previewTemplate; + dragPreview.onRemove = () => { + this.widget.doc.blockCollection.clearQuery(query); + }; + dragPreview.style.width = `${width / this.widget.scaleInNote.peek()}px`; + dragPreview.style.transform = `translate(${posX}px, ${posY}px) scale(${this.widget.scaleInNote.peek()})`; + + dragPreview.style.opacity = altKey ? '1' : '0.5'; + } + this.widget.rootComponent.append(dragPreview); + return dragPreview; + }; + + removeDragPreview = () => { + if (this.widget.dragPreview) { + this.widget.dragPreview.remove(); + this.widget.dragPreview = null; + } + }; + + constructor(readonly widget: AffineDragHandleWidget) {} +} diff --git a/blocksuite/blocks/src/root-block/widgets/drag-handle/helpers/rect-helper.ts b/blocksuite/blocks/src/root-block/widgets/drag-handle/helpers/rect-helper.ts new file mode 100644 index 0000000000..0db43c3939 --- /dev/null +++ b/blocksuite/blocks/src/root-block/widgets/drag-handle/helpers/rect-helper.ts @@ -0,0 +1,96 @@ +/* eslint-disable @typescript-eslint/no-non-null-assertion */ +import { getCurrentNativeRange } from '@blocksuite/affine-shared/utils'; +import type { BlockComponent } from '@blocksuite/block-std'; +import { Rect } from '@blocksuite/global/utils'; + +import { + DRAG_HANDLE_CONTAINER_WIDTH, + DRAG_HOVER_RECT_PADDING, +} from '../config.js'; +import type { AffineDragHandleWidget } from '../drag-handle.js'; +import { + containBlock, + getDragHandleLeftPadding, + includeTextSelection, +} from '../utils.js'; + +export class RectHelper { + private _getHoveredBlocks = (): BlockComponent[] => { + if (!this.widget.isHoverDragHandleVisible || !this.widget.anchorBlockId) + return []; + + const hoverBlock = this.widget.anchorBlockComponent.peek(); + if (!hoverBlock) return []; + + const selections = this.widget.selectionHelper.selectedBlocks; + let blocks: BlockComponent[] = []; + + // When current selection is TextSelection, should cover all the blocks in native range + if (selections.length > 0 && includeTextSelection(selections)) { + const range = getCurrentNativeRange(); + if (!range) return []; + const rangeManager = this.widget.std.range; + if (!rangeManager) return []; + blocks = rangeManager.getSelectedBlockComponentsByRange(range, { + match: el => el.model.role === 'content', + mode: 'highest', + }); + } else { + blocks = this.widget.selectionHelper.selectedBlockComponents; + } + + if ( + containBlock( + blocks.map(block => block.blockId), + this.widget.anchorBlockId.peek()! + ) + ) { + return blocks; + } + + return [hoverBlock]; + }; + + getDraggingAreaRect = (): Rect | null => { + const block = this.widget.anchorBlockComponent.value; + if (!block) return null; + + // When hover block is in selected blocks, should show hover rect on the selected blocks + // Top: the top of the first selected block + // Left: the left of the first selected block + // Right: the largest right of the selected blocks + // Bottom: the bottom of the last selected block + let { left, top, right, bottom } = block.getBoundingClientRect(); + + const blocks = this._getHoveredBlocks(); + + blocks.forEach(block => { + left = Math.min(left, block.getBoundingClientRect().left); + top = Math.min(top, block.getBoundingClientRect().top); + right = Math.max(right, block.getBoundingClientRect().right); + bottom = Math.max(bottom, block.getBoundingClientRect().bottom); + }); + + const offsetLeft = getDragHandleLeftPadding(blocks); + + const offsetParentRect = + this.widget.dragHandleContainerOffsetParent.getBoundingClientRect(); + if (!offsetParentRect) return null; + + left -= offsetParentRect.left; + right -= offsetParentRect.left; + top -= offsetParentRect.top; + bottom -= offsetParentRect.top; + + const scaleInNote = this.widget.scaleInNote.value; + // Add padding to hover rect + left -= (DRAG_HANDLE_CONTAINER_WIDTH + offsetLeft) * scaleInNote; + top -= DRAG_HOVER_RECT_PADDING * scaleInNote; + right += DRAG_HOVER_RECT_PADDING * scaleInNote; + bottom += DRAG_HOVER_RECT_PADDING * scaleInNote; + + return new Rect(left, top, right, bottom); + }; + + constructor(readonly widget: AffineDragHandleWidget) {} +} diff --git a/blocksuite/blocks/src/root-block/widgets/drag-handle/helpers/selection-helper.ts b/blocksuite/blocks/src/root-block/widgets/drag-handle/helpers/selection-helper.ts new file mode 100644 index 0000000000..d451772bd4 --- /dev/null +++ b/blocksuite/blocks/src/root-block/widgets/drag-handle/helpers/selection-helper.ts @@ -0,0 +1,68 @@ +/* eslint-disable @typescript-eslint/no-non-null-assertion */ +import { findNoteBlockModel } from '@blocksuite/affine-shared/utils'; +import type { BlockComponent } from '@blocksuite/block-std'; + +import type { AffineDragHandleWidget } from '../drag-handle.js'; + +export class SelectionHelper { + /** Check if given block component is selected */ + isBlockSelected = (block?: BlockComponent) => { + if (!block) return false; + return this.selectedBlocks.some( + selection => selection.blockId === block.model.id + ); + }; + + setSelectedBlocks = (blocks: BlockComponent[], noteId?: string) => { + const { selection } = this; + const selections = blocks.map(block => + selection.create('block', { + blockId: block.blockId, + }) + ); + + // When current page is edgeless page + // We need to remain surface selection and set editing as true + if (this.widget.mode === 'edgeless') { + const surfaceElementId = noteId + ? noteId + : findNoteBlockModel(blocks[0].model)?.id; + if (!surfaceElementId) return; + const surfaceSelection = selection.create( + 'surface', + blocks[0]!.blockId, + [surfaceElementId], + true + ); + + selections.push(surfaceSelection); + } + + selection.set(selections); + }; + + get selectedBlockComponents() { + return this.selectedBlocks + .map(block => this.widget.std.view.getBlock(block.blockId)) + .filter((block): block is BlockComponent => !!block); + } + + get selectedBlockIds() { + return this.selectedBlocks.map(block => block.blockId); + } + + get selectedBlocks() { + const selection = this.selection; + + // eslint-disable-next-line + return selection.find('text') + ? selection.filter('text') + : selection.filter('block'); + } + + get selection() { + return this.widget.std.selection; + } + + constructor(readonly widget: AffineDragHandleWidget) {} +} diff --git a/blocksuite/blocks/src/root-block/widgets/drag-handle/middleware/new-id-cross-doc.ts b/blocksuite/blocks/src/root-block/widgets/drag-handle/middleware/new-id-cross-doc.ts new file mode 100644 index 0000000000..bbb92fa174 --- /dev/null +++ b/blocksuite/blocks/src/root-block/widgets/drag-handle/middleware/new-id-cross-doc.ts @@ -0,0 +1,16 @@ +import type { BlockStdScope } from '@blocksuite/block-std'; +import type { JobMiddleware } from '@blocksuite/store'; + +export const newIdCrossDoc = + (std: BlockStdScope): JobMiddleware => + ({ slots, collection }) => { + let samePage = false; + slots.beforeImport.on(payload => { + if (payload.type === 'slice') { + samePage = payload.snapshot.pageId === std.doc.id; + } + if (payload.type === 'block' && !samePage) { + payload.snapshot.id = collection.idGenerator(); + } + }); + }; diff --git a/blocksuite/blocks/src/root-block/widgets/drag-handle/middleware/surface-ref-to-embed.ts b/blocksuite/blocks/src/root-block/widgets/drag-handle/middleware/surface-ref-to-embed.ts new file mode 100644 index 0000000000..7f1784794b --- /dev/null +++ b/blocksuite/blocks/src/root-block/widgets/drag-handle/middleware/surface-ref-to-embed.ts @@ -0,0 +1,29 @@ +import type { BlockStdScope } from '@blocksuite/block-std'; +import type { JobMiddleware } from '@blocksuite/store'; + +export const surfaceRefToEmbed = + (std: BlockStdScope): JobMiddleware => + ({ slots, collection }) => { + let pageId: string | null = null; + slots.beforeImport.on(payload => { + if (payload.type === 'slice') { + pageId = payload.snapshot.pageId; + } + }); + slots.beforeImport.on(payload => { + if ( + pageId && + payload.type === 'block' && + payload.snapshot.flavour === 'affine:surface-ref' && + !std.doc.hasBlock(payload.snapshot.id) + ) { + const id = payload.snapshot.id; + payload.snapshot.id = collection.idGenerator(); + payload.snapshot.flavour = 'affine:embed-linked-doc'; + payload.snapshot.props = { + blockId: id, + pageId, + }; + } + }); + }; diff --git a/blocksuite/blocks/src/root-block/widgets/drag-handle/styles.ts b/blocksuite/blocks/src/root-block/widgets/drag-handle/styles.ts new file mode 100644 index 0000000000..ba63d866d8 --- /dev/null +++ b/blocksuite/blocks/src/root-block/widgets/drag-handle/styles.ts @@ -0,0 +1,59 @@ +import { css } from 'lit'; + +import { DRAG_HANDLE_CONTAINER_WIDTH } from './config.js'; + +export const styles = css` + .affine-drag-handle-widget { + display: flex; + position: absolute; + left: 0; + top: 0; + contain: size layout; + } + + .affine-drag-handle-container { + top: 0; + left: 0; + position: absolute; + display: flex; + justify-content: center; + width: ${DRAG_HANDLE_CONTAINER_WIDTH}px; + min-height: 12px; + pointer-events: auto; + user-select: none; + box-sizing: border-box; + } + .affine-drag-handle-container:hover { + cursor: grab; + } + + .affine-drag-handle-grabber { + width: 4px; + height: 100%; + border-radius: 1px; + background: var(--affine-placeholder-color); + transition: width 0.25s ease; + } + + @media print { + .affine-drag-handle-widget { + display: none; + } + } + .affine-drag-hover-rect { + position: absolute; + top: 0; + left: 0; + border-radius: 6px; + background: var(--affine-hover-color); + pointer-events: none; + z-index: 2; + animation: expand 0.25s forwards; + } + @keyframes expand { + 0% { + width: 0; + height: 0; + } + } +`; diff --git a/blocksuite/blocks/src/root-block/widgets/drag-handle/utils.ts b/blocksuite/blocks/src/root-block/widgets/drag-handle/utils.ts new file mode 100644 index 0000000000..c6d2baaeb5 --- /dev/null +++ b/blocksuite/blocks/src/root-block/widgets/drag-handle/utils.ts @@ -0,0 +1,350 @@ +import { ParagraphBlockComponent } from '@blocksuite/affine-block-paragraph'; +import type { ParagraphBlockModel } from '@blocksuite/affine-model'; +import { BLOCK_CHILDREN_CONTAINER_PADDING_LEFT } from '@blocksuite/affine-shared/consts'; +import { + DocModeProvider, + type DropType, +} from '@blocksuite/affine-shared/services'; +import { + findClosestBlockComponent, + getBlockProps, + getClosestBlockComponentByElement, + getClosestBlockComponentByPoint, + getRectByBlockComponent, + matchFlavours, +} from '@blocksuite/affine-shared/utils'; +import type { + BaseSelection, + BlockComponent, + EditorHost, +} from '@blocksuite/block-std'; +import { Point, Rect } from '@blocksuite/global/utils'; +import type { BlockModel } from '@blocksuite/store'; + +import { + getDropRectByPoint, + getHoveringNote, +} from '../../../_common/utils/index.js'; +import { + DRAG_HANDLE_CONTAINER_HEIGHT, + DRAG_HANDLE_CONTAINER_OFFSET_LEFT, + DRAG_HANDLE_CONTAINER_OFFSET_LEFT_LIST, + type DropResult, + EDGELESS_NOTE_EXTRA_PADDING, + NOTE_CONTAINER_PADDING, +} from './config.js'; + +const heightMap: Record<string, number> = { + text: 23, + h1: 40, + h2: 36, + h3: 32, + h4: 32, + h5: 28, + h6: 26, + quote: 46, + list: 24, + database: 28, + image: 28, + divider: 36, +}; + +export const getDragHandleContainerHeight = (model: BlockModel) => { + const flavour = model.flavour; + const index = flavour.indexOf(':'); + let key = flavour.slice(index + 1); + if (key === 'paragraph' && (model as ParagraphBlockModel).type) { + key = (model as ParagraphBlockModel).type; + } + + const height = heightMap[key] ?? DRAG_HANDLE_CONTAINER_HEIGHT; + + return height; +}; + +// To check if the block is a child block of the selected blocks +export const containChildBlock = ( + blocks: BlockComponent[], + childModel: BlockModel +) => { + return blocks.some(block => { + let currentBlock: BlockModel | null = childModel; + while (currentBlock) { + if (currentBlock.id === block.model.id) { + return true; + } + currentBlock = block.doc.getParent(currentBlock.id); + } + return false; + }); +}; + +export const containBlock = (blockIDs: string[], targetID: string) => { + return blockIDs.some(blockID => blockID === targetID); +}; + +// TODO: this is a hack, need to find a better way +export const insideDatabaseTable = (element: Element) => { + return !!element.closest('.affine-database-block-table'); +}; + +export const includeTextSelection = (selections: BaseSelection[]) => { + return selections.some(selection => selection.type === 'text'); +}; + +/** + * Check if the path of two blocks are equal + */ +export const isBlockIdEqual = ( + id1: string | null | undefined, + id2: string | null | undefined +) => { + if (!id1 || !id2) { + return false; + } + return id1 === id2; +}; + +export const isOutOfNoteBlock = ( + editorHost: EditorHost, + noteBlock: Element, + point: Point, + scale: number +) => { + // TODO: need to find a better way to check if the point is out of note block + const rect = noteBlock.getBoundingClientRect(); + const insidePageEditor = + editorHost.std.get(DocModeProvider).getEditorMode() === 'page'; + const padding = + (NOTE_CONTAINER_PADDING + + (insidePageEditor ? 0 : EDGELESS_NOTE_EXTRA_PADDING)) * + scale; + return rect + ? insidePageEditor + ? point.y < rect.top || + point.y > rect.bottom || + point.x > rect.right + padding + : point.y < rect.top || + point.y > rect.bottom || + point.x < rect.left - padding || + point.x > rect.right + padding + : true; +}; + +export const getClosestNoteBlock = ( + editorHost: EditorHost, + rootComponent: BlockComponent, + point: Point +) => { + const isInsidePageEditor = + editorHost.std.get(DocModeProvider).getEditorMode() === 'page'; + return isInsidePageEditor + ? findClosestBlockComponent(rootComponent, point, 'affine-note') + : getHoveringNote(point)?.closest('affine-edgeless-note'); +}; + +export const getClosestBlockByPoint = ( + editorHost: EditorHost, + rootComponent: BlockComponent, + point: Point +) => { + const closestNoteBlock = getClosestNoteBlock( + editorHost, + rootComponent, + point + ); + if (!closestNoteBlock || closestNoteBlock.closest('.affine-surface-ref')) { + return null; + } + + const noteRect = Rect.fromDOM(closestNoteBlock); + + const block = getClosestBlockComponentByPoint(point, { + container: closestNoteBlock, + rect: noteRect, + }) as BlockComponent | null; + + const blockSelector = + '.affine-note-block-container > .affine-block-children-container > [data-block-id]'; + + const closestBlock = ( + block && containChildBlock([closestNoteBlock], block.model) + ? block + : findClosestBlockComponent( + closestNoteBlock as BlockComponent, + point.clone(), + blockSelector + ) + ) as BlockComponent; + + if (!closestBlock || !!closestBlock.closest('.surface-ref-note-portal')) { + return null; + } + + return closestBlock; +}; + +export function calcDropTarget( + point: Point, + model: BlockModel, + element: Element, + draggingElements: BlockComponent[], + scale: number, + /** + * Allow the dragging block to be dropped as sublist + */ + allowSublist: boolean = true +): DropResult | null { + let type: DropType | 'none' = 'none'; + const height = 3 * scale; + const { rect: domRect } = getDropRectByPoint(point, model, element); + + const distanceToTop = Math.abs(domRect.top - point.y); + const distanceToBottom = Math.abs(domRect.bottom - point.y); + const before = distanceToTop < distanceToBottom; + + type = before ? 'before' : 'after'; + let offsetY = 4; + + if (type === 'before') { + // before + let prev; + let prevRect; + + prev = element.previousElementSibling; + if (prev) { + if ( + draggingElements.length && + prev === draggingElements[draggingElements.length - 1] + ) { + type = 'none'; + } else { + prevRect = getRectByBlockComponent(prev); + } + } else { + prev = element.parentElement?.previousElementSibling; + if (prev) { + prevRect = prev.getBoundingClientRect(); + } + } + + if (prevRect) { + offsetY = (domRect.top - prevRect.bottom) / 2; + } + } else { + // Only consider drop as children when target block is list block. + // To drop in, the position must after the target first + // If drop in target has children, we can use insert before or after of that children + // to achieve the same effect. + const hasChild = (element as BlockComponent).childBlocks.length; + if ( + allowSublist && + matchFlavours(model, ['affine:list']) && + !hasChild && + point.x > domRect.x + BLOCK_CHILDREN_CONTAINER_PADDING_LEFT + ) { + type = 'in'; + } + // after + let next; + let nextRect; + + next = element.nextElementSibling; + if (next) { + if ( + type === 'after' && + draggingElements.length && + next === draggingElements[0] + ) { + type = 'none'; + next = null; + } + } else { + next = getClosestBlockComponentByElement( + element.parentElement + )?.nextElementSibling; + } + + if (next) { + nextRect = getRectByBlockComponent(next); + offsetY = (nextRect.top - domRect.bottom) / 2; + } + } + + if (type === 'none') return null; + + let top = domRect.top; + if (type === 'before') { + top -= offsetY; + } else { + top += domRect.height + offsetY; + } + + if (type === 'in') { + domRect.x += BLOCK_CHILDREN_CONTAINER_PADDING_LEFT; + domRect.width -= BLOCK_CHILDREN_CONTAINER_PADDING_LEFT; + } + + return { + rect: Rect.fromLWTH(domRect.left, domRect.width, top - height / 2, height), + dropBlockId: model.id, + dropType: type, + }; +} + +export const getDropResult = ( + event: MouseEvent, + scale: number = 1 +): DropResult | null => { + let dropIndicator = null; + const point = new Point(event.x, event.y); + const closestBlock = getClosestBlockComponentByPoint(point) as BlockComponent; + if (!closestBlock) { + return dropIndicator; + } + + const model = closestBlock.model; + + const isDatabase = matchFlavours(model, ['affine:database']); + if (isDatabase) { + return dropIndicator; + } + + const result = calcDropTarget(point, model, closestBlock, [], scale); + if (result) { + dropIndicator = result; + } + + return dropIndicator; +}; + +export function getDragHandleLeftPadding(blocks: BlockComponent[]) { + const hasToggleList = blocks.some( + block => + (matchFlavours(block.model, ['affine:list']) && + block.model.children.length > 0) || + (block instanceof ParagraphBlockComponent && + block.model.type.startsWith('h') && + block.collapsedSiblings.length > 0) + ); + const offsetLeft = hasToggleList + ? DRAG_HANDLE_CONTAINER_OFFSET_LEFT_LIST + : DRAG_HANDLE_CONTAINER_OFFSET_LEFT; + return offsetLeft; +} + +let previousEle: BlockComponent[] = []; +export function updateDragHandleClassName(blocks: BlockComponent[] = []) { + const className = 'with-drag-handle'; + previousEle.forEach(block => block.classList.remove(className)); + previousEle = blocks; + blocks.forEach(block => block.classList.add(className)); +} + +export function getDuplicateBlocks(blocks: BlockModel[]) { + const duplicateBlocks = blocks.map(block => ({ + flavour: block.flavour, + blockProps: getBlockProps(block), + })); + return duplicateBlocks; +} diff --git a/blocksuite/blocks/src/root-block/widgets/drag-handle/watchers/drag-event-watcher.ts b/blocksuite/blocks/src/root-block/widgets/drag-handle/watchers/drag-event-watcher.ts new file mode 100644 index 0000000000..d82b55d232 --- /dev/null +++ b/blocksuite/blocks/src/root-block/widgets/drag-handle/watchers/drag-event-watcher.ts @@ -0,0 +1,596 @@ +/* eslint-disable @typescript-eslint/no-non-null-assertion */ +import type { EmbedCardStyle, NoteBlockModel } from '@blocksuite/affine-model'; +import { + EMBED_CARD_HEIGHT, + EMBED_CARD_WIDTH, +} from '@blocksuite/affine-shared/consts'; +import { + DndApiExtensionIdentifier, + DocModeProvider, + TelemetryProvider, +} from '@blocksuite/affine-shared/services'; +import { + captureEventTarget, + getBlockComponentsExcludeSubtrees, + getClosestBlockComponentByPoint, + matchFlavours, +} from '@blocksuite/affine-shared/utils'; +import { + type BlockComponent, + type DndEventState, + isGfxBlockComponent, + type UIEventHandler, + type UIEventStateContext, +} from '@blocksuite/block-std'; +import { GfxControllerIdentifier } from '@blocksuite/block-std/gfx'; +import { Bound, Point } from '@blocksuite/global/utils'; +import { Job, Slice, type SliceSnapshot } from '@blocksuite/store'; + +import { + HtmlAdapter, + MarkdownAdapter, +} from '../../../../_common/adapters/index.js'; +import { + calcDropTarget, + type DropResult, +} from '../../../../_common/utils/index.js'; +import type { EdgelessRootBlockComponent } from '../../../edgeless/index.js'; +import { addNoteAtPoint } from '../../../edgeless/utils/common.js'; +import { DropIndicator } from '../components/drop-indicator.js'; +import { AFFINE_DRAG_HANDLE_WIDGET } from '../consts.js'; +import type { AffineDragHandleWidget } from '../drag-handle.js'; +import { newIdCrossDoc } from '../middleware/new-id-cross-doc.js'; +import { surfaceRefToEmbed } from '../middleware/surface-ref-to-embed.js'; +import { containBlock, includeTextSelection } from '../utils.js'; + +export class DragEventWatcher { + private _computeEdgelessBound = ( + x: number, + y: number, + width: number, + height: number + ) => { + const controller = this._std.get(GfxControllerIdentifier); + const border = 2; + const noteScale = this.widget.noteScale.peek(); + const { viewport } = controller; + const { left: viewportLeft, top: viewportTop } = viewport; + const currentViewBound = new Bound( + x - viewportLeft, + y - viewportTop, + width + border / noteScale, + height + border / noteScale + ); + const currentModelBound = viewport.toModelBound(currentViewBound); + return new Bound( + currentModelBound.x, + currentModelBound.y, + width * noteScale, + height * noteScale + ); + }; + + private _createDropIndicator = () => { + if (!this.widget.dropIndicator) { + this.widget.dropIndicator = new DropIndicator(); + this.widget.rootComponent.append(this.widget.dropIndicator); + } + }; + + private _dragEndHandler: UIEventHandler = () => { + this.widget.clearRaf(); + this.widget.hide(true); + }; + + private _dragMoveHandler: UIEventHandler = ctx => { + if ( + this.widget.isHoverDragHandleVisible || + this.widget.isTopLevelDragHandleVisible + ) { + this.widget.hide(); + } + + if (!this.widget.dragging || this.widget.draggingElements.length === 0) { + return false; + } + + ctx.get('defaultState').event.preventDefault(); + const state = ctx.get('dndState'); + + // call default drag move handler if no option return true + return this._onDragMove(state); + }; + + /** + * When start dragging, should set dragging elements and create drag preview + */ + private _dragStartHandler: UIEventHandler = ctx => { + const state = ctx.get('dndState'); + // If not click left button to start dragging, should do nothing + const { button } = state.raw; + if (button !== 0) { + return false; + } + + return this._onDragStart(state); + }; + + private _dropHandler = (context: UIEventStateContext) => { + this._onDrop(context); + this._std.selection.setGroup('gfx', []); + this.widget.clearRaf(); + this.widget.hide(true); + }; + + private _onDragMove = (state: DndEventState) => { + this.widget.clearRaf(); + + this.widget.rafID = requestAnimationFrame(() => { + this.widget.edgelessWatcher.updateDragPreviewPosition(state); + this.widget.updateDropIndicator(state, true); + }); + return true; + }; + + private _onDragStart = (state: DndEventState) => { + // Get current hover block element by path + const hoverBlock = this.widget.anchorBlockComponent.peek(); + if (!hoverBlock) return false; + + const element = captureEventTarget(state.raw.target); + const dragByHandle = !!element?.closest(AFFINE_DRAG_HANDLE_WIDGET); + const isInSurface = isGfxBlockComponent(hoverBlock); + + if (isInSurface && dragByHandle) { + this._startDragging([hoverBlock], state); + return true; + } + + const selectBlockAndStartDragging = () => { + this._std.selection.setGroup('note', [ + this._std.selection.create('block', { + blockId: hoverBlock.blockId, + }), + ]); + this._startDragging([hoverBlock], state); + }; + + if (this.widget.draggingElements.length === 0) { + const dragByBlock = + hoverBlock.contains(element) && !hoverBlock.model.text; + + const canDragByBlock = + matchFlavours(hoverBlock.model, [ + 'affine:attachment', + 'affine:bookmark', + ]) || hoverBlock.model.flavour.startsWith('affine:embed-'); + + if (!isInSurface && dragByBlock && canDragByBlock) { + selectBlockAndStartDragging(); + return true; + } + } + + // Should only start dragging when pointer down on drag handle + // And current mouse button is left button + if (!dragByHandle) { + this.widget.hide(); + return false; + } + + if (this.widget.draggingElements.length === 1 && !isInSurface) { + selectBlockAndStartDragging(); + return true; + } + + if (!this.widget.isHoverDragHandleVisible) return false; + + let selections = this.widget.selectionHelper.selectedBlocks; + + // When current selection is TextSelection + // Should set BlockSelection for the blocks in native range + if (selections.length > 0 && includeTextSelection(selections)) { + const nativeSelection = document.getSelection(); + const rangeManager = this._std.range; + if (nativeSelection && nativeSelection.rangeCount > 0 && rangeManager) { + const range = nativeSelection.getRangeAt(0); + const blocks = rangeManager.getSelectedBlockComponentsByRange(range, { + match: el => el.model.role === 'content', + mode: 'highest', + }); + this.widget.selectionHelper.setSelectedBlocks(blocks); + selections = this.widget.selectionHelper.selectedBlocks; + } + } + + // When there is no selected blocks + // Or selected blocks not including current hover block + // Set current hover block as selected + if ( + selections.length === 0 || + !containBlock( + selections.map(selection => selection.blockId), + this.widget.anchorBlockId.peek()! + ) + ) { + const block = this.widget.anchorBlockComponent.peek(); + if (block) { + this.widget.selectionHelper.setSelectedBlocks([block]); + } + } + + const blocks = this.widget.selectionHelper.selectedBlockComponents; + + // This could be skipped if we can ensure that all selected blocks are on the same level + // Which means not selecting parent block and child block at the same time + const blocksExcludingChildren = getBlockComponentsExcludeSubtrees( + blocks + ) as BlockComponent[]; + + if (blocksExcludingChildren.length === 0) return false; + + this._startDragging(blocksExcludingChildren, state); + this.widget.hide(); + return true; + }; + + private _onDrop = (context: UIEventStateContext) => { + const state = context.get('dndState'); + + const event = state.raw; + const { clientX, clientY } = event; + const point = new Point(clientX, clientY); + const element = getClosestBlockComponentByPoint(point.clone()); + if (!element) { + const target = captureEventTarget(event.target); + const isEdgelessContainer = + target?.classList.contains('edgeless-container'); + if (!isEdgelessContainer) return; + + // drop to edgeless container + this._onDropOnEdgelessCanvas(context); + return; + } + const model = element.model; + const parent = this._std.doc.getParent(model.id); + if (!parent) return; + if (matchFlavours(parent, ['affine:surface'])) { + return; + } + const result: DropResult | null = calcDropTarget(point, model, element); + if (!result) return; + + const index = + parent.children.indexOf(model) + (result.type === 'before' ? 0 : 1); + event.preventDefault(); + + if (matchFlavours(parent, ['affine:note'])) { + const snapshot = this._deserializeSnapshot(state); + if (snapshot) { + const [first] = snapshot.content; + if (first.flavour === 'affine:note') { + if (parent.id !== first.id) { + this._onDropNoteOnNote(snapshot, parent.id, index); + } + return; + } + } + } + + this._deserializeData(state, parent.id, index).catch(console.error); + }; + + private _onDropNoteOnNote = ( + snapshot: SliceSnapshot, + parent?: string, + index?: number + ) => { + const [first] = snapshot.content; + const id = first.id; + + const std = this._std; + const job = this._getJob(); + const snapshotWithoutNote = { + ...snapshot, + content: first.children, + }; + job + .snapshotToSlice(snapshotWithoutNote, std.doc, parent, index) + .then(() => { + const block = std.doc.getBlock(id)?.model; + if (block) { + std.doc.deleteBlock(block); + } + }) + .catch(console.error); + }; + + private _onDropOnEdgelessCanvas = (context: UIEventStateContext) => { + const state = context.get('dndState'); + // If drop a note, should do nothing + const snapshot = this._deserializeSnapshot(state); + const edgelessRoot = this.widget + .rootComponent as EdgelessRootBlockComponent; + + if (!snapshot) { + return; + } + + const [first] = snapshot.content; + if (first.flavour === 'affine:note') return; + + if (snapshot.content.length === 1) { + const importToSurface = ( + width: number, + height: number, + newBound: Bound + ) => { + first.props.xywh = newBound.serialize(); + first.props.width = width; + first.props.height = height; + + const std = this._std; + const job = this._getJob(); + job + .snapshotToSlice(snapshot, std.doc, edgelessRoot.surfaceBlockModel.id) + .catch(console.error); + }; + + if ( + ['affine:attachment', 'affine:bookmark'].includes(first.flavour) || + first.flavour.startsWith('affine:embed-') + ) { + const style = (first.props.style ?? 'horizontal') as EmbedCardStyle; + const width = EMBED_CARD_WIDTH[style]; + const height = EMBED_CARD_HEIGHT[style]; + + const newBound = this._computeEdgelessBound( + state.raw.clientX, + state.raw.clientY, + width, + height + ); + if (!newBound) return; + + if (first.flavour === 'affine:embed-linked-doc') { + this._trackLinkedDocCreated(first.id); + } + + importToSurface(width, height, newBound); + return; + } + + if (first.flavour === 'affine:image') { + const noteScale = this.widget.noteScale.peek(); + const width = Number(first.props.width || 100) * noteScale; + const height = Number(first.props.height || 100) * noteScale; + + const newBound = this._computeEdgelessBound( + state.raw.clientX, + state.raw.clientY, + width, + height + ); + if (!newBound) return; + + importToSurface(width, height, newBound); + return; + } + } + + const { left: viewportLeft, top: viewportTop } = edgelessRoot.viewport; + const newNoteId = addNoteAtPoint( + edgelessRoot.std, + new Point(state.raw.x - viewportLeft, state.raw.y - viewportTop), + { + scale: this.widget.noteScale.peek(), + } + ); + const newNoteBlock = this.widget.doc.getBlock(newNoteId)?.model as + | NoteBlockModel + | undefined; + if (!newNoteBlock) return; + + const bound = Bound.deserialize(newNoteBlock.xywh); + bound.h *= this.widget.noteScale.peek(); + bound.w *= this.widget.noteScale.peek(); + this.widget.doc.updateBlock(newNoteBlock, { + xywh: bound.serialize(), + edgeless: { + ...newNoteBlock.edgeless, + scale: this.widget.noteScale.peek(), + }, + }); + + this._deserializeData(state, newNoteId).catch(console.error); + }; + + private _startDragging = ( + blocks: BlockComponent[], + state: DndEventState, + dragPreviewEl?: HTMLElement, + dragPreviewOffset?: Point + ) => { + if (!blocks.length) { + return; + } + + this.widget.draggingElements = blocks; + + this.widget.dragPreview = this.widget.previewHelper.createDragPreview( + blocks, + state, + dragPreviewEl, + dragPreviewOffset + ); + + const slice = Slice.fromModels( + this._std.doc, + blocks.map(block => block.model) + ); + + this.widget.dragging = true; + this._createDropIndicator(); + this.widget.hide(); + this._serializeData(slice, state); + }; + + private _trackLinkedDocCreated = (id: string) => { + const isNewBlock = !this._std.doc.hasBlock(id); + if (!isNewBlock) { + return; + } + + const mode = + this._std.getOptional(DocModeProvider)?.getEditorMode() ?? 'page'; + + const telemetryService = this._std.getOptional(TelemetryProvider); + telemetryService?.track('LinkedDocCreated', { + control: `drop on ${mode}`, + module: 'drag and drop', + type: 'doc', + other: 'new doc', + }); + }; + + private get _dndAPI() { + return this._std.get(DndApiExtensionIdentifier); + } + + private get _std() { + return this.widget.std; + } + + constructor(readonly widget: AffineDragHandleWidget) {} + + private async _deserializeData( + state: DndEventState, + parent?: string, + index?: number + ) { + try { + const dataTransfer = state.raw.dataTransfer; + if (!dataTransfer) throw new Error('No data transfer'); + + const std = this._std; + const job = this._getJob(); + + const snapshot = this._deserializeSnapshot(state); + if (snapshot) { + if (snapshot.content.length === 1) { + const [first] = snapshot.content; + if (first.flavour === 'affine:embed-linked-doc') { + this._trackLinkedDocCreated(first.id); + } + } + // use snapshot + const slice = await job.snapshotToSlice( + snapshot, + std.doc, + parent, + index + ); + return slice; + } + + const html = dataTransfer.getData('text/html'); + if (html) { + // use html parser; + const htmlAdapter = new HtmlAdapter(job); + const slice = await htmlAdapter.toSlice( + { file: html }, + std.doc, + parent, + index + ); + return slice; + } + + const text = dataTransfer.getData('text/plain'); + const textAdapter = new MarkdownAdapter(job); + const slice = await textAdapter.toSlice( + { file: text }, + std.doc, + parent, + index + ); + return slice; + } catch { + return null; + } + } + + private _deserializeSnapshot(state: DndEventState) { + try { + const dataTransfer = state.raw.dataTransfer; + if (!dataTransfer) throw new Error('No data transfer'); + const data = dataTransfer.getData(this._dndAPI.mimeType); + const snapshot = this._dndAPI.decodeSnapshot(data); + + return snapshot; + } catch { + return null; + } + } + + private _getJob() { + const std = this._std; + return new Job({ + collection: std.collection, + middlewares: [newIdCrossDoc(std), surfaceRefToEmbed(std)], + }); + } + + private _serializeData(slice: Slice, state: DndEventState) { + const dataTransfer = state.raw.dataTransfer; + if (!dataTransfer) return; + + const job = this._getJob(); + + const snapshot = job.sliceToSnapshot(slice); + if (!snapshot) return; + + const data = this._dndAPI.encodeSnapshot(snapshot); + dataTransfer.setData(this._dndAPI.mimeType, data); + } + + watch() { + this.widget.handleEvent('pointerDown', ctx => { + const state = ctx.get('pointerState'); + const event = state.raw; + const target = captureEventTarget(event.target); + if (!target) return; + + if (this.widget.contains(target)) { + return true; + } + + return; + }); + + this.widget.handleEvent('dragStart', ctx => { + const state = ctx.get('pointerState'); + const event = state.raw; + const target = captureEventTarget(event.target); + if (!target) return; + + if (this.widget.contains(target)) { + return true; + } + + return; + }); + this.widget.handleEvent('nativeDragStart', this._dragStartHandler, { + global: true, + }); + this.widget.handleEvent('nativeDragMove', this._dragMoveHandler, { + global: true, + }); + this.widget.handleEvent('nativeDragEnd', this._dragEndHandler, { + global: true, + }); + this.widget.handleEvent('nativeDrop', this._dropHandler, { + global: true, + }); + } +} diff --git a/blocksuite/blocks/src/root-block/widgets/drag-handle/watchers/edgeless-watcher.ts b/blocksuite/blocks/src/root-block/widgets/drag-handle/watchers/edgeless-watcher.ts new file mode 100644 index 0000000000..fd4f84aa95 --- /dev/null +++ b/blocksuite/blocks/src/root-block/widgets/drag-handle/watchers/edgeless-watcher.ts @@ -0,0 +1,269 @@ +import type { DndEventState } from '@blocksuite/block-std'; +import { + GfxControllerIdentifier, + type GfxToolsFullOptionValue, +} from '@blocksuite/block-std/gfx'; +import { type IVec, Rect } from '@blocksuite/global/utils'; +import { effect } from '@preact/signals-core'; + +import type { + EdgelessRootBlockComponent, + EdgelessRootService, +} from '../../../edgeless/index.js'; +import { + getSelectedRect, + isTopLevelBlock, +} from '../../../edgeless/utils/query.js'; +import { + DRAG_HANDLE_CONTAINER_OFFSET_LEFT_TOP_LEVEL, + DRAG_HANDLE_CONTAINER_WIDTH_TOP_LEVEL, + DRAG_HANDLE_GRABBER_BORDER_RADIUS, + DRAG_HANDLE_GRABBER_WIDTH_HOVERED, + HOVER_AREA_RECT_PADDING_TOP_LEVEL, +} from '../config.js'; +import type { AffineDragHandleWidget } from '../drag-handle.js'; + +export class EdgelessWatcher { + private _handleEdgelessToolUpdated = (newTool: GfxToolsFullOptionValue) => { + if (newTool.type === 'default') { + this.checkTopLevelBlockSelection(); + } else { + this.widget.hide(); + } + }; + + private _handleEdgelessViewPortUpdated = ({ + zoom, + center, + }: { + zoom: number; + center: IVec; + }) => { + if (this.widget.scale.peek() !== zoom) { + this.widget.scale.value = zoom; + this._updateDragPreviewOnViewportUpdate(); + } + + if ( + this.widget.center[0] !== center[0] && + this.widget.center[1] !== center[1] + ) { + this.widget.center = [...center]; + this.widget.updateDropIndicatorOnScroll(); + } + + if (this.widget.isTopLevelDragHandleVisible) { + this._showDragHandleOnTopLevelBlocks().catch(console.error); + this._updateDragHoverRectTopLevelBlock(); + } else { + this.widget.hide(); + } + }; + + private _showDragHandleOnTopLevelBlocks = async () => { + if (this.widget.mode === 'page') return; + const { edgelessRoot } = this; + await edgelessRoot.surface.updateComplete; + + if (!this.widget.anchorBlockId) return; + + const container = this.widget.dragHandleContainer; + const grabber = this.widget.dragHandleGrabber; + if (!container || !grabber) return; + + const area = this.hoverAreaTopLevelBlock; + if (!area) return; + + const height = area.height; + + const posLeft = area.left; + + const posTop = (area.top += area.padding); + + container.style.transition = 'none'; + container.style.paddingTop = `0px`; + container.style.paddingBottom = `0px`; + container.style.width = `${area.containerWidth}px`; + container.style.left = `${posLeft}px`; + container.style.top = `${posTop}px`; + container.style.display = 'flex'; + container.style.height = `${height}px`; + + grabber.style.width = `${DRAG_HANDLE_GRABBER_WIDTH_HOVERED * this.widget.scale.peek()}px`; + grabber.style.borderRadius = `${ + DRAG_HANDLE_GRABBER_BORDER_RADIUS * this.widget.scale.peek() + }px`; + + this.widget.handleAnchorModelDisposables(); + + this.widget.isTopLevelDragHandleVisible = true; + }; + + private _updateDragHoverRectTopLevelBlock = () => { + if (!this.widget.dragHoverRect) return; + + this.widget.dragHoverRect = this.hoverAreaRectTopLevelBlock; + }; + + private _updateDragPreviewOnViewportUpdate = () => { + if (this.widget.dragPreview && this.widget.lastDragPointerState) { + this.updateDragPreviewPosition(this.widget.lastDragPointerState); + } + }; + + checkTopLevelBlockSelection = () => { + if (!this.widget.isConnected) return; + + if (this.widget.doc.readonly || this.widget.mode === 'page') { + this.widget.hide(); + return; + } + + const { edgelessRoot } = this; + const editing = edgelessRoot.service.selection.editing; + const selectedElements = edgelessRoot.service.selection.selectedElements; + if (editing || selectedElements.length !== 1) { + this.widget.hide(); + return; + } + + const selectedElement = selectedElements[0]; + if (!isTopLevelBlock(selectedElement)) { + this.widget.hide(); + return; + } + + const flavour = selectedElement.flavour; + const dragHandleOptions = this.widget.optionRunner.getOption(flavour); + if (!dragHandleOptions || !dragHandleOptions.edgeless) { + this.widget.hide(); + return; + } + + this.widget.anchorBlockId.value = selectedElement.id; + + this._showDragHandleOnTopLevelBlocks().catch(console.error); + }; + + updateDragPreviewPosition = (state: DndEventState) => { + if (!this.widget.dragPreview) return; + + const offsetParentRect = + this.widget.dragHandleContainerOffsetParent.getBoundingClientRect(); + + const dragPreviewOffset = this.widget.dragPreview.offset; + + const posX = state.raw.x - dragPreviewOffset.x - offsetParentRect.left; + + const posY = state.raw.y - dragPreviewOffset.y - offsetParentRect.top; + + this.widget.dragPreview.style.transform = `translate(${posX}px, ${posY}px) scale(${this.widget.scaleInNote.peek()})`; + + const altKey = state.raw.altKey; + this.widget.dragPreview.style.opacity = altKey ? '1' : '0.5'; + }; + + get edgelessRoot() { + return this.widget.rootComponent as EdgelessRootBlockComponent; + } + + get hoverAreaRectTopLevelBlock() { + const area = this.hoverAreaTopLevelBlock; + if (!area) return null; + + return new Rect(area.left, area.top, area.right, area.bottom); + } + + get hoverAreaTopLevelBlock() { + const edgelessElement = this.widget.anchorEdgelessElement.peek(); + + if (!edgelessElement) return null; + + const { edgelessRoot } = this; + const rect = getSelectedRect([edgelessElement]); + let [left, top] = edgelessRoot.service.viewport.toViewCoord( + rect.left, + rect.top + ); + const scale = this.widget.scale.peek(); + const width = rect.width * scale; + const height = rect.height * scale; + + let [right, bottom] = [left + width, top + height]; + + const padding = HOVER_AREA_RECT_PADDING_TOP_LEVEL * scale; + + const containerWidth = DRAG_HANDLE_CONTAINER_WIDTH_TOP_LEVEL * scale; + const offsetLeft = DRAG_HANDLE_CONTAINER_OFFSET_LEFT_TOP_LEVEL * scale; + + left -= containerWidth + offsetLeft; + top -= padding; + right += padding; + bottom += padding; + + return { + left, + top, + right, + bottom, + width, + height, + padding, + containerWidth, + }; + } + + constructor(readonly widget: AffineDragHandleWidget) {} + + watch() { + const { disposables, std } = this.widget; + const gfxController = std.get(GfxControllerIdentifier); + const { viewport } = gfxController; + const edgelessService = std.getService( + 'affine:page' + ) as EdgelessRootService; + const edgelessSlots = edgelessService.slots; + + disposables.add( + viewport.viewportUpdated.on(this._handleEdgelessViewPortUpdated) + ); + + disposables.add( + edgelessService.selection.slots.updated.on(() => { + this.checkTopLevelBlockSelection(); + }) + ); + + disposables.add( + effect(() => { + const value = gfxController.tool.currentToolOption$.value; + + value && this._handleEdgelessToolUpdated(value); + }) + ); + + disposables.add( + edgelessSlots.readonlyUpdated.on(() => { + this.checkTopLevelBlockSelection(); + }) + ); + + disposables.add( + edgelessSlots.draggingAreaUpdated.on(() => { + this.checkTopLevelBlockSelection(); + }) + ); + + disposables.add( + edgelessSlots.elementResizeStart.on(() => { + this.widget.hide(); + }) + ); + + disposables.add( + edgelessSlots.elementResizeEnd.on(() => { + this.checkTopLevelBlockSelection(); + }) + ); + } +} diff --git a/blocksuite/blocks/src/root-block/widgets/drag-handle/watchers/handle-event-watcher.ts b/blocksuite/blocks/src/root-block/widgets/drag-handle/watchers/handle-event-watcher.ts new file mode 100644 index 0000000000..6ed86e01cb --- /dev/null +++ b/blocksuite/blocks/src/root-block/widgets/drag-handle/watchers/handle-event-watcher.ts @@ -0,0 +1,93 @@ +import { + DRAG_HANDLE_CONTAINER_PADDING, + DRAG_HANDLE_GRABBER_BORDER_RADIUS, + DRAG_HANDLE_GRABBER_WIDTH_HOVERED, +} from '../config.js'; +import type { AffineDragHandleWidget } from '../drag-handle.js'; + +export class HandleEventWatcher { + private _onDragHandlePointerDown = () => { + if (!this.widget.isHoverDragHandleVisible || !this.widget.anchorBlockId) + return; + + this.widget.dragHoverRect = this.widget.draggingAreaRect.value; + }; + + private _onDragHandlePointerEnter = () => { + const container = this.widget.dragHandleContainer; + const grabber = this.widget.dragHandleGrabber; + if (!container || !grabber) return; + + if (this.widget.isHoverDragHandleVisible && this.widget.anchorBlockId) { + const block = this.widget.anchorBlockComponent; + if (!block) return; + + const padding = DRAG_HANDLE_CONTAINER_PADDING * this.widget.scale.peek(); + container.style.paddingTop = `${padding}px`; + container.style.paddingBottom = `${padding}px`; + container.style.transition = `padding 0.25s ease`; + + grabber.style.width = `${ + DRAG_HANDLE_GRABBER_WIDTH_HOVERED * this.widget.scaleInNote.peek() + }px`; + grabber.style.borderRadius = `${ + DRAG_HANDLE_GRABBER_BORDER_RADIUS * this.widget.scaleInNote.peek() + }px`; + + this.widget.isDragHandleHovered = true; + } else if (this.widget.isTopLevelDragHandleVisible) { + this.widget.dragHoverRect = + this.widget.edgelessWatcher.hoverAreaRectTopLevelBlock; + this.widget.isDragHandleHovered = true; + } + }; + + private _onDragHandlePointerLeave = () => { + this.widget.isDragHandleHovered = false; + this.widget.dragHoverRect = null; + + if (this.widget.isTopLevelDragHandleVisible) return; + + if (this.widget.dragging) return; + + this.widget.pointerEventWatcher.showDragHandleOnHoverBlock(); + }; + + private _onDragHandlePointerUp = () => { + if (!this.widget.isHoverDragHandleVisible) return; + this.widget.dragHoverRect = null; + }; + + constructor(readonly widget: AffineDragHandleWidget) {} + + watch() { + const { dragHandleContainer, disposables } = this.widget; + + // When pointer enter drag handle grabber + // Extend drag handle grabber to the height of the hovered block + disposables.addFromEvent( + dragHandleContainer, + 'pointerenter', + this._onDragHandlePointerEnter + ); + + disposables.addFromEvent( + dragHandleContainer, + 'pointerdown', + this._onDragHandlePointerDown + ); + + disposables.addFromEvent( + dragHandleContainer, + 'pointerup', + this._onDragHandlePointerUp + ); + + // When pointer leave drag handle grabber, should reset drag handle grabber style + disposables.addFromEvent( + dragHandleContainer, + 'pointerleave', + this._onDragHandlePointerLeave + ); + } +} diff --git a/blocksuite/blocks/src/root-block/widgets/drag-handle/watchers/keyboard-event-watcher.ts b/blocksuite/blocks/src/root-block/widgets/drag-handle/watchers/keyboard-event-watcher.ts new file mode 100644 index 0000000000..13845aab7f --- /dev/null +++ b/blocksuite/blocks/src/root-block/widgets/drag-handle/watchers/keyboard-event-watcher.ts @@ -0,0 +1,27 @@ +import type { UIEventHandler } from '@blocksuite/block-std'; + +import type { AffineDragHandleWidget } from '../drag-handle.js'; + +export class KeyboardEventWatcher { + private _keyboardHandler: UIEventHandler = ctx => { + if (!this.widget.dragging || !this.widget.dragPreview) { + return; + } + + const state = ctx.get('defaultState'); + const event = state.event as KeyboardEvent; + event.preventDefault(); + event.stopPropagation(); + + const altKey = event.key === 'Alt' && event.altKey; + this.widget.dragPreview.style.opacity = altKey ? '1' : '0.5'; + }; + + constructor(readonly widget: AffineDragHandleWidget) {} + + watch() { + this.widget.handleEvent('beforeInput', () => this.widget.hide()); + this.widget.handleEvent('keyDown', this._keyboardHandler, { global: true }); + this.widget.handleEvent('keyUp', this._keyboardHandler, { global: true }); + } +} diff --git a/blocksuite/blocks/src/root-block/widgets/drag-handle/watchers/legacy-drag-event-watcher.ts b/blocksuite/blocks/src/root-block/widgets/drag-handle/watchers/legacy-drag-event-watcher.ts new file mode 100644 index 0000000000..2fba127016 --- /dev/null +++ b/blocksuite/blocks/src/root-block/widgets/drag-handle/watchers/legacy-drag-event-watcher.ts @@ -0,0 +1,474 @@ +/* eslint-disable @typescript-eslint/no-non-null-assertion */ +import type { NoteBlockModel } from '@blocksuite/affine-model'; +import { + captureEventTarget, + findNoteBlockModel, + getBlockComponentsExcludeSubtrees, + matchFlavours, +} from '@blocksuite/affine-shared/utils'; +import { + type BlockComponent, + isGfxBlockComponent, + type PointerEventState, + type UIEventHandler, +} from '@blocksuite/block-std'; +import { GfxControllerIdentifier } from '@blocksuite/block-std/gfx'; +import { IS_MOBILE } from '@blocksuite/global/env'; +import { Bound, Point } from '@blocksuite/global/utils'; +import type { BlockModel } from '@blocksuite/store'; +import { render } from 'lit'; + +import type { EdgelessRootBlockComponent } from '../../../edgeless/index.js'; +import { addNoteAtPoint } from '../../../edgeless/utils/common.js'; +import { DropIndicator } from '../components/drop-indicator.js'; +import { AFFINE_DRAG_HANDLE_WIDGET } from '../consts.js'; +import type { AffineDragHandleWidget } from '../drag-handle.js'; +import { + containBlock, + getDuplicateBlocks, + includeTextSelection, +} from '../utils.js'; + +export class LegacyDragEventWatcher { + private _changeCursorToGrabbing = () => { + document.documentElement.classList.add('affine-drag-preview-grabbing'); + }; + + private _createDropIndicator = () => { + if (!this.widget.dropIndicator) { + this.widget.dropIndicator = new DropIndicator(); + this.widget.rootComponent.append(this.widget.dropIndicator); + } + }; + + /** + * When drag end, should move blocks to drop position + */ + private _dragEndHandler: UIEventHandler = ctx => { + this.widget.clearRaf(); + if (!this.widget.dragging || !this.widget.dragPreview) return false; + if (this.widget.draggingElements.length === 0 || this.widget.doc.readonly) { + this.widget.hide(true); + return false; + } + + const state = ctx.get('pointerState'); + const { target } = state.raw; + if (!this.widget.host.contains(target as Node)) { + this.widget.hide(true); + return true; + } + + for (const option of this.widget.optionRunner.options) { + if ( + option.onDragEnd?.({ + // @ts-expect-error FIXME: ts error + state, + draggingElements: this.widget.draggingElements, + dropBlockId: this.widget.dropBlockId, + dropType: this.widget.dropType, + dragPreview: this.widget.dragPreview, + noteScale: this.widget.noteScale.peek(), + editorHost: this.widget.host, + }) + ) { + this.widget.hide(true); + if (this.widget.mode === 'edgeless') { + this.widget.edgelessWatcher.checkTopLevelBlockSelection(); + } + return true; + } + } + + // call default drag end handler if no option return true + this._onDragEnd(state); + + if (this.widget.mode === 'edgeless') { + this.widget.edgelessWatcher.checkTopLevelBlockSelection(); + } + + return true; + }; + + /** + * When dragging, should: + * Update drag preview position + * Update indicator position + * Update drop block id + */ + private _dragMoveHandler: UIEventHandler = ctx => { + if ( + this.widget.isHoverDragHandleVisible || + this.widget.isTopLevelDragHandleVisible + ) { + this.widget.hide(); + } + + if (!this.widget.dragging || this.widget.draggingElements.length === 0) { + return false; + } + + ctx.get('defaultState').event.preventDefault(); + const state = ctx.get('pointerState'); + + for (const option of this.widget.optionRunner.options) { + if ( + option.onDragMove?.({ + // @ts-expect-error FIXME: ts error + state, + draggingElements: this.widget.draggingElements, + }) + ) { + return true; + } + } + + // call default drag move handler if no option return true + return this._onDragMove(state); + }; + + /** + * When start dragging, should set dragging elements and create drag preview + */ + private _dragStartHandler: UIEventHandler = ctx => { + const state = ctx.get('pointerState'); + // If not click left button to start dragging, should do nothing + const { button } = state.raw; + if (button !== 0) { + return false; + } + + // call default drag start handler if no option return true + for (const option of this.widget.optionRunner.options) { + if ( + option.onDragStart?.({ + // @ts-expect-error FIXME: ts error + state, + // @ts-expect-error FIXME: ts error + startDragging: this._startDragging, + anchorBlockId: this.widget.anchorBlockId.peek() ?? '', + editorHost: this.widget.host, + }) + ) { + return true; + } + } + return this._onDragStart(state); + }; + + private _onDragEnd = (state: PointerEventState) => { + const targetBlockId = this.widget.dropBlockId; + const dropType = this.widget.dropType; + const draggingElements = this.widget.draggingElements; + this.widget.hide(true); + + // handle drop of blocks from note onto edgeless container + if (!targetBlockId) { + const target = captureEventTarget(state.raw.target); + if (!target) return false; + + const isTargetEdgelessContainer = + target.classList.contains('edgeless-container'); + if (!isTargetEdgelessContainer) return false; + + const selectedBlocks = getBlockComponentsExcludeSubtrees(draggingElements) + .map(element => element.model) + .filter((x): x is BlockModel => !!x); + if (selectedBlocks.length === 0) return false; + + const isSurfaceComponent = selectedBlocks.some(block => { + const parent = this.widget.doc.getParent(block.id); + return matchFlavours(parent, ['affine:surface']); + }); + if (isSurfaceComponent) return true; + + const edgelessRoot = this.widget + .rootComponent as EdgelessRootBlockComponent; + + const { left: viewportLeft, top: viewportTop } = edgelessRoot.viewport; + + const newNoteId = addNoteAtPoint( + edgelessRoot.std, + new Point(state.raw.x - viewportLeft, state.raw.y - viewportTop), + { + scale: this.widget.noteScale.peek(), + } + ); + const newNoteBlock = this.widget.doc.getBlockById( + newNoteId + ) as NoteBlockModel; + if (!newNoteBlock) return; + + const bound = Bound.deserialize(newNoteBlock.xywh); + bound.h *= this.widget.noteScale.peek(); + bound.w *= this.widget.noteScale.peek(); + this.widget.doc.updateBlock(newNoteBlock, { + xywh: bound.serialize(), + edgeless: { + ...newNoteBlock.edgeless, + scale: this.widget.noteScale.peek(), + }, + }); + + const altKey = state.raw.altKey; + if (altKey) { + const duplicateBlocks = getDuplicateBlocks(selectedBlocks); + + this.widget.doc.addBlocks(duplicateBlocks, newNoteBlock); + } else { + this.widget.doc.moveBlocks(selectedBlocks, newNoteBlock); + } + + edgelessRoot.service.selection.set({ + elements: [newNoteBlock.id], + editing: true, + }); + + return true; + } + + // Should make sure drop block id is not in selected blocks + if ( + containBlock(this.widget.selectionHelper.selectedBlockIds, targetBlockId) + ) { + return false; + } + + const selectedBlocks = getBlockComponentsExcludeSubtrees(draggingElements) + .map(element => element.model) + .filter((x): x is BlockModel => !!x); + if (!selectedBlocks.length) { + return false; + } + + const targetBlock = this.widget.doc.getBlockById(targetBlockId); + if (!targetBlock) return; + + const shouldInsertIn = dropType === 'in'; + + const parent = shouldInsertIn + ? targetBlock + : this.widget.doc.getParent(targetBlockId); + if (!parent) return; + + const altKey = state.raw.altKey; + + if (shouldInsertIn) { + if (altKey) { + const duplicateBlocks = getDuplicateBlocks(selectedBlocks); + + this.widget.doc.addBlocks(duplicateBlocks, targetBlock); + } else { + this.widget.doc.moveBlocks(selectedBlocks, targetBlock); + } + } else { + if (altKey) { + const duplicateBlocks = getDuplicateBlocks(selectedBlocks); + + const parentIndex = + parent.children.indexOf(targetBlock) + (dropType === 'after' ? 1 : 0); + + this.widget.doc.addBlocks(duplicateBlocks, parent, parentIndex); + } else { + this.widget.doc.moveBlocks( + selectedBlocks, + parent, + targetBlock, + dropType === 'before' + ); + } + } + + // TODO: need a better way to update selection + // Should update selection after moving blocks + // In doc page mode, update selected blocks + // In edgeless mode, focus on the first block + setTimeout(() => { + if (!parent) return; + // Need to update selection when moving blocks successfully + // Because the block path may be changed after moving + const parentElement = this.widget.std.view.getBlock(parent.id); + if (parentElement) { + const newSelectedBlocks = selectedBlocks.map(block => { + return this.widget.std.view.getBlock(block.id); + }); + if (!newSelectedBlocks) return; + + const note = findNoteBlockModel(parentElement.model); + if (!note) return; + this.widget.selectionHelper.setSelectedBlocks( + newSelectedBlocks as BlockComponent[], + note.id + ); + } + }, 0); + + return true; + }; + + private _onDragMove = (state: PointerEventState) => { + this.widget.clearRaf(); + + this.widget.rafID = requestAnimationFrame(() => { + // @ts-expect-error FIXME: ts error + this.widget.edgelessWatcher.updateDragPreviewPosition(state); + // @ts-expect-error FIXME: ts error + this.widget.updateDropIndicator(state, true); + }); + return true; + }; + + private _onDragStart = (state: PointerEventState) => { + // Get current hover block element by path + const hoverBlock = this.widget.anchorBlockComponent.peek(); + if (!hoverBlock) return false; + + const element = captureEventTarget(state.raw.target); + const dragByHandle = !!element?.closest(AFFINE_DRAG_HANDLE_WIDGET); + const isInSurface = isGfxBlockComponent(hoverBlock); + + if (isInSurface && dragByHandle) { + const viewport = this.widget.std.get(GfxControllerIdentifier).viewport; + const zoom = viewport.zoom ?? 1; + const dragPreviewEl = document.createElement('div'); + const bound = Bound.deserialize(hoverBlock.model.xywh); + const offset = new Point(bound.x * zoom, bound.y * zoom); + + // TODO: not use `dangerouslyRenderModel` to render drag preview + render( + this.widget.std.host.dangerouslyRenderModel(hoverBlock.model), + dragPreviewEl + ); + + this._startDragging([hoverBlock], state, dragPreviewEl, offset); + return true; + } + + const selectBlockAndStartDragging = () => { + this.widget.std.selection.setGroup('note', [ + this.widget.std.selection.create('block', { + blockId: hoverBlock.blockId, + }), + ]); + this._startDragging([hoverBlock], state); + }; + + if (this.widget.draggingElements.length === 0) { + const dragByBlock = + hoverBlock.contains(element) && !hoverBlock.model.text; + + const canDragByBlock = + matchFlavours(hoverBlock.model, [ + 'affine:attachment', + 'affine:bookmark', + ]) || hoverBlock.model.flavour.startsWith('affine:embed-'); + + if (!isInSurface && dragByBlock && canDragByBlock) { + selectBlockAndStartDragging(); + return true; + } + } + + // Should only start dragging when pointer down on drag handle + // And current mouse button is left button + if (!dragByHandle) { + this.widget.hide(); + return false; + } + + if (this.widget.draggingElements.length === 1 && !isInSurface) { + selectBlockAndStartDragging(); + return true; + } + + if (!this.widget.isHoverDragHandleVisible) return false; + + let selections = this.widget.selectionHelper.selectedBlocks; + + // When current selection is TextSelection + // Should set BlockSelection for the blocks in native range + if (selections.length > 0 && includeTextSelection(selections)) { + const nativeSelection = document.getSelection(); + const rangeManager = this.widget.std.range; + if (nativeSelection && nativeSelection.rangeCount > 0 && rangeManager) { + const range = nativeSelection.getRangeAt(0); + const blocks = rangeManager.getSelectedBlockComponentsByRange(range, { + match: el => el.model.role === 'content', + mode: 'highest', + }); + this.widget.selectionHelper.setSelectedBlocks(blocks); + selections = this.widget.selectionHelper.selectedBlocks; + } + } + + // When there is no selected blocks + // Or selected blocks not including current hover block + // Set current hover block as selected + if ( + selections.length === 0 || + !containBlock( + selections.map(selection => selection.blockId), + this.widget.anchorBlockId.peek()! + ) + ) { + const block = this.widget.anchorBlockComponent.peek(); + if (block) { + this.widget.selectionHelper.setSelectedBlocks([block]); + } + } + + const blocks = this.widget.selectionHelper.selectedBlockComponents; + + // This could be skip if we can ensure that all selected blocks are on the same level + // Which means not selecting parent block and child block at the same time + const blocksExcludingChildren = getBlockComponentsExcludeSubtrees( + blocks + ) as BlockComponent[]; + + if (blocksExcludingChildren.length === 0) return false; + + this._startDragging(blocksExcludingChildren, state); + this.widget.hide(); + return true; + }; + + private _startDragging = ( + blocks: BlockComponent[], + state: PointerEventState, + dragPreviewEl?: HTMLElement, + dragPreviewOffset?: Point + ) => { + if (!blocks.length) { + return; + } + + this.widget.draggingElements = blocks; + + this.widget.dragPreview = this.widget.previewHelper.createDragPreview( + blocks, + // @ts-expect-error FIXME: ts error + state, + dragPreviewEl, + dragPreviewOffset + ); + + this.widget.dragging = true; + this._changeCursorToGrabbing(); + this._createDropIndicator(); + this.widget.hide(); + }; + + constructor(readonly widget: AffineDragHandleWidget) {} + + watch() { + this.widget.disposables.addFromEvent(this.widget, 'pointerdown', e => { + e.preventDefault(); + }); + + if (IS_MOBILE) return; + + this.widget.handleEvent('dragStart', this._dragStartHandler); + this.widget.handleEvent('dragMove', this._dragMoveHandler); + this.widget.handleEvent('dragEnd', this._dragEndHandler, { global: true }); + } +} diff --git a/blocksuite/blocks/src/root-block/widgets/drag-handle/watchers/page-watcher.ts b/blocksuite/blocks/src/root-block/widgets/drag-handle/watchers/page-watcher.ts new file mode 100644 index 0000000000..75b66cfcc4 --- /dev/null +++ b/blocksuite/blocks/src/root-block/widgets/drag-handle/watchers/page-watcher.ts @@ -0,0 +1,37 @@ +import { getScrollContainer } from '@blocksuite/affine-shared/utils'; + +import type { PageRootBlockComponent } from '../../../page/page-root-block.js'; +import type { AffineDragHandleWidget } from '../drag-handle.js'; + +export class PageWatcher { + get pageRoot() { + return this.widget.rootComponent as PageRootBlockComponent; + } + + constructor(readonly widget: AffineDragHandleWidget) {} + + watch() { + const { pageRoot } = this; + const { disposables } = this.widget; + const scrollContainer = getScrollContainer(pageRoot); + + disposables.add( + this.widget.doc.slots.blockUpdated.on(() => this.widget.hide()) + ); + + disposables.add( + pageRoot.slots.viewportUpdated.on(() => { + this.widget.hide(); + if (this.widget.dropIndicator) { + this.widget.dropIndicator.rect = null; + } + }) + ); + + disposables.addFromEvent( + scrollContainer, + 'scrollend', + this.widget.updateDropIndicatorOnScroll + ); + } +} diff --git a/blocksuite/blocks/src/root-block/widgets/drag-handle/watchers/pointer-event-watcher.ts b/blocksuite/blocks/src/root-block/widgets/drag-handle/watchers/pointer-event-watcher.ts new file mode 100644 index 0000000000..584fa34931 --- /dev/null +++ b/blocksuite/blocks/src/root-block/widgets/drag-handle/watchers/pointer-event-watcher.ts @@ -0,0 +1,336 @@ +import { captureEventTarget } from '@blocksuite/affine-shared/utils'; +import { + BLOCK_ID_ATTR, + type BlockComponent, + type PointerEventState, + type UIEventHandler, +} from '@blocksuite/block-std'; +import { Point, throttle } from '@blocksuite/global/utils'; +import { computed } from '@preact/signals-core'; + +import type { NoteBlockComponent } from '../../../../note-block/index.js'; +import type { EdgelessRootBlockComponent } from '../../../edgeless/index.js'; +import { + DRAG_HANDLE_CONTAINER_WIDTH, + DRAG_HANDLE_GRABBER_BORDER_RADIUS, + DRAG_HANDLE_GRABBER_HEIGHT, + DRAG_HANDLE_GRABBER_WIDTH, +} from '../config.js'; +import { AFFINE_DRAG_HANDLE_WIDGET } from '../consts.js'; +import type { AffineDragHandleWidget } from '../drag-handle.js'; +import { + getClosestBlockByPoint, + getClosestNoteBlock, + getDragHandleContainerHeight, + includeTextSelection, + insideDatabaseTable, + isBlockIdEqual, + isOutOfNoteBlock, + updateDragHandleClassName, +} from '../utils.js'; + +export class PointerEventWatcher { + private _canEditing = (noteBlock: BlockComponent) => { + if (noteBlock.doc.id !== this.widget.doc.id) return false; + + if (this.widget.mode === 'page') return true; + + const edgelessRoot = this.widget + .rootComponent as EdgelessRootBlockComponent; + + const noteBlockId = noteBlock.model.id; + return ( + edgelessRoot.service.selection.editing && + edgelessRoot.service.selection.selectedIds[0] === noteBlockId + ); + }; + + /** + * When click on drag handle + * Should select the block and show slash menu if current block is not selected + * Should clear selection if current block is the first selected block + */ + private _clickHandler: UIEventHandler = ctx => { + if (!this.widget.isHoverDragHandleVisible) return; + + const state = ctx.get('pointerState'); + const { target } = state.raw; + const element = captureEventTarget(target); + const insideDragHandle = !!element?.closest(AFFINE_DRAG_HANDLE_WIDGET); + if (!insideDragHandle) return; + + const anchorBlockId = this.widget.anchorBlockId.peek(); + + if (!anchorBlockId) return; + + const { selection } = this.widget.std; + const selectedBlocks = this.widget.selectionHelper.selectedBlocks; + + // Should clear selection if current block is the first selected block + if ( + selectedBlocks.length > 0 && + !includeTextSelection(selectedBlocks) && + selectedBlocks[0].blockId === anchorBlockId + ) { + selection.clear(['block']); + this.widget.dragHoverRect = null; + this.showDragHandleOnHoverBlock(); + return; + } + + // Should select the block if current block is not selected + const block = this.widget.anchorBlockComponent.peek(); + if (!block) return; + + if (selectedBlocks.length > 1) { + this.showDragHandleOnHoverBlock(); + } + + this.widget.selectionHelper.setSelectedBlocks([block]); + }; + + // Need to consider block padding and scale + private _getTopWithBlockComponent = (block: BlockComponent) => { + const computedStyle = getComputedStyle(block); + const { top } = block.getBoundingClientRect(); + const paddingTop = + parseInt(computedStyle.paddingTop) * this.widget.scale.peek(); + return ( + top + + paddingTop - + this.widget.dragHandleContainerOffsetParent.getBoundingClientRect().top + ); + }; + + private _containerStyle = computed(() => { + const draggingAreaRect = this.widget.draggingAreaRect.value; + if (!draggingAreaRect) return null; + + const block = this.widget.anchorBlockComponent.value; + if (!block) return null; + + const containerHeight = getDragHandleContainerHeight(block.model); + + const posTop = this._getTopWithBlockComponent(block); + + const scaleInNote = this.widget.scaleInNote.value; + + const rowPaddingY = + ((containerHeight - DRAG_HANDLE_GRABBER_HEIGHT) / 2 + 2) * scaleInNote; + + // use padding to control grabber's height + const paddingTop = rowPaddingY + posTop - draggingAreaRect.top; + const paddingBottom = + draggingAreaRect.height - + paddingTop - + DRAG_HANDLE_GRABBER_HEIGHT * scaleInNote; + + return { + paddingTop: `${paddingTop}px`, + paddingBottom: `${paddingBottom}px`, + width: `${DRAG_HANDLE_CONTAINER_WIDTH * scaleInNote}px`, + left: `${draggingAreaRect.left}px`, + top: `${draggingAreaRect.top}px`, + height: `${draggingAreaRect.height}px`, + }; + }); + + private _grabberStyle = computed(() => { + const scaleInNote = this.widget.scaleInNote.value; + return { + width: `${DRAG_HANDLE_GRABBER_WIDTH * scaleInNote}px`, + borderRadius: `${DRAG_HANDLE_GRABBER_BORDER_RADIUS * scaleInNote}px`, + }; + }); + + private _lastHoveredBlockId: string | null = null; + + private _lastShowedBlock: { id: string; el: BlockComponent } | null = null; + + /** + * When pointer move on block, should show drag handle + * And update hover block id and path + */ + private _pointerMoveOnBlock = (state: PointerEventState) => { + if (this.widget.isTopLevelDragHandleVisible) return; + + const point = new Point(state.raw.x, state.raw.y); + const closestBlock = getClosestBlockByPoint( + this.widget.host, + this.widget.rootComponent, + point + ); + if (!closestBlock) { + this.widget.anchorBlockId.value = null; + return; + } + + const blockId = closestBlock.getAttribute(BLOCK_ID_ATTR); + if (!blockId) return; + + this.widget.anchorBlockId.value = blockId; + + if (insideDatabaseTable(closestBlock) || this.widget.doc.readonly) { + this.widget.hide(); + return; + } + + // If current block is not the last hovered block, show drag handle beside the hovered block + if ( + (!this._lastHoveredBlockId || + !isBlockIdEqual( + this.widget.anchorBlockId.peek(), + this._lastHoveredBlockId + ) || + !this.widget.isHoverDragHandleVisible) && + !this.widget.isDragHandleHovered + ) { + this.showDragHandleOnHoverBlock(); + this._lastHoveredBlockId = this.widget.anchorBlockId.peek(); + } + }; + + private _pointerOutHandler: UIEventHandler = ctx => { + const state = ctx.get('pointerState'); + state.raw.preventDefault(); + + const { target } = state.raw; + const element = captureEventTarget(target); + if (!element) return; + + const { relatedTarget } = state.raw; + // TODO: when pointer out of page viewport, should hide drag handle + // But the pointer out event is not as expected + // Need to be optimized + const relatedElement = captureEventTarget(relatedTarget); + const outOfPageViewPort = element.classList.contains( + 'affine-page-viewport' + ); + const inPage = !!relatedElement?.closest('.affine-page-viewport'); + + const inDragHandle = !!relatedElement?.closest(AFFINE_DRAG_HANDLE_WIDGET); + if (outOfPageViewPort && !inDragHandle && !inPage) { + this.widget.hide(); + } + }; + + private _throttledPointerMoveHandler = throttle<UIEventHandler>(ctx => { + if ( + this.widget.doc.readonly || + this.widget.dragging || + !this.widget.isConnected + ) { + this.widget.hide(); + return; + } + if (this.widget.isTopLevelDragHandleVisible) return; + + const state = ctx.get('pointerState'); + const { target } = state.raw; + const element = captureEventTarget(target); + // When pointer not on block or on dragging, should do nothing + if (!element) return; + + // When pointer on drag handle, should do nothing + if (element.closest('.affine-drag-handle-container')) return; + + // When pointer out of note block hover area or inside database, should hide drag handle + const point = new Point(state.raw.x, state.raw.y); + + const closestNoteBlock = getClosestNoteBlock( + this.widget.host, + this.widget.rootComponent, + point + ) as NoteBlockComponent | null; + + this.widget.noteScale.value = + this.widget.mode === 'page' + ? 1 + : (closestNoteBlock?.model.edgeless.scale ?? 1); + + if ( + closestNoteBlock && + this._canEditing(closestNoteBlock) && + !isOutOfNoteBlock( + this.widget.host, + closestNoteBlock, + point, + this.widget.scaleInNote.peek() + ) + ) { + this._pointerMoveOnBlock(state); + return true; + } + + this.widget.hide(); + return false; + }, 1000 / 60); + + // Multiple blocks: drag handle should show on the vertical middle of all blocks + showDragHandleOnHoverBlock = () => { + const block = this.widget.anchorBlockComponent.peek(); + if (!block) return; + + const container = this.widget.dragHandleContainer; + const grabber = this.widget.dragHandleGrabber; + if (!container || !grabber) return; + + this.widget.isHoverDragHandleVisible = true; + + const draggingAreaRect = this.widget.draggingAreaRect.peek(); + if (!draggingAreaRect) return; + + // Ad-hoc solution for list with toggle icon + updateDragHandleClassName([block]); + // End of ad-hoc solution + + const applyStyle = (transition?: boolean) => { + const containerStyle = this._containerStyle.value; + if (!containerStyle) return; + + container.style.transition = transition ? 'padding 0.25s ease' : 'none'; + Object.assign(container.style, containerStyle); + + container.style.display = 'flex'; + }; + + if (isBlockIdEqual(block.blockId, this._lastShowedBlock?.id)) { + applyStyle(true); + } else if (this.widget.selectionHelper.selectedBlocks.length) { + if (this.widget.selectionHelper.isBlockSelected(block)) + applyStyle( + this.widget.isDragHandleHovered && + this.widget.selectionHelper.isBlockSelected( + this._lastShowedBlock?.el + ) + ); + else applyStyle(false); + } else { + applyStyle(false); + } + + const grabberStyle = this._grabberStyle.value; + Object.assign(grabber.style, grabberStyle); + + this.widget.handleAnchorModelDisposables(); + if (!isBlockIdEqual(block.blockId, this._lastShowedBlock?.id)) { + this._lastShowedBlock = { + id: block.blockId, + el: block, + }; + } + }; + + constructor(readonly widget: AffineDragHandleWidget) {} + + reset() { + this._lastHoveredBlockId = null; + this._lastShowedBlock = null; + } + + watch() { + this.widget.handleEvent('click', this._clickHandler); + this.widget.handleEvent('pointerMove', this._throttledPointerMoveHandler); + this.widget.handleEvent('pointerOut', this._pointerOutHandler); + } +} diff --git a/blocksuite/blocks/src/root-block/widgets/edgeless-auto-connect/edgeless-auto-connect.ts b/blocksuite/blocks/src/root-block/widgets/edgeless-auto-connect/edgeless-auto-connect.ts new file mode 100644 index 0000000000..984aa7834f --- /dev/null +++ b/blocksuite/blocks/src/root-block/widgets/edgeless-auto-connect/edgeless-auto-connect.ts @@ -0,0 +1,595 @@ +/* eslint-disable @typescript-eslint/no-non-null-assertion */ +import { + AutoConnectLeftIcon, + AutoConnectRightIcon, + HiddenIcon, + SmallDocIcon, +} from '@blocksuite/affine-components/icons'; +import { + FrameBlockModel, + NoteBlockModel, + NoteDisplayMode, + type RootBlockModel, + type SurfaceRefBlockModel, +} from '@blocksuite/affine-model'; +import { + matchFlavours, + stopPropagation, +} from '@blocksuite/affine-shared/utils'; +import { WidgetComponent } from '@blocksuite/block-std'; +import { Bound } from '@blocksuite/global/utils'; +import { css, html, nothing, type TemplateResult } from 'lit'; +import { state } from 'lit/decorators.js'; +import { repeat } from 'lit/directives/repeat.js'; +import { styleMap } from 'lit/directives/style-map.js'; + +import type { EdgelessRootBlockComponent } from '../../edgeless/edgeless-root-block.js'; +import type { EdgelessRootService } from '../../edgeless/edgeless-root-service.js'; +import { isNoteBlock } from '../../edgeless/utils/query.js'; + +const PAGE_VISIBLE_INDEX_LABEL_WIDTH = 44; +const PAGE_VISIBLE_INDEX_LABEL_HEIGHT = 24; +const EDGELESS_ONLY_INDEX_LABEL_WIDTH = 24; +const EDGELESS_ONLY_INDEX_LABEL_HEIGHT = 24; +const INDEX_LABEL_OFFSET = 16; + +function calculatePosition(gap: number, count: number, iconWidth: number) { + const positions = []; + if (count === 1) { + positions.push([0, 10]); + return positions; + } + const middleIndex = (count - 1) / 2; + const isEven = count % 2 === 0; + const middleOffset = (gap + iconWidth) / 2; + function getSign(num: number) { + return num - middleIndex > 0 ? 1 : -1; + } + for (let j = 0; j < count; j++) { + let left = 10; + if (isEven) { + if (Math.abs(j - middleIndex) < 1 && isEven) { + left = 10 + middleOffset * getSign(j); + } else { + left = + 10 + + ((Math.ceil(Math.abs(j - middleIndex)) - 1) * (gap + 24) + + middleOffset) * + getSign(j); + } + } else { + const offset = gap + iconWidth; + left = 10 + Math.ceil(Math.abs(j - middleIndex)) * offset * getSign(j); + } + positions.push([0, left]); + } + + return positions; +} + +function getIndexLabelTooltip(icon: TemplateResult, content: string) { + const styles = css` + .index-label-tooltip { + display: flex; + align-items: center; + flex-wrap: nowrap; + gap: 10px; + } + + .index-label-tooltip-icon { + display: flex; + align-items: center; + justify-content: center; + } + + .index-label-tooltip-content { + font-size: var(--affine-font-sm); + + display: flex; + height: 16px; + line-height: 16px; + } + `; + + return html`<style> + ${styles} + </style> + <div class="index-label-tooltip"> + <span class="index-label-tooltip-icon">${icon}</span> + <span class="index-label-tooltip-content">${content}</span> + </div>`; +} + +type AutoConnectElement = NoteBlockModel | FrameBlockModel; + +function isAutoConnectElement(element: unknown): element is AutoConnectElement { + return ( + element instanceof NoteBlockModel || element instanceof FrameBlockModel + ); +} + +export const AFFINE_EDGELESS_AUTO_CONNECT_WIDGET = + 'affine-edgeless-auto-connect-widget'; + +export class EdgelessAutoConnectWidget extends WidgetComponent< + RootBlockModel, + EdgelessRootBlockComponent, + EdgelessRootService +> { + static override styles = css` + .page-visible-index-label { + box-sizing: border-box; + padding: 0px 6px; + border: 1px solid #0000001a; + + width: fit-content; + height: 24px; + min-width: 24px; + + color: var(--affine-white); + font-size: 15px; + line-height: 22px; + text-align: center; + + cursor: pointer; + user-select: none; + + border-radius: 25px; + background: var(--affine-primary-color); + } + + .navigator { + width: 48px; + padding: 4px; + border-radius: 58px; + border: 1px solid rgba(227, 226, 228, 1); + transition: opacity 0.5s ease-in-out; + background: rgba(251, 251, 252, 1); + display: flex; + align-items: center; + justify-content: space-between; + opacity: 0; + } + + .navigator div { + display: flex; + align-items: center; + cursor: pointer; + } + + .navigator span { + display: inline-block; + height: 8px; + border: 1px solid rgba(227, 226, 228, 1); + } + + .navigator div:hover { + background: var(--affine-hover-color); + } + + .navigator.show { + opacity: 1; + } + `; + + private _updateLabels = () => { + const service = this.service; + if (!service.doc.root) return; + + const pageVisibleBlocks = new Map<AutoConnectElement, number>(); + const notes = service.doc.root?.children.filter(child => + matchFlavours(child, ['affine:note']) + ); + const edgelessOnlyNotesSet = new Set<NoteBlockModel>(); + + notes.forEach(note => { + if (isNoteBlock(note)) { + if (note.displayMode$.value === NoteDisplayMode.EdgelessOnly) { + edgelessOnlyNotesSet.add(note); + } else if (note.displayMode$.value === NoteDisplayMode.DocAndEdgeless) { + pageVisibleBlocks.set(note, 1); + } + } + + note.children.forEach(model => { + if (matchFlavours(model, ['affine:surface-ref'])) { + const reference = service.getElementById(model.reference); + + if (!isAutoConnectElement(reference)) return; + + if (!pageVisibleBlocks.has(reference)) { + pageVisibleBlocks.set(reference, 1); + } else { + pageVisibleBlocks.set( + reference, + pageVisibleBlocks.get(reference)! + 1 + ); + } + } + }); + }); + + this._edgelessOnlyNotesSet = edgelessOnlyNotesSet; + this._pageVisibleElementsMap = pageVisibleBlocks; + }; + + private _EdgelessOnlyLabels() { + const { _edgelessOnlyNotesSet } = this; + + if (!_edgelessOnlyNotesSet.size) return nothing; + + return html`${repeat( + _edgelessOnlyNotesSet, + note => note.id, + note => { + const { viewport } = this.service; + const { zoom } = viewport; + const bound = Bound.deserialize(note.xywh); + const [left, right] = viewport.toViewCoord(bound.x, bound.y); + const [width, height] = [bound.w * zoom, bound.h * zoom]; + const style = styleMap({ + width: `${EDGELESS_ONLY_INDEX_LABEL_WIDTH}px`, + height: `${EDGELESS_ONLY_INDEX_LABEL_HEIGHT}px`, + borderRadius: '50%', + backgroundColor: 'var(--affine-text-secondary-color)', + border: '1px solid var(--affine-border-color)', + color: 'var(--affine-white)', + position: 'absolute', + transform: `translate(${ + left + width / 2 - EDGELESS_ONLY_INDEX_LABEL_WIDTH / 2 + }px, + ${right + height + INDEX_LABEL_OFFSET}px)`, + display: 'flex', + justifyContent: 'center', + alignItems: 'center', + }); + + return html`<div style=${style} class="edgeless-only-index-label"> + ${HiddenIcon} + <affine-tooltip tip-position="bottom"> + ${getIndexLabelTooltip(SmallDocIcon, 'Hidden on page')} + </affine-tooltip> + </div>`; + } + )}`; + } + + private _getElementsAndCounts() { + const elements: AutoConnectElement[] = []; + const counts: number[] = []; + + for (const [key, value] of this._pageVisibleElementsMap.entries()) { + elements.push(key); + counts.push(value); + } + + return { elements, counts }; + } + + private _initLabels() { + const { service } = this.block; + const surfaceRefs = service.doc + .getBlocksByFlavour('affine:surface-ref') + .map(block => block.model) as SurfaceRefBlockModel[]; + + const getVisibility = () => { + const { selectedElements } = service.selection; + + if ( + selectedElements.length === 1 && + !service.selection.editing && + (isNoteBlock(selectedElements[0]) || + surfaceRefs.some(ref => ref.reference === selectedElements[0].id)) + ) { + this._show = true; + } else { + this._show = false; + } + + return this._show; + }; + + this._disposables.add( + service.selection.slots.updated.on(() => { + getVisibility(); + }) + ); + this._disposables.add( + this.doc.slots.blockUpdated.on(payload => { + if (payload.flavour === 'affine:surface-ref') { + switch (payload.type) { + case 'add': + surfaceRefs.push(payload.model as SurfaceRefBlockModel); + break; + case 'delete': + { + const idx = surfaceRefs.indexOf( + payload.model as SurfaceRefBlockModel + ); + if (idx >= 0) { + surfaceRefs.splice(idx, 1); + } + } + break; + case 'update': + if (payload.props.key !== 'reference') { + return; + } + } + + this.requestUpdate(); + } + }) + ); + this._disposables.add( + service.surface.elementUpdated.on(payload => { + if ( + payload.props['xywh'] && + surfaceRefs.some(ref => ref.reference === payload.id) + ) { + this.requestUpdate(); + } + }) + ); + } + + private _navigateToNext() { + const { elements } = this._getElementsAndCounts(); + if (this._index >= elements.length - 1) return; + this._index = this._index + 1; + const element = elements[this._index]; + const bound = Bound.deserialize(element.xywh); + this.service.selection.set({ + elements: [element.id], + editing: false, + }); + this.service.viewport.setViewportByBound(bound, [80, 80, 80, 80], true); + } + + private _navigateToPrev() { + const { elements } = this._getElementsAndCounts(); + if (this._index <= 0) return; + this._index = this._index - 1; + const element = elements[this._index]; + const bound = Bound.deserialize(element.xywh); + this.service.selection.set({ + elements: [element.id], + editing: false, + }); + this.service.viewport.setViewportByBound(bound, [80, 80, 80, 80], true); + } + + private _NavigatorComponent(elements: AutoConnectElement[]) { + const { viewport } = this.service; + const { zoom } = viewport; + const className = `navigator ${this._index >= 0 ? 'show' : 'hidden'}`; + const element = elements[this._index]; + const bound = Bound.deserialize(element.xywh); + const [left, right] = viewport.toViewCoord(bound.x, bound.y); + const [width, height] = [bound.w * zoom, bound.h * zoom]; + const navigatorStyle = styleMap({ + position: 'absolute', + transform: `translate(${left + width / 2 - 26}px, ${ + right + height + 16 + }px)`, + }); + + return html`<div class=${className} style=${navigatorStyle}> + <div + role="button" + class="edgeless-auto-connect-previous-button" + @pointerdown=${(e: PointerEvent) => { + stopPropagation(e); + this._navigateToPrev(); + }} + > + ${AutoConnectLeftIcon} + </div> + <span></span> + <div + role="button" + class="edgeless-auto-connect-next-button" + @pointerdown=${(e: PointerEvent) => { + stopPropagation(e); + this._navigateToNext(); + }} + > + ${AutoConnectRightIcon} + </div> + </div> `; + } + + private _PageVisibleIndexLabels( + elements: AutoConnectElement[], + counts: number[] + ) { + const { viewport } = this.service; + const { zoom } = viewport; + let index = 0; + + return html`${repeat( + elements, + element => element.id, + (element, i) => { + const bound = Bound.deserialize(element.xywh$.value); + const [left, right] = viewport.toViewCoord(bound.x, bound.y); + const [width, height] = [bound.w * zoom, bound.h * zoom]; + const style = styleMap({ + width: `${PAGE_VISIBLE_INDEX_LABEL_WIDTH}px`, + maxWidth: `${PAGE_VISIBLE_INDEX_LABEL_WIDTH}px`, + height: `${PAGE_VISIBLE_INDEX_LABEL_HEIGHT}px`, + position: 'absolute', + transform: `translate(${ + left + width / 2 - PAGE_VISIBLE_INDEX_LABEL_WIDTH / 2 + }px, + ${right + height + INDEX_LABEL_OFFSET}px)`, + display: 'flex', + justifyContent: 'center', + alignItems: 'center', + }); + const components = []; + const count = counts[i]; + const initGap = 24 / count - 24; + const positions = calculatePosition( + initGap, + count, + PAGE_VISIBLE_INDEX_LABEL_HEIGHT + ); + + for (let j = 0; j < count; j++) { + index++; + components.push(html` + <div + style=${styleMap({ + position: 'absolute', + top: positions[j][0] + 'px', + left: positions[j][1] + 'px', + transition: 'all 0.1s linear', + })} + index=${i} + class="page-visible-index-label" + @pointerdown=${(e: PointerEvent) => { + stopPropagation(e); + this._index = this._index === i ? -1 : i; + }} + > + ${index} + <affine-tooltip tip-position="bottom"> + ${getIndexLabelTooltip(SmallDocIcon, 'Page mode index')} + </affine-tooltip> + </div> + `); + } + + function updateChildrenPosition(e: MouseEvent, positions: number[][]) { + if (!e.target) return; + const children = (e.target as HTMLElement).children; + (Array.from(children) as HTMLElement[]).forEach((c, index) => { + c.style.top = positions[index][0] + 'px'; + c.style.left = positions[index][1] + 'px'; + }); + } + + return html`<div + style=${style} + @mouseenter=${(e: MouseEvent) => { + const positions = calculatePosition( + 5, + count, + PAGE_VISIBLE_INDEX_LABEL_HEIGHT + ); + updateChildrenPosition(e, positions); + }} + @mouseleave=${(e: MouseEvent) => { + const positions = calculatePosition( + initGap, + count, + PAGE_VISIBLE_INDEX_LABEL_HEIGHT + ); + updateChildrenPosition(e, positions); + }} + > + ${components} + </div>`; + } + )}`; + } + + private _setHostStyle() { + this.style.position = 'absolute'; + this.style.top = '0'; + this.style.left = '0'; + this.style.zIndex = '1'; + } + + override connectedCallback(): void { + super.connectedCallback(); + + this._setHostStyle(); + this._initLabels(); + } + + override firstUpdated(): void { + const { _disposables, service } = this; + + _disposables.add( + service.viewport.viewportUpdated.on(() => { + this.requestUpdate(); + }) + ); + + _disposables.add( + service.selection.slots.updated.on(() => { + const { selectedElements } = service.selection; + if ( + !(selectedElements.length === 1 && isNoteBlock(selectedElements[0])) + ) { + this._index = -1; + } + }) + ); + + _disposables.add( + service.uiEventDispatcher.add('dragStart', () => { + this._dragging = true; + }) + ); + _disposables.add( + service.uiEventDispatcher.add('dragEnd', () => { + this._dragging = false; + }) + ); + _disposables.add( + service.slots.elementResizeStart.on(() => { + this._dragging = true; + }) + ); + _disposables.add( + service.slots.elementResizeEnd.on(() => { + this._dragging = false; + }) + ); + } + + override render() { + const advancedVisibilityEnabled = this.doc.awarenessStore.getFlag( + 'enable_advanced_block_visibility' + ); + + if (!this._show || this._dragging || !advancedVisibilityEnabled) { + return nothing; + } + + this._updateLabels(); + + const { elements, counts } = this._getElementsAndCounts(); + + return html`${this._PageVisibleIndexLabels(elements, counts)} + ${this._EdgelessOnlyLabels()} + ${this._index >= 0 && this._index < elements.length + ? this._NavigatorComponent(elements) + : nothing} `; + } + + @state() + private accessor _dragging = false; + + @state() + private accessor _edgelessOnlyNotesSet = new Set<NoteBlockModel>(); + + @state() + private accessor _index = -1; + + @state() + private accessor _pageVisibleElementsMap: Map<AutoConnectElement, number> = + new Map(); + + @state() + private accessor _show = false; +} + +declare global { + interface HTMLElementTagNameMap { + 'affine-edgeless-auto-connect-widget': EdgelessAutoConnectWidget; + } +} diff --git a/blocksuite/blocks/src/root-block/widgets/edgeless-copilot-panel/index.ts b/blocksuite/blocks/src/root-block/widgets/edgeless-copilot-panel/index.ts new file mode 100644 index 0000000000..5b8b35c090 --- /dev/null +++ b/blocksuite/blocks/src/root-block/widgets/edgeless-copilot-panel/index.ts @@ -0,0 +1,97 @@ +import { on, stopPropagation } from '@blocksuite/affine-shared/utils'; +import type { EditorHost } from '@blocksuite/block-std'; +import { WithDisposable } from '@blocksuite/global/utils'; +import { css, html, LitElement, nothing } from 'lit'; +import { property } from 'lit/decorators.js'; + +import type { AIItemGroupConfig } from '../../../_common/components/ai-item/types.js'; +import { scrollbarStyle } from '../../../_common/components/utils.js'; +import type { EdgelessRootBlockComponent } from '../../edgeless/edgeless-root-block.js'; + +export class EdgelessCopilotPanel extends WithDisposable(LitElement) { + static override styles = css` + :host { + display: flex; + position: absolute; + } + + .edgeless-copilot-panel { + box-sizing: border-box; + padding: 8px 4px 8px 8px; + min-width: 330px; + max-height: 374px; + overflow-y: auto; + background: var(--affine-background-overlay-panel-color); + box-shadow: var(--affine-shadow-2); + border-radius: 8px; + z-index: var(--affine-z-index-popover); + } + + ${scrollbarStyle('.edgeless-copilot-panel')} + .edgeless-copilot-panel:hover::-webkit-scrollbar-thumb { + background-color: var(--affine-black-30); + } + `; + + private _getChain() { + return this.edgeless.service.std.command.chain(); + } + + override connectedCallback(): void { + super.connectedCallback(); + this._disposables.add(on(this, 'wheel', stopPropagation)); + this._disposables.add(on(this, 'pointerdown', stopPropagation)); + } + + hide() { + this.remove(); + } + + override render() { + const chain = this._getChain(); + const groups = this.groups.reduce((pre, group) => { + const filtered = group.items.filter(item => + item.showWhen?.(chain, 'edgeless', this.host) + ); + + if (filtered.length > 0) pre.push({ ...group, items: filtered }); + + return pre; + }, [] as AIItemGroupConfig[]); + + if (groups.every(group => group.items.length === 0)) return nothing; + + return html` + <div class="edgeless-copilot-panel"> + <ai-item-list + .onClick=${() => { + this.onClick?.(); + }} + .host=${this.host} + .groups=${groups} + ></ai-item-list> + </div> + `; + } + + @property({ attribute: false }) + accessor edgeless!: EdgelessRootBlockComponent; + + @property({ attribute: false }) + accessor entry: 'toolbar' | 'selection' | undefined = undefined; + + @property({ attribute: false }) + accessor groups!: AIItemGroupConfig[]; + + @property({ attribute: false }) + accessor host!: EditorHost; + + @property({ attribute: false }) + accessor onClick: (() => void) | undefined = undefined; +} + +declare global { + interface HTMLElementTagNameMap { + 'edgeless-copilot-panel': EdgelessCopilotPanel; + } +} diff --git a/blocksuite/blocks/src/root-block/widgets/edgeless-copilot-panel/toolbar-entry.ts b/blocksuite/blocks/src/root-block/widgets/edgeless-copilot-panel/toolbar-entry.ts new file mode 100644 index 0000000000..bdf447ca43 --- /dev/null +++ b/blocksuite/blocks/src/root-block/widgets/edgeless-copilot-panel/toolbar-entry.ts @@ -0,0 +1,75 @@ +import { AIStarIcon } from '@blocksuite/affine-components/icons'; +import type { EditorHost } from '@blocksuite/block-std'; +import { isGfxGroupCompatibleModel } from '@blocksuite/block-std/gfx'; +import { WithDisposable } from '@blocksuite/global/utils'; +import { css, html, LitElement } from 'lit'; +import { property } from 'lit/decorators.js'; + +import type { AIItemGroupConfig } from '../../../_common/components/ai-item/types.js'; +import type { EdgelessRootBlockComponent } from '../../edgeless/edgeless-root-block.js'; +import type { CopilotTool } from '../../edgeless/gfx-tool/copilot-tool.js'; +import { sortEdgelessElements } from '../../edgeless/utils/clone-utils.js'; + +export class EdgelessCopilotToolbarEntry extends WithDisposable(LitElement) { + static override styles = css` + .copilot-icon-button { + line-height: 20px; + + .label.medium { + color: var(--affine-brand-color); + } + } + `; + + private _onClick = () => { + this.onClick?.(); + this._showCopilotPanel(); + }; + + private _showCopilotPanel() { + const selectedElements = sortEdgelessElements( + this.edgeless.service.selection.selectedElements + ); + const toBeSelected = new Set(selectedElements); + + selectedElements.forEach(element => { + // its descendants are already selected + if (toBeSelected.has(element)) return; + + toBeSelected.add(element); + + if (isGfxGroupCompatibleModel(element)) { + element.descendantElements.forEach(descendant => { + toBeSelected.add(descendant); + }); + } + }); + + this.edgeless.gfx.tool.setTool('copilot'); + ( + this.edgeless.gfx.tool.currentTool$.peek() as CopilotTool + ).updateSelectionWith(Array.from(toBeSelected), 10); + } + + override render() { + return html`<edgeless-tool-icon-button + aria-label="Ask AI" + class="copilot-icon-button" + @click=${this._onClick} + > + ${AIStarIcon} <span class="label medium">Ask AI</span> + </edgeless-tool-icon-button>`; + } + + @property({ attribute: false }) + accessor edgeless!: EdgelessRootBlockComponent; + + @property({ attribute: false }) + accessor groups!: AIItemGroupConfig[]; + + @property({ attribute: false }) + accessor host!: EditorHost; + + @property({ attribute: false }) + accessor onClick: (() => void) | undefined = undefined; +} diff --git a/blocksuite/blocks/src/root-block/widgets/edgeless-copilot/index.ts b/blocksuite/blocks/src/root-block/widgets/edgeless-copilot/index.ts new file mode 100644 index 0000000000..1d8eb9cb26 --- /dev/null +++ b/blocksuite/blocks/src/root-block/widgets/edgeless-copilot/index.ts @@ -0,0 +1,299 @@ +import type { RootBlockModel } from '@blocksuite/affine-model'; +import { + MOUSE_BUTTON, + requestConnectedFrame, +} from '@blocksuite/affine-shared/utils'; +import { WidgetComponent } from '@blocksuite/block-std'; +import { Bound, getCommonBoundWithRotation } from '@blocksuite/global/utils'; +import { + autoUpdate, + computePosition, + flip, + offset, + shift, + size, +} from '@floating-ui/dom'; +import { effect } from '@preact/signals-core'; +import { css, html, nothing } from 'lit'; +import { query, state } from 'lit/decorators.js'; +import { styleMap } from 'lit/directives/style-map.js'; + +import type { AIItemGroupConfig } from '../../../_common/components/ai-item/types.js'; +import type { EdgelessRootBlockComponent } from '../../edgeless/edgeless-root-block.js'; +import { + AFFINE_AI_PANEL_WIDGET, + AffineAIPanelWidget, +} from '../ai-panel/ai-panel.js'; +import { EdgelessCopilotPanel } from '../edgeless-copilot-panel/index.js'; + +export const AFFINE_EDGELESS_COPILOT_WIDGET = 'affine-edgeless-copilot-widget'; + +export class EdgelessCopilotWidget extends WidgetComponent< + RootBlockModel, + EdgelessRootBlockComponent +> { + static override styles = css` + .copilot-selection-rect { + position: absolute; + box-sizing: border-box; + border-radius: 4px; + border: 2px dashed var(--affine-brand-color, #1e96eb); + } + `; + + private _clickOutsideOff: (() => void) | null = null; + + private _copilotPanel!: EdgelessCopilotPanel | null; + + private _listenClickOutsideId: number | null = null; + + private _selectionModelRect!: DOMRect; + + groups: AIItemGroupConfig[] = []; + + get edgeless() { + return this.block; + } + + get selectionModelRect() { + return this._selectionModelRect; + } + + get selectionRect() { + return this._selectionRect; + } + + get visible() { + return !!( + this._visible && + this._selectionRect.width && + this._selectionRect.height + ); + } + + set visible(visible: boolean) { + this._visible = visible; + } + + private _showCopilotPanel() { + requestConnectedFrame(() => { + if (!this._copilotPanel) { + const panel = new EdgelessCopilotPanel(); + panel.host = this.host; + panel.groups = this.groups; + panel.edgeless = this.edgeless; + this.renderRoot.append(panel); + this._copilotPanel = panel; + } + + const referenceElement = this.selectionElem; + const panel = this._copilotPanel; + // @TODO: optimize + const viewport = this.edgeless.service.viewport; + + if (!referenceElement || !referenceElement.isConnected) return; + + // show ai input + const rootBlockId = this.host.doc.root?.id; + if (rootBlockId) { + const aiPanel = this.host.view.getWidget( + AFFINE_AI_PANEL_WIDGET, + rootBlockId + ); + if (aiPanel instanceof AffineAIPanelWidget && aiPanel.config) { + aiPanel.setState('input', referenceElement); + } + } + + autoUpdate(referenceElement, panel, () => { + computePosition(referenceElement, panel, { + placement: 'right-start', + middleware: [ + offset({ + mainAxis: 16, + crossAxis: 45, + }), + flip({ + mainAxis: true, + crossAxis: true, + flipAlignment: true, + }), + shift(() => { + const { left, top, width, height } = viewport; + return { + padding: 20, + crossAxis: true, + rootBoundary: { + x: left, + y: top, + width, + height: height - 100, + }, + }; + }), + size({ + apply: ({ elements }) => { + const { height } = viewport; + elements.floating.style.maxHeight = `${height - 140}px`; + }, + }), + ], + }) + .then(({ x, y }) => { + panel.style.left = `${x}px`; + panel.style.top = `${y}px`; + }) + .catch(e => { + console.warn("Can't compute EdgelessCopilotPanel position", e); + }); + }); + }, this); + } + + private _updateSelection(rect: DOMRect) { + this._selectionModelRect = rect; + + const zoom = this.edgeless.service.viewport.zoom; + const [x, y] = this.edgeless.service.viewport.toViewCoord( + rect.left, + rect.top + ); + const [width, height] = [rect.width * zoom, rect.height * zoom]; + + this._selectionRect = { x, y, width, height }; + } + + private _watchClickOutside() { + this._clickOutsideOff?.(); + + const { width, height } = this._selectionRect; + + if (width && height) { + this._listenClickOutsideId && + cancelAnimationFrame(this._listenClickOutsideId); + this._listenClickOutsideId = requestConnectedFrame(() => { + if (!this.isConnected) { + return; + } + + const off = this.block.dispatcher.add('pointerDown', ctx => { + const e = ctx.get('pointerState').raw; + if ( + e.button === MOUSE_BUTTON.MAIN && + !this.contains(e.target as HTMLElement) + ) { + off(); + this._visible = false; + this.hideCopilotPanel(); + } + }); + this._listenClickOutsideId = null; + this._clickOutsideOff = off; + }, this); + } + } + + override connectedCallback(): void { + super.connectedCallback(); + + const CopilotSelectionTool = this.edgeless.gfx.tool.get('copilot'); + + this._disposables.add( + CopilotSelectionTool.draggingAreaUpdated.on(shouldShowPanel => { + this._visible = true; + this._updateSelection(CopilotSelectionTool.area); + if (shouldShowPanel) { + this._showCopilotPanel(); + this._watchClickOutside(); + } else { + this.hideCopilotPanel(); + } + }) + ); + + this._disposables.add( + this.edgeless.service.viewport.viewportUpdated.on(() => { + if (!this._visible) return; + + this._updateSelection(CopilotSelectionTool.area); + }) + ); + + this._disposables.add( + effect(() => { + const currentTool = this.edgeless.gfx.tool.currentToolName$.value; + + if (!this._visible || currentTool === 'copilot') return; + + this._visible = false; + this._clickOutsideOff = null; + this._copilotPanel?.remove(); + this._copilotPanel = null; + }) + ); + } + + determineInsertionBounds(width = 800, height = 95) { + const elements = this.edgeless.service.selection.selectedElements; + const offsetY = 20 / this.edgeless.service.viewport.zoom; + const bounds = new Bound(0, 0, width, height); + if (elements.length) { + const { x, y, h } = getCommonBoundWithRotation(elements); + bounds.x = x; + bounds.y = y + h + offsetY; + } else { + const { x, y, height: h } = this.selectionModelRect; + bounds.x = x; + bounds.y = y + h + offsetY; + } + return bounds; + } + + hideCopilotPanel() { + this._copilotPanel?.hide(); + this._copilotPanel = null; + this._clickOutsideOff = null; + } + + lockToolbar(disabled: boolean) { + this.edgeless.slots.toolbarLocked.emit(disabled); + } + + override render() { + if (!this._visible) return nothing; + + const rect = this._selectionRect; + + return html`<div class="affine-edgeless-ai"> + <div + class="copilot-selection-rect" + style=${styleMap({ + left: `${rect.x}px`, + top: `${rect.y}px`, + width: `${rect.width}px`, + height: `${rect.height}px`, + })} + ></div> + </div>`; + } + + @state() + private accessor _selectionRect: { + x: number; + y: number; + width: number; + height: number; + } = { x: 0, y: 0, width: 0, height: 0 }; + + @state() + private accessor _visible = false; + + @query('.copilot-selection-rect') + accessor selectionElem!: HTMLDivElement; +} + +declare global { + interface HTMLElementTagNameMap { + [AFFINE_EDGELESS_COPILOT_WIDGET]: EdgelessCopilotWidget; + } +} diff --git a/blocksuite/blocks/src/root-block/widgets/edgeless-remote-selection/index.ts b/blocksuite/blocks/src/root-block/widgets/edgeless-remote-selection/index.ts new file mode 100644 index 0000000000..c690045275 --- /dev/null +++ b/blocksuite/blocks/src/root-block/widgets/edgeless-remote-selection/index.ts @@ -0,0 +1,289 @@ +import { RemoteCursor } from '@blocksuite/affine-components/icons'; +import type { RootBlockModel } from '@blocksuite/affine-model'; +import { requestThrottledConnectedFrame } from '@blocksuite/affine-shared/utils'; +import { WidgetComponent } from '@blocksuite/block-std'; +import { assertExists, pickValues } from '@blocksuite/global/utils'; +import type { UserInfo } from '@blocksuite/store'; +import { css, html } from 'lit'; +import { state } from 'lit/decorators.js'; +import { repeat } from 'lit/directives/repeat.js'; +import { styleMap } from 'lit/directives/style-map.js'; + +import type { EdgelessRootBlockComponent } from '../../../root-block/edgeless/edgeless-root-block.js'; +import { + getSelectedRect, + isTopLevelBlock, +} from '../../../root-block/edgeless/utils/query.js'; +import { RemoteColorManager } from '../../../root-block/remote-color-manager/remote-color-manager.js'; + +export const AFFINE_EDGELESS_REMOTE_SELECTION_WIDGET = + 'affine-edgeless-remote-selection-widget'; + +export class EdgelessRemoteSelectionWidget extends WidgetComponent< + RootBlockModel, + EdgelessRootBlockComponent +> { + static override styles = css` + :host { + pointer-events: none; + position: absolute; + left: 0; + top: 0; + transform-origin: left top; + contain: size layout; + z-index: 1; + } + + .remote-rect { + position: absolute; + top: 0; + left: 0; + border-radius: 4px; + box-sizing: border-box; + border-width: 3px; + z-index: 1; + transform-origin: center center; + } + + .remote-cursor { + position: absolute; + top: 0; + left: 0; + transform-origin: left top; + z-index: 1; + } + + .remote-cursor > svg { + display: block; + } + + .remote-username { + margin-left: 22px; + margin-top: -2px; + + color: white; + + max-width: 160px; + padding: 0px 3px; + border: 1px solid var(--affine-pure-black-20); + + box-shadow: 0px 1px 6px 0px rgba(0, 0, 0, 0.16); + border-radius: 4px; + + font-size: 12px; + line-height: 18px; + + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + `; + + private _remoteColorManager: RemoteColorManager | null = null; + + private _updateOnElementChange = (element: string | { id: string }) => { + const id = typeof element === 'string' ? element : element.id; + + if (this.isConnected && this.selection.hasRemote(id)) + this._updateRemoteRects(); + }; + + private _updateRemoteCursor = () => { + const remoteCursors: EdgelessRemoteSelectionWidget['_remoteCursors'] = + new Map(); + const status = this.doc.awarenessStore.getStates(); + + this.selection.remoteCursorSelectionMap.forEach( + (cursorSelection, clientId) => { + remoteCursors.set(clientId, { + x: cursorSelection.x, + y: cursorSelection.y, + user: status.get(clientId)?.user, + }); + } + ); + + this._remoteCursors = remoteCursors; + }; + + private _updateRemoteRects = () => { + const { selection, block } = this; + const remoteSelectionsMap = selection.remoteSurfaceSelectionsMap; + const remoteRects: EdgelessRemoteSelectionWidget['_remoteRects'] = + new Map(); + + remoteSelectionsMap.forEach((selections, clientId) => { + selections.forEach(selection => { + if (selection.elements.length === 0) return; + + const elements = selection.elements + .map(id => block.service.getElementById(id)) + .filter(element => element) as BlockSuite.EdgelessModel[]; + const rect = getSelectedRect(elements); + + if (rect.width === 0 || rect.height === 0) return; + + const { left, top } = rect; + const [width, height] = [rect.width, rect.height]; + + let rotate = 0; + if (elements.length === 1) { + const element = elements[0]; + if (!isTopLevelBlock(element)) { + rotate = element.rotate ?? 0; + } + } + + remoteRects.set(clientId, { + width, + height, + borderStyle: 'solid', + left, + top, + rotate, + }); + }); + }); + + this._remoteRects = remoteRects; + }; + + private _updateTransform = requestThrottledConnectedFrame(() => { + const { translateX, translateY, zoom } = this.edgeless.service.viewport; + + this.style.setProperty('--v-zoom', `${zoom}`); + + this.style.setProperty( + 'transform', + `translate(${translateX}px, ${translateY}px) scale(var(--v-zoom))` + ); + }, this); + + get edgeless() { + return this.block; + } + + get selection() { + return this.edgeless.service.selection; + } + + get surface() { + return this.edgeless.surface; + } + + override connectedCallback() { + super.connectedCallback(); + + const { _disposables, doc, edgeless } = this; + + pickValues(edgeless.service.surface, [ + 'elementAdded', + 'elementRemoved', + 'elementUpdated', + ]).forEach(slot => { + _disposables.add(slot.on(this._updateOnElementChange)); + }); + + _disposables.add(doc.slots.blockUpdated.on(this._updateOnElementChange)); + + _disposables.add( + this.selection.slots.remoteUpdated.on(this._updateRemoteRects) + ); + _disposables.add( + this.selection.slots.remoteCursorUpdated.on(this._updateRemoteCursor) + ); + + _disposables.add( + edgeless.service.viewport.viewportUpdated.on(() => { + this._updateTransform(); + }) + ); + + this._updateTransform(); + this._updateRemoteRects(); + + this._remoteColorManager = new RemoteColorManager(this.std); + } + + override render() { + const { _remoteRects, _remoteCursors, _remoteColorManager } = this; + assertExists(_remoteColorManager); + + const rects = repeat( + _remoteRects.entries(), + value => value[0], + ([id, rect]) => + html`<div + data-client-id=${id} + class="remote-rect" + style=${styleMap({ + pointerEvents: 'none', + width: `${rect.width}px`, + height: `${rect.height}px`, + borderStyle: rect.borderStyle, + borderColor: _remoteColorManager.get(id), + transform: `translate(${rect.left}px, ${rect.top}px) rotate(${rect.rotate}deg)`, + })} + ></div>` + ); + + const cursors = repeat( + _remoteCursors.entries(), + value => value[0], + ([id, cursor]) => { + return html`<div + data-client-id=${id} + class="remote-cursor" + style=${styleMap({ + pointerEvents: 'none', + transform: `translate(${cursor.x}px, ${cursor.y}px) scale(calc(1/var(--v-zoom)))`, + color: _remoteColorManager.get(id), + })} + > + ${RemoteCursor} + <div + class="remote-username" + style=${styleMap({ + backgroundColor: _remoteColorManager.get(id), + })} + > + ${cursor.user?.name ?? 'Unknown'} + </div> + </div>`; + } + ); + + return html` + <div class="affine-edgeless-remote-selection">${rects}${cursors}</div> + `; + } + + @state() + private accessor _remoteCursors: Map< + number, + { + x: number; + y: number; + user?: UserInfo | undefined; + } + > = new Map(); + + @state() + private accessor _remoteRects: Map< + number, + { + width: number; + height: number; + borderStyle: string; + left: number; + top: number; + rotate: number; + } + > = new Map(); +} + +declare global { + interface HTMLElementTagNameMap { + [AFFINE_EDGELESS_REMOTE_SELECTION_WIDGET]: EdgelessRemoteSelectionWidget; + } +} diff --git a/blocksuite/blocks/src/root-block/widgets/edgeless-zoom-toolbar/index.ts b/blocksuite/blocks/src/root-block/widgets/edgeless-zoom-toolbar/index.ts new file mode 100644 index 0000000000..fc6695a9d1 --- /dev/null +++ b/blocksuite/blocks/src/root-block/widgets/edgeless-zoom-toolbar/index.ts @@ -0,0 +1,96 @@ +import type { RootBlockModel } from '@blocksuite/affine-model'; +import { WidgetComponent } from '@blocksuite/block-std'; +import { effect } from '@preact/signals-core'; +import { css, html, nothing } from 'lit'; +import { state } from 'lit/decorators.js'; + +import type { EdgelessRootBlockComponent } from '../../edgeless/edgeless-root-block.js'; + +export const AFFINE_EDGELESS_ZOOM_TOOLBAR_WIDGET = + 'affine-edgeless-zoom-toolbar-widget'; + +export class AffineEdgelessZoomToolbarWidget extends WidgetComponent< + RootBlockModel, + EdgelessRootBlockComponent +> { + static override styles = css` + :host { + position: absolute; + bottom: 20px; + left: 12px; + z-index: var(--affine-z-index-popover); + display: flex; + justify-content: center; + -webkit-user-select: none; + user-select: none; + } + + @container viewport (width <= 1200px) { + edgeless-zoom-toolbar { + display: none; + } + } + + @container viewport (width > 1200px) { + zoom-bar-toggle-button { + display: none; + } + } + `; + + get edgeless() { + return this.block; + } + + override connectedCallback() { + super.connectedCallback(); + + this.disposables.add( + effect(() => { + const currentTool = this.edgeless.gfx.tool.currentToolName$.value; + + if (currentTool !== 'frameNavigator') { + this._hide = false; + } + this.requestUpdate(); + }) + ); + } + + override firstUpdated() { + const { + disposables, + edgeless: { slots }, + } = this; + + disposables.add( + slots.navigatorSettingUpdated.on(({ hideToolbar }) => { + if (hideToolbar !== undefined) { + this._hide = hideToolbar; + } + }) + ); + } + + override render() { + if (this._hide || !this.edgeless) { + return nothing; + } + + return html` + <edgeless-zoom-toolbar .edgeless=${this.edgeless}></edgeless-zoom-toolbar> + <zoom-bar-toggle-button + .edgeless=${this.edgeless} + ></zoom-bar-toggle-button> + `; + } + + @state() + private accessor _hide = false; +} + +declare global { + interface HTMLElementTagNameMap { + [AFFINE_EDGELESS_ZOOM_TOOLBAR_WIDGET]: AffineEdgelessZoomToolbarWidget; + } +} diff --git a/blocksuite/blocks/src/root-block/widgets/edgeless-zoom-toolbar/zoom-bar-toggle-button.ts b/blocksuite/blocks/src/root-block/widgets/edgeless-zoom-toolbar/zoom-bar-toggle-button.ts new file mode 100644 index 0000000000..2258ddb38b --- /dev/null +++ b/blocksuite/blocks/src/root-block/widgets/edgeless-zoom-toolbar/zoom-bar-toggle-button.ts @@ -0,0 +1,114 @@ +import { MoreIcon } from '@blocksuite/affine-components/icons'; +import { createLitPortal } from '@blocksuite/affine-components/portal'; +import { stopPropagation } from '@blocksuite/affine-shared/utils'; +import { WithDisposable } from '@blocksuite/global/utils'; +import { offset } from '@floating-ui/dom'; +import { css, html, LitElement, nothing } from 'lit'; +import { property, query, state } from 'lit/decorators.js'; + +import type { EdgelessRootBlockComponent } from '../../edgeless/edgeless-root-block.js'; + +export class ZoomBarToggleButton extends WithDisposable(LitElement) { + static override styles = css` + :host { + display: flex; + } + .toggle-button { + display: flex; + position: relative; + } + edgeless-zoom-toolbar { + position: absolute; + bottom: initial; + } + `; + + private _abortController: AbortController | null = null; + + private _closeZoomMenu() { + if (this._abortController && !this._abortController.signal.aborted) { + this._abortController.abort(); + this._abortController = null; + this._showPopper = false; + } + } + + private _toggleZoomMenu() { + if (this._abortController && !this._abortController.signal.aborted) { + this._closeZoomMenu(); + return; + } + + this._abortController = new AbortController(); + this._abortController.signal.addEventListener('abort', () => { + this._showPopper = false; + }); + createLitPortal({ + template: html`<edgeless-zoom-toolbar + .edgeless=${this.edgeless} + .layout=${'vertical'} + ></edgeless-zoom-toolbar>`, + container: this._toggleButton, + computePosition: { + referenceElement: this._toggleButton, + placement: 'top', + middleware: [offset(4)], + autoUpdate: true, + }, + abortController: this._abortController, + closeOnClickAway: true, + }); + this._showPopper = true; + } + + override disconnectedCallback() { + super.disconnectedCallback(); + this._closeZoomMenu(); + } + + override firstUpdated() { + const { disposables } = this; + disposables.add( + this.edgeless.slots.readonlyUpdated.on(() => { + this.requestUpdate(); + }) + ); + } + + override render() { + if (this.edgeless.doc.readonly) { + return nothing; + } + + return html` + <div class="toggle-button" @pointerdown=${stopPropagation}> + <edgeless-tool-icon-button + .tooltip=${'Toggle Zoom Tool Bar'} + .tipPosition=${'right'} + .active=${this._showPopper} + .arrow=${false} + .activeMode=${'background'} + .iconContainerPadding=${6} + @click=${() => this._toggleZoomMenu()} + > + ${MoreIcon} + </edgeless-tool-icon-button> + </div> + `; + } + + @state() + private accessor _showPopper = false; + + @query('.toggle-button') + private accessor _toggleButton!: HTMLElement; + + @property({ attribute: false }) + accessor edgeless!: EdgelessRootBlockComponent; +} + +declare global { + interface HTMLElementTagNameMap { + 'zoom-bar-toggle-button': ZoomBarToggleButton; + } +} diff --git a/blocksuite/blocks/src/root-block/widgets/edgeless-zoom-toolbar/zoom-toolbar.ts b/blocksuite/blocks/src/root-block/widgets/edgeless-zoom-toolbar/zoom-toolbar.ts new file mode 100644 index 0000000000..ddf9fc8bfb --- /dev/null +++ b/blocksuite/blocks/src/root-block/widgets/edgeless-zoom-toolbar/zoom-toolbar.ts @@ -0,0 +1,213 @@ +import { + MinusIcon, + PlusIcon, + ViewBarIcon, +} from '@blocksuite/affine-components/icons'; +import { stopPropagation } from '@blocksuite/affine-shared/utils'; +import { WithDisposable } from '@blocksuite/global/utils'; +import { effect } from '@preact/signals-core'; +import { baseTheme } from '@toeverything/theme'; +import { css, html, LitElement, nothing, unsafeCSS } from 'lit'; +import { property } from 'lit/decorators.js'; + +import type { EdgelessRootBlockComponent } from '../../edgeless/edgeless-root-block.js'; +import { ZOOM_STEP } from '../../edgeless/utils/zoom.js'; + +export class EdgelessZoomToolbar extends WithDisposable(LitElement) { + static override styles = css` + :host { + display: flex; + } + + .edgeless-zoom-toolbar-container { + display: flex; + align-items: center; + background: transparent; + border-radius: 8px; + fill: currentcolor; + padding: 4px; + } + + .edgeless-zoom-toolbar-container.horizantal { + flex-direction: row; + } + + .edgeless-zoom-toolbar-container.vertical { + flex-direction: column; + width: 40px; + background-color: var(--affine-background-overlay-panel-color); + box-shadow: var(--affine-shadow-2); + border: 1px solid var(--affine-border-color); + border-radius: 8px; + } + + .edgeless-zoom-toolbar-container[level='second'] { + position: absolute; + bottom: 8px; + transform: translateY(-100%); + } + + .edgeless-zoom-toolbar-container[hidden] { + display: none; + } + + .zoom-percent { + display: flex; + align-items: center; + justify-content: center; + width: 40px; + height: 32px; + border: none; + box-sizing: border-box; + padding: 4px; + color: var(--affine-icon-color); + background-color: transparent; + border-radius: 4px; + cursor: pointer; + white-space: nowrap; + font-size: 12px; + font-weight: 500; + text-align: center; + font-family: ${unsafeCSS(baseTheme.fontSansFamily)}; + } + + .zoom-percent:hover { + color: var(--affine-primary-color); + background-color: var(--affine-hover-color); + } + + .zoom-percent[disabled] { + pointer-events: none; + cursor: not-allowed; + color: var(--affine-text-disable-color); + } + `; + + get edgelessService() { + return this.edgeless.service; + } + + get edgelessTool() { + return this.edgeless.gfx.tool.currentToolOption$.peek(); + } + + get locked() { + return this.edgelessService.locked; + } + + get viewport() { + return this.edgelessService.viewport; + } + + get zoom() { + if (!this.viewport) { + console.error('Something went wrong, viewport is not available'); + return 1; + } + return this.viewport.zoom; + } + + constructor(edgeless: EdgelessRootBlockComponent) { + super(); + this.edgeless = edgeless; + } + + private _isVerticalBar() { + return this.layout === 'vertical'; + } + + override connectedCallback() { + super.connectedCallback(); + + this.disposables.add( + effect(() => { + this.edgeless.gfx.tool.currentToolName$.value; + this.requestUpdate(); + }) + ); + } + + override firstUpdated() { + const { disposables } = this; + disposables.add( + this.edgeless.service.viewport.viewportUpdated.on(() => + this.requestUpdate() + ) + ); + disposables.add( + this.edgeless.slots.readonlyUpdated.on(() => { + this.requestUpdate(); + }) + ); + } + + override render() { + if (this.edgeless.doc.readonly) { + return nothing; + } + + const formattedZoom = `${Math.round(this.zoom * 100)}%`; + const classes = `edgeless-zoom-toolbar-container ${this.layout}`; + const locked = this.locked; + + return html` + <div + class=${classes} + @dblclick=${stopPropagation} + @mousedown=${stopPropagation} + @mouseup=${stopPropagation} + @pointerdown=${stopPropagation} + > + <edgeless-tool-icon-button + .tooltip=${'Fit to screen'} + .tipPosition=${this._isVerticalBar() ? 'right' : 'top-end'} + .arrow=${!this._isVerticalBar()} + @click=${() => this.edgelessService.zoomToFit()} + .iconContainerPadding=${4} + .disabled=${locked} + > + ${ViewBarIcon} + </edgeless-tool-icon-button> + <edgeless-tool-icon-button + .tooltip=${'Zoom out'} + .tipPosition=${this._isVerticalBar() ? 'right' : 'top'} + .arrow=${!this._isVerticalBar()} + @click=${() => this.edgelessService.setZoomByStep(-ZOOM_STEP)} + .iconContainerPadding=${4} + .disabled=${locked} + > + ${MinusIcon} + </edgeless-tool-icon-button> + <button + class="zoom-percent" + @click=${() => this.viewport.smoothZoom(1)} + .disabled=${locked} + > + ${formattedZoom} + </button> + <edgeless-tool-icon-button + .tooltip=${'Zoom in'} + .tipPosition=${this._isVerticalBar() ? 'right' : 'top'} + .arrow=${!this._isVerticalBar()} + @click=${() => this.edgelessService.setZoomByStep(ZOOM_STEP)} + .iconContainerPadding=${4} + .disabled=${locked} + > + ${PlusIcon} + </edgeless-tool-icon-button> + </div> + `; + } + + @property({ attribute: false }) + accessor edgeless: EdgelessRootBlockComponent; + + @property({ attribute: false }) + accessor layout: 'horizontal' | 'vertical' = 'horizontal'; +} + +declare global { + interface HTMLElementTagNameMap { + 'edgeless-zoom-toolbar': EdgelessZoomToolbar; + } +} diff --git a/blocksuite/blocks/src/root-block/widgets/element-toolbar/add-frame-button.ts b/blocksuite/blocks/src/root-block/widgets/element-toolbar/add-frame-button.ts new file mode 100644 index 0000000000..bab88940de --- /dev/null +++ b/blocksuite/blocks/src/root-block/widgets/element-toolbar/add-frame-button.ts @@ -0,0 +1,62 @@ +import { FrameIcon } from '@blocksuite/affine-components/icons'; +import { MindmapElementModel } from '@blocksuite/affine-model'; +import { TelemetryProvider } from '@blocksuite/affine-shared/services'; +import { Bound, WithDisposable } from '@blocksuite/global/utils'; +import { css, html, LitElement, nothing } from 'lit'; +import { property } from 'lit/decorators.js'; + +import type { EdgelessRootBlockComponent } from '../../edgeless/edgeless-root-block.js'; + +export class EdgelessAddFrameButton extends WithDisposable(LitElement) { + static override styles = css` + .label { + padding-left: 4px; + } + `; + + private _createFrame = () => { + const frame = this.edgeless.service.frame.createFrameOnSelected(); + if (!frame) return; + this.edgeless.std + .getOptional(TelemetryProvider) + ?.track('CanvasElementAdded', { + control: 'context-menu', + page: 'whiteboard editor', + module: 'toolbar', + segment: 'toolbar', + type: 'frame', + }); + this.edgeless.surface.fitToViewport(Bound.deserialize(frame.xywh)); + }; + + protected override render() { + return html` + <editor-icon-button + aria-label="Frame" + .tooltip=${'Frame'} + .labelHeight=${'20px'} + @click=${this._createFrame} + > + ${FrameIcon}<span class="label medium">Frame</span> + </editor-icon-button> + `; + } + + @property({ attribute: false }) + accessor edgeless!: EdgelessRootBlockComponent; +} + +export function renderAddFrameButton( + edgeless: EdgelessRootBlockComponent, + elements: BlockSuite.EdgelessModel[] +) { + if (elements.length < 2) return nothing; + if (elements.some(e => e.group instanceof MindmapElementModel)) + return nothing; + + return html` + <edgeless-add-frame-button + .edgeless=${edgeless} + ></edgeless-add-frame-button> + `; +} diff --git a/blocksuite/blocks/src/root-block/widgets/element-toolbar/add-group-button.ts b/blocksuite/blocks/src/root-block/widgets/element-toolbar/add-group-button.ts new file mode 100644 index 0000000000..8a89ed07e7 --- /dev/null +++ b/blocksuite/blocks/src/root-block/widgets/element-toolbar/add-group-button.ts @@ -0,0 +1,54 @@ +import { GroupIcon } from '@blocksuite/affine-components/icons'; +import { + GroupElementModel, + MindmapElementModel, +} from '@blocksuite/affine-model'; +import { WithDisposable } from '@blocksuite/global/utils'; +import { css, html, LitElement, nothing } from 'lit'; +import { property } from 'lit/decorators.js'; + +import type { EdgelessRootBlockComponent } from '../../edgeless/edgeless-root-block.js'; + +export class EdgelessAddGroupButton extends WithDisposable(LitElement) { + static override styles = css` + .label { + padding-left: 4px; + } + `; + + private _createGroup = () => { + this.edgeless.service.createGroupFromSelected(); + }; + + protected override render() { + return html` + <editor-icon-button + aria-label="Group" + .tooltip=${'Group'} + .labelHeight=${'20px'} + @click=${this._createGroup} + > + ${GroupIcon}<span class="label medium">Group</span> + </editor-icon-button> + `; + } + + @property({ attribute: false }) + accessor edgeless!: EdgelessRootBlockComponent; +} + +export function renderAddGroupButton( + edgeless: EdgelessRootBlockComponent, + elements: BlockSuite.EdgelessModel[] +) { + if (elements.length < 2) return nothing; + if (elements[0] instanceof GroupElementModel) return nothing; + if (elements.some(e => e.group instanceof MindmapElementModel)) + return nothing; + + return html` + <edgeless-add-group-button + .edgeless=${edgeless} + ></edgeless-add-group-button> + `; +} diff --git a/blocksuite/blocks/src/root-block/widgets/element-toolbar/align-button.ts b/blocksuite/blocks/src/root-block/widgets/element-toolbar/align-button.ts new file mode 100644 index 0000000000..6c71e4dd8e --- /dev/null +++ b/blocksuite/blocks/src/root-block/widgets/element-toolbar/align-button.ts @@ -0,0 +1,340 @@ +import { updateXYWH } from '@blocksuite/affine-block-surface'; +import { + AlignBottomIcon, + AlignDistributeHorizontallyIcon, + AlignDistributeVerticallyIcon, + AlignHorizontallyIcon, + AlignLeftIcon, + AlignRightIcon, + AlignTopIcon, + AlignVerticallyIcon, + SmallArrowDownIcon, +} from '@blocksuite/affine-components/icons'; +import { MindmapElementModel } from '@blocksuite/affine-model'; +import { Bound, WithDisposable } from '@blocksuite/global/utils'; +import { AutoTidyUpIcon, ResizeTidyUpIcon } from '@blocksuite/icons/lit'; +import { css, html, LitElement, nothing, type TemplateResult } from 'lit'; +import { property } from 'lit/decorators.js'; +import { repeat } from 'lit/directives/repeat.js'; + +import type { EdgelessRootBlockComponent } from '../../edgeless/edgeless-root-block.js'; + +const enum Alignment { + AutoArrange = 'Auto arrange', + AutoResize = 'Resize & Align', + Bottom = 'Align bottom', + DistributeHorizontally = 'Distribute horizontally', + DistributeVertically = 'Distribute vertically', + Horizontally = 'Align horizontally', + Left = 'Align left', + Right = 'Align right', + Top = 'Align top', + Vertically = 'Align vertically', +} + +interface AlignmentIcon { + name: Alignment; + content: TemplateResult<1>; +} + +const HORIZONTAL_ALIGNMENT: AlignmentIcon[] = [ + { + name: Alignment.Left, + content: AlignLeftIcon, + }, + { + name: Alignment.Horizontally, + content: AlignHorizontallyIcon, + }, + { + name: Alignment.Right, + content: AlignRightIcon, + }, + { + name: Alignment.DistributeHorizontally, + content: AlignDistributeHorizontallyIcon, + }, +]; + +const VERTICAL_ALIGNMENT: AlignmentIcon[] = [ + { + name: Alignment.Top, + content: AlignTopIcon, + }, + { + name: Alignment.Vertically, + content: AlignVerticallyIcon, + }, + { + name: Alignment.Bottom, + content: AlignBottomIcon, + }, + { + name: Alignment.DistributeVertically, + content: AlignDistributeVerticallyIcon, + }, +]; + +const AUTO_ALIGNMENT: AlignmentIcon[] = [ + { + name: Alignment.AutoArrange, + content: AutoTidyUpIcon({ width: '20px', height: '20px' }), + }, + { + name: Alignment.AutoResize, + content: ResizeTidyUpIcon({ width: '20px', height: '20px' }), + }, +]; + +export class EdgelessAlignButton extends WithDisposable(LitElement) { + static override styles = css` + .align-menu-content { + max-width: 120px; + flex-wrap: wrap; + padding: 8px 2px; + } + .align-menu-separator { + width: 120px; + height: 1px; + background-color: var(--affine-background-tertiary-color); + } + `; + + private get elements() { + return this.edgeless.service.selection.selectedElements; + } + + private _align(type: Alignment) { + switch (type) { + case Alignment.Left: + this._alignLeft(); + break; + case Alignment.Horizontally: + this._alignHorizontally(); + break; + case Alignment.Right: + this._alignRight(); + break; + case Alignment.DistributeHorizontally: + this._alignDistributeHorizontally(); + break; + case Alignment.Top: + this._alignTop(); + break; + case Alignment.Vertically: + this._alignVertically(); + break; + case Alignment.Bottom: + this._alignBottom(); + break; + case Alignment.DistributeVertically: + this._alignDistributeVertically(); + break; + case Alignment.AutoArrange: + this.edgeless.std.command.exec('autoArrangeElements'); + break; + case Alignment.AutoResize: + this.edgeless.std.command.exec('autoResizeElements'); + break; + } + } + + private _alignBottom() { + const { elements } = this; + const bounds = elements.map(a => a.elementBound); + const bottom = Math.max(...bounds.map(b => b.maxY)); + + elements.forEach((ele, index) => { + const elementBound = bounds[index]; + const bound = Bound.deserialize(ele.xywh); + const offset = bound.maxY - elementBound.maxY; + bound.y = bottom - bound.h + offset; + this._updateXYWH(ele, bound); + }); + } + + private _alignDistributeHorizontally() { + const { elements } = this; + + elements.sort((a, b) => a.elementBound.minX - b.elementBound.minX); + const bounds = elements.map(a => a.elementBound); + const left = bounds[0].minX; + const right = bounds[bounds.length - 1].maxX; + + const totalWidth = right - left; + const totalGap = + totalWidth - elements.reduce((prev, ele) => prev + ele.elementBound.w, 0); + const gap = totalGap / (elements.length - 1); + let next = bounds[0].maxX + gap; + for (let i = 1; i < elements.length - 1; i++) { + const bound = Bound.deserialize(elements[i].xywh); + bound.x = next + bounds[i].w / 2 - bound.w / 2; + next += gap + bounds[i].w; + this._updateXYWH(elements[i], bound); + } + } + + private _alignDistributeVertically() { + const { elements } = this; + + elements.sort((a, b) => a.elementBound.minY - b.elementBound.minY); + const bounds = elements.map(a => a.elementBound); + const top = bounds[0].minY; + const bottom = bounds[bounds.length - 1].maxY; + + const totalHeight = bottom - top; + const totalGap = + totalHeight - + elements.reduce((prev, ele) => prev + ele.elementBound.h, 0); + const gap = totalGap / (elements.length - 1); + let next = bounds[0].maxY + gap; + for (let i = 1; i < elements.length - 1; i++) { + const bound = Bound.deserialize(elements[i].xywh); + bound.y = next + bounds[i].h / 2 - bound.h / 2; + next += gap + bounds[i].h; + this._updateXYWH(elements[i], bound); + } + } + + private _alignHorizontally() { + const { elements } = this; + const bounds = elements.map(a => a.elementBound); + const left = Math.min(...bounds.map(b => b.minX)); + const right = Math.max(...bounds.map(b => b.maxX)); + const centerX = (left + right) / 2; + + elements.forEach(ele => { + const bound = Bound.deserialize(ele.xywh); + bound.x = centerX - bound.w / 2; + this._updateXYWH(ele, bound); + }); + } + + private _alignLeft() { + const { elements } = this; + const bounds = elements.map(a => a.elementBound); + const left = Math.min(...bounds.map(b => b.minX)); + + elements.forEach((ele, index) => { + const elementBound = bounds[index]; + const bound = Bound.deserialize(ele.xywh); + const offset = bound.minX - elementBound.minX; + bound.x = left + offset; + this._updateXYWH(ele, bound); + }); + } + + private _alignRight() { + const { elements } = this; + const bounds = elements.map(a => a.elementBound); + const right = Math.max(...bounds.map(b => b.maxX)); + + elements.forEach((ele, index) => { + const elementBound = bounds[index]; + const bound = Bound.deserialize(ele.xywh); + const offset = bound.maxX - elementBound.maxX; + bound.x = right - bound.w + offset; + this._updateXYWH(ele, bound); + }); + } + + private _alignTop() { + const { elements } = this; + const bounds = elements.map(a => a.elementBound); + const top = Math.min(...bounds.map(b => b.minY)); + + elements.forEach((ele, index) => { + const elementBound = bounds[index]; + const bound = Bound.deserialize(ele.xywh); + const offset = bound.minY - elementBound.minY; + bound.y = top + offset; + this._updateXYWH(ele, bound); + }); + } + + private _alignVertically() { + const { elements } = this; + const bounds = elements.map(a => a.elementBound); + const top = Math.min(...bounds.map(b => b.minY)); + const bottom = Math.max(...bounds.map(b => b.maxY)); + const centerY = (top + bottom) / 2; + + elements.forEach(ele => { + const bound = Bound.deserialize(ele.xywh); + bound.y = centerY - bound.h / 2; + this._updateXYWH(ele, bound); + }); + } + + private _updateXYWH(ele: BlockSuite.EdgelessModel, bound: Bound) { + const { updateElement } = this.edgeless.service; + const { updateBlock } = this.edgeless.doc; + updateXYWH(ele, bound, updateElement, updateBlock); + } + + private renderIcons(icons: AlignmentIcon[]) { + return html` + ${repeat( + icons, + (item, index) => item.name + index, + ({ name, content }) => { + return html` + <editor-icon-button + aria-label=${name} + .tooltip=${name} + @click=${() => this._align(name)} + > + ${content} + </editor-icon-button> + `; + } + )} + `; + } + + override firstUpdated() { + this._disposables.add( + this.edgeless.service.selection.slots.updated.on(() => + this.requestUpdate() + ) + ); + } + + override render() { + return html` + <editor-menu-button + .button=${html` + <editor-icon-button + aria-label="Align objects" + .tooltip=${'Align objects'} + > + ${AlignLeftIcon}${SmallArrowDownIcon} + </editor-icon-button> + `} + > + <div class="align-menu-content"> + ${this.renderIcons(HORIZONTAL_ALIGNMENT)} + ${this.renderIcons(VERTICAL_ALIGNMENT)} + <div class="align-menu-separator"></div> + ${this.renderIcons(AUTO_ALIGNMENT)} + </div> + </editor-menu-button> + `; + } + + @property({ attribute: false }) + accessor edgeless!: EdgelessRootBlockComponent; +} + +export function renderAlignButton( + edgeless: EdgelessRootBlockComponent, + elements: BlockSuite.EdgelessModel[] +) { + if (elements.length < 2) return nothing; + if (elements.some(e => e.group instanceof MindmapElementModel)) + return nothing; + + return html` + <edgeless-align-button .edgeless=${edgeless}></edgeless-align-button> + `; +} diff --git a/blocksuite/blocks/src/root-block/widgets/element-toolbar/change-attachment-button.ts b/blocksuite/blocks/src/root-block/widgets/element-toolbar/change-attachment-button.ts new file mode 100644 index 0000000000..2268b1351c --- /dev/null +++ b/blocksuite/blocks/src/root-block/widgets/element-toolbar/change-attachment-button.ts @@ -0,0 +1,159 @@ +import { + CaptionIcon, + DownloadIcon, + PaletteIcon, +} from '@blocksuite/affine-components/icons'; +import { renderToolbarSeparator } from '@blocksuite/affine-components/toolbar'; +import type { AttachmentBlockModel } from '@blocksuite/affine-model'; +import { ThemeProvider } from '@blocksuite/affine-shared/services'; +import { Bound, WithDisposable } from '@blocksuite/global/utils'; +import type { TemplateResult } from 'lit'; +import { html, LitElement, nothing } from 'lit'; +import { property } from 'lit/decorators.js'; +import { join } from 'lit/directives/join.js'; + +import { + EMBED_CARD_HEIGHT, + EMBED_CARD_WIDTH, +} from '../../../_common/consts.js'; +import type { EmbedCardStyle } from '../../../_common/types.js'; +import { getEmbedCardIcons } from '../../../_common/utils/url.js'; +import type { AttachmentBlockComponent } from '../../../attachment-block/index.js'; +import { attachmentViewToggleMenu } from '../../../attachment-block/index.js'; +import type { EdgelessRootBlockComponent } from '../../edgeless/edgeless-root-block.js'; + +export class EdgelessChangeAttachmentButton extends WithDisposable(LitElement) { + private _download = () => { + this._block?.download(); + }; + + private _setCardStyle = (style: EmbedCardStyle) => { + const bounds = Bound.deserialize(this.model.xywh); + bounds.w = EMBED_CARD_WIDTH[style]; + bounds.h = EMBED_CARD_HEIGHT[style]; + const xywh = bounds.serialize(); + this.model.doc.updateBlock(this.model, { style, xywh }); + }; + + private _showCaption = () => { + this._block?.captionEditor?.show(); + }; + + private get _block() { + const block = this.std.view.getBlock(this.model.id); + if (!block) return null; + return block as AttachmentBlockComponent; + } + + private get _doc() { + return this.model.doc; + } + + private get _getCardStyleOptions(): { + style: EmbedCardStyle; + Icon: TemplateResult<1>; + tooltip: string; + }[] { + const theme = this.std.get(ThemeProvider).theme; + const { EmbedCardListIcon, EmbedCardCubeIcon } = getEmbedCardIcons(theme); + return [ + { + style: 'horizontalThin', + Icon: EmbedCardListIcon, + tooltip: 'Horizontal style', + }, + { + style: 'cubeThick', + Icon: EmbedCardCubeIcon, + tooltip: 'Vertical style', + }, + ]; + } + + get std() { + return this.edgeless.std; + } + + get viewToggleMenu() { + const block = this._block; + const model = this.model; + if (!block || !model) return nothing; + + return attachmentViewToggleMenu({ + block, + callback: () => this.requestUpdate(), + }); + } + + override render() { + return join( + [ + this.model.style === 'pdf' + ? null + : html` + <editor-menu-button + .contentPadding=${'8px'} + .button=${html` + <editor-icon-button + aria-label="Card style" + .tooltip=${'Card style'} + > + ${PaletteIcon} + </editor-icon-button> + `} + > + <card-style-panel + .value=${this.model.style} + .options=${this._getCardStyleOptions} + .onSelect=${this._setCardStyle} + > + </card-style-panel> + </editor-menu-button> + `, + this.viewToggleMenu, + html` + <editor-icon-button + aria-label="Download" + .tooltip=${'Download'} + ?disabled=${this._doc.readonly} + @click=${this._download} + > + ${DownloadIcon} + </editor-icon-button> + `, + html` + <editor-icon-button + aria-label="Add caption" + .tooltip=${'Add caption'} + class="change-attachment-button caption" + ?disabled=${this._doc.readonly} + @click=${this._showCaption} + > + ${CaptionIcon} + </editor-icon-button> + `, + ].filter(button => button !== nothing && button), + renderToolbarSeparator + ); + } + + @property({ attribute: false }) + accessor edgeless!: EdgelessRootBlockComponent; + + @property({ attribute: false }) + accessor model!: AttachmentBlockModel; +} + +export function renderAttachmentButton( + edgeless: EdgelessRootBlockComponent, + attachments?: AttachmentBlockModel[] +) { + if (attachments?.length !== 1) return nothing; + + return html` + <edgeless-change-attachment-button + .model=${attachments[0]} + .edgeless=${edgeless} + ></edgeless-change-attachment-button> + `; +} diff --git a/blocksuite/blocks/src/root-block/widgets/element-toolbar/change-brush-button.ts b/blocksuite/blocks/src/root-block/widgets/element-toolbar/change-brush-button.ts new file mode 100644 index 0000000000..a50b6f146e --- /dev/null +++ b/blocksuite/blocks/src/root-block/widgets/element-toolbar/change-brush-button.ts @@ -0,0 +1,192 @@ +import type { + BrushElementModel, + BrushProps, + ColorScheme, +} from '@blocksuite/affine-model'; +import { LINE_COLORS, LineWidth } from '@blocksuite/affine-model'; +import { countBy, maxBy, WithDisposable } from '@blocksuite/global/utils'; +import { html, LitElement, nothing } from 'lit'; +import { property, query, state } from 'lit/decorators.js'; +import { when } from 'lit/directives/when.js'; + +import type { EdgelessColorPickerButton } from '../../edgeless/components/color-picker/button.js'; +import type { PickColorEvent } from '../../edgeless/components/color-picker/types.js'; +import { + packColor, + packColorsWithColorScheme, +} from '../../edgeless/components/color-picker/utils.js'; +import type { ColorEvent } from '../../edgeless/components/panel/color-panel.js'; +import { GET_DEFAULT_LINE_COLOR } from '../../edgeless/components/panel/color-panel.js'; +import type { LineWidthEvent } from '../../edgeless/components/panel/line-width-panel.js'; +import type { EdgelessRootBlockComponent } from '../../edgeless/edgeless-root-block.js'; + +function getMostCommonColor( + elements: BrushElementModel[], + colorScheme: ColorScheme +): string { + const colors = countBy(elements, (ele: BrushElementModel) => { + return typeof ele.color === 'object' + ? (ele.color[colorScheme] ?? ele.color.normal ?? null) + : ele.color; + }); + const max = maxBy(Object.entries(colors), ([_k, count]) => count); + return max ? (max[0] as string) : GET_DEFAULT_LINE_COLOR(colorScheme); +} + +function getMostCommonSize(elements: BrushElementModel[]): LineWidth { + const sizes = countBy(elements, ele => ele.lineWidth); + const max = maxBy(Object.entries(sizes), ([_k, count]) => count); + return max ? (Number(max[0]) as LineWidth) : LineWidth.Four; +} + +function notEqual<K extends keyof BrushProps>(key: K, value: BrushProps[K]) { + return (element: BrushElementModel) => element[key] !== value; +} + +export class EdgelessChangeBrushButton extends WithDisposable(LitElement) { + private _setBrushColor = ({ detail: color }: ColorEvent) => { + this._setBrushProp('color', color); + this._selectedColor = color; + }; + + private _setLineWidth = ({ detail: lineWidth }: LineWidthEvent) => { + this._setBrushProp('lineWidth', lineWidth); + this._selectedSize = lineWidth; + }; + + pickColor = (event: PickColorEvent) => { + if (event.type === 'pick') { + this.elements.forEach(ele => + this.service.updateElement( + ele.id, + packColor('color', { ...event.detail }) + ) + ); + return; + } + + this.elements.forEach(ele => + ele[event.type === 'start' ? 'stash' : 'pop']('color') + ); + }; + + get doc() { + return this.edgeless.doc; + } + + get selectedColor() { + const colorScheme = this.edgeless.surface.renderer.getColorScheme(); + return ( + this._selectedColor ?? getMostCommonColor(this.elements, colorScheme) + ); + } + + get selectedSize() { + return this._selectedSize ?? getMostCommonSize(this.elements); + } + + get service() { + return this.edgeless.service; + } + + get surface() { + return this.edgeless.surface; + } + + private _setBrushProp<K extends keyof BrushProps>( + key: K, + value: BrushProps[K] + ) { + this.doc.captureSync(); + this.elements + .filter(notEqual(key, value)) + .forEach(element => + this.service.updateElement(element.id, { [key]: value }) + ); + } + + override render() { + const colorScheme = this.edgeless.surface.renderer.getColorScheme(); + const elements = this.elements; + const { selectedSize, selectedColor } = this; + + return html` + <edgeless-line-width-panel + .selectedSize=${selectedSize} + @select=${this._setLineWidth} + > + </edgeless-line-width-panel> + + <editor-toolbar-separator></editor-toolbar-separator> + + ${when( + this.edgeless.doc.awarenessStore.getFlag('enable_color_picker'), + () => { + const { type, colors } = packColorsWithColorScheme( + colorScheme, + selectedColor, + elements[0].color + ); + + return html` + <edgeless-color-picker-button + class="color" + .label=${'Color'} + .pick=${this.pickColor} + .color=${selectedColor} + .colors=${colors} + .colorType=${type} + .palettes=${LINE_COLORS} + > + </edgeless-color-picker-button> + `; + }, + () => html` + <editor-menu-button + .contentPadding=${'8px'} + .button=${html` + <editor-icon-button aria-label="Color" .tooltip=${'Color'}> + <edgeless-color-button + .color=${selectedColor} + ></edgeless-color-button> + </editor-icon-button> + `} + > + <edgeless-color-panel + .value=${selectedColor} + @select=${this._setBrushColor} + > + </edgeless-color-panel> + </editor-menu-button> + ` + )} + `; + } + + @state() + private accessor _selectedColor: string | null = null; + + @state() + private accessor _selectedSize: LineWidth | null = null; + + @query('edgeless-color-picker-button.color') + accessor colorButton!: EdgelessColorPickerButton; + + @property({ attribute: false }) + accessor edgeless!: EdgelessRootBlockComponent; + + @property({ attribute: false }) + accessor elements: BrushElementModel[] = []; +} + +export function renderChangeBrushButton( + edgeless: EdgelessRootBlockComponent, + elements?: BrushElementModel[] +) { + if (!elements?.length) return nothing; + + return html` + <edgeless-change-brush-button .elements=${elements} .edgeless=${edgeless}> + </edgeless-change-brush-button> + `; +} diff --git a/blocksuite/blocks/src/root-block/widgets/element-toolbar/change-connector-button.ts b/blocksuite/blocks/src/root-block/widgets/element-toolbar/change-connector-button.ts new file mode 100644 index 0000000000..b1786de2fa --- /dev/null +++ b/blocksuite/blocks/src/root-block/widgets/element-toolbar/change-connector-button.ts @@ -0,0 +1,626 @@ +import { + AddTextIcon, + ConnectorCWithArrowIcon, + ConnectorEndpointNoneIcon, + ConnectorLWithArrowIcon, + ConnectorXWithArrowIcon, + FlipDirectionIcon, + FrontEndpointArrowIcon, + FrontEndpointCircleIcon, + FrontEndpointDiamondIcon, + FrontEndpointTriangleIcon, + GeneralStyleIcon, + RearEndpointArrowIcon, + RearEndpointCircleIcon, + RearEndpointDiamondIcon, + RearEndpointTriangleIcon, + ScribbledStyleIcon, + SmallArrowDownIcon, +} from '@blocksuite/affine-components/icons'; +import { renderToolbarSeparator } from '@blocksuite/affine-components/toolbar'; +import { + type ColorScheme, + type ConnectorElementModel, + type ConnectorElementProps, + ConnectorEndpoint, + type ConnectorLabelProps, + ConnectorMode, + DEFAULT_FRONT_END_POINT_STYLE, + DEFAULT_REAR_END_POINT_STYLE, + LINE_COLORS, + LineWidth, + PointStyle, + StrokeStyle, +} from '@blocksuite/affine-model'; +import { countBy, maxBy, WithDisposable } from '@blocksuite/global/utils'; +import { html, LitElement, nothing, type TemplateResult } from 'lit'; +import { property, query } from 'lit/decorators.js'; +import { choose } from 'lit/directives/choose.js'; +import { join } from 'lit/directives/join.js'; +import { repeat } from 'lit/directives/repeat.js'; +import { styleMap } from 'lit/directives/style-map.js'; +import { when } from 'lit/directives/when.js'; + +import type { EdgelessColorPickerButton } from '../../edgeless/components/color-picker/button.js'; +import type { PickColorEvent } from '../../edgeless/components/color-picker/types.js'; +import { + packColor, + packColorsWithColorScheme, +} from '../../edgeless/components/color-picker/utils.js'; +import { + type ColorEvent, + GET_DEFAULT_LINE_COLOR, +} from '../../edgeless/components/panel/color-panel.js'; +import { + type LineStyleEvent, + LineStylesPanel, +} from '../../edgeless/components/panel/line-styles-panel.js'; +import type { EdgelessRootBlockComponent } from '../../edgeless/edgeless-root-block.js'; +import { mountConnectorLabelEditor } from '../../edgeless/utils/text.js'; + +function getMostCommonColor( + elements: ConnectorElementModel[], + colorScheme: ColorScheme +): string | null { + const colors = countBy(elements, (ele: ConnectorElementModel) => { + return typeof ele.stroke === 'object' + ? (ele.stroke[colorScheme] ?? ele.stroke.normal ?? null) + : ele.stroke; + }); + const max = maxBy(Object.entries(colors), ([_k, count]) => count); + return max ? (max[0] as string) : null; +} + +function getMostCommonMode( + elements: ConnectorElementModel[] +): ConnectorMode | null { + const modes = countBy(elements, ele => ele.mode); + const max = maxBy(Object.entries(modes), ([_k, count]) => count); + return max ? (Number(max[0]) as ConnectorMode) : null; +} + +function getMostCommonLineWidth(elements: ConnectorElementModel[]): LineWidth { + const sizes = countBy(elements, ele => ele.strokeWidth); + const max = maxBy(Object.entries(sizes), ([_k, count]) => count); + return max ? (Number(max[0]) as LineWidth) : LineWidth.Four; +} + +export function getMostCommonLineStyle( + elements: ConnectorElementModel[] +): StrokeStyle | null { + const sizes = countBy(elements, ele => ele.strokeStyle); + const max = maxBy(Object.entries(sizes), ([_k, count]) => count); + return max ? (max[0] as StrokeStyle) : null; +} + +function getMostCommonRough(elements: ConnectorElementModel[]): boolean { + const { trueCount, falseCount } = elements.reduce( + (counts, ele) => { + if (ele.rough) { + counts.trueCount++; + } else { + counts.falseCount++; + } + return counts; + }, + { trueCount: 0, falseCount: 0 } + ); + + return trueCount > falseCount; +} + +function getMostCommonEndpointStyle( + elements: ConnectorElementModel[], + endpoint: ConnectorEndpoint +): PointStyle | null { + const field = + endpoint === ConnectorEndpoint.Front + ? 'frontEndpointStyle' + : 'rearEndpointStyle'; + const modes = countBy(elements, ele => ele[field]); + const max = maxBy(Object.entries(modes), ([_k, count]) => count); + return max ? (max[0] as PointStyle) : null; +} + +function notEqual< + K extends keyof Omit<ConnectorElementProps, keyof ConnectorLabelProps>, +>(key: K, value: ConnectorElementProps[K]) { + return (element: ConnectorElementModel) => element[key] !== value; +} + +interface EndpointStyle { + value: PointStyle; + icon: TemplateResult<1>; +} + +const STYLE_LIST = [ + { + name: 'General', + value: false, + icon: GeneralStyleIcon, + }, + { + name: 'Scribbled', + value: true, + icon: ScribbledStyleIcon, + }, +] as const; + +const STYLE_CHOOSE: [boolean, () => TemplateResult<1>][] = [ + [false, () => GeneralStyleIcon], + [true, () => ScribbledStyleIcon], +] as const; + +const FRONT_ENDPOINT_STYLE_LIST: EndpointStyle[] = [ + { + value: PointStyle.None, + icon: ConnectorEndpointNoneIcon, + }, + { + value: PointStyle.Arrow, + icon: FrontEndpointArrowIcon, + }, + { + value: PointStyle.Triangle, + icon: FrontEndpointTriangleIcon, + }, + { + value: PointStyle.Circle, + icon: FrontEndpointCircleIcon, + }, + { + value: PointStyle.Diamond, + icon: FrontEndpointDiamondIcon, + }, +] as const; + +const REAR_ENDPOINT_STYLE_LIST: EndpointStyle[] = [ + { + value: PointStyle.Diamond, + icon: RearEndpointDiamondIcon, + }, + { + value: PointStyle.Circle, + icon: RearEndpointCircleIcon, + }, + { + value: PointStyle.Triangle, + icon: RearEndpointTriangleIcon, + }, + { + value: PointStyle.Arrow, + icon: RearEndpointArrowIcon, + }, + { + value: PointStyle.None, + icon: ConnectorEndpointNoneIcon, + }, +] as const; + +const MODE_LIST = [ + { + name: 'Curve', + icon: ConnectorCWithArrowIcon, + value: ConnectorMode.Curve, + }, + { + name: 'Elbowed', + icon: ConnectorXWithArrowIcon, + value: ConnectorMode.Orthogonal, + }, + { + name: 'Straight', + icon: ConnectorLWithArrowIcon, + value: ConnectorMode.Straight, + }, +] as const; + +const MODE_CHOOSE: [ConnectorMode, () => TemplateResult<1>][] = [ + [ConnectorMode.Curve, () => ConnectorCWithArrowIcon], + [ConnectorMode.Orthogonal, () => ConnectorXWithArrowIcon], + [ConnectorMode.Straight, () => ConnectorLWithArrowIcon], +] as const; + +export class EdgelessChangeConnectorButton extends WithDisposable(LitElement) { + pickColor = (event: PickColorEvent) => { + if (event.type === 'pick') { + this.elements.forEach(ele => + this.service.updateElement( + ele.id, + packColor('stroke', { ...event.detail }) + ) + ); + return; + } + + this.elements.forEach(ele => + ele[event.type === 'start' ? 'stash' : 'pop']('stroke') + ); + }; + + get doc() { + return this.edgeless.doc; + } + + get service() { + return this.edgeless.service; + } + + private _addLabel() { + mountConnectorLabelEditor(this.elements[0], this.edgeless); + } + + private _flipEndpointStyle( + frontEndpointStyle: PointStyle, + rearEndpointStyle: PointStyle + ) { + if (frontEndpointStyle === rearEndpointStyle) return; + + this.elements.forEach(element => + this.service.updateElement(element.id, { + frontEndpointStyle: rearEndpointStyle, + rearEndpointStyle: frontEndpointStyle, + }) + ); + } + + private _getEndpointIcon(list: EndpointStyle[], style: PointStyle) { + return ( + list.find(({ value }) => value === style)?.icon || + ConnectorEndpointNoneIcon + ); + } + + private _setConnectorColor(stroke: string) { + this._setConnectorProp('stroke', stroke); + } + + private _setConnectorMode(mode: ConnectorMode) { + this._setConnectorProp('mode', mode); + } + + private _setConnectorPointStyle(end: ConnectorEndpoint, style: PointStyle) { + const props = { + [end === ConnectorEndpoint.Front + ? 'frontEndpointStyle' + : 'rearEndpointStyle']: style, + }; + this.elements.forEach(element => + this.service.updateElement(element.id, { ...props }) + ); + } + + private _setConnectorProp< + K extends keyof Omit<ConnectorElementProps, keyof ConnectorLabelProps>, + >(key: K, value: ConnectorElementProps[K]) { + this.doc.captureSync(); + this.elements + .filter(notEqual(key, value)) + .forEach(element => + this.service.updateElement(element.id, { [key]: value }) + ); + } + + private _setConnectorRough(rough: boolean) { + this._setConnectorProp('rough', rough); + } + + private _setConnectorStroke({ type, value }: LineStyleEvent) { + if (type === 'size') { + this._setConnectorStrokeWidth(value); + return; + } + this._setConnectorStrokeStyle(value); + } + + private _setConnectorStrokeStyle(strokeStyle: StrokeStyle) { + this._setConnectorProp('strokeStyle', strokeStyle); + } + + private _setConnectorStrokeWidth(strokeWidth: number) { + this._setConnectorProp('strokeWidth', strokeWidth); + } + + private _showAddButtonOrTextMenu() { + if (this.elements.length === 1 && !this.elements[0].text) { + return 'button'; + } + if (!this.elements.some(e => !e.text)) { + return 'menu'; + } + return 'nothing'; + } + + override render() { + const colorScheme = this.edgeless.surface.renderer.getColorScheme(); + const elements = this.elements; + const selectedColor = + getMostCommonColor(elements, colorScheme) ?? + GET_DEFAULT_LINE_COLOR(colorScheme); + const selectedMode = getMostCommonMode(elements); + const selectedLineSize = getMostCommonLineWidth(elements) ?? LineWidth.Four; + const selectedRough = getMostCommonRough(elements); + const selectedLineStyle = + getMostCommonLineStyle(elements) ?? StrokeStyle.Solid; + const selectedStartPointStyle = + getMostCommonEndpointStyle(elements, ConnectorEndpoint.Front) ?? + DEFAULT_FRONT_END_POINT_STYLE; + const selectedEndPointStyle = + getMostCommonEndpointStyle(elements, ConnectorEndpoint.Rear) ?? + DEFAULT_REAR_END_POINT_STYLE; + + return join( + [ + when( + this.edgeless.doc.awarenessStore.getFlag('enable_color_picker'), + () => { + const { type, colors } = packColorsWithColorScheme( + colorScheme, + selectedColor, + elements[0].stroke + ); + + return html` + <edgeless-color-picker-button + class="stroke-color" + .label=${'Stroke style'} + .pick=${this.pickColor} + .color=${selectedColor} + .colors=${colors} + .colorType=${type} + .palettes=${LINE_COLORS} + .hollowCircle=${true} + > + <div + slot="other" + class="line-styles" + style=${styleMap({ + display: 'flex', + flexDirection: 'row', + gap: '8px', + alignItems: 'center', + })} + > + ${LineStylesPanel({ + selectedLineSize: selectedLineSize, + selectedLineStyle: selectedLineStyle, + onClick: (e: LineStyleEvent) => this._setConnectorStroke(e), + lineStyles: [StrokeStyle.Solid, StrokeStyle.Dash], + })} + </div> + <editor-toolbar-separator + slot="separator" + data-orientation="horizontal" + ></editor-toolbar-separator> + </edgeless-color-picker-button> + `; + }, + () => html` + <editor-menu-button + .contentPadding=${'8px'} + .button=${html` + <editor-icon-button + aria-label="Stroke style" + .tooltip=${'Stroke style'} + > + <edgeless-color-button + .color=${selectedColor} + ></edgeless-color-button> + </editor-icon-button> + `} + > + <stroke-style-panel + .strokeWidth=${selectedLineSize} + .strokeStyle=${selectedLineStyle} + .strokeColor=${selectedColor} + .setStrokeStyle=${(e: LineStyleEvent) => + this._setConnectorStroke(e)} + .setStrokeColor=${(e: ColorEvent) => + this._setConnectorColor(e.detail)} + > + </stroke-style-panel> + </editor-menu-button> + ` + ), + + html` + <editor-menu-button + .button=${html` + <editor-icon-button aria-label="Style" .tooltip=${'Style'}> + ${choose(selectedRough, STYLE_CHOOSE)}${SmallArrowDownIcon} + </editor-icon-button> + `} + > + <div> + ${repeat( + STYLE_LIST, + item => item.name, + ({ name, value, icon }) => html` + <editor-icon-button + aria-label=${name} + .tooltip=${name} + .active=${selectedRough === value} + .activeMode=${'background'} + @click=${() => this._setConnectorRough(value)} + > + ${icon} + </editor-icon-button> + ` + )} + </div> + </editor-menu-button> + `, + + html` + <editor-menu-button + .button=${html` + <editor-icon-button + aria-label="Start point style" + .tooltip=${'Start point style'} + > + ${this._getEndpointIcon( + FRONT_ENDPOINT_STYLE_LIST, + selectedStartPointStyle + )}${SmallArrowDownIcon} + </editor-icon-button> + `} + > + <div> + ${repeat( + FRONT_ENDPOINT_STYLE_LIST, + item => item.value, + ({ value, icon }) => html` + <editor-icon-button + aria-label=${value} + .tooltip=${value} + .active=${selectedStartPointStyle === value} + .activeMode=${'background'} + @click=${() => + this._setConnectorPointStyle( + ConnectorEndpoint.Front, + value + )} + > + ${icon} + </editor-icon-button> + ` + )} + </div> + </editor-menu-button> + + <editor-icon-button + aria-label="Flip direction" + .tooltip=${'Flip direction'} + .disabled=${false} + @click=${() => + this._flipEndpointStyle( + selectedStartPointStyle, + selectedEndPointStyle + )} + > + ${FlipDirectionIcon} + </editor-icon-button> + + <editor-menu-button + .button=${html` + <editor-icon-button + aria-label="End point style" + .tooltip=${'End point style'} + > + ${this._getEndpointIcon( + REAR_ENDPOINT_STYLE_LIST, + selectedEndPointStyle + )}${SmallArrowDownIcon} + </editor-icon-button> + `} + > + <div> + ${repeat( + REAR_ENDPOINT_STYLE_LIST, + item => item.value, + ({ value, icon }) => html` + <editor-icon-button + aria-label=${value} + .tooltip=${value} + .active=${selectedEndPointStyle === value} + .activeMode=${'background'} + @click=${() => + this._setConnectorPointStyle( + ConnectorEndpoint.Rear, + value + )} + > + ${icon} + </editor-icon-button> + ` + )} + </div> + </editor-menu-button> + + <editor-menu-button + .button=${html` + <editor-icon-button + aria-label="Shape" + .tooltip=${'Connector shape'} + > + ${choose(selectedMode, MODE_CHOOSE)}${SmallArrowDownIcon} + </editor-icon-button> + `} + > + <div> + ${repeat( + MODE_LIST, + item => item.name, + ({ name, value, icon }) => html` + <editor-icon-button + aria-label=${name} + .tooltip=${name} + .active=${selectedMode === value} + .activeMode=${'background'} + @click=${() => this._setConnectorMode(value)} + > + ${icon} + </editor-icon-button> + ` + )} + </div> + </editor-menu-button> + `, + + choose<string, TemplateResult<1> | typeof nothing>( + this._showAddButtonOrTextMenu(), + [ + [ + 'button', + () => html` + <editor-icon-button + aria-label="Add text" + .tooltip=${'Add text'} + @click=${this._addLabel} + > + ${AddTextIcon} + </editor-icon-button> + `, + ], + [ + 'menu', + () => html` + <edgeless-change-text-menu + .elementType=${'connector'} + .elements=${this.elements} + .edgeless=${this.edgeless} + ></edgeless-change-text-menu> + `, + ], + ['nothing', () => nothing], + ] + ), + ].filter(button => button !== nothing), + renderToolbarSeparator + ); + } + + @property({ attribute: false }) + accessor edgeless!: EdgelessRootBlockComponent; + + @property({ attribute: false }) + accessor elements: ConnectorElementModel[] = []; + + @query('edgeless-color-picker-button.stroke-color') + accessor strokeColorButton!: EdgelessColorPickerButton; +} + +export function renderConnectorButton( + edgeless: EdgelessRootBlockComponent, + elements?: ConnectorElementModel[] +) { + if (!elements?.length) return nothing; + + return html` + <edgeless-change-connector-button + .elements=${elements} + .edgeless=${edgeless} + > + </edgeless-change-connector-button> + `; +} diff --git a/blocksuite/blocks/src/root-block/widgets/element-toolbar/change-edgeless-text-button.ts b/blocksuite/blocks/src/root-block/widgets/element-toolbar/change-edgeless-text-button.ts new file mode 100644 index 0000000000..6ddb855aa7 --- /dev/null +++ b/blocksuite/blocks/src/root-block/widgets/element-toolbar/change-edgeless-text-button.ts @@ -0,0 +1,19 @@ +import type { EdgelessTextBlockModel } from '@blocksuite/affine-model'; +import { html, nothing } from 'lit'; + +import type { EdgelessRootBlockComponent } from '../../edgeless/edgeless-root-block.js'; + +export function renderChangeEdgelessTextButton( + edgeless: EdgelessRootBlockComponent, + elements?: EdgelessTextBlockModel[] +) { + if (!elements?.length) return nothing; + + return html` + <edgeless-change-text-menu + .elementType=${'edgeless-text'} + .elements=${elements} + .edgeless=${edgeless} + ></edgeless-change-text-menu> + `; +} diff --git a/blocksuite/blocks/src/root-block/widgets/element-toolbar/change-embed-card-button.ts b/blocksuite/blocks/src/root-block/widgets/element-toolbar/change-embed-card-button.ts new file mode 100644 index 0000000000..c67a849f48 --- /dev/null +++ b/blocksuite/blocks/src/root-block/widgets/element-toolbar/change-embed-card-button.ts @@ -0,0 +1,869 @@ +import { getDocContentWithMaxLength } from '@blocksuite/affine-block-embed'; +import { + CaptionIcon, + CenterPeekIcon, + CopyIcon, + EditIcon, + ExpandFullSmallIcon, + OpenIcon, + PaletteIcon, + SmallArrowDownIcon, +} from '@blocksuite/affine-components/icons'; +import { notifyLinkedDocSwitchedToEmbed } from '@blocksuite/affine-components/notification'; +import { isPeekable, peek } from '@blocksuite/affine-components/peek'; +import { toast } from '@blocksuite/affine-components/toast'; +import { + type MenuItem, + renderToolbarSeparator, +} from '@blocksuite/affine-components/toolbar'; +import { type AliasInfo, BookmarkStyles } from '@blocksuite/affine-model'; +import { + EmbedOptionProvider, + type EmbedOptions, + GenerateDocUrlProvider, + type GenerateDocUrlService, + type LinkEventType, + type TelemetryEvent, + TelemetryProvider, + ThemeProvider, +} from '@blocksuite/affine-shared/services'; +import { getHostName, referenceToNode } from '@blocksuite/affine-shared/utils'; +import type { BlockStdScope } from '@blocksuite/block-std'; +import { Bound, WithDisposable } from '@blocksuite/global/utils'; +import { css, html, LitElement, nothing, type TemplateResult } from 'lit'; +import { property, state } from 'lit/decorators.js'; +import { ifDefined } from 'lit/directives/if-defined.js'; +import { join } from 'lit/directives/join.js'; +import { repeat } from 'lit/directives/repeat.js'; + +import { toggleEmbedCardEditModal } from '../../../_common/components/embed-card/modal/embed-card-edit-modal.js'; +import type { + EmbedBlockComponent, + EmbedModel, +} from '../../../_common/components/embed-card/type.js'; +import { isInternalEmbedModel } from '../../../_common/components/embed-card/type.js'; +import { + EMBED_CARD_HEIGHT, + EMBED_CARD_WIDTH, +} from '../../../_common/consts.js'; +import type { EmbedCardStyle } from '../../../_common/types.js'; +import { getEmbedCardIcons } from '../../../_common/utils/url.js'; +import type { EdgelessRootBlockComponent } from '../../edgeless/edgeless-root-block.js'; +import { + isBookmarkBlock, + isEmbedGithubBlock, + isEmbedHtmlBlock, + isEmbedLinkedDocBlock, + isEmbedSyncedDocBlock, +} from '../../edgeless/utils/query.js'; + +export class EdgelessChangeEmbedCardButton extends WithDisposable(LitElement) { + static override styles = css` + .affine-link-preview { + display: flex; + justify-content: flex-start; + width: 140px; + padding: var(--1, 0px); + border-radius: var(--1, 0px); + opacity: var(--add, 1); + user-select: none; + cursor: pointer; + + color: var(--affine-link-color); + font-feature-settings: + 'clig' off, + 'liga' off; + font-family: var(--affine-font-family); + font-size: var(--affine-font-sm); + font-style: normal; + font-weight: 400; + text-decoration: none; + text-wrap: nowrap; + } + + .affine-link-preview > span { + display: inline-block; + -webkit-line-clamp: 1; + -webkit-box-orient: vertical; + + text-overflow: ellipsis; + overflow: hidden; + opacity: var(--add, 1); + } + + editor-icon-button.doc-title .label { + max-width: 110px; + display: inline-block; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + user-select: none; + cursor: pointer; + color: var(--affine-link-color); + font-feature-settings: + 'clig' off, + 'liga' off; + font-family: var(--affine-font-family); + font-size: var(--affine-font-sm); + font-style: normal; + font-weight: 400; + text-decoration: none; + text-wrap: nowrap; + } + `; + + private _convertToCardView = () => { + if (this._isCardView) { + return; + } + + const block = this._blockComponent; + if (block && 'convertToCard' in block) { + block.convertToCard(); + return; + } + + if (!('url' in this.model)) { + return; + } + + const { id, url, xywh, style, caption } = this.model; + + let targetFlavour = 'affine:bookmark', + targetStyle = style; + + if (this._embedOptions && this._embedOptions.viewType === 'card') { + const { flavour, styles } = this._embedOptions; + targetFlavour = flavour; + targetStyle = styles.includes(style) ? style : styles[0]; + } else { + targetStyle = BookmarkStyles.includes(style) ? style : BookmarkStyles[0]; + } + + const bound = Bound.deserialize(xywh); + bound.w = EMBED_CARD_WIDTH[targetStyle]; + bound.h = EMBED_CARD_HEIGHT[targetStyle]; + + const newId = this.edgeless.service.addBlock( + targetFlavour, + { url, xywh: bound.serialize(), style: targetStyle, caption }, + this.edgeless.surface.model + ); + + this.std.command.exec('reassociateConnectors', { + oldId: id, + newId, + }); + + this.edgeless.service.selection.set({ + editing: false, + elements: [newId], + }); + this._doc.deleteBlock(this.model); + }; + + private _convertToEmbedView = () => { + if (this._isEmbedView) { + return; + } + + const block = this._blockComponent; + if (block && 'convertToEmbed' in block) { + const referenceInfo = block.referenceInfo$.peek(); + + block.convertToEmbed(); + + if (referenceInfo.title || referenceInfo.description) + notifyLinkedDocSwitchedToEmbed(this.std); + + return; + } + + if (!('url' in this.model)) { + return; + } + + if (!this._embedOptions) return; + + const { flavour, styles } = this._embedOptions; + + const { id, url, xywh, style } = this.model; + + const targetStyle = styles.includes(style) ? style : styles[0]; + + const bound = Bound.deserialize(xywh); + bound.w = EMBED_CARD_WIDTH[targetStyle]; + bound.h = EMBED_CARD_HEIGHT[targetStyle]; + + const newId = this.edgeless.service.addBlock( + flavour, + { + url, + xywh: bound.serialize(), + style: targetStyle, + }, + this.edgeless.surface.model + ); + + this.std.command.exec('reassociateConnectors', { + oldId: id, + newId, + }); + + this.edgeless.service.selection.set({ + editing: false, + elements: [newId], + }); + this._doc.deleteBlock(this.model); + }; + + private _copyUrl = () => { + let url!: ReturnType<GenerateDocUrlService['generateDocUrl']>; + + if ('url' in this.model) { + url = this.model.url; + } else if (isInternalEmbedModel(this.model)) { + url = this.std + .getOptional(GenerateDocUrlProvider) + ?.generateDocUrl(this.model.pageId, this.model.params); + } + + if (!url) return; + + navigator.clipboard.writeText(url).catch(console.error); + toast(this.std.host, 'Copied link to clipboard'); + this.edgeless.service.selection.clear(); + + track(this.std, this.model, this._viewType, 'CopiedLink', { + control: 'copy link', + }); + }; + + private _embedOptions: EmbedOptions | null = null; + + private _getScale = () => { + if ('scale' in this.model) { + return this.model.scale ?? 1; + } else if (isEmbedHtmlBlock(this.model)) { + return 1; + } + + const bound = Bound.deserialize(this.model.xywh); + return bound.h / EMBED_CARD_HEIGHT[this.model.style]; + }; + + private _open = () => { + this._blockComponent?.open(); + }; + + private _openEditPopup = (e: MouseEvent) => { + e.stopPropagation(); + + if (isEmbedHtmlBlock(this.model)) return; + + this.std.selection.clear(); + + const originalDocInfo = this._originalDocInfo; + + toggleEmbedCardEditModal( + this.std.host, + this.model, + this._viewType, + originalDocInfo + ); + + track(this.std, this.model, this._viewType, 'OpenedAliasPopup', { + control: 'edit', + }); + }; + + private _peek = () => { + if (!this._blockComponent) return; + peek(this._blockComponent); + }; + + private _setCardStyle = (style: EmbedCardStyle) => { + const bounds = Bound.deserialize(this.model.xywh); + bounds.w = EMBED_CARD_WIDTH[style]; + bounds.h = EMBED_CARD_HEIGHT[style]; + const xywh = bounds.serialize(); + this.model.doc.updateBlock(this.model, { style, xywh }); + + track(this.std, this.model, this._viewType, 'SelectedCardStyle', { + control: 'select card style', + type: style, + }); + }; + + private _setEmbedScale = (scale: number) => { + if (isEmbedHtmlBlock(this.model)) return; + + const bound = Bound.deserialize(this.model.xywh); + if ('scale' in this.model) { + const oldScale = this.model.scale ?? 1; + const ratio = scale / oldScale; + bound.w *= ratio; + bound.h *= ratio; + const xywh = bound.serialize(); + this.model.doc.updateBlock(this.model, { scale, xywh }); + } else { + bound.h = EMBED_CARD_HEIGHT[this.model.style] * scale; + bound.w = EMBED_CARD_WIDTH[this.model.style] * scale; + const xywh = bound.serialize(); + this.model.doc.updateBlock(this.model, { xywh }); + } + this._embedScale = scale; + + track(this.std, this.model, this._viewType, 'SelectedCardScale', { + control: 'select card scale', + type: `${scale}`, + }); + }; + + private _toggleCardScaleSelector = (e: Event) => { + const opened = (e as CustomEvent<boolean>).detail; + if (!opened) return; + + track(this.std, this.model, this._viewType, 'OpenedCardScaleSelector', { + control: 'switch card scale', + }); + }; + + private _toggleCardStyleSelector = (e: Event) => { + const opened = (e as CustomEvent<boolean>).detail; + if (!opened) return; + + track(this.std, this.model, this._viewType, 'OpenedCardStyleSelector', { + control: 'switch card style', + }); + }; + + private _toggleViewSelector = (e: Event) => { + const opened = (e as CustomEvent<boolean>).detail; + if (!opened) return; + + track(this.std, this.model, this._viewType, 'OpenedViewSelector', { + control: 'switch view', + }); + }; + + private _trackViewSelected = (type: string) => { + track(this.std, this.model, this._viewType, 'SelectedView', { + control: 'select view', + type: `${type} view`, + }); + }; + + private get _blockComponent() { + const blockSelection = + this.edgeless.service.selection.surfaceSelections.filter(sel => + sel.elements.includes(this.model.id) + ); + if (blockSelection.length !== 1) { + return; + } + + const blockComponent = this.std.view.getBlock( + blockSelection[0].blockId + ) as EmbedBlockComponent | null; + + if (!blockComponent) return; + + return blockComponent; + } + + private get _canConvertToEmbedView() { + const block = this._blockComponent; + + // synced doc entry controlled by awareness flag + if (!!block && isEmbedLinkedDocBlock(block.model)) { + const isSyncedDocEnabled = block.doc.awarenessStore.getFlag( + 'enable_synced_doc_block' + ); + if (!isSyncedDocEnabled) { + return false; + } + } + + return ( + (block && 'convertToEmbed' in block) || + this._embedOptions?.viewType === 'embed' + ); + } + + private get _canShowCardStylePanel() { + return ( + isBookmarkBlock(this.model) || + isEmbedGithubBlock(this.model) || + isEmbedLinkedDocBlock(this.model) + ); + } + + private get _canShowFullScreenButton() { + return isEmbedHtmlBlock(this.model); + } + + private get _canShowUrlOptions() { + return ( + 'url' in this.model && + (isBookmarkBlock(this.model) || + isEmbedGithubBlock(this.model) || + isEmbedLinkedDocBlock(this.model)) + ); + } + + private get _doc() { + return this.model.doc; + } + + private get _embedViewButtonDisabled() { + if (this._doc.readonly) { + return true; + } + return ( + isEmbedLinkedDocBlock(this.model) && + (referenceToNode(this.model) || + !!this._blockComponent?.closest('affine-embed-synced-doc-block') || + this.model.pageId === this._doc.id) + ); + } + + private get _getCardStyleOptions(): { + style: EmbedCardStyle; + Icon: TemplateResult<1>; + tooltip: string; + }[] { + const theme = this.std.get(ThemeProvider).theme; + const { + EmbedCardHorizontalIcon, + EmbedCardListIcon, + EmbedCardVerticalIcon, + EmbedCardCubeIcon, + } = getEmbedCardIcons(theme); + return [ + { + style: 'horizontal', + Icon: EmbedCardHorizontalIcon, + tooltip: 'Large horizontal style', + }, + { + style: 'list', + Icon: EmbedCardListIcon, + tooltip: 'Small horizontal style', + }, + { + style: 'vertical', + Icon: EmbedCardVerticalIcon, + tooltip: 'Large vertical style', + }, + { + style: 'cube', + Icon: EmbedCardCubeIcon, + tooltip: 'Small vertical style', + }, + ]; + } + + private get _isCardView() { + if (isBookmarkBlock(this.model) || isEmbedLinkedDocBlock(this.model)) { + return true; + } + return this._embedOptions?.viewType === 'card'; + } + + private get _isEmbedView() { + return ( + !isBookmarkBlock(this.model) && + (isEmbedSyncedDocBlock(this.model) || + this._embedOptions?.viewType === 'embed') + ); + } + + get _openButtonDisabled() { + return ( + isEmbedLinkedDocBlock(this.model) && this.model.pageId === this._doc.id + ); + } + + get _originalDocInfo(): AliasInfo | undefined { + const model = this.model; + const doc = isInternalEmbedModel(model) + ? this.std.collection.getDoc(model.pageId) + : null; + + if (doc) { + const title = doc.meta?.title; + const description = isEmbedLinkedDocBlock(model) + ? getDocContentWithMaxLength(doc) + : undefined; + return { title, description }; + } + + return undefined; + } + + get _originalDocTitle() { + const model = this.model; + const doc = isInternalEmbedModel(model) + ? this.std.collection.getDoc(model.pageId) + : null; + + return doc?.meta?.title || 'Untitled'; + } + + private get _viewType(): 'inline' | 'embed' | 'card' { + if (this._isCardView) { + return 'card'; + } + + if (this._isEmbedView) { + return 'embed'; + } + + // unreachable + return 'inline'; + } + + private get std() { + return this.edgeless.std; + } + + private _openMenuButton() { + const buttons: MenuItem[] = []; + + if ( + isEmbedLinkedDocBlock(this.model) || + isEmbedSyncedDocBlock(this.model) + ) { + buttons.push({ + type: 'open-this-doc', + label: 'Open this doc', + icon: ExpandFullSmallIcon, + action: this._open, + disabled: this._openButtonDisabled, + }); + } else if (this._canShowFullScreenButton) { + buttons.push({ + type: 'open-this-doc', + label: 'Open this doc', + icon: ExpandFullSmallIcon, + action: this._open, + }); + } + + // open in new tab + + if (this._blockComponent && isPeekable(this._blockComponent)) { + buttons.push({ + type: 'open-in-center-peek', + label: 'Open in center peek', + icon: CenterPeekIcon, + action: () => this._peek(), + }); + } + + // open in split view + + if (buttons.length === 0) { + return nothing; + } + + return html` + <editor-menu-button + .contentPadding=${'8px'} + .button=${html` + <editor-icon-button + aria-label="Open" + .justify=${'space-between'} + .labelHeight=${'20px'} + > + ${OpenIcon}${SmallArrowDownIcon} + </editor-icon-button> + `} + > + <div data-size="small" data-orientation="vertical"> + ${repeat( + buttons, + button => button.label, + ({ label, icon, action, disabled }) => html` + <editor-menu-action + aria-label=${ifDefined(label)} + ?disabled=${disabled} + @click=${action} + > + ${icon}<span class="label">${label}</span> + </editor-menu-action> + ` + )} + </div> + </editor-menu-button> + `; + } + + private _showCaption() { + this._blockComponent?.captionEditor?.show(); + + track(this.std, this.model, this._viewType, 'OpenedCaptionEditor', { + control: 'add caption', + }); + } + + private _viewSelector() { + if (this._canConvertToEmbedView || this._isEmbedView) { + const buttons = [ + { + type: 'card', + label: 'Card view', + action: () => this._convertToCardView(), + disabled: this.model.doc.readonly, + }, + { + type: 'embed', + label: 'Embed view', + action: () => this._convertToEmbedView(), + disabled: this.model.doc.readonly || this._embedViewButtonDisabled, + }, + ]; + + return html` + <editor-menu-button + .contentPadding=${'8px'} + .button=${html` + <editor-icon-button + aria-label="Switch view" + .justify=${'space-between'} + .labelHeight=${'20px'} + .iconContainerWidth=${'110px'} + > + <div class="label"> + <span style="text-transform: capitalize" + >${this._viewType}</span + > + view + </div> + ${SmallArrowDownIcon} + </editor-icon-button> + `} + @toggle=${this._toggleViewSelector} + > + <div data-size="small" data-orientation="vertical"> + ${repeat( + buttons, + button => button.type, + ({ type, label, action, disabled }) => html` + <editor-menu-action + data-testid=${`link-to-${type}`} + aria-label=${ifDefined(label)} + ?data-selected=${this._viewType === type} + ?disabled=${disabled || this._viewType === type} + @click=${() => { + action(); + this._trackViewSelected(type); + }} + > + ${label} + </editor-menu-action> + ` + )} + </div> + </editor-menu-button> + `; + } + + return nothing; + } + + override connectedCallback() { + super.connectedCallback(); + this._embedScale = this._getScale(); + } + + override render() { + const model = this.model; + const isHtmlBlockModel = isEmbedHtmlBlock(model); + + if ('url' in this.model) { + this._embedOptions = this.std + .get(EmbedOptionProvider) + .getEmbedBlockOptions(this.model.url); + } + + const buttons = [ + this._openMenuButton(), + + this._canShowUrlOptions && 'url' in model + ? html` + <a + class="affine-link-preview" + href=${model.url} + rel="noopener noreferrer" + target="_blank" + > + <span>${getHostName(model.url)}</span> + </a> + ` + : nothing, + + // internal embed model + isEmbedLinkedDocBlock(model) && model.title + ? html` + <editor-icon-button + class="doc-title" + aria-label="Doc title" + .hover=${false} + .labelHeight=${'20px'} + .tooltip=${this._originalDocTitle} + @click=${this._open} + > + <span class="label">${this._originalDocTitle}</span> + </editor-icon-button> + ` + : nothing, + + isHtmlBlockModel + ? nothing + : html` + <editor-icon-button + aria-label="Click link" + .tooltip=${'Click link'} + class="change-embed-card-button copy" + ?disabled=${this._doc.readonly} + @click=${this._copyUrl} + > + ${CopyIcon} + </editor-icon-button> + + <editor-icon-button + aria-label="Edit" + .tooltip=${'Edit'} + class="change-embed-card-button edit" + ?disabled=${this._doc.readonly} + @click=${this._openEditPopup} + > + ${EditIcon} + </editor-icon-button> + `, + + this._viewSelector(), + + 'style' in model && this._canShowCardStylePanel + ? html` + <editor-menu-button + .contentPadding=${'8px'} + .button=${html` + <editor-icon-button + aria-label="Card style" + .tooltip=${'Card style'} + > + ${PaletteIcon} + </editor-icon-button> + `} + @toggle=${this._toggleCardStyleSelector} + > + <card-style-panel + .value=${model.style} + .options=${this._getCardStyleOptions} + .onSelect=${this._setCardStyle} + > + </card-style-panel> + </editor-menu-button> + ` + : nothing, + + 'caption' in model + ? html` + <editor-icon-button + aria-label="Add caption" + .tooltip=${'Add caption'} + class="change-embed-card-button caption" + ?disabled=${this._doc.readonly} + @click=${this._showCaption} + > + ${CaptionIcon} + </editor-icon-button> + ` + : nothing, + + this.quickConnectButton, + + isHtmlBlockModel + ? nothing + : html` + <editor-menu-button + .contentPadding=${'8px'} + .button=${html` + <editor-icon-button + aria-label="Scale" + .tooltip=${'Scale'} + .justify=${'space-between'} + .iconContainerWidth=${'65px'} + .labelHeight=${'20px'} + > + <span class="label"> + ${Math.round(this._embedScale * 100) + '%'} + </span> + ${SmallArrowDownIcon} + </editor-icon-button> + `} + @toggle=${this._toggleCardScaleSelector} + > + <edgeless-scale-panel + class="embed-scale-popper" + .scale=${Math.round(this._embedScale * 100)} + .onSelect=${this._setEmbedScale} + ></edgeless-scale-panel> + </editor-menu-button> + `, + ]; + + return join( + buttons.filter(button => button !== nothing), + renderToolbarSeparator + ); + } + + @state() + private accessor _embedScale = 1; + + @property({ attribute: false }) + accessor edgeless!: EdgelessRootBlockComponent; + + @property({ attribute: false }) + accessor model!: EmbedModel; + + @property({ attribute: false }) + accessor quickConnectButton!: TemplateResult<1> | typeof nothing; +} + +export function renderEmbedButton( + edgeless: EdgelessRootBlockComponent, + models?: EdgelessChangeEmbedCardButton['model'][], + quickConnectButton?: TemplateResult<1>[] +) { + if (models?.length !== 1) return nothing; + + return html` + <edgeless-change-embed-card-button + .model=${models[0]} + .edgeless=${edgeless} + .quickConnectButton=${quickConnectButton?.pop() ?? nothing} + ></edgeless-change-embed-card-button> + `; +} + +function track( + std: BlockStdScope, + model: EmbedModel, + viewType: string, + event: LinkEventType, + props: Partial<TelemetryEvent> +) { + std.getOptional(TelemetryProvider)?.track(event, { + segment: 'toolbar', + page: 'whiteboard editor', + module: 'element toolbar', + type: `${viewType} view`, + category: isInternalEmbedModel(model) ? 'linked doc' : 'link', + ...props, + }); +} diff --git a/blocksuite/blocks/src/root-block/widgets/element-toolbar/change-frame-button.ts b/blocksuite/blocks/src/root-block/widgets/element-toolbar/change-frame-button.ts new file mode 100644 index 0000000000..b031ec2e82 --- /dev/null +++ b/blocksuite/blocks/src/root-block/widgets/element-toolbar/change-frame-button.ts @@ -0,0 +1,257 @@ +import { + NoteIcon, + RenameIcon, + UngroupButtonIcon, +} from '@blocksuite/affine-components/icons'; +import { toast } from '@blocksuite/affine-components/toast'; +import { renderToolbarSeparator } from '@blocksuite/affine-components/toolbar'; +import { + type ColorScheme, + DEFAULT_NOTE_HEIGHT, + FRAME_BACKGROUND_COLORS, + type FrameBlockModel, + NoteDisplayMode, +} from '@blocksuite/affine-model'; +import { matchFlavours } from '@blocksuite/affine-shared/utils'; +import { GfxExtensionIdentifier } from '@blocksuite/block-std/gfx'; +import { + countBy, + deserializeXYWH, + maxBy, + serializeXYWH, + WithDisposable, +} from '@blocksuite/global/utils'; +import { html, LitElement, nothing } from 'lit'; +import { property, query } from 'lit/decorators.js'; +import { join } from 'lit/directives/join.js'; +import { when } from 'lit/directives/when.js'; + +import type { EdgelessColorPickerButton } from '../../edgeless/components/color-picker/button.js'; +import type { PickColorEvent } from '../../edgeless/components/color-picker/types.js'; +import { + packColor, + packColorsWithColorScheme, +} from '../../edgeless/components/color-picker/utils.js'; +import type { ColorEvent } from '../../edgeless/components/panel/color-panel.js'; +import type { EdgelessRootBlockComponent } from '../../edgeless/edgeless-root-block.js'; +import type { EdgelessFrameManager } from '../../edgeless/frame-manager.js'; +import { mountFrameTitleEditor } from '../../edgeless/utils/text.js'; + +function getMostCommonColor( + elements: FrameBlockModel[], + colorScheme: ColorScheme +): string | null { + const colors = countBy(elements, (ele: FrameBlockModel) => { + return typeof ele.background === 'object' + ? (ele.background[colorScheme] ?? ele.background.normal ?? null) + : ele.background; + }); + const max = maxBy(Object.entries(colors), ([_k, count]) => count); + return max ? (max[0] as string) : null; +} + +export class EdgelessChangeFrameButton extends WithDisposable(LitElement) { + pickColor = (event: PickColorEvent) => { + if (event.type === 'pick') { + this.frames.forEach(ele => + this.service.updateElement( + ele.id, + packColor('background', { ...event.detail }) + ) + ); + return; + } + + this.frames.forEach(ele => + ele[event.type === 'start' ? 'stash' : 'pop']('background') + ); + }; + + get service() { + return this.edgeless.service; + } + + private _insertIntoPage() { + if (!this.edgeless.doc.root) return; + + const rootModel = this.edgeless.doc.root; + const notes = rootModel.children.filter( + model => + matchFlavours(model, ['affine:note']) && + model.displayMode !== NoteDisplayMode.EdgelessOnly + ); + const lastNote = notes[notes.length - 1]; + const referenceFrame = this.frames[0]; + + let targetParent = lastNote?.id; + + if (!lastNote) { + const targetXYWH = deserializeXYWH(referenceFrame.xywh); + + targetXYWH[1] = targetXYWH[1] + targetXYWH[3]; + targetXYWH[3] = DEFAULT_NOTE_HEIGHT; + + const newAddedNote = this.edgeless.doc.addBlock( + 'affine:note', + { + xywh: serializeXYWH(...targetXYWH), + }, + rootModel.id + ); + + targetParent = newAddedNote; + } + + this.edgeless.doc.addBlock( + 'affine:surface-ref', + { + reference: this.frames[0].id, + refFlavour: 'affine:frame', + }, + targetParent + ); + + toast(this.edgeless.host, 'Frame has been inserted into doc'); + } + + private _setFrameBackground(color: string) { + this.frames.forEach(frame => { + this.service.updateElement(frame.id, { background: color }); + }); + } + + protected override render() { + const { frames } = this; + const len = frames.length; + const onlyOne = len === 1; + const colorScheme = this.edgeless.surface.renderer.getColorScheme(); + const background = + getMostCommonColor(frames, colorScheme) ?? '--affine-palette-transparent'; + + return join( + [ + onlyOne + ? html` + <editor-icon-button + aria-label=${'Insert into Page'} + .tooltip=${'Insert into Page'} + .iconSize=${'20px'} + .labelHeight=${'20px'} + @click=${this._insertIntoPage} + > + ${NoteIcon} + <span class="label">Insert into Page</span> + </editor-icon-button> + ` + : nothing, + + onlyOne + ? html` + <editor-icon-button + aria-label="Rename" + .tooltip=${'Rename'} + .iconSize=${'20px'} + @click=${() => + mountFrameTitleEditor(this.frames[0], this.edgeless)} + > + ${RenameIcon} + </editor-icon-button> + ` + : nothing, + + html` + <editor-icon-button + aria-label="Ungroup" + .tooltip=${'Ungroup'} + .iconSize=${'20px'} + @click=${() => { + this.edgeless.doc.captureSync(); + const frameMgr = this.edgeless.std.get( + GfxExtensionIdentifier('frame-manager') + ) as EdgelessFrameManager; + frames.forEach(frame => + frameMgr.removeAllChildrenFromFrame(frame) + ); + frames.forEach(frame => { + this.edgeless.service.removeElement(frame); + }); + this.edgeless.service.selection.clear(); + }} + > + ${UngroupButtonIcon} + </editor-icon-button> + `, + + when( + this.edgeless.doc.awarenessStore.getFlag('enable_color_picker'), + () => { + const { type, colors } = packColorsWithColorScheme( + colorScheme, + background, + this.frames[0].background + ); + + return html` + <edgeless-color-picker-button + class="background" + .label=${'Background'} + .pick=${this.pickColor} + .color=${background} + .colors=${colors} + .colorType=${type} + .palettes=${FRAME_BACKGROUND_COLORS} + > + </edgeless-color-picker-button> + `; + }, + () => html` + <editor-menu-button + .contentPadding=${'8px'} + .button=${html` + <editor-icon-button + aria-label="Background" + .tooltip=${'Background'} + > + <edgeless-color-button + .color=${background} + ></edgeless-color-button> + </editor-icon-button> + `} + > + <edgeless-color-panel + .value=${background} + .options=${FRAME_BACKGROUND_COLORS} + @select=${(e: ColorEvent) => this._setFrameBackground(e.detail)} + > + </edgeless-color-panel> + </editor-menu-button> + ` + ), + ].filter(button => button !== nothing), + renderToolbarSeparator + ); + } + + @query('edgeless-color-picker-button.background') + accessor backgroundButton!: EdgelessColorPickerButton; + + @property({ attribute: false }) + accessor edgeless!: EdgelessRootBlockComponent; + + @property({ attribute: false }) + accessor frames: FrameBlockModel[] = []; +} + +export function renderFrameButton( + edgeless: EdgelessRootBlockComponent, + frames?: FrameBlockModel[] +) { + if (!frames?.length) return nothing; + + return html` + <edgeless-change-frame-button + .edgeless=${edgeless} + .frames=${frames} + ></edgeless-change-frame-button> + `; +} diff --git a/blocksuite/blocks/src/root-block/widgets/element-toolbar/change-group-button.ts b/blocksuite/blocks/src/root-block/widgets/element-toolbar/change-group-button.ts new file mode 100644 index 0000000000..58e4340699 --- /dev/null +++ b/blocksuite/blocks/src/root-block/widgets/element-toolbar/change-group-button.ts @@ -0,0 +1,134 @@ +import { + NoteIcon, + RenameIcon, + UngroupButtonIcon, +} from '@blocksuite/affine-components/icons'; +import { toast } from '@blocksuite/affine-components/toast'; +import { renderToolbarSeparator } from '@blocksuite/affine-components/toolbar'; +import type { GroupElementModel } from '@blocksuite/affine-model'; +import { DEFAULT_NOTE_HEIGHT, NoteDisplayMode } from '@blocksuite/affine-model'; +import { matchFlavours } from '@blocksuite/affine-shared/utils'; +import { + deserializeXYWH, + serializeXYWH, + WithDisposable, +} from '@blocksuite/global/utils'; +import { html, LitElement, nothing } from 'lit'; +import { property } from 'lit/decorators.js'; +import { join } from 'lit/directives/join.js'; + +import type { EdgelessRootBlockComponent } from '../../edgeless/edgeless-root-block.js'; +import { mountGroupTitleEditor } from '../../edgeless/utils/text.js'; + +export class EdgelessChangeGroupButton extends WithDisposable(LitElement) { + private _insertIntoPage() { + if (!this.edgeless.doc.root) return; + + const rootModel = this.edgeless.doc.root; + const notes = rootModel.children.filter( + model => + matchFlavours(model, ['affine:note']) && + model.displayMode !== NoteDisplayMode.EdgelessOnly + ); + const lastNote = notes[notes.length - 1]; + const referenceGroup = this.groups[0]; + + let targetParent = lastNote?.id; + + if (!lastNote) { + const targetXYWH = deserializeXYWH(referenceGroup.xywh); + + targetXYWH[1] = targetXYWH[1] + targetXYWH[3]; + targetXYWH[3] = DEFAULT_NOTE_HEIGHT; + + const newAddedNote = this.edgeless.doc.addBlock( + 'affine:note', + { + xywh: serializeXYWH(...targetXYWH), + }, + rootModel.id + ); + + targetParent = newAddedNote; + } + + this.edgeless.doc.addBlock( + 'affine:surface-ref', + { + reference: this.groups[0].id, + refFlavour: 'group', + }, + targetParent + ); + + toast(this.edgeless.host, 'Group has been inserted into page'); + } + + protected override render() { + const { groups } = this; + const onlyOne = groups.length === 1; + + return join( + [ + onlyOne + ? html` + <editor-icon-button + aria-label="Insert into Page" + .tooltip=${'Insert into Page'} + .iconSize=${'20px'} + .labelHeight=${'20px'} + @click=${this._insertIntoPage} + > + ${NoteIcon} + <span class="label">Insert into Page</span> + </editor-icon-button> + ` + : nothing, + + onlyOne + ? html` + <editor-icon-button + aria-label="Rename" + .tooltip=${'Rename'} + .iconSize=${'20px'} + @click=${() => mountGroupTitleEditor(groups[0], this.edgeless)} + > + ${RenameIcon} + </editor-icon-button> + ` + : nothing, + + html` + <editor-icon-button + aria-label="Ungroup" + .tooltip=${'Ungroup'} + .iconSize=${'20px'} + @click=${() => + groups.forEach(group => this.edgeless.service.ungroup(group))} + > + ${UngroupButtonIcon} + </editor-icon-button> + `, + ].filter(button => button !== nothing), + renderToolbarSeparator + ); + } + + @property({ attribute: false }) + accessor edgeless!: EdgelessRootBlockComponent; + + @property({ attribute: false }) + accessor groups!: GroupElementModel[]; +} + +export function renderGroupButton( + edgeless: EdgelessRootBlockComponent, + groups?: GroupElementModel[] +) { + if (!groups?.length) return nothing; + + return html` + <edgeless-change-group-button .edgeless=${edgeless} .groups=${groups}> + </edgeless-change-group-button> + `; +} diff --git a/blocksuite/blocks/src/root-block/widgets/element-toolbar/change-image-button.ts b/blocksuite/blocks/src/root-block/widgets/element-toolbar/change-image-button.ts new file mode 100644 index 0000000000..45f685cd7c --- /dev/null +++ b/blocksuite/blocks/src/root-block/widgets/element-toolbar/change-image-button.ts @@ -0,0 +1,85 @@ +import { CaptionIcon, DownloadIcon } from '@blocksuite/affine-components/icons'; +import type { ImageBlockModel } from '@blocksuite/affine-model'; +import { WithDisposable } from '@blocksuite/global/utils'; +import { html, LitElement, nothing } from 'lit'; +import { property } from 'lit/decorators.js'; + +import type { ImageBlockComponent } from '../../../image-block/image-block.js'; +import { downloadImageBlob } from '../../../image-block/utils.js'; +import type { EdgelessRootBlockComponent } from '../../edgeless/edgeless-root-block.js'; + +export class EdgelessChangeImageButton extends WithDisposable(LitElement) { + private _download = () => { + if (!this._blockComponent) return; + downloadImageBlob(this._blockComponent).catch(console.error); + }; + + private _showCaption = () => { + this._blockComponent?.captionEditor?.show(); + }; + + private get _blockComponent() { + const blockSelection = + this.edgeless.service.selection.surfaceSelections.filter(sel => + sel.elements.includes(this.model.id) + ); + if (blockSelection.length !== 1) { + return; + } + + const block = this.edgeless.std.view.getBlock( + blockSelection[0].blockId + ) as ImageBlockComponent | null; + + return block; + } + + private get _doc() { + return this.model.doc; + } + + override render() { + return html` + <editor-icon-button + aria-label="Download" + .tooltip=${'Download'} + ?disabled=${this._doc.readonly} + @click=${this._download} + > + ${DownloadIcon} + </editor-icon-button> + + <editor-toolbar-separator></editor-toolbar-separator> + + <editor-icon-button + aria-label="Add caption" + .tooltip=${'Add caption'} + class="change-image-button caption" + ?disabled=${this._doc.readonly} + @click=${this._showCaption} + > + ${CaptionIcon} + </editor-icon-button> + `; + } + + @property({ attribute: false }) + accessor edgeless!: EdgelessRootBlockComponent; + + @property({ attribute: false }) + accessor model!: ImageBlockModel; +} + +export function renderChangeImageButton( + edgeless: EdgelessRootBlockComponent, + images?: ImageBlockModel[] +) { + if (images?.length !== 1) return nothing; + + return html` + <edgeless-change-image-button + .model=${images[0]} + .edgeless=${edgeless} + ></edgeless-change-image-button> + `; +} diff --git a/blocksuite/blocks/src/root-block/widgets/element-toolbar/change-mindmap-button.ts b/blocksuite/blocks/src/root-block/widgets/element-toolbar/change-mindmap-button.ts new file mode 100644 index 0000000000..40f20e94ea --- /dev/null +++ b/blocksuite/blocks/src/root-block/widgets/element-toolbar/change-mindmap-button.ts @@ -0,0 +1,296 @@ +/* eslint-disable @typescript-eslint/no-non-null-assertion */ +import { + MindmapBalanceLayoutIcon, + MindmapLeftLayoutIcon, + MindmapRightLayoutIcon, + MindmapStyleFour, + MindmapStyleIcon, + MindmapStyleOne, + MindmapStyleThree, + MindmapStyleTwo, + SmallArrowDownIcon, +} from '@blocksuite/affine-components/icons'; +import { renderToolbarSeparator } from '@blocksuite/affine-components/toolbar'; +import type { + MindmapElementModel, + ShapeElementModel, +} from '@blocksuite/affine-model'; +import { LayoutType, MindmapStyle } from '@blocksuite/affine-model'; +import { EditPropsStore } from '@blocksuite/affine-shared/services'; +import { countBy, maxBy, WithDisposable } from '@blocksuite/global/utils'; +import { css, html, LitElement, nothing, type TemplateResult } from 'lit'; +import { property, state } from 'lit/decorators.js'; +import { join } from 'lit/directives/join.js'; +import { repeat } from 'lit/directives/repeat.js'; + +import type { EdgelessRootBlockComponent } from '../../edgeless/edgeless-root-block.js'; + +const MINDMAP_STYLE_LIST = [ + { + value: MindmapStyle.ONE, + icon: MindmapStyleOne, + }, + { + value: MindmapStyle.FOUR, + icon: MindmapStyleFour, + }, + { + value: MindmapStyle.THREE, + icon: MindmapStyleThree, + }, + { + value: MindmapStyle.TWO, + icon: MindmapStyleTwo, + }, +]; + +interface LayoutItem { + name: string; + value: LayoutType; + icon: TemplateResult<1>; +} + +const MINDMAP_LAYOUT_LIST: LayoutItem[] = [ + { + name: 'Left', + value: LayoutType.LEFT, + icon: MindmapLeftLayoutIcon, + }, + { + name: 'Radial', + value: LayoutType.BALANCE, + icon: MindmapBalanceLayoutIcon, + }, + { + name: 'Right', + value: LayoutType.RIGHT, + icon: MindmapRightLayoutIcon, + }, +] as const; + +export class EdgelessChangeMindmapStylePanel extends LitElement { + static override styles = css` + :host { + display: flex; + align-items: center; + justify-content: center; + flex-direction: row; + gap: 8px; + background: var(--affine-background-overlay-panel-color); + } + + .style-item { + border-radius: 4px; + } + + .style-item > svg { + vertical-align: middle; + } + + .style-item.active, + .style-item:hover { + cursor: pointer; + background-color: var(--affine-hover-color); + } + `; + + override render() { + return repeat( + MINDMAP_STYLE_LIST, + item => item.value, + ({ value, icon }) => html` + <div + role="button" + class="style-item ${value === this.mindmapStyle ? 'active' : ''}" + @click=${() => this.onSelect(value)} + > + ${icon} + </div> + ` + ); + } + + @property({ attribute: false }) + accessor mindmapStyle!: MindmapStyle | null; + + @property({ attribute: false }) + accessor onSelect!: (style: MindmapStyle) => void; +} + +export class EdgelessChangeMindmapLayoutPanel extends LitElement { + static override styles = css` + :host { + display: flex; + align-items: center; + justify-content: center; + flex-direction: row; + gap: 8px; + } + `; + + override render() { + return repeat( + MINDMAP_LAYOUT_LIST, + item => item.value, + ({ name, value, icon }) => html` + <editor-icon-button + aria-label=${name} + .tooltip=${name} + .tipPosition=${'top'} + .active=${this.mindmapLayout === value} + .activeMode=${'background'} + @click=${() => this.onSelect(value)} + > + ${icon} + </editor-icon-button> + ` + ); + } + + @property({ attribute: false }) + accessor mindmapLayout!: LayoutType | null; + + @property({ attribute: false }) + accessor onSelect!: (style: LayoutType) => void; +} + +export class EdgelessChangeMindmapButton extends WithDisposable(LitElement) { + private _updateLayoutType = (layoutType: LayoutType) => { + this.edgeless.std.get(EditPropsStore).recordLastProps('mindmap', { + layoutType, + }); + this.elements.forEach(element => { + element.layoutType = layoutType; + element.layout(); + }); + this.layoutType = layoutType; + }; + + private _updateStyle = (style: MindmapStyle) => { + this.edgeless.std.get(EditPropsStore).recordLastProps('mindmap', { style }); + this._mindmaps.forEach(element => (element.style = style)); + }; + + private get _mindmaps() { + const mindmaps = new Set<MindmapElementModel>(); + + return this.elements.reduce((_, el) => { + mindmaps.add(el); + + return mindmaps; + }, mindmaps); + } + + get layout() { + const layoutType = this.layoutType ?? this._getCommonLayoutType(); + return MINDMAP_LAYOUT_LIST.find(item => item.value === layoutType)!; + } + + private _getCommonLayoutType() { + const values = countBy(this.elements, element => element.layoutType); + const max = maxBy(Object.entries(values), ([_k, count]) => count); + return max ? (Number(max[0]) as LayoutType) : LayoutType.BALANCE; + } + + private _getCommonStyle() { + const values = countBy(this.elements, element => element.style); + const max = maxBy(Object.entries(values), ([_k, count]) => count); + return max ? (Number(max[0]) as MindmapStyle) : MindmapStyle.ONE; + } + + private _isSubnode() { + return ( + this.nodes.length === 1 && + (this.nodes[0].group as MindmapElementModel).tree.element !== + this.nodes[0] + ); + } + + override render() { + return join( + [ + html` + <editor-menu-button + .contentPadding=${'8px'} + .button=${html` + <editor-icon-button aria-label="Style" .tooltip=${'Style'}> + ${MindmapStyleIcon}${SmallArrowDownIcon} + </editor-icon-button> + `} + > + <edgeless-change-mindmap-style-panel + .mindmapStyle=${this._getCommonStyle()} + .onSelect=${this._updateStyle} + > + </edgeless-change-mindmap-style-panel> + </editor-menu-button> + `, + + this._isSubnode() + ? nothing + : html` + <editor-menu-button + .button=${html` + <editor-icon-button aria-label="Layout" .tooltip=${'Layout'}> + ${this.layout.icon}${SmallArrowDownIcon} + </editor-icon-button> + `} + > + <edgeless-change-mindmap-layout-panel + .mindmapLayout=${this.layout.value} + .onSelect=${this._updateLayoutType} + > + </edgeless-change-mindmap-layout-panel> + </editor-menu-button> + `, + ].filter(button => button !== nothing), + renderToolbarSeparator + ); + } + + @property({ attribute: false }) + accessor edgeless!: EdgelessRootBlockComponent; + + @property({ attribute: false }) + accessor elements!: MindmapElementModel[]; + + @state() + accessor layoutType!: LayoutType; + + @property({ attribute: false }) + accessor nodes!: ShapeElementModel[]; +} + +export function renderMindmapButton( + edgeless: EdgelessRootBlockComponent, + elements?: (ShapeElementModel | MindmapElementModel)[] +) { + if (!elements?.length) return nothing; + + const mindmaps: MindmapElementModel[] = []; + + elements.forEach(e => { + if (e.type === 'mindmap') { + mindmaps.push(e as MindmapElementModel); + } + + const group = edgeless.service.surface.getGroup(e.id); + + if (group && 'type' in group && group.type === 'mindmap') { + mindmaps.push(group as MindmapElementModel); + } + }); + + if (mindmaps.length === 0) { + return nothing; + } + + return html` + <edgeless-change-mindmap-button + .elements=${mindmaps} + .nodes=${elements.filter(e => e.type === 'shape')} + .edgeless=${edgeless} + > + </edgeless-change-mindmap-button> + `; +} diff --git a/blocksuite/blocks/src/root-block/widgets/element-toolbar/change-note-button.ts b/blocksuite/blocks/src/root-block/widgets/element-toolbar/change-note-button.ts new file mode 100644 index 0000000000..ab5a567933 --- /dev/null +++ b/blocksuite/blocks/src/root-block/widgets/element-toolbar/change-note-button.ts @@ -0,0 +1,499 @@ +import { + ExpandIcon, + LineStyleIcon, + NoteCornerIcon, + NoteShadowIcon, + ScissorsIcon, + ShrinkIcon, + SmallArrowDownIcon, +} from '@blocksuite/affine-components/icons'; +import { + type EditorMenuButton, + renderToolbarSeparator, +} from '@blocksuite/affine-components/toolbar'; +import { + type ColorScheme, + DEFAULT_NOTE_BACKGROUND_COLOR, + NOTE_BACKGROUND_COLORS, + type NoteBlockModel, + NoteDisplayMode, + type StrokeStyle, +} from '@blocksuite/affine-model'; +import { ThemeProvider } from '@blocksuite/affine-shared/services'; +import { matchFlavours } from '@blocksuite/affine-shared/utils'; +import { + assertExists, + Bound, + countBy, + maxBy, + WithDisposable, +} from '@blocksuite/global/utils'; +import { html, LitElement, nothing, type TemplateResult } from 'lit'; +import { property, query } from 'lit/decorators.js'; +import { join } from 'lit/directives/join.js'; +import { createRef, type Ref, ref } from 'lit/directives/ref.js'; +import { when } from 'lit/directives/when.js'; + +import type { + EdgelessColorPickerButton, + PickColorEvent, +} from '../../edgeless/components/color-picker/index.js'; +import { + packColor, + packColorsWithColorScheme, +} from '../../edgeless/components/color-picker/utils.js'; +import type { ColorEvent } from '../../edgeless/components/panel/color-panel.js'; +import { + type LineStyleEvent, + LineStylesPanel, +} from '../../edgeless/components/panel/line-styles-panel.js'; +import { getTooltipWithShortcut } from '../../edgeless/components/utils.js'; +import type { EdgelessRootBlockComponent } from '../../edgeless/edgeless-root-block.js'; + +const SIZE_LIST = [ + { name: 'None', value: 0 }, + { name: 'Small', value: 8 }, + { name: 'Medium', value: 16 }, + { name: 'Large', value: 24 }, + { name: 'Huge', value: 32 }, +] as const; + +const DisplayModeMap = { + [NoteDisplayMode.DocAndEdgeless]: 'Both', + [NoteDisplayMode.EdgelessOnly]: 'Edgeless', + [NoteDisplayMode.DocOnly]: 'Page', +} as const satisfies Record<NoteDisplayMode, string>; + +function getMostCommonBackground( + elements: NoteBlockModel[], + colorScheme: ColorScheme +): string | null { + const colors = countBy(elements, (ele: NoteBlockModel) => { + return typeof ele.background === 'object' + ? (ele.background[colorScheme] ?? ele.background.normal ?? null) + : ele.background; + }); + const max = maxBy(Object.entries(colors), ([_k, count]) => count); + return max ? (max[0] as string) : null; +} + +export class EdgelessChangeNoteButton extends WithDisposable(LitElement) { + private _setBorderRadius = (borderRadius: number) => { + this.notes.forEach(note => { + const props = { + edgeless: { + style: { + ...note.edgeless.style, + borderRadius, + }, + }, + }; + this.edgeless.service.updateElement(note.id, props); + }); + }; + + private _setNoteScale = (scale: number) => { + this.notes.forEach(note => { + this.doc.updateBlock(note, () => { + const bound = Bound.deserialize(note.xywh); + const oldScale = note.edgeless.scale ?? 1; + const ratio = scale / oldScale; + bound.w *= ratio; + bound.h *= ratio; + const xywh = bound.serialize(); + note.xywh = xywh; + note.edgeless.scale = scale; + }); + }); + }; + + pickColor = (event: PickColorEvent) => { + if (event.type === 'pick') { + this.notes.forEach(element => { + const props = packColor('background', { ...event.detail }); + this.edgeless.service.updateElement(element.id, props); + }); + return; + } + + this.notes.forEach(ele => + ele[event.type === 'start' ? 'stash' : 'pop']('background') + ); + }; + + private get _advancedVisibilityEnabled() { + return this.doc.awarenessStore.getFlag('enable_advanced_block_visibility'); + } + + private get doc() { + return this.edgeless.doc; + } + + private _getScaleLabel(scale: number) { + return Math.round(scale * 100) + '%'; + } + + private _handleNoteSlicerButtonClick() { + const surfaceService = this.edgeless.service; + if (!surfaceService) return; + + this.edgeless.slots.toggleNoteSlicer.emit(); + } + + private _setBackground(background: string) { + this.notes.forEach(element => { + this.edgeless.service.updateElement(element.id, { background }); + }); + } + + private _setCollapse() { + this.notes.forEach(note => { + const { collapse, collapsedHeight } = note.edgeless; + + if (collapse) { + this.doc.updateBlock(note, () => { + note.edgeless.collapse = false; + }); + } else if (collapsedHeight) { + const { xywh, edgeless } = note; + const bound = Bound.deserialize(xywh); + bound.h = collapsedHeight * (edgeless.scale ?? 1); + this.doc.updateBlock(note, () => { + note.edgeless.collapse = true; + note.xywh = bound.serialize(); + }); + } + }); + this.requestUpdate(); + } + + private _setDisplayMode(note: NoteBlockModel, newMode: NoteDisplayMode) { + const { displayMode: currentMode } = note; + if (newMode === currentMode) { + return; + } + + this.edgeless.service.updateElement(note.id, { displayMode: newMode }); + + const noteParent = this.doc.getParent(note); + assertExists(noteParent); + const noteParentChildNotes = noteParent.children.filter(block => + matchFlavours(block, ['affine:note']) + ) as NoteBlockModel[]; + const noteParentLastNote = + noteParentChildNotes[noteParentChildNotes.length - 1]; + + if ( + currentMode === NoteDisplayMode.EdgelessOnly && + newMode !== NoteDisplayMode.EdgelessOnly && + note !== noteParentLastNote + ) { + // move to the end + this.doc.moveBlocks([note], noteParent, noteParentLastNote, false); + } + + // if change note to page only, should clear the selection + if (newMode === NoteDisplayMode.DocOnly) { + this.edgeless.service.selection.clear(); + } + } + + private _setShadowType(shadowType: string) { + this.notes.forEach(note => { + const props = { + edgeless: { + style: { + ...note.edgeless.style, + shadowType, + }, + }, + }; + this.edgeless.service.updateElement(note.id, props); + }); + } + + private _setStrokeStyle(borderStyle: StrokeStyle) { + this.notes.forEach(note => { + const props = { + edgeless: { + style: { + ...note.edgeless.style, + borderStyle, + }, + }, + }; + this.edgeless.service.updateElement(note.id, props); + }); + } + + private _setStrokeWidth(borderSize: number) { + this.notes.forEach(note => { + const props = { + edgeless: { + style: { + ...note.edgeless.style, + borderSize, + }, + }, + }; + this.edgeless.service.updateElement(note.id, props); + }); + } + + private _setStyles({ type, value }: LineStyleEvent) { + if (type === 'size') { + this._setStrokeWidth(value); + return; + } + if (type === 'lineStyle') { + this._setStrokeStyle(value); + } + } + + override render() { + const len = this.notes.length; + const note = this.notes[0]; + const { edgeless, displayMode } = note; + const { shadowType, borderRadius, borderSize, borderStyle } = + edgeless.style; + const colorScheme = this.edgeless.surface.renderer.getColorScheme(); + const background = + getMostCommonBackground(this.notes, colorScheme) ?? + DEFAULT_NOTE_BACKGROUND_COLOR; + + const { collapse } = edgeless; + const scale = edgeless.scale ?? 1; + const currentMode = DisplayModeMap[displayMode]; + const onlyOne = len === 1; + const isDocOnly = displayMode === NoteDisplayMode.DocOnly; + const theme = this.edgeless.std.get(ThemeProvider).theme; + const buttons = [ + onlyOne && this._advancedVisibilityEnabled + ? html` + <span class="display-mode-button-label">Show in</span> + <editor-menu-button + .contentPadding=${'8px'} + .button=${html` + <editor-icon-button + aria-label="Mode" + .tooltip=${'Display mode'} + .justify=${'space-between'} + .labelHeight=${'20px'} + > + <span class="label">${currentMode}</span> + ${SmallArrowDownIcon} + </editor-icon-button> + `} + > + <note-display-mode-panel + .displayMode=${displayMode} + .onSelect=${(newMode: NoteDisplayMode) => + this._setDisplayMode(note, newMode)} + > + </note-display-mode-panel> + </editor-menu-button> + ` + : nothing, + + isDocOnly + ? nothing + : when( + this.edgeless.doc.awarenessStore.getFlag('enable_color_picker'), + () => { + const { type, colors } = packColorsWithColorScheme( + colorScheme, + background, + note.background + ); + + return html` + <edgeless-color-picker-button + class="background" + .label=${'Background'} + .pick=${this.pickColor} + .color=${background} + .colorType=${type} + .colors=${colors} + .palettes=${NOTE_BACKGROUND_COLORS} + > + </edgeless-color-picker-button> + `; + }, + () => html` + <editor-menu-button + .contentPadding=${'8px'} + .button=${html` + <editor-icon-button + aria-label="Background" + .tooltip=${'Background'} + > + <edgeless-color-button + .color=${background} + ></edgeless-color-button> + </editor-icon-button> + `} + > + <edgeless-color-panel + .value=${background} + .options=${NOTE_BACKGROUND_COLORS} + @select=${(e: ColorEvent) => this._setBackground(e.detail)} + > + </edgeless-color-panel> + </editor-menu-button> + ` + ), + + isDocOnly + ? nothing + : html` + <editor-menu-button + .contentPadding=${'6px'} + .button=${html` + <editor-icon-button + aria-label="Shadow style" + .tooltip=${'Shadow style'} + > + ${NoteShadowIcon}${SmallArrowDownIcon} + </editor-icon-button> + `} + > + <edgeless-note-shadow-panel + .theme=${theme} + .value=${shadowType} + .background=${background} + .onSelect=${(value: string) => this._setShadowType(value)} + > + </edgeless-note-shadow-panel> + </editor-menu-button> + + <editor-menu-button + .button=${html` + <editor-icon-button + aria-label="Border style" + .tooltip=${'Border style'} + > + ${LineStyleIcon}${SmallArrowDownIcon} + </editor-icon-button> + `} + > + <div data-orientation="horizontal"> + ${LineStylesPanel({ + selectedLineSize: borderSize, + selectedLineStyle: borderStyle, + onClick: event => this._setStyles(event), + })} + </div> + </editor-menu-button> + + <editor-menu-button + ${ref(this._cornersPanelRef)} + .contentPadding=${'8px'} + .button=${html` + <editor-icon-button aria-label="Corners" .tooltip=${'Corners'}> + ${NoteCornerIcon}${SmallArrowDownIcon} + </editor-icon-button> + `} + > + <edgeless-size-panel + .size=${borderRadius} + .sizeList=${SIZE_LIST} + .minSize=${0} + .onSelect=${(size: number) => this._setBorderRadius(size)} + .onPopperCose=${() => this._cornersPanelRef.value?.hide()} + > + </edgeless-size-panel> + </editor-menu-button> + `, + + onlyOne && this._advancedVisibilityEnabled + ? html` + <editor-icon-button + aria-label="Slicer" + .tooltip=${getTooltipWithShortcut('Cutting mode', '-')} + .active=${this.enableNoteSlicer} + @click=${() => this._handleNoteSlicerButtonClick()} + > + ${ScissorsIcon} + </editor-icon-button> + ` + : nothing, + + onlyOne ? this.quickConnectButton : nothing, + + html` + <editor-icon-button + aria-label="Size" + .tooltip=${collapse ? 'Auto height' : 'Customized height'} + @click=${() => this._setCollapse()} + > + ${collapse ? ExpandIcon : ShrinkIcon} + </editor-icon-button> + + <editor-menu-button + ${ref(this._scalePanelRef)} + .contentPadding=${'8px'} + .button=${html` + <editor-icon-button + aria-label="Scale" + .tooltip=${'Scale'} + .justify=${'space-between'} + .labelHeight=${'20px'} + .iconContainerWidth=${'65px'} + > + <span class="label">${this._getScaleLabel(scale)}</span + >${SmallArrowDownIcon} + </editor-icon-button> + `} + > + <edgeless-scale-panel + .scale=${Math.round(scale * 100)} + .onSelect=${(scale: number) => this._setNoteScale(scale)} + .onPopperCose=${() => this._scalePanelRef.value?.hide()} + ></edgeless-scale-panel> + </editor-menu-button> + `, + ]; + + return join( + buttons.filter(button => button !== nothing), + renderToolbarSeparator + ); + } + + private accessor _cornersPanelRef: Ref<EditorMenuButton> = createRef(); + + private accessor _scalePanelRef: Ref<EditorMenuButton> = createRef(); + + @query('edgeless-color-picker-button.background') + accessor backgroundButton!: EdgelessColorPickerButton; + + @property({ attribute: false }) + accessor edgeless!: EdgelessRootBlockComponent; + + @property({ attribute: false }) + accessor enableNoteSlicer!: boolean; + + @property({ attribute: false }) + accessor notes: NoteBlockModel[] = []; + + @property({ attribute: false }) + accessor quickConnectButton!: TemplateResult<1> | typeof nothing; +} + +export function renderNoteButton( + edgeless: EdgelessRootBlockComponent, + notes?: NoteBlockModel[], + quickConnectButton?: TemplateResult<1>[] +) { + if (!notes?.length) return nothing; + + return html` + <edgeless-change-note-button + .notes=${notes} + .edgeless=${edgeless} + .enableNoteSlicer=${false} + .quickConnectButton=${quickConnectButton?.pop() ?? nothing} + > + </edgeless-change-note-button> + `; +} diff --git a/blocksuite/blocks/src/root-block/widgets/element-toolbar/change-shape-button.ts b/blocksuite/blocks/src/root-block/widgets/element-toolbar/change-shape-button.ts new file mode 100644 index 0000000000..fb9ffa13fe --- /dev/null +++ b/blocksuite/blocks/src/root-block/widgets/element-toolbar/change-shape-button.ts @@ -0,0 +1,509 @@ +import { + AddTextIcon, + ChangeShapeIcon, + GeneralStyleIcon, + ScribbledStyleIcon, + SmallArrowDownIcon, +} from '@blocksuite/affine-components/icons'; +import { renderToolbarSeparator } from '@blocksuite/affine-components/toolbar'; +import type { + ColorScheme, + ShapeElementModel, + ShapeProps, +} from '@blocksuite/affine-model'; +import { + DEFAULT_SHAPE_FILL_COLOR, + DEFAULT_SHAPE_STROKE_COLOR, + FontFamily, + getShapeName, + getShapeRadius, + getShapeType, + LineWidth, + MindmapElementModel, + SHAPE_FILL_COLORS, + SHAPE_STROKE_COLORS, + ShapeStyle, + StrokeStyle, +} from '@blocksuite/affine-model'; +import { countBy, maxBy, WithDisposable } from '@blocksuite/global/utils'; +import { css, html, LitElement, nothing, type TemplateResult } from 'lit'; +import { property, query } from 'lit/decorators.js'; +import { cache } from 'lit/directives/cache.js'; +import { choose } from 'lit/directives/choose.js'; +import { join } from 'lit/directives/join.js'; +import { styleMap } from 'lit/directives/style-map.js'; +import { when } from 'lit/directives/when.js'; + +import type { EdgelessColorPickerButton } from '../../edgeless/components/color-picker/button.js'; +import type { PickColorEvent } from '../../edgeless/components/color-picker/types.js'; +import { + packColor, + packColorsWithColorScheme, +} from '../../edgeless/components/color-picker/utils.js'; +import { + type ColorEvent, + GET_DEFAULT_LINE_COLOR, + isTransparent, +} from '../../edgeless/components/panel/color-panel.js'; +import { + type LineStyleEvent, + LineStylesPanel, +} from '../../edgeless/components/panel/line-styles-panel.js'; +import type { EdgelessShapePanel } from '../../edgeless/components/panel/shape-panel.js'; +import type { EdgelessRootBlockComponent } from '../../edgeless/edgeless-root-block.js'; +import type { ShapeToolOption } from '../../edgeless/gfx-tool/shape-tool.js'; +import { + SHAPE_FILL_COLOR_BLACK, + SHAPE_TEXT_COLOR_PURE_BLACK, + SHAPE_TEXT_COLOR_PURE_WHITE, +} from '../../edgeless/utils/consts.js'; +import { mountShapeTextEditor } from '../../edgeless/utils/text.js'; + +const changeShapeButtonStyles = [ + css` + .edgeless-component-line-size-button { + display: flex; + justify-content: center; + align-items: center; + width: 16px; + height: 16px; + } + + .edgeless-component-line-size-button div { + border-radius: 50%; + background-color: var(--affine-icon-color); + } + + .edgeless-component-line-size-button.size-s div { + width: 4px; + height: 4px; + } + .edgeless-component-line-size-button.size-l div { + width: 10px; + height: 10px; + } + `, +]; + +function getMostCommonFillColor( + elements: ShapeElementModel[], + colorScheme: ColorScheme +): string | null { + const colors = countBy(elements, (ele: ShapeElementModel) => { + if (ele.filled) { + return typeof ele.fillColor === 'object' + ? (ele.fillColor[colorScheme] ?? ele.fillColor.normal ?? null) + : ele.fillColor; + } + return '--affine-palette-transparent'; + }); + const max = maxBy(Object.entries(colors), ([_k, count]) => count); + return max ? (max[0] as string) : null; +} + +function getMostCommonStrokeColor( + elements: ShapeElementModel[], + colorScheme: ColorScheme +): string | null { + const colors = countBy(elements, (ele: ShapeElementModel) => { + return typeof ele.strokeColor === 'object' + ? (ele.strokeColor[colorScheme] ?? ele.strokeColor.normal ?? null) + : ele.strokeColor; + }); + const max = maxBy(Object.entries(colors), ([_k, count]) => count); + return max ? (max[0] as string) : null; +} + +function getMostCommonShape( + elements: ShapeElementModel[] +): ShapeToolOption['shapeName'] | null { + const shapeTypes = countBy(elements, (ele: ShapeElementModel) => { + return getShapeName(ele.shapeType, ele.radius); + }); + const max = maxBy(Object.entries(shapeTypes), ([_k, count]) => count); + return max ? (max[0] as ShapeToolOption['shapeName']) : null; +} + +function getMostCommonLineSize(elements: ShapeElementModel[]): LineWidth { + const sizes = countBy(elements, (ele: ShapeElementModel) => { + return ele.strokeWidth; + }); + const max = maxBy(Object.entries(sizes), ([_k, count]) => count); + return max ? (Number(max[0]) as LineWidth) : LineWidth.Four; +} + +function getMostCommonLineStyle( + elements: ShapeElementModel[] +): StrokeStyle | null { + const sizes = countBy(elements, (ele: ShapeElementModel) => ele.strokeStyle); + const max = maxBy(Object.entries(sizes), ([_k, count]) => count); + return max ? (max[0] as StrokeStyle) : null; +} + +function getMostCommonShapeStyle(elements: ShapeElementModel[]): ShapeStyle { + const roughnesses = countBy(elements, (ele: ShapeElementModel) => { + return ele.shapeStyle; + }); + const max = maxBy(Object.entries(roughnesses), ([_k, count]) => count); + return max ? (max[0] as ShapeStyle) : ShapeStyle.Scribbled; +} + +export class EdgelessChangeShapeButton extends WithDisposable(LitElement) { + static override styles = [changeShapeButtonStyles]; + + get service() { + return this.edgeless.service; + } + + #pickColor<K extends keyof Pick<ShapeProps, 'fillColor' | 'strokeColor'>>( + key: K + ) { + return (event: PickColorEvent) => { + if (event.type === 'pick') { + this.elements.forEach(ele => { + const props = packColor(key, { ...event.detail }); + // If `filled` can be set separately, this logic can be removed + if (key === 'fillColor' && !ele.filled) { + Object.assign(props, { filled: true }); + } + this.service.updateElement(ele.id, props); + }); + return; + } + + this.elements.forEach(ele => + ele[event.type === 'start' ? 'stash' : 'pop'](key) + ); + }; + } + + private _addText() { + mountShapeTextEditor(this.elements[0], this.edgeless); + } + + private _getTextColor(fillColor: string) { + const colorScheme = this.edgeless.surface.renderer.getColorScheme(); + // When the shape is filled with black color, the text color should be white. + // When the shape is transparent, the text color should be set according to the theme. + // Otherwise, the text color should be black. + const textColor = isTransparent(fillColor) + ? GET_DEFAULT_LINE_COLOR(colorScheme) + : fillColor === SHAPE_FILL_COLOR_BLACK + ? SHAPE_TEXT_COLOR_PURE_WHITE + : SHAPE_TEXT_COLOR_PURE_BLACK; + + return textColor; + } + + private _setShapeFillColor(fillColor: string) { + const filled = !isTransparent(fillColor); + const color = this._getTextColor(fillColor); + this.elements.forEach(ele => + this.service.updateElement(ele.id, { filled, fillColor, color }) + ); + } + + private _setShapeStrokeColor(strokeColor: string) { + this.elements.forEach(ele => + this.service.updateElement(ele.id, { strokeColor }) + ); + } + + private _setShapeStrokeStyle(strokeStyle: StrokeStyle) { + this.elements.forEach(ele => + this.service.updateElement(ele.id, { strokeStyle }) + ); + } + + private _setShapeStrokeWidth(strokeWidth: number) { + this.elements.forEach(ele => + this.service.updateElement(ele.id, { strokeWidth }) + ); + } + + private _setShapeStyle(shapeStyle: ShapeStyle) { + const fontFamily = + shapeStyle === ShapeStyle.General ? FontFamily.Inter : FontFamily.Kalam; + + this.elements.forEach(ele => { + this.service.updateElement(ele.id, { shapeStyle, fontFamily }); + }); + } + + private _setShapeStyles({ type, value }: LineStyleEvent) { + if (type === 'size') { + this._setShapeStrokeWidth(value); + return; + } + if (type === 'lineStyle') { + this._setShapeStrokeStyle(value); + } + } + + private _showAddButtonOrTextMenu() { + if (this.elements.length === 1 && !this.elements[0].text) { + return 'button'; + } + if (!this.elements.some(e => !e.text)) { + return 'menu'; + } + return 'nothing'; + } + + override firstUpdated() { + const _disposables = this._disposables; + + _disposables.add( + this._shapePanel.slots.select.on(shapeName => { + this.edgeless.doc.captureSync(); + this.elements.forEach(element => { + this.service.updateElement(element.id, { + shapeType: getShapeType(shapeName), + radius: getShapeRadius(shapeName), + }); + }); + }) + ); + } + + override render() { + const colorScheme = this.edgeless.surface.renderer.getColorScheme(); + const elements = this.elements; + const selectedShape = getMostCommonShape(elements); + const selectedFillColor = + getMostCommonFillColor(elements, colorScheme) ?? DEFAULT_SHAPE_FILL_COLOR; + const selectedStrokeColor = + getMostCommonStrokeColor(elements, colorScheme) ?? + DEFAULT_SHAPE_STROKE_COLOR; + const selectedLineSize = getMostCommonLineSize(elements) ?? LineWidth.Four; + const selectedLineStyle = + getMostCommonLineStyle(elements) ?? StrokeStyle.Solid; + const selectedShapeStyle = + getMostCommonShapeStyle(elements) ?? ShapeStyle.Scribbled; + + return join( + [ + html` + <editor-menu-button + .button=${html` + <editor-icon-button + aria-label="Switch type" + .tooltip=${'Switch type'} + > + ${ChangeShapeIcon}${SmallArrowDownIcon} + </editor-icon-button> + `} + > + <edgeless-shape-panel + .selectedShape=${selectedShape} + .shapeStyle=${selectedShapeStyle} + > + </edgeless-shape-panel> + </editor-menu-button> + `, + + html` + <editor-menu-button + .button=${html` + <editor-icon-button aria-label="Style" .tooltip=${'Style'}> + ${cache( + selectedShapeStyle === ShapeStyle.General + ? GeneralStyleIcon + : ScribbledStyleIcon + )} + ${SmallArrowDownIcon} + </editor-icon-button> + `} + > + <edgeless-shape-style-panel + .value=${selectedShapeStyle} + .onSelect=${(value: ShapeStyle) => this._setShapeStyle(value)} + > + </edgeless-shape-style-panel> + </editor-menu-button> + `, + + when( + this.edgeless.doc.awarenessStore.getFlag('enable_color_picker'), + () => { + const { type, colors } = packColorsWithColorScheme( + colorScheme, + selectedFillColor, + elements[0].fillColor + ); + + return html` + <edgeless-color-picker-button + class="fill-color" + .label=${'Fill color'} + .pick=${this.#pickColor('fillColor')} + .color=${selectedFillColor} + .colors=${colors} + .colorType=${type} + .palettes=${SHAPE_FILL_COLORS} + > + </edgeless-color-picker-button> + `; + }, + () => html` + <editor-menu-button + .contentPadding=${'8px'} + .button=${html` + <editor-icon-button + aria-label="Fill color" + .tooltip=${'Fill color'} + > + <edgeless-color-button + .color=${selectedFillColor} + ></edgeless-color-button> + </editor-icon-button> + `} + > + <edgeless-color-panel + role="listbox" + aria-label="Fill colors" + .value=${selectedFillColor} + .options=${SHAPE_FILL_COLORS} + @select=${(e: ColorEvent) => this._setShapeFillColor(e.detail)} + > + </edgeless-color-panel> + </editor-menu-button> + ` + ), + + when( + this.edgeless.doc.awarenessStore.getFlag('enable_color_picker'), + () => { + const { type, colors } = packColorsWithColorScheme( + colorScheme, + selectedStrokeColor, + elements[0].strokeColor + ); + + return html` + <edgeless-color-picker-button + class="border-style" + .label=${'Border style'} + .pick=${this.#pickColor('strokeColor')} + .color=${selectedStrokeColor} + .colors=${colors} + .colorType=${type} + .palettes=${SHAPE_STROKE_COLORS} + .hollowCircle=${true} + > + <div + slot="other" + class="line-styles" + style=${styleMap({ + display: 'flex', + flexDirection: 'row', + gap: '8px', + alignItems: 'center', + })} + > + ${LineStylesPanel({ + selectedLineSize: selectedLineSize, + selectedLineStyle: selectedLineStyle, + onClick: (e: LineStyleEvent) => this._setShapeStyles(e), + lineStyles: [StrokeStyle.Solid, StrokeStyle.Dash], + })} + </div> + <editor-toolbar-separator + slot="separator" + data-orientation="horizontal" + ></editor-toolbar-separator> + </edgeless-color-picker-button> + `; + }, + () => html` + <editor-menu-button + .contentPadding=${'8px'} + .button=${html` + <editor-icon-button + aria-label="Border style" + .tooltip=${'Border style'} + > + <edgeless-color-button + .color=${selectedStrokeColor} + .hollowCircle=${true} + ></edgeless-color-button> + </editor-icon-button> + `} + > + <stroke-style-panel + .hollowCircle=${true} + .strokeWidth=${selectedLineSize} + .strokeStyle=${selectedLineStyle} + .strokeColor=${selectedStrokeColor} + .setStrokeStyle=${(e: LineStyleEvent) => + this._setShapeStyles(e)} + .setStrokeColor=${(e: ColorEvent) => + this._setShapeStrokeColor(e.detail)} + > + </stroke-style-panel> + </editor-menu-button> + ` + ), + + choose<string, TemplateResult<1> | typeof nothing>( + this._showAddButtonOrTextMenu(), + [ + [ + 'button', + () => html` + <editor-icon-button + aria-label="Add text" + .tooltip=${'Add text'} + @click=${this._addText} + > + ${AddTextIcon} + </editor-icon-button> + `, + ], + [ + 'menu', + () => html` + <edgeless-change-text-menu + .elementType=${'shape'} + .elements=${elements} + .edgeless=${this.edgeless} + ></edgeless-change-text-menu> + `, + ], + ['nothing', () => nothing], + ] + ), + ].filter(button => button !== nothing), + renderToolbarSeparator + ); + } + + @query('edgeless-shape-panel') + private accessor _shapePanel!: EdgelessShapePanel; + + @query('edgeless-color-picker-button.border-style') + accessor borderStyleButton!: EdgelessColorPickerButton; + + @property({ attribute: false }) + accessor edgeless!: EdgelessRootBlockComponent; + + @property({ attribute: false }) + accessor elements: ShapeElementModel[] = []; + + @query('edgeless-color-picker-button.fill-color') + accessor fillColorButton!: EdgelessColorPickerButton; +} + +export function renderChangeShapeButton( + edgeless: EdgelessRootBlockComponent, + elements?: ShapeElementModel[] +) { + if (!elements?.length) return nothing; + if (elements.some(e => e.group instanceof MindmapElementModel)) + return nothing; + + return html` + <edgeless-change-shape-button .elements=${elements} .edgeless=${edgeless}> + </edgeless-change-shape-button> + `; +} diff --git a/blocksuite/blocks/src/root-block/widgets/element-toolbar/change-text-button.ts b/blocksuite/blocks/src/root-block/widgets/element-toolbar/change-text-button.ts new file mode 100644 index 0000000000..77a0bc7359 --- /dev/null +++ b/blocksuite/blocks/src/root-block/widgets/element-toolbar/change-text-button.ts @@ -0,0 +1,19 @@ +import type { TextElementModel } from '@blocksuite/affine-model'; +import { html, nothing } from 'lit'; + +import type { EdgelessRootBlockComponent } from '../../edgeless/edgeless-root-block.js'; + +export function renderChangeTextButton( + edgeless: EdgelessRootBlockComponent, + elements?: TextElementModel[] +) { + if (!elements?.length) return nothing; + + return html` + <edgeless-change-text-menu + .elementType=${'text'} + .elements=${elements} + .edgeless=${edgeless} + ></edgeless-change-text-menu> + `; +} diff --git a/blocksuite/blocks/src/root-block/widgets/element-toolbar/change-text-menu.ts b/blocksuite/blocks/src/root-block/widgets/element-toolbar/change-text-menu.ts new file mode 100644 index 0000000000..f7a6c1479b --- /dev/null +++ b/blocksuite/blocks/src/root-block/widgets/element-toolbar/change-text-menu.ts @@ -0,0 +1,505 @@ +/* eslint-disable @typescript-eslint/no-non-null-assertion */ +import { + ConnectorUtils, + normalizeShapeBound, + TextUtils, +} from '@blocksuite/affine-block-surface'; +import { + SmallArrowDownIcon, + TextAlignCenterIcon, + TextAlignLeftIcon, + TextAlignRightIcon, +} from '@blocksuite/affine-components/icons'; +import { renderToolbarSeparator } from '@blocksuite/affine-components/toolbar'; +import { + type ColorScheme, + ConnectorElementModel, + EdgelessTextBlockModel, + FontFamily, + FontStyle, + FontWeight, + LINE_COLORS, + ShapeElementModel, + TextAlign, + TextElementModel, + type TextStyleProps, +} from '@blocksuite/affine-model'; +import { + Bound, + countBy, + maxBy, + WithDisposable, +} from '@blocksuite/global/utils'; +import { css, html, LitElement, nothing, type TemplateResult } from 'lit'; +import { property, query } from 'lit/decorators.js'; +import { choose } from 'lit/directives/choose.js'; +import { join } from 'lit/directives/join.js'; +import { when } from 'lit/directives/when.js'; + +import type { + EdgelessColorPickerButton, + PickColorEvent, +} from '../../edgeless/components/color-picker/index.js'; +import { + packColor, + packColorsWithColorScheme, +} from '../../edgeless/components/color-picker/utils.js'; +import { + type ColorEvent, + GET_DEFAULT_LINE_COLOR, +} from '../../edgeless/components/panel/color-panel.js'; +import type { EdgelessRootBlockComponent } from '../../edgeless/edgeless-root-block.js'; + +const FONT_SIZE_LIST = [ + { value: 16 }, + { value: 24 }, + { value: 32 }, + { value: 40 }, + { value: 64 }, + { value: 128 }, +] as const; + +const FONT_WEIGHT_CHOOSE: [FontWeight, () => string][] = [ + [FontWeight.Light, () => 'Light'], + [FontWeight.Regular, () => 'Regular'], + [FontWeight.SemiBold, () => 'Semibold'], +] as const; + +const FONT_STYLE_CHOOSE: [FontStyle, () => string | typeof nothing][] = [ + [FontStyle.Normal, () => nothing], + [FontStyle.Italic, () => 'Italic'], +] as const; + +const TEXT_ALIGN_CHOOSE: [TextAlign, () => TemplateResult<1>][] = [ + [TextAlign.Left, () => TextAlignLeftIcon], + [TextAlign.Center, () => TextAlignCenterIcon], + [TextAlign.Right, () => TextAlignRightIcon], +] as const; + +function countByField<K extends keyof Omit<TextStyleProps, 'color'>>( + elements: BlockSuite.EdgelessTextModelType[], + field: K +) { + return countBy(elements, element => extractField(element, field)); +} + +function extractField<K extends keyof Omit<TextStyleProps, 'color'>>( + element: BlockSuite.EdgelessTextModelType, + field: K +) { + //TODO: It's not a very good handling method. + // The edgeless-change-text-menu should be refactored into a widget to allow external registration of its own logic. + if (element instanceof EdgelessTextBlockModel) { + return field === 'fontSize' + ? null + : (element[field as keyof EdgelessTextBlockModel] as TextStyleProps[K]); + } + return ( + element instanceof ConnectorElementModel + ? element.labelStyle[field] + : element[field] + ) as TextStyleProps[K]; +} + +function getMostCommonValue<K extends keyof Omit<TextStyleProps, 'color'>>( + elements: BlockSuite.EdgelessTextModelType[], + field: K +) { + const values = countByField(elements, field); + return maxBy(Object.entries(values), ([_k, count]) => count); +} + +function getMostCommonAlign(elements: BlockSuite.EdgelessTextModelType[]) { + const max = getMostCommonValue(elements, 'textAlign'); + return max ? (max[0] as TextAlign) : TextAlign.Left; +} + +function getMostCommonColor( + elements: BlockSuite.EdgelessTextModelType[], + colorScheme: ColorScheme +): string { + const colors = countBy(elements, (ele: BlockSuite.EdgelessTextModelType) => { + const color = + ele instanceof ConnectorElementModel ? ele.labelStyle.color : ele.color; + return typeof color === 'object' + ? (color[colorScheme] ?? color.normal ?? null) + : color; + }); + const max = maxBy(Object.entries(colors), ([_k, count]) => count); + return max ? (max[0] as string) : GET_DEFAULT_LINE_COLOR(colorScheme); +} + +function getMostCommonFontFamily(elements: BlockSuite.EdgelessTextModelType[]) { + const max = getMostCommonValue(elements, 'fontFamily'); + return max ? (max[0] as FontFamily) : FontFamily.Inter; +} + +function getMostCommonFontSize(elements: BlockSuite.EdgelessTextModelType[]) { + const max = getMostCommonValue(elements, 'fontSize'); + return max ? Number(max[0]) : FONT_SIZE_LIST[0].value; +} + +function getMostCommonFontStyle(elements: BlockSuite.EdgelessTextModelType[]) { + const max = getMostCommonValue(elements, 'fontStyle'); + return max ? (max[0] as FontStyle) : FontStyle.Normal; +} + +function getMostCommonFontWeight(elements: BlockSuite.EdgelessTextModelType[]) { + const max = getMostCommonValue(elements, 'fontWeight'); + return max ? (max[0] as FontWeight) : FontWeight.Regular; +} + +function buildProps( + element: BlockSuite.EdgelessTextModelType, + props: { [K in keyof TextStyleProps]?: TextStyleProps[K] } +) { + if (element instanceof ConnectorElementModel) { + return { + labelStyle: { + ...element.labelStyle, + ...props, + }, + }; + } + + return { ...props }; +} + +export class EdgelessChangeTextMenu extends WithDisposable(LitElement) { + static override styles = css` + :host { + display: inherit; + align-items: inherit; + justify-content: inherit; + gap: inherit; + height: 100%; + } + `; + + private _setFontFamily = (fontFamily: FontFamily) => { + const currentFontWeight = getMostCommonFontWeight(this.elements); + const fontWeight = TextUtils.isFontWeightSupported( + fontFamily, + currentFontWeight + ) + ? currentFontWeight + : FontWeight.Regular; + const currentFontStyle = getMostCommonFontStyle(this.elements); + const fontStyle = TextUtils.isFontStyleSupported( + fontFamily, + currentFontStyle + ) + ? currentFontStyle + : FontStyle.Normal; + + const props = { fontFamily, fontWeight, fontStyle }; + this.elements.forEach(element => { + this.service.updateElement(element.id, buildProps(element, props)); + this._updateElementBound(element); + }); + }; + + private _setFontSize = (fontSize: number) => { + const props = { fontSize }; + this.elements.forEach(element => { + this.service.updateElement(element.id, buildProps(element, props)); + this._updateElementBound(element); + }); + }; + + private _setFontWeightAndStyle = ( + fontWeight: FontWeight, + fontStyle: FontStyle + ) => { + const props = { fontWeight, fontStyle }; + this.elements.forEach(element => { + this.service.updateElement(element.id, buildProps(element, props)); + this._updateElementBound(element); + }); + }; + + private _setTextAlign = (textAlign: TextAlign) => { + const props = { textAlign }; + this.elements.forEach(element => { + this.service.updateElement(element.id, buildProps(element, props)); + }); + }; + + private _setTextColor = ({ detail: color }: ColorEvent) => { + const props = { color }; + this.elements.forEach(element => { + this.service.updateElement(element.id, buildProps(element, props)); + }); + }; + + private _updateElementBound = (element: BlockSuite.EdgelessTextModelType) => { + const elementType = this.elementType; + if (elementType === 'text' && element instanceof TextElementModel) { + // the change of font family will change the bound of the text + const { + text: yText, + fontFamily, + fontStyle, + fontSize, + fontWeight, + hasMaxWidth, + } = element; + const newBound = TextUtils.normalizeTextBound( + { + yText, + fontFamily, + fontStyle, + fontSize, + fontWeight, + hasMaxWidth, + }, + Bound.fromXYWH(element.deserializedXYWH) + ); + this.service.updateElement(element.id, { + xywh: newBound.serialize(), + }); + } else if ( + elementType === 'connector' && + ConnectorUtils.isConnectorWithLabel(element) + ) { + const { + text, + labelXYWH, + labelStyle: { fontFamily, fontStyle, fontSize, fontWeight }, + labelConstraints: { hasMaxWidth, maxWidth }, + } = element as ConnectorElementModel; + const prevBounds = Bound.fromXYWH(labelXYWH || [0, 0, 16, 16]); + const center = prevBounds.center; + const bounds = TextUtils.normalizeTextBound( + { + yText: text!, + fontFamily, + fontStyle, + fontSize, + fontWeight, + hasMaxWidth, + maxWidth, + }, + prevBounds + ); + bounds.center = center; + this.service.updateElement(element.id, { + labelXYWH: bounds.toXYWH(), + }); + } else if ( + elementType === 'shape' && + element instanceof ShapeElementModel + ) { + const newBound = normalizeShapeBound( + element, + Bound.fromXYWH(element.deserializedXYWH) + ); + this.service.updateElement(element.id, { + xywh: newBound.serialize(), + }); + } + // no need to update the bound of edgeless text block, which updates itself using ResizeObserver + }; + + pickColor = (event: PickColorEvent) => { + if (event.type === 'pick') { + this.elements.forEach(element => { + const props = packColor('color', { ...event.detail }); + this.service.updateElement(element.id, buildProps(element, props)); + this._updateElementBound(element); + }); + return; + } + + const key = this.elementType === 'connector' ? 'labelStyle' : 'color'; + this.elements.forEach(ele => { + // @ts-expect-error: FIXME + ele[event.type === 'start' ? 'stash' : 'pop'](key); + }); + }; + + get service() { + return this.edgeless.service; + } + + override render() { + const colorScheme = this.edgeless.surface.renderer.getColorScheme(); + const elements = this.elements; + const selectedAlign = getMostCommonAlign(elements); + const selectedColor = getMostCommonColor(elements, colorScheme); + const selectedFontFamily = getMostCommonFontFamily(elements); + const selectedFontSize = Math.trunc(getMostCommonFontSize(elements)); + const selectedFontStyle = getMostCommonFontStyle(elements); + const selectedFontWeight = getMostCommonFontWeight(elements); + const matchFontFaces = + TextUtils.getFontFacesByFontFamily(selectedFontFamily); + const fontStyleBtnDisabled = + matchFontFaces.length === 1 && + matchFontFaces[0].style === selectedFontStyle && + matchFontFaces[0].weight === selectedFontWeight; + + return join( + [ + html` + <editor-menu-button + .contentPadding=${'8px'} + .button=${html` + <editor-icon-button + aria-label="Font" + .tooltip=${'Font'} + .justify=${'space-between'} + .labelHeight=${'20px'} + .iconContainerWidth=${'40px'} + > + <span + class="label padding0" + style=${`font-family: ${TextUtils.wrapFontFamily(selectedFontFamily)}`} + >Aa</span + >${SmallArrowDownIcon} + </editor-icon-button> + `} + > + <edgeless-font-family-panel + .value=${selectedFontFamily} + .onSelect=${this._setFontFamily} + ></edgeless-font-family-panel> + </editor-menu-button> + `, + + when( + this.edgeless.doc.awarenessStore.getFlag('enable_color_picker'), + () => { + const { type, colors } = packColorsWithColorScheme( + colorScheme, + selectedColor, + elements[0] instanceof ConnectorElementModel + ? elements[0].labelStyle.color + : elements[0].color + ); + + return html` + <edgeless-color-picker-button + class="text-color" + .label=${'Text color'} + .pick=${this.pickColor} + .isText=${true} + .color=${selectedColor} + .colors=${colors} + .colorType=${type} + .palettes=${LINE_COLORS} + > + </edgeless-color-picker-button> + `; + }, + () => html` + <editor-menu-button + .contentPadding=${'8px'} + .button=${html` + <editor-icon-button + aria-label="Text color" + .tooltip=${'Text color'} + > + <edgeless-text-color-icon + .color=${selectedColor} + ></edgeless-text-color-icon> + </editor-icon-button> + `} + > + <edgeless-color-panel + .value=${selectedColor} + @select=${this._setTextColor} + ></edgeless-color-panel> + </editor-menu-button> + ` + ), + + html` + <editor-menu-button + .contentPadding=${'8px'} + .button=${html` + <editor-icon-button + aria-label="Font style" + .tooltip=${'Font style'} + .justify=${'space-between'} + .labelHeight=${'20px'} + .iconContainerWidth=${'90px'} + .disabled=${fontStyleBtnDisabled} + > + <span class="label ellipsis"> + ${choose(selectedFontWeight, FONT_WEIGHT_CHOOSE)} + ${choose(selectedFontStyle, FONT_STYLE_CHOOSE)} + </span> + ${SmallArrowDownIcon} + </editor-icon-button> + `} + > + <edgeless-font-weight-and-style-panel + .fontFamily=${selectedFontFamily} + .fontWeight=${selectedFontWeight} + .fontStyle=${selectedFontStyle} + .onSelect=${this._setFontWeightAndStyle} + ></edgeless-font-weight-and-style-panel> + </editor-menu-button> + `, + + this.elementType === 'edgeless-text' + ? nothing + : html` + <editor-menu-button + .contentPadding=${'8px'} + .button=${html` + <editor-icon-button + aria-label="Font size" + .tooltip=${'Font size'} + .justify=${'space-between'} + .labelHeight=${'20px'} + .iconContainerWidth=${'60px'} + > + <span class="label">${selectedFontSize}</span> + ${SmallArrowDownIcon} + </editor-icon-button> + `} + > + <edgeless-size-panel + data-type="check" + .size=${selectedFontSize} + .sizeList=${FONT_SIZE_LIST} + .onSelect=${this._setFontSize} + ></edgeless-size-panel> + </editor-menu-button> + `, + + html` + <editor-menu-button + .button=${html` + <editor-icon-button + aria-label="Alignment" + .tooltip=${'Alignment'} + > + ${choose(selectedAlign, TEXT_ALIGN_CHOOSE)}${SmallArrowDownIcon} + </editor-icon-button> + `} + > + <edgeless-align-panel + .value=${selectedAlign} + .onSelect=${this._setTextAlign} + ></edgeless-align-panel> + </editor-menu-button> + `, + ].filter(b => b !== nothing), + renderToolbarSeparator + ); + } + + @property({ attribute: false }) + accessor edgeless!: EdgelessRootBlockComponent; + + @property({ attribute: false }) + accessor elements!: BlockSuite.EdgelessTextModelType[]; + + @property({ attribute: false }) + accessor elementType!: BlockSuite.EdgelessTextModelKeyType; + + @query('edgeless-color-picker-button.text-color') + accessor textColorButton!: EdgelessColorPickerButton; +} diff --git a/blocksuite/blocks/src/root-block/widgets/element-toolbar/effects.ts b/blocksuite/blocks/src/root-block/widgets/element-toolbar/effects.ts new file mode 100644 index 0000000000..cf611eaca3 --- /dev/null +++ b/blocksuite/blocks/src/root-block/widgets/element-toolbar/effects.ts @@ -0,0 +1,114 @@ +import { EdgelessAddFrameButton } from './add-frame-button.js'; +import { EdgelessAddGroupButton } from './add-group-button.js'; +import { EdgelessAlignButton } from './align-button.js'; +import { EdgelessChangeAttachmentButton } from './change-attachment-button.js'; +import { EdgelessChangeBrushButton } from './change-brush-button.js'; +import { EdgelessChangeConnectorButton } from './change-connector-button.js'; +import { EdgelessChangeEmbedCardButton } from './change-embed-card-button.js'; +import { EdgelessChangeFrameButton } from './change-frame-button.js'; +import { EdgelessChangeGroupButton } from './change-group-button.js'; +import { EdgelessChangeImageButton } from './change-image-button.js'; +import { + EdgelessChangeMindmapButton, + EdgelessChangeMindmapLayoutPanel, + EdgelessChangeMindmapStylePanel, +} from './change-mindmap-button.js'; +import { EdgelessChangeNoteButton } from './change-note-button.js'; +import { EdgelessChangeShapeButton } from './change-shape-button.js'; +import { EdgelessChangeTextMenu } from './change-text-menu.js'; +import { + EDGELESS_ELEMENT_TOOLBAR_WIDGET, + EdgelessElementToolbarWidget, +} from './index.js'; +import { EdgelessLockButton } from './lock-button.js'; +import { EdgelessMoreButton } from './more-menu/button.js'; +import { EdgelessReleaseFromGroupButton } from './release-from-group-button.js'; + +export function effects() { + customElements.define( + EDGELESS_ELEMENT_TOOLBAR_WIDGET, + EdgelessElementToolbarWidget + ); + customElements.define('edgeless-add-frame-button', EdgelessAddFrameButton); + customElements.define('edgeless-add-group-button', EdgelessAddGroupButton); + customElements.define('edgeless-align-button', EdgelessAlignButton); + customElements.define( + 'edgeless-change-attachment-button', + EdgelessChangeAttachmentButton + ); + customElements.define( + 'edgeless-change-brush-button', + EdgelessChangeBrushButton + ); + customElements.define( + 'edgeless-change-connector-button', + EdgelessChangeConnectorButton + ); + customElements.define( + 'edgeless-change-embed-card-button', + EdgelessChangeEmbedCardButton + ); + customElements.define( + 'edgeless-change-frame-button', + EdgelessChangeFrameButton + ); + customElements.define( + 'edgeless-change-group-button', + EdgelessChangeGroupButton + ); + customElements.define( + 'edgeless-change-image-button', + EdgelessChangeImageButton + ); + customElements.define( + 'edgeless-change-mindmap-style-panel', + EdgelessChangeMindmapStylePanel + ); + customElements.define( + 'edgeless-change-mindmap-layout-panel', + EdgelessChangeMindmapLayoutPanel + ); + customElements.define( + 'edgeless-change-mindmap-button', + EdgelessChangeMindmapButton + ); + customElements.define( + 'edgeless-change-note-button', + EdgelessChangeNoteButton + ); + customElements.define( + 'edgeless-change-shape-button', + EdgelessChangeShapeButton + ); + customElements.define('edgeless-change-text-menu', EdgelessChangeTextMenu); + customElements.define( + 'edgeless-release-from-group-button', + EdgelessReleaseFromGroupButton + ); + customElements.define('edgeless-more-button', EdgelessMoreButton); + customElements.define('edgeless-lock-button', EdgelessLockButton); +} + +declare global { + interface HTMLElementTagNameMap { + [EDGELESS_ELEMENT_TOOLBAR_WIDGET]: EdgelessElementToolbarWidget; + 'edgeless-add-frame-button': EdgelessAddFrameButton; + 'edgeless-add-group-button': EdgelessAddGroupButton; + 'edgeless-align-button': EdgelessAlignButton; + 'edgeless-change-attachment-button': EdgelessChangeAttachmentButton; + 'edgeless-change-brush-button': EdgelessChangeBrushButton; + 'edgeless-change-connector-button': EdgelessChangeConnectorButton; + 'edgeless-change-embed-card-button': EdgelessChangeEmbedCardButton; + 'edgeless-change-frame-button': EdgelessChangeFrameButton; + 'edgeless-change-group-button': EdgelessChangeGroupButton; + 'edgeless-change-mindmap-style-panel': EdgelessChangeMindmapStylePanel; + 'edgeless-change-mindmap-layout-panel': EdgelessChangeMindmapLayoutPanel; + 'edgeless-change-mindmap-button': EdgelessChangeMindmapButton; + 'edgeless-change-note-button': EdgelessChangeNoteButton; + 'edgeless-change-shape-button': EdgelessChangeShapeButton; + 'edgeless-change-text-menu': EdgelessChangeTextMenu; + 'edgeless-release-from-group-button': EdgelessReleaseFromGroupButton; + 'edgeless-more-button': EdgelessMoreButton; + 'edgeless-lock-button': EdgelessLockButton; + } +} diff --git a/blocksuite/blocks/src/root-block/widgets/element-toolbar/index.ts b/blocksuite/blocks/src/root-block/widgets/element-toolbar/index.ts new file mode 100644 index 0000000000..810a14654a --- /dev/null +++ b/blocksuite/blocks/src/root-block/widgets/element-toolbar/index.ts @@ -0,0 +1,481 @@ +import { CommonUtils } from '@blocksuite/affine-block-surface'; +import { ConnectorCWithArrowIcon } from '@blocksuite/affine-components/icons'; +import { + cloneGroups, + darkToolbarStyles, + lightToolbarStyles, + type MenuItemGroup, + renderToolbarSeparator, +} from '@blocksuite/affine-components/toolbar'; +import type { + AttachmentBlockModel, + BrushElementModel, + ConnectorElementModel, + EdgelessTextBlockModel, + FrameBlockModel, + ImageBlockModel, + MindmapElementModel, + NoteBlockModel, + RootBlockModel, + TextElementModel, +} from '@blocksuite/affine-model'; +import { + ConnectorMode, + GroupElementModel, + ShapeElementModel, +} from '@blocksuite/affine-model'; +import { ThemeProvider } from '@blocksuite/affine-shared/services'; +import { requestConnectedFrame } from '@blocksuite/affine-shared/utils'; +import { WidgetComponent } from '@blocksuite/block-std'; +import { + atLeastNMatches, + getCommonBoundWithRotation, + groupBy, + pickValues, +} from '@blocksuite/global/utils'; +import { css, html, nothing, type TemplateResult, unsafeCSS } from 'lit'; +import { property, state } from 'lit/decorators.js'; +import { join } from 'lit/directives/join.js'; + +import type { EmbedModel } from '../../../_common/components/embed-card/type.js'; +import { getMoreMenuConfig } from '../../configs/toolbar.js'; +import type { EdgelessRootBlockComponent } from '../../edgeless/edgeless-root-block.js'; +import { + isAttachmentBlock, + isBookmarkBlock, + isEdgelessTextBlock, + isEmbeddedBlock, + isFrameBlock, + isImageBlock, + isNoteBlock, +} from '../../edgeless/utils/query.js'; +import { renderAddFrameButton } from './add-frame-button.js'; +import { renderAddGroupButton } from './add-group-button.js'; +import { renderAlignButton } from './align-button.js'; +import { renderAttachmentButton } from './change-attachment-button.js'; +import { renderChangeBrushButton } from './change-brush-button.js'; +import { renderConnectorButton } from './change-connector-button.js'; +import { renderChangeEdgelessTextButton } from './change-edgeless-text-button.js'; +import { renderEmbedButton } from './change-embed-card-button.js'; +import { renderFrameButton } from './change-frame-button.js'; +import { renderGroupButton } from './change-group-button.js'; +import { renderChangeImageButton } from './change-image-button.js'; +import { renderMindmapButton } from './change-mindmap-button.js'; +import { renderNoteButton } from './change-note-button.js'; +import { renderChangeShapeButton } from './change-shape-button.js'; +import { renderChangeTextButton } from './change-text-button.js'; +import { BUILT_IN_GROUPS } from './more-menu/config.js'; +import type { ElementToolbarMoreMenuContext } from './more-menu/context.js'; +import { renderReleaseFromGroupButton } from './release-from-group-button.js'; + +type CategorizedElements = { + shape?: ShapeElementModel[]; + brush?: BrushElementModel[]; + text?: TextElementModel[]; + group?: GroupElementModel[]; + connector?: ConnectorElementModel[]; + note?: NoteBlockModel[]; + frame?: FrameBlockModel[]; + image?: ImageBlockModel[]; + attachment?: AttachmentBlockModel[]; + mindmap?: MindmapElementModel[]; + embedCard?: EmbedModel[]; + edgelessText?: EdgelessTextBlockModel[]; +}; + +type CustomEntry = { + render: (edgeless: EdgelessRootBlockComponent) => TemplateResult | null; + when: (model: BlockSuite.EdgelessModel[]) => boolean; +}; + +export const EDGELESS_ELEMENT_TOOLBAR_WIDGET = + 'edgeless-element-toolbar-widget'; + +export class EdgelessElementToolbarWidget extends WidgetComponent< + RootBlockModel, + EdgelessRootBlockComponent +> { + static override styles = css` + :host { + position: absolute; + z-index: 3; + transform: translateZ(0); + will-change: transform; + -webkit-user-select: none; + user-select: none; + } + editor-toolbar[data-app-theme='light'] { + ${unsafeCSS(lightToolbarStyles.join('\n'))} + } + editor-toolbar[data-app-theme='dark'] { + ${unsafeCSS(darkToolbarStyles.join('\n'))} + } + `; + + private _quickConnect = ({ x, y }: MouseEvent) => { + const element = this.selection.selectedElements[0]; + const point = this.edgeless.service.viewport.toViewCoordFromClientCoord([ + x, + y, + ]); + this.edgeless.doc.captureSync(); + this.edgeless.gfx.tool.setTool('connector', { + mode: ConnectorMode.Curve, + }); + + const ctc = this.edgeless.gfx.tool.get('connector'); + ctc.quickConnect(point, element); + }; + + private _updateOnSelectedChange = (element: string | { id: string }) => { + const id = typeof element === 'string' ? element : element.id; + + if (this.isConnected && !this._dragging && this.selection.has(id)) { + this._recalculatePosition(); + this.requestUpdate(); + } + }; + + /* + * Caches the more menu items. + * Currently only supports configuring more menu. + */ + moreGroups: MenuItemGroup<ElementToolbarMoreMenuContext>[] = + cloneGroups(BUILT_IN_GROUPS); + + get edgeless() { + return this.block as EdgelessRootBlockComponent; + } + + get selection() { + return this.edgeless.service.selection; + } + + get slots() { + return this.edgeless.slots; + } + + get surface() { + return this.edgeless.surface; + } + + private _groupSelected(): CategorizedElements { + const result = groupBy(this.selection.selectedElements, model => { + if (isNoteBlock(model)) { + return 'note'; + } else if (isFrameBlock(model)) { + return 'frame'; + } else if (isImageBlock(model)) { + return 'image'; + } else if (isAttachmentBlock(model)) { + return 'attachment'; + } else if (isBookmarkBlock(model) || isEmbeddedBlock(model)) { + return 'embedCard'; + } else if (isEdgelessTextBlock(model)) { + return 'edgelessText'; + } + + return (model as BlockSuite.SurfaceElementModel).type; + }); + return result as CategorizedElements; + } + + private _recalculatePosition() { + const { selection, viewport } = this.edgeless.service; + const elements = selection.selectedElements; + + if (elements.length === 0) { + this.style.transform = 'translate3d(0, 0, 0)'; + return; + } + + const bound = getCommonBoundWithRotation(elements); + + const { width, height } = viewport; + const { x, y, w } = viewport.toViewBound(bound); + + let left = x; + let top = y; + + const hasLocked = elements.some(e => e.isLocked()); + + let offset = 37 + 12; + // frame, group, shape + let hasFrame = false; + let hasGroup = false; + if ( + (hasFrame = elements.some(ele => isFrameBlock(ele))) || + (hasGroup = elements.some(ele => ele instanceof GroupElementModel)) + ) { + offset += 16 + 4; + if (hasFrame) { + offset += 8; + } + } else if ( + elements.length === 1 && + elements[0] instanceof ShapeElementModel + ) { + offset += 22 + 4; + } + + top = y - offset; + if (top < 0) { + top = y + bound.h * viewport.zoom + offset - 37; + if (hasFrame || hasGroup) { + top -= 16 + 4; + if (hasFrame) { + top -= 8; + } + } + } + + requestConnectedFrame(() => { + const rect = this.getBoundingClientRect(); + + if (hasLocked) { + left += 0.5 * (w - rect.width); + } + + left = CommonUtils.clamp(left, 10, width - rect.width - 10); + top = CommonUtils.clamp(top, 10, height - rect.height - 150); + + this.style.transform = `translate3d(${left}px, ${top}px, 0)`; + }, this); + } + + private _renderButtons() { + if (this.doc.readonly || this._dragging || !this.toolbarVisible) { + return []; + } + const { selectedElements } = this.selection; + if (selectedElements.some(e => e.isLocked())) { + return [ + html`<edgeless-lock-button + .edgeless=${this.edgeless} + ></edgeless-lock-button>`, + ]; + } + + const groupedSelected = this._groupSelected(); + const { edgeless, selection } = this; + const { + shape, + brush, + connector, + note, + text, + frame, + group, + embedCard, + attachment, + image, + edgelessText, + mindmap: mindmaps, + } = groupedSelected; + const selectedAtLeastTwoTypes = atLeastNMatches( + Object.values(groupedSelected), + e => !!e.length, + 2 + ); + + const quickConnectButton = + selectedElements.length === 1 && !connector?.length + ? this._renderQuickConnectButton() + : undefined; + + const generalButtons = + selectedElements.length !== connector?.length + ? [ + renderAddFrameButton(edgeless, selectedElements), + renderAddGroupButton(edgeless, selectedElements), + renderAlignButton(edgeless, selectedElements), + ] + : []; + + const buttons: (symbol | TemplateResult)[] = selectedAtLeastTwoTypes + ? generalButtons + : [ + ...generalButtons, + renderMindmapButton(edgeless, mindmaps), + renderMindmapButton(edgeless, shape), + renderChangeShapeButton(edgeless, shape), + renderChangeBrushButton(edgeless, brush), + renderConnectorButton(edgeless, connector), + renderNoteButton(edgeless, note, quickConnectButton), + renderChangeTextButton(edgeless, text), + renderChangeEdgelessTextButton(edgeless, edgelessText), + renderFrameButton(edgeless, frame), + renderGroupButton(edgeless, group), + renderEmbedButton(edgeless, embedCard, quickConnectButton), + renderAttachmentButton(edgeless, attachment), + renderChangeImageButton(edgeless, image), + ]; + + if (selectedElements.length === 1) { + if (selection.firstElement.group instanceof GroupElementModel) { + buttons.unshift(renderReleaseFromGroupButton(this.edgeless)); + } + + if (!connector?.length) { + buttons.push(quickConnectButton?.pop() ?? nothing); + } + } + + buttons.push( + html`<edgeless-lock-button + .edgeless=${this.edgeless} + ></edgeless-lock-button>` + ); + + this._registeredEntries + .filter(entry => entry.when(selectedElements)) + .map(entry => entry.render(this.edgeless)) + .forEach(entry => entry && buttons.unshift(entry)); + + buttons.push(html` + <edgeless-more-button + .elements=${selectedElements} + .edgeless=${edgeless} + .groups=${this.moreGroups} + .vertical=${true} + ></edgeless-more-button> + `); + + return buttons; + } + + private _renderQuickConnectButton() { + return [ + html` + <editor-icon-button + aria-label="Draw connector" + .tooltip=${'Draw connector'} + .activeMode=${'background'} + @click=${this._quickConnect} + > + ${ConnectorCWithArrowIcon} + </editor-icon-button> + `, + ]; + } + + protected override firstUpdated() { + const { _disposables, edgeless } = this; + + this.moreGroups = getMoreMenuConfig(this.std).configure(this.moreGroups); + + _disposables.add( + edgeless.service.viewport.viewportUpdated.on(() => { + this._recalculatePosition(); + }) + ); + + _disposables.add( + this.selection.slots.updated.on(() => { + if ( + this.selection.selectedIds.length === 0 || + this.selection.editing || + this.selection.inoperable + ) { + this.toolbarVisible = false; + } else { + this.selectedIds = this.selection.selectedIds; + this._recalculatePosition(); + this.toolbarVisible = true; + } + }) + ); + + pickValues(this.edgeless.service.surface, [ + 'elementAdded', + 'elementUpdated', + ]).forEach(slot => _disposables.add(slot.on(this._updateOnSelectedChange))); + + _disposables.add( + this.doc.slots.blockUpdated.on(this._updateOnSelectedChange) + ); + + _disposables.add( + edgeless.dispatcher.add('dragStart', () => { + this._dragging = true; + }) + ); + _disposables.add( + edgeless.dispatcher.add('dragEnd', () => { + this._dragging = false; + this._recalculatePosition(); + }) + ); + + _disposables.add( + edgeless.slots.elementResizeStart.on(() => { + this._dragging = true; + }) + ); + _disposables.add( + edgeless.slots.elementResizeEnd.on(() => { + this._dragging = false; + this._recalculatePosition(); + }) + ); + + _disposables.add( + edgeless.slots.readonlyUpdated.on(() => this.requestUpdate()) + ); + + this.updateComplete + .then(() => { + _disposables.add( + this.std + .get(ThemeProvider) + .theme$.subscribe(() => this.requestUpdate()) + ); + }) + .catch(console.error); + } + + registerEntry(entry: CustomEntry) { + this._registeredEntries.push(entry); + } + + override render() { + const buttons = this._renderButtons(); + if (buttons.length === 0) return nothing; + + const appTheme = this.std.get(ThemeProvider).app$.value; + return html` + <editor-toolbar data-app-theme=${appTheme}> + ${join( + buttons.filter(b => b !== nothing), + renderToolbarSeparator + )} + </editor-toolbar> + `; + } + + @state() + private accessor _dragging = false; + + @state() + private accessor _registeredEntries: { + render: (edgeless: EdgelessRootBlockComponent) => TemplateResult | null; + when: (model: BlockSuite.EdgelessModel[]) => boolean; + }[] = []; + + @property({ attribute: false }) + accessor enableNoteSlicer!: boolean; + + @state({ + hasChanged: (value: string[], oldValue: string[]) => { + if (value.length !== oldValue?.length) { + return true; + } + + return value.some((id, index) => id !== oldValue[index]); + }, + }) + accessor selectedIds: string[] = []; + + @state() + accessor toolbarVisible = false; +} diff --git a/blocksuite/blocks/src/root-block/widgets/element-toolbar/lock-button.ts b/blocksuite/blocks/src/root-block/widgets/element-toolbar/lock-button.ts new file mode 100644 index 0000000000..63abd49e20 --- /dev/null +++ b/blocksuite/blocks/src/root-block/widgets/element-toolbar/lock-button.ts @@ -0,0 +1,152 @@ +import { + GroupElementModel, + MindmapElementModel, +} from '@blocksuite/affine-model'; +import { + type ElementLockEvent, + TelemetryProvider, +} from '@blocksuite/affine-shared/services'; +import type { BlockStdScope } from '@blocksuite/block-std'; +import type { GfxModel } from '@blocksuite/block-std/gfx'; +import { SignalWatcher, WithDisposable } from '@blocksuite/global/utils'; +import { LockIcon, UnlockIcon } from '@blocksuite/icons/lit'; +import { html, LitElement, nothing } from 'lit'; +import { property } from 'lit/decorators.js'; + +import type { EdgelessRootBlockComponent } from '../../edgeless/index.js'; + +export class EdgelessLockButton extends SignalWatcher( + WithDisposable(LitElement) +) { + private get _selectedElements() { + const elements = new Set<GfxModel>(); + this.edgeless.service.selection.selectedElements.forEach(element => { + if (element.group instanceof MindmapElementModel) { + elements.add(element.group); + } else { + elements.add(element); + } + }); + return [...elements]; + } + + private _lock() { + const { service, doc, std } = this.edgeless; + + doc.captureSync(); + + // get most top selected elements(*) from tree, like in a tree below + // G0 + // / \ + // E1* G1 + // / \ + // E2* E3* + // + // (*) selected elements, [E1, E2, E3] + // return [E1] + + const selectedElements = this._selectedElements; + const levels = selectedElements.map(element => element.groups.length); + const topElement = selectedElements[levels.indexOf(Math.min(...levels))]; + const otherElements = selectedElements.filter( + element => element !== topElement + ); + + // release other elements from their groups and group with top element + otherElements.forEach(element => { + // eslint-disable-next-line + element.group?.removeChild(element); + topElement.group?.addChild(element); + }); + + if (otherElements.length === 0) { + topElement.lock(); + this.edgeless.gfx.selection.set({ + editing: false, + elements: [topElement.id], + }); + track(std, topElement, 'lock'); + return; + } + + const groupId = service.createGroup([topElement, ...otherElements]); + + if (groupId) { + const group = service.getElementById(groupId); + if (group) { + group.lock(); + this.edgeless.gfx.selection.set({ + editing: false, + elements: [groupId], + }); + track(std, group, 'group-lock'); + return; + } + } + + selectedElements.forEach(e => { + e.lock(); + track(std, e, 'lock'); + }); + + this.edgeless.gfx.selection.set({ + editing: false, + elements: selectedElements.map(e => e.id), + }); + } + + private _unlock() { + const { service, doc } = this.edgeless; + doc.captureSync(); + + this._selectedElements.forEach(element => { + if (element instanceof GroupElementModel) { + service.ungroup(element); + } else { + element.lockedBySelf = false; + } + track(this.edgeless.std, element, 'unlock'); + }); + } + + override render() { + const hasLocked = this._selectedElements.some(element => + element.isLocked() + ); + + this.dataset.locked = hasLocked ? 'true' : 'false'; + + const icon = hasLocked ? UnlockIcon : LockIcon; + + return html`<editor-icon-button + @click=${hasLocked ? this._unlock : this._lock} + > + ${icon({ width: '20px', height: '20px' })} + ${hasLocked + ? html`<span class="label medium">Click to unlock</span>` + : nothing} + </editor-icon-button>`; + } + + @property({ attribute: false }) + accessor edgeless!: EdgelessRootBlockComponent; +} + +function track( + std: BlockStdScope, + element: GfxModel, + control: ElementLockEvent['control'] +) { + const type = + 'flavour' in element + ? (element.flavour.split(':')[1] ?? element.flavour) + : element.type; + + std.getOptional(TelemetryProvider)?.track('EdgelessElementLocked', { + page: 'whiteboard editor', + segment: 'element toolbar', + module: 'element toolbar', + control, + type, + }); +} diff --git a/blocksuite/blocks/src/root-block/widgets/element-toolbar/more-menu/button.ts b/blocksuite/blocks/src/root-block/widgets/element-toolbar/more-menu/button.ts new file mode 100644 index 0000000000..91bee6c3d3 --- /dev/null +++ b/blocksuite/blocks/src/root-block/widgets/element-toolbar/more-menu/button.ts @@ -0,0 +1,49 @@ +import type { MenuItemGroup } from '@blocksuite/affine-components/toolbar'; +import { renderGroups } from '@blocksuite/affine-components/toolbar'; +import { WithDisposable } from '@blocksuite/global/utils'; +import { MoreHorizontalIcon, MoreVerticalIcon } from '@blocksuite/icons/lit'; +import { html, LitElement } from 'lit'; +import { property } from 'lit/decorators.js'; + +import type { EdgelessRootBlockComponent } from '../../../edgeless/edgeless-root-block.js'; +import { ElementToolbarMoreMenuContext } from './context.js'; + +export class EdgelessMoreButton extends WithDisposable(LitElement) { + override render() { + const context = new ElementToolbarMoreMenuContext(this.edgeless); + const actions = renderGroups(this.groups, context); + + return html` + <editor-menu-button + .contentPadding=${'8px'} + .button=${html` + <editor-icon-button aria-label="More" .tooltip=${'More'}> + ${this.vertical + ? MoreVerticalIcon({ width: '20', height: '20' }) + : MoreHorizontalIcon({ width: '20', height: '20' })} + </editor-icon-button> + `} + > + <div + class="more-actions-container" + data-size="large" + data-orientation="vertical" + > + ${actions} + </div> + </editor-menu-button> + `; + } + + @property({ attribute: false }) + accessor edgeless!: EdgelessRootBlockComponent; + + @property({ attribute: false }) + accessor elements: BlockSuite.EdgelessModel[] = []; + + @property({ attribute: false }) + accessor groups!: MenuItemGroup<ElementToolbarMoreMenuContext>[]; + + @property({ attribute: false }) + accessor vertical = false; +} diff --git a/blocksuite/blocks/src/root-block/widgets/element-toolbar/more-menu/config.ts b/blocksuite/blocks/src/root-block/widgets/element-toolbar/more-menu/config.ts new file mode 100644 index 0000000000..388f90ee54 --- /dev/null +++ b/blocksuite/blocks/src/root-block/widgets/element-toolbar/more-menu/config.ts @@ -0,0 +1,398 @@ +import type { + EmbedFigmaBlockComponent, + EmbedGithubBlockComponent, + EmbedLoomBlockComponent, + EmbedYoutubeBlockComponent, +} from '@blocksuite/affine-block-embed'; +import { isPeekable, peek } from '@blocksuite/affine-components/peek'; +import type { MenuItemGroup } from '@blocksuite/affine-components/toolbar'; +import { TelemetryProvider } from '@blocksuite/affine-shared/services'; +import { Bound, getCommonBoundWithRotation } from '@blocksuite/global/utils'; +import { + ArrowDownBigBottomIcon, + ArrowDownBigIcon, + ArrowUpBigIcon, + ArrowUpBigTopIcon, + CenterPeekIcon, + CopyIcon, + DeleteIcon, + DuplicateIcon, + FrameIcon, + GroupIcon, + LinkedPageIcon, + OpenInNewIcon, + ResetIcon, +} from '@blocksuite/icons/lit'; + +import { + createLinkedDocFromEdgelessElements, + createLinkedDocFromNote, + notifyDocCreated, + promptDocTitle, +} from '../../../../_common/utils/render-linked-doc.js'; +import type { AttachmentBlockComponent } from '../../../../attachment-block/attachment-block.js'; +import type { BookmarkBlockComponent } from '../../../../bookmark-block/bookmark-block.js'; +import type { ImageBlockComponent } from '../../../../image-block/image-block.js'; +import { duplicate } from '../../../edgeless/utils/clipboard-utils.js'; +import { getSortedCloneElements } from '../../../edgeless/utils/clone-utils.js'; +import { moveConnectors } from '../../../edgeless/utils/connector.js'; +import { deleteElements } from '../../../edgeless/utils/crud.js'; +import type { ElementToolbarMoreMenuContext } from './context.js'; + +type EmbedLinkBlockComponent = + | EmbedGithubBlockComponent + | EmbedFigmaBlockComponent + | EmbedLoomBlockComponent + | EmbedYoutubeBlockComponent; + +type RefreshableBlockComponent = + | EmbedLinkBlockComponent + | ImageBlockComponent + | AttachmentBlockComponent + | BookmarkBlockComponent; + +// Section Group: frame & group +export const sectionGroup: MenuItemGroup<ElementToolbarMoreMenuContext> = { + type: 'section', + items: [ + { + icon: FrameIcon({ width: '20', height: '20' }), + label: 'Frame section', + type: 'create-frame', + action: ({ service, edgeless, std }) => { + const frame = service.frame.createFrameOnSelected(); + if (!frame) return; + + std.getOptional(TelemetryProvider)?.track('CanvasElementAdded', { + control: 'context-menu', + page: 'whiteboard editor', + module: 'toolbar', + segment: 'toolbar', + type: 'frame', + }); + + edgeless.surface.fitToViewport(Bound.deserialize(frame.xywh)); + }, + }, + { + icon: GroupIcon({ width: '20', height: '20' }), + label: 'Group section', + type: 'create-group', + action: ({ service }) => { + service.createGroupFromSelected(); + }, + when: ctx => !ctx.hasFrame(), + }, + ], +}; + +// Reorder Group +export const reorderGroup: MenuItemGroup<ElementToolbarMoreMenuContext> = { + type: 'reorder', + items: [ + { + icon: ArrowUpBigTopIcon({ width: '20', height: '20' }), + label: 'Bring to Front', + type: 'front', + action: ({ service, selectedElements }) => { + selectedElements.forEach(el => { + service.reorderElement(el, 'front'); + }); + }, + }, + { + icon: ArrowUpBigIcon({ width: '20', height: '20' }), + label: 'Bring Forward', + type: 'forward', + action: ({ service, selectedElements }) => { + selectedElements.forEach(el => { + service.reorderElement(el, 'forward'); + }); + }, + }, + { + icon: ArrowDownBigIcon({ width: '20', height: '20' }), + label: 'Send Backward', + type: 'backward', + action: ({ service, selectedElements }) => { + selectedElements.forEach(el => { + service.reorderElement(el, 'backward'); + }); + }, + }, + { + icon: ArrowDownBigBottomIcon({ width: '20', height: '20' }), + label: 'Send to Back', + type: 'back', + action: ({ service, selectedElements }) => { + selectedElements.forEach(el => { + service.reorderElement(el, 'back'); + }); + }, + }, + ], +}; + +// Open Group +export const openGroup: MenuItemGroup<ElementToolbarMoreMenuContext> = { + type: 'open', + items: [ + { + icon: OpenInNewIcon({ width: '20', height: '20' }), + label: 'Open this doc', + type: 'open', + generate: ctx => { + const linkedDocBlock = ctx.getLinkedDocBlock(); + + if (!linkedDocBlock) return; + + const disabled = linkedDocBlock.pageId === ctx.doc.id; + + return { + action: () => { + const blockComponent = ctx.firstBlockComponent; + + if (!blockComponent) return; + if (!('open' in blockComponent)) return; + if (typeof blockComponent.open !== 'function') return; + + blockComponent.open(); + }, + + disabled, + }; + }, + }, + { + icon: CenterPeekIcon({ width: '20', height: '20' }), + label: 'Open in center peek', + type: 'center-peek', + generate: ctx => { + const valid = + ctx.isSingle() && + !!ctx.firstBlockComponent && + isPeekable(ctx.firstBlockComponent); + + if (!valid) return; + + return { + action: () => { + if (!ctx.firstBlockComponent) return; + + peek(ctx.firstBlockComponent); + }, + }; + }, + }, + ], +}; + +// Clipboard Group +export const clipboardGroup: MenuItemGroup<ElementToolbarMoreMenuContext> = { + type: 'clipboard', + items: [ + { + icon: CopyIcon({ width: '20', height: '20' }), + label: 'Copy', + type: 'copy', + action: ({ edgeless }) => edgeless.clipboardController.copy(), + }, + { + icon: DuplicateIcon({ width: '20', height: '20' }), + label: 'Duplicate', + type: 'duplicate', + action: ({ edgeless, selectedElements }) => + duplicate(edgeless, selectedElements), + }, + { + icon: ResetIcon({ width: '20', height: '20' }), + label: 'Reload', + type: 'reload', + generate: ctx => { + if (ctx.hasFrame()) { + return; + } + + const blocks = ctx.selection.surfaceSelections + .map(s => ctx.getBlockComponent(s.blockId)) + .filter(block => !!block) + .filter(block => ctx.refreshable(block.model)); + + if ( + !blocks.length || + blocks.length !== ctx.selection.surfaceSelections.length + ) { + return; + } + + return { + action: () => + blocks.forEach(block => + (block as RefreshableBlockComponent).refreshData() + ), + }; + }, + }, + ], +}; + +// Conversions Group +export const conversionsGroup: MenuItemGroup<ElementToolbarMoreMenuContext> = { + type: 'conversions', + items: [ + { + icon: LinkedPageIcon({ width: '20', height: '20' }), + label: 'Turn into linked doc', + type: 'turn-into-linked-doc', + action: async ctx => { + const { doc, service, surface, host, std } = ctx; + const element = ctx.getNoteBlock(); + if (!element) return; + + const title = await promptDocTitle(host); + if (title === null) return; + + const linkedDoc = createLinkedDocFromNote(doc, element, title); + // insert linked doc card + const cardId = service.addBlock( + 'affine:embed-synced-doc', + { + xywh: element.xywh, + style: 'syncedDoc', + pageId: linkedDoc.id, + index: element.index, + }, + surface.model.id + ); + std.getOptional(TelemetryProvider)?.track('CanvasElementAdded', { + control: 'context-menu', + page: 'whiteboard editor', + module: 'toolbar', + segment: 'toolbar', + type: 'embed-synced-doc', + }); + std.getOptional(TelemetryProvider)?.track('DocCreated', { + control: 'turn into linked doc', + page: 'whiteboard editor', + module: 'format toolbar', + type: 'embed-linked-doc', + }); + std.getOptional(TelemetryProvider)?.track('LinkedDocCreated', { + control: 'turn into linked doc', + page: 'whiteboard editor', + module: 'format toolbar', + type: 'embed-linked-doc', + other: 'new doc', + }); + moveConnectors(element.id, cardId, service); + // delete selected note + doc.transact(() => { + doc.deleteBlock(element); + }); + service.selection.set({ + elements: [cardId], + editing: false, + }); + }, + when: ctx => !!ctx.getNoteBlock(), + }, + { + icon: LinkedPageIcon({ width: '20', height: '20' }), + label: 'Create linked doc', + type: 'create-linked-doc', + action: async ({ + doc, + selection, + service, + surface, + edgeless, + host, + std, + }) => { + const title = await promptDocTitle(host); + if (title === null) return; + + const elements = getSortedCloneElements(selection.selectedElements); + const linkedDoc = createLinkedDocFromEdgelessElements( + host, + elements, + title + ); + // delete selected elements + doc.transact(() => { + deleteElements(edgeless, elements); + }); + // insert linked doc card + const width = 364; + const height = 390; + const bound = getCommonBoundWithRotation(elements); + const cardId = service.addBlock( + 'affine:embed-linked-doc', + { + xywh: `[${bound.center[0] - width / 2}, ${bound.center[1] - height / 2}, ${width}, ${height}]`, + style: 'vertical', + pageId: linkedDoc.id, + }, + surface.model.id + ); + selection.set({ + elements: [cardId], + editing: false, + }); + std.getOptional(TelemetryProvider)?.track('CanvasElementAdded', { + control: 'context-menu', + page: 'whiteboard editor', + module: 'toolbar', + segment: 'toolbar', + type: 'embed-linked-doc', + }); + std.getOptional(TelemetryProvider)?.track('DocCreated', { + control: 'create linked doc', + page: 'whiteboard editor', + module: 'format toolbar', + type: 'embed-linked-doc', + }); + std.getOptional(TelemetryProvider)?.track('LinkedDocCreated', { + control: 'create linked doc', + page: 'whiteboard editor', + module: 'format toolbar', + type: 'embed-linked-doc', + other: 'new doc', + }); + + notifyDocCreated(host, doc); + }, + when: ctx => !(ctx.getLinkedDocBlock() || ctx.getNoteBlock()), + }, + ], +}; + +// Delete Group +export const deleteGroup: MenuItemGroup<ElementToolbarMoreMenuContext> = { + type: 'delete', + items: [ + { + icon: DeleteIcon({ width: '20', height: '20' }), + label: 'Delete', + type: 'delete', + action: ({ doc, selection, selectedElements, edgeless }) => { + doc.captureSync(); + deleteElements(edgeless, selectedElements); + + selection.set({ + elements: [], + editing: false, + }); + }, + }, + ], +}; + +export const BUILT_IN_GROUPS = [ + sectionGroup, + reorderGroup, + openGroup, + clipboardGroup, + conversionsGroup, + deleteGroup, +]; diff --git a/blocksuite/blocks/src/root-block/widgets/element-toolbar/more-menu/context.ts b/blocksuite/blocks/src/root-block/widgets/element-toolbar/more-menu/context.ts new file mode 100644 index 0000000000..d1a3b1eeaa --- /dev/null +++ b/blocksuite/blocks/src/root-block/widgets/element-toolbar/more-menu/context.ts @@ -0,0 +1,150 @@ +import type { SurfaceBlockComponent } from '@blocksuite/affine-block-surface'; +import { + GfxPrimitiveElementModel, + type GfxSelectionManager, +} from '@blocksuite/block-std/gfx'; +import type { BlockModel } from '@blocksuite/store'; + +import { MenuContext } from '../../../configs/toolbar.js'; +import type { EdgelessRootBlockComponent } from '../../../edgeless/edgeless-root-block.js'; +import type { EdgelessRootService } from '../../../edgeless/edgeless-root-service.js'; +import { + isAttachmentBlock, + isBookmarkBlock, + isEmbeddedLinkBlock, + isEmbedLinkedDocBlock, + isEmbedSyncedDocBlock, + isFrameBlock, + isImageBlock, + isNoteBlock, +} from '../../../edgeless/utils/query.js'; + +export class ElementToolbarMoreMenuContext extends MenuContext { + #empty = true; + + #includedFrame = false; + + #multiple = false; + + #single = false; + + edgeless!: EdgelessRootBlockComponent; + + get doc() { + return this.edgeless.doc; + } + + get firstBlockComponent() { + return this.getBlockComponent(this.firstElement.id); + } + + override get firstElement() { + return this.selection.firstElement; + } + + get host() { + return this.edgeless.host; + } + + get selectedBlockModels() { + const [result, { selectedModels }] = this.std.command + .chain() + .getSelectedModels() + .run(); + + if (!result) return []; + + return selectedModels ?? []; + } + + get selectedElements() { + return this.selection.selectedElements; + } + + get selection(): GfxSelectionManager { + return this.service.selection; + } + + get service(): EdgelessRootService { + return this.edgeless.service; + } + + get std() { + return this.edgeless.host.std; + } + + get surface(): SurfaceBlockComponent { + return this.edgeless.surface; + } + + get view() { + return this.host.view; + } + + constructor(edgeless: EdgelessRootBlockComponent) { + super(); + this.edgeless = edgeless; + + const selectedElements = this.selection.selectedElements; + const len = selectedElements.length; + + this.#empty = len === 0; + this.#single = len === 1; + this.#multiple = !this.#empty && !this.#single; + this.#includedFrame = !this.#empty && selectedElements.some(isFrameBlock); + } + + getBlockComponent(id: string) { + return this.view.getBlock(id); + } + + getLinkedDocBlock() { + const valid = + this.#single && + (isEmbedLinkedDocBlock(this.firstElement) || + isEmbedSyncedDocBlock(this.firstElement)); + + if (!valid) return null; + + return this.firstElement; + } + + getNoteBlock() { + const valid = this.#single && isNoteBlock(this.firstElement); + + if (!valid) return null; + + return this.firstElement; + } + + hasFrame() { + return this.#includedFrame; + } + + override isElement() { + return ( + this.#single && this.firstElement instanceof GfxPrimitiveElementModel + ); + } + + override isEmpty() { + return this.#empty; + } + + isMultiple() { + return this.#multiple; + } + + isSingle() { + return this.#single; + } + + refreshable(model: BlockModel) { + return ( + isImageBlock(model) || + isBookmarkBlock(model) || + isAttachmentBlock(model) || + isEmbeddedLinkBlock(model) + ); + } +} diff --git a/blocksuite/blocks/src/root-block/widgets/element-toolbar/release-from-group-button.ts b/blocksuite/blocks/src/root-block/widgets/element-toolbar/release-from-group-button.ts new file mode 100644 index 0000000000..c0960d21a5 --- /dev/null +++ b/blocksuite/blocks/src/root-block/widgets/element-toolbar/release-from-group-button.ts @@ -0,0 +1,54 @@ +import { ReleaseFromGroupButtonIcon } from '@blocksuite/affine-components/icons'; +import { GroupElementModel } from '@blocksuite/affine-model'; +import { WithDisposable } from '@blocksuite/global/utils'; +import { html, LitElement } from 'lit'; +import { property } from 'lit/decorators.js'; + +import type { EdgelessRootBlockComponent } from '../../edgeless/edgeless-root-block.js'; + +export class EdgelessReleaseFromGroupButton extends WithDisposable(LitElement) { + private _releaseFromGroup() { + const service = this.edgeless.service; + const element = service.selection.firstElement; + + if (!(element.group instanceof GroupElementModel)) return; + + const group = element.group; + + // eslint-disable-next-line + group.removeChild(element); + + element.index = service.layer.generateIndex(); + + const parent = group.group; + if (parent instanceof GroupElementModel) { + parent.addChild(element); + } + } + + protected override render() { + return html` + <editor-icon-button + aria-label="Release from group" + .tooltip=${'Release from group'} + .iconSize=${'20px'} + @click=${() => this._releaseFromGroup()} + > + ${ReleaseFromGroupButtonIcon} + </editor-icon-button> + `; + } + + @property({ attribute: false }) + accessor edgeless!: EdgelessRootBlockComponent; +} + +export function renderReleaseFromGroupButton( + edgeless: EdgelessRootBlockComponent +) { + return html` + <edgeless-release-from-group-button + .edgeless=${edgeless} + ></edgeless-release-from-group-button> + `; +} diff --git a/blocksuite/blocks/src/root-block/widgets/embed-card-toolbar/config.ts b/blocksuite/blocks/src/root-block/widgets/embed-card-toolbar/config.ts new file mode 100644 index 0000000000..d0098fa237 --- /dev/null +++ b/blocksuite/blocks/src/root-block/widgets/embed-card-toolbar/config.ts @@ -0,0 +1,102 @@ +import { + CopyIcon, + DeleteIcon, + DuplicateIcon, + RefreshIcon, +} from '@blocksuite/affine-components/icons'; +import { toast } from '@blocksuite/affine-components/toast'; +import type { MenuItemGroup } from '@blocksuite/affine-components/toolbar'; +import { getBlockProps } from '@blocksuite/affine-shared/utils'; +import { Slice } from '@blocksuite/store'; + +import { + isAttachmentBlock, + isBookmarkBlock, + isEmbeddedLinkBlock, + isImageBlock, +} from '../../edgeless/utils/query.js'; +import type { EmbedCardToolbarContext } from './context.js'; + +export const BUILT_IN_GROUPS: MenuItemGroup<EmbedCardToolbarContext>[] = [ + { + type: 'clipboard', + items: [ + { + type: 'copy', + label: 'Copy', + icon: CopyIcon, + disabled: ({ doc }) => doc.readonly, + action: async ({ host, doc, std, blockComponent, close }) => { + const slice = Slice.fromModels(doc, [blockComponent.model]); + await std.clipboard.copySlice(slice); + toast(host, 'Copied link to clipboard'); + close(); + }, + }, + { + type: 'duplicate', + label: 'Duplicate', + icon: DuplicateIcon, + disabled: ({ doc }) => doc.readonly, + action: ({ doc, blockComponent, close }) => { + const model = blockComponent.model; + const blockProps = getBlockProps(model); + const { + width: _width, + height: _height, + xywh: _xywh, + rotate: _rotate, + zIndex: _zIndex, + ...duplicateProps + } = blockProps; + + const parent = doc.getParent(model); + const index = parent?.children.indexOf(model); + doc.addBlock( + model.flavour as BlockSuite.Flavour, + duplicateProps, + parent, + index + ); + close(); + }, + }, + { + type: 'reload', + label: 'Reload', + icon: RefreshIcon, + disabled: ({ doc }) => doc.readonly, + action: ({ blockComponent, close }) => { + blockComponent?.refreshData(); + close(); + }, + when: ({ blockComponent }) => { + const model = blockComponent.model; + + return ( + !!model && + (isImageBlock(model) || + isBookmarkBlock(model) || + isAttachmentBlock(model) || + isEmbeddedLinkBlock(model)) + ); + }, + }, + ], + }, + { + type: 'delete', + items: [ + { + type: 'delete', + label: 'Delete', + icon: DeleteIcon, + disabled: ({ doc }) => doc.readonly, + action: ({ doc, blockComponent, close }) => { + doc.deleteBlock(blockComponent.model); + close(); + }, + }, + ], + }, +]; diff --git a/blocksuite/blocks/src/root-block/widgets/embed-card-toolbar/context.ts b/blocksuite/blocks/src/root-block/widgets/embed-card-toolbar/context.ts new file mode 100644 index 0000000000..9403d5afcc --- /dev/null +++ b/blocksuite/blocks/src/root-block/widgets/embed-card-toolbar/context.ts @@ -0,0 +1,44 @@ +import type { EmbedBlockComponent } from '../../../_common/components/embed-card/type.js'; +import { MenuContext } from '../../configs/toolbar.js'; + +export class EmbedCardToolbarContext extends MenuContext { + override close = () => { + this.abortController.abort(); + }; + + get doc() { + return this.blockComponent.doc; + } + + get host() { + return this.blockComponent.host; + } + + get selectedBlockModels() { + if (this.blockComponent.model) return [this.blockComponent.model]; + return []; + } + + get std() { + return this.host.std; + } + + constructor( + public blockComponent: EmbedBlockComponent, + public abortController: AbortController + ) { + super(); + } + + isEmpty() { + return false; + } + + isMultiple() { + return false; + } + + isSingle() { + return true; + } +} diff --git a/blocksuite/blocks/src/root-block/widgets/embed-card-toolbar/embed-card-toolbar.ts b/blocksuite/blocks/src/root-block/widgets/embed-card-toolbar/embed-card-toolbar.ts new file mode 100644 index 0000000000..8cc1ee0aad --- /dev/null +++ b/blocksuite/blocks/src/root-block/widgets/embed-card-toolbar/embed-card-toolbar.ts @@ -0,0 +1,879 @@ +import { getDocContentWithMaxLength } from '@blocksuite/affine-block-embed'; +import { + CaptionIcon, + CenterPeekIcon, + CopyIcon, + EditIcon, + ExpandFullSmallIcon, + MoreVerticalIcon, + OpenIcon, + PaletteIcon, + SmallArrowDownIcon, +} from '@blocksuite/affine-components/icons'; +import { notifyLinkedDocSwitchedToEmbed } from '@blocksuite/affine-components/notification'; +import { isPeekable, peek } from '@blocksuite/affine-components/peek'; +import { toast } from '@blocksuite/affine-components/toast'; +import { + cloneGroups, + type MenuItem, + type MenuItemGroup, + renderGroups, + renderToolbarSeparator, +} from '@blocksuite/affine-components/toolbar'; +import { + type AliasInfo, + type BookmarkBlockModel, + BookmarkStyles, + type EmbedGithubModel, + type EmbedLinkedDocModel, + type RootBlockModel, +} from '@blocksuite/affine-model'; +import { + EmbedOptionProvider, + type EmbedOptions, + GenerateDocUrlProvider, + type GenerateDocUrlService, + type LinkEventType, + type TelemetryEvent, + TelemetryProvider, + ThemeProvider, +} from '@blocksuite/affine-shared/services'; +import { getHostName, referenceToNode } from '@blocksuite/affine-shared/utils'; +import { type BlockStdScope, WidgetComponent } from '@blocksuite/block-std'; +import { type BlockModel, DocCollection } from '@blocksuite/store'; +import { autoUpdate, computePosition, flip, offset } from '@floating-ui/dom'; +import { html, nothing, type TemplateResult } from 'lit'; +import { query, state } from 'lit/decorators.js'; +import { classMap } from 'lit/directives/class-map.js'; +import { ifDefined } from 'lit/directives/if-defined.js'; +import { join } from 'lit/directives/join.js'; +import { repeat } from 'lit/directives/repeat.js'; + +import { toggleEmbedCardCaptionEditModal } from '../../../_common/components/embed-card/modal/embed-card-caption-edit-modal.js'; +import { toggleEmbedCardEditModal } from '../../../_common/components/embed-card/modal/embed-card-edit-modal.js'; +import { + type EmbedBlockComponent, + type EmbedModel, + isEmbedCardBlockComponent, + isInternalEmbedModel, +} from '../../../_common/components/embed-card/type.js'; +import type { EmbedCardStyle } from '../../../_common/types.js'; +import { getEmbedCardIcons } from '../../../_common/utils/url.js'; +import { getMoreMenuConfig } from '../../configs/toolbar.js'; +import { + isBookmarkBlock, + isEmbedGithubBlock, + isEmbedHtmlBlock, + isEmbedLinkedDocBlock, + isEmbedSyncedDocBlock, +} from '../../edgeless/utils/query.js'; +import type { RootBlockComponent } from '../../types.js'; +import { BUILT_IN_GROUPS } from './config.js'; +import { EmbedCardToolbarContext } from './context.js'; +import { embedCardToolbarStyle } from './styles.js'; + +export const AFFINE_EMBED_CARD_TOOLBAR_WIDGET = 'affine-embed-card-toolbar'; + +export class EmbedCardToolbar extends WidgetComponent< + RootBlockModel, + RootBlockComponent +> { + static override styles = embedCardToolbarStyle; + + private _abortController = new AbortController(); + + private _copyUrl = () => { + const model = this.focusModel; + if (!model) return; + + let url!: ReturnType<GenerateDocUrlService['generateDocUrl']>; + const isInternal = isInternalEmbedModel(model); + + if ('url' in model) { + url = model.url; + } else if (isInternal) { + url = this.std + .getOptional(GenerateDocUrlProvider) + ?.generateDocUrl(model.pageId, model.params); + } + + if (!url) return; + + navigator.clipboard.writeText(url).catch(console.error); + toast(this.std.host, 'Copied link to clipboard'); + + track(this.std, model, this._viewType, 'CopiedLink', { + control: 'copy link', + }); + }; + + private _embedOptions: EmbedOptions | null = null; + + private _openEditPopup = (e: MouseEvent) => { + e.stopPropagation(); + + const model = this.focusModel; + if (!model || isEmbedHtmlBlock(model)) return; + + const originalDocInfo = this._originalDocInfo; + + this._hide(); + + toggleEmbedCardEditModal(this.host, model, this._viewType, originalDocInfo); + + track(this.std, model, this._viewType, 'OpenedAliasPopup', { + control: 'edit', + }); + }; + + private _resetAbortController = () => { + this._abortController.abort(); + this._abortController = new AbortController(); + }; + + private _showCaption = () => { + const focusBlock = this.focusBlock; + if (!focusBlock) { + return; + } + try { + focusBlock.captionEditor?.show(); + } catch { + toggleEmbedCardCaptionEditModal(focusBlock); + } + this._resetAbortController(); + + const model = this.focusModel; + if (!model) return; + + track(this.std, model, this._viewType, 'OpenedCaptionEditor', { + control: 'add caption', + }); + }; + + private _toggleCardStyleSelector = (e: Event) => { + const opened = (e as CustomEvent<boolean>).detail; + if (!opened) return; + + const model = this.focusModel; + if (!model) return; + + track(this.std, model, this._viewType, 'OpenedCardStyleSelector', { + control: 'switch card style', + }); + }; + + private _toggleViewSelector = (e: Event) => { + const opened = (e as CustomEvent<boolean>).detail; + if (!opened) return; + + const model = this.focusModel; + if (!model) return; + + track(this.std, model, this._viewType, 'OpenedViewSelector', { + control: 'switch view', + }); + }; + + private _trackViewSelected = (type: string) => { + const model = this.focusModel; + if (!model) return; + + track(this.std, model, this._viewType, 'SelectedView', { + control: 'selected view', + type: `${type} view`, + }); + }; + + /* + * Caches the more menu items. + * Currently only supports configuring more menu. + */ + moreGroups: MenuItemGroup<EmbedCardToolbarContext>[] = + cloneGroups(BUILT_IN_GROUPS); + + private get _canConvertToEmbedView() { + // synced doc entry controlled by awareness flag + if (this.focusModel && isEmbedLinkedDocBlock(this.focusModel)) { + const isSyncedDocEnabled = this.doc.awarenessStore.getFlag( + 'enable_synced_doc_block' + ); + if (!isSyncedDocEnabled) { + return false; + } + } + + if (!this.focusBlock) return false; + + return ( + 'convertToEmbed' in this.focusBlock || + this._embedOptions?.viewType === 'embed' + ); + } + + private get _canShowUrlOptions() { + return this.focusModel && 'url' in this.focusModel && this._isCardView; + } + + private get _embedViewButtonDisabled() { + if (this.doc.readonly) { + return true; + } + return ( + this.focusModel && + this.focusBlock && + isEmbedLinkedDocBlock(this.focusModel) && + (referenceToNode(this.focusModel) || + !!this.focusBlock.closest('affine-embed-synced-doc-block') || + this.focusModel.pageId === this.doc.id) + ); + } + + private get _isCardView() { + return ( + this.focusModel && + (isBookmarkBlock(this.focusModel) || + isEmbedLinkedDocBlock(this.focusModel) || + this._embedOptions?.viewType === 'card') + ); + } + + private get _isEmbedView() { + return ( + this.focusModel && + !isBookmarkBlock(this.focusModel) && + (isEmbedSyncedDocBlock(this.focusModel) || + this._embedOptions?.viewType === 'embed') + ); + } + + get _openButtonDisabled() { + return ( + this.focusModel && + isEmbedLinkedDocBlock(this.focusModel) && + this.focusModel.pageId === this.doc.id + ); + } + + get _originalDocInfo(): AliasInfo | undefined { + const model = this.focusModel; + if (!model) return undefined; + + const doc = isInternalEmbedModel(model) + ? this.std.collection.getDoc(model.pageId) + : null; + + if (doc) { + const title = doc.meta?.title; + const description = isEmbedLinkedDocBlock(model) + ? getDocContentWithMaxLength(doc) + : undefined; + return { title, description }; + } + + return undefined; + } + + get _originalDocTitle() { + const model = this.focusModel; + if (!model) return undefined; + + const doc = isInternalEmbedModel(model) + ? this.std.collection.getDoc(model.pageId) + : null; + + return doc?.meta?.title || 'Untitled'; + } + + private get _selection() { + return this.host.selection; + } + + private get _viewType(): 'inline' | 'embed' | 'card' { + if (this._isCardView) { + return 'card'; + } + + if (this._isEmbedView) { + return 'embed'; + } + + return 'inline'; + } + + get focusModel(): EmbedModel | undefined { + return this.focusBlock?.model; + } + + private _canShowCardStylePanel( + model: BlockModel + ): model is BookmarkBlockModel | EmbedGithubModel | EmbedLinkedDocModel { + return ( + isBookmarkBlock(model) || + isEmbedGithubBlock(model) || + isEmbedLinkedDocBlock(model) + ); + } + + private _cardStyleSelector() { + const model = this.focusModel; + + if (!model) return nothing; + if (!this._canShowCardStylePanel(model)) return nothing; + + const theme = this.std.get(ThemeProvider).theme; + const { EmbedCardHorizontalIcon, EmbedCardListIcon } = + getEmbedCardIcons(theme); + + const buttons = [ + { + type: 'horizontal', + label: 'Large horizontal style', + icon: EmbedCardHorizontalIcon, + }, + { + type: 'list', + label: 'Small horizontal style', + icon: EmbedCardListIcon, + }, + ] as { + type: EmbedCardStyle; + label: string; + icon: TemplateResult<1>; + }[]; + + return html` + <editor-menu-button + class="card-style-select" + .contentPadding=${'8px'} + .button=${html` + <editor-icon-button aria-label="Card style" .tooltip=${'Card style'}> + ${PaletteIcon} + </editor-icon-button> + `} + @toggle=${this._toggleCardStyleSelector} + > + <div> + ${repeat( + buttons, + button => button.type, + ({ type, label, icon }) => html` + <icon-button + width="76px" + height="76px" + aria-label=${label} + class=${classMap({ + selected: model.style === type, + })} + @click=${() => this._setEmbedCardStyle(type)} + > + ${icon} + <affine-tooltip .offset=${4}>${label}</affine-tooltip> + </icon-button> + ` + )} + </div> + </editor-menu-button> + `; + } + + private _convertToCardView() { + if (this._isCardView) { + return; + } + if (!this.focusBlock) { + return; + } + + if ('convertToCard' in this.focusBlock) { + this.focusBlock.convertToCard(); + return; + } + + if (!this.focusModel || !('url' in this.focusModel)) { + return; + } + + const targetModel = this.focusModel; + const { doc, url, style, caption } = targetModel; + + let targetFlavour = 'affine:bookmark', + targetStyle = style; + + if (this._embedOptions && this._embedOptions.viewType === 'card') { + const { flavour, styles } = this._embedOptions; + targetFlavour = flavour; + targetStyle = styles.includes(style) ? style : styles[0]; + } else { + targetStyle = BookmarkStyles.includes(style) + ? style + : BookmarkStyles.filter( + style => style !== 'vertical' && style !== 'cube' + )[0]; + } + + const parent = doc.getParent(targetModel); + if (!parent) return; + const index = parent.children.indexOf(targetModel); + + doc.addBlock( + targetFlavour as never, + { url, style: targetStyle, caption }, + parent, + index + ); + this.std.selection.setGroup('note', []); + doc.deleteBlock(targetModel); + } + + private _convertToEmbedView() { + if (this._isEmbedView) { + return; + } + + if (!this.focusBlock) { + return; + } + + if ('convertToEmbed' in this.focusBlock) { + const referenceInfo = this.focusBlock.referenceInfo$.peek(); + + this.focusBlock.convertToEmbed(); + + if (referenceInfo.title || referenceInfo.description) { + notifyLinkedDocSwitchedToEmbed(this.std); + } + + return; + } + + if (!this.focusModel || !('url' in this.focusModel)) { + return; + } + + const targetModel = this.focusModel; + const { doc, url, style, caption } = targetModel; + + if (!this._embedOptions || this._embedOptions.viewType !== 'embed') { + return; + } + const { flavour, styles } = this._embedOptions; + + const targetStyle = styles.includes(style) + ? style + : styles.filter(style => style !== 'vertical' && style !== 'cube')[0]; + + const parent = doc.getParent(targetModel); + if (!parent) return; + const index = parent.children.indexOf(targetModel); + + doc.addBlock( + flavour as never, + { url, style: targetStyle, caption }, + parent, + index + ); + + this.std.selection.setGroup('note', []); + doc.deleteBlock(targetModel); + } + + private _hide() { + this._resetAbortController(); + this.focusBlock = null; + this.hide = true; + } + + private _moreActions() { + if (!this.focusBlock) return nothing; + const context = new EmbedCardToolbarContext( + this.focusBlock, + this._abortController + ); + return renderGroups(this.moreGroups, context); + } + + private _openMenuButton() { + const buttons: MenuItem[] = []; + + if ( + this.focusModel && + (isEmbedLinkedDocBlock(this.focusModel) || + isEmbedSyncedDocBlock(this.focusModel)) + ) { + buttons.push({ + type: 'open-this-doc', + label: 'Open this doc', + icon: ExpandFullSmallIcon, + action: () => this.focusBlock?.open(), + }); + } + + // open in new tab + + const element = this.focusBlock; + if (element && isPeekable(element)) { + buttons.push({ + type: 'open-in-center-peek', + label: 'Open in center peek', + icon: CenterPeekIcon, + action: () => peek(element), + }); + } + + // open in split view + + if (buttons.length === 0) { + return nothing; + } + + return html` + <editor-menu-button + .contentPadding=${'8px'} + .button=${html` + <editor-icon-button + aria-label="Open" + .justify=${'space-between'} + .labelHeight=${'20px'} + > + ${OpenIcon}${SmallArrowDownIcon} + </editor-icon-button> + `} + > + <div data-size="small" data-orientation="vertical"> + ${repeat( + buttons, + button => button.label, + ({ label, icon, action, disabled }) => html` + <editor-menu-action + aria-label=${ifDefined(label)} + ?disabled=${disabled} + @click=${action} + > + ${icon}<span class="label">${label}</span> + </editor-menu-action> + ` + )} + </div> + </editor-menu-button> + `; + } + + private _setEmbedCardStyle(style: EmbedCardStyle) { + const model = this.focusModel; + if (!model) return; + + model.doc.updateBlock(model, { style }); + this.requestUpdate(); + this._abortController.abort(); + + track(this.std, model, this._viewType, 'SelectedCardStyle', { + control: 'select card style', + type: style, + }); + } + + private _show() { + if (!this.focusBlock) { + return; + } + this.hide = false; + this._abortController.signal.addEventListener( + 'abort', + autoUpdate(this.focusBlock, this, () => { + if (!this.focusBlock) { + return; + } + computePosition(this.focusBlock, this, { + placement: 'top-start', + middleware: [flip(), offset(8)], + }) + .then(({ x, y }) => { + this.style.left = `${x}px`; + this.style.top = `${y}px`; + }) + .catch(console.error); + }) + ); + } + + private _turnIntoInlineView() { + if (this.focusBlock && 'covertToInline' in this.focusBlock) { + this.focusBlock.covertToInline(); + return; + } + + if (!this.focusModel || !('url' in this.focusModel)) { + return; + } + + const targetModel = this.focusModel; + const { doc, title, caption, url } = targetModel; + const parent = doc.getParent(targetModel); + const index = parent?.children.indexOf(targetModel); + + const yText = new DocCollection.Y.Text(); + const insert = title || caption || url; + yText.insert(0, insert); + yText.format(0, insert.length, { link: url }); + const text = new doc.Text(yText); + doc.addBlock( + 'affine:paragraph', + { + text, + }, + parent, + index + ); + + doc.deleteBlock(targetModel); + } + + private _viewSelector() { + const buttons = []; + + buttons.push({ + type: 'inline', + label: 'Inline view', + action: () => this._turnIntoInlineView(), + disabled: this.doc.readonly, + }); + + buttons.push({ + type: 'card', + label: 'Card view', + action: () => this._convertToCardView(), + disabled: this.doc.readonly, + }); + + if (this._canConvertToEmbedView || this._isEmbedView) { + buttons.push({ + type: 'embed', + label: 'Embed view', + action: () => this._convertToEmbedView(), + disabled: this.doc.readonly || this._embedViewButtonDisabled, + }); + } + + return html` + <editor-menu-button + .contentPadding=${'8px'} + .button=${html` + <editor-icon-button + aria-label="Switch view" + .justify=${'space-between'} + .labelHeight=${'20px'} + .iconContainerWidth=${'110px'} + > + <div class="label"> + <span style="text-transform: capitalize">${this._viewType}</span> + view + </div> + ${SmallArrowDownIcon} + </editor-icon-button> + `} + @toggle=${this._toggleViewSelector} + > + <div data-size="small" data-orientation="vertical"> + ${repeat( + buttons, + button => button.type, + ({ type, label, action, disabled }) => html` + <editor-menu-action + data-testid=${`link-to-${type}`} + aria-label=${ifDefined(label)} + ?data-selected=${this._viewType === type} + ?disabled=${disabled || this._viewType === type} + @click=${() => { + action(); + this._trackViewSelected(type); + this._hide(); + }} + > + ${label} + </editor-menu-action> + ` + )} + </div> + </editor-menu-button> + `; + } + + override connectedCallback() { + super.connectedCallback(); + + this.moreGroups = getMoreMenuConfig(this.std).configure(this.moreGroups); + + this.disposables.add( + this._selection.slots.changed.on(() => { + const hasTextSelection = this._selection.find('text'); + if (hasTextSelection) { + this._hide(); + return; + } + + const blockSelections = this._selection.filter('block'); + if (!blockSelections || blockSelections.length !== 1) { + this._hide(); + return; + } + + const block = this.std.view.getBlock(blockSelections[0].blockId); + if (!block || !isEmbedCardBlockComponent(block)) { + this._hide(); + return; + } + + this.focusBlock = block as EmbedBlockComponent; + this._show(); + }) + ); + } + + override render() { + if (this.hide) return nothing; + + const model = this.focusModel; + if (!model) return nothing; + + this._embedOptions = + 'url' in model + ? this.std.get(EmbedOptionProvider).getEmbedBlockOptions(model.url) + : null; + + const hasUrl = this._canShowUrlOptions && 'url' in model; + + const buttons = [ + this._openMenuButton(), + + hasUrl + ? html` + <a + class="affine-link-preview" + href=${model.url} + rel="noopener noreferrer" + target="_blank" + > + <span>${getHostName(model.url)}</span> + </a> + ` + : nothing, + + // internal embed model + isEmbedLinkedDocBlock(model) && model.title + ? html` + <editor-icon-button + class="doc-title" + aria-label="Doc title" + .hover=${false} + .labelHeight=${'20px'} + .tooltip=${this._originalDocTitle} + @click=${this.focusBlock?.open} + > + <span class="label">${this._originalDocTitle}</span> + </editor-icon-button> + ` + : nothing, + + isEmbedHtmlBlock(model) + ? nothing + : html` + <editor-icon-button + aria-label="Copy link" + data-testid="copy-link" + .tooltip=${'Copy link'} + @click=${this._copyUrl} + > + ${CopyIcon} + </editor-icon-button> + + <editor-icon-button + aria-label="Edit" + data-testid="edit" + .tooltip=${'Edit'} + ?disabled=${this.doc.readonly} + @click=${this._openEditPopup} + > + ${EditIcon} + </editor-icon-button> + `, + + this._viewSelector(), + + this._cardStyleSelector(), + + html` + <editor-icon-button + aria-label="Caption" + .tooltip=${'Add Caption'} + ?disabled=${this.doc.readonly} + @click=${this._showCaption} + > + ${CaptionIcon} + </editor-icon-button> + `, + + html` + <editor-menu-button + .contentPadding=${'8px'} + .button=${html` + <editor-icon-button aria-label="More" .tooltip=${'More'}> + ${MoreVerticalIcon} + </editor-icon-button> + `} + > + <div data-size="large" data-orientation="vertical"> + ${this._moreActions()} + </div> + </editor-menu-button> + `, + ]; + + return html` + <editor-toolbar class="embed-card-toolbar"> + ${join( + buttons.filter(button => button !== nothing), + renderToolbarSeparator + )} + </editor-toolbar> + `; + } + + @query('.embed-card-toolbar-button.card-style') + accessor cardStyleButton: HTMLElement | null = null; + + @query('.embed-card-toolbar') + accessor embedCardToolbarElement!: HTMLElement; + + @state() + accessor focusBlock: EmbedBlockComponent | null = null; + + @state() + accessor hide: boolean = true; + + @query('.embed-card-toolbar-button.more-button') + accessor moreButton: HTMLElement | null = null; +} + +declare global { + interface HTMLElementTagNameMap { + [AFFINE_EMBED_CARD_TOOLBAR_WIDGET]: EmbedCardToolbar; + } +} + +function track( + std: BlockStdScope, + model: EmbedModel, + viewType: string, + event: LinkEventType, + props: Partial<TelemetryEvent> +) { + std.getOptional(TelemetryProvider)?.track(event, { + segment: 'toolbar', + page: 'doc editor', + module: 'embed card toolbar', + type: `${viewType} view`, + category: isInternalEmbedModel(model) ? 'linked doc' : 'link', + ...props, + }); +} diff --git a/blocksuite/blocks/src/root-block/widgets/embed-card-toolbar/styles.ts b/blocksuite/blocks/src/root-block/widgets/embed-card-toolbar/styles.ts new file mode 100644 index 0000000000..c042746bcf --- /dev/null +++ b/blocksuite/blocks/src/root-block/widgets/embed-card-toolbar/styles.ts @@ -0,0 +1,67 @@ +import { css } from 'lit'; + +export const embedCardToolbarStyle = css` + :host { + position: absolute; + top: 0; + left: 0; + z-index: var(--affine-z-index-popover); + } + + .affine-link-preview { + display: flex; + justify-content: flex-start; + min-width: 60px; + max-width: 140px; + padding: var(--1, 0px); + border-radius: var(--1, 0px); + opacity: var(--add, 1); + user-select: none; + cursor: pointer; + + color: var(--affine-link-color); + font-feature-settings: + 'clig' off, + 'liga' off; + font-family: var(--affine-font-family); + font-size: var(--affine-font-sm); + font-style: normal; + font-weight: 400; + text-decoration: none; + text-wrap: nowrap; + } + + .affine-link-preview > span { + display: inline-block; + -webkit-line-clamp: 1; + -webkit-box-orient: vertical; + + text-overflow: ellipsis; + overflow: hidden; + opacity: var(--add, 1); + } + + .card-style-select icon-button.selected { + border: 1px solid var(--affine-brand-color); + } + + editor-icon-button.doc-title .label { + max-width: 110px; + display: inline-block; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + user-select: none; + cursor: pointer; + color: var(--affine-link-color); + font-feature-settings: + 'clig' off, + 'liga' off; + font-family: var(--affine-font-family); + font-size: var(--affine-font-sm); + font-style: normal; + font-weight: 400; + text-decoration: none; + text-wrap: nowrap; + } +`; diff --git a/blocksuite/blocks/src/root-block/widgets/format-bar/components/config-renderer.ts b/blocksuite/blocks/src/root-block/widgets/format-bar/components/config-renderer.ts new file mode 100644 index 0000000000..cf8fd35753 --- /dev/null +++ b/blocksuite/blocks/src/root-block/widgets/format-bar/components/config-renderer.ts @@ -0,0 +1,95 @@ +import { isFormatSupported } from '@blocksuite/affine-components/rich-text'; +import { renderToolbarSeparator } from '@blocksuite/affine-components/toolbar'; +import { html, type TemplateResult } from 'lit'; + +import type { AffineFormatBarWidget } from '../format-bar.js'; +import { HighlightButton } from './highlight/highlight-button.js'; +import { ParagraphButton } from './paragraph-button.js'; + +export function ConfigRenderer(formatBar: AffineFormatBarWidget) { + return ( + formatBar.configItems + .filter(item => { + if (item.type === 'paragraph-action') { + return false; + } + if (item.type === 'highlighter-dropdown') { + const [supported] = isFormatSupported( + formatBar.std.command.chain() + ).run(); + return supported; + } + if (item.type === 'inline-action') { + return item.showWhen(formatBar.std.command.chain(), formatBar); + } + return true; + }) + .map(item => { + let template: TemplateResult | null = null; + switch (item.type) { + case 'divider': + template = renderToolbarSeparator(); + break; + case 'highlighter-dropdown': { + template = HighlightButton(formatBar); + break; + } + case 'paragraph-dropdown': + template = ParagraphButton(formatBar); + break; + case 'inline-action': { + template = html` + <editor-icon-button + data-testid=${item.id} + ?active=${item.isActive( + formatBar.std.command.chain(), + formatBar + )} + .tooltip=${item.name} + @click=${() => { + item.action(formatBar.std.command.chain(), formatBar); + formatBar.requestUpdate(); + }} + > + ${typeof item.icon === 'function' ? item.icon() : item.icon} + </editor-icon-button> + `; + break; + } + case 'custom': { + template = item.render(formatBar); + break; + } + default: + template = null; + } + + return [template, item] as const; + }) + .filter(([template]) => template !== null && template !== undefined) + // 1. delete the redundant dividers in the middle + .filter(([_, item], index, list) => { + if ( + item.type === 'divider' && + index + 1 < list.length && + list[index + 1][1].type === 'divider' + ) { + return false; + } + return true; + }) + // 2. delete the redundant dividers at the head and tail + .filter(([_, item], index, list) => { + if (item.type === 'divider') { + if (index === 0) { + return false; + } + if (index === list.length - 1) { + return false; + } + } + return true; + }) + .map(([template]) => template) + ); +} diff --git a/blocksuite/blocks/src/root-block/widgets/format-bar/components/highlight/consts.ts b/blocksuite/blocks/src/root-block/widgets/format-bar/components/highlight/consts.ts new file mode 100644 index 0000000000..1d52057c93 --- /dev/null +++ b/blocksuite/blocks/src/root-block/widgets/format-bar/components/highlight/consts.ts @@ -0,0 +1,42 @@ +interface HighlightConfig { + name: string; + color: string | null; + hotkey: string | null; +} + +const colors = [ + 'red', + 'orange', + 'yellow', + 'green', + 'teal', + 'blue', + 'purple', + 'grey', +]; + +export const backgroundConfig: HighlightConfig[] = [ + { + name: 'Default Background', + color: null, + hotkey: null, + }, + ...colors.map(color => ({ + name: `${color[0].toUpperCase()}${color.slice(1)} Background`, + color: `var(--affine-text-highlight-${color})`, + hotkey: null, + })), +]; + +export const foregroundConfig: HighlightConfig[] = [ + { + name: 'Default Color', + color: null, + hotkey: null, + }, + ...colors.map(color => ({ + name: `${color[0].toUpperCase()}${color.slice(1)}`, + color: `var(--affine-text-highlight-foreground-${color})`, + hotkey: null, + })), +]; diff --git a/blocksuite/blocks/src/root-block/widgets/format-bar/components/highlight/highlight-button.ts b/blocksuite/blocks/src/root-block/widgets/format-bar/components/highlight/highlight-button.ts new file mode 100644 index 0000000000..8984c2ae51 --- /dev/null +++ b/blocksuite/blocks/src/root-block/widgets/format-bar/components/highlight/highlight-button.ts @@ -0,0 +1,161 @@ +import { whenHover } from '@blocksuite/affine-components/hover'; +import { + ArrowDownIcon, + HighLightDuotoneIcon, + TextBackgroundDuotoneIcon, + TextForegroundDuotoneIcon, +} from '@blocksuite/affine-components/icons'; +import type { AffineTextAttributes } from '@blocksuite/affine-shared/types'; +import type { EditorHost } from '@blocksuite/block-std'; +import { assertExists } from '@blocksuite/global/utils'; +import { computePosition, flip, offset, shift } from '@floating-ui/dom'; +import { html } from 'lit'; +import { ref, type RefOrCallback } from 'lit/directives/ref.js'; + +import type { AffineFormatBarWidget } from '../../format-bar.js'; +import { backgroundConfig, foregroundConfig } from './consts.js'; + +enum HighlightType { + Foreground, + Background, +} + +let lastUsedColor: string | null = null; +let lastUsedHighlightType: HighlightType = HighlightType.Background; + +const updateHighlight = ( + host: EditorHost, + color: string | null, + highlightType: HighlightType +) => { + lastUsedColor = color; + lastUsedHighlightType = highlightType; + + const payload: { + styles: AffineTextAttributes; + } = { + styles: { + color: highlightType === HighlightType.Foreground ? color : null, + background: highlightType === HighlightType.Background ? color : null, + }, + }; + host.std.command + .chain() + .try(chain => [ + chain.getTextSelection().formatText(payload), + chain.getBlockSelections().formatBlock(payload), + chain.formatNative(payload), + ]) + .run(); +}; + +const HighlightPanel = ( + formatBar: AffineFormatBarWidget, + containerRef?: RefOrCallback +) => { + return html` + <editor-menu-content class="highlight-panel" data-show ${ref(containerRef)}> + <div data-orientation="vertical"> + <!-- Text Color Highlight --> + <div class="highligh-panel-heading">Color</div> + ${foregroundConfig.map( + ({ name, color }) => html` + <editor-menu-action + data-testid="${color ?? 'unset'}" + @click="${() => { + updateHighlight( + formatBar.host, + color, + HighlightType.Foreground + ); + formatBar.requestUpdate(); + }}" + > + <span style="display: flex; color: ${color}"> + ${TextForegroundDuotoneIcon} + </span> + ${name} + </editor-menu-action> + ` + )} + + <!-- Text Background Highlight --> + <div class="highligh-panel-heading">Background</div> + ${backgroundConfig.map( + ({ name, color }) => html` + <editor-menu-action + @click="${() => { + updateHighlight( + formatBar.host, + color, + HighlightType.Background + ); + formatBar.requestUpdate(); + }}" + > + <span style="display: flex; color: ${color ?? 'transparent'}"> + ${TextBackgroundDuotoneIcon} + </span> + ${name} + </editor-menu-action> + ` + )} + </div> + </editor-menu-content> + `; +}; + +export const HighlightButton = (formatBar: AffineFormatBarWidget) => { + const editorHost = formatBar.host; + + const { setFloating, setReference } = whenHover(isHover => { + if (!isHover) { + const panel = + formatBar.shadowRoot?.querySelector<HTMLElement>('.highlight-panel'); + if (!panel) return; + panel.style.display = 'none'; + return; + } + const button = + formatBar.shadowRoot?.querySelector<HTMLElement>('.highlight-button'); + const panel = + formatBar.shadowRoot?.querySelector<HTMLElement>('.highlight-panel'); + assertExists(button); + assertExists(panel); + panel.style.display = 'flex'; + computePosition(button, panel, { + placement: 'bottom', + middleware: [ + flip(), + offset(6), + shift({ + padding: 6, + }), + ], + }) + .then(({ x, y }) => { + panel.style.left = `${x}px`; + panel.style.top = `${y}px`; + }) + .catch(console.error); + }); + + const highlightPanel = HighlightPanel(formatBar, setFloating); + + return html` + <div class="highlight-button" ${ref(setReference)}> + <editor-icon-button + class="highlight-icon" + data-last-used="${lastUsedColor ?? 'unset'}" + @click="${() => + updateHighlight(editorHost, lastUsedColor, lastUsedHighlightType)}" + > + <span style="display: flex; color: ${lastUsedColor}"> + ${HighLightDuotoneIcon} + </span> + ${ArrowDownIcon} + </editor-icon-button> + ${highlightPanel} + </div> + `; +}; diff --git a/blocksuite/blocks/src/root-block/widgets/format-bar/components/paragraph-button.ts b/blocksuite/blocks/src/root-block/widgets/format-bar/components/paragraph-button.ts new file mode 100644 index 0000000000..cca3a30bac --- /dev/null +++ b/blocksuite/blocks/src/root-block/widgets/format-bar/components/paragraph-button.ts @@ -0,0 +1,127 @@ +import { whenHover } from '@blocksuite/affine-components/hover'; +import { ArrowDownIcon } from '@blocksuite/affine-components/icons'; +import type { ParagraphBlockModel } from '@blocksuite/affine-model'; +import type { EditorHost } from '@blocksuite/block-std'; +import { assertExists } from '@blocksuite/global/utils'; +import { computePosition, flip, offset, shift } from '@floating-ui/dom'; +import { html } from 'lit'; +import { ref, type RefOrCallback } from 'lit/directives/ref.js'; +import { repeat } from 'lit/directives/repeat.js'; + +import { textConversionConfigs } from '../../../../_common/configs/text-conversion.js'; +import type { ParagraphActionConfigItem } from '../config.js'; +import type { AffineFormatBarWidget } from '../format-bar.js'; + +interface ParagraphPanelProps { + host: EditorHost; + formatBar: AffineFormatBarWidget; + ref?: RefOrCallback; +} + +const ParagraphPanel = ({ + formatBar, + host, + ref: containerRef, +}: ParagraphPanelProps) => { + const config = formatBar.configItems + .filter( + (item): item is ParagraphActionConfigItem => + item.type === 'paragraph-action' + ) + .filter(({ flavour }) => host.doc.schema.flavourSchemaMap.has(flavour)); + + const renderedConfig = repeat( + config, + item => html` + <editor-menu-action + data-testid="${item.id}" + @click="${() => item.action(formatBar.std.command.chain(), formatBar)}" + > + ${typeof item.icon === 'function' ? item.icon() : item.icon} + ${item.name} + </editor-menu-action> + ` + ); + + return html` + <editor-menu-content class="paragraph-panel" data-show ${ref(containerRef)}> + <div data-orientation="vertical">${renderedConfig}</div> + </editor-menu-content> + `; +}; + +export const ParagraphButton = (formatBar: AffineFormatBarWidget) => { + if (formatBar.displayType !== 'text' && formatBar.displayType !== 'block') { + return null; + } + + const selectedBlocks = formatBar.selectedBlocks; + // only support model with text + if (selectedBlocks.some(el => !el.model.text)) { + return null; + } + + const paragraphIcon = + selectedBlocks.length < 1 + ? textConversionConfigs[0].icon + : (textConversionConfigs.find( + ({ flavour, type }) => + selectedBlocks[0].flavour === flavour && + (selectedBlocks[0].model as ParagraphBlockModel).type === type + )?.icon ?? textConversionConfigs[0].icon); + + const rootComponent = formatBar.block; + if (rootComponent.model.flavour !== 'affine:page') { + console.error('paragraph button host is not a page component'); + return null; + } + + const { setFloating, setReference } = whenHover(isHover => { + if (!isHover) { + const panel = + formatBar.shadowRoot?.querySelector<HTMLElement>('.paragraph-panel'); + if (!panel) return; + panel.style.display = 'none'; + return; + } + const formatQuickBarElement = formatBar.formatBarElement; + const button = + formatBar.shadowRoot?.querySelector<HTMLElement>('.paragraph-button'); + const panel = + formatBar.shadowRoot?.querySelector<HTMLElement>('.paragraph-panel'); + assertExists(button); + assertExists(panel); + assertExists(formatQuickBarElement, 'format quick bar should exist'); + panel.style.display = 'flex'; + computePosition(formatQuickBarElement, panel, { + placement: 'top-start', + middleware: [ + flip(), + offset(6), + shift({ + padding: 6, + }), + ], + }) + .then(({ x, y }) => { + panel.style.left = `${x}px`; + panel.style.top = `${y}px`; + }) + .catch(console.error); + }); + + const paragraphPanel = ParagraphPanel({ + formatBar, + host: formatBar.host, + ref: setFloating, + }); + + return html` + <div class="paragraph-button" ${ref(setReference)}> + <editor-icon-button class="paragraph-button-icon"> + ${paragraphIcon} ${ArrowDownIcon} + </editor-icon-button> + ${paragraphPanel} + </div> + `; +}; diff --git a/blocksuite/blocks/src/root-block/widgets/format-bar/config.ts b/blocksuite/blocks/src/root-block/widgets/format-bar/config.ts new file mode 100644 index 0000000000..42722a3ebf --- /dev/null +++ b/blocksuite/blocks/src/root-block/widgets/format-bar/config.ts @@ -0,0 +1,453 @@ +import { + BoldIcon, + BulletedListIcon, + CheckBoxIcon, + CodeIcon, + CopyIcon, + DatabaseTableViewIcon20, + DeleteIcon, + DuplicateIcon, + Heading1Icon, + Heading2Icon, + Heading3Icon, + Heading4Icon, + Heading5Icon, + Heading6Icon, + ItalicIcon, + LinkedDocIcon, + LinkIcon, + MoreVerticalIcon, + NumberedListIcon, + QuoteIcon, + StrikethroughIcon, + TextIcon, + UnderlineIcon, +} from '@blocksuite/affine-components/icons'; +import { toast } from '@blocksuite/affine-components/toast'; +import type { MenuItemGroup } from '@blocksuite/affine-components/toolbar'; +import { renderGroups } from '@blocksuite/affine-components/toolbar'; +import { TelemetryProvider } from '@blocksuite/affine-shared/services'; +import type { + Chain, + CommandKeyToData, + InitCommandCtx, +} from '@blocksuite/block-std'; +import { tableViewMeta } from '@blocksuite/data-view/view-presets'; +import { assertExists } from '@blocksuite/global/utils'; +import { Slice } from '@blocksuite/store'; +import { html, type TemplateResult } from 'lit'; + +import { + convertSelectedBlocksToLinkedDoc, + getTitleFromSelectedModels, + notifyDocCreated, + promptDocTitle, +} from '../../../_common/utils/render-linked-doc.js'; +import { convertToDatabase } from '../../../database-block/data-source.js'; +import { DATABASE_CONVERT_WHITE_LIST } from '../../../database-block/utils/block-utils.js'; +import { FormatBarContext } from './context.js'; +import type { AffineFormatBarWidget } from './format-bar.js'; + +export type DividerConfigItem = { + type: 'divider'; +}; +export type HighlighterDropdownConfigItem = { + type: 'highlighter-dropdown'; +}; +export type ParagraphDropdownConfigItem = { + type: 'paragraph-dropdown'; +}; +export type InlineActionConfigItem = { + id: string; + name: string; + type: 'inline-action'; + action: ( + chain: Chain<InitCommandCtx>, + formatBar: AffineFormatBarWidget + ) => void; + icon: TemplateResult | (() => HTMLElement); + isActive: ( + chain: Chain<InitCommandCtx>, + formatBar: AffineFormatBarWidget + ) => boolean; + showWhen: ( + chain: Chain<InitCommandCtx>, + formatBar: AffineFormatBarWidget + ) => boolean; +}; +export type ParagraphActionConfigItem = { + id: string; + type: 'paragraph-action'; + name: string; + action: ( + chain: Chain<InitCommandCtx>, + formatBar: AffineFormatBarWidget + ) => void; + icon: TemplateResult | (() => HTMLElement); + flavour: string; +}; + +export type CustomConfigItem = { + type: 'custom'; + render: (formatBar: AffineFormatBarWidget) => TemplateResult | null; +}; + +export type FormatBarConfigItem = + | DividerConfigItem + | HighlighterDropdownConfigItem + | ParagraphDropdownConfigItem + | ParagraphActionConfigItem + | InlineActionConfigItem + | CustomConfigItem; + +export function toolbarDefaultConfig(toolbar: AffineFormatBarWidget) { + toolbar + .clearConfig() + .addParagraphDropdown() + .addDivider() + .addTextStyleToggle({ + key: 'bold', + action: chain => chain.toggleBold().run(), + icon: BoldIcon, + }) + .addTextStyleToggle({ + key: 'italic', + action: chain => chain.toggleItalic().run(), + icon: ItalicIcon, + }) + .addTextStyleToggle({ + key: 'underline', + action: chain => chain.toggleUnderline().run(), + icon: UnderlineIcon, + }) + .addTextStyleToggle({ + key: 'strike', + action: chain => chain.toggleStrike().run(), + icon: StrikethroughIcon, + }) + .addTextStyleToggle({ + key: 'code', + action: chain => chain.toggleCode().run(), + icon: CodeIcon, + }) + .addTextStyleToggle({ + key: 'link', + action: chain => chain.toggleLink().run(), + icon: LinkIcon, + }) + .addDivider() + .addHighlighterDropdown() + .addDivider() + .addInlineAction({ + id: 'convert-to-database', + name: 'Create Table', + icon: DatabaseTableViewIcon20, + isActive: () => false, + action: () => { + convertToDatabase(toolbar.host, tableViewMeta.type); + }, + showWhen: chain => { + const middleware = (count = 0) => { + return ( + ctx: CommandKeyToData<'selectedBlocks'>, + next: () => void + ) => { + const { selectedBlocks } = ctx; + if (!selectedBlocks || selectedBlocks.length === count) return; + + const allowed = selectedBlocks.every(block => + DATABASE_CONVERT_WHITE_LIST.includes(block.flavour) + ); + if (!allowed) return; + + next(); + }; + }; + let [result] = chain + .getTextSelection() + .getSelectedBlocks({ + types: ['text'], + }) + .inline(middleware(1)) + .run(); + + if (result) return true; + + [result] = chain + .tryAll(chain => [ + chain.getBlockSelections(), + chain.getImageSelections(), + ]) + .getSelectedBlocks({ + types: ['block', 'image'], + }) + .inline(middleware(0)) + .run(); + + return result; + }, + }) + .addDivider() + .addInlineAction({ + id: 'convert-to-linked-doc', + name: 'Create Linked Doc', + icon: LinkedDocIcon, + isActive: () => false, + action: (chain, formatBar) => { + const [_, ctx] = chain + .getSelectedModels({ + types: ['block', 'text'], + mode: 'highest', + }) + .draftSelectedModels() + .run(); + const { draftedModels, selectedModels } = ctx; + if (!selectedModels?.length || !draftedModels) return; + + const host = formatBar.host; + host.selection.clear(); + + const doc = host.doc; + const autofill = getTitleFromSelectedModels(selectedModels); + void promptDocTitle(host, autofill).then(async title => { + if (title === null) return; + await convertSelectedBlocksToLinkedDoc( + host.std, + doc, + draftedModels, + title + ); + notifyDocCreated(host, doc); + host.std.getOptional(TelemetryProvider)?.track('DocCreated', { + control: 'create linked doc', + page: 'doc editor', + module: 'format toolbar', + type: 'embed-linked-doc', + }); + host.std.getOptional(TelemetryProvider)?.track('LinkedDocCreated', { + control: 'create linked doc', + page: 'doc editor', + module: 'format toolbar', + type: 'embed-linked-doc', + }); + }); + }, + showWhen: chain => { + const [_, ctx] = chain + .getSelectedModels({ + types: ['block', 'text'], + mode: 'highest', + }) + .run(); + const { selectedModels } = ctx; + return !!selectedModels && selectedModels.length > 0; + }, + }) + .addBlockTypeSwitch({ + flavour: 'affine:paragraph', + type: 'text', + name: 'Text', + icon: TextIcon, + }) + .addBlockTypeSwitch({ + flavour: 'affine:paragraph', + type: 'h1', + name: 'Heading 1', + icon: Heading1Icon, + }) + .addBlockTypeSwitch({ + flavour: 'affine:paragraph', + type: 'h2', + name: 'Heading 2', + icon: Heading2Icon, + }) + .addBlockTypeSwitch({ + flavour: 'affine:paragraph', + type: 'h3', + name: 'Heading 3', + icon: Heading3Icon, + }) + .addBlockTypeSwitch({ + flavour: 'affine:paragraph', + type: 'h4', + name: 'Heading 4', + icon: Heading4Icon, + }) + .addBlockTypeSwitch({ + flavour: 'affine:paragraph', + type: 'h5', + name: 'Heading 5', + icon: Heading5Icon, + }) + .addBlockTypeSwitch({ + flavour: 'affine:paragraph', + type: 'h6', + name: 'Heading 6', + icon: Heading6Icon, + }) + .addBlockTypeSwitch({ + flavour: 'affine:list', + type: 'bulleted', + name: 'Bulleted List', + icon: BulletedListIcon, + }) + .addBlockTypeSwitch({ + flavour: 'affine:list', + type: 'numbered', + name: 'Numbered List', + icon: NumberedListIcon, + }) + .addBlockTypeSwitch({ + flavour: 'affine:list', + type: 'todo', + name: 'To-do List', + icon: CheckBoxIcon, + }) + .addBlockTypeSwitch({ + flavour: 'affine:code', + name: 'Code Block', + icon: CodeIcon, + }) + .addBlockTypeSwitch({ + flavour: 'affine:paragraph', + type: 'quote', + name: 'Quote', + icon: QuoteIcon, + }); +} + +export const BUILT_IN_GROUPS: MenuItemGroup<FormatBarContext>[] = [ + { + type: 'clipboard', + items: [ + { + type: 'copy', + label: 'Copy', + icon: CopyIcon, + disabled: c => c.doc.readonly, + action: c => { + c.std.command + .chain() + .getSelectedModels() + .with({ + onCopy: () => { + toast(c.host, 'Copied to clipboard'); + }, + }) + .draftSelectedModels() + .copySelectedModels() + .run(); + }, + }, + { + type: 'duplicate', + label: 'Duplicate', + icon: DuplicateIcon, + disabled: c => c.doc.readonly, + action: c => { + c.doc.captureSync(); + c.std.command + .chain() + .try(cmd => [ + cmd + .getTextSelection() + .inline<'currentSelectionPath'>((ctx, next) => { + const textSelection = ctx.currentTextSelection; + assertExists(textSelection); + const end = textSelection.to ?? textSelection.from; + next({ currentSelectionPath: end.blockId }); + }), + cmd + .getBlockSelections() + .inline<'currentSelectionPath'>((ctx, next) => { + const currentBlockSelections = ctx.currentBlockSelections; + assertExists(currentBlockSelections); + const blockSelection = currentBlockSelections.at(-1); + if (!blockSelection) { + return; + } + next({ currentSelectionPath: blockSelection.blockId }); + }), + ]) + .getBlockIndex() + .getSelectedModels() + .draftSelectedModels() + .inline((ctx, next) => { + if (!ctx.draftedModels) { + return next(); + } + + ctx.draftedModels + .then(models => { + const slice = Slice.fromModels(ctx.std.doc, models); + return ctx.std.clipboard.duplicateSlice( + slice, + ctx.std.doc, + ctx.parentBlock?.model.id, + ctx.blockIndex ? ctx.blockIndex + 1 : 1 + ); + }) + .catch(console.error); + + return next(); + }) + .run(); + }, + }, + ], + }, + { + type: 'delete', + items: [ + { + type: 'delete', + label: 'Delete', + icon: DeleteIcon, + disabled: c => c.doc.readonly, + action: c => { + // remove text + const [result] = c.std.command + .chain() + .getTextSelection() + .deleteText() + .run(); + + if (result) { + return; + } + + // remove blocks + c.std.command + .chain() + .tryAll(chain => [ + chain.getBlockSelections(), + chain.getImageSelections(), + ]) + .getSelectedModels() + .deleteSelectedModels() + .run(); + + c.toolbar.reset(); + }, + }, + ], + }, +]; + +export function toolbarMoreButton(toolbar: AffineFormatBarWidget) { + const context = new FormatBarContext(toolbar); + const actions = renderGroups(toolbar.moreGroups, context); + + return html` + <editor-menu-button + .contentPadding="${'8px'}" + .button="${html` + <editor-icon-button aria-label="More" .tooltip=${'More'}> + ${MoreVerticalIcon} + </editor-icon-button> + `}" + > + <div data-size="large" data-orientation="vertical">${actions}</div> + </editor-menu-button> + `; +} diff --git a/blocksuite/blocks/src/root-block/widgets/format-bar/context.ts b/blocksuite/blocks/src/root-block/widgets/format-bar/context.ts new file mode 100644 index 0000000000..3b243a1485 --- /dev/null +++ b/blocksuite/blocks/src/root-block/widgets/format-bar/context.ts @@ -0,0 +1,65 @@ +import { MenuContext } from '../../configs/toolbar.js'; +import type { AffineFormatBarWidget } from './format-bar.js'; + +export class FormatBarContext extends MenuContext { + get doc() { + return this.toolbar.host.doc; + } + + get host() { + return this.toolbar.host; + } + + get selectedBlockModels() { + const [success, result] = this.std.command + .chain() + .tryAll(chain => [ + chain.getTextSelection(), + chain.getBlockSelections(), + chain.getImageSelections(), + ]) + .getSelectedModels({ + mode: 'highest', + }) + .run(); + + if (!success) { + return []; + } + + // should return an empty array if `to` of the range is null + if ( + result.currentTextSelection && + !result.currentTextSelection.to && + result.currentTextSelection.from.length === 0 + ) { + return []; + } + + if (result.selectedModels?.length) { + return result.selectedModels; + } + + return []; + } + + get std() { + return this.toolbar.std; + } + + constructor(public toolbar: AffineFormatBarWidget) { + super(); + } + + isEmpty() { + return this.selectedBlockModels.length === 0; + } + + isMultiple() { + return this.selectedBlockModels.length > 1; + } + + isSingle() { + return this.selectedBlockModels.length === 1; + } +} diff --git a/blocksuite/blocks/src/root-block/widgets/format-bar/format-bar.ts b/blocksuite/blocks/src/root-block/widgets/format-bar/format-bar.ts new file mode 100644 index 0000000000..db802dd8b9 --- /dev/null +++ b/blocksuite/blocks/src/root-block/widgets/format-bar/format-bar.ts @@ -0,0 +1,604 @@ +import { HoverController } from '@blocksuite/affine-components/hover'; +import type { RichText } from '@blocksuite/affine-components/rich-text'; +import { isFormatSupported } from '@blocksuite/affine-components/rich-text'; +import { + cloneGroups, + type MenuItemGroup, +} from '@blocksuite/affine-components/toolbar'; +import type { AffineTextAttributes } from '@blocksuite/affine-shared/types'; +import { matchFlavours } from '@blocksuite/affine-shared/utils'; +import type { + BaseSelection, + BlockComponent, + CursorSelection, +} from '@blocksuite/block-std'; +import { WidgetComponent } from '@blocksuite/block-std'; +import { + assertExists, + DisposableGroup, + nextTick, +} from '@blocksuite/global/utils'; +import { + autoUpdate, + computePosition, + inline, + offset, + type Placement, + type ReferenceElement, + shift, +} from '@floating-ui/dom'; +import { html, nothing } from 'lit'; +import { query, state } from 'lit/decorators.js'; + +import { getMoreMenuConfig } from '../../configs/toolbar.js'; +import { ConfigRenderer } from './components/config-renderer.js'; +import { + BUILT_IN_GROUPS, + type FormatBarConfigItem, + type InlineActionConfigItem, + type ParagraphActionConfigItem, + toolbarDefaultConfig, + toolbarMoreButton, +} from './config.js'; +import type { FormatBarContext } from './context.js'; +import { formatBarStyle } from './styles.js'; + +export const AFFINE_FORMAT_BAR_WIDGET = 'affine-format-bar-widget'; + +export class AffineFormatBarWidget extends WidgetComponent { + static override styles = formatBarStyle; + + private _abortController = new AbortController(); + + private _floatDisposables: DisposableGroup | null = null; + + private _lastCursor: CursorSelection | undefined = undefined; + + private _placement: Placement = 'top'; + + /* + * Caches the more menu items. + * Currently only supports configuring more menu. + */ + moreGroups: MenuItemGroup<FormatBarContext>[] = cloneGroups(BUILT_IN_GROUPS); + + private get _selectionManager() { + return this.host.selection; + } + + get displayType() { + return this._displayType; + } + + get nativeRange() { + const sl = document.getSelection(); + if (!sl || sl.rangeCount === 0) return null; + return sl.getRangeAt(0); + } + + get selectedBlocks() { + return this._selectedBlocks; + } + + private _calculatePlacement() { + const rootComponent = this.block; + + this.handleEvent('dragStart', () => { + this._dragging = true; + }); + + this.handleEvent('dragEnd', () => { + this._dragging = false; + }); + + // calculate placement + this.disposables.add( + this.host.event.add('pointerUp', ctx => { + let targetRect: DOMRect | null = null; + if (this.displayType === 'text' || this.displayType === 'native') { + const range = this.nativeRange; + if (!range) { + this.reset(); + return; + } + targetRect = range.getBoundingClientRect(); + } else if (this.displayType === 'block') { + const block = this._selectedBlocks[0]; + if (!block) return; + targetRect = block.getBoundingClientRect(); + } else { + return; + } + + const { top: editorHostTop, bottom: editorHostBottom } = + this.host.getBoundingClientRect(); + const e = ctx.get('pointerState'); + if (editorHostBottom - targetRect.bottom < 50) { + this._placement = 'top'; + } else if (targetRect.top - Math.max(editorHostTop, 0) < 50) { + this._placement = 'bottom'; + } else if (e.raw.y < targetRect.top + targetRect.height / 2) { + this._placement = 'top'; + } else { + this._placement = 'bottom'; + } + }) + ); + + // listen to selection change + this.disposables.add( + this._selectionManager.slots.changed.on(() => { + const update = async () => { + const textSelection = rootComponent.selection.find('text'); + const blockSelections = rootComponent.selection.filter('block'); + + // Should not re-render format bar when only cursor selection changed in edgeless + const cursorSelection = rootComponent.selection.find('cursor'); + if (cursorSelection) { + if (!this._lastCursor) { + this._lastCursor = cursorSelection; + return; + } + + if (!this._selectionEqual(cursorSelection, this._lastCursor)) { + this._lastCursor = cursorSelection; + return; + } + } + + // We cannot use `host.getUpdateComplete()` here + // because it would cause excessive DOM queries, leading to UI jamming. + await nextTick(); + + if (textSelection) { + const block = this.host.view.getBlock(textSelection.blockId); + + if ( + !textSelection.isCollapsed() && + block && + block.model.role === 'content' + ) { + this._displayType = 'text'; + if (!rootComponent.std.range) return; + this.host.std.command + .chain() + .getTextSelection() + .getSelectedBlocks({ + types: ['text'], + }) + .inline(ctx => { + const { selectedBlocks } = ctx; + if (!selectedBlocks) return; + this._selectedBlocks = selectedBlocks; + }) + .run(); + + return; + } + + this.reset(); + return; + } + + if (this.block && blockSelections.length > 0) { + this._displayType = 'block'; + const selectedBlocks = blockSelections + .map(selection => { + const path = selection.blockId; + return this.block.host.view.getBlock(path); + }) + .filter((el): el is BlockComponent => !!el); + + this._selectedBlocks = selectedBlocks; + return; + } + + this.reset(); + }; + + update().catch(console.error); + }) + ); + this.disposables.addFromEvent(document, 'selectionchange', () => { + if (!this.host.event.active) return; + + const databaseSelection = this.host.selection.find('database'); + if (!databaseSelection) { + return; + } + + const reset = () => { + this.reset(); + this.requestUpdate(); + }; + const viewSelection = databaseSelection.viewSelection; + // check table selection + if ( + viewSelection.type === 'table' && + (viewSelection.selectionType !== 'area' || !viewSelection.isEditing) + ) + return reset(); + // check kanban selection + if ( + (viewSelection.type === 'kanban' && + viewSelection.selectionType !== 'cell') || + !viewSelection.isEditing + ) + return reset(); + + const range = this.nativeRange; + + if (!range || range.collapsed) return reset(); + this._displayType = 'native'; + this.requestUpdate(); + }); + } + + private _listenFloatingElement() { + const formatQuickBarElement = this.formatBarElement; + assertExists(formatQuickBarElement, 'format quick bar should exist'); + + const listenFloatingElement = ( + getElement: () => ReferenceElement | void + ) => { + const initialElement = getElement(); + if (!initialElement) { + return; + } + + assertExists(this._floatDisposables); + HoverController.globalAbortController?.abort(); + this._floatDisposables.add( + autoUpdate( + initialElement, + formatQuickBarElement, + () => { + const element = getElement(); + if (!element) return; + + computePosition(element, formatQuickBarElement, { + placement: this._placement, + middleware: [ + offset(10), + inline(), + shift({ + padding: 6, + }), + ], + }) + .then(({ x, y }) => { + formatQuickBarElement.style.display = 'flex'; + formatQuickBarElement.style.top = `${y}px`; + formatQuickBarElement.style.left = `${x}px`; + }) + .catch(console.error); + }, + { + // follow edgeless viewport update + animationFrame: true, + } + ) + ); + }; + + const getReferenceElementFromBlock = () => { + const firstBlock = this._selectedBlocks[0]; + let rect = firstBlock?.getBoundingClientRect(); + + if (!rect) return; + + this._selectedBlocks.forEach(el => { + const elRect = el.getBoundingClientRect(); + if (elRect.top < rect.top) { + rect = new DOMRect(rect.left, elRect.top, rect.width, rect.bottom); + } + if (elRect.bottom > rect.bottom) { + rect = new DOMRect(rect.left, rect.top, rect.width, elRect.bottom); + } + if (elRect.left < rect.left) { + rect = new DOMRect(elRect.left, rect.top, rect.right, rect.bottom); + } + if (elRect.right > rect.right) { + rect = new DOMRect(rect.left, rect.top, elRect.right, rect.bottom); + } + }); + return { + getBoundingClientRect: () => rect, + getClientRects: () => + this._selectedBlocks.map(el => el.getBoundingClientRect()), + }; + }; + + const getReferenceElementFromText = () => { + const range = this.nativeRange; + if (!range) { + return; + } + return { + getBoundingClientRect: () => range.getBoundingClientRect(), + getClientRects: () => range.getClientRects(), + }; + }; + + switch (this.displayType) { + case 'text': + case 'native': + return listenFloatingElement(getReferenceElementFromText); + case 'block': + return listenFloatingElement(getReferenceElementFromBlock); + default: + return; + } + } + + private _selectionEqual( + target: BaseSelection | undefined, + current: BaseSelection | undefined + ) { + if (target === current || (target && current && target.equals(current))) { + return true; + } + + return false; + } + + private _shouldDisplay() { + const readonly = this.doc.awarenessStore.isReadonly( + this.doc.blockCollection + ); + const active = this.host.event.active; + if (readonly || !active) return false; + + if ( + this.displayType === 'block' && + this._selectedBlocks?.[0]?.flavour === 'affine:surface-ref' + ) { + return false; + } + + if (this.displayType === 'block' && this._selectedBlocks.length === 1) { + const selectedBlock = this._selectedBlocks[0]; + if ( + !matchFlavours(selectedBlock.model, [ + 'affine:paragraph', + 'affine:list', + 'affine:code', + 'affine:image', + ]) + ) { + return false; + } + } + + if (this.displayType === 'none' || this._dragging) { + return false; + } + + // if the selection is on an embed (ex. linked page), we should not display the format bar + if (this.displayType === 'text' && this._selectedBlocks.length === 1) { + const isEmbed = () => { + const [element] = this._selectedBlocks; + const richText = element.querySelector<RichText>('rich-text'); + const inline = richText?.inlineEditor; + if (!richText || !inline) { + return false; + } + const range = inline.getInlineRange(); + if (!range || range.length > 1) { + return false; + } + const deltas = inline.getDeltasByInlineRange(range); + if (deltas.length > 2) { + return false; + } + const delta = deltas?.[1]?.[0]; + if (!delta) { + return false; + } + + return inline.isEmbed(delta); + }; + + if (isEmbed()) { + return false; + } + } + + // todo: refactor later that ai panel & format bar should not depend on each other + // do not display if AI panel is open + const rootBlockId = this.host.doc.root?.id; + const aiPanel = rootBlockId + ? this.host.view.getWidget('affine-ai-panel-widget', rootBlockId) + : null; + + // @ts-expect-error FIXME: ts error + if (aiPanel && aiPanel?.state !== 'hidden') { + return false; + } + + return true; + } + + addBlockTypeSwitch(config: { + flavour: BlockSuite.Flavour; + icon: ParagraphActionConfigItem['icon']; + type?: string; + name?: string; + }) { + const { flavour, type, icon } = config; + return this.addParagraphAction({ + id: `${flavour}/${type ?? ''}`, + icon, + flavour, + name: config.name ?? camelCaseToWords(type ?? flavour), + action: chain => { + chain + .updateBlockType({ + flavour, + props: type != null ? { type } : undefined, + }) + .run(); + }, + }); + } + + addDivider() { + this.configItems.push({ type: 'divider' }); + return this; + } + + addHighlighterDropdown() { + this.configItems.push({ type: 'highlighter-dropdown' }); + return this; + } + + addInlineAction(config: Omit<InlineActionConfigItem, 'type'>) { + this.configItems.push({ ...config, type: 'inline-action' }); + return this; + } + + addParagraphAction(config: Omit<ParagraphActionConfigItem, 'type'>) { + this.configItems.push({ ...config, type: 'paragraph-action' }); + return this; + } + + addParagraphDropdown() { + this.configItems.push({ type: 'paragraph-dropdown' }); + return this; + } + + addRawConfigItems(configItems: FormatBarConfigItem[], index?: number) { + if (index === undefined) { + this.configItems.push(...configItems); + } else { + this.configItems.splice(index, 0, ...configItems); + } + return this; + } + + addTextStyleToggle(config: { + icon: InlineActionConfigItem['icon']; + key: Exclude< + keyof AffineTextAttributes, + 'color' | 'background' | 'reference' + >; + action: InlineActionConfigItem['action']; + }) { + const { key } = config; + return this.addInlineAction({ + id: key, + name: camelCaseToWords(key), + icon: config.icon, + isActive: chain => { + const [result] = chain.isTextStyleActive({ key }).run(); + return result; + }, + action: config.action, + showWhen: chain => { + const [result] = isFormatSupported(chain).run(); + return result; + }, + }); + } + + clearConfig() { + this.configItems = []; + return this; + } + + override connectedCallback() { + super.connectedCallback(); + this._abortController = new AbortController(); + + const rootComponent = this.block; + assertExists(rootComponent); + const widgets = rootComponent.widgets; + + // check if the host use the format bar widget + if (!Object.hasOwn(widgets, AFFINE_FORMAT_BAR_WIDGET)) { + return; + } + + // check if format bar widget support the host + if (rootComponent.model.flavour !== 'affine:page') { + console.error( + `format bar not support rootComponent: ${rootComponent.constructor.name} but its widgets has format bar` + ); + return; + } + + this._calculatePlacement(); + + if (this.configItems.length === 0) { + toolbarDefaultConfig(this); + } + + this.moreGroups = getMoreMenuConfig(this.std).configure(this.moreGroups); + } + + override disconnectedCallback() { + super.disconnectedCallback(); + this._abortController.abort(); + this.reset(); + this._lastCursor = undefined; + } + + override render() { + if (!this._shouldDisplay()) { + return nothing; + } + + const items = ConfigRenderer(this); + + return html` + <editor-toolbar class="${AFFINE_FORMAT_BAR_WIDGET}"> + ${items} + <editor-toolbar-separator></editor-toolbar-separator> + ${toolbarMoreButton(this)} + </editor-toolbar> + `; + } + + reset() { + this._displayType = 'none'; + this._selectedBlocks = []; + } + + override updated() { + if (!this._shouldDisplay()) { + if (this._floatDisposables) { + this._floatDisposables.dispose(); + } + return; + } + + this._floatDisposables = new DisposableGroup(); + this._listenFloatingElement(); + } + + @state() + private accessor _displayType: 'text' | 'block' | 'native' | 'none' = 'none'; + + @state() + private accessor _dragging = false; + + @state() + private accessor _selectedBlocks: BlockComponent[] = []; + + @state() + accessor configItems: FormatBarConfigItem[] = []; + + @query(`.${AFFINE_FORMAT_BAR_WIDGET}`) + accessor formatBarElement: HTMLElement | null = null; +} + +function camelCaseToWords(s: string) { + const result = s.replace(/([A-Z])/g, ' $1'); + return result.charAt(0).toUpperCase() + result.slice(1); +} + +declare global { + interface HTMLElementTagNameMap { + [AFFINE_FORMAT_BAR_WIDGET]: AffineFormatBarWidget; + } +} diff --git a/blocksuite/blocks/src/root-block/widgets/format-bar/index.ts b/blocksuite/blocks/src/root-block/widgets/format-bar/index.ts new file mode 100644 index 0000000000..efe6a4df4e --- /dev/null +++ b/blocksuite/blocks/src/root-block/widgets/format-bar/index.ts @@ -0,0 +1,2 @@ +export * from './config.js'; +export { AffineFormatBarWidget } from './format-bar.js'; diff --git a/blocksuite/blocks/src/root-block/widgets/format-bar/styles.ts b/blocksuite/blocks/src/root-block/widgets/format-bar/styles.ts new file mode 100644 index 0000000000..27222b37b4 --- /dev/null +++ b/blocksuite/blocks/src/root-block/widgets/format-bar/styles.ts @@ -0,0 +1,56 @@ +import { css } from 'lit'; + +import { scrollbarStyle } from '../../../_common/components/utils.js'; + +const paragraphButtonStyle = css` + .paragraph-button-icon > svg:nth-child(2) { + transition-duration: 0.3s; + } + .paragraph-button-icon:is(:hover, :focus-visible, :active) + > svg:nth-child(2) { + transform: rotate(180deg); + } + + .highlight-icon > svg:nth-child(2) { + transition-duration: 0.3s; + } + .highlight-icon:is(:hover, :focus-visible, :active) > svg:nth-child(2) { + transform: rotate(180deg); + } + + .highlight-panel { + max-height: 380px; + } + + .highligh-panel-heading { + display: flex; + color: var(--affine-text-secondary-color); + padding: 4px; + } + + editor-menu-content { + display: none; + position: absolute; + padding: 0; + z-index: var(--affine-z-index-popover); + --packed-height: 6px; + } + + editor-menu-content > div[data-orientation='vertical'] { + padding: 8px; + overflow-y: auto; + } + + ${scrollbarStyle('editor-menu-content > div[data-orientation="vertical"]')} +`; + +export const formatBarStyle = css` + .affine-format-bar-widget { + position: absolute; + display: none; + z-index: var(--affine-z-index-popover); + user-select: none; + } + + ${paragraphButtonStyle} +`; diff --git a/blocksuite/blocks/src/root-block/widgets/frame-title/effects.ts b/blocksuite/blocks/src/root-block/widgets/frame-title/effects.ts new file mode 100644 index 0000000000..3f9a5ca710 --- /dev/null +++ b/blocksuite/blocks/src/root-block/widgets/frame-title/effects.ts @@ -0,0 +1,7 @@ +import { AFFINE_FRAME_TITLE, AffineFrameTitle } from './frame-title.js'; +import { AFFINE_FRAME_TITLE_WIDGET, AffineFrameTitleWidget } from './index.js'; + +export function effects() { + customElements.define(AFFINE_FRAME_TITLE_WIDGET, AffineFrameTitleWidget); + customElements.define(AFFINE_FRAME_TITLE, AffineFrameTitle); +} diff --git a/blocksuite/blocks/src/root-block/widgets/frame-title/frame-title.ts b/blocksuite/blocks/src/root-block/widgets/frame-title/frame-title.ts new file mode 100644 index 0000000000..a6a55a2c89 --- /dev/null +++ b/blocksuite/blocks/src/root-block/widgets/frame-title/frame-title.ts @@ -0,0 +1,285 @@ +import { ColorScheme, FrameBlockModel } from '@blocksuite/affine-model'; +import { ThemeProvider } from '@blocksuite/affine-shared/services'; +import { + type BlockStdScope, + PropTypes, + requiredProperties, + stdContext, +} from '@blocksuite/block-std'; +import { GfxControllerIdentifier } from '@blocksuite/block-std/gfx'; +import { + Bound, + type SerializedXYWH, + SignalWatcher, + WithDisposable, +} from '@blocksuite/global/utils'; +import { consume } from '@lit/context'; +import { themeToVar } from '@toeverything/theme/v2'; +import { LitElement } from 'lit'; +import { property, state } from 'lit/decorators.js'; + +import { parseStringToRgba } from '../../edgeless/components/color-picker/utils.js'; +import { isTransparent } from '../../edgeless/components/panel/color-panel.js'; +import type { EdgelessRootService } from '../../edgeless/index.js'; +import { frameTitleStyle, frameTitleStyleVars } from './styles.js'; + +export const AFFINE_FRAME_TITLE = 'affine-frame-title'; + +@requiredProperties({ + model: PropTypes.instanceOf(FrameBlockModel), +}) +export class AffineFrameTitle extends SignalWatcher( + WithDisposable(LitElement) +) { + static override styles = frameTitleStyle; + + private _cachedHeight = 0; + + private _cachedWidth = 0; + + get colors() { + let backgroundColor = this.std + .get(ThemeProvider) + .getColorValue(this.model.background, undefined, true); + if (isTransparent(backgroundColor)) { + backgroundColor = this.std + .get(ThemeProvider) + .getCssVariableColor(themeToVar('edgeless/frame/background/white')); + } + + const { r, g, b, a } = parseStringToRgba(backgroundColor); + + const theme = this.std.get(ThemeProvider).theme; + let textColor: string; + { + let rPrime, gPrime, bPrime; + if (theme === ColorScheme.Light) { + rPrime = 1 - a + a * r; + gPrime = 1 - a + a * g; + bPrime = 1 - a + a * b; + } else { + rPrime = a * r; + gPrime = a * g; + bPrime = a * b; + } + + // light + const L = 0.299 * rPrime + 0.587 * gPrime + 0.114 * bPrime; + textColor = L > 0.5 ? 'black' : 'white'; + } + + return { + background: backgroundColor, + text: textColor, + }; + } + + get doc() { + return this.model.doc; + } + + get gfx() { + return this.std.get(GfxControllerIdentifier); + } + + get rootService() { + return this.std.getService('affine:page') as EdgelessRootService; + } + + private _isInsideFrame() { + return this.gfx.grid.has( + this.model.elementBound, + true, + true, + model => model !== this.model && model instanceof FrameBlockModel + ); + } + + private _updateFrameTitleSize() { + const { _nestedFrame, _zoom: zoom } = this; + const { elementBound } = this.model; + const width = this._cachedWidth / zoom; + const height = this._cachedHeight / zoom; + + const { nestedFrameOffset } = frameTitleStyleVars; + if (width && height) { + this.model.externalXYWH = `[${ + elementBound.x + (_nestedFrame ? nestedFrameOffset / zoom : 0) + },${ + elementBound.y + + (_nestedFrame + ? nestedFrameOffset / zoom + : -(height + nestedFrameOffset / zoom)) + },${width},${height}]`; + + this.gfx.grid.update(this.model); + } else { + this.model.externalXYWH = undefined; + } + } + + private _updateStyle() { + if ( + this._frameTitle.length === 0 || + this._editing || + this.gfx.tool.currentToolName$.value === 'frameNavigator' + ) { + this.style.display = 'none'; + return; + } + + const model = this.model; + const bound = Bound.deserialize(model.xywh); + + const { _zoom: zoom } = this; + const { nestedFrameOffset, height } = frameTitleStyleVars; + + const nestedFrame = this._nestedFrame; + const maxWidth = nestedFrame + ? bound.w * zoom - nestedFrameOffset / zoom + : bound.w * zoom; + const hidden = height / zoom >= bound.h; + const transformOperation = [ + `translate(0%, ${nestedFrame ? 0 : -100}%)`, + `translate(${nestedFrame ? nestedFrameOffset : 0}px, ${ + nestedFrame ? nestedFrameOffset : -nestedFrameOffset + }px)`, + ]; + + const anchor = this.gfx.viewport.toViewCoord(bound.x, bound.y); + + this.style.display = ''; + this.style.setProperty('--bg-color', this.colors.background); + this.style.left = `${anchor[0]}px`; + this.style.top = `${anchor[1]}px`; + this.style.display = hidden ? 'none' : 'flex'; + this.style.transform = transformOperation.join(' '); + this.style.maxWidth = `${maxWidth}px`; + this.style.transformOrigin = nestedFrame ? 'top left' : 'bottom left'; + this.style.color = this.colors.text; + } + + override connectedCallback() { + super.connectedCallback(); + + const { _disposables, doc, gfx, rootService } = this; + + this._nestedFrame = this._isInsideFrame(); + + _disposables.add( + doc.slots.blockUpdated.on(payload => { + if ( + (payload.type === 'update' && + payload.props.key === 'xywh' && + doc.getBlock(payload.id)?.model instanceof FrameBlockModel) || + (payload.type === 'add' && payload.flavour === 'affine:frame') + ) { + this._nestedFrame = this._isInsideFrame(); + } + + if ( + payload.type === 'delete' && + payload.model instanceof FrameBlockModel && + payload.model !== this.model + ) { + this._nestedFrame = this._isInsideFrame(); + } + }) + ); + + _disposables.add( + this.model.propsUpdated.on(() => { + this._xywh = this.model.xywh; + this.requestUpdate(); + }) + ); + + _disposables.add( + rootService.selection.slots.updated.on(() => { + this._editing = + rootService.selection.selectedIds[0] === this.model.id && + rootService.selection.editing; + }) + ); + + _disposables.add( + gfx.viewport.viewportUpdated.on(({ zoom }) => { + this._zoom = zoom; + this.requestUpdate(); + }) + ); + + this._zoom = gfx.viewport.zoom; + + const updateTitle = () => { + this._frameTitle = this.model.title.toString().trim(); + }; + _disposables.add(() => { + this.model.title.yText.unobserve(updateTitle); + }); + this.model.title.yText.observe(updateTitle); + + this._frameTitle = this.model.title.toString().trim(); + this._xywh = this.model.xywh; + } + + override firstUpdated() { + this._cachedWidth = this.clientWidth; + this._cachedHeight = this.clientHeight; + this._updateFrameTitleSize(); + } + + override render() { + this._updateStyle(); + return this._frameTitle; + } + + override updated(_changedProperties: Map<string, unknown>) { + if ( + !this.gfx.viewport.viewportBounds.contains(this.model.elementBound) && + !this.gfx.viewport.viewportBounds.isIntersectWithBound( + this.model.elementBound + ) + ) { + return; + } + + let sizeChanged = false; + if ( + this._cachedWidth === 0 || + this._cachedHeight === 0 || + _changedProperties.has('_frameTitle') || + _changedProperties.has('_nestedFrame') || + _changedProperties.has('_xywh') || + _changedProperties.has('_editing') + ) { + this._cachedWidth = this.clientWidth; + this._cachedHeight = this.clientHeight; + sizeChanged = true; + } + if (sizeChanged || _changedProperties.has('_zoom')) { + this._updateFrameTitleSize(); + } + } + + @state() + private accessor _editing = false; + + @state() + private accessor _frameTitle = ''; + + @state() + private accessor _nestedFrame = false; + + @state() + private accessor _xywh: SerializedXYWH | null = null; + + @state() + private accessor _zoom = 1; + + @property({ attribute: false }) + accessor model!: FrameBlockModel; + + @consume({ context: stdContext }) + accessor std!: BlockStdScope; +} diff --git a/blocksuite/blocks/src/root-block/widgets/frame-title/index.ts b/blocksuite/blocks/src/root-block/widgets/frame-title/index.ts new file mode 100644 index 0000000000..31c0e73cb3 --- /dev/null +++ b/blocksuite/blocks/src/root-block/widgets/frame-title/index.ts @@ -0,0 +1,40 @@ +import { FrameBlockModel, type RootBlockModel } from '@blocksuite/affine-model'; +import { WidgetComponent } from '@blocksuite/block-std'; +import { html } from 'lit'; +import { repeat } from 'lit/directives/repeat.js'; + +import type { EdgelessRootBlockComponent } from '../../index.js'; +import type { AffineFrameTitle } from './frame-title.js'; + +export const AFFINE_FRAME_TITLE_WIDGET = 'affine-frame-title-widget'; + +export class AffineFrameTitleWidget extends WidgetComponent< + RootBlockModel, + EdgelessRootBlockComponent +> { + private get _frames() { + return Object.values(this.doc.blocks.value) + .map(({ model }) => model) + .filter(model => model instanceof FrameBlockModel); + } + + getFrameTitle(frame: FrameBlockModel | string) { + const id = typeof frame === 'string' ? frame : frame.id; + const frameTitle = this.shadowRoot?.querySelector( + `affine-frame-title[data-id="${id}"]` + ) as AffineFrameTitle | null; + return frameTitle; + } + + override render() { + return repeat( + this._frames, + ({ id }) => id, + frame => + html`<affine-frame-title + .model=${frame} + data-id=${frame.id} + ></affine-frame-title>` + ); + } +} diff --git a/blocksuite/blocks/src/root-block/widgets/frame-title/styles.ts b/blocksuite/blocks/src/root-block/widgets/frame-title/styles.ts new file mode 100644 index 0000000000..5a3209cb40 --- /dev/null +++ b/blocksuite/blocks/src/root-block/widgets/frame-title/styles.ts @@ -0,0 +1,35 @@ +import { unsafeCSSVarV2 } from '@blocksuite/affine-shared/theme'; +import { css } from 'lit'; + +export const frameTitleStyleVars = { + nestedFrameOffset: 4, + height: 22, + fontSize: 14, +}; + +export const frameTitleStyle = css` + :host { + position: absolute; + display: flex; + align-items: center; + z-index: 1; + border: 1px solid ${unsafeCSSVarV2('edgeless/frame/border/default')}; + border-radius: 4px; + width: fit-content; + height: ${frameTitleStyleVars.height}px; + padding: 0px 4px; + transform-origin: left bottom; + background-color: var(--bg-color); + + font-family: var(--affine-font-family); + font-size: ${frameTitleStyleVars.fontSize}px; + cursor: default; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + + :hover { + background-color: color-mix(in srgb, var(--bg-color), #000000 7%); + } +`; diff --git a/blocksuite/blocks/src/root-block/widgets/image-toolbar/components/image-toolbar.ts b/blocksuite/blocks/src/root-block/widgets/image-toolbar/components/image-toolbar.ts new file mode 100644 index 0000000000..87afcb705d --- /dev/null +++ b/blocksuite/blocks/src/root-block/widgets/image-toolbar/components/image-toolbar.ts @@ -0,0 +1,141 @@ +import { MoreVerticalIcon } from '@blocksuite/affine-components/icons'; +import { createLitPortal } from '@blocksuite/affine-components/portal'; +import type { + EditorIconButton, + MenuItemGroup, +} from '@blocksuite/affine-components/toolbar'; +import { renderGroups } from '@blocksuite/affine-components/toolbar'; +import { assertExists, noop } from '@blocksuite/global/utils'; +import { flip, offset } from '@floating-ui/dom'; +import { html, LitElement } from 'lit'; +import { property, query, state } from 'lit/decorators.js'; +import { styleMap } from 'lit/directives/style-map.js'; + +import type { ImageToolbarContext } from '../context.js'; +import { styles } from '../styles.js'; + +export class AffineImageToolbar extends LitElement { + static override styles = styles; + + private _currentOpenMenu: AbortController | null = null; + + private _popMenuAbortController: AbortController | null = null; + + closeCurrentMenu = () => { + if (this._currentOpenMenu && !this._currentOpenMenu.signal.aborted) { + this._currentOpenMenu.abort(); + this._currentOpenMenu = null; + } + }; + + private _clearPopMenu() { + if (this._popMenuAbortController) { + this._popMenuAbortController.abort(); + this._popMenuAbortController = null; + } + } + + private _toggleMoreMenu() { + // If the menu we're trying to open is already open, return + if ( + this._currentOpenMenu && + !this._currentOpenMenu.signal.aborted && + this._currentOpenMenu === this._popMenuAbortController + ) { + this.closeCurrentMenu(); + this._moreMenuOpen = false; + return; + } + + this.closeCurrentMenu(); + this._popMenuAbortController = new AbortController(); + this._popMenuAbortController.signal.addEventListener('abort', () => { + this._moreMenuOpen = false; + this.onActiveStatusChange(false); + }); + this.onActiveStatusChange(true); + + this._currentOpenMenu = this._popMenuAbortController; + + assertExists(this._moreButton); + + createLitPortal({ + template: html` + <editor-menu-content + data-show + class="image-more-popup-menu" + style=${styleMap({ + '--content-padding': '8px', + '--packed-height': '4px', + })} + > + <div data-size="large" data-orientation="vertical"> + ${renderGroups(this.moreGroups, this.context)} + </div> + </editor-menu-content> + `, + container: this.context.host, + // stacking-context(editor-host) + portalStyles: { + zIndex: 'var(--affine-z-index-popover)', + }, + computePosition: { + referenceElement: this._moreButton, + placement: 'bottom-start', + middleware: [flip(), offset(4)], + autoUpdate: { animationFrame: true }, + }, + abortController: this._popMenuAbortController, + closeOnClickAway: true, + }); + this._moreMenuOpen = true; + } + + override disconnectedCallback() { + super.disconnectedCallback(); + this.closeCurrentMenu(); + this._clearPopMenu(); + } + + override render() { + return html` + <editor-toolbar class="affine-image-toolbar-container" data-without-bg> + ${renderGroups(this.primaryGroups, this.context)} + <editor-icon-button + class="image-toolbar-button more" + aria-label="More" + .tooltip=${'More'} + .tooltipOffset=${4} + .showTooltip=${!this._moreMenuOpen} + @click=${() => this._toggleMoreMenu()} + > + ${MoreVerticalIcon} + </editor-icon-button> + </editor-toolbar> + `; + } + + @query('editor-icon-button.more') + private accessor _moreButton!: EditorIconButton; + + @state() + private accessor _moreMenuOpen = false; + + @property({ attribute: false }) + accessor context!: ImageToolbarContext; + + @property({ attribute: false }) + accessor moreGroups!: MenuItemGroup<ImageToolbarContext>[]; + + @property({ attribute: false }) + accessor onActiveStatusChange: (active: boolean) => void = noop; + + @property({ attribute: false }) + accessor primaryGroups!: MenuItemGroup<ImageToolbarContext>[]; +} + +declare global { + interface HTMLElementTagNameMap { + 'affine-image-toolbar': AffineImageToolbar; + } +} diff --git a/blocksuite/blocks/src/root-block/widgets/image-toolbar/config.ts b/blocksuite/blocks/src/root-block/widgets/image-toolbar/config.ts new file mode 100644 index 0000000000..1ef43d7c8c --- /dev/null +++ b/blocksuite/blocks/src/root-block/widgets/image-toolbar/config.ts @@ -0,0 +1,145 @@ +import { + BookmarkIcon, + CaptionIcon, + CopyIcon, + DeleteIcon, + DownloadIcon, + DuplicateIcon, +} from '@blocksuite/affine-components/icons'; +import type { MenuItemGroup } from '@blocksuite/affine-components/toolbar'; +import { html } from 'lit'; +import { ifDefined } from 'lit/directives/if-defined.js'; + +import type { ImageToolbarContext } from './context.js'; +import { duplicate } from './utils.js'; + +export const PRIMARY_GROUPS: MenuItemGroup<ImageToolbarContext>[] = [ + { + type: 'primary', + items: [ + { + type: 'download', + label: 'Download', + icon: DownloadIcon, + generate: ({ blockComponent }) => { + return { + action: () => { + blockComponent.download(); + }, + render: item => html` + <editor-icon-button + class="image-toolbar-button download" + aria-label=${ifDefined(item.label)} + .tooltip=${item.label} + .tooltipOffset=${4} + @click=${(e: MouseEvent) => { + e.stopPropagation(); + item.action(); + }} + > + ${item.icon} + </editor-icon-button> + `, + }; + }, + }, + { + type: 'caption', + label: 'Caption', + icon: CaptionIcon, + when: ({ doc }) => !doc.readonly, + generate: ({ blockComponent }) => { + return { + action: () => { + blockComponent.captionEditor?.show(); + }, + render: item => html` + <editor-icon-button + class="image-toolbar-button caption" + aria-label=${ifDefined(item.label)} + .tooltip=${item.label} + .tooltipOffset=${4} + @click=${(e: MouseEvent) => { + e.stopPropagation(); + item.action(); + }} + > + ${item.icon} + </editor-icon-button> + `, + }; + }, + }, + ], + }, +]; + +// Clipboard Group +export const clipboardGroup: MenuItemGroup<ImageToolbarContext> = { + type: 'clipboard', + items: [ + { + type: 'copy', + label: 'Copy', + icon: CopyIcon, + action: ({ blockComponent, close }) => { + blockComponent.copy(); + close(); + }, + }, + { + type: 'duplicate', + label: 'Duplicate', + icon: DuplicateIcon, + when: ({ doc }) => !doc.readonly, + action: ({ blockComponent, abortController }) => { + duplicate(blockComponent, abortController); + }, + }, + ], +}; + +// Conversions Group +export const conversionsGroup: MenuItemGroup<ImageToolbarContext> = { + type: 'conversions', + items: [ + { + label: 'Turn into card view', + type: 'turn-into-card-view', + icon: BookmarkIcon, + when: ({ doc, blockComponent }) => { + const supportAttachment = + doc.schema.flavourSchemaMap.has('affine:attachment'); + const readonly = doc.readonly; + return supportAttachment && !readonly && !!blockComponent.blob; + }, + action: ({ blockComponent, close }) => { + blockComponent.convertToCardView(); + close(); + }, + }, + ], +}; + +// Delete Group +export const deleteGroup: MenuItemGroup<ImageToolbarContext> = { + type: 'delete', + items: [ + { + type: 'delete', + label: 'Delete', + icon: DeleteIcon, + when: ({ doc }) => !doc.readonly, + action: ({ doc, blockComponent, close }) => { + doc.deleteBlock(blockComponent.model); + close(); + }, + }, + ], +}; + +export const MORE_GROUPS: MenuItemGroup<ImageToolbarContext>[] = [ + clipboardGroup, + conversionsGroup, + deleteGroup, +]; diff --git a/blocksuite/blocks/src/root-block/widgets/image-toolbar/context.ts b/blocksuite/blocks/src/root-block/widgets/image-toolbar/context.ts new file mode 100644 index 0000000000..c29fb120ba --- /dev/null +++ b/blocksuite/blocks/src/root-block/widgets/image-toolbar/context.ts @@ -0,0 +1,43 @@ +import type { ImageBlockComponent } from '../../../image-block/image-block.js'; +import { MenuContext } from '../../configs/toolbar.js'; + +export class ImageToolbarContext extends MenuContext { + override close = () => { + this.abortController.abort(); + }; + + get doc() { + return this.blockComponent.doc; + } + + get host() { + return this.blockComponent.host; + } + + get selectedBlockModels() { + return [this.blockComponent.model]; + } + + get std() { + return this.blockComponent.std; + } + + constructor( + public blockComponent: ImageBlockComponent, + public abortController: AbortController + ) { + super(); + } + + isEmpty() { + return false; + } + + isMultiple() { + return false; + } + + isSingle() { + return true; + } +} diff --git a/blocksuite/blocks/src/root-block/widgets/image-toolbar/index.ts b/blocksuite/blocks/src/root-block/widgets/image-toolbar/index.ts new file mode 100644 index 0000000000..8cb066fdcc --- /dev/null +++ b/blocksuite/blocks/src/root-block/widgets/image-toolbar/index.ts @@ -0,0 +1,167 @@ +import { HoverController } from '@blocksuite/affine-components/hover'; +import type { + AdvancedMenuItem, + MenuItemGroup, +} from '@blocksuite/affine-components/toolbar'; +import { cloneGroups } from '@blocksuite/affine-components/toolbar'; +import type { ImageBlockModel } from '@blocksuite/affine-model'; +import { WidgetComponent } from '@blocksuite/block-std'; +import { limitShift, shift } from '@floating-ui/dom'; +import { html } from 'lit'; + +import { PAGE_HEADER_HEIGHT } from '../../../_common/consts.js'; +import type { ImageBlockComponent } from '../../../image-block/image-block.js'; +import { getMoreMenuConfig } from '../../configs/toolbar.js'; +import { MORE_GROUPS, PRIMARY_GROUPS } from './config.js'; +import { ImageToolbarContext } from './context.js'; + +export const AFFINE_IMAGE_TOOLBAR_WIDGET = 'affine-image-toolbar-widget'; + +export class AffineImageToolbarWidget extends WidgetComponent< + ImageBlockModel, + ImageBlockComponent +> { + private _hoverController: HoverController | null = null; + + private _isActivated = false; + + private _setHoverController = () => { + this._hoverController = null; + this._hoverController = new HoverController( + this, + ({ abortController }) => { + const imageBlock = this.block; + const selection = this.host.selection; + + const textSelection = selection.find('text'); + if ( + !!textSelection && + (!!textSelection.to || !!textSelection.from.length) + ) { + return null; + } + + const blockSelections = selection.filter('block'); + if ( + blockSelections.length > 1 || + (blockSelections.length === 1 && + blockSelections[0].blockId !== imageBlock.blockId) + ) { + return null; + } + + const imageContainer = + imageBlock.resizableImg ?? imageBlock.fallbackCard; + if (!imageContainer) { + return null; + } + + const context = new ImageToolbarContext(imageBlock, abortController); + + return { + template: html`<affine-image-toolbar + .context=${context} + .primaryGroups=${this.primaryGroups} + .moreGroups=${this.moreGroups} + .onActiveStatusChange=${(active: boolean) => { + this._isActivated = active; + if (!active && !this._hoverController?.isHovering) { + this._hoverController?.abort(); + } + }} + ></affine-image-toolbar>`, + container: this.block, + // stacking-context(editor-host) + portalStyles: { + zIndex: 'var(--affine-z-index-popover)', + }, + computePosition: { + referenceElement: imageContainer, + placement: 'right-start', + middleware: [ + shift({ + crossAxis: true, + padding: { + top: PAGE_HEADER_HEIGHT + 12, + bottom: 12, + right: 12, + }, + limiter: limitShift(), + }), + ], + autoUpdate: true, + }, + }; + }, + { allowMultiple: true } + ); + + const imageBlock = this.block; + this._hoverController.setReference(imageBlock); + this._hoverController.onAbort = () => { + // If the more menu is opened, don't close it. + if (this._isActivated) return; + this._hoverController?.abort(); + return; + }; + }; + + addMoreItems = ( + items: AdvancedMenuItem<ImageToolbarContext>[], + index?: number, + type?: string + ) => { + let group; + if (type) { + group = this.moreGroups.find(g => g.type === type); + } + if (!group) { + group = this.moreGroups[0]; + } + + if (index === undefined) { + group.items.push(...items); + return this; + } + + group.items.splice(index, 0, ...items); + return this; + }; + + addPrimaryItems = ( + items: AdvancedMenuItem<ImageToolbarContext>[], + index?: number + ) => { + if (index === undefined) { + this.primaryGroups[0].items.push(...items); + return this; + } + + this.primaryGroups[0].items.splice(index, 0, ...items); + return this; + }; + + /* + * Caches the more menu items. + * Currently only supports configuring more menu. + */ + moreGroups: MenuItemGroup<ImageToolbarContext>[] = cloneGroups(MORE_GROUPS); + + primaryGroups: MenuItemGroup<ImageToolbarContext>[] = + cloneGroups(PRIMARY_GROUPS); + + override firstUpdated() { + if (this.doc.getParent(this.model.id)?.flavour === 'affine:surface') { + return; + } + + this.moreGroups = getMoreMenuConfig(this.std).configure(this.moreGroups); + this._setHoverController(); + } +} + +declare global { + interface HTMLElementTagNameMap { + [AFFINE_IMAGE_TOOLBAR_WIDGET]: AffineImageToolbarWidget; + } +} diff --git a/blocksuite/blocks/src/root-block/widgets/image-toolbar/styles.ts b/blocksuite/blocks/src/root-block/widgets/image-toolbar/styles.ts new file mode 100644 index 0000000000..9fee848179 --- /dev/null +++ b/blocksuite/blocks/src/root-block/widgets/image-toolbar/styles.ts @@ -0,0 +1,24 @@ +import { css } from 'lit'; + +export const styles = css` + :host { + position: absolute; + top: 0; + right: 0; + z-index: var(--affine-z-index-popover); + } + + .affine-image-toolbar-container { + height: 24px; + gap: 4px; + padding: 4px; + margin: 0; + } + + .image-toolbar-button { + color: var(--affine-icon-color); + background-color: var(--affine-background-primary-color); + box-shadow: var(--affine-shadow-1); + border-radius: 4px; + } +`; diff --git a/blocksuite/blocks/src/root-block/widgets/image-toolbar/utils.ts b/blocksuite/blocks/src/root-block/widgets/image-toolbar/utils.ts new file mode 100644 index 0000000000..cd045df926 --- /dev/null +++ b/blocksuite/blocks/src/root-block/widgets/image-toolbar/utils.ts @@ -0,0 +1,54 @@ +import { + getBlockProps, + isInsidePageEditor, +} from '@blocksuite/affine-shared/utils'; +import { assertExists } from '@blocksuite/global/utils'; + +import type { ImageBlockComponent } from '../../../image-block/image-block.js'; + +export function duplicate( + block: ImageBlockComponent, + abortController?: AbortController +) { + const model = block.model; + const blockProps = getBlockProps(model); + const { + width: _width, + height: _height, + xywh: _xywh, + rotate: _rotate, + zIndex: _zIndex, + ...duplicateProps + } = blockProps; + + const { doc } = model; + const parent = doc.getParent(model); + assertExists(parent, 'Parent not found'); + + const index = parent?.children.indexOf(model); + const duplicateId = doc.addBlock( + model.flavour as BlockSuite.Flavour, + duplicateProps, + parent, + index + 1 + ); + abortController?.abort(); + + const editorHost = block.host; + editorHost.updateComplete + .then(() => { + const { selection } = editorHost; + selection.setGroup('note', [ + selection.create('block', { + blockId: duplicateId, + }), + ]); + if (isInsidePageEditor(editorHost)) { + const duplicateElement = editorHost.view.getBlock(duplicateId); + if (duplicateElement) { + duplicateElement.scrollIntoView(true); + } + } + }) + .catch(console.error); +} diff --git a/blocksuite/blocks/src/root-block/widgets/index.ts b/blocksuite/blocks/src/root-block/widgets/index.ts new file mode 100644 index 0000000000..389fd57d56 --- /dev/null +++ b/blocksuite/blocks/src/root-block/widgets/index.ts @@ -0,0 +1,59 @@ +export { + AFFINE_AI_PANEL_WIDGET, + AffineAIPanelWidget, +} from './ai-panel/ai-panel.js'; +export { + type AffineAIPanelState, + type AffineAIPanelWidgetConfig, +} from './ai-panel/type.js'; +export { AffineCodeToolbarWidget } from './code-toolbar/index.js'; +export { AffineDocRemoteSelectionWidget } from './doc-remote-selection/doc-remote-selection.js'; +export { AffineDragHandleWidget } from './drag-handle/drag-handle.js'; +export { + AFFINE_EDGELESS_COPILOT_WIDGET, + EdgelessCopilotWidget, +} from './edgeless-copilot/index.js'; +export { EdgelessCopilotToolbarEntry } from './edgeless-copilot-panel/toolbar-entry.js'; +export { EdgelessRemoteSelectionWidget } from './edgeless-remote-selection/index.js'; +export { AffineEdgelessZoomToolbarWidget } from './edgeless-zoom-toolbar/index.js'; +export { + EDGELESS_ELEMENT_TOOLBAR_WIDGET, + EdgelessElementToolbarWidget, +} from './element-toolbar/index.js'; +export { + AFFINE_EMBED_CARD_TOOLBAR_WIDGET, + EmbedCardToolbar, +} from './embed-card-toolbar/embed-card-toolbar.js'; +export { toolbarDefaultConfig } from './format-bar/config.js'; +export { + AFFINE_FORMAT_BAR_WIDGET, + AffineFormatBarWidget, +} from './format-bar/format-bar.js'; +export { AffineFrameTitleWidget } from './frame-title/index.js'; +export { AffineImageToolbarWidget } from './image-toolbar/index.js'; +export { AffineInnerModalWidget } from './inner-modal/inner-modal.js'; +export * from './keyboard-toolbar/index.js'; +export { + type LinkedMenuGroup, + type LinkedMenuItem, + type LinkedWidgetConfig, + LinkedWidgetUtils, +} from './linked-doc/config.js'; +export { + // It's used in the AFFiNE! + showImportModal, +} from './linked-doc/import-doc/index.js'; +export { AffineLinkedDocWidget } from './linked-doc/index.js'; +export { AffineModalWidget } from './modal/modal.js'; +export { AffinePageDraggingAreaWidget } from './page-dragging-area/page-dragging-area.js'; +export { AffinePieMenuWidget } from './pie-menu/index.js'; +export { + type AffineSlashMenuActionItem, + type AffineSlashMenuContext, + type AffineSlashMenuGroupDivider, + type AffineSlashMenuItem, + type AffineSlashMenuItemGenerator, + AffineSlashMenuWidget, + type AffineSlashSubMenu, +} from './slash-menu/index.js'; +export { AffineSurfaceRefToolbar } from './surface-ref-toolbar/surface-ref-toolbar.js'; diff --git a/blocksuite/blocks/src/root-block/widgets/inner-modal/inner-modal.ts b/blocksuite/blocks/src/root-block/widgets/inner-modal/inner-modal.ts new file mode 100644 index 0000000000..c95b8a2b4e --- /dev/null +++ b/blocksuite/blocks/src/root-block/widgets/inner-modal/inner-modal.ts @@ -0,0 +1,64 @@ +import { WidgetComponent } from '@blocksuite/block-std'; +import { + autoUpdate, + computePosition, + type FloatingElement, + type ReferenceElement, + size, +} from '@floating-ui/dom'; +import { nothing } from 'lit'; + +export const AFFINE_INNER_MODAL_WIDGET = 'affine-inner-modal-widget'; + +export class AffineInnerModalWidget extends WidgetComponent { + private _getTarget?: () => ReferenceElement; + + get target(): ReferenceElement { + if (this._getTarget) { + return this._getTarget(); + } + return document.body; + } + + open( + modal: FloatingElement, + ops: { onClose?: () => void } + ): { close(): void } { + const cancel = autoUpdate(this.target, modal, () => { + computePosition(this.target, modal, { + middleware: [ + size({ + apply: ({ rects }) => { + Object.assign(modal.style, { + left: `${rects.reference.x}px`, + top: `${rects.reference.y}px`, + width: `${rects.reference.width}px`, + height: `${rects.reference.height}px`, + }); + }, + }), + ], + }).catch(console.error); + }); + const close = () => { + modal.remove(); + ops.onClose?.(); + cancel(); + }; + return { close }; + } + + override render() { + return nothing; + } + + setTarget(fn: () => ReferenceElement) { + this._getTarget = fn; + } +} + +declare global { + interface HTMLElementTagNameMap { + [AFFINE_INNER_MODAL_WIDGET]: AffineInnerModalWidget; + } +} diff --git a/blocksuite/blocks/src/root-block/widgets/keyboard-toolbar/config.ts b/blocksuite/blocks/src/root-block/widgets/keyboard-toolbar/config.ts new file mode 100644 index 0000000000..f2a4efecda --- /dev/null +++ b/blocksuite/blocks/src/root-block/widgets/keyboard-toolbar/config.ts @@ -0,0 +1,1011 @@ +import { + getInlineEditorByModel, + insertContent, + REFERENCE_NODE, +} from '@blocksuite/affine-components/rich-text'; +import { toast } from '@blocksuite/affine-components/toast'; +import type { FrameBlockModel } from '@blocksuite/affine-model'; +import type { AffineTextAttributes } from '@blocksuite/affine-shared/types'; +import { + createDefaultDoc, + openFileOrFiles, +} from '@blocksuite/affine-shared/utils'; +import type { BlockStdScope } from '@blocksuite/block-std'; +import { viewPresets } from '@blocksuite/data-view/view-presets'; +import { assertType } from '@blocksuite/global/utils'; +import { + AttachmentIcon, + BoldIcon, + BulletedListIcon, + CheckBoxCheckLinearIcon, + CloseIcon, + CodeBlockIcon, + CodeIcon, + CollapseTabIcon, + CopyIcon, + DatabaseKanbanViewIcon, + DatabaseTableViewIcon, + DeleteIcon, + DividerIcon, + DuplicateIcon, + FontIcon, + FrameIcon, + GithubIcon, + GroupIcon, + ImageIcon, + ItalicIcon, + LinkedPageIcon, + LinkIcon, + LoomLogoIcon, + NewPageIcon, + NowIcon, + NumberedListIcon, + PlusIcon, + QuoteIcon, + RedoIcon, + RightTabIcon, + StrikeThroughIcon, + TeXIcon, + TextIcon, + TodayIcon, + TomorrowIcon, + UnderLineIcon, + UndoIcon, + YesterdayIcon, + YoutubeDuotoneIcon, +} from '@blocksuite/icons/lit'; +import { cssVarV2 } from '@toeverything/theme/v2'; +import type { TemplateResult } from 'lit'; + +import { toggleEmbedCardCreateModal } from '../../../_common/components/embed-card/modal/embed-card-create-modal.js'; +import { addSiblingAttachmentBlocks } from '../../../attachment-block/utils.js'; +import { getSurfaceBlock } from '../../../surface-ref-block/utils.js'; +import type { PageRootBlockComponent } from '../../page/page-root-block.js'; +import { formatDate, formatTime } from '../../utils/misc.js'; +import type { AffineLinkedDocWidget } from '../linked-doc/index.js'; +import { + FigmaDuotoneIcon, + HeadingIcon, + HighLightDuotoneIcon, + TextBackgroundDuotoneIcon, + TextColorIcon, +} from './icons.js'; + +export type KeyboardToolbarConfig = { + items: KeyboardToolbarItem[]; + /** + * @description Whether to use the screen height as the keyboard height when the virtual keyboard API is not supported. + * It is useful when the app is running in a webview and the keyboard is not overlaid on the content. + * @default false + */ + useScreenHeight?: boolean; +}; + +export type KeyboardToolbarItem = + | KeyboardToolbarActionItem + | KeyboardSubToolbarConfig + | KeyboardToolPanelConfig; + +export type KeyboardIconType = + | TemplateResult + | ((ctx: KeyboardToolbarContext) => TemplateResult); + +export type KeyboardToolbarActionItem = { + name: string; + icon: KeyboardIconType; + background?: string | ((ctx: KeyboardToolbarContext) => string | undefined); + /** + * @default true + * @description Whether to show the item in the toolbar. + */ + showWhen?: (ctx: KeyboardToolbarContext) => boolean; + /** + * @default false + * @description Whether to set the item as disabled status. + */ + disableWhen?: (ctx: KeyboardToolbarContext) => boolean; + /** + * @description The action to be executed when the item is clicked. + */ + action?: (ctx: KeyboardToolbarContext) => void | Promise<void>; +}; + +export type KeyboardSubToolbarConfig = { + icon: KeyboardIconType; + items: KeyboardToolbarItem[]; +}; + +export type KeyboardToolbarContext = { + std: BlockStdScope; + rootComponent: PageRootBlockComponent; + /** + * Close tool bar, and blur the focus if blur is true, default is false + */ + closeToolbar: (blur?: boolean) => void; + /** + * Close current tool panel and show virtual keyboard + */ + closeToolPanel: () => void; +}; + +export type KeyboardToolPanelConfig = { + icon: KeyboardIconType; + activeIcon?: KeyboardIconType; + activeBackground?: string; + groups: (KeyboardToolPanelGroup | DynamicKeyboardToolPanelGroup)[]; +}; + +export type KeyboardToolPanelGroup = { + name: string; + items: KeyboardToolbarActionItem[]; +}; + +export type DynamicKeyboardToolPanelGroup = ( + ctx: KeyboardToolbarContext +) => KeyboardToolPanelGroup | null; + +const textToolActionItems: KeyboardToolbarActionItem[] = [ + { + name: 'Text', + icon: TextIcon(), + showWhen: ({ std }) => + std.doc.schema.flavourSchemaMap.has('affine:paragraph'), + action: ({ std }) => { + std.command.exec('updateBlockType', { + flavour: 'affine:paragraph', + props: { type: 'text' }, + }); + }, + }, + ...([1, 2, 3, 4, 5, 6] as const).map(i => ({ + name: `Heading ${i}`, + icon: HeadingIcon(i), + showWhen: ({ std }: KeyboardToolbarContext) => + std.doc.schema.flavourSchemaMap.has('affine:paragraph'), + action: ({ std }: KeyboardToolbarContext) => { + std.command.exec('updateBlockType', { + flavour: 'affine:paragraph', + props: { type: `h${i}` }, + }); + }, + })), + { + name: 'CodeBlock', + showWhen: ({ std }) => std.doc.schema.flavourSchemaMap.has('affine:code'), + icon: CodeBlockIcon(), + action: ({ std }) => { + std.command.exec('updateBlockType', { + flavour: 'affine:code', + }); + }, + }, + { + name: 'Quote', + showWhen: ({ std }) => + std.doc.schema.flavourSchemaMap.has('affine:paragraph'), + icon: QuoteIcon(), + action: ({ std }) => { + std.command.exec('updateBlockType', { + flavour: 'affine:paragraph', + props: { type: 'quote' }, + }); + }, + }, + { + name: 'Divider', + icon: DividerIcon(), + showWhen: ({ std }) => + std.doc.schema.flavourSchemaMap.has('affine:divider'), + action: ({ std }) => { + std.command.exec('updateBlockType', { + flavour: 'affine:divider', + props: { type: 'divider' }, + }); + }, + }, + { + name: 'Inline equation', + icon: TeXIcon(), + showWhen: ({ std }) => + std.doc.schema.flavourSchemaMap.has('affine:paragraph'), + action: ({ std }) => { + std.command.chain().getTextSelection().insertInlineLatex().run(); + }, + }, +]; + +const listToolActionItems: KeyboardToolbarActionItem[] = [ + { + name: 'BulletedList', + icon: BulletedListIcon(), + showWhen: ({ std }) => std.doc.schema.flavourSchemaMap.has('affine:list'), + action: ({ std }) => { + std.command.exec('updateBlockType', { + flavour: 'affine:list', + props: { + type: 'bulleted', + }, + }); + }, + }, + { + name: 'NumberedList', + icon: NumberedListIcon(), + showWhen: ({ std }) => std.doc.schema.flavourSchemaMap.has('affine:list'), + action: ({ std }) => { + std.command.exec('updateBlockType', { + flavour: 'affine:list', + props: { + type: 'numbered', + }, + }); + }, + }, + { + name: 'CheckBox', + icon: CheckBoxCheckLinearIcon(), + showWhen: ({ std }) => std.doc.schema.flavourSchemaMap.has('affine:list'), + action: ({ std }) => { + std.command.exec('updateBlockType', { + flavour: 'affine:list', + props: { + type: 'todo', + }, + }); + }, + }, +]; + +const pageToolGroup: KeyboardToolPanelGroup = { + name: 'Page', + items: [ + { + name: 'NewPage', + icon: NewPageIcon(), + showWhen: ({ std }) => + std.doc.schema.flavourSchemaMap.has('affine:embed-linked-doc'), + action: ({ std }) => { + std.command + .chain() + .getSelectedModels() + .inline(({ selectedModels }) => { + const newDoc = createDefaultDoc(std.doc.collection); + if (!selectedModels?.length) return; + insertContent(std.host, selectedModels[0], REFERENCE_NODE, { + reference: { + type: 'LinkedPage', + pageId: newDoc.id, + }, + }); + }) + .run(); + }, + }, + { + name: 'LinkedPage', + icon: LinkedPageIcon(), + showWhen: ({ std, rootComponent }) => { + const linkedDocWidget = std.view.getWidget( + 'affine-linked-doc-widget', + rootComponent.model.id + ); + if (!linkedDocWidget) return false; + + return std.doc.schema.flavourSchemaMap.has('affine:embed-linked-doc'); + }, + action: ({ rootComponent, closeToolPanel }) => { + const { std } = rootComponent; + + const linkedDocWidget = std.view.getWidget( + 'affine-linked-doc-widget', + rootComponent.model.id + ); + if (!linkedDocWidget) return; + assertType<AffineLinkedDocWidget>(linkedDocWidget); + + const triggerKey = linkedDocWidget.config.triggerKeys[0]; + + std.command + .chain() + .getSelectedModels() + .inline(ctx => { + const { selectedModels } = ctx; + if (!selectedModels?.length) return; + + const currentModel = selectedModels[0]; + insertContent(std.host, currentModel, triggerKey); + + const inlineEditor = getInlineEditorByModel(std.host, currentModel); + // Wait for range to be updated + inlineEditor?.slots.inlineRangeSync.once(() => { + linkedDocWidget.show('mobile'); + closeToolPanel(); + }); + }) + .run(); + }, + }, + ], +}; + +const contentMediaToolGroup: KeyboardToolPanelGroup = { + name: 'Content & Media', + items: [ + { + name: 'Image', + icon: ImageIcon(), + showWhen: ({ std }) => + std.doc.schema.flavourSchemaMap.has('affine:image'), + action: ({ std }) => { + std.command + .chain() + .getSelectedModels() + .insertImages({ removeEmptyLine: true }) + .run(); + }, + }, + { + name: 'Link', + icon: LinkIcon(), + showWhen: ({ std }) => + std.doc.schema.flavourSchemaMap.has('affine:bookmark'), + action: async ({ std }) => { + const { selectedModels } = std.command.exec('getSelectedModels'); + const model = selectedModels?.[0]; + if (!model) return; + + const parentModel = std.doc.getParent(model); + if (!parentModel) return; + + const index = parentModel.children.indexOf(model) + 1; + await toggleEmbedCardCreateModal( + std.host, + 'Links', + 'The added link will be displayed as a card view.', + { mode: 'page', parentModel, index } + ); + if (model.text?.length === 0) { + std.doc.deleteBlock(model); + } + }, + }, + { + name: 'Attachment', + icon: AttachmentIcon(), + showWhen: ({ std }) => + std.doc.schema.flavourSchemaMap.has('affine:attachment'), + action: async ({ std }) => { + const { selectedModels } = std.command.exec('getSelectedModels'); + const model = selectedModels?.[0]; + if (!model) return; + + const file = await openFileOrFiles(); + if (!file) return; + + const attachmentService = std.getService('affine:attachment'); + if (!attachmentService) return; + const maxFileSize = attachmentService.maxFileSize; + + await addSiblingAttachmentBlocks(std.host, [file], maxFileSize, model); + if (model.text?.length === 0) { + std.doc.deleteBlock(model); + } + }, + }, + { + name: 'Youtube', + icon: YoutubeDuotoneIcon({ + style: `color: white`, + }), + showWhen: ({ std }) => + std.doc.schema.flavourSchemaMap.has('affine:embed-youtube'), + action: async ({ std }) => { + const { selectedModels } = std.command.exec('getSelectedModels'); + const model = selectedModels?.[0]; + if (!model) return; + + const parentModel = std.doc.getParent(model); + if (!parentModel) return; + + const index = parentModel.children.indexOf(model) + 1; + await toggleEmbedCardCreateModal( + std.host, + 'YouTube', + 'The added YouTube video link will be displayed as an embed view.', + { mode: 'page', parentModel, index } + ); + if (model.text?.length === 0) { + std.doc.deleteBlock(model); + } + }, + }, + { + name: 'Github', + icon: GithubIcon({ style: `color: black` }), + showWhen: ({ std }) => + std.doc.schema.flavourSchemaMap.has('affine:embed-github'), + action: async ({ std }) => { + const { selectedModels } = std.command.exec('getSelectedModels'); + const model = selectedModels?.[0]; + if (!model) return; + + const parentModel = std.doc.getParent(model); + if (!parentModel) return; + + const index = parentModel.children.indexOf(model) + 1; + await toggleEmbedCardCreateModal( + std.host, + 'GitHub', + 'The added GitHub issue or pull request link will be displayed as a card view.', + { mode: 'page', parentModel, index } + ); + if (model.text?.length === 0) { + std.doc.deleteBlock(model); + } + }, + }, + { + name: 'Figma', + icon: FigmaDuotoneIcon, + showWhen: ({ std }) => + std.doc.schema.flavourSchemaMap.has('affine:embed-figma'), + action: async ({ std }) => { + const { selectedModels } = std.command.exec('getSelectedModels'); + const model = selectedModels?.[0]; + if (!model) return; + + const parentModel = std.doc.getParent(model); + if (!parentModel) { + return; + } + const index = parentModel.children.indexOf(model) + 1; + await toggleEmbedCardCreateModal( + std.host, + 'Figma', + 'The added Figma link will be displayed as an embed view.', + { mode: 'page', parentModel, index } + ); + if (model.text?.length === 0) { + std.doc.deleteBlock(model); + } + }, + }, + { + name: 'Loom', + icon: LoomLogoIcon({ style: `color: #625DF5` }), + showWhen: ({ std }) => + std.doc.schema.flavourSchemaMap.has('affine:embed-loom'), + action: async ({ std }) => { + const { selectedModels } = std.command.exec('getSelectedModels'); + const model = selectedModels?.[0]; + if (!model) return; + + const parentModel = std.doc.getParent(model); + if (!parentModel) return; + + const index = parentModel.children.indexOf(model) + 1; + await toggleEmbedCardCreateModal( + std.host, + 'Loom', + 'The added Loom video link will be displayed as an embed view.', + { mode: 'page', parentModel, index } + ); + if (model.text?.length === 0) { + std.doc.deleteBlock(model); + } + }, + }, + { + name: 'Equation', + icon: TeXIcon(), + showWhen: ({ std }) => + std.doc.schema.flavourSchemaMap.has('affine:latex'), + action: ({ std }) => { + std.command + .chain() + .getSelectedModels() + .insertLatexBlock({ + place: 'after', + removeEmptyLine: true, + }) + .run(); + }, + }, + ], +}; + +const documentGroupFrameToolGroup: DynamicKeyboardToolPanelGroup = ({ + std, +}) => { + const { doc } = std; + + const frameModels = doc + .getBlocksByFlavour('affine:frame') + .map(block => block.model) as FrameBlockModel[]; + + const frameItems = frameModels.map<KeyboardToolbarActionItem>(frameModel => ({ + name: 'Frame: ' + frameModel.title.toString(), + icon: FrameIcon(), + action: ({ std }) => { + std.command + .chain() + .getSelectedModels() + .insertSurfaceRefBlock({ + reference: frameModel.id, + place: 'after', + removeEmptyLine: true, + }) + .run(); + }, + })); + + const surfaceModel = getSurfaceBlock(doc); + + const groupElements = surfaceModel + ? surfaceModel.getElementsByType('group') + : []; + + const groupItems = groupElements.map<KeyboardToolbarActionItem>(group => ({ + name: 'Group: ' + group.title.toString(), + icon: GroupIcon(), + action: ({ std }) => { + std.command + .chain() + .getSelectedModels() + .insertSurfaceRefBlock({ + reference: group.id, + place: 'after', + removeEmptyLine: true, + }) + .run(); + }, + })); + + const items = [...frameItems, ...groupItems]; + + if (items.length === 0) return null; + + return { + name: 'Document Group&Frame', + items, + }; +}; + +const dateToolGroup: KeyboardToolPanelGroup = { + name: 'Date', + items: [ + { + name: 'Today', + icon: TodayIcon(), + action: ({ std }) => { + const { selectedModels } = std.command.exec('getSelectedModels'); + const model = selectedModels?.[0]; + if (!model) return; + + insertContent(std.host, model, formatDate(new Date())); + }, + }, + { + name: 'Tomorrow', + icon: TomorrowIcon(), + action: ({ std }) => { + const { selectedModels } = std.command.exec('getSelectedModels'); + const model = selectedModels?.[0]; + if (!model) return; + + const tomorrow = new Date(); + tomorrow.setDate(tomorrow.getDate() + 1); + insertContent(std.host, model, formatDate(tomorrow)); + }, + }, + { + name: 'Yesterday', + icon: YesterdayIcon(), + action: ({ std }) => { + const { selectedModels } = std.command.exec('getSelectedModels'); + const model = selectedModels?.[0]; + if (!model) return; + + const yesterday = new Date(); + yesterday.setDate(yesterday.getDate() - 1); + insertContent(std.host, model, formatDate(yesterday)); + }, + }, + { + name: 'Now', + icon: NowIcon(), + action: ({ std }) => { + const { selectedModels } = std.command.exec('getSelectedModels'); + const model = selectedModels?.[0]; + if (!model) return; + + insertContent(std.host, model, formatTime(new Date())); + }, + }, + ], +}; + +const databaseToolGroup: KeyboardToolPanelGroup = { + name: 'Database', + items: [ + { + name: 'Table view', + icon: DatabaseTableViewIcon(), + showWhen: ({ std }) => + std.doc.schema.flavourSchemaMap.has('affine:database'), + action: ({ std }) => { + std.command + .chain() + .getSelectedModels() + .insertDatabaseBlock({ + viewType: viewPresets.tableViewMeta.type, + place: 'after', + removeEmptyLine: true, + }) + .run(); + }, + }, + { + name: 'Kanban view', + icon: DatabaseKanbanViewIcon(), + showWhen: ({ std }) => + std.doc.schema.flavourSchemaMap.has('affine:database'), + action: ({ std }) => { + std.command + .chain() + .getSelectedModels() + .insertDatabaseBlock({ + viewType: viewPresets.kanbanViewMeta.type, + place: 'after', + removeEmptyLine: true, + }) + .run(); + }, + }, + ], +}; + +const moreToolPanel: KeyboardToolPanelConfig = { + icon: PlusIcon(), + activeIcon: CloseIcon({ + style: `color: ${cssVarV2('icon/activated')}`, + }), + activeBackground: cssVarV2('edgeless/selection/selectionMarqueeBackground'), + groups: [ + { name: 'Basic', items: textToolActionItems }, + { name: 'List', items: listToolActionItems }, + pageToolGroup, + contentMediaToolGroup, + documentGroupFrameToolGroup, + dateToolGroup, + databaseToolGroup, + ], +}; + +const textToolPanel: KeyboardToolPanelConfig = { + icon: TextIcon(), + groups: [ + { + name: 'Turn into', + items: textToolActionItems, + }, + ], +}; + +const textStyleToolItems: KeyboardToolbarItem[] = [ + { + name: 'Bold', + icon: BoldIcon(), + background: ({ std }) => { + const { textStyle } = std.command.exec('getTextStyle'); + return textStyle?.bold ? '#00000012' : ''; + }, + action: ({ std }) => { + std.command.exec('toggleBold'); + }, + }, + { + name: 'Italic', + icon: ItalicIcon(), + background: ({ std }) => { + const { textStyle } = std.command.exec('getTextStyle'); + return textStyle?.italic ? '#00000012' : ''; + }, + action: ({ std }) => { + std.command.exec('toggleItalic'); + }, + }, + { + name: 'UnderLine', + icon: UnderLineIcon(), + background: ({ std }) => { + const { textStyle } = std.command.exec('getTextStyle'); + return textStyle?.underline ? '#00000012' : ''; + }, + action: ({ std }) => { + std.command.exec('toggleUnderline'); + }, + }, + { + name: 'StrikeThrough', + icon: StrikeThroughIcon(), + background: ({ std }) => { + const { textStyle } = std.command.exec('getTextStyle'); + return textStyle?.strike ? '#00000012' : ''; + }, + action: ({ std }) => { + std.command.exec('toggleStrike'); + }, + }, + { + name: 'Code', + icon: CodeIcon(), + background: ({ std }) => { + const { textStyle } = std.command.exec('getTextStyle'); + return textStyle?.code ? '#00000012' : ''; + }, + action: ({ std }) => { + std.command.exec('toggleCode'); + }, + }, + { + name: 'Link', + icon: LinkIcon(), + background: ({ std }) => { + const { textStyle } = std.command.exec('getTextStyle'); + return textStyle?.link ? '#00000012' : ''; + }, + action: ({ std }) => { + std.command.exec('toggleLink'); + }, + }, +]; + +const highlightToolPanel: KeyboardToolPanelConfig = { + icon: ({ std }) => { + const { textStyle } = std.command.exec('getTextStyle'); + if (textStyle?.color) { + return HighLightDuotoneIcon(textStyle.color); + } else { + return HighLightDuotoneIcon(cssVarV2('icon/primary')); + } + }, + groups: [ + { + name: 'Color', + items: [ + { + name: 'Default Color', + icon: TextColorIcon(cssVarV2('text/highlight/fg/orange')), + }, + ...( + [ + 'red', + 'orange', + 'yellow', + 'green', + 'teal', + 'blue', + 'purple', + 'grey', + ] as const + ).map<KeyboardToolbarActionItem>(color => ({ + name: color.charAt(0).toUpperCase() + color.slice(1), + icon: TextColorIcon(cssVarV2(`text/highlight/fg/${color}`)), + action: ({ std }) => { + const payload = { + styles: { + color: cssVarV2(`text/highlight/fg/${color}`), + } satisfies AffineTextAttributes, + }; + std.command + .chain() + .try(chain => [ + chain.getTextSelection().formatText(payload), + chain.getBlockSelections().formatBlock(payload), + chain.formatNative(payload), + ]) + .run(); + }, + })), + ], + }, + { + name: 'Background', + items: [ + { + name: 'Default Color', + icon: TextBackgroundDuotoneIcon(cssVarV2('text/highlight/bg/orange')), + }, + ...( + [ + 'red', + 'orange', + 'yellow', + 'green', + 'teal', + 'blue', + 'purple', + 'grey', + ] as const + ).map<KeyboardToolbarActionItem>(color => ({ + name: color.charAt(0).toUpperCase() + color.slice(1), + icon: TextBackgroundDuotoneIcon( + cssVarV2(`text/highlight/bg/${color}`) + ), + action: ({ std }) => { + const payload = { + styles: { + background: cssVarV2(`text/highlight/bg/${color}`), + } satisfies AffineTextAttributes, + }; + std.command + .chain() + .try(chain => [ + chain.getTextSelection().formatText(payload), + chain.getBlockSelections().formatBlock(payload), + chain.formatNative(payload), + ]) + .run(); + }, + })), + ], + }, + ], +}; + +const textSubToolbarConfig: KeyboardSubToolbarConfig = { + icon: FontIcon(), + items: [ + textToolPanel, + ...textStyleToolItems, + { + name: 'InlineTex', + icon: TeXIcon(), + action: ({ std }) => { + std.command.chain().getTextSelection().insertInlineLatex().run(); + }, + }, + highlightToolPanel, + ], +}; + +export const defaultKeyboardToolbarConfig: KeyboardToolbarConfig = { + items: [ + moreToolPanel, + // TODO(@L-Sun): add ai function in AFFiNE side + // { icon: AiIcon(iconStyle) }, + textSubToolbarConfig, + { + name: 'Image', + icon: ImageIcon(), + showWhen: ({ std }) => + std.doc.schema.flavourSchemaMap.has('affine:image'), + action: ({ std }) => { + std.command + .chain() + .getSelectedModels() + .insertImages({ removeEmptyLine: true }) + .run(); + }, + }, + { + name: 'Attachment', + icon: AttachmentIcon(), + showWhen: ({ std }) => + std.doc.schema.flavourSchemaMap.has('affine:attachment'), + action: async ({ std }) => { + const { selectedModels } = std.command.exec('getSelectedModels'); + const model = selectedModels?.[0]; + if (!model) return; + + const file = await openFileOrFiles(); + if (!file) return; + + const attachmentService = std.getService('affine:attachment'); + if (!attachmentService) return; + const maxFileSize = attachmentService.maxFileSize; + + await addSiblingAttachmentBlocks(std.host, [file], maxFileSize, model); + if (model.text?.length === 0) { + std.doc.deleteBlock(model); + } + }, + }, + { + name: 'Undo', + icon: UndoIcon(), + disableWhen: ({ std }) => !std.doc.canUndo, + action: ({ std }) => { + std.doc.undo(); + }, + }, + { + name: 'Redo', + icon: RedoIcon(), + disableWhen: ({ std }) => !std.doc.canRedo, + action: ({ std }) => { + std.doc.redo(); + }, + }, + { + name: 'RightTab', + icon: RightTabIcon(), + disableWhen: ({ std }) => { + const [success] = std.command + .chain() + .tryAll(chain => [chain.canIndentParagraph(), chain.canIndentList()]) + .run(); + return !success; + }, + action: ({ std }) => { + std.command + .chain() + .tryAll(chain => [ + chain.canIndentParagraph().indentParagraph(), + chain.canIndentList().indentList(), + ]) + .run(); + }, + }, + ...listToolActionItems, + ...textToolActionItems.filter(({ name }) => name === 'Divider'), + { + name: 'CollapseTab', + icon: CollapseTabIcon(), + disableWhen: ({ std }) => { + const [success] = std.command + .chain() + .tryAll(chain => [chain.canDedentParagraph(), chain.canDedentList()]) + .run(); + return !success; + }, + action: ({ std }) => { + std.command + .chain() + .tryAll(chain => [ + chain.canDedentParagraph().dedentParagraph(), + chain.canDedentList().dedentList(), + ]) + .run(); + }, + }, + { + name: 'Copy', + icon: CopyIcon(), + action: ({ std }) => { + std.command + .chain() + .getSelectedModels() + .with({ + onCopy: () => { + toast(std.host, 'Copied to clipboard'); + }, + }) + .draftSelectedModels() + .copySelectedModels() + .run(); + }, + }, + { + name: 'Duplicate', + icon: DuplicateIcon(), + action: ({ std }) => { + std.command + .chain() + .getSelectedModels() + .draftSelectedModels() + .duplicateSelectedModels() + .run(); + }, + }, + { + name: 'Delete', + icon: DeleteIcon(), + action: ({ std }) => { + std.command.chain().getSelectedModels().deleteSelectedModels().run(); + }, + }, + ], + useScreenHeight: false, +}; diff --git a/blocksuite/blocks/src/root-block/widgets/keyboard-toolbar/effects.ts b/blocksuite/blocks/src/root-block/widgets/keyboard-toolbar/effects.ts new file mode 100644 index 0000000000..0aa638ef4e --- /dev/null +++ b/blocksuite/blocks/src/root-block/widgets/keyboard-toolbar/effects.ts @@ -0,0 +1,28 @@ +import { + AFFINE_KEYBOARD_TOOLBAR_WIDGET, + AffineKeyboardToolbarWidget, +} from './index.js'; +import { + AFFINE_KEYBOARD_TOOL_PANEL, + AffineKeyboardToolPanel, +} from './keyboard-tool-panel.js'; +import { + AFFINE_KEYBOARD_TOOLBAR, + AffineKeyboardToolbar, +} from './keyboard-toolbar.js'; + +export function effects() { + customElements.define( + AFFINE_KEYBOARD_TOOLBAR_WIDGET, + AffineKeyboardToolbarWidget + ); + customElements.define(AFFINE_KEYBOARD_TOOLBAR, AffineKeyboardToolbar); + customElements.define(AFFINE_KEYBOARD_TOOL_PANEL, AffineKeyboardToolPanel); +} + +declare global { + interface HTMLElementTagNameMap { + [AFFINE_KEYBOARD_TOOLBAR]: AffineKeyboardToolbar; + [AFFINE_KEYBOARD_TOOL_PANEL]: AffineKeyboardToolPanel; + } +} diff --git a/blocksuite/blocks/src/root-block/widgets/keyboard-toolbar/icons.ts b/blocksuite/blocks/src/root-block/widgets/keyboard-toolbar/icons.ts new file mode 100644 index 0000000000..df059e2bde --- /dev/null +++ b/blocksuite/blocks/src/root-block/widgets/keyboard-toolbar/icons.ts @@ -0,0 +1,138 @@ +import { + Heading1Icon, + Heading2Icon, + Heading3Icon, + Heading4Icon, + Heading5Icon, + Heading6Icon, +} from '@blocksuite/icons/lit'; +import { cssVarV2 } from '@toeverything/theme/v2'; +import { html } from 'lit'; + +export function HeadingIcon(i: 1 | 2 | 3 | 4 | 5 | 6) { + switch (i) { + case 1: + return Heading1Icon(); + case 2: + return Heading2Icon(); + case 3: + return Heading3Icon(); + case 4: + return Heading4Icon(); + case 5: + return Heading5Icon(); + case 6: + return Heading6Icon(); + default: + return Heading1Icon(); + } +} + +export const HighLightDuotoneIcon = (color: string) => + html`<svg + xmlns="http://www.w3.org/2000/svg" + width="24" + height="24" + viewBox="0 0 24 24" + fill="none" + > + <path + d="M5.8291 16.441L7.91757 18.5295L6.57811 19.8689C6.53119 19.9158 6.46406 19.9364 6.3989 19.9239L3.37036 19.3412C3.21285 19.3109 3.15331 19.1168 3.26673 19.0034L5.8291 16.441Z" + fill="${color}" + /> + <path + fill-rule="evenodd" + clip-rule="evenodd" + d="M19.0095 3.63759C17.9526 2.58067 16.26 2.516 15.1255 3.48919L7.32135 10.1837C6.35438 11.0132 6.05275 12.3823 6.58163 13.5414L6.73501 13.8775L5.67697 14.9356C5.30169 15.3108 5.30169 15.9193 5.67697 16.2946L8.06379 18.6814C8.43907 19.0567 9.04752 19.0567 9.4228 18.6814L10.4808 17.6234L10.8171 17.7768C11.9761 18.3057 13.3452 18.0041 14.1747 17.0371L20.8692 9.23294C21.8424 8.09846 21.7778 6.40588 20.7208 5.34896L19.0095 3.63759ZM16.1021 4.62769C16.6415 4.16498 17.4463 4.19572 17.9488 4.69825L19.6602 6.40962C20.1627 6.91215 20.1935 7.7169 19.7307 8.25631L14.6424 14.188L10.1704 9.71604L16.1021 4.62769ZM9.02857 10.6955L8.29798 11.3222C7.83822 11.7166 7.6948 12.3676 7.94627 12.9187L8.29785 13.6892C8.4348 13.9893 8.37947 14.3544 8.13372 14.6001L7.11878 15.6151L8.74329 17.2396L9.75812 16.2247C10.004 15.9789 10.3691 15.9236 10.6693 16.0606L11.4398 16.4122C11.9908 16.6636 12.6418 16.5202 13.0362 16.0605L13.6629 15.3299L9.02857 10.6955Z" + fill="${cssVarV2('icon/primary')}" + /> + </svg>`; + +export const TextColorIcon = (color: string) => + html`<svg + xmlns="http://www.w3.org/2000/svg" + width="32" + height="32" + viewBox="0 0 32 32" + fill="none" + > + <path + fill-rule="evenodd" + clip-rule="evenodd" + d="M14.0627 6.16255C14.385 5.30291 15.2068 4.7334 16.1249 4.7334C17.043 4.7334 17.8648 5.30291 18.1872 6.16255L23.7279 20.9378C23.9219 21.455 23.6599 22.0314 23.1427 22.2253C22.6256 22.4192 22.0492 22.1572 21.8553 21.6401L20.2289 17.3031H12.021L10.3946 21.6401C10.2007 22.1572 9.62428 22.4192 9.10716 22.2253C8.59004 22.0314 8.32803 21.455 8.52195 20.9378L14.0627 6.16255ZM12.771 15.3031H19.4789L16.3146 6.8648C16.2849 6.78576 16.2094 6.7334 16.1249 6.7334C16.0405 6.7334 15.965 6.78576 15.9353 6.8648L12.771 15.3031Z" + fill="${cssVarV2('icon/primary')}" + /> + <rect + x="5.45837" + y="24" + width="21.3333" + height="3.33333" + rx="1" + fill=${color} + /> + </svg>`; + +export const TextBackgroundDuotoneIcon = (color: string) => + html`<svg + xmlns="http://www.w3.org/2000/svg" + width="32" + height="32" + viewBox="0 0 32 32" + fill="none" + > + <path + fill-rule="evenodd" + clip-rule="evenodd" + d="M4.57507 7.33336C4.57507 5.60287 5.97791 4.20003 7.70841 4.20003H25.0417C26.7722 4.20003 28.1751 5.60287 28.1751 7.33336V24.6667C28.1751 26.3972 26.7722 27.8 25.0417 27.8H7.70841C5.97791 27.8 4.57507 26.3972 4.57507 24.6667V7.33336Z" + fill="${color}" + /> + <path + fill-rule="evenodd" + clip-rule="evenodd" + d="M4.57495 7.33333C4.57495 5.60284 5.97779 4.2 7.70828 4.2H25.0416C26.7721 4.2 28.175 5.60284 28.175 7.33333V24.6667C28.175 26.3972 26.7721 27.8 25.0416 27.8H7.70828C5.97779 27.8 4.57495 26.3972 4.57495 24.6667V7.33333ZM7.70828 5.13333C6.49326 5.13333 5.50828 6.1183 5.50828 7.33333V24.6667C5.50828 25.8817 6.49326 26.8667 7.70828 26.8667H25.0416C26.2566 26.8667 27.2416 25.8817 27.2416 24.6667V7.33333C27.2416 6.1183 26.2566 5.13333 25.0416 5.13333H7.70828Z" + fill="black" + fill-opacity="0.22" + /> + <path + fill-rule="evenodd" + clip-rule="evenodd" + d="M14.5379 10.0064C14.8251 9.24064 15.5571 8.73332 16.375 8.73332C17.1928 8.73332 17.9249 9.24064 18.2121 10.0064L22.6446 21.8266C22.8386 22.3438 22.5766 22.9202 22.0594 23.1141C21.5423 23.308 20.9659 23.046 20.772 22.5289L19.5196 19.1891H13.2304L11.978 22.5289C11.7841 23.046 11.2076 23.308 10.6905 23.1141C10.1734 22.9202 9.9114 22.3438 10.1053 21.8266L14.5379 10.0064ZM13.9804 17.1891H18.7696L16.375 10.8035L13.9804 17.1891Z" + fill="${cssVarV2('text/primary')}" + /> + </svg>`; + +export const FigmaDuotoneIcon = html`<svg + width="32" + height="32" + viewBox="0 0 24 24" + fill="none" + xmlns="http://www.w3.org/2000/svg" +> + <g id="Figma_Duotone"> + <path + id="Vector" + d="M8.41842 22.5027C10.3047 22.5027 11.8356 20.9719 11.8356 19.0856V15.6685H8.41842C6.53216 15.6685 5.00128 17.1993 5.00128 19.0856C5.00128 20.9719 6.53216 22.5027 8.41842 22.5027Z" + fill="#0ACF83" + /> + <path + id="Vector_2" + d="M5.00128 12.2514C5.00128 10.3651 6.53216 8.83423 8.41842 8.83423H11.8356V15.6685H8.41842C6.53216 15.6685 5.00128 14.1376 5.00128 12.2514Z" + fill="#A259FF" + /> + <path + id="Vector_3" + d="M5.00146 5.41714C5.00146 3.53088 6.53234 2 8.4186 2H11.8357V8.83428H8.4186C6.53234 8.83428 5.00146 7.3034 5.00146 5.41714Z" + fill="#F24E1E" + /> + <path + id="Vector_4" + d="M11.8356 2H15.2527C17.139 2 18.6699 3.53088 18.6699 5.41714C18.6699 7.3034 17.139 8.83428 15.2527 8.83428H11.8356V2Z" + fill="#FF7262" + /> + <path + id="Vector_5" + d="M18.6699 12.2514C18.6699 14.1376 17.139 15.6685 15.2527 15.6685C13.3665 15.6685 11.8356 14.1376 11.8356 12.2514C11.8356 10.3651 13.3665 8.83423 15.2527 8.83423C17.139 8.83423 18.6699 10.3651 18.6699 12.2514Z" + fill="#1ABCFE" + /> + </g> +</svg> `; diff --git a/blocksuite/blocks/src/root-block/widgets/keyboard-toolbar/index.ts b/blocksuite/blocks/src/root-block/widgets/keyboard-toolbar/index.ts new file mode 100644 index 0000000000..87d083b213 --- /dev/null +++ b/blocksuite/blocks/src/root-block/widgets/keyboard-toolbar/index.ts @@ -0,0 +1,97 @@ +import type { RootBlockModel } from '@blocksuite/affine-model'; +import { WidgetComponent } from '@blocksuite/block-std'; +import { IS_MOBILE } from '@blocksuite/global/env'; +import { assertType } from '@blocksuite/global/utils'; +import { signal } from '@preact/signals-core'; +import { html, nothing } from 'lit'; + +import type { PageRootBlockComponent } from '../../page/page-root-block.js'; +import { defaultKeyboardToolbarConfig } from './config.js'; + +export * from './config.js'; + +export const AFFINE_KEYBOARD_TOOLBAR_WIDGET = 'affine-keyboard-toolbar-widget'; + +export class AffineKeyboardToolbarWidget extends WidgetComponent< + RootBlockModel, + PageRootBlockComponent +> { + private _close = (blur: boolean) => { + if (blur) { + if (document.activeElement === this._docTitle) { + this._docTitle?.blur(); + } else if (document.activeElement === this.block.rootComponent) { + this.block.rootComponent?.blur(); + } + } + this._show$.value = false; + }; + + private readonly _show$ = signal(false); + + private get _docTitle(): HTMLDivElement | null { + const docTitle = this.std.host + .closest('.affine-page-viewport') + ?.querySelector('doc-title rich-text .inline-editor'); + assertType<HTMLDivElement | null>(docTitle); + return docTitle; + } + + get config() { + return { + ...defaultKeyboardToolbarConfig, + ...this.std.getConfig('affine:page')?.keyboardToolbar, + }; + } + + override connectedCallback(): void { + super.connectedCallback(); + + const { rootComponent } = this.block; + if (rootComponent) { + this.disposables.addFromEvent(rootComponent, 'focus', () => { + this._show$.value = true; + }); + this.disposables.addFromEvent(rootComponent, 'blur', () => { + this._show$.value = false; + }); + } + + if (this._docTitle) { + this.disposables.addFromEvent(this._docTitle, 'focus', () => { + this._show$.value = true; + }); + this.disposables.addFromEvent(this._docTitle, 'blur', () => { + this._show$.value = false; + }); + } + } + + override render() { + if ( + this.doc.readonly || + !IS_MOBILE || + !this.doc.awarenessStore.getFlag('enable_mobile_keyboard_toolbar') + ) + return nothing; + + if (!this._show$.value) return nothing; + + if (!this.block.rootComponent) return nothing; + + return html`<blocksuite-portal + .shadowDom=${false} + .template=${html`<affine-keyboard-toolbar + .config=${this.config} + .rootComponent=${this.block.rootComponent} + .close=${this._close} + ></affine-keyboard-toolbar> `} + ></blocksuite-portal>`; + } +} + +declare global { + interface HTMLElementTagNameMap { + [AFFINE_KEYBOARD_TOOLBAR_WIDGET]: AffineKeyboardToolbarWidget; + } +} diff --git a/blocksuite/blocks/src/root-block/widgets/keyboard-toolbar/keyboard-tool-panel.ts b/blocksuite/blocks/src/root-block/widgets/keyboard-toolbar/keyboard-tool-panel.ts new file mode 100644 index 0000000000..a43b99308f --- /dev/null +++ b/blocksuite/blocks/src/root-block/widgets/keyboard-toolbar/keyboard-tool-panel.ts @@ -0,0 +1,100 @@ +import { + PropTypes, + requiredProperties, + ShadowlessElement, +} from '@blocksuite/block-std'; +import { SignalWatcher, WithDisposable } from '@blocksuite/global/utils'; +import { html, nothing, type PropertyValues } from 'lit'; +import { property } from 'lit/decorators.js'; +import { repeat } from 'lit/directives/repeat.js'; + +import type { + KeyboardIconType, + KeyboardToolbarActionItem, + KeyboardToolbarContext, + KeyboardToolPanelConfig, + KeyboardToolPanelGroup, +} from './config.js'; +import { keyboardToolPanelStyles } from './styles.js'; + +export const AFFINE_KEYBOARD_TOOL_PANEL = 'affine-keyboard-tool-panel'; + +@requiredProperties({ + context: PropTypes.object, +}) +export class AffineKeyboardToolPanel extends SignalWatcher( + WithDisposable(ShadowlessElement) +) { + static override styles = keyboardToolPanelStyles; + + private readonly _handleItemClick = (item: KeyboardToolbarActionItem) => { + if (item.disableWhen && item.disableWhen(this.context)) return; + if (item.action) { + Promise.resolve(item.action(this.context)).catch(console.error); + } + }; + + private _renderGroup(group: KeyboardToolPanelGroup) { + const items = group.items.filter( + item => item.showWhen?.(this.context) ?? true + ); + + return html`<div class="keyboard-tool-panel-group"> + <div class="keyboard-tool-panel-group-header">${group.name}</div> + <div class="keyboard-tool-panel-group-item-container"> + ${repeat( + items, + item => item.name, + item => this._renderItem(item) + )} + </div> + </div>`; + } + + private _renderIcon(icon: KeyboardIconType) { + return typeof icon === 'function' ? icon(this.context) : icon; + } + + private _renderItem(item: KeyboardToolbarActionItem) { + return html`<div class="keyboard-tool-panel-item"> + <button @click=${() => this._handleItemClick(item)}> + ${this._renderIcon(item.icon)} + </button> + <span>${item.name}</span> + </div>`; + } + + override render() { + if (!this.config) return nothing; + + const groups = this.config.groups + .map(group => (typeof group === 'function' ? group(this.context) : group)) + .filter((group): group is KeyboardToolPanelGroup => group !== null); + + return repeat( + groups, + group => group.name, + group => this._renderGroup(group) + ); + } + + protected override willUpdate(changedProperties: PropertyValues<this>) { + if (changedProperties.has('height')) { + this.style.height = `${this.height}px`; + if (this.height === 0) { + this.style.padding = '0'; + } else { + this.style.padding = ''; + } + } + } + + @property({ attribute: false }) + accessor config: KeyboardToolPanelConfig | null = null; + + @property({ attribute: false }) + accessor context!: KeyboardToolbarContext; + + @property({ type: Number }) + accessor height = 0; +} diff --git a/blocksuite/blocks/src/root-block/widgets/keyboard-toolbar/keyboard-toolbar.ts b/blocksuite/blocks/src/root-block/widgets/keyboard-toolbar/keyboard-toolbar.ts new file mode 100644 index 0000000000..38d3d4e214 --- /dev/null +++ b/blocksuite/blocks/src/root-block/widgets/keyboard-toolbar/keyboard-toolbar.ts @@ -0,0 +1,328 @@ +import { + VirtualKeyboardController, + type VirtualKeyboardControllerConfig, +} from '@blocksuite/affine-components/virtual-keyboard'; +import { + PropTypes, + requiredProperties, + ShadowlessElement, +} from '@blocksuite/block-std'; +import { SignalWatcher, WithDisposable } from '@blocksuite/global/utils'; +import { ArrowLeftBigIcon, KeyboardIcon } from '@blocksuite/icons/lit'; +import { effect, signal } from '@preact/signals-core'; +import { html } from 'lit'; +import { property } from 'lit/decorators.js'; +import { repeat } from 'lit/directives/repeat.js'; +import { styleMap } from 'lit/directives/style-map.js'; +import { when } from 'lit/directives/when.js'; + +import { PageRootBlockComponent } from '../../page/page-root-block.js'; +import type { + KeyboardIconType, + KeyboardToolbarConfig, + KeyboardToolbarContext, + KeyboardToolbarItem, + KeyboardToolPanelConfig, +} from './config.js'; +import { keyboardToolbarStyles, TOOLBAR_HEIGHT } from './styles.js'; +import { + isKeyboardSubToolBarConfig, + isKeyboardToolBarActionItem, + isKeyboardToolPanelConfig, +} from './utils.js'; + +export const AFFINE_KEYBOARD_TOOLBAR = 'affine-keyboard-toolbar'; + +@requiredProperties({ + config: PropTypes.object, + rootComponent: PropTypes.instanceOf(PageRootBlockComponent), +}) +export class AffineKeyboardToolbar extends SignalWatcher( + WithDisposable(ShadowlessElement) +) { + static override styles = keyboardToolbarStyles; + + private readonly _closeToolPanel = () => { + if (!this._isPanelOpened) return; + + this._currentPanelIndex$.value = -1; + this._keyboardController.show(); + }; + + private readonly _currentPanelIndex$ = signal(-1); + + private readonly _goPrevToolbar = () => { + if (!this._isSubToolbarOpened) return; + + if (this._isPanelOpened) this._closeToolPanel(); + + this._path$.value = this._path$.value.slice(0, -1); + }; + + private readonly _handleItemClick = ( + item: KeyboardToolbarItem, + index: number + ) => { + if (isKeyboardToolBarActionItem(item)) { + item.action && + Promise.resolve(item.action(this._context)).catch(console.error); + } else if (isKeyboardSubToolBarConfig(item)) { + this._closeToolPanel(); + this._path$.value = [...this._path$.value, index]; + } else if (isKeyboardToolPanelConfig(item)) { + if (this._currentPanelIndex$.value === index) { + this._closeToolPanel(); + } else { + this._currentPanelIndex$.value = index; + this._keyboardController.hide(); + this.scrollCurrentBlockIntoView(); + } + } + this._lastActiveItem$.value = item; + }; + + private readonly _keyboardController = new VirtualKeyboardController(this); + + private readonly _lastActiveItem$ = signal<KeyboardToolbarItem | null>(null); + + /** This field records the panel static height, which dose not aim to control the panel opening */ + private readonly _panelHeight$ = signal(0); + + private readonly _path$ = signal<number[]>([]); + + private scrollCurrentBlockIntoView = () => { + const { std } = this.rootComponent; + std.command + .chain() + .getSelectedModels() + .inline(({ selectedModels }) => { + if (!selectedModels?.length) return; + + const block = std.view.getBlock(selectedModels[0].id); + if (!block) return; + + const { y: y1 } = this.getBoundingClientRect(); + const { bottom: y2 } = block.getBoundingClientRect(); + const gap = 8; + + if (y2 < y1 + gap) return; + + scrollTo({ + top: window.scrollY + y2 - y1 + gap, + behavior: 'instant', + }); + }) + .run(); + }; + + private get _context(): KeyboardToolbarContext { + return { + std: this.rootComponent.std, + rootComponent: this.rootComponent, + closeToolbar: (blur = false) => { + this.close(blur); + }, + closeToolPanel: () => { + this._closeToolPanel(); + }, + }; + } + + private get _currentPanelConfig(): KeyboardToolPanelConfig | null { + if (!this._isPanelOpened) return null; + + const result = this._currentToolbarItems[this._currentPanelIndex$.value]; + + return isKeyboardToolPanelConfig(result) ? result : null; + } + + private get _currentToolbarItems(): KeyboardToolbarItem[] { + let items = this.config.items; + // eslint-disable-next-line @typescript-eslint/prefer-for-of + for (let i = 0; i < this._path$.value.length; i++) { + const index = this._path$.value[i]; + if (isKeyboardSubToolBarConfig(items[index])) { + items = items[index].items; + } else { + break; + } + } + + return items.filter(item => + isKeyboardToolBarActionItem(item) + ? (item.showWhen?.(this._context) ?? true) + : true + ); + } + + private get _isPanelOpened() { + return this._currentPanelIndex$.value !== -1; + } + + private get _isSubToolbarOpened() { + return this._path$.value.length > 0; + } + + get virtualKeyboardControllerConfig(): VirtualKeyboardControllerConfig { + return { + useScreenHeight: this.config.useScreenHeight ?? false, + inputElement: this.rootComponent, + }; + } + + private _renderIcon(icon: KeyboardIconType) { + return typeof icon === 'function' ? icon(this._context) : icon; + } + + private _renderItem(item: KeyboardToolbarItem, index: number) { + let icon = item.icon; + let style = styleMap({}); + const disabled = + ('disableWhen' in item && item.disableWhen?.(this._context)) ?? false; + + if (isKeyboardToolBarActionItem(item)) { + const background = + typeof item.background === 'function' + ? item.background(this._context) + : item.background; + if (background) + style = styleMap({ + background: background, + }); + } else if (isKeyboardToolPanelConfig(item)) { + const { activeIcon, activeBackground } = item; + const active = this._currentPanelIndex$.value === index; + + if (active && activeIcon) icon = activeIcon; + if (active && activeBackground) + style = styleMap({ background: activeBackground }); + } + + return html`<icon-button + size="36px" + style=${style} + ?disabled=${disabled} + @click=${() => { + this._handleItemClick(item, index); + }} + > + ${this._renderIcon(icon)} + </icon-button>`; + } + + private _renderItems() { + if (document.activeElement !== this.rootComponent) + return html`<div class="item-container"></div>`; + + const goPrevToolbarAction = when( + this._isSubToolbarOpened, + () => + html`<icon-button size="36px" @click=${this._goPrevToolbar}> + ${ArrowLeftBigIcon()} + </icon-button>` + ); + + return html`<div class="item-container"> + ${goPrevToolbarAction} + ${repeat(this._currentToolbarItems, (item, index) => + this._renderItem(item, index) + )} + </div>`; + } + + private _renderKeyboardButton() { + return html`<div class="keyboard-container"> + <icon-button + size="36px" + @click=${() => { + this.close(true); + }} + > + ${KeyboardIcon()} + </icon-button> + </div>`; + } + + override connectedCallback() { + super.connectedCallback(); + + // prevent editor blur when click item in toolbar + this.disposables.addFromEvent(this, 'pointerdown', e => { + e.preventDefault(); + }); + + this.disposables.add( + effect(() => { + if (this._keyboardController.opened) { + this._panelHeight$.value = this._keyboardController.keyboardHeight; + } else if (this._isPanelOpened && this._panelHeight$.peek() === 0) { + this._panelHeight$.value = 260; + } + }) + ); + + this.disposables.add( + effect(() => { + if (this._keyboardController.opened && !this.config.useScreenHeight) { + document.body.style.paddingBottom = `${this._keyboardController.keyboardHeight + TOOLBAR_HEIGHT}px`; + } else if (this._isPanelOpened) { + document.body.style.paddingBottom = `${this._panelHeight$.value + TOOLBAR_HEIGHT}px`; + } else { + document.body.style.paddingBottom = ''; + } + }) + ); + + this.disposables.add( + effect(() => { + const std = this.rootComponent.std; + std.selection.value; + // wait cursor updated + requestAnimationFrame(() => { + this.scrollCurrentBlockIntoView(); + }); + }) + ); + } + + override disconnectedCallback() { + super.disconnectedCallback(); + document.body.style.paddingBottom = ''; + } + + override firstUpdated() { + // workaround for the virtual keyboard showing transition animation + setTimeout(() => { + this.scrollCurrentBlockIntoView(); + }, 700); + } + + override render() { + this.style.bottom = + this.config.useScreenHeight && this._keyboardController.opened + ? `${-this._panelHeight$.value}px` + : '0px'; + + return html` + <div class="keyboard-toolbar"> + ${this._renderItems()} + <div class="divider"></div> + ${this._renderKeyboardButton()} + </div> + <affine-keyboard-tool-panel + .config=${this._currentPanelConfig} + .context=${this._context} + height=${this._panelHeight$.value} + ></affine-keyboard-tool-panel> + `; + } + + @property({ attribute: false }) + accessor close: (blur: boolean) => void = () => {}; + + @property({ attribute: false }) + accessor config!: KeyboardToolbarConfig; + + @property({ attribute: false }) + accessor rootComponent!: PageRootBlockComponent; +} diff --git a/blocksuite/blocks/src/root-block/widgets/keyboard-toolbar/styles.ts b/blocksuite/blocks/src/root-block/widgets/keyboard-toolbar/styles.ts new file mode 100644 index 0000000000..0ec0a580dc --- /dev/null +++ b/blocksuite/blocks/src/root-block/widgets/keyboard-toolbar/styles.ts @@ -0,0 +1,148 @@ +import { unsafeCSSVarV2 } from '@blocksuite/affine-shared/theme'; +import { css } from 'lit'; + +import { scrollbarStyle } from '../../../_common/components/utils.js'; + +export const TOOLBAR_HEIGHT = 46; + +export const keyboardToolbarStyles = css` + affine-keyboard-toolbar { + position: fixed; + display: block; + width: 100vw; + } + + .keyboard-toolbar { + width: 100%; + height: ${TOOLBAR_HEIGHT}px; + display: inline-flex; + align-items: center; + padding: 0px 8px; + box-sizing: border-box; + gap: 8px; + z-index: var(--affine-z-index-popover); + + background-color: ${unsafeCSSVarV2('layer/background/primary')}; + border-top: 0.5px solid ${unsafeCSSVarV2('layer/insideBorder/border')}; + box-shadow: 0px -4px 10px 0px rgba(0, 0, 0, 0.05); + + > div { + padding-top: 4px; + } + > div:not(.item-container) { + padding-bottom: 4px; + } + + icon-button svg { + width: 24px; + height: 24px; + } + } + + .item-container { + flex: 1; + display: flex; + overflow-x: auto; + gap: 8px; + padding-bottom: 0px; + + icon-button { + flex: 0 0 auto; + } + } + + .item-container::-webkit-scrollbar { + display: none; + } + + .divider { + height: 24px; + border: 0.5px solid ${unsafeCSSVarV2('layer/insideBorder/border')}; + } +`; + +export const keyboardToolPanelStyles = css` + affine-keyboard-tool-panel { + display: flex; + flex-direction: column; + gap: 24px; + width: 100%; + padding: 16px 4px 8px 8px; + overflow-y: auto; + box-sizing: border-box; + background-color: ${unsafeCSSVarV2('layer/background/primary')}; + } + + ${scrollbarStyle('affine-keyboard-tool-panel')} + + .keyboard-tool-panel-group { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 12px; + align-self: stretch; + } + + .keyboard-tool-panel-group-header { + color: ${unsafeCSSVarV2('text/secondary')}; + + /* Footnote/Emphasized */ + font-family: -apple-system, BlinkMacSystemFont, sans-serif; + font-size: 13px; + font-style: normal; + font-weight: 590; + line-height: 18px; /* 138.462% */ + } + + .keyboard-tool-panel-group-item-container { + width: 100%; + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + column-gap: 12px; + row-gap: 12px; + } + + .keyboard-tool-panel-item { + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + gap: 4px; + + button { + display: flex; + padding: 16px; + justify-content: center; + align-items: center; + gap: 10px; + align-self: stretch; + + border: none; + border-radius: 4px; + color: ${unsafeCSSVarV2('icon/primary')}; + background: ${unsafeCSSVarV2('layer/background/secondary')}; + } + + button:active { + background: #00000012; + } + + button svg { + width: 32px; + height: 32px; + } + + span { + width: 100%; + font-family: -apple-system, BlinkMacSystemFont, sans-serif; + font-size: 13px; + font-weight: 400; + line-height: 18px; + text-align: center; + text-overflow: ellipsis; + white-space: nowrap; + overflow-x: hidden; + color: ${unsafeCSSVarV2('text/secondary')}; + } + } +`; diff --git a/blocksuite/blocks/src/root-block/widgets/keyboard-toolbar/utils.ts b/blocksuite/blocks/src/root-block/widgets/keyboard-toolbar/utils.ts new file mode 100644 index 0000000000..f89c50504b --- /dev/null +++ b/blocksuite/blocks/src/root-block/widgets/keyboard-toolbar/utils.ts @@ -0,0 +1,24 @@ +import type { + KeyboardSubToolbarConfig, + KeyboardToolbarActionItem, + KeyboardToolbarItem, + KeyboardToolPanelConfig, +} from './config.js'; + +export function isKeyboardToolBarActionItem( + item: KeyboardToolbarItem +): item is KeyboardToolbarActionItem { + return 'action' in item; +} + +export function isKeyboardSubToolBarConfig( + item: KeyboardToolbarItem +): item is KeyboardSubToolbarConfig { + return 'items' in item; +} + +export function isKeyboardToolPanelConfig( + item: KeyboardToolbarItem +): item is KeyboardToolPanelConfig { + return 'groups' in item; +} diff --git a/blocksuite/blocks/src/root-block/widgets/linked-doc/config.ts b/blocksuite/blocks/src/root-block/widgets/linked-doc/config.ts new file mode 100644 index 0000000000..60f8083ba6 --- /dev/null +++ b/blocksuite/blocks/src/root-block/widgets/linked-doc/config.ts @@ -0,0 +1,237 @@ +import { + ImportIcon, + LinkedDocIcon, + LinkedEdgelessIcon, + NewDocIcon, +} from '@blocksuite/affine-components/icons'; +import { + type AffineInlineEditor, + insertLinkedNode, +} from '@blocksuite/affine-components/rich-text'; +import { toast } from '@blocksuite/affine-components/toast'; +import { + DocModeProvider, + TelemetryProvider, +} from '@blocksuite/affine-shared/services'; +import { + createDefaultDoc, + isFuzzyMatch, + type Signal, +} from '@blocksuite/affine-shared/utils'; +import type { BlockStdScope, EditorHost } from '@blocksuite/block-std'; +import type { InlineRange } from '@blocksuite/inline'; +import type { TemplateResult } from 'lit'; + +import { showImportModal } from './import-doc/index.js'; + +export interface LinkedWidgetConfig { + /** + * The first item of the trigger keys will be the primary key + * e.g. @, [[ + */ + triggerKeys: [string, ...string[]]; + /** + * Convert trigger key to primary key (the first item of the trigger keys) + * [[ -> @ + */ + convertTriggerKey: boolean; + ignoreBlockTypes: (keyof BlockSuite.BlockModels)[]; + getMenus: ( + query: string, + abort: () => void, + editorHost: EditorHost, + inlineEditor: AffineInlineEditor, + abortSignal: AbortSignal + ) => Promise<LinkedMenuGroup[]> | LinkedMenuGroup[]; + + mobile: { + useScreenHeight?: boolean; + /** + * The linked doc menu widget will scroll the container to make sure the input cursor is visible in viewport. + * It accepts a selector string, HTMLElement or Window + * + * @default getViewportElement(editorHost) this is the scrollable container in playground + */ + scrollContainer?: string | HTMLElement | Window; + /** + * The offset between the top of viewport and the input cursor + * + * @default 46 The height of header in playground + */ + scrollTopOffset?: number | (() => number); + }; +} + +export type LinkedMenuItem = { + key: string; + name: string | TemplateResult<1>; + icon: TemplateResult<1>; + suffix?: string | TemplateResult<1>; + // disabled?: boolean; + action: () => Promise<void> | void; +}; + +export type LinkedMenuGroup = { + name: string; + items: LinkedMenuItem[] | Signal<LinkedMenuItem[]>; + styles?: string; + // maximum quantity displayed by default + maxDisplay?: number; + // copywriting when display quantity exceeds + overflowText?: string; +}; + +export type LinkedDocContext = { + std: BlockStdScope; + inlineEditor: AffineInlineEditor; + startRange: InlineRange; + triggerKey: string; + config: LinkedWidgetConfig; + close: () => void; +}; + +const DEFAULT_DOC_NAME = 'Untitled'; +const DISPLAY_NAME_LENGTH = 8; + +export function createLinkedDocMenuGroup( + query: string, + abort: () => void, + editorHost: EditorHost, + inlineEditor: AffineInlineEditor +) { + const doc = editorHost.doc; + const { docMetas } = doc.collection.meta; + const filteredDocList = docMetas + .filter(({ id }) => id !== doc.id) + .filter(({ title }) => isFuzzyMatch(title, query)); + const MAX_DOCS = 6; + + return { + name: 'Link to Doc', + items: filteredDocList.map(doc => ({ + key: doc.id, + name: doc.title || DEFAULT_DOC_NAME, + icon: + editorHost.std.get(DocModeProvider).getPrimaryMode(doc.id) === + 'edgeless' + ? LinkedEdgelessIcon + : LinkedDocIcon, + action: () => { + abort(); + insertLinkedNode({ + inlineEditor, + docId: doc.id, + }); + editorHost.std + .getOptional(TelemetryProvider) + ?.track('LinkedDocCreated', { + control: 'linked doc', + module: 'inline @', + type: 'doc', + other: 'existing doc', + }); + }, + })), + maxDisplay: MAX_DOCS, + overflowText: `${filteredDocList.length - MAX_DOCS} more docs`, + }; +} + +export function createNewDocMenuGroup( + query: string, + abort: () => void, + editorHost: EditorHost, + inlineEditor: AffineInlineEditor +): LinkedMenuGroup { + const doc = editorHost.doc; + const docName = query || DEFAULT_DOC_NAME; + const displayDocName = + docName.slice(0, DISPLAY_NAME_LENGTH) + + (docName.length > DISPLAY_NAME_LENGTH ? '..' : ''); + + return { + name: 'New Doc', + items: [ + { + key: 'create', + name: `Create "${displayDocName}" doc`, + icon: NewDocIcon, + action: () => { + abort(); + const docName = query; + const newDoc = createDefaultDoc(doc.collection, { + title: docName, + }); + insertLinkedNode({ + inlineEditor, + docId: newDoc.id, + }); + const telemetryService = + editorHost.std.getOptional(TelemetryProvider); + telemetryService?.track('LinkedDocCreated', { + control: 'new doc', + module: 'inline @', + type: 'doc', + other: 'new doc', + }); + telemetryService?.track('DocCreated', { + control: 'new doc', + module: 'inline @', + type: 'doc', + }); + }, + }, + { + key: 'import', + name: 'Import', + icon: ImportIcon, + action: () => { + abort(); + const onSuccess = ( + docIds: string[], + options: { + importedCount: number; + } + ) => { + toast( + editorHost, + `Successfully imported ${options.importedCount} Doc${options.importedCount > 1 ? 's' : ''}.` + ); + for (const docId of docIds) { + insertLinkedNode({ + inlineEditor, + docId, + }); + } + }; + const onFail = (message: string) => { + toast(editorHost, message); + }; + showImportModal({ + collection: doc.collection, + onSuccess, + onFail, + }); + }, + }, + ], + }; +} + +export function getMenus( + query: string, + abort: () => void, + editorHost: EditorHost, + inlineEditor: AffineInlineEditor +): Promise<LinkedMenuGroup[]> { + return Promise.resolve([ + createLinkedDocMenuGroup(query, abort, editorHost, inlineEditor), + createNewDocMenuGroup(query, abort, editorHost, inlineEditor), + ]); +} + +export const LinkedWidgetUtils = { + createLinkedDocMenuGroup, + createNewDocMenuGroup, + insertLinkedNode, +}; diff --git a/blocksuite/blocks/src/root-block/widgets/linked-doc/effects.ts b/blocksuite/blocks/src/root-block/widgets/linked-doc/effects.ts new file mode 100644 index 0000000000..dda5747aba --- /dev/null +++ b/blocksuite/blocks/src/root-block/widgets/linked-doc/effects.ts @@ -0,0 +1,15 @@ +import { ImportDoc } from './import-doc/import-doc.js'; +import { AFFINE_LINKED_DOC_WIDGET, AffineLinkedDocWidget } from './index.js'; +import { LinkedDocPopover } from './linked-doc-popover.js'; +import { AffineMobileLinkedDocMenu } from './mobile-linked-doc-menu.js'; + +export function effects() { + customElements.define('affine-linked-doc-popover', LinkedDocPopover); + customElements.define(AFFINE_LINKED_DOC_WIDGET, AffineLinkedDocWidget); + customElements.define('import-doc', ImportDoc); + + customElements.define( + 'affine-mobile-linked-doc-menu', + AffineMobileLinkedDocMenu + ); +} diff --git a/blocksuite/blocks/src/root-block/widgets/linked-doc/import-doc/import-doc.ts b/blocksuite/blocks/src/root-block/widgets/linked-doc/import-doc/import-doc.ts new file mode 100644 index 0000000000..864fb12b54 --- /dev/null +++ b/blocksuite/blocks/src/root-block/widgets/linked-doc/import-doc/import-doc.ts @@ -0,0 +1,289 @@ +import { + CloseIcon, + ExportToHTMLIcon, + ExportToMarkdownIcon, + HelpIcon, + NewIcon, + NotionIcon, +} from '@blocksuite/affine-components/icons'; +import { WithDisposable } from '@blocksuite/global/utils'; +import type { DocCollection } from '@blocksuite/store'; +import { html, LitElement, type PropertyValues } from 'lit'; +import { query, state } from 'lit/decorators.js'; + +import { HtmlTransformer } from '../../../../_common/transformers/html.js'; +import { MarkdownTransformer } from '../../../../_common/transformers/markdown.js'; +import { NotionHtmlTransformer } from '../../../../_common/transformers/notion-html.js'; +import { openFileOrFiles } from '../../../../_common/utils/index.js'; +import { styles } from './styles.js'; + +export type OnSuccessHandler = ( + pageIds: string[], + options: { isWorkspaceFile: boolean; importedCount: number } +) => void; + +export type OnFailHandler = (message: string) => void; + +const SHOW_LOADING_SIZE = 1024 * 200; + +export class ImportDoc extends WithDisposable(LitElement) { + static override styles = styles; + + constructor( + private collection: DocCollection, + private onSuccess?: OnSuccessHandler, + private onFail?: OnFailHandler, + private abortController = new AbortController() + ) { + super(); + + this._loading = false; + + this.x = 0; + this.y = 0; + this._startX = 0; + this._startY = 0; + + this._onMouseMove = this._onMouseMove.bind(this); + } + + private async _importHtml() { + const files = await openFileOrFiles({ acceptType: 'Html', multiple: true }); + if (!files) return; + const pageIds: string[] = []; + for (const file of files) { + const text = await file.text(); + const needLoading = file.size > SHOW_LOADING_SIZE; + const fileName = file.name.split('.').slice(0, -1).join('.'); + if (needLoading) { + this.hidden = false; + this._loading = true; + } else { + this.abortController.abort(); + } + const pageId = await HtmlTransformer.importHTMLToDoc({ + collection: this.collection, + html: text, + fileName, + }); + needLoading && this.abortController.abort(); + if (pageId) { + pageIds.push(pageId); + } + } + this._onImportSuccess(pageIds); + } + + private async _importMarkDown() { + const files = await openFileOrFiles({ + acceptType: 'Markdown', + multiple: true, + }); + if (!files) return; + const pageIds: string[] = []; + for (const file of files) { + const text = await file.text(); + const fileName = file.name.split('.').slice(0, -1).join('.'); + const needLoading = file.size > SHOW_LOADING_SIZE; + if (needLoading) { + this.hidden = false; + this._loading = true; + } else { + this.abortController.abort(); + } + const pageId = await MarkdownTransformer.importMarkdownToDoc({ + collection: this.collection, + markdown: text, + fileName, + }); + needLoading && this.abortController.abort(); + if (pageId) { + pageIds.push(pageId); + } + } + this._onImportSuccess(pageIds); + } + + private async _importNotion() { + const file = await openFileOrFiles({ acceptType: 'Zip' }); + if (!file) return; + const needLoading = file.size > SHOW_LOADING_SIZE; + if (needLoading) { + this.hidden = false; + this._loading = true; + } else { + this.abortController.abort(); + } + const { entryId, pageIds, isWorkspaceFile, hasMarkdown } = + await NotionHtmlTransformer.importNotionZip({ + collection: this.collection, + imported: file, + }); + needLoading && this.abortController.abort(); + if (hasMarkdown) { + this._onFail( + 'Importing markdown files from Notion is deprecated. Please export your Notion pages as HTML.' + ); + return; + } + this._onImportSuccess([entryId], { + isWorkspaceFile, + importedCount: pageIds.length, + }); + } + + private _onCloseClick(event: MouseEvent) { + event.stopPropagation(); + this.abortController.abort(); + } + + private _onFail(message: string) { + this.onFail?.(message); + } + + private _onImportSuccess( + pageIds: string[], + options: { isWorkspaceFile?: boolean; importedCount?: number } = {} + ) { + const { + isWorkspaceFile = false, + importedCount: pagesImportedCount = pageIds.length, + } = options; + this.onSuccess?.(pageIds, { + isWorkspaceFile, + importedCount: pagesImportedCount, + }); + } + + private _onMouseDown(event: MouseEvent) { + this._startX = event.clientX - this.x; + this._startY = event.clientY - this.y; + window.addEventListener('mousemove', this._onMouseMove); + } + + private _onMouseMove(event: MouseEvent) { + this.x = event.clientX - this._startX; + this.y = event.clientY - this._startY; + } + + private _onMouseUp() { + window.removeEventListener('mousemove', this._onMouseMove); + } + + private _openLearnImportLink(event: MouseEvent) { + event.stopPropagation(); + window.open( + 'https://affine.pro/blog/import-your-data-from-notion-into-affine', + '_blank' + ); + } + + override render() { + if (this._loading) { + return html` + <div class="overlay-mask"></div> + <div class="container"> + <header + class="loading-header" + @mousedown="${this._onMouseDown}" + @mouseup="${this._onMouseUp}" + > + <div>Import</div> + <loader-element .width=${'50px'}></loader-element> + </header> + <div> + Importing the file may take some time. It depends on document size + and complexity. + </div> + </div> + `; + } + return html` + <div + class="overlay-mask" + @click="${() => this.abortController.abort()}" + ></div> + <div class="container"> + <header @mousedown="${this._onMouseDown}" @mouseup="${this._onMouseUp}"> + <icon-button height="28px" @click="${this._onCloseClick}"> + ${CloseIcon} + </icon-button> + <div>Import</div> + </header> + <div> + AFFiNE will gradually support more file formats for import. + <a + href="https://community.affine.pro/c/feature-requests/import-export" + target="_blank" + >Provide feedback.</a + > + </div> + <div class="button-container"> + <icon-button + class="button-item" + text="Markdown" + @click="${this._importMarkDown}" + > + ${ExportToMarkdownIcon} + </icon-button> + <icon-button + class="button-item" + text="HTML" + @click="${this._importHtml}" + > + ${ExportToHTMLIcon} + </icon-button> + </div> + <div class="button-container"> + <icon-button + class="button-item" + text="Notion" + @click="${this._importNotion}" + > + ${NotionIcon} + <div + slot="suffix" + class="button-suffix" + @click="${this._openLearnImportLink}" + > + ${HelpIcon} + <affine-tooltip> + Learn how to Import your Notion pages into AFFiNE. + </affine-tooltip> + </div> + </icon-button> + <icon-button class="button-item" text="Coming soon..." disabled> + ${NewIcon} + </icon-button> + </div> + <!-- <div class="footer"> + <div>Migrate from other versions of AFFiNE?</div> + </div> --> + </div> + `; + } + + override updated(changedProps: PropertyValues) { + if (changedProps.has('x') || changedProps.has('y')) { + this.containerEl.style.transform = `translate(${this.x}px, ${this.y}px)`; + } + } + + @state() + accessor _loading = false; + + @state() + accessor _startX = 0; + + @state() + accessor _startY = 0; + + @query('.container') + accessor containerEl!: HTMLElement; + + @state() + accessor x = 0; + + @state() + accessor y = 0; +} diff --git a/blocksuite/blocks/src/root-block/widgets/linked-doc/import-doc/index.ts b/blocksuite/blocks/src/root-block/widgets/linked-doc/import-doc/index.ts new file mode 100644 index 0000000000..681b6acc71 --- /dev/null +++ b/blocksuite/blocks/src/root-block/widgets/linked-doc/import-doc/index.ts @@ -0,0 +1,32 @@ +import type { DocCollection } from '@blocksuite/store'; + +import { + ImportDoc, + type OnFailHandler, + type OnSuccessHandler, +} from './import-doc.js'; + +export function showImportModal({ + collection, + onSuccess, + onFail, + container = document.body, + abortController = new AbortController(), +}: { + collection: DocCollection; + onSuccess?: OnSuccessHandler; + onFail?: OnFailHandler; + multiple?: boolean; + container?: HTMLElement; + abortController?: AbortController; +}) { + const importDoc = new ImportDoc( + collection, + onSuccess, + onFail, + abortController + ); + container.append(importDoc); + abortController.signal.addEventListener('abort', () => importDoc.remove()); + return importDoc; +} diff --git a/blocksuite/blocks/src/root-block/widgets/linked-doc/import-doc/styles.ts b/blocksuite/blocks/src/root-block/widgets/linked-doc/import-doc/styles.ts new file mode 100644 index 0000000000..21848d270f --- /dev/null +++ b/blocksuite/blocks/src/root-block/widgets/linked-doc/import-doc/styles.ts @@ -0,0 +1,88 @@ +import { baseTheme } from '@toeverything/theme'; +import { css, unsafeCSS } from 'lit'; + +export const styles = css` + .container { + position: absolute; + width: 480px; + left: calc(50% - 480px / 2); + top: calc(50% - 270px / 2); + font-family: ${unsafeCSS(baseTheme.fontSansFamily)}; + font-size: var(--affine-font-base); + line-height: var(--affine-line-height); + padding: 12px 40px 36px; + gap: 20px; + display: flex; + flex-direction: column; + background: var(--affine-background-primary-color); + box-shadow: var(--affine-shadow-2); + border-radius: 16px; + z-index: var(--affine-z-index-popover); + } + + .container[hidden] { + display: none; + } + + header { + cursor: move; + user-select: none; + font-size: var(--affine-font-h-6); + font-weight: 600; + } + + a { + white-space: nowrap; + word-break: break-word; + color: var(--affine-link-color); + fill: var(--affine-link-color); + text-decoration: none; + cursor: pointer; + } + + header icon-button { + margin-left: auto; + position: relative; + left: 24px; + } + + .button-container { + display: flex; + justify-content: space-between; + } + + .button-container icon-button { + padding: 8px 12px; + justify-content: flex-start; + gap: 12px; + width: 190px; + height: 40px; + box-shadow: var(--affine-shadow-1); + border-radius: 10px; + } + + .footer { + display: flex; + align-items: center; + color: var(--affine-text-secondary-color); + } + + .loading-header { + display: flex; + align-items: center; + } + + .button-suffix { + display: flex; + margin-left: auto; + } + + .overlay-mask { + position: fixed; + top: 0; + left: 0; + width: 100vw; + height: 100vh; + z-index: var(--affine-z-index-popover); + } +`; diff --git a/blocksuite/blocks/src/root-block/widgets/linked-doc/index.ts b/blocksuite/blocks/src/root-block/widgets/linked-doc/index.ts new file mode 100644 index 0000000000..4dda048307 --- /dev/null +++ b/blocksuite/blocks/src/root-block/widgets/linked-doc/index.ts @@ -0,0 +1,339 @@ +import type { AffineInlineEditor } from '@blocksuite/affine-components/rich-text'; +import { getInlineEditorByModel } from '@blocksuite/affine-components/rich-text'; +import type { RootBlockModel } from '@blocksuite/affine-model'; +import type { SelectionRect } from '@blocksuite/affine-shared/commands'; +import { + getViewportElement, + matchFlavours, +} from '@blocksuite/affine-shared/utils'; +import type { UIEventStateContext } from '@blocksuite/block-std'; +import { WidgetComponent } from '@blocksuite/block-std'; +import { IS_MOBILE } from '@blocksuite/global/env'; +import type { Disposable } from '@blocksuite/global/utils'; +import { InlineEditor, type InlineRange } from '@blocksuite/inline'; +import { signal } from '@preact/signals-core'; +import { html, nothing } from 'lit'; +import { state } from 'lit/decorators.js'; +import { choose } from 'lit/directives/choose.js'; +import { repeat } from 'lit/directives/repeat.js'; +import { styleMap } from 'lit/directives/style-map.js'; + +import type { PageRootBlockComponent } from '../../index.js'; +import { + getMenus, + type LinkedDocContext, + type LinkedWidgetConfig, +} from './config.js'; +import { linkedDocWidgetStyles } from './styles.js'; +export { type LinkedWidgetConfig } from './config.js'; + +export const AFFINE_LINKED_DOC_WIDGET = 'affine-linked-doc-widget'; + +export class AffineLinkedDocWidget extends WidgetComponent< + RootBlockModel, + PageRootBlockComponent +> { + static override styles = linkedDocWidgetStyles; + + private _disposeObserveInputRects: Disposable | null = null; + + private readonly _getInlineEditor = ( + evt?: KeyboardEvent | CompositionEvent + ) => { + if (evt && evt.target instanceof HTMLElement) { + const editor = ( + evt.target.closest('.can-link-doc > .inline-editor') as { + inlineEditor?: AffineInlineEditor; + } + )?.inlineEditor; + if (editor instanceof InlineEditor) { + return editor; + } + } + + const text = this.host.selection.value.find(selection => + selection.is('text') + ); + if (!text) return null; + + const model = this.host.doc.getBlockById(text.blockId); + if (!model) return null; + + if (matchFlavours(model, this.config.ignoreBlockTypes)) { + return null; + } + + return getInlineEditorByModel(this.host, model); + }; + + private _inlineEditor: AffineInlineEditor | null = null; + + private _observeInputRects = () => { + if (!this._inlineEditor) return; + + const updateInputRects = () => { + const blockId = + this.std.command.exec('getSelectedModels').selectedModels?.[0]?.id; + if (!blockId) return; + + if (!this._startRange) return; + const index = this._startRange.index - this._triggerKey.length; + if (index < 0) return; + + const currentRange = this._inlineEditor?.getInlineRange(); + if (!currentRange) return; + const length = currentRange.index + currentRange.length - index; + + const textSelection = this.std.selection.create('text', { + from: { blockId, index, length }, + to: null, + }); + + const { selectionRects } = this.std.command.exec('getSelectionRects', { + textSelection, + }); + + if (!selectionRects) return; + + this._inputRects = selectionRects; + }; + + updateInputRects(); + this._disposeObserveInputRects = + this._inlineEditor.slots.renderComplete.on(updateInputRects); + }; + + private readonly _onCompositionEnd = (ctx: UIEventStateContext) => { + const event = ctx.get('defaultState').event as CompositionEvent; + + const key = event.data; + + if ( + !key || + !this.config.triggerKeys.some(triggerKey => triggerKey.includes(key)) + ) + return; + + this._inlineEditor = this._getInlineEditor(event); + if (!this._inlineEditor) return; + + this._handleInput(true); + }; + + private readonly _onKeyDown = (ctx: UIEventStateContext) => { + const eventState = ctx.get('keyboardState'); + const event = eventState.raw; + + const key = event.key; + if ( + key === undefined || // in mac os, the key may be undefined + key === 'Process' || + event.isComposing + ) + return; + + if (!this.config.triggerKeys.some(triggerKey => triggerKey.includes(key))) + return; + + this._inlineEditor = this._getInlineEditor(event); + if (!this._inlineEditor) return; + + const inlineRange = this._inlineEditor.getInlineRange(); + if (!inlineRange) return; + + if (inlineRange.length > 0) { + // When select text and press `[[` should not trigger transform, + // since it will break the bracket complete. + // Expected `[[selected text]]` instead of `@selected text]]` + return; + } + + this._handleInput(false); + }; + + private readonly _renderLinkedDocMenu = () => { + if (!this.block.rootComponent) return nothing; + + return html`<affine-mobile-linked-doc-menu + .context=${this._context} + .rootComponent=${this.block.rootComponent} + ></affine-mobile-linked-doc-menu>`; + }; + + private readonly _renderLinkedDocPopover = () => { + return html`<affine-linked-doc-popover + .context=${this._context} + ></affine-linked-doc-popover>`; + }; + + private readonly _show$ = signal<'desktop' | 'mobile' | 'none'>('none'); + + private _startRange: InlineRange | null = null; + + close = () => { + this._disposeObserveInputRects?.dispose(); + this._disposeObserveInputRects = null; + this._inlineEditor = null; + this._triggerKey = ''; + this._show$.value = 'none'; + this._startRange = null; + }; + + show = (mode: 'desktop' | 'mobile' = 'desktop') => { + if (this._inlineEditor === null) { + this._inlineEditor = this._getInlineEditor(); + } + if (this._triggerKey === '') { + this._triggerKey = this.config.triggerKeys[0]; + } + + this._startRange = this._inlineEditor?.getInlineRange() ?? null; + + const enableMobile = this.doc.awarenessStore.getFlag( + 'enable_mobile_linked_doc_menu' + ); + + this._observeInputRects(); + + this._show$.value = enableMobile ? mode : 'desktop'; + }; + + private get _context(): LinkedDocContext { + return { + std: this.std, + inlineEditor: this._inlineEditor!, + startRange: this._startRange!, + triggerKey: this._triggerKey, + config: this.config, + close: this.close, + }; + } + + get config(): LinkedWidgetConfig { + return { + triggerKeys: ['@', '[[', '【【'], + ignoreBlockTypes: ['affine:code'], + convertTriggerKey: true, + getMenus, + mobile: { + useScreenHeight: false, + scrollContainer: getViewportElement(this.std.host) ?? window, + scrollTopOffset: 46, + }, + ...this.std.getConfig('affine:page')?.linkedWidget, + }; + } + + private _handleInput(isCompositionEnd: boolean) { + const primaryTriggerKey = this.config.triggerKeys[0]; + + const inlineEditor = this._inlineEditor; + if (!inlineEditor) return; + + const inlineRangeApplyCallback = (callback: () => void) => { + // the inline ranged updated in compositionEnd event before this event callback + if (isCompositionEnd) callback(); + else inlineEditor.slots.inlineRangeSync.once(callback); + }; + + inlineRangeApplyCallback(() => { + const inlineRange = inlineEditor.getInlineRange(); + if (!inlineRange) return; + const textPoint = inlineEditor.getTextPoint(inlineRange.index); + if (!textPoint) return; + const [leafStart, offsetStart] = textPoint; + + const text = leafStart.textContent + ? leafStart.textContent.slice(0, offsetStart) + : ''; + + const matchedKey = this.config.triggerKeys.find(triggerKey => + text.endsWith(triggerKey) + ); + if (!matchedKey) return; + + if (this.config.convertTriggerKey && primaryTriggerKey !== matchedKey) { + const inlineRange = inlineEditor.getInlineRange(); + if (!inlineRange) return; + + // Convert to the primary trigger key + // e.g. [[ -> @ + this._triggerKey = primaryTriggerKey; + const startIdxBeforeMatchKey = inlineRange.index - matchedKey.length; + inlineEditor.deleteText({ + index: startIdxBeforeMatchKey, + length: matchedKey.length, + }); + inlineEditor.insertText( + { index: startIdxBeforeMatchKey, length: 0 }, + primaryTriggerKey + ); + inlineEditor.setInlineRange({ + index: startIdxBeforeMatchKey + primaryTriggerKey.length, + length: 0, + }); + inlineEditor.slots.inlineRangeSync.once(() => { + this.show(IS_MOBILE ? 'mobile' : 'desktop'); + }); + return; + } else { + this._triggerKey = matchedKey; + this.show(IS_MOBILE ? 'mobile' : 'desktop'); + } + }); + } + + private _renderInputMask() { + return html`${repeat( + this._inputRects, + ({ top, left, width, height }, index) => { + const last = index === this._inputRects.length - 1; + const padding = 2; + return html`<div + class="input-mask" + style=${styleMap({ + top: `${top - padding}px`, + left: `${left}px`, + width: `${width + (last ? 10 : 0)}px`, + height: `${height + 2 * padding}px`, + })} + ></div>`; + } + )}`; + } + + override connectedCallback() { + super.connectedCallback(); + this.handleEvent('keyDown', this._onKeyDown); + this.handleEvent('compositionEnd', this._onCompositionEnd); + } + + override render() { + if (this._show$.value === 'none') return nothing; + + return html`${this._renderInputMask()} + <blocksuite-portal + .shadowDom=${false} + .template=${choose( + this._show$.value, + [ + ['desktop', this._renderLinkedDocPopover], + ['mobile', this._renderLinkedDocMenu], + ], + () => html`${nothing}` + )} + ></blocksuite-portal>`; + } + + @state() + private accessor _inputRects: SelectionRect[] = []; + + @state() + private accessor _triggerKey = ''; +} + +declare global { + interface HTMLElementTagNameMap { + [AFFINE_LINKED_DOC_WIDGET]: AffineLinkedDocWidget; + } +} diff --git a/blocksuite/blocks/src/root-block/widgets/linked-doc/linked-doc-popover.ts b/blocksuite/blocks/src/root-block/widgets/linked-doc/linked-doc-popover.ts new file mode 100644 index 0000000000..4b391844d7 --- /dev/null +++ b/blocksuite/blocks/src/root-block/widgets/linked-doc/linked-doc-popover.ts @@ -0,0 +1,320 @@ +import { MoreHorizontalIcon } from '@blocksuite/affine-components/icons'; +import { + getCurrentNativeRange, + getViewportElement, +} from '@blocksuite/affine-shared/utils'; +import { PropTypes, requiredProperties } from '@blocksuite/block-std'; +import { + SignalWatcher, + throttle, + WithDisposable, +} from '@blocksuite/global/utils'; +import { html, LitElement, nothing } from 'lit'; +import { property, query, queryAll, state } from 'lit/decorators.js'; +import { styleMap } from 'lit/directives/style-map.js'; + +import type { IconButton } from '../../../_common/components/button.js'; +import { + cleanSpecifiedTail, + createKeydownObserver, + getQuery, +} from '../../../_common/components/utils.js'; +import { getPopperPosition } from '../../utils/position.js'; +import type { LinkedDocContext, LinkedMenuGroup } from './config.js'; +import { linkedDocPopoverStyles } from './styles.js'; +import { resolveSignal } from './utils.js'; + +@requiredProperties({ + context: PropTypes.object, +}) +export class LinkedDocPopover extends SignalWatcher( + WithDisposable(LitElement) +) { + static override styles = linkedDocPopoverStyles; + + private _abort = () => { + // remove popover dom + this.context.close(); + // clear input query + cleanSpecifiedTail( + this.context.std.host, + this.context.inlineEditor, + this.context.triggerKey + (this._query || '') + ); + }; + + private _expanded = new Map<string, boolean>(); + + private _updateLinkedDocGroup = async () => { + const query = this._query; + if (this._updateLinkedDocGroupAbortController) { + this._updateLinkedDocGroupAbortController.abort(); + } + this._updateLinkedDocGroupAbortController = new AbortController(); + + if (query === null) { + this.context.close(); + return; + } + this._linkedDocGroup = await this.context.config.getMenus( + query, + this._abort, + this.context.std.host, + this.context.inlineEditor, + this._updateLinkedDocGroupAbortController.signal + ); + }; + + private _updateLinkedDocGroupAbortController: AbortController | null = null; + + private get _actionGroup() { + return this._linkedDocGroup.map(group => { + return { + ...group, + items: this._getActionItems(group), + }; + }); + } + + private get _flattenActionList() { + return this._actionGroup + .map(group => + group.items.map(item => ({ ...item, groupName: group.name })) + ) + .flat(); + } + + private get _query() { + return getQuery(this.context.inlineEditor, this.context.startRange); + } + + private _getActionItems(group: LinkedMenuGroup) { + const isExpanded = !!this._expanded.get(group.name); + const items = resolveSignal(group.items); + if (isExpanded) { + return items; + } + const isOverflow = !!group.maxDisplay && items.length > group.maxDisplay; + if (isOverflow) { + return items.slice(0, group.maxDisplay).concat({ + key: `${group.name} More`, + name: group.overflowText || 'more', + icon: MoreHorizontalIcon, + action: () => { + this._expanded.set(group.name, true); + this.requestUpdate(); + }, + }); + } + return items; + } + + private _isTextOverflowing(element: HTMLElement) { + return element.scrollWidth > element.clientWidth; + } + + override connectedCallback() { + super.connectedCallback(); + + // init + this._updateLinkedDocGroup().catch(console.error); + this._disposables.addFromEvent(this, 'mousedown', e => { + // Prevent input from losing focus + e.preventDefault(); + }); + this._disposables.addFromEvent(window, 'mousedown', e => { + if (e.target === this) return; + // We don't clear the query when clicking outside the popover + this.context.close(); + }); + + const keydownObserverAbortController = new AbortController(); + this._disposables.add(() => keydownObserverAbortController.abort()); + + const { eventSource } = this.context.inlineEditor; + if (!eventSource) return; + + createKeydownObserver({ + target: eventSource, + signal: keydownObserverAbortController.signal, + onInput: isComposition => { + this._activatedItemIndex = 0; + if (isComposition) { + this._updateLinkedDocGroup().catch(console.error); + } else { + this.context.inlineEditor.slots.renderComplete.once( + this._updateLinkedDocGroup + ); + } + }, + onPaste: () => { + this._activatedItemIndex = 0; + setTimeout(() => { + this._updateLinkedDocGroup().catch(console.error); + }, 50); + }, + onDelete: () => { + const curRange = this.context.inlineEditor.getInlineRange(); + if (!this.context.startRange || !curRange) { + return; + } + if (curRange.index < this.context.startRange.index) { + this.context.close(); + } + this._activatedItemIndex = 0; + this.context.inlineEditor.slots.renderComplete.once( + this._updateLinkedDocGroup + ); + }, + onMove: step => { + const itemLen = this._flattenActionList.length; + this._activatedItemIndex = + (itemLen + this._activatedItemIndex + step) % itemLen; + + // Scroll to the active item + const item = this._flattenActionList[this._activatedItemIndex]; + const shadowRoot = this.shadowRoot; + if (!shadowRoot) { + console.warn('Failed to find the shadow root!', this); + return; + } + const ele = shadowRoot.querySelector( + `icon-button[data-id="${item.key}"]` + ); + if (!ele) { + console.warn('Failed to find the active item!', item); + return; + } + ele.scrollIntoView({ + block: 'nearest', + }); + }, + onConfirm: () => { + this._flattenActionList[this._activatedItemIndex] + .action() + ?.catch(console.error); + }, + onAbort: () => { + this.context.close(); + }, + }); + } + + override render() { + const MAX_HEIGHT = 380; + const style = this._position + ? styleMap({ + transform: `translate(${this._position.x}, ${this._position.y})`, + maxHeight: `${Math.min(this._position.height, MAX_HEIGHT)}px`, + }) + : styleMap({ + visibility: 'hidden', + }); + + // XXX This is a side effect + let accIdx = 0; + return html`<div class="linked-doc-popover" style="${style}"> + ${this._actionGroup + .filter(group => group.items.length) + .map((group, idx) => { + return html` + <div class="divider" ?hidden=${idx === 0}></div> + <div class="group-title">${group.name}</div> + <div class="group" style=${group.styles ?? ''}> + ${group.items.map(({ key, name, icon, action }) => { + accIdx++; + const curIdx = accIdx - 1; + const tooltip = this._showTooltip + ? html`<affine-tooltip tip-position=${'right'} + >${name}</affine-tooltip + >` + : nothing; + return html`<icon-button + width="280px" + height="30px" + data-id=${key} + .text=${name} + hover=${this._activatedItemIndex === curIdx} + @click=${() => { + action()?.catch(console.error); + }} + @mousemove=${() => { + // Use `mousemove` instead of `mouseover` to avoid navigate conflict with keyboard + this._activatedItemIndex = curIdx; + // show tooltip whether text length overflows + for (const button of this.iconButtons.values()) { + if (button.dataset.id == key && button.textElement) { + const isOverflowing = this._isTextOverflowing( + button.textElement + ); + this._showTooltip = isOverflowing; + break; + } + } + }} + > + ${icon} ${tooltip} + </icon-button>`; + })} + </div> + `; + })} + </div>`; + } + + updatePosition(position: { height: number; x: string; y: string }) { + this._position = position; + } + + override willUpdate() { + if (!this.hasUpdated) { + const curRange = getCurrentNativeRange(); + if (!curRange) return; + + const updatePosition = throttle(() => { + const position = getPopperPosition(this, curRange); + this.updatePosition(position); + }, 10); + + this.disposables.addFromEvent(window, 'resize', updatePosition); + const scrollContainer = getViewportElement(this.context.std.host); + if (scrollContainer) { + // Note: in edgeless mode, the scroll container is not exist! + this.disposables.addFromEvent( + scrollContainer, + 'scroll', + updatePosition, + { + passive: true, + } + ); + } + updatePosition(); + } + } + + @state() + private accessor _activatedItemIndex = 0; + + @state() + private accessor _linkedDocGroup: LinkedMenuGroup[] = []; + + @state() + private accessor _position: { + height: number; + x: string; + y: string; + } | null = null; + + @state() + private accessor _showTooltip = false; + + @property({ attribute: false }) + accessor context!: LinkedDocContext; + + @queryAll('icon-button') + accessor iconButtons!: NodeListOf<IconButton>; + + @query('.linked-doc-popover') + accessor linkedDocElement: Element | null = null; +} diff --git a/blocksuite/blocks/src/root-block/widgets/linked-doc/mobile-linked-doc-menu.ts b/blocksuite/blocks/src/root-block/widgets/linked-doc/mobile-linked-doc-menu.ts new file mode 100644 index 0000000000..7ce603c4e7 --- /dev/null +++ b/blocksuite/blocks/src/root-block/widgets/linked-doc/mobile-linked-doc-menu.ts @@ -0,0 +1,257 @@ +import { + VirtualKeyboardController, + type VirtualKeyboardControllerConfig, +} from '@blocksuite/affine-components/virtual-keyboard'; +import { getViewportElement } from '@blocksuite/affine-shared/utils'; +import { PropTypes, requiredProperties } from '@blocksuite/block-std'; +import { SignalWatcher, WithDisposable } from '@blocksuite/global/utils'; +import { MoreHorizontalIcon } from '@blocksuite/icons/lit'; +import { signal } from '@preact/signals-core'; +import { html, LitElement, nothing } from 'lit'; +import { property } from 'lit/decorators.js'; +import { join } from 'lit/directives/join.js'; +import { repeat } from 'lit/directives/repeat.js'; + +import { + cleanSpecifiedTail, + createKeydownObserver, + getQuery, +} from '../../../_common/components/utils.js'; +import { PageRootBlockComponent } from '../../index.js'; +import type { + LinkedDocContext, + LinkedMenuGroup, + LinkedMenuItem, +} from './config.js'; +import { mobileLinkedDocMenuStyles } from './styles.js'; +import { resolveSignal } from './utils.js'; + +export const AFFINE_MOBILE_LINKED_DOC_MENU = 'affine-mobile-linked-doc-menu'; + +@requiredProperties({ + context: PropTypes.object, + rootComponent: PropTypes.instanceOf(PageRootBlockComponent), +}) +export class AffineMobileLinkedDocMenu extends SignalWatcher( + WithDisposable(LitElement) +) { + static override styles = mobileLinkedDocMenuStyles; + + private readonly _expand = new Set<string>(); + + private _firstActionItem: LinkedMenuItem | null = null; + + private readonly _keyboardController = new VirtualKeyboardController(this); + + private readonly _linkedDocGroup$ = signal<LinkedMenuGroup[]>([]); + + private _renderGroup = (group: LinkedMenuGroup) => { + let items = resolveSignal(group.items); + + const isOverflow = !!group.maxDisplay && items.length > group.maxDisplay; + const expanded = this._expand.has(group.name); + + let moreItem = null; + if (!expanded && isOverflow) { + items = items.slice(0, group.maxDisplay); + + moreItem = html`<div + class="mobile-linked-doc-menu-item" + @click=${() => { + this._expand.add(group.name); + this.requestUpdate(); + }} + > + ${MoreHorizontalIcon()} + <div class="text">${group.overflowText || 'more'}</div> + </div>`; + } + + return html` + ${repeat(items, item => item.key, this._renderItem)} ${moreItem} + `; + }; + + private readonly _renderItem = ({ + key, + name, + icon, + action, + }: LinkedMenuItem) => { + return html`<button + class="mobile-linked-doc-menu-item" + data-id=${key} + @pointerup=${() => { + action()?.catch(console.error); + }} + > + ${icon} + <div class="text">${name}</div> + </button>`; + }; + + private _scrollInputToTop = () => { + const { inlineEditor } = this.context; + const { scrollContainer, scrollTopOffset } = this.context.config.mobile; + + let container = null; + let containerScrollTop = 0; + if (typeof scrollContainer === 'string') { + container = document.querySelector(scrollContainer); + containerScrollTop = container?.scrollTop ?? 0; + } else if (scrollContainer instanceof HTMLElement) { + container = scrollContainer; + containerScrollTop = scrollContainer.scrollTop; + } else if (scrollContainer === window) { + container = window; + containerScrollTop = scrollContainer.scrollY; + } else { + container = getViewportElement(this.context.std.host); + containerScrollTop = container?.scrollTop ?? 0; + } + + let offset = 0; + if (typeof scrollTopOffset === 'function') { + offset = scrollTopOffset(); + } else { + offset = scrollTopOffset ?? 0; + } + + container?.scrollTo({ + top: + inlineEditor.rootElement.getBoundingClientRect().top + + containerScrollTop - + offset, + behavior: 'smooth', + }); + }; + + private readonly _updateLinkedDocGroup = async () => { + if (this._updateLinkedDocGroupAbortController) { + this._updateLinkedDocGroupAbortController.abort(); + } + this._updateLinkedDocGroupAbortController = new AbortController(); + this._linkedDocGroup$.value = await this.context.config.getMenus( + this._query ?? '', + () => { + this.context.close(); + cleanSpecifiedTail( + this.context.std.host, + this.context.inlineEditor, + this.context.triggerKey + (this._query ?? '') + ); + }, + this.context.std.host, + this.context.inlineEditor, + this._updateLinkedDocGroupAbortController.signal + ); + }; + + private _updateLinkedDocGroupAbortController: AbortController | null = null; + + private get _query() { + return getQuery(this.context.inlineEditor, this.context.startRange); + } + + get virtualKeyboardControllerConfig(): VirtualKeyboardControllerConfig { + return { + useScreenHeight: this.context.config.mobile.useScreenHeight ?? false, + inputElement: this.rootComponent, + }; + } + + override connectedCallback() { + super.connectedCallback(); + + const { inlineEditor, close } = this.context; + + this._updateLinkedDocGroup().catch(console.error); + + // prevent editor blur when click menu + this._disposables.addFromEvent(this, 'pointerdown', e => { + e.preventDefault(); + }); + + // close menu when click outside + this.disposables.addFromEvent( + window, + 'pointerdown', + e => { + if (e.target === this) return; + close(); + }, + true + ); + + // bind some key events + { + const { eventSource } = inlineEditor; + if (!eventSource) return; + + const keydownObserverAbortController = new AbortController(); + this._disposables.add(() => keydownObserverAbortController.abort()); + + createKeydownObserver({ + target: eventSource, + signal: keydownObserverAbortController.signal, + onInput: isComposition => { + if (isComposition) { + this._updateLinkedDocGroup().catch(console.error); + } else { + inlineEditor.slots.renderComplete.once(this._updateLinkedDocGroup); + } + }, + onDelete: () => { + inlineEditor.slots.renderComplete.once(() => { + const curRange = inlineEditor.getInlineRange(); + + if (!this.context.startRange || !curRange) return; + + if (curRange.index < this.context.startRange.index) { + this.context.close(); + } + this._updateLinkedDocGroup().catch(console.error); + }); + }, + onConfirm: () => { + this._firstActionItem?.action()?.catch(console.error); + }, + onAbort: () => { + this.context.close(); + }, + }); + } + } + + override firstUpdated() { + if (!this._keyboardController.opened) { + this._keyboardController.show(); + } + this._scrollInputToTop(); + } + + override render() { + const groups = this._linkedDocGroup$.value; + if (groups.length === 0) { + return nothing; + } + + this._firstActionItem = resolveSignal(groups[0].items)[0]; + + this.style.bottom = + this.context.config.mobile.useScreenHeight && + this._keyboardController.opened + ? '0px' + : `max(0px, ${this._keyboardController.keyboardHeight}px)`; + + return html` + ${join(groups.map(this._renderGroup), html`<div class="divider"></div>`)} + `; + } + + @property({ attribute: false }) + accessor context!: LinkedDocContext; + + @property({ attribute: false }) + accessor rootComponent!: PageRootBlockComponent; +} diff --git a/blocksuite/blocks/src/root-block/widgets/linked-doc/styles.ts b/blocksuite/blocks/src/root-block/widgets/linked-doc/styles.ts new file mode 100644 index 0000000000..6784db9c49 --- /dev/null +++ b/blocksuite/blocks/src/root-block/widgets/linked-doc/styles.ts @@ -0,0 +1,145 @@ +import { unsafeCSSVar, unsafeCSSVarV2 } from '@blocksuite/affine-shared/theme'; +import { baseTheme } from '@toeverything/theme'; +import { css, unsafeCSS } from 'lit'; + +import { scrollbarStyle } from '../../../_common/components/utils.js'; + +export const linkedDocWidgetStyles = css` + .input-mask { + position: absolute; + pointer-events: none; + background: rgba(0, 0, 0, 0.1); + border-radius: 4px; + } +`; + +export const linkedDocPopoverStyles = css` + :host { + position: absolute; + } + + .linked-doc-popover { + position: fixed; + left: 0; + top: 0; + box-sizing: border-box; + font-family: ${unsafeCSS(baseTheme.fontSansFamily)}; + font-size: var(--affine-font-base); + padding: 8px; + display: flex; + flex-direction: column; + overflow-y: auto; + gap: 4px; + + background: ${unsafeCSSVarV2('layer/background/primary')}; + box-shadow: ${unsafeCSSVar('overlayPanelShadow')}; + border-radius: 4px; + z-index: var(--affine-z-index-popover); + } + + .linked-doc-popover icon-button { + justify-content: flex-start; + gap: 12px; + padding: 0 8px; + } + + .linked-doc-popover .group-title { + color: var(--affine-text-secondary-color); + padding: 0 8px; + height: 30px; + font-size: var(--affine-font-xs); + display: flex; + align-items: center; + flex-shrink: 0; + font-weight: 500; + } + + .linked-doc-popover .divider { + border-top: 0.5px solid ${unsafeCSSVarV2('layer/insideBorder/border')}; + } + + .group icon-button svg { + width: 20px; + height: 20px; + } + + .linked-doc-popover .group { + display: flex; + flex-direction: column; + gap: 4px; + } + + ${scrollbarStyle('.linked-doc-popover')} +`; + +export const mobileLinkedDocMenuStyles = css` + :host { + height: 220px; + width: 100%; + position: fixed; + overflow-y: auto; + + display: flex; + flex-direction: column; + align-items: flex-start; + flex-shrink: 0; + + --border-style: 1px solid ${unsafeCSSVarV2('layer/insideBorder/border')}; + + border-radius: 12px 12px 0px 0px; + border-top: var(--border-style); + border-right: var(--border-style); + border-left: var(--border-style); + background: ${unsafeCSSVarV2('layer/background/primary')}; + box-shadow: 0px -3px 10px 0px rgba(0, 0, 0, 0.07); + } + + :host::-webkit-scrollbar { + display: none; + } + + .divider { + width: 100%; + border-top: 0.5px solid ${unsafeCSSVarV2('layer/insideBorder/border')}; + } + + .mobile-linked-doc-menu-item { + display: flex; + width: 100%; + height: 44px; + flex-shrink: 0; + padding: 11px 20px; + justify-content: flex-start; + align-items: center; + gap: 8px; + flex-shrink: 0; + box-sizing: border-box; + + border: none; + background: inherit; + + > svg { + width: 20px; + height: 20px; + color: ${unsafeCSSVarV2('icon/primary')}; + } + + .text { + overflow: hidden; + color: ${unsafeCSSVarV2('text/primary')}; + text-align: justify; + text-overflow: ellipsis; + + font-family: -apple-system, BlinkMacSystemFont, sans-serif; + font-size: 17px; + font-style: normal; + font-weight: 400; + line-height: 22px; + letter-spacing: -0.43px; + } + } + + .mobile-linked-doc-menu-item:active { + background: ${unsafeCSSVarV2('layer/background/hoverOverlay')}; + } +`; diff --git a/blocksuite/blocks/src/root-block/widgets/linked-doc/utils.ts b/blocksuite/blocks/src/root-block/widgets/linked-doc/utils.ts new file mode 100644 index 0000000000..dd2a91afab --- /dev/null +++ b/blocksuite/blocks/src/root-block/widgets/linked-doc/utils.ts @@ -0,0 +1,5 @@ +import { Signal } from '@preact/signals-core'; + +export function resolveSignal<T>(data: T | Signal<T>): T { + return data instanceof Signal ? data.value : data; +} diff --git a/blocksuite/blocks/src/root-block/widgets/modal/custom-modal.ts b/blocksuite/blocks/src/root-block/widgets/modal/custom-modal.ts new file mode 100644 index 0000000000..e7e3185a7c --- /dev/null +++ b/blocksuite/blocks/src/root-block/widgets/modal/custom-modal.ts @@ -0,0 +1,144 @@ +import { css, html, LitElement, nothing } from 'lit'; +import { ref } from 'lit/directives/ref.js'; +import { repeat } from 'lit/directives/repeat.js'; + +type ModalButton = { + text: string; + type?: 'primary'; + onClick: () => Promise<void> | void; +}; + +type ModalOptions = { + footer: null | ModalButton[]; +}; + +export class AffineCustomModal extends LitElement { + static override styles = css` + :host { + z-index: calc(var(--affine-z-index-modal) + 3); + position: fixed; + top: 0; + left: 0; + width: 100vw; + height: 100vh; + } + + .modal-background { + width: 100%; + height: 100%; + box-sizing: border-box; + align-items: center; + background-color: var(--affine-background-modal-color); + justify-content: center; + display: flex; + } + + .modal-window { + width: 70%; + min-width: 500px; + height: 80%; + overflow-y: scroll; + background-color: var(--affine-background-overlay-panel-color); + border-radius: 12px; + box-shadow: var(--affine-shadow-3); + position: relative; + } + + .modal-main { + height: 100%; + } + + .modal-footer { + display: flex; + justify-content: flex-end; + gap: 20px; + padding: 24px; + position: absolute; + box-sizing: border-box; + bottom: 0; + right: 0; + } + + .modal-footer .button { + align-items: center; + background: var(--affine-white); + border: 1px solid; + border-color: var(--affine-border-color); + border-radius: 8px; + color: var(--affine-text-primary-color); + cursor: pointer; + display: inline-flex; + font-size: var(--affine-font-sm); + font-weight: 500; + justify-content: center; + outline: 0; + padding: 12px 18px; + touch-action: manipulation; + transition: all 0.3s; + user-select: none; + } + + .modal-footer .primary { + background: var(--affine-primary-color); + border-color: var(--affine-black-10); + box-shadow: var(--affine-button-inner-shadow); + color: var(--affine-pure-white); + } + `; + + onOpen!: (div: HTMLDivElement) => void; + + options!: ModalOptions; + + close() { + this.remove(); + } + + modalRef(modal: Element | undefined) { + if (modal) this.onOpen?.(modal as HTMLDivElement); + } + + override render() { + const { options } = this; + + return html`<div class="modal-background"> + <div class="modal-window"> + <div class="modal-main" ${ref(this.modalRef)}></div> + <div class="modal-footer"> + ${options.footer + ? repeat( + options.footer, + button => button.text, + button => html` + <button + class="button ${button.type ?? ''}" + @click=${button.onClick} + > + ${button.text} + </button> + ` + ) + : nothing} + </div> + </div> + </div>`; + } +} + +type CreateModalOption = ModalOptions & { + entry: (div: HTMLDivElement) => void; +}; + +export function createCustomModal( + options: CreateModalOption, + container: HTMLElement = document.body +) { + const modal = new AffineCustomModal(); + + modal.onOpen = options.entry; + modal.options = options; + + container.append(modal); + + return modal; +} diff --git a/blocksuite/blocks/src/root-block/widgets/modal/modal.ts b/blocksuite/blocks/src/root-block/widgets/modal/modal.ts new file mode 100644 index 0000000000..2cc6727066 --- /dev/null +++ b/blocksuite/blocks/src/root-block/widgets/modal/modal.ts @@ -0,0 +1,22 @@ +import { WidgetComponent } from '@blocksuite/block-std'; +import { nothing } from 'lit'; + +import { createCustomModal } from './custom-modal.js'; + +export const AFFINE_MODAL_WIDGET = 'affine-modal-widget'; + +export class AffineModalWidget extends WidgetComponent { + open(options: Parameters<typeof createCustomModal>[0]) { + return createCustomModal(options, this.ownerDocument.body); + } + + override render() { + return nothing; + } +} + +declare global { + interface HTMLElementTagNameMap { + [AFFINE_MODAL_WIDGET]: AffineModalWidget; + } +} diff --git a/blocksuite/blocks/src/root-block/widgets/page-dragging-area/page-dragging-area.ts b/blocksuite/blocks/src/root-block/widgets/page-dragging-area/page-dragging-area.ts new file mode 100644 index 0000000000..eed66f8172 --- /dev/null +++ b/blocksuite/blocks/src/root-block/widgets/page-dragging-area/page-dragging-area.ts @@ -0,0 +1,483 @@ +import type { RootBlockModel } from '@blocksuite/affine-model'; +import { + getScrollContainer, + matchFlavours, +} from '@blocksuite/affine-shared/utils'; +import { + BLOCK_ID_ATTR, + BlockComponent, + type PointerEventState, + WidgetComponent, +} from '@blocksuite/block-std'; +import { assertInstanceOf } from '@blocksuite/global/utils'; +import { html, nothing } from 'lit'; +import { state } from 'lit/decorators.js'; +import { styleMap } from 'lit/directives/style-map.js'; + +import type { PageRootBlockComponent } from '../../index.js'; +import { autoScroll } from '../../text-selection/utils.js'; + +type Rect = { + left: number; + top: number; + width: number; + height: number; +}; + +type BlockInfo = { + element: BlockComponent; + rect: Rect; +}; + +export const AFFINE_PAGE_DRAGGING_AREA_WIDGET = + 'affine-page-dragging-area-widget'; + +export class AffinePageDraggingAreaWidget extends WidgetComponent< + RootBlockModel, + PageRootBlockComponent +> { + static excludeFlavours: string[] = ['affine:note', 'affine:surface']; + + private _dragging = false; + + private _initialContainerOffset: { + x: number; + y: number; + } = { + x: 0, + y: 0, + }; + + private _initialScrollOffset: { + top: number; + left: number; + } = { + top: 0, + left: 0, + }; + + private _lastPointerState: PointerEventState | null = null; + + private _rafID = 0; + + private _updateDraggingArea = ( + state: PointerEventState, + shouldAutoScroll: boolean + ) => { + const { x, y } = state; + const { x: startX, y: startY } = state.start; + + const { left: initScrollX, top: initScrollY } = this._initialScrollOffset; + if (!this._viewport) { + return; + } + const { scrollLeft, scrollTop, scrollWidth, scrollHeight } = this._viewport; + + const { x: initConX, y: initConY } = this._initialContainerOffset; + const { x: conX, y: conY } = state.containerOffset; + + const { left: viewportLeft, top: viewportTop } = this._viewport; + let left = Math.min( + startX + initScrollX + initConX - viewportLeft, + x + scrollLeft + conX - viewportLeft + ); + let right = Math.max( + startX + initScrollX + initConX - viewportLeft, + x + scrollLeft + conX - viewportLeft + ); + let top = Math.min( + startY + initScrollY + initConY - viewportTop, + y + scrollTop + conY - viewportTop + ); + let bottom = Math.max( + startY + initScrollY + initConY - viewportTop, + y + scrollTop + conY - viewportTop + ); + + left = Math.max(left, conX - viewportLeft); + right = Math.min(right, scrollWidth); + top = Math.max(top, conY - viewportTop); + bottom = Math.min(bottom, scrollHeight); + + const userRect = { + left, + top, + width: right - left, + height: bottom - top, + }; + this.rect = userRect; + this._selectBlocksByRect({ + left: userRect.left + viewportLeft, + top: userRect.top + viewportTop, + width: userRect.width, + height: userRect.height, + }); + this._lastPointerState = state; + + if (shouldAutoScroll) { + const rect = this.scrollContainer.getBoundingClientRect(); + const result = autoScroll(this.scrollContainer, state.raw.y - rect.top); + if (!result) { + this._clearRaf(); + return; + } + } + }; + + private get _allBlocksWithRect(): BlockInfo[] { + if (!this._viewport) { + return []; + } + const { scrollLeft, scrollTop } = this._viewport; + + const getAllNodeFromTree = (): BlockComponent[] => { + const blocks: BlockComponent[] = []; + this.host.view.walkThrough(node => { + const view = node; + if (!(view instanceof BlockComponent)) { + return true; + } + if ( + view.model.role !== 'root' && + !AffinePageDraggingAreaWidget.excludeFlavours.includes( + view.model.flavour + ) + ) { + blocks.push(view); + } + return; + }); + return blocks; + }; + + const elements = getAllNodeFromTree(); + + return elements.map(element => { + const bounding = element.getBoundingClientRect(); + return { + element, + rect: { + left: bounding.left + scrollLeft, + top: bounding.top + scrollTop, + width: bounding.width, + height: bounding.height, + }, + }; + }); + } + + private get _viewport() { + const rootComponent = this.block; + if (!rootComponent) return; + return rootComponent.viewport; + } + + private get scrollContainer() { + return getScrollContainer(this.block); + } + + private _clearRaf() { + if (this._rafID) { + cancelAnimationFrame(this._rafID); + this._rafID = 0; + } + } + + private _selectBlocksByRect(userRect: Rect) { + const selections = getSelectingBlockPaths( + this._allBlocksWithRect, + userRect + ).map(blockPath => { + return this.host.selection.create('block', { + blockId: blockPath, + }); + }); + + this.host.selection.setGroup('note', selections); + } + + override connectedCallback() { + super.connectedCallback(); + + this.handleEvent( + 'pointerDown', + ctx => { + const container = this.block.rootElementContainer; + if (!container) return; + + const containerRect = container.getBoundingClientRect(); + const containerStyles = window.getComputedStyle(container); + const paddingLeft = parseFloat(containerStyles.paddingLeft); + const paddingRight = parseFloat(containerStyles.paddingRight); + const state = ctx.get('pointerState'); + const raw = state.raw; + + if ( + raw.clientX > containerRect.left + paddingLeft && + raw.clientX < containerRect.right - paddingRight && + raw.clientY > containerRect.top && + raw.clientY < containerRect.bottom + ) { + return; + } + + state.raw.preventDefault(); + }, + { + global: true, + } + ); + + this.handleEvent( + 'dragStart', + ctx => { + const state = ctx.get('pointerState'); + const { button } = state.raw; + if (button !== 0) return; + if (isDragArea(state)) { + if (!this._viewport) { + return; + } + this._dragging = true; + const { scrollLeft, scrollTop } = this._viewport; + this._initialScrollOffset = { + left: scrollLeft, + top: scrollTop, + }; + this._initialContainerOffset = { + x: state.containerOffset.x, + y: state.containerOffset.y, + }; + return true; + } + return; + }, + { global: true } + ); + + this.handleEvent( + 'dragMove', + ctx => { + this._clearRaf(); + if (!this._dragging) { + return; + } + + const state = ctx.get('pointerState'); + // TODO(@L-Sun) support drag area for touch device + if (state.raw.pointerType === 'touch') return; + + ctx.get('defaultState').event.preventDefault(); + + this._rafID = requestAnimationFrame(() => { + this._updateDraggingArea(state, true); + }); + + return true; + }, + { global: true } + ); + + this.handleEvent( + 'dragEnd', + () => { + this._clearRaf(); + this._dragging = false; + this.rect = null; + this._initialScrollOffset = { + top: 0, + left: 0, + }; + this._initialContainerOffset = { + x: 0, + y: 0, + }; + this._lastPointerState = null; + }, + { + global: true, + } + ); + + this.handleEvent( + 'pointerMove', + ctx => { + if (this._dragging) { + const state = ctx.get('pointerState'); + state.raw.preventDefault(); + } + }, + { + global: true, + } + ); + } + + override disconnectedCallback() { + this._clearRaf(); + this._disposables.dispose(); + super.disconnectedCallback(); + } + + override firstUpdated() { + this._disposables.addFromEvent(this.scrollContainer, 'scroll', () => { + if (!this._dragging || !this._lastPointerState) return; + + const state = this._lastPointerState; + this._rafID = requestAnimationFrame(() => { + this._updateDraggingArea(state, false); + }); + }); + } + + override render() { + const rect = this.rect; + if (!rect) return nothing; + + const style = { + left: rect.left + 'px', + top: rect.top + 'px', + width: rect.width + 'px', + height: rect.height + 'px', + }; + return html` + <style> + .affine-page-dragging-area { + position: absolute; + background: var(--affine-hover-color); + z-index: 1; + pointer-events: none; + } + </style> + <div class="affine-page-dragging-area" style=${styleMap(style)}></div> + `; + } + + @state() + accessor rect: Rect | null = null; +} + +function rectIntersects(a: Rect, b: Rect) { + return ( + a.left < b.left + b.width && + a.left + a.width > b.left && + a.top < b.top + b.height && + a.top + a.height > b.top + ); +} + +function rectIncludesTopAndBottom(a: Rect, b: Rect) { + return a.top <= b.top && a.top + a.height >= b.top + b.height; +} + +function filterBlockInfos(blockInfos: BlockInfo[], userRect: Rect) { + const results: BlockInfo[] = []; + for (const blockInfo of blockInfos) { + const rect = blockInfo.rect; + if (userRect.top + userRect.height < rect.top) break; + + results.push(blockInfo); + } + + return results; +} + +function filterBlockInfosByParent( + parentInfos: BlockInfo, + userRect: Rect, + filteredBlockInfos: BlockInfo[] +) { + const targetBlock = parentInfos.element; + let results = [parentInfos]; + if (targetBlock.childElementCount > 0) { + const childBlockInfos = targetBlock.childBlocks + .map(el => + filteredBlockInfos.find( + blockInfo => blockInfo.element.model.id === el.model.id + ) + ) + .filter(block => block) as BlockInfo[]; + const firstIndex = childBlockInfos.findIndex( + bl => rectIntersects(bl.rect, userRect) && bl.rect.top < userRect.top + ); + const lastIndex = childBlockInfos.findIndex( + bl => + rectIntersects(bl.rect, userRect) && + bl.rect.top + bl.rect.height > userRect.top + userRect.height + ); + + if (firstIndex !== -1 && lastIndex !== -1) { + results = childBlockInfos.slice(firstIndex, lastIndex + 1); + } + } + + return results; +} + +function getSelectingBlockPaths(blockInfos: BlockInfo[], userRect: Rect) { + const filteredBlockInfos = filterBlockInfos(blockInfos, userRect); + const len = filteredBlockInfos.length; + const blockPaths: string[] = []; + let singleTargetParentBlock: BlockInfo | null = null; + let blocks: BlockInfo[] = []; + if (len === 0) return blockPaths; + + // To get the single target parent block info + for (const block of filteredBlockInfos) { + const rect = block.rect; + + if ( + rectIntersects(userRect, rect) && + rectIncludesTopAndBottom(rect, userRect) + ) { + singleTargetParentBlock = block; + } + } + + if (singleTargetParentBlock) { + blocks = filterBlockInfosByParent( + singleTargetParentBlock, + userRect, + filteredBlockInfos + ); + } else { + // If there is no block contains the top and bottom of the userRect + // Then get all the blocks that intersect with the userRect + for (const block of filteredBlockInfos) { + if (rectIntersects(userRect, block.rect)) { + blocks.push(block); + } + } + } + + // Filter out the blocks which parent is in the blocks + // eslint-disable-next-line @typescript-eslint/prefer-for-of + for (let i = 0; i < blocks.length; i++) { + const block = blocks[i]; + const parent = blocks[i].element.doc.getParent(block.element.model); + const parentId = parent?.id; + if (parentId) { + const isParentInBlocks = blocks.some( + block => block.element.model.id === parentId + ); + if (!isParentInBlocks) { + blockPaths.push(blocks[i].element.blockId); + } + } + } + + return blockPaths; +} + +function isDragArea(e: PointerEventState) { + const el = e.raw.target; + assertInstanceOf(el, Element); + const block = el.closest<BlockComponent>(`[${BLOCK_ID_ATTR}]`); + return block && matchFlavours(block.model, ['affine:page', 'affine:note']); +} + +declare global { + interface HTMLElementTagNameMap { + [AFFINE_PAGE_DRAGGING_AREA_WIDGET]: AffinePageDraggingAreaWidget; + } +} diff --git a/blocksuite/blocks/src/root-block/widgets/pie-menu/base.ts b/blocksuite/blocks/src/root-block/widgets/pie-menu/base.ts new file mode 100644 index 0000000000..38dfb28d91 --- /dev/null +++ b/blocksuite/blocks/src/root-block/widgets/pie-menu/base.ts @@ -0,0 +1,87 @@ +import type { TemplateResult } from 'lit'; + +import type { EdgelessRootBlockComponent } from '../../edgeless/edgeless-root-block.js'; +import type { PieMenuId } from '../../types.js'; +import type { AffinePieMenuWidget } from './index.js'; +import type { PieMenu } from './menu.js'; +import type { PieNode } from './node.js'; + +export interface PieMenuSchema { + id: PieMenuId; + + label: string; + + root: PieRootNodeModel; + + trigger: (props: { + keyEvent: KeyboardEvent; + rootComponent: EdgelessRootBlockComponent; + }) => boolean; +} + +export type IconGetter = (ctx: PieMenuContext) => TemplateResult; +export type DisabledGetter = (ctx: PieMenuContext) => boolean; +export interface PieBaseNodeModel { + type: 'root' | 'command' | 'submenu' | 'toggle' | 'color'; + + label: string; + + icon?: IconGetter | TemplateResult; + + angle?: number; + + startAngle?: number; + + endAngle?: number; + + disabled?: boolean | DisabledGetter; +} + +// A menu can only have one root node +export interface PieRootNodeModel extends PieBaseNodeModel { + type: 'root'; + children: Array<PieNonRootNode>; +} + +export type PieMenuContext = { + rootComponent: EdgelessRootBlockComponent; + menu: PieMenu; + widgetComponent: AffinePieMenuWidget; + node: PieNode; +}; +export type ActionFunction = (ctx: PieMenuContext) => void; + +// Nodes which can perform a given action +export interface PieCommandNodeModel extends PieBaseNodeModel { + type: 'command'; + action: ActionFunction; +} + +// Open a submenu +export interface PieSubmenuNodeModel extends PieBaseNodeModel { + type: 'submenu'; + role: 'default' | 'color-picker' | 'command'; + action?: ActionFunction; + children: Array<PieNonRootNode>; + openOnHover?: boolean; + timeoutOverride?: number; +} + +export interface PieColorNodeModel extends PieBaseNodeModel { + type: 'color'; + color: string; + hollowCircle: boolean; + text?: string; + onChange: (color: string, ctx: PieMenuContext) => void; +} + +export type IPieNodeWithAction = + | PieCommandNodeModel + | (PieSubmenuNodeModel & { role: 'command'; action: ActionFunction }); + +export type PieNonRootNode = + | PieCommandNodeModel + | PieColorNodeModel + | PieSubmenuNodeModel; + +export type PieNodeModel = PieRootNodeModel | PieNonRootNode; diff --git a/blocksuite/blocks/src/root-block/widgets/pie-menu/components/pie-node-center.ts b/blocksuite/blocks/src/root-block/widgets/pie-menu/components/pie-node-center.ts new file mode 100644 index 0000000000..6ecd6e0fde --- /dev/null +++ b/blocksuite/blocks/src/root-block/widgets/pie-menu/components/pie-node-center.ts @@ -0,0 +1,87 @@ +import { css, html, LitElement } from 'lit'; +import { property } from 'lit/decorators.js'; +import { styleMap } from 'lit/directives/style-map.js'; + +import { PieNode } from '../node.js'; + +const styles = css` + .pie-parent-node-container { + position: absolute; + list-style-type: none; + } + + .pie-node.center { + width: 6rem; + height: 6rem; + padding: 0.4rem; + } + + .pie-node.center[active='true'] .node-content > svg, + .pie-node.center[active='true'] .node-content > .color-unit, + .pie-node.center[active='true'] .node-content > .color-unit > svg { + width: 2rem !important; + height: 2rem !important; + } + + .pie-node.center[active='false'] { + width: 3rem; + height: 3rem; + opacity: 0.6; + } +`; + +export class PieNodeCenter extends LitElement { + static override styles = [PieNode.styles, styles]; + + protected override render() { + const [x, y] = this.node.position; + + const styles = { + transform: `translate(${x}px, ${y}px) translate(-50%, -50%)`, + }; + + return html` + <div style="${styleMap(styles)}" class="pie-parent-node-container"> + <div + style="${styleMap({ transform: 'translate(-50%, -50%)' })}" + active="${this.isActive.toString()}" + @mouseenter="${this.onMouseEnter}" + class="pie-node center" + > + <pie-node-content + .node="${this.node}" + .hoveredNode="${this.hoveredNode}" + .isActive="${this.isActive}" + ></pie-node-content> + + <pie-center-rotator + .angle=${this.rotatorAngle} + .isActive=${this.isActive} + ></pie-center-rotator> + </div> + <slot></slot> + </div> + `; + } + + @property({ attribute: false }) + accessor hoveredNode!: PieNode | null; + + @property({ attribute: false }) + accessor isActive!: boolean; + + @property({ attribute: false }) + accessor node!: PieNode; + + @property({ attribute: false }) + accessor onMouseEnter!: (ev: MouseEvent) => void; + + @property({ attribute: false }) + accessor rotatorAngle: number | null = null; +} + +declare global { + interface HTMLElementTagNameMap { + 'pie-node-center': PieNodeCenter; + } +} diff --git a/blocksuite/blocks/src/root-block/widgets/pie-menu/components/pie-node-child.ts b/blocksuite/blocks/src/root-block/widgets/pie-menu/components/pie-node-child.ts new file mode 100644 index 0000000000..2e138f4527 --- /dev/null +++ b/blocksuite/blocks/src/root-block/widgets/pie-menu/components/pie-node-child.ts @@ -0,0 +1,96 @@ +import { css, html, LitElement } from 'lit'; +import { property } from 'lit/decorators.js'; +import { styleMap } from 'lit/directives/style-map.js'; + +import { PieNode } from '../node.js'; + +const styles = css` + .pie-node.child { + width: 3rem; + height: 3rem; + padding: 0.6rem; + animation: my-anim 250ms cubic-bezier(0.775, 1.325, 0.535, 1); + } + + .pie-node.child.node-color { + width: 0.7rem; + height: 0.7rem; + } + + .pie-node.child:not(.node-color)::after { + content: attr(index); + color: var(--affine-text-secondary-color); + position: absolute; + font-size: 8px; + bottom: 10%; + right: 30%; + } + + .pie-node.child[hovering='true'] { + border-color: var(--affine-primary-color); + background-color: var(--affine-hover-color-filled); + scale: 1.06; + } + + .pie-node.child.node-submenu::before { + content: ''; + position: absolute; + top: 50%; + right: 10%; + transform: translateY(-50%); + width: 5px; + height: 5px; + background-color: var(--affine-primary-color); + border-radius: 50%; + } +`; + +export class PieNodeChild extends LitElement { + static override styles = [PieNode.styles, styles]; + + protected override render() { + const { model, position } = this.node; + + const [x, y] = position; + + const styles = { + top: '50%', + left: '50%', + transform: `translate(${x}px, ${y}px) translate(-50%, -50%)`, + visibility: this.visible ? 'visible' : 'hidden', + }; + + return html`<li + style="${styleMap(styles)}" + hovering="${this.hovering.toString()}" + @click="${this.onClick}" + index="${this.node.index}" + class=${`pie-node child node-${model.type}`} + > + <pie-node-content + .node=${this.node} + .isActive=${false} + .hoveredNode=${null} + > + </pie-node-content> + </li>`; + } + + @property({ attribute: false }) + accessor hovering!: boolean; + + @property({ attribute: false }) + accessor node!: PieNode; + + @property({ attribute: false }) + accessor onClick!: (ev: MouseEvent) => void; + + @property({ attribute: false }) + accessor visible!: boolean; +} + +declare global { + interface HTMLElementTagNameMap { + 'pie-node-child': PieNodeChild; + } +} diff --git a/blocksuite/blocks/src/root-block/widgets/pie-menu/components/pie-node-content.ts b/blocksuite/blocks/src/root-block/widgets/pie-menu/components/pie-node-content.ts new file mode 100644 index 0000000000..70c04b00c4 --- /dev/null +++ b/blocksuite/blocks/src/root-block/widgets/pie-menu/components/pie-node-content.ts @@ -0,0 +1,120 @@ +import { assertEquals } from '@blocksuite/global/utils'; +import { css, html, LitElement, type PropertyValues } from 'lit'; +import { property, query } from 'lit/decorators.js'; + +import { ColorUnit } from '../../../edgeless/components/panel/color-panel.js'; +import type { PieNode } from '../node.js'; +import { isSubmenuNode } from '../utils.js'; + +const styles = css` + .node-content > svg { + width: 24px; + height: 24px; + } + + .node-content.center[active='true'] > svg, + .node-content.center[active='true'] > .color-unit, + .node-content.center[active='true'] > .color-unit > svg { + width: 2rem !important; + height: 2rem !important; + } +`; + +export class PieNodeContent extends LitElement { + static override styles = styles; + + private _renderCenterNodeContent() { + if (isSubmenuNode(this.node.model) && !this.isActive) { + return this._renderChildNodeContent(); + } + + const { menu, model } = this.node; + const isActiveNode = menu.isActiveNode(this.node); + const hoveredNode = this.hoveredNode; + + if ( + this.isActive && + isSubmenuNode(model) && + model.role === 'color-picker' + ) { + if (!hoveredNode) return this.node.icon; + + assertEquals( + hoveredNode.model.type, + 'color', + 'IPieSubMenuNode.role with color-picker should have children of type color' + ); + const { color, hollowCircle } = hoveredNode.model; + return ColorUnit(color, { hollowCircle }); + } + + const { label } = model; + const centerLabelOrIcon = this.node.icon ?? label; + + return isActiveNode + ? hoveredNode + ? hoveredNode.model.label + : centerLabelOrIcon + : centerLabelOrIcon; + } + + private _renderChildNodeContent() { + return this.node.icon; + } + + protected override render() { + const content = this.node.isCenterNode() + ? this._renderCenterNodeContent() + : this._renderChildNodeContent(); + + return html` + <div + active="${this.isActive.toString()}" + class="node-content ${this.node.isCenterNode() ? 'center' : 'child'}" + > + ${content} + </div> + `; + } + + protected override updated(changedProperties: PropertyValues): void { + super.updated(changedProperties); + + if ( + !changedProperties.has('hoveredNode') || + !this._nodeContentElement || + !this.isActive + ) + return; + const fadeIn = [ + { + opacity: 0, + }, + { opacity: 1 }, + ]; + + this._nodeContentElement.animate(fadeIn, { + duration: 250, + easing: 'cubic-bezier(0.775, 1.325, 0.535, 1)', + fill: 'forwards' as const, + }); + } + + @query('.node-content') + private accessor _nodeContentElement!: HTMLDivElement; + + @property({ attribute: false }) + accessor hoveredNode!: PieNode | null; + + @property({ attribute: false }) + accessor isActive!: boolean; + + @property({ attribute: false }) + accessor node!: PieNode; +} + +declare global { + interface HTMLElementTagNameMap { + 'pie-node-content': PieNodeContent; + } +} diff --git a/blocksuite/blocks/src/root-block/widgets/pie-menu/components/rotator.ts b/blocksuite/blocks/src/root-block/widgets/pie-menu/components/rotator.ts new file mode 100644 index 0000000000..3369cd7486 --- /dev/null +++ b/blocksuite/blocks/src/root-block/widgets/pie-menu/components/rotator.ts @@ -0,0 +1,48 @@ +import { CommonUtils } from '@blocksuite/affine-block-surface'; +import { css, html, LitElement, nothing } from 'lit'; +import { property } from 'lit/decorators.js'; +import { styleMap } from 'lit/directives/style-map.js'; + +import { getPosition } from '../utils.js'; + +const styles = css` + .rotator { + position: absolute; + background: var(--affine-background-overlay-panel-color); + box-shadow: var(--affine-shadow-2); + border: 2px solid var(--affine-primary-color); + border-radius: 50%; + width: 7px; + height: 7px; + top: 50%; + left: 50%; + } +`; + +export class PieCenterRotator extends LitElement { + static override styles = styles; + + protected override render() { + if (!this.isActive || this.angle === null) return nothing; + + const [x, y] = getPosition(CommonUtils.toRadian(this.angle), [45, 45]); + + const styles = { + transform: `translate(${x}px, ${y}px) translate(-50%, -50%)`, + }; + + return html`<span style="${styleMap(styles)}" class="rotator"></span>`; + } + + @property({ attribute: false }) + accessor angle: number | null = null; + + @property({ attribute: false }) + accessor isActive!: boolean; +} + +declare global { + interface HTMLElementTagNameMap { + 'pie-center-rotator': PieCenterRotator; + } +} diff --git a/blocksuite/blocks/src/root-block/widgets/pie-menu/config.ts b/blocksuite/blocks/src/root-block/widgets/pie-menu/config.ts new file mode 100644 index 0000000000..b2c043bb83 --- /dev/null +++ b/blocksuite/blocks/src/root-block/widgets/pie-menu/config.ts @@ -0,0 +1,338 @@ +import { + ConnectorCWithArrowIcon, + ConnectorIcon, + ConnectorLWithArrowIcon, + ConnectorXWithArrowIcon, + DiamondIcon, + EdgelessEraserLightIcon, + EdgelessGeneralShapeIcon, + EdgelessPenLightIcon, + EllipseIcon, + FrameIcon, + FrameNavigatorIcon, + GeneralStyleIcon, + NoteIcon, + ScribbledDiamondIcon, + ScribbledEllipseIcon, + ScribbledSquareIcon, + ScribbledStyleIcon, + ScribbledTriangleIcon, + SelectIcon, + SquareIcon, + ToolsIcon, + TriangleIcon, + ViewBarIcon, +} from '@blocksuite/affine-components/icons'; +import { + ConnectorMode, + LINE_COLORS, + SHAPE_FILL_COLORS, + SHAPE_STROKE_COLORS, + ShapeStyle, + ShapeType, +} from '@blocksuite/affine-model'; +import { + EditPropsStore, + type LastProps, +} from '@blocksuite/affine-shared/services'; +import { isControlledKeyboardEvent } from '@blocksuite/affine-shared/utils'; +import { html } from 'lit'; +import { styleMap } from 'lit/directives/style-map.js'; + +import { + DEFAULT_NOTE_CHILD_FLAVOUR, + DEFAULT_NOTE_CHILD_TYPE, + DEFAULT_NOTE_TIP, +} from '../../edgeless/utils/consts.js'; +import type { PieMenuContext } from './base.js'; +import { PieMenuBuilder } from './pie-builder.js'; +import { + getActiveConnectorStrokeColor, + getActiveShapeColor, + setEdgelessToolAction, + updateShapeOverlay, +} from './utils.js'; + +//---------------------------------------------------------- +// EDGELESS TOOLS PIE MENU SCHEMA +//---------------------------------------------------------- + +export const AFFINE_PIE_MENU_ID_EDGELESS_TOOLS = 'affine:pie:edgeless:tools'; + +const pie = new PieMenuBuilder({ + id: AFFINE_PIE_MENU_ID_EDGELESS_TOOLS, + label: 'Tools', + icon: ToolsIcon, + trigger: ({ keyEvent: ev, rootComponent }) => { + if (isControlledKeyboardEvent(ev)) return false; + const isEditing = rootComponent.service.selection.editing; + + return ev.key === 'q' && !isEditing; + }, +}); + +pie.expandableCommand({ + label: 'Pen', + icon: EdgelessPenLightIcon, + action: setEdgelessToolAction(tool => tool.setTool('brush')), + submenus: pie => { + pie.colorPicker({ + label: 'Pen Color', + active: getActiveConnectorStrokeColor, + onChange: (color: string, { rootComponent }: PieMenuContext) => { + rootComponent.std.get(EditPropsStore).recordLastProps('brush', { + color: color as LastProps['brush']['color'], + }); + }, + colors: LINE_COLORS.map(color => ({ color })), + }); + }, +}); + +pie.command({ + label: 'Eraser', + icon: EdgelessEraserLightIcon, + action: setEdgelessToolAction(tool => tool.setTool('eraser')), +}); + +pie.command({ + label: 'Frame', + icon: FrameIcon, + action: setEdgelessToolAction(tool => tool.setTool('frame')), +}); + +pie.command({ + label: 'Select', + icon: SelectIcon, + action: setEdgelessToolAction(tool => tool.setTool('default')), +}); + +pie.command({ + label: 'Note', + icon: NoteIcon, + action: setEdgelessToolAction(tool => + tool.setTool('affine:note', { + childFlavour: DEFAULT_NOTE_CHILD_FLAVOUR, + childType: DEFAULT_NOTE_CHILD_TYPE, + tip: DEFAULT_NOTE_TIP, + }) + ), +}); + +pie.command({ + label: 'Reset Zoom', + icon: ViewBarIcon, + action: ({ rootComponent }) => { + rootComponent.service.zoomToFit(); + }, +}); + +pie.command({ + label: 'Present', + icon: ({ rootComponent }) => { + const { type } = rootComponent.gfx.tool.currentToolOption$.peek(); + if (type === 'frameNavigator') { + return html` + <span + style="${styleMap({ + color: '#eb4335', + fontWeight: 'bold', + })}" + >Stop</span + > + `; + } + + return FrameNavigatorIcon; + }, + action: ({ rootComponent }) => { + const toolName = rootComponent.gfx.tool.currentToolName$.peek(); + if (toolName === 'frameNavigator') { + rootComponent.gfx.tool.setTool('default'); + + if (document.fullscreenElement) { + document.exitFullscreen().catch(console.error); + } + + return; + } + + rootComponent.gfx.tool.setTool('frameNavigator', { + mode: 'fit', + }); + }, +}); +// Connector submenu +pie.beginSubmenu({ + label: 'Connector', + icon: ({ rootComponent }) => { + const tool = rootComponent.gfx.tool.currentToolOption$.peek(); + + if (tool.type === 'connector') { + switch (tool.mode) { + case ConnectorMode.Orthogonal: + return ConnectorLWithArrowIcon; + case ConnectorMode.Curve: + return ConnectorCWithArrowIcon; + case ConnectorMode.Straight: + return ConnectorXWithArrowIcon; + } + } + return ConnectorIcon; + }, +}); + +pie.command({ + label: 'Curved', + icon: ConnectorCWithArrowIcon, + action: setEdgelessToolAction(tool => + tool.setTool('connector', { + mode: ConnectorMode.Curve, + }) + ), +}); + +pie.command({ + label: 'Elbowed', + icon: ConnectorXWithArrowIcon, + action: setEdgelessToolAction(tool => + tool.setTool('connector', { + mode: ConnectorMode.Orthogonal, + }) + ), +}); + +pie.command({ + label: 'Straight', + icon: ConnectorLWithArrowIcon, + action: setEdgelessToolAction(tool => + tool.setTool('connector', { + mode: ConnectorMode.Straight, + }) + ), +}); + +pie.colorPicker({ + label: 'Line Color', + active: getActiveConnectorStrokeColor, + onChange: (color: string, { rootComponent }: PieMenuContext) => { + rootComponent.std.get(EditPropsStore).recordLastProps('connector', { + stroke: color as LastProps['connector']['stroke'], + }); + }, + colors: LINE_COLORS.map(color => ({ color })), +}); + +pie.endSubmenu(); + +// Shapes Submenu +pie.beginSubmenu({ + label: 'Shapes', + icon: EdgelessGeneralShapeIcon, +}); + +const shapes = [ + { + type: ShapeType.Rect, + label: 'Rect', + icon: (style: ShapeStyle) => + style === ShapeStyle.General ? SquareIcon : ScribbledSquareIcon, + }, + { + type: ShapeType.Ellipse, + label: 'Ellipse', + icon: (style: ShapeStyle) => + style === ShapeStyle.General ? EllipseIcon : ScribbledEllipseIcon, + }, + { + type: ShapeType.Triangle, + label: 'Triangle', + icon: (style: ShapeStyle) => + style === ShapeStyle.General ? TriangleIcon : ScribbledTriangleIcon, + }, + { + type: ShapeType.Diamond, + label: 'Diamond', + icon: (style: ShapeStyle) => + style === ShapeStyle.General ? DiamondIcon : ScribbledDiamondIcon, + }, +]; + +shapes.forEach(shape => { + pie.command({ + label: shape.label, + icon: ({ rootComponent }) => { + const attributes = + rootComponent.std.get(EditPropsStore).lastProps$.value[ + `shape:${shape.type}` + ]; + return shape.icon(attributes.shapeStyle); + }, + + action: ({ rootComponent }) => { + rootComponent.gfx.tool.setTool('shape', { + shapeName: shape.type, + }); + updateShapeOverlay(rootComponent); + }, + }); +}); + +pie.command({ + label: 'Toggle Style', + icon: ({ rootComponent }) => { + const { shapeStyle } = + rootComponent.std.get(EditPropsStore).lastProps$.value[ + 'shape:roundedRect' + ]; + return shapeStyle === ShapeStyle.General + ? ScribbledStyleIcon + : GeneralStyleIcon; + }, + + action: ({ rootComponent }) => { + const { shapeStyle } = + rootComponent.std.get(EditPropsStore).lastProps$.value[ + 'shape:roundedRect' + ]; + const toggleType = + shapeStyle === ShapeStyle.General + ? ShapeStyle.Scribbled + : ShapeStyle.General; + + rootComponent.std.get(EditPropsStore).recordLastProps('shape:roundedRect', { + shapeStyle: toggleType, + }); + + updateShapeOverlay(rootComponent); + }, +}); + +pie.colorPicker({ + label: 'Fill', + active: getActiveShapeColor('fill'), + onChange: (color: string, { rootComponent }: PieMenuContext) => { + rootComponent.std.get(EditPropsStore).recordLastProps('shape:roundedRect', { + fillColor: color as LastProps['shape:roundedRect']['fillColor'], + }); + updateShapeOverlay(rootComponent); + }, + colors: SHAPE_FILL_COLORS.map(color => ({ color })), +}); + +pie.colorPicker({ + label: 'Stroke', + hollow: true, + active: getActiveShapeColor('stroke'), + onChange: (color: string, { rootComponent }: PieMenuContext) => { + rootComponent.std.get(EditPropsStore).recordLastProps('shape:roundedRect', { + strokeColor: color as LastProps['shape:roundedRect']['strokeColor'], + }); + updateShapeOverlay(rootComponent); + }, + colors: SHAPE_STROKE_COLORS.map(color => ({ color, name: 'Color' })), +}); + +pie.endSubmenu(); + +export const edgelessToolsPieSchema = pie.build(); diff --git a/blocksuite/blocks/src/root-block/widgets/pie-menu/index.ts b/blocksuite/blocks/src/root-block/widgets/pie-menu/index.ts new file mode 100644 index 0000000000..6bee2823d7 --- /dev/null +++ b/blocksuite/blocks/src/root-block/widgets/pie-menu/index.ts @@ -0,0 +1,165 @@ +import type { UIEventStateContext } from '@blocksuite/block-std'; +import { WidgetComponent } from '@blocksuite/block-std'; +import type { IVec } from '@blocksuite/global/utils'; +import { noop } from '@blocksuite/global/utils'; +import { nothing } from 'lit'; +import { state } from 'lit/decorators.js'; + +import { EdgelessRootBlockComponent } from '../../edgeless/edgeless-root-block.js'; +import type { PieMenuSchema } from './base.js'; +import { PieNodeCenter } from './components/pie-node-center.js'; +import { PieNodeChild } from './components/pie-node-child.js'; +import { PieNodeContent } from './components/pie-node-content.js'; +import { PieCenterRotator } from './components/rotator.js'; +import { edgelessToolsPieSchema } from './config.js'; +import { PieMenu } from './menu.js'; +import { PieManager } from './pie-manager.js'; + +noop(PieNodeContent); +noop(PieNodeCenter); +noop(PieCenterRotator); +noop(PieNodeChild); + +export const AFFINE_PIE_MENU_WIDGET = 'affine-pie-menu-widget'; + +export class AffinePieMenuWidget extends WidgetComponent { + private _handleCursorPos = (ctx: UIEventStateContext) => { + const ev = ctx.get('pointerState'); + const { x, y } = ev.point; + this.mouse = [x, y]; + }; + + private _handleKeyUp = (ctx: UIEventStateContext) => { + if (!this.currentMenu) return; + const ev = ctx.get('keyboardState'); + const { trigger } = this.currentMenu.schema; + + if (trigger({ keyEvent: ev.raw, rootComponent: this.rootComponent })) { + clearTimeout(this.selectOnTrigRelease.timeout); + if (this.selectOnTrigRelease.allow) { + this.currentMenu.selectHovered(); + this.currentMenu.close(); + } + } + }; + + mouse: IVec = [innerWidth / 2, innerHeight / 2]; + + // No action if the currently hovered node is a submenu + selectOnTrigRelease: { allow: boolean; timeout?: NodeJS.Timeout } = { + allow: false, + }; + + get isEnabled() { + return this.doc.awarenessStore.getFlag('enable_pie_menu'); + } + + // if key is released before 100ms then the menu is kept open, else + get isOpen() { + return !!this.currentMenu; + } + + get rootComponent(): EdgelessRootBlockComponent { + const rootComponent = this.block; + if (rootComponent instanceof EdgelessRootBlockComponent) { + return rootComponent; + } + throw new Error('AffinePieMenuWidget is only supported in edgeless'); + } + + private _attachMenu(schema: PieMenuSchema) { + if (this.currentMenu && this.currentMenu.id === schema.id) + return this.currentMenu.close(); + + const [x, y] = this.mouse; + const menu = this._createMenu(schema, { + x, + y, + widgetComponent: this, + }); + this.currentMenu = menu; + + this.selectOnTrigRelease.timeout = setTimeout(() => { + this.selectOnTrigRelease.allow = true; + }, PieManager.settings.SELECT_ON_RELEASE_TIMEOUT); + } + + private _initPie() { + PieManager.setup({ rootComponent: this.rootComponent }); + + this._disposables.add( + PieManager.slots.open.on(this._attachMenu.bind(this)) + ); + } + + private _onMenuClose() { + this.currentMenu = null; + this.selectOnTrigRelease.allow = false; + } + + // on trigger key release it will select the currently hovered menu node + _createMenu( + schema: PieMenuSchema, + { + x, + y, + widgetComponent, + }: { + x: number; + y: number; + widgetComponent: AffinePieMenuWidget; + } + ) { + const menu = new PieMenu(); + menu.id = schema.id; + menu.schema = schema; + menu.position = [x, y]; + menu.rootComponent = widgetComponent.rootComponent; + menu.widgetComponent = widgetComponent; + menu.abortController.signal.addEventListener( + 'abort', + this._onMenuClose.bind(this) + ); + + return menu; + } + + override connectedCallback(): void { + super.connectedCallback(); + + if (!this.isEnabled) return; + + this.handleEvent('keyUp', this._handleKeyUp, { global: true }); + this.handleEvent('pointerMove', this._handleCursorPos, { global: true }); + this.handleEvent( + 'wheel', + ctx => { + const state = ctx.get('defaultState'); + if (state.event instanceof WheelEvent) state.event.stopPropagation(); + }, + { global: true } + ); + + this._initPie(); + } + + override disconnectedCallback(): void { + super.disconnectedCallback(); + PieManager.dispose(); + } + + override render() { + return this.currentMenu ?? nothing; + } + + @state() + accessor currentMenu: PieMenu | null = null; +} + +declare global { + interface HTMLElementTagNameMap { + [AFFINE_PIE_MENU_WIDGET]: AffinePieMenuWidget; + } +} + +PieManager.add(edgelessToolsPieSchema); diff --git a/blocksuite/blocks/src/root-block/widgets/pie-menu/menu.ts b/blocksuite/blocks/src/root-block/widgets/pie-menu/menu.ts new file mode 100644 index 0000000000..1b98182316 --- /dev/null +++ b/blocksuite/blocks/src/root-block/widgets/pie-menu/menu.ts @@ -0,0 +1,296 @@ +import { CommonUtils } from '@blocksuite/affine-block-surface'; +import type { IVec } from '@blocksuite/global/utils'; +import { + assertEquals, + assertExists, + Slot, + Vec, + WithDisposable, +} from '@blocksuite/global/utils'; +import { html, LitElement, nothing } from 'lit'; +import { property } from 'lit/decorators.js'; +import { styleMap } from 'lit/directives/style-map.js'; + +import type { EdgelessRootBlockComponent } from '../../edgeless/edgeless-root-block.js'; +import type { PieMenuSchema, PieNodeModel } from './base.js'; +import type { AffinePieMenuWidget } from './index.js'; +import { PieNode } from './node.js'; +import { PieManager } from './pie-manager.js'; +import { pieMenuStyles } from './styles.js'; +import { + getPosition, + isColorNode, + isCommandNode, + isNodeWithAction, + isNodeWithChildren, + isRootNode, + isSubmenuNode, +} from './utils.js'; + +const { toDegree, toRadian } = CommonUtils; + +export class PieMenu extends WithDisposable(LitElement) { + static override styles = pieMenuStyles; + + private _handleKeyDown = (ev: KeyboardEvent) => { + const { key } = ev; + if (key === 'Escape') { + return this.abortController.abort(); + } + + if (ev.code === 'Backspace') { + if (this.selectionChain.length <= 1) return; + const { containerNode } = this.activeNode; + if (containerNode) this.popSelectionChainTo(containerNode); + } + + if (key.match(/\d+/)) { + this.selectChildWithIndex(parseInt(key)); + } + }; + + private _handlePointerMove = (ev: PointerEvent) => { + const { clientX, clientY } = ev; + + const { ACTIVATE_THRESHOLD_MIN } = PieManager.settings; + + const lenSq = this.getActiveNodeToMouseLenSq([clientX, clientY]); + + if (lenSq > ACTIVATE_THRESHOLD_MIN ** 2) { + const [nodeX, nodeY] = this.getActiveNodeRelPos(); + const dx = clientX - nodeX; + const dy = clientY - nodeY; + + const TAU = Math.PI * 2; + const angle = toDegree((Math.atan2(dy, dx) + TAU) % TAU); // convert from [-PI, PI] to [0 TAU] + this.slots.pointerAngleUpdated.emit(angle); + } else { + this.slots.pointerAngleUpdated.emit(null); // acts like a abort signal + } + }; + + private _hoveredNode: PieNode | null = null; + + private _openSubmenuTimeout?: NodeJS.Timeout; + + private selectChildWithIndex = (index: number) => { + const activeNode = this.activeNode; + if (!activeNode || isNaN(index)) return; + + const node = activeNode.querySelector( + `& > affine-pie-node[index='${index}']` + ); + + if (node instanceof PieNode && !isColorNode(node.model)) { + // colors are more than 9 may be another method ? + if (isSubmenuNode(node.model)) this.openSubmenu(node); + else node.select(); + + if (isCommandNode(node.model)) this.close(); + } + }; + + abortController = new AbortController(); + + selectionChain: PieNode[] = []; + + slots = { + pointerAngleUpdated: new Slot<number | null>(), + requestNodeUpdate: new Slot(), + }; + + get activeNode() { + const node = this.selectionChain[this.selectionChain.length - 1]; + assertExists(node, 'Required atLeast 1 node active'); + return node; + } + + get hoveredNode() { + return this._hoveredNode; + } + + get rootNode() { + const node = this.selectionChain[0]; + assertExists(node, 'No root node'); + return node; + } + + private _createNodeTree(nodeSchema: PieNodeModel): PieNode { + const node = new PieNode(); + const { angle, startAngle, endAngle, label } = nodeSchema; + + node.id = label; + node.model = nodeSchema; + node.angle = angle ?? 0; + node.startAngle = startAngle ?? 0; + node.endAngle = endAngle ?? 0; + node.menu = this; + + if (!isRootNode(nodeSchema)) { + node.slot = 'children-slot'; + const { PIE_RADIUS } = PieManager.settings; + const isColorNode = nodeSchema.type === 'color'; + const radius = isColorNode ? PIE_RADIUS * 0.6 : PIE_RADIUS; + + node.position = getPosition(toRadian(node.angle), [radius, radius]); + } else { + node.position = [0, 0]; + } + + if (isNodeWithChildren(nodeSchema)) { + nodeSchema.children.forEach((childSchema, i) => { + const childNode = this._createNodeTree(childSchema); + childNode.containerNode = node; + childNode.index = i + 1; + childNode.setAttribute('index', childNode.index.toString()); + + node.append(childNode); + }); + } + + return node; + } + + private _setupEvents() { + this._disposables.addFromEvent( + this.widgetComponent, + 'pointermove', + this._handlePointerMove + ); + + this._disposables.addFromEvent(document, 'keydown', this._handleKeyDown); + } + + close() { + this.abortController.abort(); + } + + override connectedCallback(): void { + super.connectedCallback(); + this._setupEvents(); + const root = this._createNodeTree(this.schema.root); + this.selectionChain.push(root); + } + + /** + * Position of the active node relative to the view + */ + getActiveNodeRelPos(): IVec { + const position: IVec = [...this.position]; // use the menus position at start which will be the position of the root node + + for (const node of this.selectionChain) { + position[0] += node.position[0]; + position[1] += node.position[1]; + } + return position; + } + + getActiveNodeToMouseLenSq(mouse: IVec) { + const [x, y] = mouse; + const [nodeX, nodeY] = this.getActiveNodeRelPos(); + + const dx = x - nodeX; + const dy = y - nodeY; + + return Vec.len2([dx, dy]); + } + + getNodeRelPos(node: PieNode): IVec { + const position: IVec = [...this.position]; + let cur: PieNode | null = node; + + while (cur !== null) { + position[0] += cur.position[0]; + position[1] += cur.position[1]; + cur = cur.containerNode; + } + + return position; + } + + isActiveNode(node: PieNode) { + return this.activeNode === node; + } + + isChildOfActiveNode(node: PieNode) { + return node.containerNode === this.activeNode; + } + + openSubmenu(submenu: PieNode) { + assertEquals(submenu.model.type, 'submenu', 'Need node of type submenu'); + + if (isNodeWithAction(submenu.model)) submenu.select(); + + this.selectionChain.push(submenu); + this.setHovered(null); + this.slots.requestNodeUpdate.emit(); + } + + popSelectionChainTo(node: PieNode) { + assertEquals( + isNodeWithChildren(node.model), + true, + 'Required a root node or a submenu node' + ); + + while (this.selectionChain.length > 1 && this.activeNode !== node) { + this.selectionChain.pop(); + } + this.requestUpdate(); + this.slots.requestNodeUpdate.emit(); + } + + override render() { + const [x, y] = this.position; + const menuStyles = { + transform: `translate(${x}px, ${y}px) translate(-50%, -50%)`, + }; + + return html` <div class="pie-menu-container"> + <div class="overlay" @click="${() => this.abortController.abort()}"></div> + + <div style="${styleMap(menuStyles)}" class="pie-menu"> + ${this.rootNode ?? nothing} + </div> + </div>`; + } + + selectHovered() { + const { hoveredNode } = this; + + if (hoveredNode) { + hoveredNode.select(); + } + } + + setHovered(node: PieNode | null) { + clearTimeout(this._openSubmenuTimeout); + + this._hoveredNode = node; + + if (!node) return; + + if (isSubmenuNode(node.model)) { + const { openOnHover, timeoutOverride } = node.model; + const { SUBMENU_OPEN_TIMEOUT } = PieManager.settings; + + if (openOnHover !== undefined && !openOnHover) return; + + this._openSubmenuTimeout = setTimeout(() => { + this.openSubmenu(node); + }, timeoutOverride ?? SUBMENU_OPEN_TIMEOUT); + } + } + + @property({ attribute: false }) + accessor position!: IVec; + + @property({ attribute: false }) + accessor rootComponent!: EdgelessRootBlockComponent; + + @property({ attribute: false }) + accessor schema!: PieMenuSchema; + + @property({ attribute: false }) + accessor widgetComponent!: AffinePieMenuWidget; +} diff --git a/blocksuite/blocks/src/root-block/widgets/pie-menu/node.ts b/blocksuite/blocks/src/root-block/widgets/pie-menu/node.ts new file mode 100644 index 0000000000..b2c473ff0a --- /dev/null +++ b/blocksuite/blocks/src/root-block/widgets/pie-menu/node.ts @@ -0,0 +1,182 @@ +import type { IVec } from '@blocksuite/global/utils'; +import { WithDisposable } from '@blocksuite/global/utils'; +import { html, LitElement } from 'lit'; +import { property, state } from 'lit/decorators.js'; + +import type { PieNodeModel } from './base.js'; +import type { PieMenu } from './menu.js'; +import { pieNodeStyles } from './styles.js'; +import { + isAngleBetween, + isColorNode, + isCommandNode, + isNodeWithAction, + isNodeWithChildren, + isRootNode, +} from './utils.js'; + +export class PieNode extends WithDisposable(LitElement) { + static override styles = pieNodeStyles; + + private _handleChildNodeClick = () => { + this.select(); + if (isCommandNode(this.model)) this.menu.close(); + }; + + private _handleGoBack = () => { + // If the node is not active and if it is hovered then we can go back to that node + if (this.menu.activeNode !== this) { + this.menu.popSelectionChainTo(this); + } + }; + + private _onPointerAngleUpdated = (angle: number | null) => { + this._rotatorAngle = angle; + this.menu.activeNode.requestUpdate(); + + if (isRootNode(this.model) || !this.menu.isChildOfActiveNode(this)) return; + if (angle === null) { + this._isHovering = false; + this.menu.setHovered(null); + return; + } + + if (isAngleBetween(angle, this.startAngle, this.endAngle)) { + if (this.menu.hoveredNode !== this) { + this._isHovering = true; + this.menu.setHovered(this); + } + } else { + this._isHovering = false; + } + }; + + private _rotatorAngle: number | null = null; + + get icon() { + const icon = this.model.icon; + if (typeof icon === 'function') { + const { menu } = this; + const { rootComponent, widgetComponent } = menu; + return icon({ + rootComponent, + menu, + widgetComponent, + node: this, + }); + } + return icon; + } + + private _renderCenterNode() { + const isActiveNode = this.isActive(); + + return html` + <pie-node-center + .node=${this} + .hoveredNode=${this.menu.hoveredNode} + .isActive=${isActiveNode} + .onMouseEnter=${this._handleGoBack} + .rotatorAngle="${this._rotatorAngle}" + > + <slot name="children-slot"></slot> + </pie-node-center> + `; + } + + private _renderChildNode() { + const visible = this.menu.isChildOfActiveNode(this); + return html`<pie-node-child + .node="${this}" + .visible="${visible}" + .hovering="${this._isHovering}" + .onClick="${this._handleChildNodeClick}" + > + </pie-node-child>`; + } // for selecting with keyboard + + private _setupEvents() { + this._disposables.add( + this.menu.slots.pointerAngleUpdated.on(this._onPointerAngleUpdated) + ); + + this._disposables.add( + this.menu.slots.requestNodeUpdate.on(() => this.requestUpdate()) + ); + } + + override connectedCallback(): void { + super.connectedCallback(); + this._setupEvents(); + } + + isActive() { + return this.menu.isActiveNode(this); + } + + isCenterNode() { + return ( + isNodeWithChildren(this.model) && this.menu.selectionChain.includes(this) + ); + } + + protected override render() { + return this.isCenterNode() + ? this._renderCenterNode() + : this._renderChildNode(); + } + + select() { + const schema = this.model; + + if (isRootNode(schema)) return; + + const ctx = { + rootComponent: this.menu.rootComponent, + menu: this.menu, + widgetComponent: this.menu.widgetComponent, + node: this, + }; + + if (isNodeWithAction(schema)) { + schema.action(ctx); + } else if (isColorNode(schema)) { + schema.onChange(schema.color, ctx); + } + + this.requestUpdate(); + } + + @state() + private accessor _isHovering = false; + + @property({ attribute: false }) + accessor angle!: number; + + @property({ attribute: false }) + accessor containerNode: PieNode | null = null; + + @property({ attribute: false }) + accessor endAngle!: number; + + @property({ attribute: false }) + accessor index!: number; + + @property({ attribute: false }) + accessor menu!: PieMenu; + + @property({ attribute: false }) + accessor model!: PieNodeModel; + + @property({ attribute: false }) + accessor position!: IVec; + + @property({ attribute: false }) + accessor startAngle!: number; +} + +declare global { + interface HTMLElementTagNameMap { + 'pie-node': PieNode; + } +} diff --git a/blocksuite/blocks/src/root-block/widgets/pie-menu/pie-builder.ts b/blocksuite/blocks/src/root-block/widgets/pie-menu/pie-builder.ts new file mode 100644 index 0000000000..8d4246ea73 --- /dev/null +++ b/blocksuite/blocks/src/root-block/widgets/pie-menu/pie-builder.ts @@ -0,0 +1,185 @@ +import { assertExists } from '@blocksuite/global/utils'; + +import { ColorUnit } from '../../edgeless/components/panel/color-panel.js'; +import type { + ActionFunction, + PieColorNodeModel, + PieCommandNodeModel, + PieMenuContext, + PieMenuSchema, + PieNodeModel, + PieSubmenuNodeModel, +} from './base.js'; +import { PieManager } from './pie-manager.js'; +import { calcNodeAngles, calcNodeWedges, isNodeWithChildren } from './utils.js'; + +export interface IPieColorPickerNodeProps { + label: string; + active: (ctx: PieMenuContext) => string; + onChange: PieColorNodeModel['onChange']; + openOnHover?: PieSubmenuNodeModel['openOnHover']; + hollow?: boolean; + colors: { color: string }[]; +} + +type PieBuilderConstructorProps = Omit< + PieMenuSchema, + 'root' | 'angle' | 'startAngle' | 'endAngle' | 'disabled' +> & { icon: PieNodeModel['icon'] }; + +export class PieMenuBuilder { + private _schema: PieMenuSchema | null = null; + + private _stack: PieNodeModel[] = []; + + constructor(base: PieBuilderConstructorProps) { + this._schema = { + ...base, + root: { + type: 'root', + children: [], + label: base.label, + icon: base.icon, + disabled: false, + }, + }; + this._stack.push(this._schema.root); + } + + private _computeAngles(node: PieNodeModel) { + if ( + !isNodeWithChildren(node) || + !node.children || + node.children.length === 0 + ) { + return; + } + const parentAngle = + node.angle == undefined ? undefined : (node.angle + 180) % 360; + const angles = calcNodeAngles(node.children, parentAngle); + const wedges = calcNodeWedges(angles, parentAngle); + + for (let i = 0; i < node.children.length; ++i) { + const child = node.children[i]; + child.angle = angles[i]; + child.startAngle = wedges[i].start; + child.endAngle = wedges[i].end; + + this._computeAngles(child); + } + } + + private _currentNode(): PieNodeModel { + const node = this._stack[this._stack.length - 1]; + assertExists(node, 'No node active'); + return node; + } + + beginSubmenu( + node: Omit<PieSubmenuNodeModel, 'type' | 'children' | 'role'>, + action?: PieSubmenuNodeModel['action'] + ) { + const curNode = this._currentNode(); + const submenuNode: PieSubmenuNodeModel = { + openOnHover: true, + ...node, + type: 'submenu', + role: action ? 'default' : 'command', + action, + children: [], + }; + if (submenuNode.action !== undefined) + submenuNode.timeoutOverride = + PieManager.settings.EXPANDABLE_ACTION_NODE_TIMEOUT; + + if (isNodeWithChildren(curNode)) { + curNode.children.push(submenuNode); + } + + this._stack.push(submenuNode); + + return this; + } + + build() { + const schema = this._schema; + assertExists(schema); + this._computeAngles(schema.root); + + this._schema = null; + this._stack = []; + return schema; + } + + colorPicker(props: IPieColorPickerNodeProps) { + const hollow = props.hollow ?? false; + + const icon = (ctx: PieMenuContext) => { + const color = props.active(ctx); + + return ColorUnit(color, { hollowCircle: hollow }); + }; + + const colorPickerNode: PieSubmenuNodeModel = { + type: 'submenu', + icon, + label: props.label, + role: 'color-picker', + openOnHover: props.openOnHover ?? true, + children: props.colors.map(({ color }) => ({ + icon: () => ColorUnit(color, { hollowCircle: hollow }), + type: 'color', + hollowCircle: hollow, + label: color, + color: color, + onChange: props.onChange, + })), + }; + + const curNode = this._currentNode(); + if (isNodeWithChildren(curNode)) { + curNode.children.push(colorPickerNode); + } + } + + command(node: Omit<PieCommandNodeModel, 'type'>) { + const curNode = this._currentNode(); + const actionNode: PieCommandNodeModel = { ...node, type: 'command' }; + + if (isNodeWithChildren(curNode)) { + curNode.children.push(actionNode); + } + + return this; + } + + endSubmenu() { + if (this._stack.length === 1) + throw new Error('Cant end submenu already on the root node'); + this._stack.pop(); + + return this; + } + + expandableCommand( + node: Omit<PieSubmenuNodeModel, 'type' | 'children' | 'role'> & { + action: ActionFunction; + submenus: (pie: PieMenuBuilder) => void; + } + ) { + const { icon, label } = node; + this.beginSubmenu({ icon, label }, node.action); + node.submenus(this); + this.endSubmenu(); + } + + reset(base: PieBuilderConstructorProps) { + this._stack = []; + this._schema = { + ...base, + root: { type: 'root', children: [], label: base.label }, + }; + + this._stack.push(this._schema.root); + } +} diff --git a/blocksuite/blocks/src/root-block/widgets/pie-menu/pie-manager.ts b/blocksuite/blocks/src/root-block/widgets/pie-menu/pie-manager.ts new file mode 100644 index 0000000000..dacaecaa27 --- /dev/null +++ b/blocksuite/blocks/src/root-block/widgets/pie-menu/pie-manager.ts @@ -0,0 +1,105 @@ +import { assertExists } from '@blocksuite/global/utils'; +import { Slot } from '@blocksuite/store'; + +import type { EdgelessRootBlockComponent } from '../../edgeless/edgeless-root-block.js'; +import type { PieMenuId } from '../../types.js'; +import type { PieMenuSchema } from './base.js'; + +/** + * Static class for managing pie menus + */ + +export class PieManager { + private static registeredSchemas: Record<string, PieMenuSchema> = {}; + + private static schemas = new Set<PieMenuSchema>(); + + static settings = { + /** + * Specifies the distance between the root-node and the child-nodes + */ + PIE_RADIUS: 150, + /** + * After the specified time if trigger is released the menu will select the currently hovered node\ + * If released before the time the pie menu will stay open and you can select with mouse or the trigger key\ + * Time is in `milliseconds` + * @default 150 + */ + SELECT_ON_RELEASE_TIMEOUT: 150, + + /** + * Distance from the center of the active node to start focusing a child node + */ + ACTIVATE_THRESHOLD_MIN: 60, + + /** + * Time delay to open submenu after hovering a submenu node + */ + SUBMENU_OPEN_TIMEOUT: 200, + + EXPANDABLE_ACTION_NODE_TIMEOUT: 300, + }; + + static slots = { + open: new Slot<PieMenuSchema>(), + }; + + private static _getSchema(id: string) { + const schema = this.registeredSchemas[id]; + assertExists(schema); + return schema; + } + + private static _register(schema: PieMenuSchema) { + const { id } = schema; + + if (this.registeredSchemas[id]) { + return; + } + + this.registeredSchemas[id] = schema; + } + + private static _setupTriggers(rootComponent: EdgelessRootBlockComponent) { + Object.values(this.registeredSchemas).forEach(schema => { + const { trigger } = schema; + + rootComponent.handleEvent( + 'keyDown', + ctx => { + const ev = ctx.get('keyboardState'); + + if (trigger({ keyEvent: ev.raw, rootComponent }) && !ev.raw.repeat) { + this.open(schema.id); + } + }, + { global: true } + ); + }); + } + + static add(schema: PieMenuSchema) { + return this.schemas.add(schema); + } + + static dispose() { + this.registeredSchemas = {}; + } + + static open(id: PieMenuId) { + this.slots.open.emit(this._getSchema(id)); + } + + static remove(schema: PieMenuSchema) { + return this.schemas.delete(schema); + } + + static setup({ + rootComponent, + }: { + rootComponent: EdgelessRootBlockComponent; + }) { + this.schemas.forEach(schema => this._register(schema)); + this._setupTriggers(rootComponent); + } +} diff --git a/blocksuite/blocks/src/root-block/widgets/pie-menu/styles.ts b/blocksuite/blocks/src/root-block/widgets/pie-menu/styles.ts new file mode 100644 index 0000000000..5930beca1b --- /dev/null +++ b/blocksuite/blocks/src/root-block/widgets/pie-menu/styles.ts @@ -0,0 +1,58 @@ +import { css } from 'lit'; + +export const pieMenuStyles = css` + .menu-container { + user-select: none; + z-index: var(--affine-z-index-popover); + isolation: isolate; + } + + .pie-menu-container > .overlay { + top: 0; + left: 0; + height: 100vh; + width: 100vw; + position: fixed; + z-index: var(--affine-z-index-popover); + } + + .pie-menu { + position: fixed; + top: 0; + left: 0; + box-sizing: border-box; + z-index: calc( + var(--affine-z-index-popover) + 10 + ); /* This is important or else will hover will not work */ + } +`; + +export const pieNodeStyles = css` + .pie-node { + position: absolute; + background: var(--affine-background-overlay-panel-color); + user-select: none; + box-shadow: var(--affine-shadow-2); + border: 2px solid var(--affine-border-color); + border-radius: 50%; + display: flex; + font-size: 0.8rem; + align-items: center; + justify-content: center; + text-align: center; + transition: all 250ms cubic-bezier(0.775, 1.325, 0.535, 1); + } + + @keyframes my-anim { + 0% { + transform: translate(0, 0); + opacity: 0; + } + 40% { + opacity: 0; + } + 100% { + opacity: 100; + } + } +`; diff --git a/blocksuite/blocks/src/root-block/widgets/pie-menu/utils.ts b/blocksuite/blocks/src/root-block/widgets/pie-menu/utils.ts new file mode 100644 index 0000000000..48bd84ebd1 --- /dev/null +++ b/blocksuite/blocks/src/root-block/widgets/pie-menu/utils.ts @@ -0,0 +1,346 @@ +import { EditPropsStore } from '@blocksuite/affine-shared/services'; +import type { ToolController } from '@blocksuite/block-std/gfx'; +import type { IVec } from '@blocksuite/global/utils'; + +import { EdgelessRootBlockComponent } from '../../edgeless/edgeless-root-block.js'; +import { ShapeTool } from '../../edgeless/gfx-tool/shape-tool.js'; +import type { + ActionFunction, + IPieNodeWithAction, + PieColorNodeModel, + PieCommandNodeModel, + PieMenuContext, + PieNodeModel, + PieNonRootNode, + PieRootNodeModel, + PieSubmenuNodeModel, +} from './base.js'; + +export function updateShapeOverlay(rootComponent: EdgelessRootBlockComponent) { + const controller = rootComponent.gfx.tool.currentTool$.peek(); + if (controller instanceof ShapeTool) { + controller.createOverlay(); + } +} + +export function getActiveShapeColor(type: 'fill' | 'stroke') { + return ({ rootComponent }: PieMenuContext) => { + if (rootComponent instanceof EdgelessRootBlockComponent) { + const props = + rootComponent.std.get(EditPropsStore).lastProps$.value[ + 'shape:roundedRect' + ]; + const color = type == 'fill' ? props.fillColor : props.strokeColor; + return color.toString(); + } + return ''; + }; +} + +export function getActiveConnectorStrokeColor({ + rootComponent, +}: PieMenuContext) { + if (rootComponent instanceof EdgelessRootBlockComponent) { + const props = + rootComponent.std.get(EditPropsStore).lastProps$.value.connector; + const color = props.stroke; + return color.toString(); + } + return ''; +} + +export function setEdgelessToolAction( + callback: (tool: ToolController) => void +): ActionFunction { + return ({ rootComponent }) => { + callback(rootComponent.gfx.tool); + }; +} + +export function getPosition(angleRad: number, v: IVec): IVec { + const x = Math.cos(angleRad) * v[0]; + const y = Math.sin(angleRad) * v[1]; + return [x, y]; +} + +export function isNodeWithChildren( + node: PieNodeModel +): node is PieNodeModel & { children: PieNonRootNode[] } { + return 'children' in node; +} + +export function isRootNode(model: PieNodeModel): model is PieRootNodeModel { + return model.type === 'root'; +} + +export function isSubmenuNode( + model: PieNodeModel +): model is PieSubmenuNodeModel { + return model.type === 'submenu'; +} + +export function isCommandNode( + model: PieNodeModel +): model is PieCommandNodeModel { + return model.type === 'command'; +} + +export function isColorNode(model: PieNodeModel): model is PieColorNodeModel { + return model.type === 'color'; +} + +export function isNodeWithAction( + node: PieNodeModel +): node is IPieNodeWithAction { + return 'action' in node && typeof node.action === 'function'; +} + +//------------------------------------------------------------------------------------ +// credits: https://github.com/kando-menu/kando/blob/main/src/renderer/math/index.ts +//------------------------------------------------------------------------------------ +export function calcNodeAngles( + node: { angle?: number }[], + parentAngle?: number +): number[] { + const nodeAngles: number[] = []; + + // Shouldn't happen, but who knows... + if (node.length == 0) { + return nodeAngles; + } + + // We begin by storing all fixed angles. + const fixedAngles: { angle: number; index: number }[] = []; + node.forEach((item, index) => { + if (item.angle && item.angle >= 0) { + fixedAngles.push({ angle: item.angle, index: index }); + } + }); + + // Make sure that the parent link does not collide with a fixed item. For now, we + // just move the fixed angle a tiny bit. This is somewhat error-prone as it may + // collide with another fixed angle now. Maybe this could be solved in a better way? + // Maybe some global minimum angular spacing of items? + if (parentAngle != undefined) { + // eslint-disable-next-line @typescript-eslint/prefer-for-of + for (let i = 0; i < fixedAngles.length; i++) { + if (Math.abs(fixedAngles[i].angle - parentAngle) < 0.0001) { + fixedAngles[i].angle += 0.1; + } + } + } + + // Make sure that the fixed angles are between 0° and 360°. + // eslint-disable-next-line @typescript-eslint/prefer-for-of + for (let i = 0; i < fixedAngles.length; i++) { + fixedAngles[i].angle = fixedAngles[i].angle % 360; + } + + // Make sure that the fixed angles increase monotonically. If a fixed angle is larger + // than the next one, the next one will be ignored. + for (let i = 0; i < fixedAngles.length - 1; ) { + if (fixedAngles[i].angle > fixedAngles[i + 1].angle) { + fixedAngles.splice(i + 1, 1); + } else { + ++i; + } + } + + // If no item has a fixed angle, we assign one to the first item. If there is no + // parent item, this is on the top (0°). Else, the angular space will be evenly + // distributed to all child items and the first item will be at the first possible + // location with an angle > 0. + if (fixedAngles.length == 0) { + let firstAngle = 0; + if (parentAngle != undefined) { + const wedgeSize = 360 / (node.length + 1); + let minAngle = 360; + for (let i = 0; i < node.length; i++) { + minAngle = Math.min( + minAngle, + (parentAngle + (i + 1) * wedgeSize) % 360 + ); + } + firstAngle = minAngle; + } + fixedAngles.push({ angle: firstAngle, index: 0 }); + nodeAngles[0] = firstAngle; + } + + // Now we iterate through the fixed angles, always considering wedges between + // consecutive pairs of fixed angles. If there is only one fixed angle, there is also + // only one 360°-wedge. + for (let i = 0; i < fixedAngles.length; i++) { + const wedgeBeginIndex = fixedAngles[i].index; + const wedgeBeginAngle = fixedAngles[i].angle; + const wedgeEndIndex = fixedAngles[(i + 1) % fixedAngles.length].index; + let wedgeEndAngle = fixedAngles[(i + 1) % fixedAngles.length].angle; + + // The fixed angle can be stored in our output. + nodeAngles[wedgeBeginIndex] = wedgeBeginAngle; + + // Make sure we loop around. + if (wedgeEndAngle <= wedgeBeginAngle) { + wedgeEndAngle += 360; + } + + // Calculate the number of items between the begin and end indices. + let wedgeItemCount = + (wedgeEndIndex - wedgeBeginIndex - 1 + node.length) % node.length; + + // We have one item more if the parent link is inside our wedge. + let parentInWedge = false; + + if (parentAngle != undefined) { + // It can be that the parent link is inside the current wedge, but it's angle is + // one full turn off. + if (parentAngle < wedgeBeginAngle) { + parentAngle += 360; + } + + parentInWedge = + parentAngle > wedgeBeginAngle && parentAngle < wedgeEndAngle; + if (parentInWedge) { + wedgeItemCount += 1; + } + } + + // Calculate the angular difference between consecutive items in the current wedge. + const wedgeItemGap = + (wedgeEndAngle - wedgeBeginAngle) / (wedgeItemCount + 1); + + // Now we assign an angle to each item between the begin and end indices. + let index = (wedgeBeginIndex + 1) % node.length; + let count = 1; + let parentGapRequired = parentInWedge; + + while (index != wedgeEndIndex) { + let itemAngle = wedgeBeginAngle + wedgeItemGap * count; + + // Insert gap for parent link if required. for connector + if ( + parentGapRequired && + itemAngle + wedgeItemGap / 2 - (parentAngle ?? 0) > 0 + ) { + count += 1; + itemAngle = wedgeBeginAngle + wedgeItemGap * count; + parentGapRequired = false; + } + + nodeAngles[index] = itemAngle % 360; + + index = (index + 1) % node.length; + count += 1; + } + } + + return nodeAngles; +} + +export function calcNodeWedges( + nodeAngles: number[], + parentAngle?: number +): { start: number; end: number }[] { + // This should never happen, but who knows... + if (nodeAngles.length === 0 && parentAngle === undefined) { + return []; + } + + // If the node has a single child but no parent (e.g. it's the root node), we can + // simply return a full circle. + if (nodeAngles.length === 1 && parentAngle === undefined) { + return [{ start: 0, end: 360 }]; + } + + // If the node has a single child and a parent, we can set the start and end + // angles to the center angles. + if (nodeAngles.length === 1 && parentAngle !== undefined) { + let start = parentAngle; + let center = nodeAngles[0]; + let end = parentAngle + 360; + + [start, center, end] = normalizeConsecutiveAngles(start, center, end); + [start, end] = scaleWedge(start, center, end, 0.5); + + return [{ start: start, end: end }]; + } + + // In all other cases, we loop through the items and compute the wedges. If the parent + // angle happens to be inside a wedge, we crop the wedge accordingly. + const wedges: { start: number; end: number }[] = []; + + for (let i = 0; i < nodeAngles.length; i++) { + let start = nodeAngles[(i + nodeAngles.length - 1) % nodeAngles.length]; + let center = nodeAngles[i]; + let end = nodeAngles[(i + 1) % nodeAngles.length]; + + [start, center, end] = normalizeConsecutiveAngles(start, center, end); + + if (parentAngle !== undefined) { + [start, end] = cropWedge(start, center, end, parentAngle); + [start, center, end] = normalizeConsecutiveAngles(start, center, end); + } + + [start, end] = scaleWedge(start, center, end, 0.5); + + wedges.push({ start: start, end: end }); + } + + return wedges; +} +export function isAngleBetween( + angle: number, + start: number, + end: number +): boolean { + return ( + (angle > start && angle <= end) || + (angle - 360 > start && angle - 360 <= end) || + (angle + 360 > start && angle + 360 <= end) + ); +} + +function normalizeConsecutiveAngles( + start: number, + center: number, + end: number +) { + while (center < start) { + center += 360; + } + + while (end < center) { + end += 360; + } + + while (center >= 360) { + start -= 360; + center -= 360; + end -= 360; + } + + return [start, center, end]; +} + +function cropWedge( + start: number, + center: number, + end: number, + cropAngle: number +) { + if (isAngleBetween(cropAngle, start, center)) { + start = cropAngle; + } + + if (isAngleBetween(cropAngle, center, end)) { + end = cropAngle; + } + + return [start, end]; +} +function scaleWedge(start: number, center: number, end: number, scale: number) { + start = center - (center - start) * scale; + end = center + (end - center) * scale; + + return [start, end]; +} diff --git a/blocksuite/blocks/src/root-block/widgets/slash-menu/config.ts b/blocksuite/blocks/src/root-block/widgets/slash-menu/config.ts new file mode 100644 index 0000000000..09c3be0fa8 --- /dev/null +++ b/blocksuite/blocks/src/root-block/widgets/slash-menu/config.ts @@ -0,0 +1,732 @@ +import { + FigmaIcon, + GithubIcon, + LoomIcon, + YoutubeIcon, +} from '@blocksuite/affine-block-embed'; +import { + ArrowDownBigIcon, + ArrowUpBigIcon, + CopyIcon, + DatabaseKanbanViewIcon20, + DatabaseTableViewIcon20, + DeleteIcon, + FileIcon, + FrameIcon, + HeadingIcon, + ImageIcon20, + LinkedDocIcon, + LinkIcon, + NewDocIcon, + NowIcon, + TodayIcon, + TomorrowIcon, + YesterdayIcon, +} from '@blocksuite/affine-components/icons'; +import { + getInlineEditorByModel, + insertContent, + REFERENCE_NODE, + textFormatConfigs, +} from '@blocksuite/affine-components/rich-text'; +import { toast } from '@blocksuite/affine-components/toast'; +import type { + FrameBlockModel, + ParagraphBlockModel, +} from '@blocksuite/affine-model'; +import { TelemetryProvider } from '@blocksuite/affine-shared/services'; +import { + createDefaultDoc, + openFileOrFiles, +} from '@blocksuite/affine-shared/utils'; +import { viewPresets } from '@blocksuite/data-view/view-presets'; +import { assertType } from '@blocksuite/global/utils'; +import { DualLinkIcon, GroupingIcon, TeXIcon } from '@blocksuite/icons/lit'; +import type { DeltaInsert } from '@blocksuite/inline'; +import type { BlockModel } from '@blocksuite/store'; +import { Slice, Text } from '@blocksuite/store'; +import type { TemplateResult } from 'lit'; + +import { toggleEmbedCardCreateModal } from '../../../_common/components/embed-card/modal/embed-card-create-modal.js'; +import { textConversionConfigs } from '../../../_common/configs/text-conversion.js'; +import { addSiblingAttachmentBlocks } from '../../../attachment-block/utils.js'; +import type { DataViewBlockComponent } from '../../../data-view-block/index.js'; +import { getSurfaceBlock } from '../../../surface-ref-block/utils.js'; +import type { RootBlockComponent } from '../../types.js'; +import { formatDate, formatTime } from '../../utils/misc.js'; +import type { AffineLinkedDocWidget } from '../linked-doc/index.js'; +import { type SlashMenuTooltip, slashMenuToolTips } from './tooltips/index.js'; +import { + createConversionItem, + createTextFormatItem, + insideEdgelessText, + tryRemoveEmptyLine, +} from './utils.js'; + +export type SlashMenuConfig = { + triggerKeys: string[]; + ignoreBlockTypes: BlockSuite.Flavour[]; + items: SlashMenuItem[]; + maxHeight: number; + tooltipTimeout: number; +}; + +export type SlashMenuStaticConfig = Omit<SlashMenuConfig, 'items'> & { + items: SlashMenuStaticItem[]; +}; + +export type SlashMenuItem = SlashMenuStaticItem | SlashMenuItemGenerator; + +export type SlashMenuStaticItem = + | SlashMenuGroupDivider + | SlashMenuActionItem + | SlashSubMenu; + +export type SlashMenuGroupDivider = { + groupName: string; + showWhen?: (ctx: SlashMenuContext) => boolean; +}; + +export type SlashMenuActionItem = { + name: string; + description?: string; + icon?: TemplateResult; + tooltip?: SlashMenuTooltip; + alias?: string[]; + showWhen?: (ctx: SlashMenuContext) => boolean; + action: (ctx: SlashMenuContext) => void | Promise<void>; + + customTemplate?: TemplateResult<1>; +}; + +export type SlashSubMenu = { + name: string; + description?: string; + icon?: TemplateResult; + alias?: string[]; + showWhen?: (ctx: SlashMenuContext) => boolean; + subMenu: SlashMenuStaticItem[]; +}; + +export type SlashMenuItemGenerator = ( + ctx: SlashMenuContext +) => (SlashMenuGroupDivider | SlashMenuActionItem | SlashSubMenu)[]; + +export type SlashMenuContext = { + rootComponent: RootBlockComponent; + model: BlockModel; +}; + +export const defaultSlashMenuConfig: SlashMenuConfig = { + triggerKeys: ['/'], + ignoreBlockTypes: ['affine:code'], + maxHeight: 344, + tooltipTimeout: 800, + items: [ + // --------------------------------------------------------- + { groupName: 'Basic' }, + ...textConversionConfigs + .filter(i => i.type && ['h1', 'h2', 'h3', 'text'].includes(i.type)) + .map(createConversionItem), + { + name: 'Other Headings', + icon: HeadingIcon, + subMenu: [ + { groupName: 'Headings' }, + ...textConversionConfigs + .filter(i => i.type && ['h4', 'h5', 'h6'].includes(i.type)) + .map<SlashMenuActionItem>(createConversionItem), + ], + }, + ...textConversionConfigs + .filter(i => i.flavour === 'affine:code') + .map<SlashMenuActionItem>(createConversionItem), + + ...textConversionConfigs + .filter(i => i.type && ['divider', 'quote'].includes(i.type)) + .map<SlashMenuActionItem>(config => ({ + ...createConversionItem(config), + showWhen: ({ model }) => + model.doc.schema.flavourSchemaMap.has(config.flavour) && + !insideEdgelessText(model), + })), + + { + name: 'Inline equation', + description: 'Create a equation block.', + icon: TeXIcon({ + width: '20', + height: '20', + }), + alias: ['inlineMath, inlineEquation', 'inlineLatex'], + action: ({ rootComponent }) => { + rootComponent.std.command + .chain() + .getTextSelection() + .insertInlineLatex() + .run(); + }, + }, + + // --------------------------------------------------------- + { groupName: 'List' }, + ...textConversionConfigs + .filter(i => i.flavour === 'affine:list') + .map(createConversionItem), + + // --------------------------------------------------------- + { groupName: 'Style' }, + ...textFormatConfigs + .filter(i => !['Code', 'Link'].includes(i.name)) + .map<SlashMenuActionItem>(createTextFormatItem), + + // --------------------------------------------------------- + { + groupName: 'Page', + showWhen: ({ model }) => + model.doc.schema.flavourSchemaMap.has('affine:embed-linked-doc'), + }, + { + name: 'New Doc', + description: 'Start a new document.', + icon: NewDocIcon, + tooltip: slashMenuToolTips['New Doc'], + showWhen: ({ model }) => + model.doc.schema.flavourSchemaMap.has('affine:embed-linked-doc'), + action: ({ rootComponent, model }) => { + const newDoc = createDefaultDoc(rootComponent.doc.collection); + insertContent(rootComponent.host, model, REFERENCE_NODE, { + reference: { + type: 'LinkedPage', + pageId: newDoc.id, + }, + }); + }, + }, + { + name: 'Linked Doc', + description: 'Link to another document.', + icon: LinkedDocIcon, + tooltip: slashMenuToolTips['Linked Doc'], + alias: ['dual link'], + showWhen: ({ rootComponent, model }) => { + const { std } = rootComponent; + const linkedDocWidget = std.view.getWidget( + 'affine-linked-doc-widget', + rootComponent.model.id + ); + if (!linkedDocWidget) return false; + + return model.doc.schema.flavourSchemaMap.has('affine:embed-linked-doc'); + }, + action: ({ model, rootComponent }) => { + const { std } = rootComponent; + + const linkedDocWidget = std.view.getWidget( + 'affine-linked-doc-widget', + rootComponent.model.id + ); + if (!linkedDocWidget) return; + assertType<AffineLinkedDocWidget>(linkedDocWidget); + + const triggerKey = linkedDocWidget.config.triggerKeys[0]; + + insertContent(rootComponent.host, model, triggerKey); + + const inlineEditor = getInlineEditorByModel(rootComponent.host, model); + // Wait for range to be updated + inlineEditor?.slots.inlineRangeSync.once(() => { + linkedDocWidget.show(); + }); + }, + }, + + // --------------------------------------------------------- + { groupName: 'Content & Media' }, + { + name: 'Image', + description: 'Insert an image.', + icon: ImageIcon20, + tooltip: slashMenuToolTips['Image'], + showWhen: ({ model }) => + model.doc.schema.flavourSchemaMap.has('affine:image'), + action: async ({ rootComponent }) => { + const [success, ctx] = rootComponent.std.command + .chain() + .getSelectedModels() + .insertImages({ removeEmptyLine: true }) + .run(); + + if (success) await ctx.insertedImageIds; + }, + }, + { + name: 'Link', + description: 'Add a bookmark for reference.', + icon: LinkIcon, + tooltip: slashMenuToolTips['Link'], + showWhen: ({ model }) => + model.doc.schema.flavourSchemaMap.has('affine:bookmark'), + action: async ({ rootComponent, model }) => { + const parentModel = rootComponent.doc.getParent(model); + if (!parentModel) { + return; + } + const index = parentModel.children.indexOf(model) + 1; + await toggleEmbedCardCreateModal( + rootComponent.host, + 'Links', + 'The added link will be displayed as a card view.', + { mode: 'page', parentModel, index } + ); + tryRemoveEmptyLine(model); + }, + }, + { + name: 'Attachment', + description: 'Attach a file to document.', + icon: FileIcon, + tooltip: slashMenuToolTips['Attachment'], + alias: ['file'], + showWhen: ({ model }) => + model.doc.schema.flavourSchemaMap.has('affine:attachment'), + action: async ({ rootComponent, model }) => { + const file = await openFileOrFiles(); + if (!file) return; + + const attachmentService = + rootComponent.std.getService('affine:attachment'); + if (!attachmentService) return; + const maxFileSize = attachmentService.maxFileSize; + + await addSiblingAttachmentBlocks( + rootComponent.host, + [file], + maxFileSize, + model + ); + tryRemoveEmptyLine(model); + }, + }, + { + name: 'YouTube', + description: 'Embed a YouTube video.', + icon: YoutubeIcon, + tooltip: slashMenuToolTips['YouTube'], + showWhen: ({ model }) => + model.doc.schema.flavourSchemaMap.has('affine:embed-youtube'), + action: async ({ rootComponent, model }) => { + const parentModel = rootComponent.doc.getParent(model); + if (!parentModel) { + return; + } + const index = parentModel.children.indexOf(model) + 1; + await toggleEmbedCardCreateModal( + rootComponent.host, + 'YouTube', + 'The added YouTube video link will be displayed as an embed view.', + { mode: 'page', parentModel, index } + ); + tryRemoveEmptyLine(model); + }, + }, + { + name: 'GitHub', + description: 'Link to a GitHub repository.', + icon: GithubIcon, + tooltip: slashMenuToolTips['Github'], + showWhen: ({ model }) => + model.doc.schema.flavourSchemaMap.has('affine:embed-github'), + action: async ({ rootComponent, model }) => { + const parentModel = rootComponent.doc.getParent(model); + if (!parentModel) { + return; + } + const index = parentModel.children.indexOf(model) + 1; + await toggleEmbedCardCreateModal( + rootComponent.host, + 'GitHub', + 'The added GitHub issue or pull request link will be displayed as a card view.', + { mode: 'page', parentModel, index } + ); + tryRemoveEmptyLine(model); + }, + }, + // TODO: X Twitter + + { + name: 'Figma', + description: 'Embed a Figma document.', + icon: FigmaIcon, + tooltip: slashMenuToolTips['Figma'], + showWhen: ({ model }) => + model.doc.schema.flavourSchemaMap.has('affine:embed-figma'), + action: async ({ rootComponent, model }) => { + const parentModel = rootComponent.doc.getParent(model); + if (!parentModel) { + return; + } + const index = parentModel.children.indexOf(model) + 1; + await toggleEmbedCardCreateModal( + rootComponent.host, + 'Figma', + 'The added Figma link will be displayed as an embed view.', + { mode: 'page', parentModel, index } + ); + tryRemoveEmptyLine(model); + }, + }, + + { + name: 'Loom', + icon: LoomIcon, + showWhen: ({ model }) => + model.doc.schema.flavourSchemaMap.has('affine:embed-loom'), + action: async ({ rootComponent, model }) => { + const parentModel = rootComponent.doc.getParent(model); + if (!parentModel) { + return; + } + const index = parentModel.children.indexOf(model) + 1; + await toggleEmbedCardCreateModal( + rootComponent.host, + 'Loom', + 'The added Loom video link will be displayed as an embed view.', + { mode: 'page', parentModel, index } + ); + tryRemoveEmptyLine(model); + }, + }, + + { + name: 'Equation', + description: 'Create a equation block.', + icon: TeXIcon({ + width: '20', + height: '20', + }), + alias: ['mathBlock, equationBlock', 'latexBlock'], + action: ({ rootComponent }) => { + rootComponent.std.command + .chain() + .getSelectedModels() + .insertLatexBlock({ + place: 'after', + removeEmptyLine: true, + }) + .run(); + }, + }, + + // TODO(@L-Sun): Linear + + // --------------------------------------------------------- + ({ model, rootComponent }) => { + const { doc } = rootComponent; + + const surfaceModel = getSurfaceBlock(doc); + if (!surfaceModel) return []; + + const parent = doc.getParent(model); + if (!parent) return []; + + const frameModels = doc + .getBlocksByFlavour('affine:frame') + .map(block => block.model as FrameBlockModel); + + const frameItems = frameModels.map<SlashMenuActionItem>(frameModel => ({ + name: 'Frame: ' + frameModel.title, + icon: FrameIcon, + action: ({ rootComponent }) => { + rootComponent.std.command + .chain() + .getSelectedModels() + .insertSurfaceRefBlock({ + reference: frameModel.id, + place: 'after', + removeEmptyLine: true, + }) + .run(); + }, + })); + + const groupElements = surfaceModel.getElementsByType('group'); + const groupItems = groupElements.map(group => ({ + name: 'Group: ' + group.title.toString(), + icon: GroupingIcon(), + action: () => { + rootComponent.std.command + .chain() + .getSelectedModels() + .insertSurfaceRefBlock({ + reference: group.id, + place: 'after', + removeEmptyLine: true, + }) + .run(); + }, + })); + + const items = [...frameItems, ...groupItems]; + if (items.length !== 0) { + return [ + { + groupName: 'Document Group & Frame', + }, + ...items, + ]; + } else { + return []; + } + }, + + // --------------------------------------------------------- + { groupName: 'Date' }, + () => { + const now = new Date(); + const tomorrow = new Date(); + const yesterday = new Date(); + + yesterday.setDate(yesterday.getDate() - 1); + tomorrow.setDate(tomorrow.getDate() + 1); + + return [ + { + name: 'Today', + icon: TodayIcon, + tooltip: slashMenuToolTips['Today'], + description: formatDate(now), + action: ({ rootComponent, model }) => { + insertContent(rootComponent.host, model, formatDate(now)); + }, + }, + { + name: 'Tomorrow', + icon: TomorrowIcon, + tooltip: slashMenuToolTips['Tomorrow'], + description: formatDate(tomorrow), + action: ({ rootComponent, model }) => { + const tomorrow = new Date(); + tomorrow.setDate(tomorrow.getDate() + 1); + insertContent(rootComponent.host, model, formatDate(tomorrow)); + }, + }, + { + name: 'Yesterday', + icon: YesterdayIcon, + tooltip: slashMenuToolTips['Yesterday'], + description: formatDate(yesterday), + action: ({ rootComponent, model }) => { + const yesterday = new Date(); + yesterday.setDate(yesterday.getDate() - 1); + insertContent(rootComponent.host, model, formatDate(yesterday)); + }, + }, + { + name: 'Now', + icon: NowIcon, + tooltip: slashMenuToolTips['Now'], + description: formatTime(now), + action: ({ rootComponent, model }) => { + insertContent(rootComponent.host, model, formatTime(now)); + }, + }, + ]; + }, + + // --------------------------------------------------------- + { groupName: 'Database' }, + { + name: 'Table View', + description: 'Display items in a table format.', + alias: ['database'], + icon: DatabaseTableViewIcon20, + tooltip: slashMenuToolTips['Table View'], + showWhen: ({ model }) => + model.doc.schema.flavourSchemaMap.has('affine:database') && + !insideEdgelessText(model), + action: ({ rootComponent }) => { + rootComponent.std.command + .chain() + .getSelectedModels() + .insertDatabaseBlock({ + viewType: viewPresets.tableViewMeta.type, + place: 'after', + removeEmptyLine: true, + }) + .inline(({ insertedDatabaseBlockId }) => { + if (insertedDatabaseBlockId) { + const telemetry = + rootComponent.std.getOptional(TelemetryProvider); + telemetry?.track('AddDatabase', { + blockId: insertedDatabaseBlockId, + }); + } + }) + .run(); + }, + }, + { + name: 'Todo', + alias: ['todo view'], + icon: DatabaseTableViewIcon20, + tooltip: slashMenuToolTips['Todo'], + showWhen: ({ model }) => + model.doc.schema.flavourSchemaMap.has('affine:database') && + !insideEdgelessText(model) && + !!model.doc.awarenessStore.getFlag('enable_block_query'), + + action: ({ model, rootComponent }) => { + const parent = rootComponent.doc.getParent(model); + if (!parent) return; + const index = parent.children.indexOf(model); + const id = rootComponent.doc.addBlock( + 'affine:data-view', + {}, + rootComponent.doc.getParent(model), + index + 1 + ); + const dataViewModel = rootComponent.doc.getBlock(id)!; + + Promise.resolve().then(() => { + const dataView = rootComponent.std.view.getBlock( + dataViewModel.id + ) as DataViewBlockComponent | null; + dataView?.dataSource.viewManager.viewAdd('table'); + }); + tryRemoveEmptyLine(model); + }, + }, + { + name: 'Kanban View', + description: 'Visualize data in a dashboard.', + alias: ['database'], + icon: DatabaseKanbanViewIcon20, + tooltip: slashMenuToolTips['Kanban View'], + showWhen: ({ model }) => + model.doc.schema.flavourSchemaMap.has('affine:database') && + !insideEdgelessText(model), + action: ({ rootComponent }) => { + rootComponent.std.command + .chain() + .getSelectedModels() + .insertDatabaseBlock({ + viewType: viewPresets.kanbanViewMeta.type, + place: 'after', + removeEmptyLine: true, + }) + .inline(({ insertedDatabaseBlockId }) => { + if (insertedDatabaseBlockId) { + const telemetry = + rootComponent.std.getOptional(TelemetryProvider); + telemetry?.track('AddDatabase', { + blockId: insertedDatabaseBlockId, + }); + } + }) + .run(); + }, + }, + + // --------------------------------------------------------- + { groupName: 'Actions' }, + { + name: 'Move Up', + description: 'Shift this line up.', + icon: ArrowUpBigIcon, + tooltip: slashMenuToolTips['Move Up'], + action: ({ rootComponent, model }) => { + const doc = rootComponent.doc; + const previousSiblingModel = doc.getPrev(model); + if (!previousSiblingModel) return; + + const parentModel = doc.getParent(previousSiblingModel); + if (!parentModel) return; + + doc.moveBlocks([model], parentModel, previousSiblingModel, true); + }, + }, + { + name: 'Move Down', + description: 'Shift this line down.', + icon: ArrowDownBigIcon, + tooltip: slashMenuToolTips['Move Down'], + action: ({ rootComponent, model }) => { + const doc = rootComponent.doc; + const nextSiblingModel = doc.getNext(model); + if (!nextSiblingModel) return; + + const parentModel = doc.getParent(nextSiblingModel); + if (!parentModel) return; + + doc.moveBlocks([model], parentModel, nextSiblingModel, false); + }, + }, + { + name: 'Copy', + description: 'Copy this line to clipboard.', + icon: CopyIcon, + tooltip: slashMenuToolTips['Copy'], + action: ({ rootComponent, model }) => { + const slice = Slice.fromModels(rootComponent.std.doc, [model]); + + rootComponent.std.clipboard + .copy(slice) + .then(() => { + toast(rootComponent.host, 'Copied to clipboard'); + }) + .catch(e => { + console.error(e); + }); + }, + }, + { + name: 'Duplicate', + description: 'Create a duplicate of this line.', + icon: DualLinkIcon({ width: '20', height: '20' }), + tooltip: slashMenuToolTips['Copy'], + action: ({ rootComponent, model }) => { + if (!model.text || !(model.text instanceof Text)) { + console.error("Can't duplicate a block without text"); + return; + } + const parent = rootComponent.doc.getParent(model); + if (!parent) { + console.error( + 'Failed to duplicate block! Parent not found: ' + + model.id + + '|' + + model.flavour + ); + return; + } + const index = parent.children.indexOf(model); + + // TODO add clone model util + rootComponent.doc.addBlock( + model.flavour as never, + { + type: (model as ParagraphBlockModel).type, + text: new rootComponent.doc.Text( + model.text.toDelta() as DeltaInsert[] + ), + // @ts-expect-error FIXME: ts error + checked: model.checked, + }, + rootComponent.doc.getParent(model), + index + ); + }, + }, + { + name: 'Delete', + description: 'Remove this line permanently.', + alias: ['remove'], + icon: DeleteIcon, + tooltip: slashMenuToolTips['Delete'], + action: ({ rootComponent, model }) => { + rootComponent.doc.deleteBlock(model); + }, + }, + ], +}; diff --git a/blocksuite/blocks/src/root-block/widgets/slash-menu/index.ts b/blocksuite/blocks/src/root-block/widgets/slash-menu/index.ts new file mode 100644 index 0000000000..79d201c268 --- /dev/null +++ b/blocksuite/blocks/src/root-block/widgets/slash-menu/index.ts @@ -0,0 +1,254 @@ +import { + type AffineInlineEditor, + getInlineEditorByModel, +} from '@blocksuite/affine-components/rich-text'; +import { + getCurrentNativeRange, + matchFlavours, +} from '@blocksuite/affine-shared/utils'; +import type { UIEventStateContext } from '@blocksuite/block-std'; +import { WidgetComponent } from '@blocksuite/block-std'; +import { + assertExists, + assertType, + debounce, + DisposableGroup, + throttle, +} from '@blocksuite/global/utils'; +import { InlineEditor } from '@blocksuite/inline'; + +import type { RootBlockComponent } from '../../types.js'; +import { getPopperPosition } from '../../utils/position.js'; +import { + defaultSlashMenuConfig, + type SlashMenuActionItem, + type SlashMenuContext, + type SlashMenuGroupDivider, + type SlashMenuItem, + type SlashMenuItemGenerator, + type SlashMenuStaticConfig, + type SlashSubMenu, +} from './config.js'; +import { SlashMenu } from './slash-menu-popover.js'; +import { filterEnabledSlashMenuItems } from './utils.js'; + +export type AffineSlashMenuContext = SlashMenuContext; +export type AffineSlashMenuItem = SlashMenuItem; +export type AffineSlashMenuActionItem = SlashMenuActionItem; +export type AffineSlashMenuItemGenerator = SlashMenuItemGenerator; +export type AffineSlashSubMenu = SlashSubMenu; +export type AffineSlashMenuGroupDivider = SlashMenuGroupDivider; + +let globalAbortController = new AbortController(); + +function closeSlashMenu() { + globalAbortController.abort(); +} + +const showSlashMenu = debounce( + ({ + context, + container = document.body, + abortController = new AbortController(), + config, + triggerKey, + }: { + context: SlashMenuContext; + container?: HTMLElement; + abortController?: AbortController; + config: SlashMenuStaticConfig; + triggerKey: string; + }) => { + const curRange = getCurrentNativeRange(); + if (!curRange) return; + + globalAbortController = abortController; + const disposables = new DisposableGroup(); + abortController.signal.addEventListener('abort', () => + disposables.dispose() + ); + + const inlineEditor = getInlineEditorByModel( + context.rootComponent.host, + context.model + ); + if (!inlineEditor) return; + const slashMenu = new SlashMenu(inlineEditor, abortController); + disposables.add(() => slashMenu.remove()); + slashMenu.context = context; + slashMenu.config = config; + slashMenu.triggerKey = triggerKey; + + // Handle position + const updatePosition = throttle(() => { + const slashMenuElement = slashMenu.slashMenuElement; + assertExists( + slashMenuElement, + 'You should render the slash menu node even if no position' + ); + const position = getPopperPosition(slashMenuElement, curRange); + slashMenu.updatePosition(position); + }, 10); + + disposables.addFromEvent(window, 'resize', updatePosition); + + // FIXME(Flrande): It is not a best practice, + // but merely a temporary measure for reusing previous components. + // Mount + container.append(slashMenu); + // Wait for the Node to be mounted + setTimeout(updatePosition); + return slashMenu; + }, + 100 +); + +export const AFFINE_SLASH_MENU_WIDGET = 'affine-slash-menu-widget'; + +export class AffineSlashMenuWidget extends WidgetComponent { + static DEFAULT_CONFIG = defaultSlashMenuConfig; + + private _getInlineEditor = (evt: KeyboardEvent | CompositionEvent) => { + if (evt.target instanceof HTMLElement) { + const editor = ( + evt.target.closest('.inline-editor') as { + inlineEditor?: AffineInlineEditor; + } + )?.inlineEditor; + if (editor instanceof InlineEditor) { + return editor; + } + } + + const textSelection = this.host.selection.find('text'); + if (!textSelection) return; + + const model = this.host.doc.getBlock(textSelection.blockId)?.model; + if (!model) return; + + return getInlineEditorByModel(this.host, model); + }; + + private _handleInput = ( + inlineEditor: InlineEditor, + isCompositionEnd: boolean + ) => { + const inlineRangeApplyCallback = (callback: () => void) => { + // the inline ranged updated in compositionEnd event before this event callback + if (isCompositionEnd) callback(); + else inlineEditor.slots.inlineRangeSync.once(callback); + }; + + const rootComponent = this.block; + if (rootComponent.model.flavour !== 'affine:page') { + console.error('SlashMenuWidget should be used in RootBlock'); + return; + } + assertType<RootBlockComponent>(rootComponent); + + inlineRangeApplyCallback(() => { + const textSelection = this.host.selection.find('text'); + if (!textSelection) return; + + const model = this.host.doc.getBlock(textSelection.blockId)?.model; + if (!model) return; + + if (matchFlavours(model, this.config.ignoreBlockTypes)) return; + + const inlineRange = inlineEditor.getInlineRange(); + if (!inlineRange) return; + + const textPoint = inlineEditor.getTextPoint(inlineRange.index); + if (!textPoint) return; + + const [leafStart, offsetStart] = textPoint; + + const text = leafStart.textContent + ? leafStart.textContent.slice(0, offsetStart) + : ''; + + const matchedKey = this.config.triggerKeys.find(triggerKey => + text.endsWith(triggerKey) + ); + if (!matchedKey) return; + + const config: SlashMenuStaticConfig = { + ...this.config, + items: filterEnabledSlashMenuItems(this.config.items, { + model, + rootComponent, + }), + }; + + closeSlashMenu(); + showSlashMenu({ + context: { + model, + rootComponent, + }, + triggerKey: matchedKey, + config, + }); + }); + }; + + private _onCompositionEnd = (ctx: UIEventStateContext) => { + const event = ctx.get('defaultState').event as CompositionEvent; + + if ( + !this.config.triggerKeys.some(triggerKey => + triggerKey.includes(event.data) + ) + ) + return; + + const inlineEditor = this._getInlineEditor(event); + if (!inlineEditor) return; + + this._handleInput(inlineEditor, true); + }; + + private _onKeyDown = (ctx: UIEventStateContext) => { + const eventState = ctx.get('keyboardState'); + const event = eventState.raw; + + const key = event.key; + + // check event is not composing + if ( + key === undefined || // in mac os, the key may be undefined + key === 'Process' || + event.isComposing + ) + return; + + if (!this.config.triggerKeys.some(triggerKey => triggerKey.includes(key))) + return; + + const inlineEditor = this._getInlineEditor(event); + if (!inlineEditor) return; + + this._handleInput(inlineEditor, false); + }; + + config = AffineSlashMenuWidget.DEFAULT_CONFIG; + + override connectedCallback() { + super.connectedCallback(); + + if (this.config.triggerKeys.some(key => key.length === 0)) { + console.error('Trigger key of slash menu should not be empty string'); + return; + } + + // this.handleEvent('beforeInput', this._onBeforeInput); + this.handleEvent('keyDown', this._onKeyDown); + this.handleEvent('compositionEnd', this._onCompositionEnd); + } +} + +declare global { + interface HTMLElementTagNameMap { + [AFFINE_SLASH_MENU_WIDGET]: AffineSlashMenuWidget; + } +} diff --git a/blocksuite/blocks/src/root-block/widgets/slash-menu/slash-menu-popover.ts b/blocksuite/blocks/src/root-block/widgets/slash-menu/slash-menu-popover.ts new file mode 100644 index 0000000000..a57f61be38 --- /dev/null +++ b/blocksuite/blocks/src/root-block/widgets/slash-menu/slash-menu-popover.ts @@ -0,0 +1,603 @@ +import { ArrowDownIcon } from '@blocksuite/affine-components/icons'; +import { createLitPortal } from '@blocksuite/affine-components/portal'; +import type { AffineInlineEditor } from '@blocksuite/affine-components/rich-text'; +import { getInlineEditorByModel } from '@blocksuite/affine-components/rich-text'; +import { + isControlledKeyboardEvent, + isFuzzyMatch, + substringMatchScore, +} from '@blocksuite/affine-shared/utils'; +import { assertExists, WithDisposable } from '@blocksuite/global/utils'; +import { autoPlacement, offset } from '@floating-ui/dom'; +import { html, LitElement, nothing, type PropertyValues } from 'lit'; +import { property, query, state } from 'lit/decorators.js'; +import { ifDefined } from 'lit/directives/if-defined.js'; +import { styleMap } from 'lit/directives/style-map.js'; + +import { + cleanSpecifiedTail, + createKeydownObserver, + getQuery, +} from '../../../_common/components/utils.js'; +import type { + SlashMenuActionItem, + SlashMenuContext, + SlashMenuGroupDivider, + SlashMenuItem, + SlashMenuStaticConfig, + SlashMenuStaticItem, + SlashSubMenu, +} from './config.js'; +import { slashItemToolTipStyle, styles } from './styles.js'; +import { + getFirstNotDividerItem, + isActionItem, + isGroupDivider, + isSubMenuItem, + notGroupDivider, + slashItemClassName, +} from './utils.js'; + +type InnerSlashMenuContext = SlashMenuContext & { + tooltipTimeout: number; + onClickItem: (item: SlashMenuActionItem) => void; +}; + +export class SlashMenu extends WithDisposable(LitElement) { + static override styles = styles; + + private _handleClickItem = (item: SlashMenuActionItem) => { + // Need to remove the search string + // We must to do clean the slash string before we do the action + // Otherwise, the action may change the model and cause the slash string to be changed + cleanSpecifiedTail( + this.host, + this.context.model, + this.triggerKey + (this._query || '') + ); + this.inlineEditor + .waitForUpdate() + .then(() => { + item.action(this.context)?.catch(console.error); + this.abortController.abort(); + }) + .catch(console.error); + }; + + private _initItemPathMap = () => { + const traverse = (item: SlashMenuStaticItem, path: number[]) => { + this._itemPathMap.set(item, [...path]); + if (isSubMenuItem(item)) { + item.subMenu.forEach((subItem, index) => + traverse(subItem, [...path, index]) + ); + } + }; + + this.config.items.forEach((item, index) => traverse(item, [index])); + }; + + private _innerSlashMenuContext!: InnerSlashMenuContext; + + private _itemPathMap = new Map<SlashMenuItem, number[]>(); + + private _queryState: 'off' | 'on' | 'no_result' = 'off'; + + private _startRange = this.inlineEditor.getInlineRange(); + + private _updateFilteredItems = () => { + const query = this._query; + if (query === null) { + this.abortController.abort(); + return; + } + this._filteredItems = []; + const searchStr = query.toLowerCase(); + if (searchStr === '' || searchStr.endsWith(' ')) { + this._queryState = searchStr === '' ? 'off' : 'no_result'; + return; + } + + // Layer order traversal + let depth = 0; + let queue = this.config.items.filter(notGroupDivider); + while (queue.length !== 0) { + // remove the sub menu item from the previous layer result + this._filteredItems = this._filteredItems.filter( + item => !isSubMenuItem(item) + ); + + this._filteredItems = this._filteredItems.concat( + queue.filter(({ name, alias = [] }) => + [name, ...alias].some(str => isFuzzyMatch(str, searchStr)) + ) + ); + + // We search first and second layer + if (this._filteredItems.length !== 0 && depth >= 1) break; + + queue = queue + .map<typeof queue>(item => { + if (isSubMenuItem(item)) { + return item.subMenu.filter(notGroupDivider); + } else { + return []; + } + }) + .flat(); + + depth++; + } + + this._filteredItems = this._filteredItems.sort((a, b) => { + return -( + substringMatchScore(a.name, searchStr) - + substringMatchScore(b.name, searchStr) + ); + }); + + this._queryState = this._filteredItems.length === 0 ? 'no_result' : 'on'; + }; + + updatePosition = (position: { x: string; y: string; height: number }) => { + this._position = position; + }; + + private get _query() { + return getQuery(this.inlineEditor, this._startRange); + } + + get host() { + return this.context.rootComponent.host; + } + + constructor( + private inlineEditor: AffineInlineEditor, + private abortController = new AbortController() + ) { + super(); + } + + override connectedCallback() { + super.connectedCallback(); + + this._innerSlashMenuContext = { + ...this.context, + onClickItem: this._handleClickItem, + tooltipTimeout: this.config.tooltipTimeout, + }; + + this._initItemPathMap(); + + this._disposables.addFromEvent(this, 'mousedown', e => { + // Prevent input from losing focus + e.preventDefault(); + }); + + const inlineEditor = this.inlineEditor; + if (!inlineEditor || !inlineEditor.eventSource) { + console.error('inlineEditor or eventSource is not found'); + return; + } + + /** + * Handle arrow key + * + * The slash menu will be closed in the following keyboard cases: + * - Press the space key + * - Press the backspace key and the search string is empty + * - Press the escape key + * - When the search item is empty, the slash menu will be hidden temporarily, + * and if the following key is not the backspace key, the slash menu will be closed + */ + createKeydownObserver({ + target: inlineEditor.eventSource, + signal: this.abortController.signal, + interceptor: (event, next) => { + const { key, isComposing, code } = event; + if (key === this.triggerKey) { + // Can not stopPropagation here, + // otherwise the rich text will not be able to trigger a new the slash menu + return; + } + + if (key === 'Process' && !isComposing && code === 'Slash') { + // The IME case of above + return; + } + + if (key !== 'Backspace' && this._queryState === 'no_result') { + // if the following key is not the backspace key, + // the slash menu will be closed + this.abortController.abort(); + return; + } + + if (key === 'ArrowRight' || key === 'ArrowLeft' || key === 'Escape') { + return; + } + + next(); + }, + onInput: isComposition => { + if (isComposition) { + this._updateFilteredItems(); + } else { + this.inlineEditor.slots.renderComplete.once( + this._updateFilteredItems + ); + } + }, + onPaste: () => { + setTimeout(() => { + this._updateFilteredItems(); + }, 50); + }, + onDelete: () => { + const curRange = this.inlineEditor.getInlineRange(); + if (!this._startRange || !curRange) { + return; + } + if (curRange.index < this._startRange.index) { + this.abortController.abort(); + } + this.inlineEditor.slots.renderComplete.once(this._updateFilteredItems); + }, + onAbort: () => this.abortController.abort(), + }); + } + + override render() { + const slashMenuStyles = this._position + ? { + transform: `translate(${this._position.x}, ${this._position.y})`, + maxHeight: `${Math.min(this._position.height, this.config.maxHeight)}px`, + } + : { + visibility: 'hidden', + }; + + return html`${this._queryState !== 'no_result' + ? html` <div + class="overlay-mask" + @click="${() => this.abortController.abort()}" + ></div>` + : nothing} + <inner-slash-menu + .context=${this._innerSlashMenuContext} + .menu=${this._queryState === 'off' + ? this.config.items + : this._filteredItems} + .onClickItem=${this._handleClickItem} + .mainMenuStyle=${slashMenuStyles} + .abortController=${this.abortController} + > + </inner-slash-menu>`; + } + + @state() + private accessor _filteredItems: (SlashMenuActionItem | SlashSubMenu)[] = []; + + @state() + private accessor _position: { + x: string; + y: string; + height: number; + } | null = null; + + @property({ attribute: false }) + accessor config!: SlashMenuStaticConfig; + + @property({ attribute: false }) + accessor context!: SlashMenuContext; + + @query('inner-slash-menu') + accessor slashMenuElement!: HTMLElement; + + @property({ attribute: false }) + accessor triggerKey!: string; +} + +export class InnerSlashMenu extends WithDisposable(LitElement) { + static override styles = styles; + + private _closeSubMenu = () => { + this._subMenuAbortController?.abort(); + this._subMenuAbortController = null; + this._currentSubMenu = null; + }; + + private _currentSubMenu: SlashSubMenu | null = null; + + private _openSubMenu = (item: SlashSubMenu) => { + if (item === this._currentSubMenu) return; + + const itemElement = this.shadowRoot?.querySelector( + `.${slashItemClassName(item)}` + ); + if (!itemElement) return; + + this._closeSubMenu(); + this._currentSubMenu = item; + this._subMenuAbortController = new AbortController(); + this._subMenuAbortController.signal.addEventListener('abort', () => { + this._closeSubMenu(); + }); + + const subMenuElement = createLitPortal({ + shadowDom: false, + template: html`<inner-slash-menu + .context=${this.context} + .menu=${item.subMenu} + .depth=${this.depth + 1} + .abortController=${this._subMenuAbortController} + > + ${item.subMenu.map(this._renderItem)} + </inner-slash-menu>`, + computePosition: { + referenceElement: itemElement, + autoUpdate: true, + middleware: [ + offset(12), + autoPlacement({ + allowedPlacements: ['right-start', 'right-end'], + }), + ], + }, + abortController: this._subMenuAbortController, + }); + + subMenuElement.style.zIndex = `calc(var(--affine-z-index-popover) + ${this.depth})`; + subMenuElement.focus(); + }; + + private _renderActionItem = (item: SlashMenuActionItem) => { + const { name, icon, description, tooltip, customTemplate } = item; + + const hover = item === this._activeItem; + + return html`<icon-button + class="slash-menu-item ${slashItemClassName(item)}" + width="100%" + height="44px" + text=${customTemplate ?? name} + subText=${ifDefined(description)} + data-testid="${name}" + hover=${hover} + @mousemove=${() => { + this._activeItem = item; + this._closeSubMenu(); + }} + @click=${() => this.context.onClickItem(item)} + > + ${icon && html`<div class="slash-menu-item-icon">${icon}</div>`} + ${tooltip && + html`<affine-tooltip + tip-position="right" + .offset=${22} + .tooltipStyle=${slashItemToolTipStyle} + .hoverOptions=${{ + enterDelay: this.context.tooltipTimeout, + allowMultiple: false, + }} + > + <div class="tooltip-figure">${tooltip.figure}</div> + <div class="tooltip-caption">${tooltip.caption}</div> + </affine-tooltip>`} + </icon-button>`; + }; + + private _renderGroupItem = (item: SlashMenuGroupDivider) => { + return html`<div class="slash-menu-group-name">${item.groupName}</div>`; + }; + + private _renderItem = (item: SlashMenuStaticItem) => { + if (isGroupDivider(item)) return this._renderGroupItem(item); + else if (isActionItem(item)) return this._renderActionItem(item); + else if (isSubMenuItem(item)) return this._renderSubMenuItem(item); + else { + console.error('Unknown item type for slash menu'); + console.error(item); + return nothing; + } + }; + + private _renderSubMenuItem = (item: SlashSubMenu) => { + const { name, icon, description } = item; + + const hover = item === this._activeItem; + + return html`<icon-button + class="slash-menu-item ${slashItemClassName(item)}" + width="100%" + height="44px" + text=${name} + subText=${ifDefined(description)} + data-testid="${name}" + hover=${hover} + @mousemove=${() => { + this._activeItem = item; + this._openSubMenu(item); + }} + @touchstart=${() => { + isSubMenuItem(item) && + (this._currentSubMenu === item + ? this._closeSubMenu() + : this._openSubMenu(item)); + }} + > + ${icon && html`<div class="slash-menu-item-icon">${icon}</div>`} + <div slot="suffix" style="transform: rotate(-90deg);"> + ${ArrowDownIcon} + </div> + </icon-button>`; + }; + + private _subMenuAbortController: AbortController | null = null; + + private _scrollToItem(item: SlashMenuStaticItem) { + const shadowRoot = this.shadowRoot; + if (!shadowRoot) { + return; + } + + const text = isGroupDivider(item) ? item.groupName : item.name; + + const ele = shadowRoot.querySelector(`icon-button[text="${text}"]`); + if (!ele) { + return; + } + ele.scrollIntoView({ + block: 'nearest', + }); + } + + override connectedCallback() { + super.connectedCallback(); + + // close all sub menus + this.abortController?.signal?.addEventListener('abort', () => { + this._subMenuAbortController?.abort(); + }); + this.addEventListener('wheel', event => { + if (this._currentSubMenu) { + event.preventDefault(); + } + }); + + const inlineEditor = getInlineEditorByModel( + this.context.rootComponent.host, + this.context.model + ); + + if (!inlineEditor || !inlineEditor.eventSource) { + console.error('inlineEditor or eventSource is not found'); + return; + } + + inlineEditor.eventSource.addEventListener( + 'keydown', + event => { + if (this._currentSubMenu) return; + if (event.isComposing) return; + + const { key, ctrlKey, metaKey, altKey, shiftKey } = event; + + const onlyCmd = (ctrlKey || metaKey) && !altKey && !shiftKey; + const onlyShift = shiftKey && !isControlledKeyboardEvent(event); + const notControlShift = !(ctrlKey || metaKey || altKey || shiftKey); + + let moveStep = 0; + if ( + (key === 'ArrowUp' && notControlShift) || + (key === 'Tab' && onlyShift) || + (key === 'P' && onlyCmd) || + (key === 'p' && onlyCmd) + ) { + moveStep = -1; + } + + if ( + (key === 'ArrowDown' && notControlShift) || + (key === 'Tab' && notControlShift) || + (key === 'n' && onlyCmd) || + (key === 'N' && onlyCmd) + ) { + moveStep = 1; + } + + if (moveStep !== 0) { + let itemIndex = this.menu.indexOf(this._activeItem); + do { + itemIndex = + (itemIndex + moveStep + this.menu.length) % this.menu.length; + } while (isGroupDivider(this.menu[itemIndex])); + + this._activeItem = this.menu[itemIndex] as typeof this._activeItem; + this._scrollToItem(this._activeItem); + + event.preventDefault(); + event.stopPropagation(); + } + + if (key === 'ArrowRight' && notControlShift) { + if (isSubMenuItem(this._activeItem)) { + this._openSubMenu(this._activeItem); + } + + event.preventDefault(); + event.stopPropagation(); + } + + if ((key === 'ArrowLeft' || key === 'Escape') && notControlShift) { + this.abortController.abort(); + + event.preventDefault(); + event.stopPropagation(); + } + + if (key === 'Enter' && notControlShift) { + if (isSubMenuItem(this._activeItem)) { + this._openSubMenu(this._activeItem); + } else if (isActionItem(this._activeItem)) { + this.context.onClickItem(this._activeItem); + } + + event.preventDefault(); + event.stopPropagation(); + } + }, + { + capture: true, + signal: this.abortController.signal, + } + ); + } + + override disconnectedCallback() { + this.abortController.abort(); + } + + override render() { + if (this.menu.length === 0) return nothing; + + const style = styleMap(this.mainMenuStyle ?? { position: 'relative' }); + + return html`<div + class="slash-menu" + style=${style} + data-testid=${`sub-menu-${this.depth}`} + > + ${this.menu.map(this._renderItem)} + </div>`; + } + + override willUpdate(changedProperties: PropertyValues<this>) { + if (changedProperties.has('menu') && this.menu.length !== 0) { + const firstItem = getFirstNotDividerItem(this.menu); + assertExists(firstItem); + this._activeItem = firstItem; + + // this case happen on query updated + this._subMenuAbortController?.abort(); + } + } + + @state() + private accessor _activeItem!: SlashMenuActionItem | SlashSubMenu; + + @property({ attribute: false }) + accessor abortController!: AbortController; + + @property({ attribute: false }) + accessor context!: InnerSlashMenuContext; + + @property({ attribute: false }) + accessor depth: number = 0; + + @property({ attribute: false }) + accessor mainMenuStyle: Parameters<typeof styleMap>[0] | null = null; + + @property({ attribute: false }) + accessor menu!: SlashMenuStaticItem[]; +} diff --git a/blocksuite/blocks/src/root-block/widgets/slash-menu/styles.ts b/blocksuite/blocks/src/root-block/widgets/slash-menu/styles.ts new file mode 100644 index 0000000000..5ae8fe8983 --- /dev/null +++ b/blocksuite/blocks/src/root-block/widgets/slash-menu/styles.ts @@ -0,0 +1,107 @@ +import { baseTheme } from '@toeverything/theme'; +import { css, unsafeCSS } from 'lit'; + +import { scrollbarStyle } from '../../../_common/components/utils.js'; + +export const styles = css` + .overlay-mask { + pointer-events: auto; + position: fixed; + top: 0; + left: 0; + width: 100vw; + height: 100vh; + z-index: var(--affine-z-index-popover); + } + + .slash-menu { + position: fixed; + left: 0; + top: 0; + box-sizing: border-box; + padding: 8px 4px 8px 8px; + width: 258px; + overflow-y: auto; + font-family: ${unsafeCSS(baseTheme.fontSansFamily)}; + + background: var(--affine-background-overlay-panel-color); + box-shadow: var(--affine-shadow-2); + border-radius: 8px; + z-index: var(--affine-z-index-popover); + user-select: none; + /* transition: max-height 0.2s ease-in-out; */ + } + + ${scrollbarStyle('.slash-menu')} + + .slash-menu-group-name { + box-sizing: border-box; + padding: 2px 8px; + + font-size: var(--affine-font-xs); + font-weight: 500; + line-height: var(--affine-line-height); + text-align: left; + color: var( + --light-textColor-textSecondaryColor, + var(--textColor-textSecondaryColor, #8e8d91) + ); + } + + .slash-menu-item { + padding: 2px 8px 2px 8px; + justify-content: flex-start; + gap: 10px; + } + + .slash-menu-item-icon { + box-sizing: border-box; + width: 28px; + height: 28px; + padding: 4px; + border: 1px solid var(--affine-border-color, #e3e2e4); + border-radius: 4px; + color: var(--affine-icon-color); + background: var(--affine-background-overlay-panel-color); + + display: flex; + justify-content: center; + align-items: center; + } + + .slash-menu-item-icon svg { + display: block; + } + + .slash-menu-item.ask-ai { + color: var(--affine-brand-color); + } + .slash-menu-item.github .github-icon { + color: var(--affine-black); + } +`; + +export const slashItemToolTipStyle = css` + .affine-tooltip { + display: flex; + padding: 4px 4px 2px 4px; + flex-direction: column; + align-items: flex-start; + gap: 3px; + } + + .tooltip-figure svg { + display: block; + } + + .tooltip-caption { + padding-left: 4px; + color: var( + --light-textColor-textSecondaryColor, + var(--textColor-textSecondaryColor, #8e8d91) + ); + font-family: var(--affine-font-family); + font-size: var(--affine-font-xs); + line-height: var(--affine-line-height); + } +`; diff --git a/blocksuite/blocks/src/root-block/widgets/slash-menu/tooltips/attachment.ts b/blocksuite/blocks/src/root-block/widgets/slash-menu/tooltips/attachment.ts new file mode 100644 index 0000000000..19d3841bcf --- /dev/null +++ b/blocksuite/blocks/src/root-block/widgets/slash-menu/tooltips/attachment.ts @@ -0,0 +1,41 @@ +import { html } from 'lit'; +// prettier-ignore +export const AttachmentTooltip = html`<svg width="170" height="68" viewBox="0 0 170 68" fill="none" xmlns="http://www.w3.org/2000/svg"> +<rect width="170" height="68" rx="2" fill="white"/> +<mask id="mask0_16460_1013" style="mask-type:alpha" maskUnits="userSpaceOnUse" x="0" y="0" width="170" height="68"> +<rect width="170" height="68" rx="2" fill="white"/> +</mask> +<g mask="url(#mask0_16460_1013)"> +<rect x="8.5" y="28.5" width="169" height="67" rx="3.5" fill="white" stroke="#E3E2E4"/> +<mask id="mask1_16460_1013" style="mask-type:alpha" maskUnits="userSpaceOnUse" x="8" y="28" width="170" height="68"> +<rect x="8" y="28" width="170" height="68" rx="4" fill="white"/> +</mask> +<g mask="url(#mask1_16460_1013)"> +<g filter="url(#filter0_d_16460_1013)"> +<path fill-rule="evenodd" clip-rule="evenodd" d="M149.686 42.516C149.796 41.7313 149.251 41.0057 148.47 40.8954L130.084 38.2995C128.913 38.1341 127.829 38.9542 127.665 40.1313L122.298 78.494C122.133 79.671 122.95 80.7593 124.121 80.9248L157.357 85.6174C158.529 85.7828 159.612 84.9627 159.777 83.7856L163.057 60.3418C163.166 59.5571 162.622 58.8315 161.841 58.7212L151.941 57.3234C149.598 56.9926 147.965 54.816 148.294 52.4619L149.686 42.516ZM133.567 59.8003C133.649 59.2118 134.191 58.8017 134.776 58.8844L152.455 61.3805C153.041 61.4632 153.449 62.0074 153.367 62.5959C153.284 63.1844 152.743 63.5945 152.157 63.5118L134.478 61.0157C133.892 60.933 133.484 60.3888 133.567 59.8003ZM133.932 64.923C133.346 64.8403 132.804 65.2504 132.722 65.8389C132.64 66.4274 133.048 66.9716 133.634 67.0543L151.312 69.5504C151.898 69.6331 152.44 69.223 152.522 68.6345C152.604 68.046 152.196 67.5018 151.61 67.4191L133.932 64.923Z" fill="white"/> +<path d="M153.295 43.1135C152.819 42.4792 151.818 42.7394 151.708 43.5259L150.416 52.7614C150.251 53.9385 151.067 55.0268 152.239 55.1922L161.432 56.4901C162.215 56.6007 162.74 55.7051 162.264 55.0708L153.295 43.1135Z" fill="white"/> +</g> +<g clip-path="url(#clip0_16460_1013)"> +<path fill-rule="evenodd" clip-rule="evenodd" d="M20.1088 36.804L17.0887 39.8241C16.1371 40.7509 16.1371 42.2492 17.0886 43.1759C18.0457 44.108 19.6014 44.108 20.5584 43.1759L23.5034 40.3144C23.6519 40.1701 23.8893 40.1735 24.0337 40.322C24.178 40.4706 24.1746 40.708 24.026 40.8523L21.0817 43.7132C21.0817 43.7133 21.0818 43.7131 21.0817 43.7132C19.8334 44.9288 17.8136 44.9289 16.5653 43.7132C15.3122 42.4926 15.3116 40.5099 16.5635 39.2886L19.5838 36.2683C20.4645 35.4105 21.8884 35.4106 22.7691 36.2683C23.6548 37.1309 23.6554 38.5332 22.771 39.3965L19.7507 42.4169C19.2376 42.9167 18.4095 42.9166 17.8964 42.4168C17.3777 41.9116 17.3777 41.0884 17.8964 40.5832L20.9956 37.5647C21.1439 37.4202 21.3813 37.4233 21.5259 37.5717C21.6704 37.7201 21.6672 37.9575 21.5189 38.102L18.4197 41.1205C18.2033 41.3312 18.2033 41.6688 18.4197 41.8796C18.6411 42.0952 19.0038 42.0957 19.2259 41.881L22.2458 38.861C22.8298 38.2923 22.8298 37.3744 22.2459 36.8056C21.6569 36.232 20.6984 36.2315 20.1088 36.804Z" fill="#77757D"/> +</g> +<text fill="#121212" xml:space="preserve" style="white-space: pre" font-family="Inter" font-size="10" font-weight="600" letter-spacing="0em"><tspan x="30" y="43.6364">Rickroll.mp3</tspan></text> +</g> +<text fill="#8E8D91" xml:space="preserve" style="white-space: pre" font-family="Inter" font-size="10" letter-spacing="0px"><tspan x="10" y="18.6364">Attach a file.</tspan></text> +</g> +<defs> +<filter id="filter0_d_16460_1013" x="118.277" y="34.2783" width="48.7936" height="55.3602" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB"> +<feFlood flood-opacity="0" result="BackgroundImageFix"/> +<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/> +<feOffset/> +<feGaussianBlur stdDeviation="2"/> +<feComposite in2="hardAlpha" operator="out"/> +<feColorMatrix type="matrix" values="0 0 0 0 0.258824 0 0 0 0 0.254902 0 0 0 0 0.286275 0 0 0 0.18 0"/> +<feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow_16460_1013"/> +<feBlend mode="normal" in="SourceGraphic" in2="effect1_dropShadow_16460_1013" result="shape"/> +</filter> +<clipPath id="clip0_16460_1013"> +<rect width="12" height="12" fill="white" transform="translate(14 34)"/> +</clipPath> +</defs> +</svg> +`; diff --git a/blocksuite/blocks/src/root-block/widgets/slash-menu/tooltips/bold-text.ts b/blocksuite/blocks/src/root-block/widgets/slash-menu/tooltips/bold-text.ts new file mode 100644 index 0000000000..0f4a430613 --- /dev/null +++ b/blocksuite/blocks/src/root-block/widgets/slash-menu/tooltips/bold-text.ts @@ -0,0 +1,13 @@ +import { html } from 'lit'; +// prettier-ignore +export const BoldTextTooltip = html`<svg width="170" height="68" viewBox="0 0 170 68" fill="none" xmlns="http://www.w3.org/2000/svg"> +<rect width="170" height="68" rx="2" fill="white"/> +<mask id="mask0_16460_971" style="mask-type:alpha" maskUnits="userSpaceOnUse" x="0" y="0" width="170" height="68"> +<rect width="170" height="68" rx="2" fill="white"/> +</mask> +<g mask="url(#mask0_16460_971)"> +<text fill="#121212" xml:space="preserve" style="white-space: pre" font-family="Inter" font-size="10" letter-spacing="0px"><tspan x="8" y="43.6364">Any user may have a different perspective on what data they </tspan><tspan x="8" y="55.6364">either have, choose to share, or accept.&#10;</tspan><tspan x="8" y="71.6364">For example, one user&#x2019;s edits to a document might be on </tspan><tspan x="8" y="83.6364">their laptop on an airplane; when the plane lands and the </tspan><tspan x="8" y="95.6364">computer reconnects, those changes are distributed to </tspan><tspan x="8" y="107.636">other users.&#10;</tspan><tspan x="8" y="123.636">Other users might choose to accept all, some, or none of </tspan><tspan x="8" y="135.636">those changes to their version of the document.</tspan></text> +<text fill="#121212" xml:space="preserve" style="white-space: pre" font-family="Inter" font-size="10" font-weight="bold" letter-spacing="0px"><tspan x="8" y="15.6364">In a decentralized system, we can have a kaleidoscopic </tspan><tspan x="8" y="27.6364">complexity to our data.&#10;</tspan></text> +</g> +</svg> +`; diff --git a/blocksuite/blocks/src/root-block/widgets/slash-menu/tooltips/bulleted-list.ts b/blocksuite/blocks/src/root-block/widgets/slash-menu/tooltips/bulleted-list.ts new file mode 100644 index 0000000000..f1e7921c56 --- /dev/null +++ b/blocksuite/blocks/src/root-block/widgets/slash-menu/tooltips/bulleted-list.ts @@ -0,0 +1,15 @@ +import { html } from 'lit'; +// prettier-ignore +export const BulletedListTooltip = html`<svg width="170" height="68" viewBox="0 0 170 68" fill="none" xmlns="http://www.w3.org/2000/svg"> +<rect width="170" height="68" rx="2" fill="white"/> +<mask id="mask0_16460_934" style="mask-type:alpha" maskUnits="userSpaceOnUse" x="0" y="0" width="170" height="68"> +<rect width="170" height="68" rx="2" fill="white"/> +</mask> +<g mask="url(#mask0_16460_934)"> +<circle cx="14" cy="26" r="1.5" fill="#1C81D9"/> +<text fill="#121212" xml:space="preserve" style="white-space: pre" font-family="Inter" font-size="10" letter-spacing="0px"><tspan x="22" y="29.6364">Here&#39;s an example of a bulleted list.</tspan></text> +<circle cx="14" cy="42" r="1.5" fill="#1C81D9"/> +<text fill="#121212" xml:space="preserve" style="white-space: pre" font-family="Inter" font-size="10" letter-spacing="0px"><tspan x="22" y="45.6364">You can list your plans such as this</tspan></text> +</g> +</svg> +`; diff --git a/blocksuite/blocks/src/root-block/widgets/slash-menu/tooltips/code-block.ts b/blocksuite/blocks/src/root-block/widgets/slash-menu/tooltips/code-block.ts new file mode 100644 index 0000000000..9ebff92cbd --- /dev/null +++ b/blocksuite/blocks/src/root-block/widgets/slash-menu/tooltips/code-block.ts @@ -0,0 +1,17 @@ +import { html } from 'lit'; +// prettier-ignore +export const CodeBlockTooltip = html`<svg width="170" height="68" viewBox="0 0 170 68" fill="none" xmlns="http://www.w3.org/2000/svg"> +<rect width="170" height="68" rx="2" fill="white"/> +<mask id="mask0_16460_915" style="mask-type:alpha" maskUnits="userSpaceOnUse" x="0" y="0" width="170" height="68"> +<rect width="170" height="68" rx="2" fill="white"/> +</mask> +<g mask="url(#mask0_16460_915)"> +<text fill="#121212" xml:space="preserve" style="white-space: pre" font-family="var(--affine-font-code-family)" font-size="11" letter-spacing="0em"><tspan x="47.5742" y="17.46"> </tspan><tspan x="126.723" y="17.46">: </tspan><tspan x="166.297" y="17.46"> {&#10;</tspan><tspan x="8" y="32.46"> </tspan><tspan x="100.34" y="32.46"> helloTo </tspan><tspan x="166.297" y="32.46"> &#34;World&#34;&#10;</tspan><tspan x="8" y="47.46"> </tspan><tspan x="54.1699" y="47.46"> body: </tspan><tspan x="126.723" y="47.46"> </tspan><tspan x="159.701" y="47.46"> {&#10;</tspan><tspan x="8" y="62.46"> </tspan><tspan x="87.1484" y="62.46">(</tspan><tspan x="219.062" y="62.46">)&#10;</tspan><tspan x="8" y="77.46">}&#10;</tspan><tspan x="8" y="92.46">}</tspan></text> +<text fill="#0782A0" xml:space="preserve" style="white-space: pre" font-family="var(--affine-font-code-family)" font-size="11" letter-spacing="0em"><tspan x="8" y="17.46">struct</tspan><tspan x="73.957" y="32.46"> var</tspan><tspan x="159.701" y="32.46">=</tspan><tspan x="34.3828" y="47.46">var</tspan><tspan x="100.34" y="47.46">some</tspan><tspan x="139.914" y="62.46">\(</tspan></text> +<text fill="#842ED3" xml:space="preserve" style="white-space: pre" font-family="var(--affine-font-code-family)" font-size="11" letter-spacing="0em"><tspan x="54.1699" y="17.46">ContentView</tspan></text> +<text fill="#C62222" xml:space="preserve" style="white-space: pre" font-family="var(--affine-font-code-family)" font-size="11" letter-spacing="0em"><tspan x="139.914" y="17.46">View</tspan><tspan x="34.3828" y="32.46">@State</tspan><tspan x="133.318" y="47.46">View</tspan></text> +<text fill="#2159D3" xml:space="preserve" style="white-space: pre" font-family="var(--affine-font-code-family)" font-size="11" letter-spacing="0em"><tspan x="60.7656" y="62.46">Text</tspan></text> +<text fill="#D34F0B" xml:space="preserve" style="white-space: pre" font-family="var(--affine-font-code-family)" font-size="11" letter-spacing="0em"><tspan x="93.7441" y="62.46">&#34;Hello </tspan><tspan x="153.105" y="62.46">helloTo)!&#34;</tspan></text> +</g> +</svg> +`; diff --git a/blocksuite/blocks/src/root-block/widgets/slash-menu/tooltips/copy.ts b/blocksuite/blocks/src/root-block/widgets/slash-menu/tooltips/copy.ts new file mode 100644 index 0000000000..bbe6d09fb7 --- /dev/null +++ b/blocksuite/blocks/src/root-block/widgets/slash-menu/tooltips/copy.ts @@ -0,0 +1,13 @@ +import { html } from 'lit'; +// prettier-ignore +export const CopyTooltip = html`<svg width="170" height="68" viewBox="0 0 170 68" fill="none" xmlns="http://www.w3.org/2000/svg"> +<rect width="170" height="68" rx="2" fill="white"/> +<mask id="mask0_16460_1240" style="mask-type:alpha" maskUnits="userSpaceOnUse" x="0" y="0" width="170" height="68"> +<rect width="170" height="68" rx="2" fill="white"/> +</mask> +<g mask="url(#mask0_16460_1240)"> +<path d="M4 7C4 5.89543 4.89543 5 6 5H172V32H6C4.89543 32 4 31.1046 4 30V7Z" fill="#F4F4F5"/> +<text fill="#121212" xml:space="preserve" style="white-space: pre" font-family="Inter" font-size="10" letter-spacing="0px"><tspan x="8" y="15.6364">In a decentralized system, we can have a kaleidoscopic </tspan><tspan x="8" y="27.6364">complexity to our data.&#10;</tspan><tspan x="8" y="43.6364">Any user may have a different perspective on what data they </tspan><tspan x="8" y="55.6364">either have, choose to share, or accept.&#10;</tspan><tspan x="8" y="71.6364">For example, one user&#x2019;s edits to a document might be on </tspan><tspan x="8" y="83.6364">their laptop on an airplane; when the plane lands and the </tspan><tspan x="8" y="95.6364">computer reconnects, those changes are distributed to </tspan><tspan x="8" y="107.636">other users.&#10;</tspan><tspan x="8" y="123.636">Other users might choose to accept all, some, or none of </tspan><tspan x="8" y="135.636">those changes to their version of the document.</tspan></text> +</g> +</svg> +`; diff --git a/blocksuite/blocks/src/root-block/widgets/slash-menu/tooltips/delete.ts b/blocksuite/blocks/src/root-block/widgets/slash-menu/tooltips/delete.ts new file mode 100644 index 0000000000..32569722b2 --- /dev/null +++ b/blocksuite/blocks/src/root-block/widgets/slash-menu/tooltips/delete.ts @@ -0,0 +1,14 @@ +import { html } from 'lit'; +// prettier-ignore +export const DeleteTooltip = html`<svg width="170" height="68" viewBox="0 0 170 68" fill="none" xmlns="http://www.w3.org/2000/svg"> +<rect width="170" height="68" rx="2" fill="white"/> +<mask id="mask0_16460_1246" style="mask-type:alpha" maskUnits="userSpaceOnUse" x="0" y="0" width="170" height="68"> +<rect width="170" height="68" rx="2" fill="white"/> +</mask> +<g mask="url(#mask0_16460_1246)"> +<path d="M4 7C4 5.89543 4.89543 5 6 5H172V32H6C4.89543 32 4 31.1046 4 30V7Z" fill="#FDECEB"/> +<text fill="#121212" xml:space="preserve" style="white-space: pre" font-family="Inter" font-size="10" letter-spacing="0px"><tspan x="8" y="43.6364">Any user may have a different perspective on what data they </tspan><tspan x="8" y="55.6364">either have, choose to share, or accept.&#10;</tspan><tspan x="8" y="71.6364">For example, one user&#x2019;s edits to a document might be on </tspan><tspan x="8" y="83.6364">their laptop on an airplane; when the plane lands and the </tspan><tspan x="8" y="95.6364">computer reconnects, those changes are distributed to </tspan><tspan x="8" y="107.636">other users.&#10;</tspan><tspan x="8" y="123.636">Other users might choose to accept all, some, or none of </tspan><tspan x="8" y="135.636">those changes to their version of the document.</tspan></text> +<text fill="#EB4335" xml:space="preserve" style="white-space: pre" font-family="Inter" font-size="10" letter-spacing="0px"><tspan x="8" y="15.6364">In a decentralized system, we can have a kaleidoscopic </tspan><tspan x="8" y="27.6364">complexity to our data.&#10;</tspan></text> +</g> +</svg> +`; diff --git a/blocksuite/blocks/src/root-block/widgets/slash-menu/tooltips/divider.ts b/blocksuite/blocks/src/root-block/widgets/slash-menu/tooltips/divider.ts new file mode 100644 index 0000000000..2df75f9f1e --- /dev/null +++ b/blocksuite/blocks/src/root-block/widgets/slash-menu/tooltips/divider.ts @@ -0,0 +1,13 @@ +import { html } from 'lit'; +// prettier-ignore +export const DividerTooltip = html`<svg width="170" height="68" viewBox="0 0 170 68" fill="none" xmlns="http://www.w3.org/2000/svg"> +<rect width="170" height="68" rx="2" fill="white"/> +<mask id="mask0_16460_928" style="mask-type:alpha" maskUnits="userSpaceOnUse" x="0" y="0" width="170" height="68"> +<rect width="170" height="68" rx="2" fill="white"/> +</mask> +<g mask="url(#mask0_16460_928)"> +<text fill="black" xml:space="preserve" style="white-space: pre" font-family="Inter" font-size="10" letter-spacing="0px"><tspan x="8" y="16.6364">In a decentralized system, we can have a </tspan><tspan x="8" y="30.6364">kaleidoscopic complexity to our data.&#10;</tspan><tspan x="8" y="54.6364">Any user may have a different perspective </tspan><tspan x="8" y="68.6364">on what data they either have, choose to </tspan><tspan x="8" y="82.6364">share, or accept.&#10;</tspan><tspan x="8" y="106.636">For example, one user&#x2019;s edits to a </tspan><tspan x="8" y="120.636">document might be on their laptop on an </tspan><tspan x="8" y="134.636">airplane; when the plane lands and the </tspan><tspan x="8" y="148.636">computer reconnects, those changes are </tspan><tspan x="8" y="162.636">distributed to other users.&#10;</tspan><tspan x="8" y="186.636">Other users might choose to accept all, </tspan><tspan x="8" y="200.636">some, or none of those changes to their </tspan><tspan x="8" y="214.636">version of the document.</tspan></text> +<line x1="8.25" y1="40.75" x2="169.75" y2="40.75" stroke="#E3E2E4" stroke-width="0.5" stroke-linecap="round"/> +</g> +</svg> +`; diff --git a/blocksuite/blocks/src/root-block/widgets/slash-menu/tooltips/edgeless.ts b/blocksuite/blocks/src/root-block/widgets/slash-menu/tooltips/edgeless.ts new file mode 100644 index 0000000000..ff718bab30 --- /dev/null +++ b/blocksuite/blocks/src/root-block/widgets/slash-menu/tooltips/edgeless.ts @@ -0,0 +1,28 @@ +import { html } from 'lit'; +// prettier-ignore +export const EdgelessTooltip = html`<svg width="170" height="106" viewBox="0 0 170 106" fill="none" xmlns="http://www.w3.org/2000/svg"> +<rect width="170" height="106" rx="2" fill="white"/> +<mask id="mask0_16460_1252" style="mask-type:alpha" maskUnits="userSpaceOnUse" x="0" y="0" width="170" height="106"> +<rect width="170" height="106" rx="2" fill="white"/> +</mask> +<g mask="url(#mask0_16460_1252)"> +<rect x="100.5" y="42.6565" width="141" height="51" stroke="#1E96EB" stroke-width="3" stroke-dasharray="5 5"/> +<circle cx="101.5" cy="43.5" r="6" fill="white" stroke="#1E96EB" stroke-width="3"/> +<rect x="105" y="8" width="59" height="26" rx="10" fill="black" fill-opacity="0.1"/> +<text fill="#121212" xml:space="preserve" style="white-space: pre" font-family="Inter" font-size="12" letter-spacing="0em"><tspan x="117" y="25.3636">Group</tspan></text> +<mask id="mask1_16460_1252" style="mask-type:alpha" maskUnits="userSpaceOnUse" x="0" y="0" width="98" height="106"> +<path d="M0 1.5C0 0.947717 0.447715 0.5 1 0.5H96.2527C96.8927 0.5 97.368 1.09278 97.2288 1.71742L74.1743 105.217C74.0725 105.675 73.6667 106 73.1982 106H0.999999C0.447715 106 0 105.552 0 105V1.5Z" fill="#F4F4F5"/> +</mask> +<g mask="url(#mask1_16460_1252)"> +<path d="M0 1.5C0 0.947717 0.447715 0.5 1 0.5H96.2527C96.8927 0.5 97.368 1.09278 97.2288 1.71742L74.1743 105.217C74.0725 105.675 73.6667 106 73.1982 106H0.999999C0.447715 106 0 105.552 0 105V1.5Z" fill="#F4F4F5"/> +<rect x="23" y="41.6565" width="142" height="52" rx="9" stroke="black" stroke-opacity="0.5" stroke-width="2"/> +<path d="M23 12.4244C23 10.0964 24.8829 8.20923 27.2056 8.20923H71.7846C74.1073 8.20923 75.9902 10.0964 75.9902 12.4244V29.2849C75.9902 31.6128 74.1073 33.5 71.7846 33.5H27.2056C24.8829 33.5 23 31.6128 23 29.2849V12.4244Z" fill="black" fill-opacity="0.8"/> +<path d="M64.0353 25.2043C63.415 25.2043 62.8798 25.0674 62.4299 24.7935C61.9828 24.5169 61.6377 24.1313 61.3946 23.6367C61.1543 23.1393 61.0341 22.5608 61.0341 21.9014C61.0341 21.2419 61.1543 20.6606 61.3946 20.1577C61.6377 19.6519 61.9759 19.2579 62.409 18.9756C62.8449 18.6906 63.3535 18.5481 63.9347 18.5481C64.27 18.5481 64.6012 18.604 64.9281 18.7158C65.2551 18.8275 65.5527 19.0092 65.8209 19.2607C66.0892 19.5094 66.303 19.8391 66.4622 20.2499C66.6215 20.6606 66.7012 21.1664 66.7012 21.7672V22.1864H61.7383V21.3313H65.6952C65.6952 20.968 65.6225 20.6439 65.4772 20.3589C65.3347 20.0738 65.1307 19.8489 64.8652 19.684C64.6026 19.5191 64.2924 19.4367 63.9347 19.4367C63.5407 19.4367 63.1998 19.5345 62.912 19.7301C62.6269 19.9229 62.4076 20.1744 62.2539 20.4846C62.1002 20.7948 62.0234 21.1273 62.0234 21.4822V22.0523C62.0234 22.5385 62.1072 22.9506 62.2748 23.2888C62.4453 23.6241 62.6814 23.8798 62.9832 24.0558C63.285 24.2291 63.6357 24.3157 64.0353 24.3157C64.2952 24.3157 64.5299 24.2794 64.7395 24.2067C64.9519 24.1313 65.1349 24.0195 65.2886 23.8714C65.4423 23.7205 65.561 23.5333 65.6449 23.3097L66.6006 23.578C66.5 23.9021 66.3309 24.1872 66.0934 24.4331C65.8558 24.6762 65.5624 24.8662 65.2131 25.0031C64.8638 25.1373 64.4712 25.2043 64.0353 25.2043Z" fill="white"/> +<path d="M51.0779 25.0702V18.6319H52.0336V19.6379H52.1174C52.2515 19.2942 52.4681 19.0273 52.7671 18.8373C53.0661 18.6445 53.4252 18.5481 53.8443 18.5481C54.2691 18.5481 54.6226 18.6445 54.9048 18.8373C55.1898 19.0273 55.412 19.2942 55.5713 19.6379H55.6383C55.8032 19.3054 56.0505 19.0413 56.3802 18.8457C56.71 18.6473 57.1054 18.5481 57.5665 18.5481C58.1421 18.5481 58.613 18.7283 58.979 19.0888C59.3451 19.4465 59.5281 20.004 59.5281 20.7612V25.0702H58.5389V20.7612C58.5389 20.2862 58.409 19.9467 58.1491 19.7427C57.8892 19.5387 57.5832 19.4367 57.2311 19.4367C56.7784 19.4367 56.4278 19.5736 56.179 19.8475C55.9303 20.1185 55.806 20.4622 55.806 20.8786V25.0702H54.8V20.6606C54.8 20.2946 54.6813 19.9998 54.4437 19.7762C54.2062 19.5499 53.9002 19.4367 53.5258 19.4367C53.2687 19.4367 53.0284 19.5052 52.8048 19.6421C52.5841 19.779 52.4052 19.969 52.2683 20.2121C52.1342 20.4525 52.0671 20.7305 52.0671 21.0463V25.0702H51.0779Z" fill="white"/> +<path d="M46.3224 25.2211C45.9144 25.2211 45.5442 25.1442 45.2116 24.9905C44.8791 24.8341 44.615 24.6091 44.4194 24.3157C44.2238 24.0195 44.126 23.6618 44.126 23.2427C44.126 22.8738 44.1987 22.5748 44.344 22.3457C44.4893 22.1137 44.6835 21.9321 44.9266 21.8008C45.1697 21.6694 45.438 21.5716 45.7314 21.5073C46.0276 21.4403 46.3252 21.3872 46.6242 21.3481C47.0154 21.2978 47.3326 21.26 47.5757 21.2349C47.8216 21.207 48.0004 21.1608 48.1122 21.0966C48.2268 21.0323 48.284 20.9205 48.284 20.7612V20.7277C48.284 20.3141 48.1709 19.9928 47.9445 19.7637C47.721 19.5345 47.3815 19.4199 46.926 19.4199C46.4537 19.4199 46.0835 19.5233 45.8152 19.7301C45.547 19.9369 45.3583 20.1577 45.2493 20.3924L44.3104 20.0571C44.4781 19.6658 44.7017 19.3613 44.9811 19.1433C45.2633 18.9225 45.5707 18.7689 45.9032 18.6822C46.2386 18.5928 46.5683 18.5481 46.8924 18.5481C47.0992 18.5481 47.3368 18.5732 47.605 18.6235C47.8761 18.671 48.1373 18.7702 48.3888 18.9211C48.6431 19.072 48.8541 19.2998 49.0218 19.6044C49.1894 19.909 49.2733 20.3169 49.2733 20.8283V25.0702H48.284V24.1983H48.2337C48.1667 24.3381 48.0549 24.4876 47.8984 24.6468C47.7419 24.8061 47.5338 24.9416 47.2739 25.0534C47.014 25.1652 46.6968 25.2211 46.3224 25.2211ZM46.4733 24.3325C46.8645 24.3325 47.1942 24.2556 47.4625 24.1019C47.7336 23.9482 47.9375 23.7498 48.0745 23.5067C48.2142 23.2636 48.284 23.0079 48.284 22.7397V21.8343C48.2421 21.8846 48.1499 21.9307 48.0074 21.9726C47.8677 22.0117 47.7056 22.0467 47.5212 22.0774C47.3395 22.1053 47.1621 22.1305 46.9889 22.1528C46.8184 22.1724 46.6801 22.1892 46.5739 22.2031C46.3168 22.2367 46.0765 22.2912 45.8529 22.3666C45.6322 22.4393 45.4533 22.5497 45.3164 22.6978C45.1823 22.8431 45.1152 23.0415 45.1152 23.293C45.1152 23.6367 45.2424 23.8965 45.4967 24.0726C45.7537 24.2458 46.0793 24.3325 46.4733 24.3325Z" fill="white"/> +<path d="M40.0364 25.0704V18.6321H40.9921V19.6045H41.0592C41.1765 19.286 41.3889 19.0275 41.6963 18.8291C42.0037 18.6307 42.3502 18.5315 42.7358 18.5315C42.8084 18.5315 42.8993 18.5329 43.0082 18.5357C43.1172 18.5385 43.1997 18.5427 43.2556 18.5483V19.5542C43.222 19.5459 43.1452 19.5333 43.025 19.5165C42.9077 19.497 42.7833 19.4872 42.652 19.4872C42.339 19.4872 42.0596 19.5528 41.8136 19.6842C41.5705 19.8127 41.3777 19.9916 41.2352 20.2207C41.0955 20.447 41.0256 20.7055 41.0256 20.9961V25.0704H40.0364Z" fill="white"/> +<path d="M33.3126 25.0704V16.4861H38.4598V17.4082H34.3521V20.3088H38.0742V21.2309H34.3521V25.0704H33.3126Z" fill="white"/> +</g> +</g> +</svg> +`; diff --git a/blocksuite/blocks/src/root-block/widgets/slash-menu/tooltips/empty.ts b/blocksuite/blocks/src/root-block/widgets/slash-menu/tooltips/empty.ts new file mode 100644 index 0000000000..d273746e5e --- /dev/null +++ b/blocksuite/blocks/src/root-block/widgets/slash-menu/tooltips/empty.ts @@ -0,0 +1,11 @@ +import { html } from 'lit'; +// prettier-ignore +export const EmptyTooltip = html`<svg width="170" height="68" viewBox="0 0 170 68" fill="none" xmlns="http://www.w3.org/2000/svg"> +<rect width="170" height="68" rx="2" fill="white"/> +<mask id="mask0_16460_864" style="mask-type:alpha" maskUnits="userSpaceOnUse" x="0" y="0" width="170" height="68"> +<rect width="170" height="68" rx="2" fill="white"/> +</mask> +<g mask="url(#mask0_16460_864)"> +</g> +</svg> +`; diff --git a/blocksuite/blocks/src/root-block/widgets/slash-menu/tooltips/figma.ts b/blocksuite/blocks/src/root-block/widgets/slash-menu/tooltips/figma.ts new file mode 100644 index 0000000000..200134d6e1 --- /dev/null +++ b/blocksuite/blocks/src/root-block/widgets/slash-menu/tooltips/figma.ts @@ -0,0 +1,22 @@ +import { html } from 'lit'; +// prettier-ignore +export const FigmaTooltip = html`<svg width="170" height="106" viewBox="0 0 170 106" fill="none" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"> +<rect width="170" height="106" rx="2" fill="white"/> +<mask id="mask0_16460_1083" style="mask-type:alpha" maskUnits="userSpaceOnUse" x="0" y="0" width="170" height="106"> +<rect width="170" height="106" rx="2" fill="white"/> +</mask> +<g mask="url(#mask0_16460_1083)"> +<text fill="#8E8D91" xml:space="preserve" style="white-space: pre" font-family="Inter" font-size="10" letter-spacing="0px"><tspan x="10" y="18.6364">Embed a Figma document.</tspan></text> +<rect x="8.5" y="28.5" width="169" height="121" rx="3.5" fill="white" stroke="#E3E2E4"/> +<text fill="#121212" xml:space="preserve" style="white-space: pre" font-family="Inter" font-size="9" font-weight="500" letter-spacing="0em"><tspan x="18" y="44.7727">AFFiNE Design System - Jul 2077</tspan></text> +<text fill="#8E8D91" xml:space="preserve" style="white-space: pre" font-family="Inter" font-size="9" letter-spacing="0em"><tspan x="18" y="57.7727">Edited just now</tspan></text> +<rect x="16" y="66" width="154" height="58" rx="2" fill="url(#pattern0_16460_1083)"/> +</g> +<defs> +<pattern id="pattern0_16460_1083" patternContentUnits="objectBoundingBox" width="1" height="1"> +<use xlink:href="#image0_16460_1083" transform="scale(0.00324675 0.00862069)"/> +</pattern> +<image id="image0_16460_1083" width="308" height="116" xlink:href="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAATQAAAB0CAYAAAALrs5HAAAACXBIWXMAABYlAAAWJQFJUiTwAAAAAXNSR0IArs4c6QAAAARnQU1BAACxjwv8YQUAABDPSURBVHgB7Z11kBxFG8Z7Q3C7w53gDgnu7u7uUIUWEKBwl0IKCFC4BSgKLSDBCw3uJLhDcM8drsl99+tL3/fuu7138Nft9T2/qq3czs7MzvR0P/1abyqhnZaWlgFtbW1D2/8c2P5qCkII0XsYVqlUBjc3N4+ujBezkUFCJoTovbS2i9qgfu1iNiRIzIQQvZsmvMzKmDFj2oIQQvR+WvsFIYQogyYJmhCiGCRoQohikKAJIYpBgiaEKAYJmhCiGCRoQohikKAJIYpBgiaEKAYJmhCiGCRoQohikKAJIYpBgiaEKAYJmhCiGCRoQohikKAJIYpBgiaEKAYJmhCiGCRoQohikKAJIYpBgiaEKAYJmhCiGCRoQohikKAJIYpBgiaEKAYJmhCiGCRoQohikKAJIYpBgiaEKAYJmhCiGCRoQohikKAJIYpBgiaEKAYJmhCiGCRoQohikKAJIYpBgiaEKAYJmhCiGCRoQohi6B9EEbz99tvhwQcfDNNPP33YZJNNwpRTThmE6GtI0AyvvfZa+PPPP7OfTTjhhGGyySYLs88+e5h00klDI3HvvfeG8847r/M94nbqqaeGRue7774Lb7zxRhg9enT466+/Qv/+/cMcc8wRFlhggfivEP8VCdp4/vjjj3DooYeGtra2LvebYoopwsCBA8Nee+3VMIPuzjvvrHr/zTffhEaGieOaa64J7733XnYC6devX1hxxRXDQQcdFKaddtrQkyC0vCyTTz55qFQqQTQeErTxYCV0J2bwyy+/hKeeeiqMGjUq7LnnnmGzzTYLPc1UU01V9X611VYLjcqNN94YbrjhhrqWMIwbNy62Mc9k6NChUeB6irPPPju8+OKLVdsuuuiiaKmLxkNJgfF8/fXX/2l/hO3SSy8Njz76aOhpDj/88LD88stHq+bAAw8MO+20U2hEbr/99nD11Vd3KWaWzz//PApaT8I18KzTC0t+1llnDaIxkYU2nvfff79m23TTTRfjZlgMuHF///131ee8v/7668Maa6zRoy7ILLPMEk4//fTQyLS2tkY307PEEktEF562xCr79NNPqz5/9dVXQ08xduzY8PHHH1dto0/0pMUoukaCNh7fceG0004L8803X/wbd/S5556LLshPP/3Uuc9nn30WY0IMTMsPP/wQhg0bFl555ZXwxRdfxDjMxBNPHBZddNGw7rrrhlVWWaXm+37//ff4HU888UR45513okXwzz//xCTEbLPNFlZdddUonjauhKB6K/HYY4/tvG54/vnnw/Dhw+M5sY4GDBgQ9t5777D44ovHf62rPeecc4aTTz45fPjhhzWJBfZFfG6++ebwzDPPRJEnWYJ1uNtuu4WZZ5451AO3DevGwnFWiLfeeuuwzTbbRCFJ/Pbbb/H+uE/LPvvsE1ZYYYXO96+//no499xzq/aZeuqp4zaSDQjl3XffHd59991oddEOCBP7LLLIImG99dYLSy65ZOexRx55ZLTaaX9LS0tL2H333aOwnXHGGfH+4ddffw1PPvlkePzxx2Pb8X6CCSaIrinPbf3114/flfjggw9i/7LsvPPOsU24V85FP+N7Ntpoo9guTJrJaiWZQv+YZpppwhZbbBG23HLLICRonfhAOoNgrrnm6nxPZ2IAbbvttuGqq66q2pfOaQWNgcM+dDgLgwgh4LX55ptH9zBZdnw/Hfytt96quTbE8McffwxvvvlmTACcddZZUeCA/RFVS4qpIQyXXXZZPMaKFsJ2yimnhGOOOabGIkpCSMDenxcOPvjgGNtKIFIPPfRQHGDElpqamkKOnKXFfXFdqQ0Y8GRrraCxDWHx18LAttxzzz01+yAEPMdbbrklXHvttTXBfUAwv/rqq/Dwww+HTTfdNN4fkxGTVG5/niHfQ1lMErOPPvooHH/88dmwBW3Niz7Bvc0wwwxxe+6eaEsSIZ988knVfV5++eXx+yaZZJIwZMiQKJaJL7/8Mlx88cVRPBshntvTyHYOHa6j71zMjAwGTy4Y/P3333f+zex5wQUX1IiZB+uNWRgYwAyInJh5GAh06gQd2oKLnAYNg/iOO+7IJjuY/S+55JKa7en+rGglsPJy2wFRYNDWI3cNWK+I+pVXXhktUwYqFiyTQ3phTS600EI1xyLwiTFjxoSXX3656nOs2NVXXz26sVdccUVWnDx33XVXvCbKSbrbP2W4EbPBgwd3G4OlfawFiRXn4VqtmFnuv//+cOGFF1aJmeW2226LoZG+jiy0dr799tsqqwCohcrBTOhJFsazzz4bM3iWeeedN6yzzjrx/HQ6XJYEQXLcEdwl38GxwHbZZZfoUtx0001xoCWY8RnElA/gzloIWHM9DAxcQw9uIedGPL11BnPPPXf811tAwDXgNnN+7z5CvcEIiy22WHjggQdqticLhmvlvAsuuGB0PcnUWsuNlxUx+zd1eLZdgcQIRcYnnnhizXfi2uFm0nYkKXD1E1iayy67bHwuZLJteAFWWmml2Af4l7gg57eTFxPKVlttFWaaaaZoNVITmEB0scRnnHHGrADiljc3N8fz+Xgt1jkw0XKvvr9yDNYmZUV9GQla6BhUnnrxIGZvD0F5wBKwYO1gTdHJgc6Ku5hgdgdcVg8xkbXXXjv+TQzNZtYY6GzzYgYIKDCY/IxNzIwYIK7SCy+8EI4++uia4+eZZ56610SsZt99940DhwwvKxMsXSVGiFE9/fTT0d2uB1ZcKgpGPPfbb7/OImba2IpYElSOIW5lSdYZn/mJgs923HHHKMyAEOBi2vtHVBEqYoZW0Gg34ovpPpmgvIWMpY0gAnFSxDlZe1wPEwmChkvv2X///aMYMpkQp/NWLfFGYmyPPfZYTfwNd7TRCr57Agla6HAHPEkYPC+99FLNNsSGmd1bPHR8Bn7CWxEIA+RcW9wLrL35558/Wk2DBg2KLpiNUeUGBQOf2TsnHBQOp7jP0ksvHa/PDhqEFyFHLLAALSQLcA+BGB3i6AUtubo5+C6ECrcO94mscld1f1hdtOt2220X3xPPtNZOsqp4Hj6hs/HGG3cG4BEuG9hHvIg1MdkgXLi0JAMWXnjhKkHmGG9xckzah899QTNtSxghhRJyMMnw8kJIuyNmgAXNs7DuJdeImAFuOX3G3hcimfMe+hoStJCPZ+AyeBAK3EMLnYiB4Ts3IHA5ty7BrAokGxAvL3iICrElXkAnJ9u16667xvc5IcbCwPLzLg0DprtiUEQLON5bdwx8i7WW6u2Tg8A7a01pF+JxDH4yj36AAzGlJGgpCZJIkwHBfAtiSxslSAzceuutVfvgznGPyUIGXOLDDjuss424Ht8GyR0HBNknkjjvfffdF7oihQl8jM5a4Nybj5XZBBUxW599VaFvBxK00BFDs0w00URxxvMQy/IxFdwLTP1cHVt3M2ZyVfmuk046KZx//vnZ8pEEHf26666LnZfyjdx34lLllj75Ds+g8hZSGjTeOgNiTpZcvCxXcIolRXmDhTbDikJAiZUxUSDovjTDuvf++4lfYW2NGDGiajvWl3W9cBuZOHAPbazMw0RF5pewAVZYzuW295cLPVAG0l09InG9XPzMLqPLtW2abCB3vP28L9PnBY00vA+AIzQE4y24QIiJhzIO8EFcXJ3uZmsLbgSlHgga1gFBYCxH3EovoiQfEDQ/qAgII1w5ofPV+WQsPbYUxOPXrfr4He5WLu7ItRM7szDoEbQEwu/r+MAKCEka62YhujwTa6kgFn6VBOemRg6rDZeVZ414YSF6yxyLjcmNCaar+GT6fs+ZZ54ZllpqqdAdubAFLm8i9/xs++fEVov5O+jzgka5Rq7EIokXrhXxsZxbusEGG8RYEuBKWBA4XMI0yLGaKJOwVhG1aAx4XxhLMJwsGuBSeQsHi4Nr9pYYQoyQ5mJyCCMDAZcUV46kgSe5VDkLwbrgiIi3Eoif5WrQclYq8T1qq1ZeeeXoRuN6ssbTY8UBwSSpkkQclwxBs+DKphghAn7OOedUCTkW6B577NFZr0UW2bu6ybrLPW9b0OzXzwLtZq8Za89OlvQFni0utsfGH33b0obWY6gXahAStOxsyACrV2+VoGKeavUELpGN5xB/oUgTlwpXkayindXpwLgJWFvegmEQEPvBrfIxIkDs6NQ+zpLEMzdb425xvQx4b03aawI/YHDBbQyL6/PxPmrFcu4WIsBgtOJL2xDX8rEtC9bWWmutVbWNot8kaJzD1v9hUXurj+JYuw9tTZvRTlihXsw4fxIqe1yCNbNYwYQeiJvSLjYWRoErViDCS8mHF0UyoOCz6r6I2/dJ2s+KqT+eCc57FH2VPi9oOdeiKxi0CApLY1I5BlCWQC2VHbjEeChs9WDNHXLIIbGTEk/ivRUnZvpc0StQCrDccstlM2nJbcE9Iytma9cSiBnC5QWBrCADHevLFxmn2rZELmmQ4oEejsMSwlr6t9AeZFR9HLOrpVW4lHZpESKx4YYbVsXluOZc8gYQKgQrkSuBYGJK5SLcL5MVqyQs9X6sgPINSkloXx8qQMxsX/KfI+7J0kVA/edY1lpf2kGfbwWb6eoOSiiOO+64+LIdEHD1qF3KJRMsxLhIACBKgLhQX8Ss3hV0aAYt6zTBljAkrNuBdWhn/QRW4RFHHFGzHbHA4shl97w7k1sSZdeOenDNEaiU1e0KxJjyDtzR3LXnYMCnmj0LIpLbnjsv9YE2RlZvGRFtk0TtgAMOCGuuuWboCoSSe6d+D3KTgXU3mWS8hWzbnxirP17W2f/p8xYaA7He+kNmPVwQOjwuVXeBVwYjy3jIvOEqJpcGi4Pjsewo0fCuGSUDLFMitkTRJC4dgwb3EEsA8eOngWyQnEGMVWixZRO4iFxLcq3IJBIHw1Xm/LaYFJIQ//zzzzXnTfVPdl+/jy1pyEFRLhYN1fK42FihuMFMBJyPWOQyyyxTd4UG2MC5hTKW3KCm3SkeJrbGMyEWittP+/MZ58PixUr2zwRBZR0sqzmYPGg/vsMmL1hfyXpYwgMkgNiP58Y9MXFR64cbnIp4gYnJt12a3BLe1bbiznX64+0i/b5Opf0Bd/+rhqLXQBKDdaIW4nsMrgRukf+5IayNRv3FBlwsBBw3G+vWgnWDcPf1JT+iA9WhFQZZPV/PxaDfYYcdorU5cuTI8Mgjj1R9jrvprYJGATEjM0l2OLd+dPvtt5eYiU4kaIWBeBFzsRk2aqv4BZAcKUFhA+qNBPVy9QpicfX0kznCotRIgRx11FF1s44WYoMnnHBClTvaSBDnY+2nh9giC8z52R4hLIqhFQrpfRIM/GQPAXjeE5Amm0qCggA9wehG/t+LKIEhiUDdFSUTZJbJNJMgUWZP5JCgCSGKQS6nEKIYJGhCiGKQoAkhikGCJoQoBgmaEKIYJGhCiGKQoAkhikGCJoQoBgmaEKIYJGhCiGKQoAkhikGCJoQoBgmaEKIYJGhCiGKQoAkhikGCJoQoBgmaEKIYJGhCiGKQoAkhikGCJoQoBgmaEKIYJGhCiGKQoAkhikGCJoQoBgmaEKIYJGhCiGKQoAkhikGCJoQoBgmaEKIYJGhCiGKQoAkhikGCJoQoBgmaEKIYJGhCiGKQoAkhikGCJoQoBgmaEKIYJGhCiGKQoAkhikGCJoQoBgmaEKIYJGhCiGKQoAkhigFBaw1CCFEACNqoIIQQvZ/hlZaWlgFtbW0j2980BSGE6J20ViqVQf2am5tH80f7hmFBCCF6F4TMRqBhaNn/ALlwN3AovtEaAAAAAElFTkSuQmCC"/> +</defs> +</svg> +`; diff --git a/blocksuite/blocks/src/root-block/widgets/slash-menu/tooltips/github-repo.ts b/blocksuite/blocks/src/root-block/widgets/slash-menu/tooltips/github-repo.ts new file mode 100644 index 0000000000..ff89346629 --- /dev/null +++ b/blocksuite/blocks/src/root-block/widgets/slash-menu/tooltips/github-repo.ts @@ -0,0 +1,23 @@ +import { html } from 'lit'; +// prettier-ignore +export const GithubRepoTooltip = html`<svg width="170" height="68" viewBox="0 0 170 68" fill="none" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"> +<rect width="170" height="68" rx="2" fill="white"/> +<mask id="mask0_16460_1028" style="mask-type:alpha" maskUnits="userSpaceOnUse" x="0" y="0" width="170" height="68"> +<rect width="170" height="68" rx="2" fill="white"/> +</mask> +<g mask="url(#mask0_16460_1028)"> +<rect x="6.5" y="28.5" width="169" height="67" rx="3.5" fill="white" stroke="#E3E2E4"/> +<text fill="#121212" xml:space="preserve" style="white-space: pre" font-family="Inter" font-size="9" letter-spacing="0em"><tspan x="18" y="46.7727">toeverything/</tspan></text> +<text fill="#121212" xml:space="preserve" style="white-space: pre" font-family="Inter" font-size="9" font-weight="bold" letter-spacing="0em"><tspan x="75.041" y="46.7727">AFFiNE</tspan></text> +<text fill="#8E8D91" xml:space="preserve" style="white-space: pre" font-family="Inter" font-size="7" letter-spacing="0em"><tspan x="18" y="57.5455">Write, Draw and Plan All at Once.</tspan></text> +<rect x="146" y="38" width="24" height="24" fill="url(#pattern0_16460_1028)"/> +<text fill="#8E8D91" xml:space="preserve" style="white-space: pre" font-family="Inter" font-size="10" letter-spacing="0px"><tspan x="10" y="18.6364">Link to a GitHub repository.</tspan></text> +</g> +<defs> +<pattern id="pattern0_16460_1028" patternContentUnits="objectBoundingBox" width="1" height="1"> +<use xlink:href="#image0_16460_1028" transform="scale(0.0208333)"/> +</pattern> +<image id="image0_16460_1028" width="48" height="48" xlink:href="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAADAAAAAwCAYAAABXAvmHAAAACXBIWXMAABYlAAAWJQFJUiTwAAAAAXNSR0IArs4c6QAAAARnQU1BAACxjwv8YQUAAAgNSURBVHgB7VldbBzVFf7unZmd/ct61xjHxEkU4jhRbYeQxAlp2tBC+4Cg/1LpHy8VqipVVaU+9K3vfe5zJaSqrdq0Qg1UoQoEKlBjKMQtYBKbn/zbBsf2Ouv17s7uzJ3bc+54nYBc8M8saaQcabOzk5k75zvnO985dyw0GW5hk7jF7TaAm223AazEWqkTLQHADgdBsPTb9/2WgYgfADmq4WNuvkrHMJ9i2YsOWmAtAhDgwmQNIR+HwNiVKh2HaIXFDICcFAJ1ZePVkRloqRAKjZGxWVR9gVZYzAC0Icr5yQr+9UYFQUi50AJvjfl470oZrbBYAXCdhuTw0JsfoLRg48J4BdMzFbw/F+DU6SmEYWCStPhPLGYjRtM6gBdIDJ0uQ8kkhkdLyCUtNCwHw2drKFZCtGcbkDoBLUKIGOIXbwZouaefH0dxJgdt2Xhp+BqGRkoU8BSulhw89fwk6ZNvBEkgQBwWK4DJGYWn/1mkVQVspXHxisK/x6pIEHU0FfOzLxcxMcneK7raQhy2LgCh+TSgUEXD1/jTM1dQKgtytkFOajQ8D1XuAUQXdrdaTeDo8XF4LKlU4EZjORNcPGtsE+sCIMh5QZIZKhfH/vE+hl6vUfBFpP/cDeoe/HqdjgiUtkhWBV45M4+jz0wRCCo/TY8PGVozK586AAVFzv7thXH84fgU6jrSeiGi74+OD4oyEYgcjp2Yxx+fHUMtrJtipoUok2tzZX0UouI8evIDPPH3OTBJpP7wcgxEyuY5DYsAylDAtzz89USVKDeFaoP/h6m0tkYnPn5LyUuraHGOLlNVsHqQxk9U8eRz4zj1nwCBjBwzkddMmNBQolKu0bEHJ9cJh2kvdLQWUQ9hBglae3d/HY8+vA27tibNGpLWjuLAMiuicUoroiYrvlwlAG5MHBwaCRhKSCvPzZPSvDCB46/M0MSZgVb6hsXogbJh6CB0Bk6CHK3V4DkuuVWjxZJYijQXsGTQQEp4+PJn8/jWQ9tQyFAmEQE1TOQPAYCwsFyWlgXAp8xpVpJQYbasce5yCS9Thx15dwHX5vImGoJnHVKTJuclj9EyNJKZoPNffTiLnJPF756aIFBtpuhvlJuQC54pRg5aJAZpp4E9A8Dn9nZSRrK4I2/DlrR+6NAz5NJzbrRlOzFfyPN8qdzAeRoHRi+W8M54A+cuaZS9LJQVwA4jFZEfUg9FD6PIYwFfOuDiO/dvQ9JRmJpdwIlTNUPDxTI3QBiwFSy6QdSs+Qpvvk014s1h4lIDvdvS2Lo5jXwuhGsLiJVm4IZURNngpNK9vvIxRmBOUod98dVrKE1X4Nc8KEWkITo4dgbZDhuPfbMb375/E/2OJDKoA8dOX8bvnyyiOF0y0hpSZoW0kHDS2JCz8MAXuvHFwTb0390GV3J+OeJRNsPo17JlvoJZiMSSFlmoK7x7oYQ3Rucx+lYZtp9A2tXwKLUqYGcEkokEOZ3G6DtzuLqvgI2FDD24hko9gbOvF6FI8x3XNXSIasCC66YhEzTBvldCwaZgEdgdPVlsSArSNSpqipylRNS4l0HwPzPA28BiuYqLVys0DhD/X7uG8kKa+qZNFHKpoCskixxgZ2ldCYqsZqcVejYF+OVPeimDPn71xHmcvUyOk9NEafPh+tJoqhJpHTlIzRwu3duVBQ7fm0P/LgebujbgjpxLVHSJritUoaUiNuuHRiYrVY3hM7N48bVpjLxNsz42mCLki1h9WPRsViHBcw/XhcKePgsdKRcnTzMJA7peGndZTYXp2KHZvVFekKRo9G6X+PzBDtzXl0Fb2jEqxMIquVvLVWZgOWM9JrLgzKUKfvOXc9QLQjMaMxNDETYXRFNpAt8zD7WcFN3LsqiXmpY2A0akQD1dHh772k7s39UGy2JwCtc9/vgGtyoAHNXQTJYW5omrLw1N4c/PTWK6noUTNGBRhK+PBFQ3pSIC6hP59o5FIaA80QzEIDm6jljA976+BQ8eaEMhTYwXjrnvulp+cndeXQZgGBVNj0RkgoNhKr5f/3YU5fmNNBcp0xuiR2vMXZ0ys9KdnXeR80Qi8swKudd6yCTn8bPHB7B3Z4o6shUNdaaoVjfdrOpqTr82VSiMQzx57usp4BeP34PshlnTsaMldTR6YHGsWKJMJIVpt4Kf/mgPBnfl4ZjdGY0P1trm6VUBWFJj0WSnpmhKDNzt4uc/7ENKVnlko3pQ0TU8v1ihOcezEQcg5Xr48Q96cXBHJlJGKRbXkljLbLmuaVQvvrmylMTe7Ql8/xubCdAC9Q3b9A7XzSCVyi5CZSlXeOSBAg7tcY2+x2Hr3A/YphaEpJmFCvvBAwVsvdM3VGb3cnmJVJofoYzD3XkPjxzZSFpPxSrjeTOxvj1xM4rmy6E3EAKPfmUHFSXRhjS+7zNpHN5fiIgWNvDdh7bTWwkijnYRl60PgGguERWFFAkMDuTRszVhSHNodwFH7i2A971buoAj+9tN4Zus6f+DTf1HjXmesBQO7U/SOO1jb28e/T15OlfCfYMFWHZommGcFu+rRVIai/R8cKATWzZpdLU7yGds9HSncHh3pxkfRHNnFdOr0pjfjUaC2N2ewsF7kkZ0uUz29aWwpTOJlYwGq7VYAQij98KoUP/OTjO1cS3s7uuAJW+Ft9Nm1oga0+aNWQOA5fOujiRa4/6qh7lPMLOPFubLD+hFC8k9N7lAKPPKRVrxw4gXwNIGJcJi1HVx84IW5SBmAJ++3f478c222wButv0X5BSXyMVazj4AAAAASUVORK5CYII="/> +</defs> +</svg> +`; diff --git a/blocksuite/blocks/src/root-block/widgets/slash-menu/tooltips/heading-1.ts b/blocksuite/blocks/src/root-block/widgets/slash-menu/tooltips/heading-1.ts new file mode 100644 index 0000000000..ad432d2a84 --- /dev/null +++ b/blocksuite/blocks/src/root-block/widgets/slash-menu/tooltips/heading-1.ts @@ -0,0 +1,13 @@ +import { html } from 'lit'; +// prettier-ignore +export const Heading1Tooltip = html`<svg width="170" height="68" viewBox="0 0 170 68" fill="none" xmlns="http://www.w3.org/2000/svg"> +<rect width="170" height="68" rx="2" fill="white"/> +<mask id="mask0_16460_873" style="mask-type:alpha" maskUnits="userSpaceOnUse" x="0" y="0" width="170" height="68"> +<rect width="170" height="68" rx="2" fill="white"/> +</mask> +<g mask="url(#mask0_16460_873)"> +<text fill="#121212" xml:space="preserve" style="white-space: pre" font-family="Inter" font-size="28" font-weight="bold" letter-spacing="-0.24px"><tspan x="8" y="34.1818">Heading 1</tspan></text> +<text fill="#121212" xml:space="preserve" style="white-space: pre" font-family="Inter" font-size="10" letter-spacing="0px"><tspan x="8" y="51.6364">In a decentralized system, we can have a kaleidoscopic </tspan><tspan x="8" y="63.6364">complexity to our data.&#10;</tspan><tspan x="8" y="79.6364">Any user may have a different perspective on what data they </tspan><tspan x="8" y="91.6364">either have, choose to share, or accept.&#10;</tspan><tspan x="8" y="107.636">For example, one user&#x2019;s edits to a document might be on </tspan><tspan x="8" y="119.636">their laptop on an airplane; when the plane lands and the </tspan><tspan x="8" y="131.636">computer reconnects, those changes are distributed to </tspan><tspan x="8" y="143.636">other users.&#10;</tspan><tspan x="8" y="159.636">Other users might choose to accept all, some, or none of </tspan><tspan x="8" y="171.636">those changes to their version of the document.</tspan></text> +</g> +</svg> +`; diff --git a/blocksuite/blocks/src/root-block/widgets/slash-menu/tooltips/heading-2.ts b/blocksuite/blocks/src/root-block/widgets/slash-menu/tooltips/heading-2.ts new file mode 100644 index 0000000000..8ba032f02c --- /dev/null +++ b/blocksuite/blocks/src/root-block/widgets/slash-menu/tooltips/heading-2.ts @@ -0,0 +1,13 @@ +import { html } from 'lit'; +// prettier-ignore +export const Heading2Tooltip = html`<svg width="170" height="68" viewBox="0 0 170 68" fill="none" xmlns="http://www.w3.org/2000/svg"> +<rect width="170" height="68" rx="2" fill="white"/> +<mask id="mask0_16460_880" style="mask-type:alpha" maskUnits="userSpaceOnUse" x="0" y="0" width="170" height="68"> +<rect width="170" height="68" rx="2" fill="white"/> +</mask> +<g mask="url(#mask0_16460_880)"> +<text fill="#121212" xml:space="preserve" style="white-space: pre" font-family="Inter" font-size="26" font-weight="600" letter-spacing="-0.24px"><tspan x="8" y="33.4545">Heading 2</tspan></text> +<text fill="#121212" xml:space="preserve" style="white-space: pre" font-family="Inter" font-size="10" letter-spacing="0px"><tspan x="8" y="51.6364">In a decentralized system, we can have a kaleidoscopic </tspan><tspan x="8" y="63.6364">complexity to our data.&#10;</tspan><tspan x="8" y="79.6364">Any user may have a different perspective on what data they </tspan><tspan x="8" y="91.6364">either have, choose to share, or accept.&#10;</tspan><tspan x="8" y="107.636">For example, one user&#x2019;s edits to a document might be on </tspan><tspan x="8" y="119.636">their laptop on an airplane; when the plane lands and the </tspan><tspan x="8" y="131.636">computer reconnects, those changes are distributed to </tspan><tspan x="8" y="143.636">other users.&#10;</tspan><tspan x="8" y="159.636">Other users might choose to accept all, some, or none of </tspan><tspan x="8" y="171.636">those changes to their version of the document.</tspan></text> +</g> +</svg> +`; diff --git a/blocksuite/blocks/src/root-block/widgets/slash-menu/tooltips/heading-3.ts b/blocksuite/blocks/src/root-block/widgets/slash-menu/tooltips/heading-3.ts new file mode 100644 index 0000000000..a9036a4944 --- /dev/null +++ b/blocksuite/blocks/src/root-block/widgets/slash-menu/tooltips/heading-3.ts @@ -0,0 +1,13 @@ +import { html } from 'lit'; +// prettier-ignore +export const Heading3Tooltip = html`<svg width="170" height="68" viewBox="0 0 170 68" fill="none" xmlns="http://www.w3.org/2000/svg"> +<rect width="170" height="68" rx="2" fill="white"/> +<mask id="mask0_16460_887" style="mask-type:alpha" maskUnits="userSpaceOnUse" x="0" y="0" width="170" height="68"> +<rect width="170" height="68" rx="2" fill="white"/> +</mask> +<g mask="url(#mask0_16460_887)"> +<text fill="#121212" xml:space="preserve" style="white-space: pre" font-family="Inter" font-size="24" font-weight="600" letter-spacing="-0.24px"><tspan x="8" y="30.7273">Heading 3</tspan></text> +<text fill="#121212" xml:space="preserve" style="white-space: pre" font-family="Inter" font-size="10" letter-spacing="0px"><tspan x="8" y="47.6364">In a decentralized system, we can have a kaleidoscopic </tspan><tspan x="8" y="59.6364">complexity to our data.&#10;</tspan><tspan x="8" y="75.6364">Any user may have a different perspective on what data they </tspan><tspan x="8" y="87.6364">either have, choose to share, or accept.&#10;</tspan><tspan x="8" y="103.636">For example, one user&#x2019;s edits to a document might be on </tspan><tspan x="8" y="115.636">their laptop on an airplane; when the plane lands and the </tspan><tspan x="8" y="127.636">computer reconnects, those changes are distributed to </tspan><tspan x="8" y="139.636">other users.&#10;</tspan><tspan x="8" y="155.636">Other users might choose to accept all, some, or none of </tspan><tspan x="8" y="167.636">those changes to their version of the document.</tspan></text> +</g> +</svg> +`; diff --git a/blocksuite/blocks/src/root-block/widgets/slash-menu/tooltips/heading-4.ts b/blocksuite/blocks/src/root-block/widgets/slash-menu/tooltips/heading-4.ts new file mode 100644 index 0000000000..1d54f9b2fb --- /dev/null +++ b/blocksuite/blocks/src/root-block/widgets/slash-menu/tooltips/heading-4.ts @@ -0,0 +1,13 @@ +import { html } from 'lit'; +// prettier-ignore +export const Heading4Tooltip = html`<svg width="170" height="68" viewBox="0 0 170 68" fill="none" xmlns="http://www.w3.org/2000/svg"> +<rect width="170" height="68" rx="2" fill="white"/> +<mask id="mask0_16460_894" style="mask-type:alpha" maskUnits="userSpaceOnUse" x="0" y="0" width="170" height="68"> +<rect width="170" height="68" rx="2" fill="white"/> +</mask> +<g mask="url(#mask0_16460_894)"> +<text fill="#121212" xml:space="preserve" style="white-space: pre" font-family="Inter" font-size="22" font-weight="600" letter-spacing="0.24px"><tspan x="8" y="29">Heading 4</tspan></text> +<text fill="#121212" xml:space="preserve" style="white-space: pre" font-family="Inter" font-size="10" letter-spacing="0px"><tspan x="8" y="45.6364">In a decentralized system, we can have a kaleidoscopic </tspan><tspan x="8" y="57.6364">complexity to our data.&#10;</tspan><tspan x="8" y="73.6364">Any user may have a different perspective on what data they </tspan><tspan x="8" y="85.6364">either have, choose to share, or accept.&#10;</tspan><tspan x="8" y="101.636">For example, one user&#x2019;s edits to a document might be on </tspan><tspan x="8" y="113.636">their laptop on an airplane; when the plane lands and the </tspan><tspan x="8" y="125.636">computer reconnects, those changes are distributed to </tspan><tspan x="8" y="137.636">other users.&#10;</tspan><tspan x="8" y="153.636">Other users might choose to accept all, some, or none of </tspan><tspan x="8" y="165.636">those changes to their version of the document.</tspan></text> +</g> +</svg> +`; diff --git a/blocksuite/blocks/src/root-block/widgets/slash-menu/tooltips/heading-5.ts b/blocksuite/blocks/src/root-block/widgets/slash-menu/tooltips/heading-5.ts new file mode 100644 index 0000000000..b83c82daed --- /dev/null +++ b/blocksuite/blocks/src/root-block/widgets/slash-menu/tooltips/heading-5.ts @@ -0,0 +1,13 @@ +import { html } from 'lit'; +// prettier-ignore +export const Heading5Tooltip = html`<svg width="170" height="68" viewBox="0 0 170 68" fill="none" xmlns="http://www.w3.org/2000/svg"> +<rect width="170" height="68" rx="2" fill="white"/> +<mask id="mask0_16460_901" style="mask-type:alpha" maskUnits="userSpaceOnUse" x="0" y="0" width="170" height="68"> +<rect width="170" height="68" rx="2" fill="white"/> +</mask> +<g mask="url(#mask0_16460_901)"> +<text fill="#121212" xml:space="preserve" style="white-space: pre" font-family="Inter" font-size="20" font-weight="600" letter-spacing="0.24px"><tspan x="8" y="27.2727">Heading 5</tspan></text> +<text fill="#121212" xml:space="preserve" style="white-space: pre" font-family="Inter" font-size="10" letter-spacing="0px"><tspan x="8" y="43.6364">In a decentralized system, we can have a kaleidoscopic </tspan><tspan x="8" y="55.6364">complexity to our data.&#10;</tspan><tspan x="8" y="71.6364">Any user may have a different perspective on what data they </tspan><tspan x="8" y="83.6364">either have, choose to share, or accept.&#10;</tspan><tspan x="8" y="99.6364">For example, one user&#x2019;s edits to a document might be on </tspan><tspan x="8" y="111.636">their laptop on an airplane; when the plane lands and the </tspan><tspan x="8" y="123.636">computer reconnects, those changes are distributed to </tspan><tspan x="8" y="135.636">other users.&#10;</tspan><tspan x="8" y="151.636">Other users might choose to accept all, some, or none of </tspan><tspan x="8" y="163.636">those changes to their version of the document.</tspan></text> +</g> +</svg> +`; diff --git a/blocksuite/blocks/src/root-block/widgets/slash-menu/tooltips/heading-6.ts b/blocksuite/blocks/src/root-block/widgets/slash-menu/tooltips/heading-6.ts new file mode 100644 index 0000000000..e8acc16f23 --- /dev/null +++ b/blocksuite/blocks/src/root-block/widgets/slash-menu/tooltips/heading-6.ts @@ -0,0 +1,13 @@ +import { html } from 'lit'; +// prettier-ignore +export const Heading6Tooltip = html`<svg width="170" height="68" viewBox="0 0 170 68" fill="none" xmlns="http://www.w3.org/2000/svg"> +<rect width="170" height="68" rx="2" fill="white"/> +<mask id="mask0_16460_908" style="mask-type:alpha" maskUnits="userSpaceOnUse" x="0" y="0" width="170" height="68"> +<rect width="170" height="68" rx="2" fill="white"/> +</mask> +<g mask="url(#mask0_16460_908)"> +<text fill="#121212" xml:space="preserve" style="white-space: pre" font-family="Inter" font-size="18" font-weight="600" letter-spacing="0.24px"><tspan x="8" y="25.5455">Heading 6</tspan></text> +<text fill="#121212" xml:space="preserve" style="white-space: pre" font-family="Inter" font-size="10" letter-spacing="0px"><tspan x="8" y="41.6364">In a decentralized system, we can have a kaleidoscopic </tspan><tspan x="8" y="53.6364">complexity to our data.&#10;</tspan><tspan x="8" y="69.6364">Any user may have a different perspective on what data they </tspan><tspan x="8" y="81.6364">either have, choose to share, or accept.&#10;</tspan><tspan x="8" y="97.6364">For example, one user&#x2019;s edits to a document might be on </tspan><tspan x="8" y="109.636">their laptop on an airplane; when the plane lands and the </tspan><tspan x="8" y="121.636">computer reconnects, those changes are distributed to </tspan><tspan x="8" y="133.636">other users.&#10;</tspan><tspan x="8" y="149.636">Other users might choose to accept all, some, or none of </tspan><tspan x="8" y="161.636">those changes to their version of the document.</tspan></text> +</g> +</svg> +`; diff --git a/blocksuite/blocks/src/root-block/widgets/slash-menu/tooltips/index.ts b/blocksuite/blocks/src/root-block/widgets/slash-menu/tooltips/index.ts new file mode 100644 index 0000000000..834d8b9840 --- /dev/null +++ b/blocksuite/blocks/src/root-block/widgets/slash-menu/tooltips/index.ts @@ -0,0 +1,237 @@ +import type { TemplateResult } from 'lit'; + +import { AttachmentTooltip } from './attachment.js'; +import { BoldTextTooltip } from './bold-text.js'; +import { BulletedListTooltip } from './bulleted-list.js'; +import { CodeBlockTooltip } from './code-block.js'; +import { CopyTooltip } from './copy.js'; +import { DeleteTooltip } from './delete.js'; +import { DividerTooltip } from './divider.js'; +import { EdgelessTooltip } from './edgeless.js'; +import { FigmaTooltip } from './figma.js'; +import { GithubRepoTooltip } from './github-repo.js'; +import { Heading1Tooltip } from './heading-1.js'; +import { Heading2Tooltip } from './heading-2.js'; +import { Heading3Tooltip } from './heading-3.js'; +import { Heading4Tooltip } from './heading-4.js'; +import { Heading5Tooltip } from './heading-5.js'; +import { Heading6Tooltip } from './heading-6.js'; +import { ItalicTooltip } from './italic.js'; +import { KanbanViewTooltip } from './kanban-view.js'; +import { LinearTooltip } from './linear.js'; +import { LinkTooltip } from './link.js'; +import { LinkDocTooltip } from './link-doc.js'; +import { MoveDownTooltip } from './move-down.js'; +import { MoveUpTooltip } from './move-up.js'; +import { NewDocTooltip } from './new-doc.js'; +import { NowTooltip } from './now.js'; +import { NumberedListTooltip } from './numbered-list.js'; +import { PhotoTooltip } from './photo.js'; +import { QuoteTooltip } from './quote.js'; +import { StrikethroughTooltip } from './strikethrough.js'; +import { TableViewTooltip } from './table-view.js'; +import { TextTooltip } from './text.js'; +import { ToDoListTooltip } from './to-do-list.js'; +import { TodayTooltip } from './today.js'; +import { TomorrowTooltip } from './tomorrow.js'; +import { TweetTooltip } from './tweet.js'; +import { UnderlineTooltip } from './underline.js'; +import { YesterdayTooltip } from './yesterday.js'; +import { YoutubeVideoTooltip } from './youtube-video.js'; + +export type SlashMenuTooltip = { + figure: TemplateResult; + caption: string; +}; + +export const slashMenuToolTips: Record<string, SlashMenuTooltip> = { + Text: { + figure: TextTooltip, + caption: 'Text', + }, + + 'Heading 1': { + figure: Heading1Tooltip, + caption: 'Heading #1', + }, + + 'Heading 2': { + figure: Heading2Tooltip, + caption: 'Heading #2', + }, + + 'Heading 3': { + figure: Heading3Tooltip, + caption: 'Heading #3', + }, + + 'Heading 4': { + figure: Heading4Tooltip, + caption: 'Heading #4', + }, + + 'Heading 5': { + figure: Heading5Tooltip, + caption: 'Heading #5', + }, + + 'Heading 6': { + figure: Heading6Tooltip, + caption: 'Heading #6', + }, + + 'Code Block': { + figure: CodeBlockTooltip, + caption: 'Code Block', + }, + + Quote: { + figure: QuoteTooltip, + caption: 'Quote', + }, + + Divider: { + figure: DividerTooltip, + caption: 'Divider', + }, + + 'Bulleted List': { + figure: BulletedListTooltip, + caption: 'Bulleted List', + }, + + 'Numbered List': { + figure: NumberedListTooltip, + caption: 'Numbered List', + }, + + 'To-do List': { + figure: ToDoListTooltip, + caption: 'To-do List', + }, + + Bold: { + figure: BoldTextTooltip, + caption: 'Bold Text', + }, + + Italic: { + figure: ItalicTooltip, + caption: 'Italic', + }, + + Underline: { + figure: UnderlineTooltip, + caption: 'Underline', + }, + + Strikethrough: { + figure: StrikethroughTooltip, + caption: 'Strikethrough', + }, + + 'New Doc': { + figure: NewDocTooltip, + caption: 'New Doc', + }, + + 'Linked Doc': { + figure: LinkDocTooltip, + caption: 'Link Doc', + }, + + Link: { + figure: LinkTooltip, + caption: 'Link', + }, + + Attachment: { + figure: AttachmentTooltip, + caption: 'Attachment', + }, + + Github: { + figure: GithubRepoTooltip, + caption: 'GitHub Repo', + }, + + YouTube: { + figure: YoutubeVideoTooltip, + caption: 'YouTube Video', + }, + + Image: { + figure: PhotoTooltip, + caption: 'Photo', + }, + + 'X (Twitter)': { + figure: TweetTooltip, + caption: 'Tweet', + }, + + Figma: { + figure: FigmaTooltip, + caption: 'Figma', + }, + + Linear: { + figure: LinearTooltip, + caption: 'Linear', + }, + + Today: { + figure: TodayTooltip, + caption: 'Today', + }, + + Tomorrow: { + figure: TomorrowTooltip, + caption: 'Tomorrow', + }, + + Yesterday: { + figure: YesterdayTooltip, + caption: 'Yesterday', + }, + + Now: { + figure: NowTooltip, + caption: 'Now', + }, + + 'Table View': { + figure: TableViewTooltip, + caption: 'Table View', + }, + + 'Kanban View': { + figure: KanbanViewTooltip, + caption: 'Kanban View', + }, + + 'Move Up': { + figure: MoveUpTooltip, + caption: 'Move Up', + }, + + 'Move Down': { + figure: MoveDownTooltip, + caption: 'Move Down', + }, + + Copy: { + figure: CopyTooltip, + caption: 'Copy / Duplicate', + }, + + Delete: { + figure: DeleteTooltip, + caption: 'Delete', + }, + + 'Group & Frame': { + figure: EdgelessTooltip, + caption: 'Edgeless', + }, +}; diff --git a/blocksuite/blocks/src/root-block/widgets/slash-menu/tooltips/italic.ts b/blocksuite/blocks/src/root-block/widgets/slash-menu/tooltips/italic.ts new file mode 100644 index 0000000000..caa6586f2c --- /dev/null +++ b/blocksuite/blocks/src/root-block/widgets/slash-menu/tooltips/italic.ts @@ -0,0 +1,13 @@ +import { html } from 'lit'; +// prettier-ignore +export const ItalicTooltip = html`<svg width="170" height="68" viewBox="0 0 170 68" fill="none" xmlns="http://www.w3.org/2000/svg"> +<rect width="170" height="68" rx="2" fill="white"/> +<mask id="mask0_16460_976" style="mask-type:alpha" maskUnits="userSpaceOnUse" x="0" y="0" width="170" height="68"> +<rect width="170" height="68" rx="2" fill="white"/> +</mask> +<g mask="url(#mask0_16460_976)"> +<text fill="#121212" xml:space="preserve" style="white-space: pre" font-family="Inter" font-size="10" letter-spacing="0px"><tspan x="8" y="43.6364">Any user may have a different perspective on what data they </tspan><tspan x="8" y="55.6364">either have, choose to share, or accept.&#10;</tspan><tspan x="8" y="71.6364">For example, one user&#x2019;s edits to a document might be on </tspan><tspan x="8" y="83.6364">their laptop on an airplane; when the plane lands and the </tspan><tspan x="8" y="95.6364">computer reconnects, those changes are distributed to </tspan><tspan x="8" y="107.636">other users.&#10;</tspan><tspan x="8" y="123.636">Other users might choose to accept all, some, or none of </tspan><tspan x="8" y="135.636">those changes to their version of the document.</tspan></text> +<text fill="#121212" xml:space="preserve" style="white-space: pre" font-family="Inter" font-size="10" font-style="italic" letter-spacing="0px"><tspan x="8" y="15.6364">In a decentralized system, we can have a kaleidoscopic </tspan><tspan x="8" y="27.6364">complexity to our data.&#10;</tspan></text> +</g> +</svg> +`; diff --git a/blocksuite/blocks/src/root-block/widgets/slash-menu/tooltips/kanban-view.ts b/blocksuite/blocks/src/root-block/widgets/slash-menu/tooltips/kanban-view.ts new file mode 100644 index 0000000000..a1cddeba4f --- /dev/null +++ b/blocksuite/blocks/src/root-block/widgets/slash-menu/tooltips/kanban-view.ts @@ -0,0 +1,80 @@ +import { html } from 'lit'; +// prettier-ignore +export const KanbanViewTooltip = html`<svg width="170" height="106" viewBox="0 0 170 106" fill="none" xmlns="http://www.w3.org/2000/svg"> +<rect width="170" height="106" rx="2" fill="white"/> +<mask id="mask0_16460_1185" style="mask-type:alpha" maskUnits="userSpaceOnUse" x="0" y="0" width="170" height="106"> +<rect width="170" height="106" rx="2" fill="white"/> +</mask> +<g mask="url(#mask0_16460_1185)"> +<rect x="8.5" y="26.5" width="169" height="84" rx="3.5" fill="white" stroke="#E3E2E4"/> +<mask id="mask1_16460_1185" style="mask-type:alpha" maskUnits="userSpaceOnUse" x="9" y="27" width="168" height="83"> +<rect x="9" y="27" width="168" height="83" rx="4" fill="white"/> +</mask> +<g mask="url(#mask1_16460_1185)"> +<text fill="#121212" xml:space="preserve" style="white-space: pre" font-family="Inter" font-size="9" font-weight="500" letter-spacing="0em"><tspan x="18" y="42.7727">Untitled Kanban</tspan></text> +<text fill="black" xml:space="preserve" style="white-space: pre" font-family="Inter" font-size="5" letter-spacing="0em"><tspan x="18" y="55.8182">Ungroups</tspan></text> +<rect x="84" y="50" width="31" height="8" rx="1" fill="#F3F0FF"/> +<text fill="black" xml:space="preserve" style="white-space: pre" font-family="Inter" font-size="5" letter-spacing="0em"><tspan x="86" y="55.8182">In Progress</tspan></text> +<rect x="150" y="50" width="17" height="8" rx="1" fill="#DFF4E8"/> +<text fill="black" xml:space="preserve" style="white-space: pre" font-family="Inter" font-size="5" letter-spacing="0em"><tspan x="152" y="55.8182">Done</tspan></text> +<rect x="16.25" y="60.25" width="59.5" height="53.5" rx="1.75" fill="white" stroke="#E3E2E4" stroke-width="0.5"/> +<text fill="#121212" xml:space="preserve" style="white-space: pre" font-family="Inter" font-size="5" letter-spacing="0em"><tspan x="21" y="68.8182">Task 5</tspan></text> +<rect x="20" y="88" width="8" height="8" rx="1" fill="#EEEEEE"/> +<g clip-path="url(#clip0_16460_1185)"> +<g clip-path="url(#clip1_16460_1185)"> +<path fill-rule="evenodd" clip-rule="evenodd" d="M21.8125 90C21.8125 89.8964 21.8964 89.8125 22 89.8125H26C26.1036 89.8125 26.1875 89.8964 26.1875 90V90.6667C26.1875 90.7702 26.1036 90.8542 26 90.8542C25.8964 90.8542 25.8125 90.7702 25.8125 90.6667V90.1875H24.1875V93.8125H25C25.1036 93.8125 25.1875 93.8964 25.1875 94C25.1875 94.1036 25.1036 94.1875 25 94.1875H23C22.8964 94.1875 22.8125 94.1036 22.8125 94C22.8125 93.8964 22.8964 93.8125 23 93.8125H23.8125V90.1875H22.1875V90.6667C22.1875 90.7702 22.1036 90.8542 22 90.8542C21.8964 90.8542 21.8125 90.7702 21.8125 90.6667V90Z" fill="#77757D"/> +</g> +</g> +<rect x="82.25" y="60.25" width="59.5" height="53.5" rx="1.75" fill="white" stroke="#E3E2E4" stroke-width="0.5"/> +<rect x="148.25" y="60.25" width="59.5" height="53.5" rx="1.75" fill="white" stroke="#E3E2E4" stroke-width="0.5"/> +<line x1="16" y1="99.75" x2="76" y2="99.75" stroke="#E3E2E4" stroke-width="0.5"/> +<line x1="82" y1="99.75" x2="142" y2="99.75" stroke="#E3E2E4" stroke-width="0.5"/> +<line x1="148" y1="99.75" x2="208" y2="99.75" stroke="#E3E2E4" stroke-width="0.5"/> +<text fill="#121212" xml:space="preserve" style="white-space: pre" font-family="Inter" font-size="5" letter-spacing="0em"><tspan x="87" y="68.8182">Task 1</tspan></text> +<rect x="86" y="88" width="8" height="8" rx="1" fill="#EEEEEE"/> +<g clip-path="url(#clip2_16460_1185)"> +<g clip-path="url(#clip3_16460_1185)"> +<path fill-rule="evenodd" clip-rule="evenodd" d="M87.8125 90C87.8125 89.8964 87.8964 89.8125 88 89.8125H92C92.1036 89.8125 92.1875 89.8964 92.1875 90V90.6667C92.1875 90.7702 92.1036 90.8542 92 90.8542C91.8964 90.8542 91.8125 90.7702 91.8125 90.6667V90.1875H90.1875V93.8125H91C91.1036 93.8125 91.1875 93.8964 91.1875 94C91.1875 94.1036 91.1036 94.1875 91 94.1875H89C88.8964 94.1875 88.8125 94.1036 88.8125 94C88.8125 93.8964 88.8964 93.8125 89 93.8125H89.8125V90.1875H88.1875V90.6667C88.1875 90.7702 88.1036 90.8542 88 90.8542C87.8964 90.8542 87.8125 90.7702 87.8125 90.6667V90Z" fill="#77757D"/> +</g> +</g> +<text fill="#121212" xml:space="preserve" style="white-space: pre" font-family="Inter" font-size="5" letter-spacing="0em"><tspan x="153" y="68.8182">Task 4</tspan></text> +<rect x="152" y="88" width="8" height="8" rx="1" fill="#EEEEEE"/> +<g clip-path="url(#clip4_16460_1185)"> +<g clip-path="url(#clip5_16460_1185)"> +<path fill-rule="evenodd" clip-rule="evenodd" d="M153.812 90C153.812 89.8964 153.896 89.8125 154 89.8125H158C158.104 89.8125 158.188 89.8964 158.188 90V90.6667C158.188 90.7702 158.104 90.8542 158 90.8542C157.896 90.8542 157.812 90.7702 157.812 90.6667V90.1875H156.188V93.8125H157C157.104 93.8125 157.188 93.8964 157.188 94C157.188 94.1036 157.104 94.1875 157 94.1875H155C154.896 94.1875 154.812 94.1036 154.812 94C154.812 93.8964 154.896 93.8125 155 93.8125H155.812V90.1875H154.188V90.6667C154.188 90.7702 154.104 90.8542 154 90.8542C153.896 90.8542 153.812 90.7702 153.812 90.6667V90Z" fill="#77757D"/> +</g> +</g> +<rect x="43" y="50" width="8" height="8" rx="1" fill="#F5F5F5"/> +<text fill="black" xml:space="preserve" style="white-space: pre" font-family="Inter" font-size="5" letter-spacing="0em"><tspan x="45.5" y="55.8182">1</tspan></text> +<rect x="117" y="50" width="8" height="8" rx="1" fill="#F5F5F5"/> +<text fill="black" xml:space="preserve" style="white-space: pre" font-family="Inter" font-size="5" letter-spacing="0em"><tspan x="119.5" y="55.8182">1</tspan></text> +<rect x="169" y="50" width="8" height="8" rx="1" fill="#F5F5F5"/> +<rect x="86" y="103" width="31" height="8" rx="1" fill="#F3F0FF"/> +<text fill="black" xml:space="preserve" style="white-space: pre" font-family="Inter" font-size="5" letter-spacing="0em"><tspan x="88" y="108.818">In Progress</tspan></text> +<rect x="152" y="103" width="17" height="8" rx="1" fill="#DFF4E8"/> +<text fill="black" xml:space="preserve" style="white-space: pre" font-family="Inter" font-size="5" letter-spacing="0em"><tspan x="154" y="108.818">Done</tspan></text> +</g> +<text fill="#8E8D91" xml:space="preserve" style="white-space: pre" font-family="Inter" font-size="10" letter-spacing="0px"><tspan x="8" y="16.6364">Visualize data in a dashboard.</tspan></text> +</g> +<defs> +<clipPath id="clip0_16460_1185"> +<rect width="6" height="6" fill="white" transform="translate(21 89)"/> +</clipPath> +<clipPath id="clip1_16460_1185"> +<rect width="6" height="6" fill="white" transform="translate(21 89)"/> +</clipPath> +<clipPath id="clip2_16460_1185"> +<rect width="6" height="6" fill="white" transform="translate(87 89)"/> +</clipPath> +<clipPath id="clip3_16460_1185"> +<rect width="6" height="6" fill="white" transform="translate(87 89)"/> +</clipPath> +<clipPath id="clip4_16460_1185"> +<rect width="6" height="6" fill="white" transform="translate(153 89)"/> +</clipPath> +<clipPath id="clip5_16460_1185"> +<rect width="6" height="6" fill="white" transform="translate(153 89)"/> +</clipPath> +</defs> +</svg> +`; diff --git a/blocksuite/blocks/src/root-block/widgets/slash-menu/tooltips/linear.ts b/blocksuite/blocks/src/root-block/widgets/slash-menu/tooltips/linear.ts new file mode 100644 index 0000000000..5f92af6643 --- /dev/null +++ b/blocksuite/blocks/src/root-block/widgets/slash-menu/tooltips/linear.ts @@ -0,0 +1,49 @@ +import { html } from 'lit'; +// prettier-ignore +export const LinearTooltip = html`<svg width="170" height="106" viewBox="0 0 170 106" fill="none" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"> +<rect width="170" height="106" rx="2" fill="white"/> +<mask id="mask0_16460_1097" style="mask-type:alpha" maskUnits="userSpaceOnUse" x="0" y="0" width="170" height="106"> +<rect width="170" height="106" rx="2" fill="white"/> +</mask> +<g mask="url(#mask0_16460_1097)"> +<text fill="#8E8D91" xml:space="preserve" style="white-space: pre" font-family="Inter" font-size="10" letter-spacing="0px"><tspan x="10" y="18.6364">Insert a Linear issue.</tspan></text> +<rect x="8.5" y="28.5" width="169" height="84" rx="3.5" fill="white" stroke="#E3E2E4"/> +<text fill="#121212" xml:space="preserve" style="white-space: pre" font-family="Inter" font-size="9" font-weight="500" letter-spacing="0em"><tspan x="18" y="44.7727">Change theme following phone case color</tspan></text> +<g clip-path="url(#clip0_16460_1097)"> +<path fill-rule="evenodd" clip-rule="evenodd" d="M20.5474 50.5324C20.6694 50.6545 20.6694 50.8523 20.5474 50.9744L19.7835 51.7383C19.6614 51.8603 19.4636 51.8603 19.3415 51.7383C19.2195 51.6162 19.2195 51.4184 19.3415 51.2963L20.1054 50.5324C20.2275 50.4104 20.4253 50.4104 20.5474 50.5324ZM25.4526 50.5324C25.5747 50.4104 25.7725 50.4104 25.8946 50.5324L26.6585 51.2963C26.7805 51.4184 26.7805 51.6162 26.6585 51.7383C26.5364 51.8603 26.3386 51.8603 26.2165 51.7383L25.4526 50.9744C25.3306 50.8523 25.3306 50.6545 25.4526 50.5324ZM23 51.4479C21.4851 51.4479 20.2569 52.676 20.2569 54.1909C20.2569 55.7059 21.4851 56.934 23 56.934C24.5149 56.934 25.7431 55.7059 25.7431 54.1909C25.7431 52.676 24.5149 51.4479 23 51.4479ZM19.6319 54.1909C19.6319 52.3308 21.1399 50.8229 23 50.8229C24.8601 50.8229 26.3681 52.3308 26.3681 54.1909C26.3681 56.051 24.8601 57.559 23 57.559C21.1399 57.559 19.6319 56.051 19.6319 54.1909Z" fill="#8E8D91"/> +<path fill-rule="evenodd" clip-rule="evenodd" d="M24.3321 53.316C24.4541 53.4381 24.4541 53.6359 24.3321 53.758L22.8506 55.2394C22.7286 55.3615 22.5307 55.3615 22.4087 55.2394L21.6679 54.4987C21.5459 54.3767 21.5459 54.1788 21.6679 54.0568C21.79 53.9347 21.9878 53.9347 22.1099 54.0568L22.6296 54.5765L23.8901 53.316C24.0122 53.194 24.21 53.194 24.3321 53.316Z" fill="#8E8D91"/> +</g> +<text fill="#8E8D91" xml:space="preserve" style="white-space: pre" font-family="Inter" font-size="7" letter-spacing="0em"><tspan x="30" y="56.5455">Work in Progress &#xb7; </tspan></text> +<g clip-path="url(#clip1_16460_1097)"> +<path fill-rule="evenodd" clip-rule="evenodd" d="M96.6591 50.3635C96.8266 50.4054 96.9284 50.5751 96.8865 50.7425L96.4627 52.4375H98.3185L98.7802 50.5909C98.822 50.4235 98.9917 50.3217 99.1591 50.3635C99.3266 50.4054 99.4284 50.5751 99.3865 50.7425L98.9627 52.4375H100.333C100.506 52.4375 100.646 52.5774 100.646 52.75C100.646 52.9226 100.506 53.0625 100.333 53.0625H98.8065L98.3377 54.9375H99.5C99.6726 54.9375 99.8125 55.0774 99.8125 55.25C99.8125 55.4226 99.6726 55.5625 99.5 55.5625H98.1815L97.7198 57.4092C97.678 57.5766 97.5083 57.6784 97.3409 57.6365C97.1734 57.5947 97.0716 57.425 97.1135 57.2576L97.5373 55.5625H95.6815L95.2198 57.4092C95.178 57.5766 95.0083 57.6784 94.8409 57.6365C94.6734 57.5947 94.5716 57.425 94.6135 57.2576L95.0373 55.5625H93.6667C93.4941 55.5625 93.3542 55.4226 93.3542 55.25C93.3542 55.0774 93.4941 54.9375 93.6667 54.9375H95.1935L95.6623 53.0625H94.5C94.3274 53.0625 94.1875 52.9226 94.1875 52.75C94.1875 52.5774 94.3274 52.4375 94.5 52.4375H95.8185L96.2802 50.5909C96.322 50.4235 96.4917 50.3217 96.6591 50.3635ZM96.3065 53.0625L95.8377 54.9375H97.6935L98.1623 53.0625H96.3065Z" fill="#8E8D91"/> +</g> +<text fill="#8E8D91" xml:space="preserve" style="white-space: pre" font-family="Inter" font-size="7" letter-spacing="0em"><tspan x="104" y="56.5455">High</tspan></text> +<rect x="16" y="65" width="12" height="12" rx="6" fill="url(#pattern0_16460_1097)"/> +<text fill="#121212" xml:space="preserve" style="white-space: pre" font-family="Inter" font-size="7" font-weight="500" letter-spacing="0em"><tspan x="32" y="71.5455">qpomelo</tspan></text> +<text fill="#8E8D91" xml:space="preserve" style="white-space: pre" font-family="Inter" font-size="7" letter-spacing="0em"><tspan x="32" y="79.5455">re-assigned to </tspan></text> +<text fill="#121212" xml:space="preserve" style="white-space: pre" font-family="Inter" font-size="7" font-weight="500" letter-spacing="0em"><tspan x="82.1006" y="79.5455">tsiheng</tspan></text> +<rect x="16" y="87" width="12" height="12" rx="6" fill="url(#pattern1_16460_1097)"/> +<text fill="#121212" xml:space="preserve" style="white-space: pre" font-family="Inter" font-size="7" font-weight="500" letter-spacing="0em"><tspan x="32" y="93.5455">tsiheng</tspan></text> +<text fill="#8E8D91" xml:space="preserve" style="white-space: pre" font-family="Inter" font-size="7" letter-spacing="0em"><tspan x="32" y="101.545">re-assigned to </tspan></text> +<text fill="#121212" xml:space="preserve" style="white-space: pre" font-family="Inter" font-size="7" font-weight="500" letter-spacing="0em"><tspan x="82.1006" y="101.545">qpomelo</tspan></text> +<path d="M22 79V85" stroke="#E3E2E4" stroke-width="0.5" stroke-linecap="round" stroke-dasharray="5 5"/> +<path d="M22 101V107" stroke="#E3E2E4" stroke-width="0.5" stroke-linecap="round" stroke-dasharray="5 5"/> +</g> +<defs> +<pattern id="pattern0_16460_1097" patternContentUnits="objectBoundingBox" width="1" height="1"> +<use xlink:href="#image0_16460_1097" transform="scale(0.0416667)"/> +</pattern> +<pattern id="pattern1_16460_1097" patternContentUnits="objectBoundingBox" width="1" height="1"> +<use xlink:href="#image1_16460_1097" transform="scale(0.0416667)"/> +</pattern> +<clipPath id="clip0_16460_1097"> +<rect width="10" height="10" fill="white" transform="translate(18 49)"/> +</clipPath> +<clipPath id="clip1_16460_1097"> +<rect width="10" height="10" fill="white" transform="translate(92 49)"/> +</clipPath> +<image id="image0_16460_1097" width="24" height="24" xlink:href="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAAACXBIWXMAABYlAAAWJQFJUiTwAAAAAXNSR0IArs4c6QAAAARnQU1BAACxjwv8YQUAAAaaSURBVHgBfVVLbFzlFf7u+854ZnznmbGd2OMHxDYxOIkbkjaUCFUiVChAV3TBw111l6KsWDlWVdFdvGHTDemiUklVkhTRqmpLE6pAoJAYWa7j2COPY3vG9sQz987c99xHjweCAgSu5mo0/+j/zjnf+c53GHzP8/zZi4rH4DTH4ATDMgUOKJTf/z3cpqaOPfeL2UKhcFnq4C69/sKx0ndhMA86PHn2YoEJ/bc4hjvB8Rw4jgPbfhk0lz6FGHp49Imn8LORNKS4jAv/rZxXXXZ6ZvLbgbhvHpz69eXTDIM/cgI/LIgiBEmEGJEgRwXEeEDoGkSmdwDH8yJGJRVJvoWgQxm/Udx4lb9bdsrl0vX78dj7fzz7xrtTIcfOsKKg8AQsRyXEYzKyCRkj2Rgm+hSEggCuZWN/zEZ1bR2VhUU8vk+Af2dOAcOeO3r81NQDKzj1m3dPE2G/hcCDAlDmEn7aZePZ8QyGEyGOZFtIZJLwfQ+PxDzY1QrYgEEYBhApEd63cO3TOURHf3Ti6Jk3tfl33rz+VYAXz10sgOGJFkEGZQjuiyBqi8czeRMjB/qRymfRnZQxRsHMpgVTtxDSXY8gTMPGk88cxzvvXUP86Zdhr986mrH0t9fXl9U2RRwTmYpEREWSRfAEzAgcQobDCIE5BIaAPkEAhppstAKsV3YQshyi6RSEaBSRVBqe4+KlM2cQz+bg3r6h8JH4W20VvXju34WX9wcrvYM9cFwfO00bxS0dqUYVMmxMPJRGdqAPfDoHc3kRf7+5iYAVEeEDZKM8ehQSQqmERi6Djv5BTM9HsXP1L5AeOQYhmU9yQyd//mrerZ6U6ts40KtgdH8PDj+cgxTYQG0bwxNjxDOgV7fx4efrMEAZCwxSnTGYcgLFbRPzZR2lWgsjRhmDe7OIjv0ATSlBiQQOt//pV97QdL2gCCE21qqo3/wYEZLl/GIFo4dH2+AbDQ9zxRr8qILBvhwmJoaxrz8PTSPwDR01qRMVs4WNmoVHZROP9aUwX/dgcEQ5yXL8rrwX5foS+nsyuB1w0JY1KLkkdCfErZVNlOouDJ/axXrEtQ2/vApGlmFxEh7KRbGks2g0Ddyu+XhSiBB1Ak6PR3BhySnwIc8rPqlmRcyCK1dRq6vYuhsnxcRRLjcgKCl0xgWU/1eEy7BgurKYD1jYdgMiNT40LDi2CdcmGbMhNi0fAySGtz9ZxazYXWAZAmdYHnrnPmzUTVTp1Z0Alsfio9lbqO40oWo69mTTkO0abv7rb2QXC9SJABwVRXOMesOE2jRhGSa4OwswF+fxxGgOIsuAZXi+FJLP2CGD+PBhkJDQ8hkslbfByxLCIERIZ42mhtqOgaGRQxSoCdEy2tL0TBuW3gQXicCgROe0EBfe+wjsjf+ANxoqD04ocQxTiDWrWF+9RfpnobkuzM1V+PoOuvfkaQYlKIqCDxQLnT19GCt0o0agRpMCEE1JPsQSSTW9txufVRyI1NzNzyooPK7NsmTDV1kaLD9OQ5PtIm5ttGigW46HrY4e0ruPrc0yDE3DU/lODKEJ1zGg2mHbjHcr/jGpqjMeA9Q6LK0Gi+423BDJavEyTxTOhJwwZZNzBhQ52PWWRAJyIo7MwBBGB7rIPSQkSBk+gfmU8Y7tw/B8NBs6qtUqrngmxI44jEYdTZp0yXaR4NJYa7QusZcmD6osz1/ZtWZ+YBSxfC+0xUVShYUjcQY7tTpiMoOGqlK2ARwvBLs7HPS/RLsinc2iqtpt75I7UxDJhdNKHAcG9px//bXJEt/2og5+8pe9/k3NE5TZ1RTuuCYYzYJHMqnrDjaXi0gmMwjlOHEetocvEe+ASdlWtbuQqcE+BeTJxn948GGMD+bVjXpt+msbza6v/Iqs9dyf31/GJ+SBwnoR/bkIJKrMdRxw9E3JtwNQIXCIr616A4bpQCBfjUUEnDoyhMOPDQAi/5qYHpzZxeXvBZCT/TOt+orykyN7p8xqFEbQxIbhIEPrURYltIh7P9yVsA+XpGk3GuBoemNk7y+9cKxtlNnOjl0Ln5a+BP9aBfcenyrxPZ+2EqNYNBv/WKhj69oH8Ei+OgHu+o9AwykQ5zyBHxrOokyKufJxUQ1ZYfoPvzs7cz/eA5e+tbJQ4GT2LBuRXjFIMS5tsRg56OdzRfzzwyLdomtElRe4EP0GRg5NXBkupCdHDn576T8wwL0ntCqFlu0+zyJ4rrS2Nm7oLeWv19fI9JgSdbp08U/nr26X1ZlSaVb9Loz/A5eGBRhyu+yTAAAAAElFTkSuQmCC"/> +<image id="image1_16460_1097" width="24" height="24" xlink:href="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAAACXBIWXMAABYlAAAWJQFJUiTwAAAAAXNSR0IArs4c6QAAAARnQU1BAACxjwv8YQUAAAX5SURBVHgBlVVLjBRFGP6q+jE9O8syu6KuIO4Isr7A+IjRKIl48KAxUS+eNTFe0XjyIrs3Dh6IiY+L0fhKNBgfMb4SdI0JECNhUVnBFRhmXWAXZqZnpqdf1d3l181AQMTESv6Zqq7u7/sfX/0l8B9Da10F4q2A2AJktbOm8x0XkLNZln0mpfpUiNE6/s/QOqhp7X2vdZ8cAS2kRYN5f2CeztK21mpZ63jpLR2crP0blrgUvLkVmZzSkFUhJZ/IwWsCGb1vHj2Aw/t245rra1i36U4IWYLOIganXK0608bKu3dclkDr9jak6RR0SlyTuwMbEHinF/DR1ofQaCpESmDitk149uU3CR5BqDYBJIQ9NCVKN06fw5TnwcNjWxG7U0i7XMXFR9AxfVYDAon23C5cu1Lg2nKGUVuhsX8fkpDgwuInIXSviWSpMdWZ+/K5iwh0e08NcXMK9EJnfUaSQBsGtHC4W6aZha259T7cdcfVmFxtY2wow9rJSViGB9H9HfDOAH0fyguRJNm2k/u/ql0QQbgNSb8K1YUIW3y5Dt2ZB/w/geQUSZt8R0FcsRFjjz6D68ZLGLEknnjhJe57zCgjzi1RLIXCvl+OVheX3LdyZFMf+6SGwHuKcTIdelAVAZGwDslp6GrAjyPELRfWGKNpHUNHXY0r77gfY6MG0D7JTCbMqECoMrz36Y/45PsDWL1m7ZYttVrVjLrJ45adQMqMimC6rDwuTgyqxiZV0sJXr+1E+dRhbLxlBZpnAhw5KXDQO42ft+/EvfdtwOaN49Asuk3HIq+F+T8OotE4guef3PycadvRY2KgpfwIIeVvlv+Lgmf/nl+xa2Yfhk2BpXaMtp/gtDLQYoRuL8bssWV0HrkHD99JEhJUHAt+GCKOY0xM3vCAYRtyx+bbNjhI6C1D1LFGRpMxWaIU737wLQ7XlxBy3fJTLPYUlmOBPj32QsqVCmoxiw9uWo03vt2DV7+Ygc9iC7uM7xodGI3FM9vXrLoStfFxqDhXW4aFE12M2A5EmuL9L3ZjodlHj5H16EA70QgU5+QPKU2fMvVRYV26ePXzb2CkzGuQYtX4OvhpUDV7foiXXt+JkWvWQyURxobLaCy26BVPQBzh4PElxHlkDN+XRiGA4oDzP02Y87gNy2vj/V1/QakUMguwemI9zFIFXbcLM0mTuhdFtVfe+ZgfJHDbblGPsZUjKJcM9JkmXZSFgDSdkUpq2ObZdRQr+H4f1avGcaJ5nOIw0I4WEEUVxKZ0ebZFXVA+tXXrMXnTzegFPk6cWsLs3CEMlSvIKABF0JSWk8QZozEstqsSpDOC4aFhtJaPY2HuJ9isi2Ite5mFgN0gRTQry075hyyTmDt4GPNHjhYeScskACXuebBMzplvTallNNMwGYGFJEthD40iFiWEfQ+l4QqMKMRwn5KPaEyr7CefiSqHY5rthEUsV8o8iQGkMHjcFW7csJ7gKVpn3MJ703KwypFYohCcyhgMq8RUGVhu/AZnZARt5SINE5SpMk2M6yZvul66HF7Qn1FpQo9WQJFICKPw2LZtVCoVAht8zqZHz8OcqFQpopEktCorIEs2Vo2tLA5SZkj06ZwXhW/PzMzU8y4Gev90yZb7Q69b9YMAYRCi7JRwaH6eNROsxXB+uyEMOygbFaZHkXyoIMkl1en0ILgfuz48qpIKc61MTJ9vdmEY1k3DmO512E2p/ZTdNCCJ5/kYXlFBHl2ewpgyXO54XKdMYZoXhjXieeH7Km96rJXgM0Yy7RIzxzbO9e2+7++lt3nT2JKvi27B6gbMqWU7gwvpbDsxmCpp2mCf4Z6FxcY8HEYcxynPjpruh/H2c7jnCfKhknTGtOwOS3Qvg3OKYtP7jOeDFzDn6iyLzNUtCwKbijMR4e51V7mVkvXiocby9gsxLyIo6pEkew3T+tA05CgDvr24LAlWKttUVJZ3daQqojRdckUkkKyTPRNI+fB3uw98/U+8Sy79C4fjODUh9OOOM/TY2omJ2/moqnXe03XdkLIehsEPjuPvmJ2tu5fD+Bu6WUVVx9cBeAAAAABJRU5ErkJggg=="/> +</defs> +</svg> +`; diff --git a/blocksuite/blocks/src/root-block/widgets/slash-menu/tooltips/link-doc.ts b/blocksuite/blocks/src/root-block/widgets/slash-menu/tooltips/link-doc.ts new file mode 100644 index 0000000000..75d3e46b9b --- /dev/null +++ b/blocksuite/blocks/src/root-block/widgets/slash-menu/tooltips/link-doc.ts @@ -0,0 +1,18 @@ +import { html } from 'lit'; +// prettier-ignore +export const LinkDocTooltip = html`<svg width="170" height="68" viewBox="0 0 170 68" fill="none" xmlns="http://www.w3.org/2000/svg"> +<rect width="170" height="68" rx="2" fill="white"/> +<mask id="mask0_16460_998" style="mask-type:alpha" maskUnits="userSpaceOnUse" x="0" y="0" width="170" height="68"> +<rect width="170" height="68" rx="2" fill="white"/> +</mask> +<g mask="url(#mask0_16460_998)"> +<text fill="#121212" xml:space="preserve" style="white-space: pre" font-family="Inter" font-size="10" letter-spacing="0px"><tspan x="8" y="15.6364">In a decentralized system, we can have a kaleidoscopic </tspan><tspan x="8" y="27.6364">complexity to our data.&#10;</tspan><tspan x="8" y="63.6364">Any user may have a different perspective on what data they </tspan><tspan x="8" y="75.6364">either have, choose to share, or accept.&#10;</tspan><tspan x="8" y="111.636">For example, one user&#x2019;s edits to a document might be on </tspan><tspan x="8" y="123.636">their laptop on an airplane; when the plane lands and the </tspan><tspan x="8" y="135.636">computer reconnects, those changes are distributed to </tspan><tspan x="8" y="147.636">other users.&#10;</tspan><tspan x="8" y="183.636">Other users might choose to accept all, some, or none of </tspan><tspan x="8" y="195.636">those changes to their version of the document.</tspan></text> +<path fill-rule="evenodd" clip-rule="evenodd" d="M10.125 39C10.125 38.2406 10.7406 37.625 11.5 37.625H16.5C17.2594 37.625 17.875 38.2406 17.875 39V42C17.875 42.2071 17.7071 42.375 17.5 42.375C17.2929 42.375 17.125 42.2071 17.125 42V39C17.125 38.6548 16.8452 38.375 16.5 38.375H11.5C11.1548 38.375 10.875 38.6548 10.875 39V45C10.875 45.3452 11.1548 45.625 11.5 45.625H14C14.2071 45.625 14.375 45.7929 14.375 46C14.375 46.2071 14.2071 46.375 14 46.375H11.5C10.7406 46.375 10.125 45.7594 10.125 45V39ZM12.125 40C12.125 39.7929 12.2929 39.625 12.5 39.625H14C14.2071 39.625 14.375 39.7929 14.375 40C14.375 40.2071 14.2071 40.375 14 40.375H12.5C12.2929 40.375 12.125 40.2071 12.125 40ZM12.5 41.375C12.2929 41.375 12.125 41.5429 12.125 41.75C12.125 41.9571 12.2929 42.125 12.5 42.125H15.5C15.7071 42.125 15.875 41.9571 15.875 41.75C15.875 41.5429 15.7071 41.375 15.5 41.375H12.5ZM12.125 43.5C12.125 43.2929 12.2929 43.125 12.5 43.125H13.75C13.9571 43.125 14.125 43.2929 14.125 43.5C14.125 43.7071 13.9571 43.875 13.75 43.875H12.5C12.2929 43.875 12.125 43.7071 12.125 43.5ZM15.75 43.125C15.5429 43.125 15.375 43.2929 15.375 43.5C15.375 43.7071 15.5429 43.875 15.75 43.875H17.0947L15.2348 45.7348C15.0884 45.8813 15.0884 46.1187 15.2348 46.2652C15.3813 46.4116 15.6187 46.4116 15.7652 46.2652L17.625 44.4053V45.75C17.625 45.9571 17.7929 46.125 18 46.125C18.2071 46.125 18.375 45.9571 18.375 45.75V43.5C18.375 43.4005 18.3355 43.3052 18.2652 43.2348C18.1948 43.1645 18.0995 43.125 18 43.125H15.75Z" fill="#77757D"/> +<mask id="path-5-inside-1_16460_998" fill="white"> +<path d="M24 35H98V49H24V35Z"/> +</mask> +<path d="M98 48.5H24V49.5H98V48.5Z" fill="#E3E2E4" mask="url(#path-5-inside-1_16460_998)"/> +<text fill="#121212" xml:space="preserve" style="white-space: pre" font-family="Inter" font-size="10" letter-spacing="0px"><tspan x="24" y="45.6364">What&#x2019;s AFFiNE?</tspan></text> +</g> +</svg> +`; diff --git a/blocksuite/blocks/src/root-block/widgets/slash-menu/tooltips/link.ts b/blocksuite/blocks/src/root-block/widgets/slash-menu/tooltips/link.ts new file mode 100644 index 0000000000..946fbc2eb3 --- /dev/null +++ b/blocksuite/blocks/src/root-block/widgets/slash-menu/tooltips/link.ts @@ -0,0 +1,13 @@ +import { html } from 'lit'; +// prettier-ignore +export const LinkTooltip = html`<svg width="170" height="68" viewBox="0 0 170 68" fill="none" xmlns="http://www.w3.org/2000/svg"> +<rect width="170" height="68" rx="2" fill="white"/> +<mask id="mask0_16460_1007" style="mask-type:alpha" maskUnits="userSpaceOnUse" x="0" y="0" width="170" height="68"> +<rect width="170" height="68" rx="2" fill="white"/> +</mask> +<g mask="url(#mask0_16460_1007)"> +<text fill="#121212" xml:space="preserve" style="white-space: pre" font-family="Inter" font-size="10" letter-spacing="0px"><tspan x="8" y="15.6364">In a decentralized system, we can have a kaleidoscopic </tspan><tspan x="8" y="27.6364">complexity to our data.&#10;</tspan><tspan x="8" y="63.6364">Any user may have a different perspective on what data they </tspan><tspan x="8" y="75.6364">either have, choose to share, or accept.&#10;</tspan><tspan x="8" y="111.636">For example, one user&#x2019;s edits to a document might be on </tspan><tspan x="8" y="123.636">their laptop on an airplane; when the plane lands and the </tspan><tspan x="8" y="135.636">computer reconnects, those changes are distributed to </tspan><tspan x="8" y="147.636">other users.&#10;</tspan><tspan x="8" y="183.636">Other users might choose to accept all, some, or none of </tspan><tspan x="8" y="195.636">those changes to their version of the document.</tspan></text> +<text fill="#1E67AF" xml:space="preserve" style="white-space: pre" font-family="Inter" font-size="11" letter-spacing="0em"><tspan x="8" y="45.5">Learn about AFFiNE</tspan></text> +</g> +</svg> +`; diff --git a/blocksuite/blocks/src/root-block/widgets/slash-menu/tooltips/move-down.ts b/blocksuite/blocks/src/root-block/widgets/slash-menu/tooltips/move-down.ts new file mode 100644 index 0000000000..826b22b975 --- /dev/null +++ b/blocksuite/blocks/src/root-block/widgets/slash-menu/tooltips/move-down.ts @@ -0,0 +1,21 @@ +import { html } from 'lit'; +// prettier-ignore +export const MoveDownTooltip = html`<svg width="170" height="68" viewBox="0 0 170 68" fill="none" xmlns="http://www.w3.org/2000/svg"> +<rect width="170" height="68" rx="2" fill="white"/> +<mask id="mask0_16460_1234" style="mask-type:alpha" maskUnits="userSpaceOnUse" x="0" y="0" width="170" height="68"> +<rect width="170" height="68" rx="2" fill="white"/> +</mask> +<g mask="url(#mask0_16460_1234)"> +<text fill="#121212" xml:space="preserve" style="white-space: pre" font-family="Inter" font-size="10" letter-spacing="0px"><tspan x="8" y="15.6364">In a decentralized system, we can have a kaleidoscopic </tspan><tspan x="8" y="27.6364">complexity to our data.&#10;</tspan></text> +<text fill="#A9A9AD" xml:space="preserve" style="white-space: pre" font-family="Inter" font-size="10" letter-spacing="0px"><tspan x="8" y="43.6364">Any user may have a different perspective on what data they </tspan><tspan x="8" y="55.6364">either have, choose to share, or accept.&#10;</tspan><tspan x="8" y="71.6364">For example, one user&#x2019;s edits to a document might be on </tspan><tspan x="8" y="83.6364">their laptop on an airplane; when the plane lands and the </tspan><tspan x="8" y="95.6364">computer reconnects, those changes are distributed to </tspan><tspan x="8" y="107.636">other users.&#10;</tspan><tspan x="8" y="123.636">Other users might choose to accept all, some, or none of </tspan><tspan x="8" y="135.636">those changes to their version of the document.</tspan></text> +</g> +<g clip-path="url(#clip0_16460_1234)"> +<path fill-rule="evenodd" clip-rule="evenodd" d="M25.5073 51.0032C25.2022 50.723 24.7278 50.7432 24.4476 51.0483L20.75 55.0745L20.75 43C20.75 42.5858 20.4142 42.25 20 42.25C19.5858 42.25 19.25 42.5858 19.25 43L19.25 55.0745L15.5524 51.0483C15.2722 50.7432 14.7978 50.723 14.4927 51.0032C14.1876 51.2833 14.1674 51.7578 14.4476 52.0629L19.4476 57.5073C19.5896 57.662 19.79 57.75 20 57.75C20.21 57.75 20.4104 57.662 20.5524 57.5073L25.5524 52.0629C25.8326 51.7578 25.8124 51.2833 25.5073 51.0032Z" fill="#121212"/> +</g> +<defs> +<clipPath id="clip0_16460_1234"> +<rect width="24" height="24" fill="white" transform="translate(8 38)"/> +</clipPath> +</defs> +</svg> +`; diff --git a/blocksuite/blocks/src/root-block/widgets/slash-menu/tooltips/move-up.ts b/blocksuite/blocks/src/root-block/widgets/slash-menu/tooltips/move-up.ts new file mode 100644 index 0000000000..e91c63963f --- /dev/null +++ b/blocksuite/blocks/src/root-block/widgets/slash-menu/tooltips/move-up.ts @@ -0,0 +1,21 @@ +import { html } from 'lit'; +// prettier-ignore +export const MoveUpTooltip = html`<svg width="170" height="68" viewBox="0 0 170 68" fill="none" xmlns="http://www.w3.org/2000/svg"> +<rect width="170" height="68" rx="2" fill="white"/> +<mask id="mask0_16460_1228" style="mask-type:alpha" maskUnits="userSpaceOnUse" x="0" y="0" width="170" height="68"> +<rect width="170" height="68" rx="2" fill="white"/> +</mask> +<g mask="url(#mask0_16460_1228)"> +<text fill="#121212" xml:space="preserve" style="white-space: pre" font-family="Inter" font-size="10" letter-spacing="0px"><tspan x="8" y="43.6364">Any user may have a different perspective on what data they </tspan><tspan x="8" y="55.6364">either have, choose to share, or accept.&#10;</tspan><tspan x="8" y="71.6364">For example, one user&#x2019;s edits to a document might be on </tspan><tspan x="8" y="83.6364">their laptop on an airplane; when the plane lands and the </tspan><tspan x="8" y="95.6364">computer reconnects, those changes are distributed to </tspan><tspan x="8" y="107.636">other users.&#10;</tspan><tspan x="8" y="123.636">Other users might choose to accept all, some, or none of </tspan><tspan x="8" y="135.636">those changes to their version of the document.</tspan></text> +<text fill="#A9A9AD" xml:space="preserve" style="white-space: pre" font-family="Inter" font-size="10" letter-spacing="0px"><tspan x="8" y="15.6364">In a decentralized system, we can have a kaleidoscopic </tspan><tspan x="8" y="27.6364">complexity to our data.&#10;</tspan></text> +</g> +<g clip-path="url(#clip0_16460_1228)"> +<path fill-rule="evenodd" clip-rule="evenodd" d="M25.5073 16.9968C25.2022 17.277 24.7278 17.2568 24.4476 16.9517L20.75 12.9255L20.75 25C20.75 25.4142 20.4142 25.75 20 25.75C19.5858 25.75 19.25 25.4142 19.25 25L19.25 12.9255L15.5524 16.9517C15.2722 17.2568 14.7978 17.277 14.4927 16.9968C14.1876 16.7167 14.1674 16.2422 14.4476 15.9371L19.4476 10.4927C19.5896 10.338 19.79 10.25 20 10.25C20.21 10.25 20.4104 10.338 20.5524 10.4927L25.5524 15.9371C25.8326 16.2422 25.8124 16.7167 25.5073 16.9968Z" fill="#121212"/> +</g> +<defs> +<clipPath id="clip0_16460_1228"> +<rect width="24" height="24" fill="white" transform="translate(8 6)"/> +</clipPath> +</defs> +</svg> +`; diff --git a/blocksuite/blocks/src/root-block/widgets/slash-menu/tooltips/new-doc.ts b/blocksuite/blocks/src/root-block/widgets/slash-menu/tooltips/new-doc.ts new file mode 100644 index 0000000000..800f2ff70b --- /dev/null +++ b/blocksuite/blocks/src/root-block/widgets/slash-menu/tooltips/new-doc.ts @@ -0,0 +1,13 @@ +import { html } from 'lit'; +// prettier-ignore +export const NewDocTooltip = html`<svg width="170" height="68" viewBox="0 0 170 68" fill="none" xmlns="http://www.w3.org/2000/svg"> +<rect width="170" height="68" rx="2" fill="white"/> +<mask id="mask0_16460_991" style="mask-type:alpha" maskUnits="userSpaceOnUse" x="0" y="0" width="170" height="68"> +<rect width="170" height="68" rx="2" fill="white"/> +</mask> +<g mask="url(#mask0_16460_991)"> +<text fill="#8E8D91" xml:space="preserve" style="white-space: pre" font-family="Inter" font-size="22" font-weight="600" letter-spacing="0px"><tspan x="8" y="27.5">Title</tspan></text> +<text fill="#8E8D91" xml:space="preserve" style="white-space: pre" font-family="Inter" font-size="10" letter-spacing="0px"><tspan x="8" y="44.6364">Type &#39;/&#39; for commands</tspan></text> +</g> +</svg> +`; diff --git a/blocksuite/blocks/src/root-block/widgets/slash-menu/tooltips/now.ts b/blocksuite/blocks/src/root-block/widgets/slash-menu/tooltips/now.ts new file mode 100644 index 0000000000..14ffa6f1ac --- /dev/null +++ b/blocksuite/blocks/src/root-block/widgets/slash-menu/tooltips/now.ts @@ -0,0 +1,14 @@ +import { html } from 'lit'; +// prettier-ignore +export const NowTooltip = html`<svg width="170" height="68" viewBox="0 0 170 68" fill="none" xmlns="http://www.w3.org/2000/svg"> +<rect width="170" height="68" rx="2" fill="white"/> +<mask id="mask0_16460_1143" style="mask-type:alpha" maskUnits="userSpaceOnUse" x="0" y="0" width="170" height="68"> +<rect width="170" height="68" rx="2" fill="white"/> +</mask> +<g mask="url(#mask0_16460_1143)"> +<text fill="#8E8D91" xml:space="preserve" style="white-space: pre" font-family="Inter" font-size="10" letter-spacing="0px"><tspan x="38.0488" y="16.6364">now</tspan><tspan x="81.8574" y="16.6364"> and time.&#10;</tspan></text> +<text fill="#8E8D91" xml:space="preserve" style="white-space: pre" font-family="Inter" font-size="10" letter-spacing="0px"><tspan x="8" y="16.6364">Insert </tspan><tspan x="57.8047" y="16.6364"> date</tspan></text> +<text fill="#121212" xml:space="preserve" style="white-space: pre" font-family="Inter" font-size="10" letter-spacing="0px"><tspan x="8" y="34.6364">11:45:14 Wed 3 Aug, 2022</tspan></text> +</g> +</svg> +`; diff --git a/blocksuite/blocks/src/root-block/widgets/slash-menu/tooltips/numbered-list.ts b/blocksuite/blocks/src/root-block/widgets/slash-menu/tooltips/numbered-list.ts new file mode 100644 index 0000000000..f29bba2a77 --- /dev/null +++ b/blocksuite/blocks/src/root-block/widgets/slash-menu/tooltips/numbered-list.ts @@ -0,0 +1,27 @@ +import { html } from 'lit'; +// prettier-ignore +export const NumberedListTooltip = html`<svg width="170" height="68" viewBox="0 0 170 68" fill="none" xmlns="http://www.w3.org/2000/svg"> +<rect width="170" height="68" rx="2" fill="white"/> +<mask id="mask0_16460_947" style="mask-type:alpha" maskUnits="userSpaceOnUse" x="0" y="0" width="170" height="68"> +<rect width="170" height="68" rx="2" fill="white"/> +</mask> +<g mask="url(#mask0_16460_947)"> +<g clip-path="url(#clip0_16460_947)"> +<text fill="#1C81D9" xml:space="preserve" style="white-space: pre" font-family="Inter" font-size="10" letter-spacing="0px"><tspan x="10" y="29.6364">1.</tspan></text> +</g> +<text fill="#121212" xml:space="preserve" style="white-space: pre" font-family="Inter" font-size="10" letter-spacing="0px"><tspan x="24" y="29.6364">Here&#39;s an example of a numbered list.</tspan></text> +<g clip-path="url(#clip1_16460_947)"> +<text fill="#1C81D9" xml:space="preserve" style="white-space: pre" font-family="Inter" font-size="10" letter-spacing="0px"><tspan x="10" y="45.6364">2.</tspan></text> +</g> +<text fill="#121212" xml:space="preserve" style="white-space: pre" font-family="Inter" font-size="10" letter-spacing="0px"><tspan x="24" y="45.6364">You can list your plans such as this</tspan></text> +</g> +<defs> +<clipPath id="clip0_16460_947"> +<rect width="16" height="16" fill="white" transform="translate(10 18)"/> +</clipPath> +<clipPath id="clip1_16460_947"> +<rect width="16" height="16" fill="white" transform="translate(10 34)"/> +</clipPath> +</defs> +</svg> +`; diff --git a/blocksuite/blocks/src/root-block/widgets/slash-menu/tooltips/photo.ts b/blocksuite/blocks/src/root-block/widgets/slash-menu/tooltips/photo.ts new file mode 100644 index 0000000000..1b434e029e --- /dev/null +++ b/blocksuite/blocks/src/root-block/widgets/slash-menu/tooltips/photo.ts @@ -0,0 +1,20 @@ +import { html } from 'lit'; +// prettier-ignore +export const PhotoTooltip = html`<svg width="170" height="106" viewBox="0 0 170 106" fill="none" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"> +<g clip-path="url(#clip0_24732_2238)"> +<path d="M168 0H2C0.89543 0 0 0.89543 0 2V104C0 105.105 0.89543 106 2 106H168C169.105 106 170 105.105 170 104V2C170 0.89543 169.105 0 168 0Z" fill="white"/> +<path d="M168 0H2C0.89543 0 0 0.89543 0 2V104C0 105.105 0.89543 106 2 106H168C169.105 106 170 105.105 170 104V2C170 0.89543 169.105 0 168 0Z" fill="white"/> +<text fill="#8E8D91" xml:space="preserve" style="white-space: pre" font-family="Inter" font-size="10" letter-spacing="0em"><tspan x="10" y="18.2727">Insert a photo.</tspan></text> +<path d="M191 28H10C8.89543 28 8 28.8954 8 30V113C8 114.105 8.89543 115 10 115H191C192.105 115 193 114.105 193 113V30C193 28.8954 192.105 28 191 28Z" fill="url(#pattern0_24732_2238)"/> +</g> +<defs> +<pattern id="pattern0_24732_2238" patternContentUnits="objectBoundingBox" width="1" height="1"> +<use xlink:href="#image0_24732_2238" transform="matrix(0.00617283 0 0 0.0153756 0 -0.193986)"/> +</pattern> +<clipPath id="clip0_24732_2238"> +<rect width="170" height="106" fill="white"/> +</clipPath> +<image id="image0_24732_2238" width="162" height="78" xlink:href="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAKIAAABOCAMAAACUqj3QAAADAFBMVEWDnWNLaSgbODEfODQ5Wm8sQiw5W3MeNTgiPD9AYXkjPkI2TkMsRD0cNzU1U2drhZo8XHM/VDg+XnZdeo8vR0FhfZEgOTdYdos2VWk/YX1EZ4N0jlZEXVNab0c3V2wkPDdog5lrhFppgVlKbo5kfIxVbEIgOTs7UDVGaYc4UkVVe6BSaT5DVz1ddEsxT2FPdJZYa3Vof49pfogpQTs5SzRHa4pmgJYmPj5jek9McZNidoFXfqRMcJAzUWZAWFBNZEg9UDxdb3pqgpRAZIF0i2I6VEpthF5ngFVkfFRUa01Qdppxho1Zb1Bgd01PbIJDZH1rglIzSkl4kKNvhZKHnKtvh5xSa3pRaHQ5UmEvTF43Tkxth0sxRDFli7FYcn9YbnpleoVcclUySkJNZTv3+/dwlblbgah/lqZTeJxgdlVmfk9ifpVeeIhIXWhviFxviFVxjE9QZk4tREMpPyhJXT5qkrxtgos7XnpBWWhbdIRTcYNMan9fc35mjbdTZm9NYGtlelxObYg5Sjpeg6tSb355kllRZkVLXkT09vBkhKN3kGXt8OxVcoglQElEVkUpP0M1RzBbe5ppfWVKYVpvi6Jtg4FKZnoySlNPX0scNTBDWV1UZ1ZpgkjU3+Hf495aj9R+ocRuj69/lmvm6ueUq7qRp7VliKtMZHErRU9XaUnw+/lzmcGNorF1i55qeHhMZWE8UVR4iZGFnnJsgHVvh2N8lV7P1dRZi8OBnrVXd5RSdI9leWxUa1+qvMZjfEOWsMGOpXs3VW9gdWFbfqB5ioZab2R2la9HXzV6m7eBkprc2daJpLu5ubastrSKnJ5hgJ1cdXhjcG9bc0CVo6NFYHNRZmhJVFDEz81+kZBYcW6+xL97gHzY5ulcZF8/Skijtb6KmZCJjYuMpmvj8PLD093IyMOhsbSerKoxUnaxx9S1wsSmqaVQiNGet8eYopd3i3iVsHRwiHNrmM+IqcmVrLCftoaClHyjvtcpOTFHdbshNiKsmpw2X59XV1GHXWFZMTJyqZ0aAAAAAnRSTlP+Xwd4TW4AAB3KSURBVGjeXNZNaNNgHAbwiAeFVhSsu6Rl5sUORCL9ABd2qFiyJKMrpAx1tuDWepAGTeqQscHQHcyEHDbYqIr21Mv0oM4uPVlKGQ7xIqMqzJPSg+JpG6hnn3+6+fVsbUgd+Nvz/t+84/a107qls5DBNCYYp8SQGA5JknDqlFqtVQVZUxVVkx1ZkbRhVcPPMNdlruKy2dlZLc9cphVrrZpLn+5s7ey82nxTLm++e/fpy9u331Op8uSL1GQqNZCMLo+Onr506dLKh5dra2vrjxqNpaVGoxEM4vJoCXfBpSBuHgUHfX5/r98fXJ+fXwv2HqBwXLPZNixD13VVNZiYBjAUkgTFrTrVmsBcUxUEQZYVxlQ1XGQMd4qp0IUxMUR3NRjxS5imI8P4+dWzV5ubb79/+fbpe6o8PjkeS02euDDlCUcffoRwnYSN9fXnS0sJsNbXG4lEIRAoBPwFvz/es3///t5CMOibbyT6d4mcXa/zKNLQDUkXDUkTBEmCqlqtGQY+xY1pmorEEEnUJFVVBRM+U5ZNTRIcp9Zq/cBb1XEc2d3Z+vzq1efNNyjx7dvUx6mp0anrU6MAIqMXP35sNJ4HE8/Jlhj0+QoFXwJdBvwTAUomkwlMxOPxiWwimJh/EJw42NfXd4DLcdtcx86VrApv6Xo6dEVghhQSJCbLVVW3ectQDYE0mqClmapJMIpoltygqqYruNVWq1VryTKI7uLWz89ft1bL5XJqY+Pi6F7Q4empD42ND89XVrLZbCGTzeDi8w1iqYOJlQQyOIi3lWyG4kPmE9mF/v5+ECsl7sd2Z7tTyvGWZeltQ5BgUFX06Ji2hYZVlRqDSsUUKsw0VVUjpSkRVQiLTEaJNZMJrixjTn/+pKksb0TGN65PUd6/f798797y8q2NgY1bt259+PDh+XMsM3AUvMNGRiQbQCZQasE3P59FvZkFrpTLlXIlntvervO2bSGmjv+dqd5SYwJ4x0FdDt5VLLfmojmVCeG0hHdSK2lRYvSzNUVlMgaYunRXX6UGBlKT49OUa0dPINcGZmY2zkU84R8i8pt40pelcYz3L/gnCr6TJwd9hUB2givZJR5M3q7zNJZ8ybbFtAEGqnNqdTTL847s1J1qtSqrV0IC1hr/JoXSmsQURRUUhZwKlArTFFd2nFbL2dnZfPPmRSo1koxeoxBxfCASidy6dfs2dNeX/ghx2S3RB5I/3o8sTAR8JxFfwOfnbLuEFiuVUrMCXcnO8byVTquKzARDcLCTEMj5ehXT5ohhRgOpmKaGDa0p6FsRFEcWGJiyojHHdWgyt8qbmy8oI9Frl+/cmf5NJOP1pYdI8K90hSchDPj9u0TUiBQCca7E28CVvCptHgtex1DiEcTLvG4YPAcdfJ0q7VgspsY0U8YCu+hQDBUV3CkUPCYXXbfKZvOus/XzJ4ApAFMjI7HpOxcuXCbh+MC5yLlIBMTrD5ceBrtf3RDQI9IcEjHup3HEngmA2MQoVproLlfJgVmyOz+2USzaxGTydY6vWxZX69Rq7bbh1GqygNZUhgrTGhsaQqeKpjAAi7OLi7Pu4uzizg72NC1yil6p2PTYhQvTJ6JREGdmZs6BCONei4ldIgmJWCh0iQvxCewZ6jTu50oVMqLHHLDeVNLGoT3UXWKuXrdtrm7X9VNt3XSqioptrClKupifLc4NF7WiKBSRPIz0tbi4s4Vnd2pyEj4izhAx6hERqhE9gph46D1r/iHCGABxN/FuOKqu3a5Uck2ygoaJ5CGud7broNa3tzkL4S3etMRTklOVGYBP2Nzc3CIbulmkzCLFInSUVRBx7CFd4sAY1jmZjGKlYzEs9gyQZFyhJHbjCUH0UYsH+w/29fT09fV2wzXblWYTQCjBA5Rm0ga186PT6XD41g0y6hZOOCFstqpVbN+54bNzc/n82aF8l0gh38vF1dWdcgrEWIyIuA5cvjwdxc6OxmJRfDrgrTVa9B7hgysrpKO9QiHiAoB9PTj4enp6e3tAxEJjmZuVdrONOazQYts8Bxqe5hz6pInUERLiKYRti31bLA4PDw0N5YeHhvNdI62wR1xdxcFC5YEI48hkbPzatWPJw0kYo2gWPY5FUCIJ/w7x8MpgnXHmUQiJ7N/PlSzQeP610cRTmkR8CTu43qnbuYpl84iOw1uSmCmAqGmOzGjy8jAOgwgjjSFCQAiJSOWRERt6JHriRDR5HEkmk7ilpb4I4ujKyn0cgRm8EDrxQESHcQhB/J39INpcB/vWbhuGofNkLKFTvsLnvHItHC+yTs9p1WGS6YiSrJDwDxHGLnENwl0itUXEEQTlJc+cOU45fNgjjmFPT6HFh/fv38/AmPHilRjw9/f+ER6i16FDHN/ZrjRLaKwZNmjq+FyzSda6VTEMScfBhrRkQdNw0GlhDcsMHzLkZQ+5trZHLBMx9puI8iAEj5LEKI5hR9/C2X17efS61+UEMQsFnHeDnhFzuGfsQQ5xdJpgBCu61ZZ0STeaTVpy3ZDxt8uPH/Rdk1Ut/GROLGqIMktAEt64sWfMIyC+RDwh8U7EotE9odfg+fMQJsdnxiKRsYvL15cvLk9NTT8duXj/MYRochBCJBvo7/V4fRB2wzUr8LQNXZdNwzIsXdRDoRCAwIGHDWypUhgfoULvUH7SBQ7dOAvj/0QIu0AMYDSK2YMqSUAQD585cyZ6bebyGIRjl6PTF05cfXrk0mMkg2RpS3vGzMKfGrvE121DSl85JeksLUliWsGeVSVJrjo1JR0Kh3RDNERcxS6RiXc9IRHPgkj5QwSwSyQhcL+osLNYF6IwDuAexRJLjT6NLSYe2sot0kpfqAgeVCg1eJgpvdLUFEE1bRUl0jZpqUhRaYxlSE2EREhFrJFQqnaxEyKVIHiwJcTy/047yufeLvfp5/+d78ycYc1lTQbT4eDoz2FJlrCZh33BoF2S5u3ceYhW5AKDiN9ZTgqxw+xhsUQi6Yg1Epp8Qk/rET3E2ywBwcbDy/MWG9YjPtg8Hs+0aVOHj7ClM4yVnoBaQdWOkYRHiWhE6CCiUQRUTCa6IwtP8vrss73B4JHw3KUU4ubCypU01yCiyDnIiRyNQor7J2esW7euWmXVp6etEetCXbAI6KqHZecZvwRlIyFCnOYJTQeRCVkxYrk8HUJGpEEBEcWxFIcZQofCKZzDATauM+FJR7xgTjpiFw9t3rx5ZQG1skOkwaaR6RCn66WSddX37+MWpqyRNJwCH+BBmopNiDbEJVPH4xtLMcTrwLWBGSPGcrm88NIlEvqxW0ftTMhxCkQG0cFxJpPZbOLwB+TnnTMniBPX6EMQFnr17NWrl7MwyzCy7bEPBhlbDv2AmNFLE7du3TqqBB2mAokhPhuuJssn71+2xIb4PLZ2TdfT0BEvEyGhAcRp7vbto0ddfhoRO9cuJvQBiBZzisIGRhnmRYCT8J+QK0vnUYa9evaE0dmrsHJWbyakasdoEKeFto4bYB0V0XWbIAgWbNJYgyNmeu4IHtzOIE8S8ihdJ6FBhNHIsCV0ufx+WoNchwiVr1j0YSFyJjNCpO9EnI1VOG8nMiQijIgRb84+vQlpEAd3tu4BAyK4aYGQF2oWSEDCC5IU9A9fUxiU8cRLYwksvHCiHSL5jDaT8NEtAvqpw6zYm6M1yjQmCrQOSrToPYLdplIRsfPIO/8n9nRiD28bF42FcTB4jFgbIMBjQYQ8DTKCNFjld49fpFKeKSGeT/OhyV9TXz+U2JBQjOxDuQ285Qq3y4TiTCRUYCpSITYHvBA7FPtI3OBiz5ZcLhGbNgl7GUJ6x3EAQCj7jB2Kq3PfXVQ9AhZ+qicwfrxQAw7bDLo9fvIyj8BPLzeubwulQl+3bfuSSg0P6XoqQwUeqxIixFOFR49uJeOki5tRMFKxPvcr+tBahXkpUvR8tj2ZlEUQcY5xVXYaRAKycjr7wEidJqNBtAieER7extdGBQLwoXg84dEFffqTd48vXiz9/PX755OFOvpc3pZZUaICkoAsQUQYD+Mcmkgk8n+NhILKcZnFiG4X2ZBI7mjUzYiiO+ka48K8gMeUf4nOQQCyu28Y28Rzd2zYCQOBQC3QMtqG2ywCSOXT16svPnx8+eTXj2Zz27NnF8vHn70jYZk5ISRf0gVfHsBNLSIJCYiu9vNevlz00dQMM2+aK8tdcCVxzy3GllbkJA4J63cS0eAZxEX/GFvEO3fuBARLgHxtIo97htLHl89e1rPNZqPxo66p2oeXjcYDVVWflbaUtpUyAKLJLaGEQ2hi0yZGZAUi2qrM2GDCpDiG+XycGTfeI2fjTtGNELvWx2IgSjjRRNlq7Ahpg0SKjEjnVRgZ0YL8LLUa8bAuMS/pSLp0+8Xjt7lctlmv16vd2VxWa6iPtWxO/fD1ZVX9cPzjpTM0JZIk0RF07po1M2Z0hCiO3hRFwQyjx0Gv3SXPvupHivjXtTS2XpTnrl86N3heImMnw0IBB+hF4JEQRBgZkbKzUIgWXJtRaex5pbuPd1/fvbtbbTJnVsup9aP1nKbW1Xdarnnp+PZbAFIhjB1rQEzk83+B5kQeI3zz5rBhxeAcLy7LY1xd6/1EdIui6K5U8Lo+tvTIm2/XBtJyNJZiYSUVnuItaglRY2EEUQCOgK0I09iWI8+qT7VqtVqvNlUV8WnvmvUchLlcTmsi0g8vHsrMh/7GN6FoWPBDQuzSeTNL0dHPYR8pVSqyi1ocPTYGCeKhkyy6kWGsf/79m/ef9s1Hiu2ZcY4lIVyoQX1AJORQmmgbb2kVVmEaFYlYrc9eHr19dOOzJ09eqo+zqob0slnkiWJvuesPZRkNlvCsBkzyzUAxomJKJOijKT8XYXWJIPrZ2dTvBhDV1eXyi7EZyuVPn/bu3Zs/xHyFzYX2Iwh67ERII8ahICI9gYKkl0AmQ8L7z29cvd44+qJaV3P1bqzFbti6u+lVVbEk62/vyptEcaR3pMSEBFxDrTYpnBmfITWb4xKmYz0egYqi7KJxpgNBVxKXoag049S+K/ChrgzZzIhoMHMxmEHEOR+txr7IgHSXiGoRn5zd/fTpK7QVMNDefYBOU7P4Sr/1xuvPB6S58YMHZ8fntoXGuJjgpMuLw+c7ggNqEs9AYxUYQXR3gRmNHsOJy7Tv8OHD8FH1Q4zMOIstwI6RhMzYw+gyhJgTJtxy/O3u3U81jRFRdS1XrT6udiNAAjbe7/t0OTHpoDcsQojCtBCQZXfKRFe+SXbsMUk3WCCKICaR3hgcXaN+HLqKlyFsGT99ura0FSOMs0gJp0HEMZ+QPVqjXKtBiAKQEau7dxNRy2ma1t39uNmo55CkmlObjXdz1u45ubbIYZYh3EG1CW1NALqGWm2KxyVZXOd3yyCKsXszMDdebz8unMD+JIftw/YtXrx4NYgoGK8F55GR9pyVRopwwYgrIJBE/MOWvcYkFYZxAG/VWvdizWqtlVsummOTzGqZNWzrQ63VUrLLMmtl1IhvWXZBAl3trIuxmJYNkQXYCq2+eKDGWKOIQqImwRSVHESh4Qd1Wau2/s85gLX1l8tRv/z2PO/l8PIKQAgvp4Egvu/ydQ30DAz4+nzhga5jxzqaAUSu9nR2hkudrOGrclUA/TtUV9aAJQez5UIAx8W4yMGNjEKhqPlQk19cXJiz+pRc5/Z4aMxVGhmG0RltLfClAqrH5jWVUqvp4H09MpuGIF8+EDnkpFdHEdxrCQQACoUCIr583dUe7nwx/GmgYyjUgfKBR8/oYGlbzjwP+5XdDuGhnFMNENJ8Dkyt3ktrZL70ycUaFeaESnwGN2c6t7uykm8pXithwm+wKemhTBHN5or7IMJIWY7BBxzRFmaIr06fPnJph0gIoiBF7PN1dOjvXX3w4MGxdHAdjclOMQ91llrWUHVh7x7Z6py6BtpWds6YH6irwxLTuhdrtLimmJsZpTojOkohJGSZKFMp54hWq9l8+H6aiKOm9bDxOHpdyBFfXT55WXSzgIgCIX44or6ZFmqsiPrmjjdEjIZKFzH+cna8imVZS8yRBLCBdj71kiXqnXVl4m6cJqlUKuhIWKjjeVV86f61pVLp8Xi9EJpM4noYU0gAJyM8EVyuituuCIU3b6J+AhgLhKJvdz9+fBFuf9/X9zsY7Hvf1wNgoqRswQK3p8pgYA3sxgvmhOwcjcOd6qKiPLtdnnWq5oO4RqEAsZCIh7KM0BGPkvb9SzR6POizFcL42/v1fxPJiPqhhmuWgyhAowFDFUEUCSC88fHus2B/fxDP2d8Pan4HO3rHW477tXLvuJI1GEaqKkedSWf+XhqI6iKtC9Ngz5aLHx6JixUw1uTjALFsEWM0YsShkS02T2UlWHhAxf0gtRaLxe312mxSEOORSHE91ZEnwggk3+zdcE56hVmSISKiK7fD4fBBTT+As79/p4vxyhEDu8rTMlrFAtgy7kAJseIEps7fpbb7IczOoa9Tii8qyHgYO905v8UITKXXidjcmdbir0i5xcIwFqPN6TRZqc8OhyPeWF+fIcLI587C3WtABArtxadUvONx48rt2+Hh4e+aYF+wXxN87/OFpaMGtNdSzgI4onRLrfGxwkNlgblTG7DT+eXnyw5VYIERE7EJxouHz2UxFlTM6PEmk0mn0+sx8r5yo5vi8RhBhDCZNFGImHjCE5EJIhpNq+MkoQARYipTJIIbSLizp7+/L/w+GBzUP9C/tbFfYRxhDRCO2hKxwbH8Vtp6K+gECYdcCtrdDos/iEHEfDm0wA8hTQZT0gGhyeQ1WtDa2lpLudtjs9lMthKd2+aFLAIfGh0BMfG4fubMtBDPlJOWRxAnIkEdNZrgsL7Z97m9a/DJ51gsmjAq2a8wopAjLS3JwVj8RaFqX3UdBh+fpkdipBj3CMUgtpZlMUwtUv7QmwTCaQLRUoscx/gzuml2SHEeYXZGIDRbpTKZ1RyJRBwXQaQy0mdTEvJrDi3hIArTNRRCKNH0v/dxi2Aslhh/WK5kAURYduShzRnrDb3NLxZX7AzsXbr51i1O2KSoqKjgvqnY2xposPsJyBndTtSQIzLHU2F0XqfJDJbUjNKZpMCWlOAXULfsp/mSKt8EEUv5X0R6uyKS3Hod69UnxkdbHtL8/fHDQMCqUYt7vFcfG3yNY9aK1sC5wxzwQ2trK7dD006IoWnXEjDlsVBXKUbXRiQ31+VyMV6n2SojIgagWVbShjsiGK1WaVbgOhEJOGcKes0TgZw9CTSEF9KyfWVrfZOq+uyvH9RcEH+hw8o8v9cRi94LxV+vrmtVNany33U/etR9MUcu9zMUvxZxuY4fTwPJdNxC/6En6XLpRau1OSF0y6RjjkRESie3e+DFtJZm12GLwRkEhHOARMjKE9NGIUVSIJm1sr5x/xcQ+Unyi83dzmyR6qO9UX1Xu0pR3dQeHgq97C48tUVnSQUwnsbz0nFRCJ9LOXEiF5dumy4bJztSZyIhnUfHeW0oIRpdsu5c9fX1aDDJKMTjNhmMRZEoYywo0Iie3Xpc/3guqviDHcEac2KtetmpRC98g13djw887m8fGAq96WjP0TGE+78tNxUIc7UnKNp0/H673a7NljnHk21bICwtkXLC7KxrO/djvlAV+RCQjGsm3RRdOU1GTlgg+RzKVzSWXcs7UatUGpkFWavREv2DB/qx7t27GxsPNA0MdL7p7Pm8hfmrchlZ2kcoSuaSs5HQbs8rKip6qtXZvDruuLGtRErGtgVP1erAdZ44GcDJ6WADpL1ZmCZKPkaeFIoVhThG9TodMb0+GqXJHSkNTN1H35K0D/180zPs63zL1NKwy+h4GpFItT0d4Ph3rZ/BAY9frrU/LSpaAaPdTkei2XvaXsuIaN1zTa1Wo4wrqb0kzIR2F4mmABFy+9+NF0/w9R1mRq8eNtyLoX6R0obnc59PWwzii+aejs5hX3zQ6nJN2FIwPrwtLy9vQ95E7HSog9mbLX9atIKLGuGIEL6VSkvkajU+jVdfT1cRtoxx0mkQbx4skIAokkiCNfFYL2wIbsCaQ5G3qsXPv1CeNza+br73c2g4Pmabp9ZihuKBZHhpG/HSeVr0lCKn42MsgbpstHPt2rUr8AQxK3sPFhwYZW3n1KuWLLnQ0Dg93egpfxEFuIPAQ0LCAk3/wD3OF70a1YdCXTXV+/bv54WbmuK9zc0/hz6OuRkXdBOBLpV/eMifPu0ntGkojgO4SKvOqZOG2q7IKCi1glSKc6JsEuZx0OKfyipB1MsOnYcpWF1BSBBEqHGia50yL152sHVDGKzgIVgPBVspc+3U+e/gKhP04kH04vf3krxnq/jtGjp2+fB9ee8tTXqps8KLFwk/UbAK+j1qMLgXCSKQ+2NEHCrl3KoPyby8TkQ2vMK4CmcgatwhI92apulvPnzBtdXkbCO/kL886O2Khxix7+yTB/fvz/76Ujn2FGcgkwmanSYcQo0Vrr2QEpi3IMZ6JFCCe9EYkk70uDuG8Afq1+PzRZFQwFp1IOREzBEZb13WtG68FUXR3izXV8anp0MXjzsMIx7f5O38bFzClvOcrv8WpacEFHNi/35LRkTIenEgHYUa671ZSHtuoywIexIRH8shvNWEW+oYeoilPJfrSJOwM+qiGhmyhajJV+/o8phMpyWMPzZMH74xNRXaZBjG5/imwcFM5uHkUv35p9nX9cbwHHwIr446oxCxwMsjnxkwI6oHX5K5pYQfxCgJDwGUkiQpNvTwWCzsTngi0Wgb4upbx4xMKIgY310jI1dlRcOgK4pT+TExsTFwPR7vMxDHscsnzj1cWpot18tfPjWOzkGHkM0KaqNlBNldsMsLivjo7YuokVRE8rtT0eghAFlw4S3lho5sH42kUgyYdLk2T63bspqNsiDSbJb1kRG9W8G0GWPEnxMb164Hsa8vdPbU+/yeH9XJydefGm+ely9Ic2LaUn20zDHhzRe05onyfC0hk5pGWSxteMMUTY26ezyRTIYKdCVdIG6OrxM6YBlR1vGlK+4POeGD0Ok8/5NanIqH4tkbd6er787U8iB++Pi6MT48Z/t6GY+FrrCwcWyzfOxUO9ScKE8npY0nk0KFGQCJR3E5sjA2t9gt3xk5fU8notMJoU3MXsT13EJ1vLg4j2vW+ofi68bAcAH7F3hcRywYC3OFgmr7OK+zEz8swsV1LjNtbXjmrY3p1rBsdlzE6tg8XWSZStRlDDSALD/3bNg3ffnAQHGxMt9oNMors0v5r/XyADrk5UHHg8+2z6S1huNodIURnZntCaED6RqcWtdE3CVf1XVMam1MA9GMdnADHoTEbbPieH5xYeZVfWm5PlvqL6BA0R7CpwSCIweaHhuHCBhodsBiOIdDCFlQZADGiQneokxLzQ5trHvMJiq6cvDMu1vTh5+9q1XPjT9amWw0vg+ne/dvs4VBPqxNQ4skzbBBTCbtwnC0XCJsXKHiMbAOo8Quh8M7FaDVmxNJZk5np52rys5bC9VqFSV+qDSWV+pvywPpgjXEvdtUqz+BE+MLHb1YuI2H10U4vCyhQTajyzC6rAyuxz7D10VNOc+ICoSC2P7oVTU/XqTTcX65XK9XTqVV81+UAiaHahZILtuWtBt0ASiqaukOJiv8A/Vm4CXi9Xpxp3U99mlGpO5M4nnEJupa+1a0+NgkzjdWlmfCaZUSVAsJv6TCx5cQ5kvaIRZVRW862+zTzdESAfo7m7zY07L7bKKTYArSRNTbdx6s3XpWe5enFOcrA2FPOp3GHqF6/B1SJPWn0Dr7IOPhRDYX/oXjnOZ4zSMzrsfaAyKEjIilRhCxnMvt7ZqmKT9qtUcLMyX8pxuWcHMetlF3zD8cSSERH9u1rKz5dywVXgLXpOtDrA/0Y2cQiV8PAAmi5eIlwjumYZ7r374VH3/8iHGu0FOxYdy/xXYfkWK5/ktX2BfwKWtXaFVxGo58QIVO+LgRutaAGMreaCXiYAq7NTLewZd4b99WihfwSCyCpzCkyCiEII5eGna7JRAtYRIvcf6LSQGhcHUJmIhtCyE4mp95sutAJBnRRIkkJGK7rN/T9VpxcaZUKuVyuVgP7gp4OvAhjKcg+vvdaeytfBWBisfY3AQUabaF/h+0iHkd4EQWU4hlCGmXqUcQ8yXKTC7Wfwlfing6Yrj5HUap209mzM1VCHHGsRiOf0wI64QToQfmkXhz+O/mn7LnAr8Byxgcn8HVQT0AAAAASUVORK5CYII="/> +</defs> +</svg> +`; diff --git a/blocksuite/blocks/src/root-block/widgets/slash-menu/tooltips/quote.ts b/blocksuite/blocks/src/root-block/widgets/slash-menu/tooltips/quote.ts new file mode 100644 index 0000000000..2bdd7e8933 --- /dev/null +++ b/blocksuite/blocks/src/root-block/widgets/slash-menu/tooltips/quote.ts @@ -0,0 +1,13 @@ +import { html } from 'lit'; +// prettier-ignore +export const QuoteTooltip = html`<svg width="170" height="68" viewBox="0 0 170 68" fill="none" xmlns="http://www.w3.org/2000/svg"> +<rect width="170" height="68" rx="2" fill="white"/> +<mask id="mask0_16460_920" style="mask-type:alpha" maskUnits="userSpaceOnUse" x="0" y="0" width="170" height="68"> +<rect width="170" height="68" rx="2" fill="white"/> +</mask> +<g mask="url(#mask0_16460_920)"> +<rect x="12" y="14" width="2" height="33" rx="1" fill="#C2C1C5"/> +<text fill="#121212" xml:space="preserve" style="white-space: pre" font-family="Inter" font-size="10" letter-spacing="0px"><tspan x="24" y="26.6364">In a decentralized system, we can have a </tspan><tspan x="24" y="40.6364">kaleidoscopic complexity to our data.&#10;…</tspan></text> +</g> +</svg> +`; diff --git a/blocksuite/blocks/src/root-block/widgets/slash-menu/tooltips/strikethrough.ts b/blocksuite/blocks/src/root-block/widgets/slash-menu/tooltips/strikethrough.ts new file mode 100644 index 0000000000..cb34d9dd4d --- /dev/null +++ b/blocksuite/blocks/src/root-block/widgets/slash-menu/tooltips/strikethrough.ts @@ -0,0 +1,13 @@ +import { html } from 'lit'; +// prettier-ignore +export const StrikethroughTooltip = html`<svg width="170" height="68" viewBox="0 0 170 68" fill="none" xmlns="http://www.w3.org/2000/svg"> +<rect width="170" height="68" rx="2" fill="white"/> +<mask id="mask0_16460_986" style="mask-type:alpha" maskUnits="userSpaceOnUse" x="0" y="0" width="170" height="68"> +<rect width="170" height="68" rx="2" fill="white"/> +</mask> +<g mask="url(#mask0_16460_986)"> +<text fill="#121212" xml:space="preserve" style="white-space: pre" font-family="Inter" font-size="10" letter-spacing="0px"><tspan x="8" y="43.6364">Any user may have a different perspective on what data they </tspan><tspan x="8" y="55.6364">either have, choose to share, or accept.&#10;</tspan><tspan x="8" y="71.6364">For example, one user&#x2019;s edits to a document might be on </tspan><tspan x="8" y="83.6364">their laptop on an airplane; when the plane lands and the </tspan><tspan x="8" y="95.6364">computer reconnects, those changes are distributed to </tspan><tspan x="8" y="107.636">other users.&#10;</tspan><tspan x="8" y="123.636">Other users might choose to accept all, some, or none of </tspan><tspan x="8" y="135.636">those changes to their version of the document.</tspan></text> +<text fill="#121212" xml:space="preserve" style="white-space: pre" font-family="Inter" font-size="10" letter-spacing="0px" text-decoration="line-through"><tspan x="8" y="15.6364">In a decentralized system, we can have a kaleidoscopic </tspan><tspan x="8" y="27.6364">complexity to our data.&#10;</tspan></text> +</g> +</svg> +`; diff --git a/blocksuite/blocks/src/root-block/widgets/slash-menu/tooltips/table-view.ts b/blocksuite/blocks/src/root-block/widgets/slash-menu/tooltips/table-view.ts new file mode 100644 index 0000000000..541b08efd6 --- /dev/null +++ b/blocksuite/blocks/src/root-block/widgets/slash-menu/tooltips/table-view.ts @@ -0,0 +1,86 @@ +import { html } from 'lit'; +// prettier-ignore +export const TableViewTooltip = html`<svg width="170" height="106" viewBox="0 0 170 106" fill="none" xmlns="http://www.w3.org/2000/svg"> +<rect width="170" height="106" rx="2" fill="white"/> +<mask id="mask0_16460_1148" style="mask-type:alpha" maskUnits="userSpaceOnUse" x="0" y="0" width="170" height="106"> +<rect width="170" height="106" rx="2" fill="white"/> +</mask> +<g mask="url(#mask0_16460_1148)"> +<rect x="7.5" y="26.5" width="169" height="84" rx="3.5" fill="white" stroke="#E3E2E4"/> +<text fill="#121212" xml:space="preserve" style="white-space: pre" font-family="Inter" font-size="9" font-weight="500" letter-spacing="0em"><tspan x="17" y="42.7727">Untitled Table</tspan></text> +<line x1="17" y1="50.75" x2="176" y2="50.75" stroke="#E3E2E4" stroke-width="0.5"/> +<line x1="65.25" y1="51" x2="65.25" y2="110" stroke="#E3E2E4" stroke-width="0.5"/> +<line x1="17" y1="63.75" x2="176" y2="63.75" stroke="#E3E2E4" stroke-width="0.5"/> +<line x1="17" y1="76.75" x2="176" y2="76.75" stroke="#E3E2E4" stroke-width="0.5"/> +<line x1="17" y1="89.75" x2="176" y2="89.75" stroke="#E3E2E4" stroke-width="0.5"/> +<line x1="17" y1="102.75" x2="176" y2="102.75" stroke="#E3E2E4" stroke-width="0.5"/> +<text fill="#8E8D91" xml:space="preserve" style="white-space: pre" font-family="Inter" font-size="5" letter-spacing="0em"><tspan x="25" y="58.8182">Title</tspan></text> +<text fill="#121212" xml:space="preserve" style="white-space: pre" font-family="Inter" font-size="5" letter-spacing="0em"><tspan x="28" y="71.8182">Task 1</tspan></text> +<text fill="#8E8D91" xml:space="preserve" style="white-space: pre" font-family="Inter" font-size="5" letter-spacing="0em"><tspan x="78" y="58.8182">Status</tspan></text> +<rect x="69" y="66" width="16" height="8" rx="1" fill="#FFE1E1"/> +<text fill="black" xml:space="preserve" style="white-space: pre" font-family="Inter" font-size="5" letter-spacing="0em"><tspan x="71" y="71.8182">Todo</tspan></text> +<rect x="69" y="79" width="31" height="8" rx="1" fill="#F3F0FF"/> +<text fill="black" xml:space="preserve" style="white-space: pre" font-family="Inter" font-size="5" letter-spacing="0em"><tspan x="71" y="84.8182">In Progress</tspan></text> +<rect x="69" y="93" width="17" height="8" rx="1" fill="#DFF4E8"/> +<text fill="black" xml:space="preserve" style="white-space: pre" font-family="Inter" font-size="5" letter-spacing="0em"><tspan x="71" y="98.8182">Done</tspan></text> +<g clip-path="url(#clip0_16460_1148)"> +<path fill-rule="evenodd" clip-rule="evenodd" d="M17.7102 55.3125C17.6067 55.3125 17.5227 55.3964 17.5227 55.5V56C17.5227 56.1036 17.6067 56.1875 17.7102 56.1875C17.8138 56.1875 17.8977 56.1036 17.8977 56V55.6875H18.75V58.3125H18.2557C18.1521 58.3125 18.0682 58.3964 18.0682 58.5C18.0682 58.6036 18.1521 58.6875 18.2557 58.6875H18.9375H19.6193C19.7228 58.6875 19.8068 58.6036 19.8068 58.5C19.8068 58.3964 19.7228 58.3125 19.6193 58.3125H19.125V55.6875H19.9773V56C19.9773 56.1036 20.0612 56.1875 20.1648 56.1875C20.2683 56.1875 20.3523 56.1036 20.3523 56V55.5C20.3523 55.3964 20.2683 55.3125 20.1648 55.3125H18.9375H17.7102ZM20.9011 55.3125C20.7976 55.3125 20.7136 55.3964 20.7136 55.5C20.7136 55.6036 20.7976 55.6875 20.9011 55.6875H22.2647C22.3683 55.6875 22.4522 55.6036 22.4522 55.5C22.4522 55.3964 22.3683 55.3125 22.2647 55.3125H20.9011ZM20.7136 57C20.7136 56.8964 20.7976 56.8125 20.9011 56.8125H22.2647C22.3683 56.8125 22.4522 56.8964 22.4522 57C22.4522 57.1036 22.3683 57.1875 22.2647 57.1875H20.9011C20.7976 57.1875 20.7136 57.1036 20.7136 57ZM20.9011 58.3125C20.7976 58.3125 20.7136 58.3964 20.7136 58.5C20.7136 58.6036 20.7976 58.6875 20.9011 58.6875H22.2647C22.3683 58.6875 22.4522 58.6036 22.4522 58.5C22.4522 58.3964 22.3683 58.3125 22.2647 58.3125H20.9011Z" fill="#8E8D91"/> +</g> +<rect x="17" y="66" width="8" height="8" rx="1" fill="#EEEEEE"/> +<g clip-path="url(#clip1_16460_1148)"> +<g clip-path="url(#clip2_16460_1148)"> +<path fill-rule="evenodd" clip-rule="evenodd" d="M18.8125 68C18.8125 67.8964 18.8964 67.8125 19 67.8125H23C23.1036 67.8125 23.1875 67.8964 23.1875 68V68.6667C23.1875 68.7702 23.1036 68.8542 23 68.8542C22.8964 68.8542 22.8125 68.7702 22.8125 68.6667V68.1875H21.1875V71.8125H22C22.1036 71.8125 22.1875 71.8964 22.1875 72C22.1875 72.1036 22.1036 72.1875 22 72.1875H20C19.8964 72.1875 19.8125 72.1036 19.8125 72C19.8125 71.8964 19.8964 71.8125 20 71.8125H20.8125V68.1875H19.1875V68.6667C19.1875 68.7702 19.1036 68.8542 19 68.8542C18.8964 68.8542 18.8125 68.7702 18.8125 68.6667V68Z" fill="#77757D"/> +</g> +</g> +<text fill="#121212" xml:space="preserve" style="white-space: pre" font-family="Inter" font-size="5" letter-spacing="0em"><tspan x="28" y="84.8182">Task 2</tspan></text> +<rect x="17" y="79" width="8" height="8" rx="1" fill="#EEEEEE"/> +<g clip-path="url(#clip3_16460_1148)"> +<g clip-path="url(#clip4_16460_1148)"> +<path fill-rule="evenodd" clip-rule="evenodd" d="M18.8125 81C18.8125 80.8964 18.8964 80.8125 19 80.8125H23C23.1036 80.8125 23.1875 80.8964 23.1875 81V81.6667C23.1875 81.7702 23.1036 81.8542 23 81.8542C22.8964 81.8542 22.8125 81.7702 22.8125 81.6667V81.1875H21.1875V84.8125H22C22.1036 84.8125 22.1875 84.8964 22.1875 85C22.1875 85.1036 22.1036 85.1875 22 85.1875H20C19.8964 85.1875 19.8125 85.1036 19.8125 85C19.8125 84.8964 19.8964 84.8125 20 84.8125H20.8125V81.1875H19.1875V81.6667C19.1875 81.7702 19.1036 81.8542 19 81.8542C18.8964 81.8542 18.8125 81.7702 18.8125 81.6667V81Z" fill="#77757D"/> +</g> +</g> +<text fill="#121212" xml:space="preserve" style="white-space: pre" font-family="Inter" font-size="5" letter-spacing="0em"><tspan x="28" y="98.8182">Task 3</tspan></text> +<rect x="17" y="93" width="8" height="8" rx="1" fill="#EEEEEE"/> +<g clip-path="url(#clip5_16460_1148)"> +<g clip-path="url(#clip6_16460_1148)"> +<path fill-rule="evenodd" clip-rule="evenodd" d="M18.8125 95C18.8125 94.8964 18.8964 94.8125 19 94.8125H23C23.1036 94.8125 23.1875 94.8964 23.1875 95V95.6667C23.1875 95.7702 23.1036 95.8542 23 95.8542C22.8964 95.8542 22.8125 95.7702 22.8125 95.6667V95.1875H21.1875V98.8125H22C22.1036 98.8125 22.1875 98.8964 22.1875 99C22.1875 99.1036 22.1036 99.1875 22 99.1875H20C19.8964 99.1875 19.8125 99.1036 19.8125 99C19.8125 98.8964 19.8964 98.8125 20 98.8125H20.8125V95.1875H19.1875V95.6667C19.1875 95.7702 19.1036 95.8542 19 95.8542C18.8964 95.8542 18.8125 95.7702 18.8125 95.6667V95Z" fill="#77757D"/> +</g> +</g> +<g clip-path="url(#clip7_16460_1148)"> +<g clip-path="url(#clip8_16460_1148)"> +<path fill-rule="evenodd" clip-rule="evenodd" d="M71 54.6875C71.1036 54.6875 71.1875 54.7714 71.1875 54.875V55.0625H72.8125V54.875C72.8125 54.7714 72.8964 54.6875 73 54.6875C73.1036 54.6875 73.1875 54.7714 73.1875 54.875V55.0625H73.75C74.1297 55.0625 74.4375 55.3703 74.4375 55.75V56.375V57C74.4375 57.1036 74.3536 57.1875 74.25 57.1875C74.1464 57.1875 74.0625 57.1036 74.0625 57V56.5625H69.9375V58.75C69.9375 58.9226 70.0774 59.0625 70.25 59.0625H72C72.1036 59.0625 72.1875 59.1464 72.1875 59.25C72.1875 59.3536 72.1036 59.4375 72 59.4375H70.25C69.8703 59.4375 69.5625 59.1297 69.5625 58.75V56.375V55.75C69.5625 55.3703 69.8703 55.0625 70.25 55.0625H70.8125V54.875C70.8125 54.7714 70.8964 54.6875 71 54.6875ZM72.8125 55.4375V55.625C72.8125 55.7286 72.8964 55.8125 73 55.8125C73.1036 55.8125 73.1875 55.7286 73.1875 55.625V55.4375H73.75C73.9226 55.4375 74.0625 55.5774 74.0625 55.75V56.1875H69.9375V55.75C69.9375 55.5774 70.0774 55.4375 70.25 55.4375H70.8125V55.625C70.8125 55.7286 70.8964 55.8125 71 55.8125C71.1036 55.8125 71.1875 55.7286 71.1875 55.625V55.4375H72.8125ZM72.8586 57.8981C72.9975 57.7326 73.2059 57.6276 73.4386 57.6276C73.7911 57.6276 74.0877 57.8687 74.1718 58.1952C74.1976 58.2955 74.2998 58.3559 74.4001 58.33C74.5004 58.3042 74.5607 58.202 74.5349 58.1017C74.4093 57.6135 73.9663 57.2526 73.4386 57.2526C73.0495 57.2526 72.7065 57.4489 72.5029 57.7474L72.354 57.6842C72.2983 57.6606 72.239 57.7093 72.2513 57.7686L72.3723 58.3507C72.383 58.402 72.4416 58.4269 72.4859 58.3989L72.9884 58.081C73.0395 58.0487 73.0333 57.9722 72.9776 57.9486L72.8586 57.8981ZM73.3836 59.2519C73.6163 59.2519 73.8246 59.1469 73.9636 58.9814L73.8446 58.9309C73.7889 58.9073 73.7827 58.8308 73.8338 58.7985L74.3363 58.4806C74.3806 58.4526 74.4392 58.4775 74.4499 58.5287L74.5709 59.1109C74.5832 59.1702 74.5239 59.2189 74.4682 59.1952L74.3193 59.1321C74.1156 59.4306 73.7727 59.6269 73.3836 59.6269C72.9868 59.6269 72.6378 59.4226 72.436 59.1143C72.3693 59.0125 72.3185 58.8991 72.2873 58.7778C72.2615 58.6775 72.3218 58.5752 72.4221 58.5494C72.5224 58.5236 72.6246 58.584 72.6504 58.6843C72.6712 58.7651 72.7051 58.8407 72.7497 58.9089C72.8852 59.1158 73.1186 59.2519 73.3836 59.2519Z" fill="#8E8D91"/> +</g> +</g> +<text fill="#8E8D91" xml:space="preserve" style="white-space: pre" font-family="Inter" font-size="10" letter-spacing="0px"><tspan x="8" y="16.6364">Display items in a table format.</tspan></text> +</g> +<defs> +<clipPath id="clip0_16460_1148"> +<rect width="6" height="6" fill="white" transform="translate(17 54)"/> +</clipPath> +<clipPath id="clip1_16460_1148"> +<rect width="6" height="6" fill="white" transform="translate(18 67)"/> +</clipPath> +<clipPath id="clip2_16460_1148"> +<rect width="6" height="6" fill="white" transform="translate(18 67)"/> +</clipPath> +<clipPath id="clip3_16460_1148"> +<rect width="6" height="6" fill="white" transform="translate(18 80)"/> +</clipPath> +<clipPath id="clip4_16460_1148"> +<rect width="6" height="6" fill="white" transform="translate(18 80)"/> +</clipPath> +<clipPath id="clip5_16460_1148"> +<rect width="6" height="6" fill="white" transform="translate(18 94)"/> +</clipPath> +<clipPath id="clip6_16460_1148"> +<rect width="6" height="6" fill="white" transform="translate(18 94)"/> +</clipPath> +<clipPath id="clip7_16460_1148"> +<rect width="6" height="6" fill="white" transform="translate(69 54)"/> +</clipPath> +<clipPath id="clip8_16460_1148"> +<rect width="6" height="6" fill="white" transform="translate(69 54)"/> +</clipPath> +</defs> +</svg> +`; diff --git a/blocksuite/blocks/src/root-block/widgets/slash-menu/tooltips/text.ts b/blocksuite/blocks/src/root-block/widgets/slash-menu/tooltips/text.ts new file mode 100644 index 0000000000..787d06d521 --- /dev/null +++ b/blocksuite/blocks/src/root-block/widgets/slash-menu/tooltips/text.ts @@ -0,0 +1,12 @@ +import { html } from 'lit'; +// prettier-ignore +export const TextTooltip = html`<svg width="170" height="68" viewBox="0 0 170 68" fill="none" xmlns="http://www.w3.org/2000/svg"> +<rect width="170" height="68" rx="2" fill="white"/> +<mask id="mask0_16460_868" style="mask-type:alpha" maskUnits="userSpaceOnUse" x="0" y="0" width="170" height="68"> +<rect width="170" height="68" rx="2" fill="white"/> +</mask> +<g mask="url(#mask0_16460_868)"> +<text fill="#121212" xml:space="preserve" style="white-space: pre" font-family="Inter" font-size="10" letter-spacing="0px"><tspan x="8" y="15.6364">In a decentralized system, we can have a kaleidoscopic </tspan><tspan x="8" y="27.6364">complexity to our data.&#10;</tspan><tspan x="8" y="43.6364">Any user may have a different perspective on what data they </tspan><tspan x="8" y="55.6364">either have, choose to share, or accept.&#10;</tspan><tspan x="8" y="71.6364">For example, one user&#x2019;s edits to a document might be on </tspan><tspan x="8" y="83.6364">their laptop on an airplane; when the plane lands and the </tspan><tspan x="8" y="95.6364">computer reconnects, those changes are distributed to </tspan><tspan x="8" y="107.636">other users.&#10;</tspan><tspan x="8" y="123.636">Other users might choose to accept all, some, or none of </tspan><tspan x="8" y="135.636">those changes to their version of the document.</tspan></text> +</g> +</svg> +`; diff --git a/blocksuite/blocks/src/root-block/widgets/slash-menu/tooltips/to-do-list.ts b/blocksuite/blocks/src/root-block/widgets/slash-menu/tooltips/to-do-list.ts new file mode 100644 index 0000000000..7fa0b1def2 --- /dev/null +++ b/blocksuite/blocks/src/root-block/widgets/slash-menu/tooltips/to-do-list.ts @@ -0,0 +1,15 @@ +import { html } from 'lit'; +// prettier-ignore +export const ToDoListTooltip = html`<svg width="170" height="68" viewBox="0 0 170 68" fill="none" xmlns="http://www.w3.org/2000/svg"> +<rect width="170" height="68" rx="2" fill="white"/> +<mask id="mask0_16460_960" style="mask-type:alpha" maskUnits="userSpaceOnUse" x="0" y="0" width="170" height="68"> +<rect width="170" height="68" rx="2" fill="white"/> +</mask> +<g mask="url(#mask0_16460_960)"> +<path fill-rule="evenodd" clip-rule="evenodd" d="M13.6667 19C12.7462 19 12 19.7462 12 20.6667V27.3333C12 28.2538 12.7462 29 13.6667 29H20.3333C21.2538 29 22 28.2538 22 27.3333V20.6667C22 19.7462 21.2538 19 20.3333 19H13.6667ZM12.9091 20.6667C12.9091 20.2483 13.2483 19.9091 13.6667 19.9091H20.3333C20.7517 19.9091 21.0909 20.2483 21.0909 20.6667V27.3333C21.0909 27.7517 20.7517 28.0909 20.3333 28.0909H13.6667C13.2483 28.0909 12.9091 27.7517 12.9091 27.3333V20.6667Z" fill="#77757D"/> +<text fill="#121212" xml:space="preserve" style="white-space: pre" font-family="Inter" font-size="10" letter-spacing="0px"><tspan x="28" y="27.6364">Here is an example of todo list.</tspan></text> +<path fill-rule="evenodd" clip-rule="evenodd" d="M12 40.6667C12 39.7462 12.7462 39 13.6667 39H20.3333C21.2538 39 22 39.7462 22 40.6667V47.3333C22 48.2538 21.2538 49 20.3333 49H13.6667C12.7462 49 12 48.2538 12 47.3333V40.6667ZM19.7457 42.5032C19.9232 42.3257 19.9232 42.0379 19.7457 41.8604C19.5681 41.6829 19.2803 41.6829 19.1028 41.8604L16.0909 44.8723L15.2002 43.9816C15.0227 43.8041 14.7349 43.8041 14.5574 43.9816C14.3799 44.1591 14.3799 44.4469 14.5574 44.6244L15.7695 45.8366C15.947 46.0141 16.2348 46.0141 16.4123 45.8366L19.7457 42.5032Z" fill="#1E96EB"/> +<text fill="#8E8D91" xml:space="preserve" style="white-space: pre" font-family="Inter" font-size="10" letter-spacing="0px"><tspan x="28" y="47.6364">Make a list for building preview.</tspan></text> +</g> +</svg> +`; diff --git a/blocksuite/blocks/src/root-block/widgets/slash-menu/tooltips/today.ts b/blocksuite/blocks/src/root-block/widgets/slash-menu/tooltips/today.ts new file mode 100644 index 0000000000..b2da931f2c --- /dev/null +++ b/blocksuite/blocks/src/root-block/widgets/slash-menu/tooltips/today.ts @@ -0,0 +1,14 @@ +import { html } from 'lit'; +// prettier-ignore +export const TodayTooltip = html`<svg width="170" height="68" viewBox="0 0 170 68" fill="none" xmlns="http://www.w3.org/2000/svg"> +<rect width="170" height="68" rx="2" fill="white"/> +<mask id="mask0_16460_1128" style="mask-type:alpha" maskUnits="userSpaceOnUse" x="0" y="0" width="170" height="68"> +<rect width="170" height="68" rx="2" fill="white"/> +</mask> +<g mask="url(#mask0_16460_1128)"> +<text fill="#8E8D91" xml:space="preserve" style="white-space: pre" font-family="Inter" font-size="10" letter-spacing="0px"><tspan x="95.5098" y="16.6364">.&#10;</tspan></text> +<text fill="#121212" xml:space="preserve" style="white-space: pre" font-family="Inter" font-size="10" letter-spacing="0px"><tspan x="8" y="34.6364">Wed 3 Aug, 2022</tspan></text> +<text fill="#8E8D91" xml:space="preserve" style="white-space: pre" font-family="Inter" font-size="10" letter-spacing="0px"><tspan x="8" y="16.6364">Insert today&#x2019;s date</tspan></text> +</g> +</svg> +`; diff --git a/blocksuite/blocks/src/root-block/widgets/slash-menu/tooltips/tomorrow.ts b/blocksuite/blocks/src/root-block/widgets/slash-menu/tooltips/tomorrow.ts new file mode 100644 index 0000000000..22b958cc25 --- /dev/null +++ b/blocksuite/blocks/src/root-block/widgets/slash-menu/tooltips/tomorrow.ts @@ -0,0 +1,14 @@ +import { html } from 'lit'; +// prettier-ignore +export const TomorrowTooltip = html`<svg width="170" height="68" viewBox="0 0 170 68" fill="none" xmlns="http://www.w3.org/2000/svg"> +<rect width="170" height="68" rx="2" fill="white"/> +<mask id="mask0_16460_1133" style="mask-type:alpha" maskUnits="userSpaceOnUse" x="0" y="0" width="170" height="68"> +<rect width="170" height="68" rx="2" fill="white"/> +</mask> +<g mask="url(#mask0_16460_1133)"> +<text fill="#8E8D91" xml:space="preserve" style="white-space: pre" font-family="Inter" font-size="10" letter-spacing="0px"><tspan x="38.0488" y="16.6364">tomorrow&#x2019;s</tspan><tspan x="114.211" y="16.6364">.&#10;</tspan></text> +<text fill="#121212" xml:space="preserve" style="white-space: pre" font-family="Inter" font-size="10" letter-spacing="0px"><tspan x="8" y="34.6364">Wed 3 Aug, 2022</tspan></text> +<text fill="#8E8D91" xml:space="preserve" style="white-space: pre" font-family="Inter" font-size="10" letter-spacing="0px"><tspan x="8" y="16.6364">Insert </tspan><tspan x="90.1582" y="16.6364"> date</tspan></text> +</g> +</svg> +`; diff --git a/blocksuite/blocks/src/root-block/widgets/slash-menu/tooltips/tweet.ts b/blocksuite/blocks/src/root-block/widgets/slash-menu/tooltips/tweet.ts new file mode 100644 index 0000000000..734bff4bf3 --- /dev/null +++ b/blocksuite/blocks/src/root-block/widgets/slash-menu/tooltips/tweet.ts @@ -0,0 +1,33 @@ +import { html } from 'lit'; +// prettier-ignore +export const TweetTooltip = html`<svg width="170" height="106" viewBox="0 0 170 106" fill="none" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"> +<g clip-path="url(#clip0_24732_2246)"> +<path d="M168 0H2C0.89543 0 0 0.89543 0 2V104C0 105.105 0.89543 106 2 106H168C169.105 106 170 105.105 170 104V2C170 0.89543 169.105 0 168 0Z" fill="white"/> +<mask id="mask0_24732_2246" style="mask-type:alpha" maskUnits="userSpaceOnUse" x="0" y="0" width="170" height="106"> +<path d="M168 0H2C0.89543 0 0 0.89543 0 2V104C0 105.105 0.89543 106 2 106H168C169.105 106 170 105.105 170 104V2C170 0.89543 169.105 0 168 0Z" fill="white"/> +</mask> +<g mask="url(#mask0_24732_2246)"> +<text fill="#8E8D91" xml:space="preserve" style="white-space: pre" font-family="Inter" font-size="10" letter-spacing="0em"><tspan x="10" y="18.2727">Embed a X post (tweet).</tspan></text> +<path d="M174 28.5H12C10.067 28.5 8.5 30.067 8.5 32V209C8.5 210.933 10.067 212.5 12 212.5H174C175.933 212.5 177.5 210.933 177.5 209V32C177.5 30.067 175.933 28.5 174 28.5Z" fill="white" stroke="#E3E2E4"/> +<path d="M168.244 71H17.7557C16.7861 71 16 71.2054 16 71.4588V105.541C16 105.795 16.7861 106 17.7557 106H168.244C169.214 106 170 105.795 170 105.541V71.4588C170 71.2054 169.214 71 168.244 71Z" fill="url(#pattern0_24732_2246)" stroke="#E3E2E4" stroke-width="0.5"/> +<text fill="#121212" xml:space="preserve" style="white-space: pre" font-family="Inter" font-size="9" letter-spacing="0em"><tspan x="16" y="62.5454">To Shape, Not to Adapt.</tspan></text> +<path d="M32 42C32 37.5817 28.4183 34 24 34C19.5817 34 16 37.5817 16 42C16 46.4183 19.5817 50 24 50C28.4183 50 32 46.4183 32 42Z" fill="url(#pattern1_24732_2246)" stroke="#E3E2E4" stroke-width="0.5"/> +<text fill="#121212" xml:space="preserve" style="white-space: pre" font-family="Inter" font-size="9" letter-spacing="0em"><tspan x="38" y="45.0454">AFFiNE</tspan></text> +<text fill="#8E8D91" xml:space="preserve" style="white-space: pre" font-family="Inter" font-size="9" letter-spacing="0em"><tspan x="72" y="45.0454">@AFFiNEOfficial</tspan></text> +</g> +</g> +<defs> +<pattern id="pattern0_24732_2246" patternContentUnits="objectBoundingBox" width="1" height="1"> +<use xlink:href="#image0_24732_2246" transform="scale(0.00324675 0.0142857)"/> +</pattern> +<pattern id="pattern1_24732_2246" patternContentUnits="objectBoundingBox" width="1" height="1"> +<use xlink:href="#image1_24732_2246" transform="scale(0.0208333)"/> +</pattern> +<clipPath id="clip0_24732_2246"> +<rect width="170" height="106" fill="white"/> +</clipPath> +<image id="image0_24732_2246" width="308" height="70" xlink:href="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAATQAAABGCAMAAAB1wpjTAAAC/VBMVEXu7u7s6+zj4uTm5ebj4uTj4uXn5+ff3+cAAADn5ufm5uf4+Pfj4uTt7e0BAQEeluv39/YTExPx8fEYGBjl5eSKLen29vb19fUnJycEBATS0tK+vr7m5uZBQUAdHR0tLS0ODg46Ojmvr67Q0M+rq6oQEBDi4uGampohISELCwrp6enV1dTOzs7JyMrFxcTZ2djIyMfBwcK8u71ZWVgGBgbr6+u0tLV6eno2NjYrKysaGhrt7evg4N/e3t7b29vLysu6urlMTEsVFRXo6Oe5ubuwsLCSkpF8fHzAv8GtrbCVlZWEhIQkJCPz8/Ld3dzX19fMzM2oqKkICAiu1/Pu7u3r6+qysrIzMzMvLy/Dw8W2trddXV1EREM9PT3v7+/GxsaxsbSsrK2fn59sbGxUVFT09PSn1PPw8O85ou3W1tbU1NO3t7eioqFzc3NPT05ISEjy9ffAwL+8vLumpqZ2dnZoaGhlZWRJSUmlpad5eXhwcHBfX18xMTHy7PfF4fTB3/RSre7GxcfDw8K3t7mPj4+Li4qIiIhSUlFGRkb18vfV6PXKysmYmJdiYmFXV1Zbse6dU+0om+upqax+fn7q3/Xz8/OBw/CTQewhl+va2tqkpKONjY2KioqGhoaBgYBGqe6DK+bFxcaioaTbwPnZvPjVtffn8Pa6l/aiYO+PPeuILOign6LEsPns5Pjd7PbDmfW7ivOzcvKYY++XNe6YSOyKNep9MOSbm57byvvj2frq2PnPufnCq/jkwvfTqfTh0vO/f/OwgfKjP/Kra/Bltu+SUO2PMOuqfOqPN+qLSed8KON1KOCYl5vDuPvPrPfBefXAjPSlffOWzPLbyfKvXfKGxfG2fvGkc/GmUPBuuu8tne2dSO22jeyWWeiFPuWcnJze0/y/o/fKn/XMjvWebPLUvfHIrO4+pO2fZ+icYuiENuadnaDl0fmyofjS5/XN5fWqkvW02vSf0PLNsu/Anu01oewpm+zJavgunu28l+ykcOiryd2CoLVwjKFceo8YK9G+AAAAC3RSTlP78O/4v68gIADy8sgKh8wAAA6nSURBVHja7NC5AcAgDATBA/PZiJj+O3UDCiBTsFPCaPSSDedqG+r5SVo4ld7cVD/tiWNbT5Elzq7slGVr4oqMtGuLNNI8pEVAGmku0iIgjTQXaRGQRpqLtAh+9ur3J4k4DuD4H/BpXw+OwR3IcQccApLoBIFTlMNpmyDmL3ABsgSzH4hJiANcNZfB0mit2Sp/9KyH/RX9BW0+qCc+7FmttlpbP9YXNWVFWw98gJvvB/f57u4e3F77bHeKdopWs1O0eqge0Ag4YdUBmjWX21iWwgnqeNE+7LyE2lks6/CPDCkvw2X7xuDEdLxoo+9+ZC7a8WFyJR/sxFO2NTtEVGASkVYF1E6+zI1ITBO5AJyUjhft0uWfxtQVfDjLmQrdeHpCxvkKWhfFqtYHMkNyqNWiyZcXJGEl/F/yF/d+9/I6AEhbVLiYmwCYVMXg7+od7TsltODDrhFRZ/FcYUoWwEm1SQMhSjoGoUaNLva8e9hpSj0FqKwnIVPgi0EK0G3fQ7JCJSUBe13/9OrB717tvACiS+Rw6qJDKVdG26FGdY72TW27MQhEXmIb0U4DEWbP9akGdcHVtCcwv0RvXzAQYFb1XXNXr1yapLoJhSdKZxXj7UpYjReDYMkUF5SQ3QZccjGIr0Mlz/6evb/ccNiTz5eAaJbYIikxwpAFdwvSQI3qHO1WnM3IoLHkVfNbZjCrR561R5pSoZyamyrxyO8cnklnoyFnMTkDh63wCRkA0cGz6QDtm4+whfOeOZ7ly+MUaQbodCJxEGAAre0v2seGqr7sVNAkF1db26ZcZP8JRVtjxGZQabhtf9GKp8vD0a658AaPbjgElIin50tk2ZEVqGdSOEieGBnoxFNaQPkALYrkytVYlC4nC6agC10FeGajBfy8iIL7aKOHa3b5XcPDxxU04QoA9M6OLFbQFFc2FrI5rXk6Nrtm2crGmwx1j6bT+JeJfiYzzxljsMmEezgU9iiHBLTaxdFT1vH7bDwms/bzURkchH8SPQrA3UWLbpok73TKRLqsI/QoGUItoJjz+r2TYGe8Z6rRsNnzS2+q0cYG6CWMpugRmYmQcWLTfNGriU8YWaOjt97RpKJpauY+Pzye5fXr7exKN4fSgyBlkJJQ01qpjDK2TgP0OlklAftdI51uwHVGUb4ZoVQvbNg4C0COPl9CKtDxxSVmGi7QIlGNhs1GR59Xo9nP2SqbZnewZa3Hx4Tm9TZyYWo4TlJ99Y4GDj7TrKbaiH4ho4sKHb0cGoIjtBjJJwo4hk5Pw34Z1mHGQ5a3CcttiO4DN2XSY0MXar6PrsE5dGPCT0CY1sMh2pPbX/fMnhygkWfNvYEkxd7BaJPppE4OSpHp19s0FwbllhS5VfdoQb+4qylfhVWXOOCKWDo5ZK1CS5OSyByuVGpbh70UnE0rBXDHee+adA2F7LCCUviTmkboM4+Q1uCnGidcEPBqxo/Q3n56ffsj3rOGAzTTYiIhukzlAEYDYkzXMezTCD69LToG0DUrcdQ9msEpJASfDGZKVDsz6/4DzUIywZa9GgnYq4shlXLCU+CFJjuE0SYAfg/AuoSMiiRK7uI7IQ3k0QAcoX15c/MWNjtEo40UpVn09Sgw2nTrQsQZCpEkRlMDgMEhydU92i/u7O8nqTCMA/gf8NTbgXOEQxzoICAHosIpJaFkOHAj0CQwl6iLNFaIZKau0tacYs0oN2zlz9rAfm2VztqqLTNr1lW21lyrruqi2tq6at3GG6zIrTaaF9j34px3L7AzPjx7X55zhNVqntTCBwiQm9mhlb+hVfD1a9QKBwBj9dZCIpWkpKwrUsN6vD6otTtXA0jZQtDmsSIJ1KHDNVwRSOwW8wkK3Dk/17TXj19cOP8LTamqrPTrGAKj5WzlNY2c6g4uKzSwkqhmCwGQzSGpmEhBy3PWN5aPKGUqcXGArmYKh1UYbohHn5aYpa34Rkddfkf8KHFuGvWQATYMOWaa3UZAtXInrxiYtl5dAg1jfV2Fk7IR4MTRDH1q+3Z9w9r6ZYO240McTUyjPbjvLNqJ6ldDCtpuWmlQDQZIWiYzF7SsFBvwf1qQ02oRF/auhXj8aBvEtQvMJDlWRPfA2dMoWARgE7HdfCjk0J6U3fNPaOsRzYBLYU9FI/T7czIW7caOGwCaodBBIwA0KEIbNdBQEWIAhKdCFKG3RALFvsGKTW2to7s0RCPvSAnEPRWRHsV+HwHxFEawdm1xaKyFgiE5gG5TFwCUjVk1AD6rCCMIvrxOQXu9dzHaBhmbd3hPO5eK5squ92YsGhzAPaVPzycAcxjx2afHcy58JJhCFwEEVT7oJgC2KXf7IB4HE59MhkmcjIwAgC8EAOrHSyUufMxFYXy69OLM8Z/BDXtZsKks2VzIa6pdil67of3Ioa3eZkkrAGhUQRs1Kh3OXLR0ctS+loA0QrSpxYk7Q8d+5Qb+QZrX1ybf4hb3Eca+3RXFgxtyTe5mHa6+otxyUytd+V+gNbQeYiCdKMgIH/4t2nZP2X+BRvStdaSFHBj758W8ueNIw6Krn+zsFCw/tHQj6GLgX0PZcuG3dN5/Pz//fjo6cHLZoGkod2J9pxw+RkP87dtu0RYtBbjRscjs2+zU1NSr+ZmZ99P373UuAzQq3Btw48Ge+j5/9fCiyzO/Ckq7rVfiaYpoYalzb352Livr1s3o+MybVy8nZ6YHMh7NQiKzDi8oa1C2ipdHQUqwZFlylLuVzK+qYnlBPyxtBPdn587133p0592z2JW5ubmpNwsPMh0trDaLhl1/QqPRusRgnyS/XeX3NwbzC0ywpOmcnn30LBaL3pt4Hn2UhfP0rTCz0QppXoCVUIvQNiisVgUFG1o2o9GWH0gRUQF+VkrUyVApaFv2UQqrqg7XyQqLzVaJP+XY0mj1dgkh3Qw8fzk+vnDzc3Ri/Hps8lwc7eLHgcxGU/FO6OysWPgb2sa8NRxXFZbvNuQju8GC+4k1KNn6NIrslNVgPVoltUu6QLglr4PjCsI6qLV5aI72dPPTXtImJ+9evnr1ypX+5+PPYhNP42ifHmQ0muC0s0cTdpbqU9HKg7y2U6c8zjZ5N4kOda/GU04ek9w4eCJtKapqGumRoV6HqUndPtITZE8bK+xcqS1CG9JuKz+/XOi8M4Er7Fz/m5ux/qxrmV5p2zlyO/hJgykVrcQ7nCMgdEjqdtCoziHAtYeqIBGiA20sRR3lfH6fWioPOSUmvlF3okYhU7YYhYzFLCuBtHIyNntrInr3YRbO7YUoRnsiyGi0kLqdAman2s9PQSP0jMPU3IOUJT83gmy0E5IpQNmlqBVXqYHc2MRaAaBWu30X27FeLtfu4r5LLovEeuDRZtEtF9YuBwfahN07109Y9erOoK49TWX5HN28vGokS0WQy7TCKlkLSx/kQNPjE4ZqmerD5+DOZw4OPmGVGidpsKiQiqRgBBBIBAnHk1gPHLjxaPeOBwchgXbuwe71K08+G9SB1mTFJygnJ6fJp6Eshgg06Vhhxyq3MGigwUoyA4gWZZMgfXc+d2ighQs7QAONT8McDNpsSawHrhx+sBNUpoEAsDrY+PDu3cuDOtAAyg2SdfcGAnnNJF5EoJlrRkvZaokgB5p4OV8ppF6s4osRggeakrFgC6j7buBarCBhpwYCMqkkNm1vbFxzfcX06ZuObhU9cXjP8vV7jr6cMpgDzTSCr8dGBghyFaJ5EIFWqjkPSN6HBBq0A1CkIDhZC9hRbXPSDOOGB5qZW5CiLbdYlqyFioWwGzAIMvOStboudZFQDyy9ffDDyhMr9uxec3Hr9OU7dq+5/up452AONICUBDWkwIxCK2EDS3igtQhGtOSUWEgCA82RLzYZrETITdMptjpaUVhwXggi0KSlZFUd57bFaqbYFAtLJPe0hSuULDuScYSEQNu3+cnbJ5senl2+Zs+mlQcfTtiy9d7PQd33LDOJ1gIz4sIlWzTggWZTKmylEe5lAgy0whoTVXXIYMgs2SBNTckYdRFuRKAZhugZCwo7KUQri6mpKzrJCTuV+C+7OeMmKSntxPSLb/ec/XDy4NknW5ZfBDZtT34e1IFm4DAT1h13AMhP2sHU1gHSXvDPTio21CqSTgX2jcJUoF32OGWHwOJsZVNQL8tBH6w93po7XSo7sLjIT5xbTMbAbJZKk5pYwbv6dSSMfu6fsGrl0U2bTm67fnbNmuVHga20u5cHdaDh9kqckACUKe6LEBUCimIAcZiogLiQL5glRlI77enLbae2bTt9+t6mi9ePbgUG2q/aoRloANETdO5/dvzFydOnT297tVUUGGb3voiNBhrhZN05Zeflp8+O3713Cgi2HV87+EduBwkQq52y//LTL3fufF4rMBpoJOXU2rVrO4fExMpQB4DmzRjFcRiMwgeQA4NZYrDcGbaILRwJ4mYdq4gMUorYhnhALmbkaojLrdQa7QkMKQLb2K0P4EvkJrnEpgoD0+5AvurB/1cfvPJ9gzSvXSHUoTYKkgQVC8sGn4nsrzufNFktAU0AaN0vMzNrB0AOPuGky2RrA+fy+LO3j8Yl98MJvFQrcIwc8OD5pXVN1HBdQ6iZLPGiGOSbRU+boIH8vSBC5jJgzO9asubEivwOkhGy7tdVbmSssfwoIOGSr7mpfY6I2K7ghFHL1lICEBaI6RqLkDGFXMMDWRhOOEsdn0HcxWK35cIlkt3jsB4IooehI5enl/ZS/uXq1BcdI3McR2cs9W6I40pfR+0LpedTRvpYib52r2V6uI5KTTw0R90loxiFcFTE+lkJfyRzRjRMQzSW+d7k8GoDfz/HvVCK/ul7S/SZLjXr3boxb9N+utyy2oJuqW5EzPvGbaYbHpVhjXGeXdqH34a0ot7BTZGvSBUgvXEVPv9uB0Ro0FG3WU8xzzAJyesPm8m04NQrzsh6hVQSDtAi6LjwvWZiAsHa82JCTV4XsufLDcNJgYoc1zUiITODwO49xPSYlbjiNat+Gk4ylwlB5HumqArjwcD0f0v7B+yXXTonq4IXAAAAAElFTkSuQmCC"/> +<image id="image1_24732_2246" width="48" height="48" xlink:href="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAADAAAAAwCAMAAABg3Am1AAAA9lBMVEUAAAD///////////////////////////////////////////////////////////8BAQH9/f0UFBQGBgawsLD7+/vf39+RkZEwMDAYGBgJCQlsbGwtLS0kJCQPDw/29vaMjIx+fn5eXl5AQEAhISEMDAzu7u7j4+Ofn59jY2NYWFhJSUk0NDQqKiofHx/r6+vb29u9vb24uLitra2FhYV4eHhwcHBOTk5FRUU7OzsbGxsRERGBgYF5eXlnZ2fy8vLR0dHCwsKqqqqlpaWTk5OOjo6IiIh0dHTo6OjW1tbLy8vIyMizs7Ojo6OWlpZTU1NDQ0N2dnYEsD/KAAAAD3RSTlMAgO8gYN8Qz7+Qn1CvX7Cz4vbnAAAC20lEQVRIx41V53riMBAkgAFDch4hbNzovfde0nu993+Zk00IUkzOmR98YM/szFrLOuBBJCyfSkFFUaSYHA4FfBA5iSkCooLGSw8qXsR/lIh0cvh6cpQeinLkYUd/4RTSEZMzvvxDGTB63IVgwhNH4XEPhglnIcTy8vsUhQ3MC+VHRUK4RRbIqKqOEhEun3H9ik+zDtTdz5GgCB46lwS+mgFuiOOj58RDPN4AuQVDmihPFHfkWBshRUDNgF12FMTC6kERQkVcQVw02AKb2gdTqD0DRUW0OGIwAHCl5LaOIg8MvBZhseMZgG6dkAJTnHfR8nYRFQJdw0XrUs1TpFPfH23seyJW04nUAprawEC6hWVVIETERCyIgxvtsgTjtl/GtIKCYBEOyPxPrYIKpQD0+8YcmYKNJLKPPEMOCH/JBgy7baL1ul2YyDJdB0CRt/gjjMUjkF8VKahu2zbFHk8cRQrwHdjoDPFmAtn1eLxe4QMu2ip3EpyAvAEXU1yaFCv2aHqG9RfFMgCa5qpygtoSRbVNc+aSZseKku9uMDm3wJCscoLgl8EUpqahTWgDzWRN687NyUsHRiEDbA59H5rWTEzIKxqEWvp7SW9XKpYFTKukn0FFOzR9ut9CTXSrpIk1ocX77DWAUgelZ6LU1mXgnXzNhvwpSAFs5JYYEdoaoEEBal+rF3cLCgfDT4W8H41cGTNVeQb6hOq5LIDVnTZugyFjjdip5/ajEdkZ1N0ieSQJoZkJgHmhRIHsLN0nhAyB251FKLCbjVoZDSZrYk40N0OywsjjgVvXvbHsuXtgvwLSgJVKjQwsWF0XnelV6oAtkCduIrbjnZPIwxc3u0SfFtVi0gczJ1Kc0XcWxBfq3sC18Ie4j6O/40v8MvaDuIyd4/ZHwvNC8W1AVPjzRST+20cwfOS1K/3Mj3L9/iJWkI8jmsSP0iMiS5SEoyI95qV7NXJMcipLp3LYy/4HwhGNw7uiTMQAAAAASUVORK5CYII="/> +</defs> +</svg> +`; diff --git a/blocksuite/blocks/src/root-block/widgets/slash-menu/tooltips/underline.ts b/blocksuite/blocks/src/root-block/widgets/slash-menu/tooltips/underline.ts new file mode 100644 index 0000000000..63bedbbcfd --- /dev/null +++ b/blocksuite/blocks/src/root-block/widgets/slash-menu/tooltips/underline.ts @@ -0,0 +1,13 @@ +import { html } from 'lit'; +// prettier-ignore +export const UnderlineTooltip = html`<svg width="170" height="68" viewBox="0 0 170 68" fill="none" xmlns="http://www.w3.org/2000/svg"> +<rect width="170" height="68" rx="2" fill="white"/> +<mask id="mask0_16460_981" style="mask-type:alpha" maskUnits="userSpaceOnUse" x="0" y="0" width="170" height="68"> +<rect width="170" height="68" rx="2" fill="white"/> +</mask> +<g mask="url(#mask0_16460_981)"> +<text fill="#121212" xml:space="preserve" style="white-space: pre" font-family="Inter" font-size="10" letter-spacing="0px"><tspan x="8" y="43.6364">Any user may have a different perspective on what data they </tspan><tspan x="8" y="55.6364">either have, choose to share, or accept.&#10;</tspan><tspan x="8" y="71.6364">For example, one user&#x2019;s edits to a document might be on </tspan><tspan x="8" y="83.6364">their laptop on an airplane; when the plane lands and the </tspan><tspan x="8" y="95.6364">computer reconnects, those changes are distributed to </tspan><tspan x="8" y="107.636">other users.&#10;</tspan><tspan x="8" y="123.636">Other users might choose to accept all, some, or none of </tspan><tspan x="8" y="135.636">those changes to their version of the document.</tspan></text> +<text fill="#121212" xml:space="preserve" style="white-space: pre" font-family="Inter" font-size="10" letter-spacing="0px" text-decoration="underline"><tspan x="8" y="15.6364">In a decentralized system, we can have a kaleidoscopic </tspan><tspan x="8" y="27.6364">complexity to our data.&#10;</tspan></text> +</g> +</svg> +`; diff --git a/blocksuite/blocks/src/root-block/widgets/slash-menu/tooltips/yesterday.ts b/blocksuite/blocks/src/root-block/widgets/slash-menu/tooltips/yesterday.ts new file mode 100644 index 0000000000..b37f0b19f1 --- /dev/null +++ b/blocksuite/blocks/src/root-block/widgets/slash-menu/tooltips/yesterday.ts @@ -0,0 +1,14 @@ +import { html } from 'lit'; +// prettier-ignore +export const YesterdayTooltip = html`<svg width="170" height="68" viewBox="0 0 170 68" fill="none" xmlns="http://www.w3.org/2000/svg"> +<rect width="170" height="68" rx="2" fill="white"/> +<mask id="mask0_16460_1138" style="mask-type:alpha" maskUnits="userSpaceOnUse" x="0" y="0" width="170" height="68"> +<rect width="170" height="68" rx="2" fill="white"/> +</mask> +<g mask="url(#mask0_16460_1138)"> +<text fill="#8E8D91" xml:space="preserve" style="white-space: pre" font-family="Inter" font-size="10" letter-spacing="0px"><tspan x="38.0488" y="16.6364">yesterday&#x2019;s</tspan><tspan x="115.334" y="16.6364">.&#10;</tspan></text> +<text fill="#121212" xml:space="preserve" style="white-space: pre" font-family="Inter" font-size="10" letter-spacing="0px"><tspan x="8" y="34.6364">Wed 3 Aug, 2022</tspan></text> +<text fill="#8E8D91" xml:space="preserve" style="white-space: pre" font-family="Inter" font-size="10" letter-spacing="0px"><tspan x="8" y="16.6364">Insert </tspan><tspan x="91.2812" y="16.6364"> date</tspan></text> +</g> +</svg> +`; diff --git a/blocksuite/blocks/src/root-block/widgets/slash-menu/tooltips/youtube-video.ts b/blocksuite/blocks/src/root-block/widgets/slash-menu/tooltips/youtube-video.ts new file mode 100644 index 0000000000..971eaf75ba --- /dev/null +++ b/blocksuite/blocks/src/root-block/widgets/slash-menu/tooltips/youtube-video.ts @@ -0,0 +1,23 @@ +import { html } from 'lit'; +// prettier-ignore +export const YoutubeVideoTooltip = html`<svg width="170" height="106" viewBox="0 0 170 106" fill="none" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"> +<g clip-path="url(#clip0_1_2)"> +<path d="M168 0H2C0.89543 0 0 0.89543 0 2V104C0 105.105 0.89543 106 2 106H168C169.105 106 170 105.105 170 104V2C170 0.89543 169.105 0 168 0Z" fill="white"/> +<path d="M168 0H2C0.89543 0 0 0.89543 0 2V104C0 105.105 0.89543 106 2 106H168C169.105 106 170 105.105 170 104V2C170 0.89543 169.105 0 168 0Z" fill="white"/> +<text fill="#8E8D91" xml:space="preserve" style="white-space: pre" font-family="Inter" font-size="10" letter-spacing="0em"><tspan x="10" y="18.2728">Embed a YouTube video.</tspan></text> +<path d="M172.561 28H10C8.89543 28 8 28.8954 8 30V120C8 121.105 8.89543 122 10 122H172.561C173.666 122 174.561 121.105 174.561 120V30C174.561 28.8954 173.666 28 172.561 28Z" fill="url(#pattern0_1_2)"/> +<path d="M172.561 28H10C8.89543 28 8 28.8954 8 30V120C8 121.105 8.89543 122 10 122H172.561C173.666 122 174.561 121.105 174.561 120V30C174.561 28.8954 173.666 28 172.561 28Z" fill="black" fill-opacity="0.2"/> +<path d="M104.691 68.8597C104.538 68.2666 104.24 67.7259 103.828 67.2914C103.415 66.8569 102.901 66.5438 102.337 66.3833C100.273 65.7911 91.9673 65.7911 91.9673 65.7911C91.9673 65.7911 83.6609 65.8091 81.5972 66.4012C81.0334 66.5618 80.5195 66.8749 80.1066 67.3094C79.6936 67.7439 79.3961 68.2846 79.2436 68.8777C78.6193 72.7357 78.3772 78.6144 79.2607 82.3181C79.4133 82.9112 79.7108 83.4519 80.1237 83.8863C80.5367 84.3208 81.0506 84.6339 81.6143 84.7944C83.6781 85.3866 91.9842 85.3866 91.9842 85.3866C91.9842 85.3866 100.29 85.3866 102.354 84.7944C102.918 84.6339 103.432 84.3208 103.845 83.8864C104.257 83.4519 104.555 82.9112 104.708 82.3181C105.366 78.4546 105.569 72.5795 104.691 68.8597Z" fill="#FF0000"/> +<path d="M89.1812 79.7879L96.1796 75.5889L89.1812 71.3899V79.7879Z" fill="white"/> +</g> +<defs> +<pattern id="pattern0_1_2" patternContentUnits="objectBoundingBox" width="1" height="1"> +<use xlink:href="#image0_1_2" transform="matrix(0.0061891 0 0 0.0128205 -0.00131663 0)"/> +</pattern> +<clipPath id="clip0_1_2"> +<rect width="170" height="106" fill="white"/> +</clipPath> +<image id="image0_1_2" width="162" height="78" xlink:href="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAKIAAABOCAMAAACUqj3QAAADAFBMVEUdICISFRkRFxwVGR0NEBQPExgLDBEbHyUfIicYGx8YHSMTGyE0OT0kJimmpTscJCoTERWiozGgpqcqMjkjKC5xjpYpLDG1saunpUg2NDROUVRGRkScmJU9R0srLCkhHyBFSlBjaWpLTkqZnp4vMTFFJyJrcG9oi5ZAaSt6kJRXVlMgKzU8QURxdXZfgY9iY2GQlZVZT0qkWy8pIiSNmJ11bmEMO1pAT1g2Wi54fHxKYGxCPjw1JCOhNCKprKujoJuCk5p9goNoaDo2ZSyuMyKOkotRWV5Mci85LCxWJB2mpqKVh3uufXYuO0VXYWg3dF9tZF4QOzZ/jC1WMCybnSY+YTaUNSAyRlGrpCgzOSOIMyOJiISLfXY7XHE6VGRaXV0XPFVyLSIeFhd4cWwZSmU0TF0/NTdthi27uLCtpJGwhH1hW1YrUy5oNi4nMR8qGhiuqaNOZ3UbLz9pQz5LVCN9MCOFi45yg4tmaVF/eXN/XlgmNkKLjTlNSDg+Xy0VHymukYcnV3YhOUxiKyMaIxy5pZtfcHlRVDyzPSZTc4RFWGKmbV8VQl8zQEh6QTUVJjKXQiCnmI5ZaXCMQi5WWiUdKiNpdnupdmwsUmoHO2FxS0ZUREQrXS5OcniqpVeCTkhXYUWUSz4KMDlFLS6ymI0PS2yrpWgmS2EgSDFndipjVUxJZjgxPjNMNTKlazAuRCVmfYackIOen3eFbmclQ1eWnj5gaSc/RySjRydHIBpEa3+pYlV0d1BZc0F1eyw3HBifkGkzSz5bPDaNkm9hfW4WRTaXZjAhOC5HTS09OihSe4+aeW5FdFtmfFl9ej4eUjR7kYM1Ynx+fV5HY1SemFOSkkmhh1B6VDOuo3uCiHZtg3WoWklFbEStTTyPl3yFbiyReiuRVCM3aomanoeJg2aboF2TX09ifDl/YzNmUS+ljH2hf3SFh1KgcEU5W0WnezAQMkuniymqlyiwUSiRlF1wVlWQe1GkkDxPQCGRkyNXalV8imNUYS6VbGGur0kyYlh8V54eAAAAAXRSTlNZUlExNgAAKfRJREFUaN5dmgdcU1kWxk2eTDphTAwkIZWQgZAQICCEIkVBirAII0jvbZAuvQkoCApIFUEFREEQVJoooiJWdLD3OmMb+7i7llnL7J6X4LbPFxOd35g/37nn3HPue3Pm0JadXHa6q6zMV0Gl2t444Dd1/qpH+/Wr/KrPpp5XJDaxUouJCvYHU0tv7kE3N56GBgaDQQgEBIvFkkhwaWC01MI2NWnMTXE7c+bI0L1D9pMFPqsIRAqiMfe/RIruIX0Hmgsv9A2Lbdr7ZckSg5W7Llw4uf3klgtvL3An/2b2txW/n1qx4u+/r/ib2V/hmsPpP43HUZctW3Y61lfBkJCN+q6+nnz/9MOH1tjYZ3tOnTLb6NuVMcWsGJlxJWA1sBgtDVSY2a/WwGqgv2vMvrB0m2qp09CmBwVHxgh4nAboGyMKRopuSYcPaki1MMiavauAcsvJ7Re2nFx24bS3zh5gQzHNNFfs2QPXHAqDXl3NIVMI1NPLFLGT18gfPjw12/Yhry22Lay83NKI7fs+oyLv6TD/uAjBYJqKqVQul+sK6uoa8E1SKyIz0A9kZ2fXYJpf3RFX4DNG4HIJ2H8jqonAxehw7Nzv1H+DvqshSWsCipbsttpyYdnJCydFT/4KfMCmeeDjir+aaX40m4OjUnF4PN2zmkxmuLatf+307Pc9v5u10uTOQbq6unrNlnl9fde/dn/ouRiAIIhbmZtKBw8e1EdfqGxt9f+Y1fM/2EalOeenEW7/WJGB1d5kHAHzH8y5xdHRPUbfEGffQLBciE0B4KXVhQvLmiw0zX5focI0W7HC7KOm2RwiDoGlhQPRaXQGp93s2dSD31/7esvZHEeaVMISNL83tTmvmeNyy4ZAwroxcjo6Dh/+rRfk9KURtL9x1aozq86MrQKNjY1VNx1jEpINllglr1mTvLfIoGgvl4dgZymLN0RHl2oA3Cy2ylr4gEW4NjymaOwLUO4SXf0rygbRPvBXzd/3rPgdEEkkBITFEog4HLWY1uXL43GJUUFByizrMKG3S7NkgCm52hZqXIiQsGXTjeq1iGYLlSblW7IkkrDq6tLS9OqKTzk5xzyTvxw7vGTJ3btFe632NiGE5DEVJo5IAjuLN2yI3kBSG6g1d1bov6RR6JFIsJnmBgBl8sQKs7/CSgTMPWjCfJxDwBFVhBhIVSz4SU37JDpGZsiloihdmRgvV1qT8USb61Ivjwr4Wcq6GjFEfEBFh1zC8r4fYqJ31uS28yNxVai1dci++Pj4rT1N+0/fvXv3DYUR27/SYHk/QUsLSd5rVWS19zQdh7q4Yfu3GH9LOBKRgFjUBVvwuAoFkTfdz/we+IBtxV8PoIuyGxBRF7FokqKQxPR97P5/NocFeUtlWY7i4MEhcVRQiOlTT/nWDoSEuHVVsMIkNmQ6LB+RteHChYalTJtYsqelV33aJb34+LOAyL17jG5z49czryNcd72haKm1Bjj3qhDDsWh4QVoY8AVEImKYg4Nsi2AKgUcgKGIVr8BGYAMb0UV5cw6RSEQRMbMFD2eZ3V/9z62f7HfYuzs6pK7dcfNlKyu7NaM07RaKeHAgh02aFTttvoM7S5p7y9RbXBoacumScXy88c6mXhH3j1MHDiQ5Xb/TTjmG0/qPMIAIqlUvQXAFvhULiFiM92Aw26LOg4vwAAdTqAk2ogmjcvP6HJxKwIiBiyDyd2xm+udUXdFdqy3j893dTVKd86mOLp7sia0qRN8cNhFHoeBIOBxnfJ7hz2RnJV8apXQsHT9bOe4eb1zCZk5tPHDAbqPtQGdnzWlA/C8Vb94AkOEaqIvAhxZTyGaCFtcj0bs9z7tOQsEQgYV3DWxUJ8z1Fb/vAUQi/DXggbC4K1569f1GHKG/o3aqgyBLe6G9vYDoKfCqjhl/3EEkIkd9ndhEfFeblE6mWY67P8rhOMv5Yr7skX9I5fhD7XjnmCngs/OziyBX6Ud0HaP9v4toqEmqOIMlaOhgmWmZ5gab3mtPlHjUxWJIOKKWd/drs9m6o/k3tOgQARKL2oggZL6JfVjHsVI5me+eKjZ9r2ti4lzLiUlL51jkGncQiKQU3xwyrAwiMVTGpl26+EgsD5LW810s9Zp7Lj18uDB+YZprIBD6+bmGbLWm4Y9REZRNQ2suXHM1ALEnOrqFNpsnWCzsibCF8iw8LIRPW00H1671YGIQIia2bse9v4KTYONHTXARJSSgiBoIhSOzFzCdWKH+HKqXnrmjTO+sOas4faQ0hu9Z0iwhEJGUMicpEf4PWlWOBTWmPOiWhQ2d4yLwv9Xc8hBdi3rWbW5+Bw74lflv3bqVgyPAmlizpqkpuQl0mlAcDbXb0tQXAxEGQLSQgIkark/XbxqO8m6/t27dvTwbBDIiOGGTGYR4BSSNzkd0LRIIBJQQS+F4pSpLeybE/uFe+TjLLHuHH5pzIVs9i0st83vOThDBxcz9QiAkeYZODPZ/kiQ+TnjFEgr4zc4xJQ8fOsTHZ8W0HczcmNTItd4KuuzUe+1yEw6HJ5JQEWp3RsdkRBy0vfLm2Ok1s0aSCISMzoaGvODcvJpD69f/lJCIaHxnmrBa1UGs+GimowmIBNhe4IXFc6ohi2NGJpodresta4v9Pf1H0oJsONXpxfkx6TEPPwcQSbyI/VIcyN/aQrui7Mbk2vhX8ii+TGC54dKlswvj47OrYw+62WXa0lDCejxWgznBg9WFikREtrdkRBxVHPsL6OefVZwY2A4JTBub9zU1Ne25w32xQjKBh5vLG7y5Z4/m7691tpn9vm0ODwonUYXY72+xbZuzzOtyeX15qFc13royZLwy25SW/rY2fcRIeu6+EwEQG8ngIt3aJSr+cURmXnz8K7Gc5VX1tqTk0tkfHPSs85kHUwL9AvU9t24tlxIUPGQ/j0ScRSS4lh2lvPnL/+jnK8dOEzEYhUfdcJ+pjYiERXe5775L3PY3M52PZh+3XT+UMAdMVCMiHI7FhMzRRZqT1hM+Un8Fb12yoT7bQU4TvK01GqnN5z/+gCNEZvaxwUROmsvl+ISkzFbt+MtK3ftBJuUtLT0llx6OWxoZ6Ue6bQwMTKJUlbMRKMW9yUixmtHW7Sj3zc//Rfcz8PVTMCQi17emps+UiaiaC8ja776jTOrcea25Z33Cjjs/zQET0UBjsRRPr0ShY32V5WWBV2m49RV8c2WJyTzzKFxp6fZi/1pR1cNbXEJkxHMhA0+tquQX5iS66k8lxF+WaWvLXEY2RLeURPdUcgDxaAramkUoYqyhUHB9plWIPH23o/Q3QPUN7+6b/iYE0lwL/S+RsTaEb30uQkDrZmLC0nvdL+6sXrvpECCC0Caa5imryLG2DhWKGHyvnjQOdd94iYnePAGR7/+WxKmlvb98sRBFlIhwZOtKTwKe6Z3IPR7/OEhbO4pGi97ZcqlFT4+Gq9V3Axv9Nm4MtPUMGSH3jY0hOJyt20He6X/+l3mnm9BeGEPAu7odjJyrRdACODUh5DkG3iGp72xKWL/ew7sLio4qWTBUTxd5x6eYmBYvWUzpyAZrslF2SYmzQzaN2FX9trawuCv0UvMEjwAu0oicS2eNeArvHasTLeIvO2prp4rTo3duiM7WzqITjY6WpRxFEX/9tdAolLU6URHpVnaQe+wbHiQJEavq0Kl0Lhiope4a/9OFYwERZJPw01Nvm8g1yWMoIoLGmsFRdrxihe5L85LX95REu1DI2SEtjmI6TprvmzRQQWV/zbt0m4EikomscUDEC3fs6JNoX5bpuWunhuzcGR2aqq0kkowiy1IiI/xQxoIv+MTVq9t4tseuAJ6aDqPu5TBEumhAX80HhP+RBoLhqf7MS2TyxvYf8fGJm3URi/eUhX22UDo7hgbJ60dCejhE2pW7HTO32M66QXxFRh+j1vr660E2qey9kIyzfFhJpyhcE/sUNtoWShN77Xj7tAULrqRqNyOkdAIsrYNAeODXX66Jo15YCI9d+fmfb2BCwmqo+CAdcHRX37KDkU0rz8D49R1c3zQXAj1XjTg3YD/g9S6xWjQH+MBFopFAftmJERP+1kum2xwe0mNZcXji1auCUQtlqpyeXjHgpvD3f3VxglTWKhTiXcbHcRRf32aOwjXBQmxvn5rKbhnZcEJbux5DSse6HQUbVYhDglzWG+Gx07Xb377lsD0pBAy0wkRGbFKSWwrlWE1O3BkEKjcRq2ZSu4idS1DZqrW/12DXIoNFF7bMIhp5ieVySmlI9ohSyfCKCQ2p8jcaKc+qexREllJL/W+URehfeSX0siguey6U4FnjIQRKDSDaKgYt5GigTUM37NyqvbBKg1RK0j+YooXaCIisME66J3tkQ/iIf0XHglIigceInarxVSiOdTTEPWvwCYDNBUrhNx/ngsWAqP7TXoOV21caLDq5C/pFAiCSvVgCPkcWGlIfFuSsjGlpqbJUdUAgqqilIqlYgZd//dBhSSt7zhbipWkhyECDr0yqSHllITBxT00w7Vmwc2vqvGpALP7jYEpkZODGX08VvCrdvGBBONkih8JjTzuNbOBQ2tqTuNyBuw8ajjQ889n4jItBpRp0URfhHZpr5Bviru1bDBYtO4m6CHluKWaJhWSv8pBQL05Y/YbokCufy71YfD5LHiX5Uv0gU1+/q7pyPOowJ+lGhYRCtrYWJQX6Cvg2sU45/FR391SLkgXRW1PdyRqk9OIUt5TIuQc37jl1beJt+ILNm/MTvZkUmwCnRDabGcAYWBLX8Clu44Nfa+IeZPIwZ9ZAR6EeWVVdJFhKAlNViBdQF5ctUwUaoaZlscSc2rfWzZYsF7FLT6X1zP1scz1zc/NU+4qcHCcFHk+58fTrS4sJoZOThEKzTLtv4TsgqBqpjZ1yZQfp2QcBYoi7CR1cpEa6paRoRQaanbqWGB4OjG+Z3jmm3OmKPgqXufznBqe4Z62/Pdj4IPBwXCOitX+/FlDNLkUAxEKyqxGTDbaoAr1MvbvQKrOgMU0vzwp1EYQJrEcqQ2eyss3d9XRTtXPrK4/f0LfF2xbOzFy+xWInsll4Bick5tZQnzCtJMTXryFzIMhd99KC6FB3RzyKiAHESC23U79c4wPhgs0L0nEUZLrRtI/NFB2Ou3Ej7sjGw0dQGw+fQbScnJJV/fd36AWIEGeiugo1LVm0bKUBRFuFSLRMM+GIWUabd5YKOHw+x2Wf/4m05mw991Rt3fIrVz7XuNrqdz39EJVWHkaW2EjxdH5lToJ2gvfWS/VTh7Tr2tkm9lsX7Oxxd0RQROzBo0dTtI7+UvCuegFItRyFTEqbaZuNzUzckYbf4n5t/e3XwAeBTv1I5PmKXgwUo9mpEEUkIAALiGsgyLsMINpoM0bCp6WZ8GUuQllVi7W4mqU0LOcos6qqquqV5VW/FcycuOHbVZbheGLm1QcLJhtchHzp6167Niq7RCa8mUqWsLNS9y2I7tETYDRI+bXI0YPgYsovVz1KF4CLoHA2vtFJweOOnboRF9fwPO4B2OgHNnIB8fFVGw1oydUjoQoRo446pghSxcBg5TLV7FK9tdyBJbOm8S17ZNUyAWdfWseQubngyt3DPqOj71w8Tcn8KFlYYvBjsdLGRhpGp7CtpzYlJCYmCJTChJuvPExRxH+OmLBg6ecbkY662aZo2X6cDA4HRIj1CIeC3V/gc37C55eNv8U9Axs3go0NR57DJFWgczWKp0aEsg4dLreNiwFHAdMK1uEigy0qREpaSJaDi7Ke7BI6EsQnSy2VMTP3H+k5mD9KSEgwzP5USKfRWCy5jG1x9YOpDVNCxbOsWQ0eTObXpCCWdrz247CFemkL/tljIlUhElPc9G21/rj+wiJ8s8pDqQiLha3iQUOczwGnuCN+kDBgY+BEBxGjGNW8GuQyi4gBQrr30q9kRJUwVgaACPkCiDhypb+5g5eynMwv9arKco5qH7acyD5uYv7DvHnz9AzLX3Vm1Ifm06jUMLHEWs5m8HF4duhWdkRmZlmbs/DxcYswPW1ny3/+3HObBojpRsWRbvopWs8nX5h6hoeXloZz2rgI0uuDWujzK7oS0YRximu4UUHSctXRHMrKEmloaanPJUSJ75cOe5MJQKyqOlAYL8zB43Ch1pbzTFxkYrLMq1nJD8ttjxJ2f3503NzBfN488+yZp3nllSEhMxM2ODI77TZbZIPHSb3mG2+9ImKInaUcQZa7u3ZQrGXJzitUFSJUHUiXZ4DIJjMoeBGcTuKxgAgW+vxi5xQH5QaN9rNALqIVC4gmJmIE5lWYWgmM4dXDpsNLh7koYjJsLxcWASKOSE3zd5zn6HI7h5xuWW8pzs1rEHQ8Grr45JH5/IXzzMtffTXZNz5e8tv5zgzqMeFxiRDOeISWNIGhOZkuc5bK9PTmuad61Fjv3BlKUruo4Xs0MvLB5LmMiJqa1tYMG+FtIbYREME+n42ohX0qG59TEK1CHZ1BE3MTNiDCRCg699NPP3nnbbIQgo2qwrhLhYijhvrb6wGiQGokC/L3V37Nq5p5l3D/0RPdH6B6n/jgvK8yLaQl7vyBTO9bxyU5kjA6kciR0RgCMo4fRBOb6OmBi4FeG3aGYwGxthiLDByMTCl4kZhfS6fiFUyX27flgIguxsM+pzZCpGugPDbMHMZhVIjmJuZZFNgGScxzSwHxhWni8E+myFxV1VmGIuLx1NAw+yxH1m1BmItJvTJX3J7o//n+k4T7VyezHeZlfw7KMtwXUlLvc35dpmPziQ6hRMLA44rpHCMYYugCmkBvoba29mCgfMPOt6q9FiNitx2MtP34QgoVB1J6QwzrhJiwyifO58iBnDiINCRMR1yv37MKODWr0NFxNgcJEAzBBjxE5dG2aWkiRYV4cvsWFSK9ylFPr5x1wjs3yFpmWZXbmdix74fjQwlPrimN9W7f0jUxzx4fP+HzbF1g7qOhy0wJWwSIOBpNnFbuyaDJFkJOx+sGyqN3psOpntP5VwUFjb6Rf1z3yIcTHCC0ll5RIf7mA4nym8+BGqg5qI2d/YCYo7NNhWgiIbBfgoeodgQPDwcn8uZiFqG79KKVgMiwLtfTk8ln3uuKrWO8BN6dLj+fddB7ZP9k9KuhyeeghfHuoSH7PgNiZm59zrUJCZsGzlM5ZJalcaVRumM8CBC9o3d6TsCR7VhAcjJzIPL55PBbtORsSLt9+8SJMESF+Kxmt88Dv09xzwI//dZRSMBiInMnBx0dZXKWlEw+txQQ1YzebZuGKXMRcBAW4xZAFIUKmh2VtzragqViS3/+cJ5ltoPDD8bmQ6MfBo8fdzZJGHrU0jLj83RxkuD+o0cwxZLxFCpVShZaGhsb1VZpqxETWzZ4mkZElGVmZkbYDkTeeOEdvnnz5ugTKomwAfv3JvMIkZH6f9gqFDzYxHkK14GpwooKsqV1VdrWrWleKkQ15aD3pjwmgbBy5cqTu7ZsmUPB94dWlcs4t4SeGcJQL4FLnkBgAuVmobn5k4K8Vx66C++Pjg6FHj7SaWcbNvPueG4Yi0yh06meNBt/Y2NacW3WQoC0D+wr2UCumNbXdyvLDDxamPJgNQv6sNATt2/DxcJiG51GFrylHCko+Ai6evXj95qamjpwbROOnz1rXOnVHBwMiEtBP63+aXVuMJQNJHkNBgtHp+Cip79jdppAWWraGqyb5SIc9rJ3h5LtME/vXUHGjX32evMvjo4eHyF3+ikktz87KyV8qHZ0KpnG8DxrTCORLOct1AbE95fCRc/hdH7/qgDblGnbDy+gh7CWfToBYZYQsKTGNzt3bhD94/vvgU1HR0cTPsDHbTe72eNA6KLMzQ1+CXzqYC89Z0PEzMUkr+rdv2oseQ4FEE1002JivDv127hklmleUOo8aFL1DOc9Kcho3+du/sP846OjVZ6dmXjTE59nZsKkZAadDueL9PyzlUYkUm22O4ypDRkh/kIYio/CWNnYyJ2+7mHZU3/rtpDBEOGhMCOr7u7cGR7wI3ChgN8Qu7ddlW41LufnengEozaqITct7cOhR49z9xoYLOntnYOn+PvrmjjG1Gc0xPZN0+oliXWp2vCd9oYOTwpMW43dzR0cTN79OGF5z5ciuT10/5ZcGkahi+hSCDe5Fs67iv1RxJo/Go/4HDmjDysxBcN+PHlzdRRk8m2lLErSj8WqEd9Og4UABpDbUDfBxW3XOGf3uUR5eAwOeoCNKm1aOozDoIiYIoNl0NTOoVAsY5xN6q37OttdTSvoLlHeuqnx8dpr7bMujvrYJBrrGTroCgt9CrwP2DJMm7f+fEJCljPDgsRiKVnEgP0NzidlgJjhG6mFST7iZBvhFum9uvvPP7uzsqv4QmZhbPKbby6mnwEoHRAsQR24NAHxVbWxo7zOA6S2cdOmpUtfioAQENdAP7sLLTqUK9a6WS2C1kNPY/t6FdWmFs5ylrP22rXO10adWF7G5g66QXLRqtFNETxmhvj48VtysnOqNoglZHKZNkwuV99Xd6F2q19gphvvyC9HpiMTd+zYtu3Pmzt0s+uFIlfR3rsQNWwjIG7vszDtmHjc3d0NeCovu7dd5mS98E6oG0RdDH65CbT0pQ2MXOCh1t5Fy7ZbGewCxE/1utbWbZ33OmMtPkwzXYfFZBw7Ye1a+yfnJXzZPgcHc2eWaELnqb5iKkl24i8zOeQgPT1ddz3L491DHrly3R0bayDSrYsX2wWm7G941siu27HjZsKOm3/+eb/SugqPL7rbhMVgGw/v3Fl7XtMJT4H1wfJ+PHTzJoB2d1skvlidO5gwWFcHjOdmCcFBVFYrt580KFo2B9//SZwVKs64d6eTa3qtV6LotODg2bru9o5V1aXpMkGWA+jRk9dlttyMLlnOk88dZOWwK54uoqF2PDmurX0nL1XbxDfT7ahbyv4HN6a9gfDm6her//zzUeWl8ZjTS5b0YyGjD2/YYHTkeyd03CSht8nIrCiPm90JwR6rV7+wqEuoq6vzgEhDmNswKmHROIOJVtvnwKykrJe13Vu/vtO179oRISPiTlSY+PNExxv/KheWPEhucXxocvJpkr6tTY2rWPLzJwu2LFchKYNjEd6ZjvNPum92Bqc6SjFrxr4MHA1o3P+HKRjYneuhRtwa0g/39QBx1eHwcJrP9xNE7KxIqJ/SxKjg1atXe0ShiIPBuS8hmRE0yOhrr2ow2LUMEP2VVVENhw7d63SVeBR0iPQXr7tzaP3rPXvaW1tbnx64c+jr01ZfW303UV+Gq0CQ8zlHKAtWOHb6BUbo8wh4HvdMIQ+pLbV+NzpamBmhH9DI3tH9501xsCrQ45Whe5csWU7AQrpsCIeyeJ6IQH6DoPWCW3sMEZXCEFq8HLYAwkGP3OBN7wmYbypaiU6Ay07PwdNjvJTvD91bB4je1wqchNwyu3UguyQ6BVcWCK21vj5eoe/GTWzNcBVfFp8QtMnzeFm5gKiAht2bSaCXtrS0lANiRaBdZvJ+8tCf4GLuze5uXePxynxA3N2EJa56g5ZFQMQCI1BigRJhm5hkycI4ZCabKQmGnM7NHU5yO2jLQwAQ4nzyZBHEGW0jYix1h++Biw2ufefeHalILKbadnUxaotxOIWvLdxsBpXpM73BVFfvd7eam03D3qc46k35BZZRcJ75+aFwhpyfXw7lPSfTLiK5V+SccNNeN0oPtK+8nAqIS04jKOLbP75HXURUPgImIrFfiI4eICULzpFCQy050MhNJbVnRsKPkGwFMyrEuQhF9A+aunPv3qanrhaD73zOyKl46GTwOHxXRJevLXpThkCksKKG22uSutomnR2VElZ7ilhbnjQlzafG9PiHtPQYpZdU3n8yernMLoI3FiBHv9erfl92uXVVFQlF7Fe5mN74veYEcdZEhEAKm7cQENWq080yPnv27Dgj9s6d9cNJRzHYJquTy7YsWrTstAEg+lfnxt47tO7eU6b3O0BM7IrQVygU+nDu3/U8wrdL37ZYP2l4OK+9PSPJ5oOzo6M3uzVFoK1b3bJzMw1FDKWWlgDi0NB9Xzu/zJTGZIaL0iRI5sJiWacxGCjiGyx21d1oo/M62yYIKKFqIf43of2OHTucs89W0SmU9kOH1meUwSMTK6HlVh2OASLNM2qg886dQ52uFnXvCr5IBuz8AgP97NYttut6vs6uc13g9vSphnYUMYP59H6WYxAzKSVMe6HYvyWGFhPjz6HHlIRYK+3dU3Wn4Nba0VWrAgKSCRTnefOML43UpqsQEY1Vu8PJVzV1LhMQ4ANCouC/CVcDYl0wm4qjMAYAMS9CEXAGqd1lsGjXMisrNWJ7u2otQn0p2G8aC1XYzm7x4nWLfZ+vW9y5riF9+3ZaZkM7LEZR3qRjllgUAYjz9MgUWzcFnW4UMuKJJxLxIIotrNoAaHd8kk132Dtc6il+W2uFIhIAsdQGdmVARICQiBP/B9DdfjWqKAa6umIZnXAPK8OX6bS/KXnRFliOKGJ/dVRN+7179zq7zh1/dG0/KxbggHHdusURN9atO7DObqr2dCwtqT0P8iXjtVLpSM48KtFeOM+RG2jnZ5tfUlIqFX7q2F1RUVEolUpr4OgO05icu0P3YQs1f7MKcS8Gs2p3eiH0DZeJqicxqOJ5s4Tus4QvWl2ZooCxsTOrnt1Zv6k1s0/+pTdgDezRqg2wn3PLIgOti7Eej44DomfGAfBxMdA9SPr1wIG/nzr17MYUHZ+U9/79wMCdYKWMGXiQr609z51lm+k6UlJS4slZs3LX8uVFqGZeLl68WH9M9GKHcQuVGbO5tgjqIhGLWXU4vQIaMUAkEKlAqDZxIQDWqSxkch/84x//+BFeBevhpmXNy+C9S+LGkCY4NUFdvBIUlfH0zvqvrl+vDl3rFeIp0zd+BUKzZ7kmAniayNSGQqTW0mk1ee8z6J2Twc2xfgdZ2trw0AGNGgqELdVkZKUVaLfV8uW7n9yxi0gJkOy4f4lh22apQtyFUSE6fa9ykYTzbAbChf8J8gtvEREJQNvdH38EyAfA2Hpn8PQig7hVhL1WgEgRfXIOyqtZd+ip6/DQ0KsvQgoeIH9dt+eyvba54b4cqZejMu1SC5UWmxf1npvxOlhsaufGRxHN0+goYk81Y83K3UVWRSutlhcdfjLZ0D7Nze2+RXOtUSPebYLT4bHfjFDExziESIZRYiHIHQQWeiQyiAgGu+rHb/oICTPc8LLfasvK3i9Na7aj49UnXb3cqTyoi3mvnMh0ORuHp0m5zyfczef/MH9+9vz5Do5p1jHUrtip3Lyu2DsezX12B/nwFdrztroYhVwKiWHg1qy0AgetAPNwt87Xc1dzHm+rsK2paXMBRIPlCBaDIp5HEfEE6OUWqhhTExLqdnhI6AgWvQ/TC3DqUOtcP7T+3NQAd+WWZVt6ewMQQGQI4TDiSuyAr6sNM4xFp4fRcWSxmH1f13C+g6Hhvn1wGOFP65/GD9DeR7VRWh979YGL5g6GDtl/EZDzY2LysYQ1AAiB7thtdfiJ5tUXTopYGy67sLCfbUQqWpKMYFBERgH0248pBLqUw4FDdCFLwpZHmTJxwA932pp8VA4C5o86OutfnisjYE8bnNy1aEkvjFdUhmeWe/NIvhHDRi6WUuktziwcw/Lhw3EQDI9pArHARf5bQS+Oazsgn8IzJwTCLhyxiU5p4vHWwC8ezMMQaEBcLr0LiN//+IciKcJNH+o/AiNc0XKCCrE3YFTtIm0+yPAiSClh4hCMagYgBPzj33Hedn39y1Z9DNK06AIUbwOYXWAE9Eq1d7b2z48S0OmeHEGIjILnO8rEAi8vL4FY6egocFFmZ89Q9P26FLFMKucyXzG9qvHBL7M69RxuIa/cjUZauLsIEL8vSIKqCoW/r7CAhxT1q56wGTtyZnRUU/MdHqHvMzQERkOgdMGjAwAwIjhYipAt6LX++vr155J4GoQ1WyDSi3atRBHJlu56zs6FEgGNYRTq1OGVxsLROXKZY5BSJvZy8fIic7Kys5tFlNhYAr8NR76VeBR+dN+/g9at+/u6v/tisWuK7qKIGRW7d1+FxGwMRBEHMgCRYIVT+RRwxEkTBIi4+vmAiEJeFDOxQAgi4PYDHghMXA+EeWUw7mBhMV5YhPaLEOnqoIsmt4Ty/HxaTEnBmcsh5QxGeSUI1mF29nwHExa53tCwn+pJUbSxGFQh7fkfthiM/gFABMwD8Dl59+7dUHAaCg/vvgZTaIE+IGaaZhT+mLxmF6KKZXLFeRUiBc7+wcHZWAuR2Wes8AXgnybKeB1FrLGF+1gYQFy2ZSUgUuDJQOVF+xkZmV5dGlMycwQY+dSQhw/PGmYJwsTOcKCsy6exXMg0Gl5iw2fjGHxKwHPfSIytn8pGPx4A7F6+e/ndnCTmYdRFHU2nzMV2+nlThd8H4JoAEcStKJhFJJB/ABvR18WLcrwGVvWQFaUA8GCDvH793Ln16y0gzoC4aws0O1aACDbSxM73ZyR4XHp4T8ila2fO14fSYx4CI0yZfBmcnugFAR/NE9dPMSLzbcj11Z7k6Sn9yMhMsHFdJiy1puWgu062BMHMVfSrCrrsArvyWgtHp6k4rMpFSuEoeg5xlYsQaPtQPvS6eNtG3TvimNe3aUKqAODLl5tetvoiGhoI9vQiOBkrOq1CPCbQvX+LgccXl4aHlHw+P10QwioHxGxdOUUUpGturmfPMqLlGyXvx4vIzLYwF1pQQh932tcWKQNGfSxMvEVFEGcnHkF469q1awXXRhsjMtvaawZGcyR4daDxhZqovdcAkVL+AzCq81pOgHyBOOs3rJ8EB1HCHQnevq5YcJGwZtEWOLyDPRp0LMz+fhi842rDQ0K26pwxzXE6c+mhSaq9PTwIOqira6KrpNHycV8CKGPFZKaLi9OmhDvnpXhXfd5RvwMpsOSbilAXawio6GR4tuhaV+bA+7zCa4OfWMAIVhEAERivihAC3usHldCMUXIhBlgc3cY18NC5c5tALzxMhVEUdLohIFvg5hCKSAUXXexvidCbprjiEeuS468Ke89QOPvW2pvowsSMyl6XnG8U8IV4Bp9uxOewJsxO/XJqKIeL59qmQI3VwAQYAGJHEpYSVl8JB13G+544RdhFxLY9DsqVknEoIgKIEGkUEcdXAf4AhA5ZbBUiY1+9aw3as65falEYvDYRh46AOMxKaLrhNIJKhY6RrxsGvRoOVJzfUu60P4DO4cDzkya68HJGEd1hCOjgcgNq05mWnsqcU2ZDr1+//nUVHq+AWQgQoXIvd+oi8M2NjdEG3/jJ6IBfpi/DRiiR5+YyER6Cq1Dlw2gAIEqN1S4aAqQ3AfzCkQ3nlzOZbRkZSWVtHt4ebAStUzjs3kWQL4AI6pcGkfEqQmoxiXrmC53TbKjrMm4CbGio69YmpPKphWfwY8XpDH54s7Pg1B7tIbNfTl2/zITIQg8QgFbuGwq8IyCqdFzTKSKjL9HbwiLKYtibQiDgnQAQDsRQRMgXldCccRSBi1RLWJgC5uO1a+ssBl/AsQrcFdTC4LDJqkep54z0tIRyaFV0HCoYvyk4LoNc7mC/ts6lMgsi7FxX9yJYLuczcprGKBBmz6qLzt6/7Cl4bTaZ8PrU+X6eKwEQIc67nxPIeiazhDrdgwN2MGDARmjLg06bJzoPgPBrGhCpad9cBEZTRAPBi+cDsLAQJjhfX1/TYVMMKiLsL+hh95yekRH//Px8Kl4tQIQBQuCwYy0who4be+VIONU0xpkvltXcaaN8MlnqeNFZPjRptuf668lTvzxiK1yxasT9A0jYQrWLFzV1uk0EgYtnZRcYYctTVW6dbRU4qlTighJCC4WGOgqHxYqyUWIZc8qVIWLG5nmoEQl42AJPwnjlf4zmme5Pp1JpRiDVb/lGfENAXJvAehtuJOHQ6HRhVMeRgGqjdDKDIVM6K2eGhswmU8FDs+NkfVsMGuii5U4KLMsdEK0tm68ConN20r8RF/uVJY/CSZ2Ozk1vSZgNk2OMpouhCjEIbhDaqBCz2a4RsYXMdjUiVB3RmiI4Ad2PrkUjTxr8Fr5zM6oFoLe07Lq1KGNYOlDRGVFgaU5vfz6NTPW67ews+FBw6tTrd6dOJZxrsC3jgovJu4t2VxCwDBNA9ERWwcHc1SDDRD81oF1nZ6dvIQQZjhS3WTDxFBtG5Q/zAdEBlQkbQSSQ3sAopw/4xmZ8/eYilgvDJI/ABER6Og0OMqmlwKdihKu23GStSnIjMpXsAR+iRNzeMQqdr3TWlR2fNLs+aXbq/KnJ188VSTgUEZbiNBwlhaEuFhYAzYes+c1ldqDOTF9YjryM63BUN5nDxHNFTDbRGkV0UCuRgBNDzIGxmcqYis37mvfYFDxExTW18Lh581+A1rDILNhlaAAAAABJRU5ErkJggg=="/> +</defs> +</svg> +`; diff --git a/blocksuite/blocks/src/root-block/widgets/slash-menu/utils.ts b/blocksuite/blocks/src/root-block/widgets/slash-menu/utils.ts new file mode 100644 index 0000000000..9b3547461c --- /dev/null +++ b/blocksuite/blocks/src/root-block/widgets/slash-menu/utils.ts @@ -0,0 +1,142 @@ +import type { TextFormatConfig } from '@blocksuite/affine-components/rich-text'; +import { isInsideBlockByFlavour } from '@blocksuite/affine-shared/utils'; +import { assertType } from '@blocksuite/global/utils'; +import type { BlockModel } from '@blocksuite/store'; + +import type { TextConversionConfig } from '../../../_common/configs/text-conversion.js'; +import type { + SlashMenuActionItem, + SlashMenuContext, + SlashMenuGroupDivider, + SlashMenuItem, + SlashMenuItemGenerator, + SlashMenuStaticItem, + SlashSubMenu, +} from './config.js'; +import { slashMenuToolTips } from './tooltips/index.js'; + +export function isGroupDivider( + item: SlashMenuStaticItem +): item is SlashMenuGroupDivider { + return 'groupName' in item; +} + +export function notGroupDivider( + item: SlashMenuStaticItem +): item is Exclude<SlashMenuStaticItem, SlashMenuGroupDivider> { + return !isGroupDivider(item); +} + +export function isActionItem( + item: SlashMenuStaticItem +): item is SlashMenuActionItem { + return 'action' in item; +} + +export function isSubMenuItem(item: SlashMenuStaticItem): item is SlashSubMenu { + return 'subMenu' in item; +} + +export function isMenuItemGenerator( + item: SlashMenuItem +): item is SlashMenuItemGenerator { + return typeof item === 'function'; +} + +export function slashItemClassName(item: SlashMenuStaticItem) { + const name = isGroupDivider(item) ? item.groupName : item.name; + + return name.split(' ').join('-').toLocaleLowerCase(); +} + +export function filterEnabledSlashMenuItems( + items: SlashMenuItem[], + context: SlashMenuContext +): SlashMenuStaticItem[] { + const result = items + .map(item => (isMenuItemGenerator(item) ? item(context) : item)) + .flat() + .filter(item => (item.showWhen ? item.showWhen(context) : true)) + .map(item => { + if (isSubMenuItem(item)) { + return { + ...item, + subMenu: filterEnabledSlashMenuItems(item.subMenu, context), + }; + } else { + return { ...item }; + } + }); + return result; +} + +export function getFirstNotDividerItem( + items: SlashMenuStaticItem[] +): SlashMenuActionItem | SlashSubMenu | null { + const firstItem = items.find(item => !isGroupDivider(item)); + assertType<SlashMenuActionItem | SlashSubMenu | undefined>(firstItem); + return firstItem ?? null; +} + +export function insideEdgelessText(model: BlockModel) { + return isInsideBlockByFlavour(model.doc, model, 'affine:edgeless-text'); +} + +export function tryRemoveEmptyLine(model: BlockModel) { + if (model.text?.length === 0) { + model.doc.deleteBlock(model); + } +} + +export function createConversionItem( + config: TextConversionConfig +): SlashMenuActionItem { + const { name, description, icon, flavour, type } = config; + return { + name, + description, + icon, + tooltip: slashMenuToolTips[name], + showWhen: ({ model }) => model.doc.schema.flavourSchemaMap.has(flavour), + action: ({ rootComponent }) => { + rootComponent.std.command + .chain() + .updateBlockType({ + flavour, + props: { type }, + }) + .run(); + }, + }; +} + +export function createTextFormatItem( + config: TextFormatConfig +): SlashMenuActionItem { + const { name, icon, id, action } = config; + return { + name, + icon, + tooltip: slashMenuToolTips[name], + action: ({ rootComponent, model }) => { + const { std, host } = rootComponent; + + if (model.text?.length !== 0) { + std.command + .chain() + .formatBlock({ + blockSelections: [ + std.selection.create('block', { + blockId: model.id, + }), + ], + styles: { [id]: true }, + }) + .run(); + } else { + // like format bar when the line is empty + action(host); + } + }, + }; +} diff --git a/blocksuite/blocks/src/root-block/widgets/surface-ref-toolbar/config.ts b/blocksuite/blocks/src/root-block/widgets/surface-ref-toolbar/config.ts new file mode 100644 index 0000000000..5067bdaa03 --- /dev/null +++ b/blocksuite/blocks/src/root-block/widgets/surface-ref-toolbar/config.ts @@ -0,0 +1,110 @@ +import { + CopyIcon, + DeleteIcon, + DownloadIcon, +} from '@blocksuite/affine-components/icons'; +import { toast } from '@blocksuite/affine-components/toast'; +import type { MenuItemGroup } from '@blocksuite/affine-components/toolbar'; +import { downloadBlob } from '@blocksuite/affine-shared/utils'; + +import type { EdgelessRootPreviewBlockComponent } from '../../edgeless/edgeless-root-preview-block.js'; +import type { SurfaceRefToolbarContext } from './context.js'; +import { edgelessToBlob, writeImageBlobToClipboard } from './utils.js'; + +export const BUILT_IN_GROUPS: MenuItemGroup<SurfaceRefToolbarContext>[] = [ + { + type: 'clipboard', + when: ctx => !!(ctx.blockComponent.referenceModel && ctx.doc.root), + items: [ + { + type: 'copy', + label: 'Copy', + icon: CopyIcon, + action: ctx => { + if (!(ctx.blockComponent.referenceModel && ctx.doc.root?.id)) { + ctx.close(); + return; + } + + const referencedModel = ctx.blockComponent.referenceModel; + const editor = ctx.blockComponent.previewEditor; + const edgelessRootElement = editor?.view.getBlock(ctx.doc.root.id); + const surfaceRenderer = ( + edgelessRootElement as EdgelessRootPreviewBlockComponent + )?.surface?.renderer; + + if (!surfaceRenderer) { + ctx.close(); + return; + } + + edgelessToBlob(ctx.host, { + surfaceRefBlock: ctx.blockComponent, + surfaceRenderer, + edgelessElement: referencedModel, + }) + .then(blob => writeImageBlobToClipboard(blob)) + .then(() => toast(ctx.host, 'Copied image to clipboard')) + .catch(console.error); + + ctx.close(); + }, + }, + { + type: 'download', + label: 'Download', + icon: DownloadIcon, + action: ctx => { + if (!(ctx.blockComponent.referenceModel && ctx.doc.root?.id)) { + ctx.close(); + return; + } + + const referencedModel = ctx.blockComponent.referenceModel; + const editor = ctx.blockComponent.previewEditor; + const edgelessRootElement = editor?.view.getBlock(ctx.doc.root.id); + const surfaceRenderer = ( + edgelessRootElement as EdgelessRootPreviewBlockComponent + )?.surface?.renderer; + + if (!surfaceRenderer) { + ctx.close(); + return; + } + + edgelessToBlob(ctx.host, { + surfaceRefBlock: ctx.blockComponent, + surfaceRenderer, + edgelessElement: referencedModel, + }) + .then(blob => { + const fileName = + 'title' in referencedModel + ? (referencedModel.title?.toString() ?? 'Edgeless Content') + : 'Edgeless Content'; + + downloadBlob(blob, fileName); + }) + .catch(console.error); + + ctx.close(); + }, + }, + ], + }, + { + type: 'delete', + items: [ + { + type: 'delete', + label: 'Delete', + icon: DeleteIcon, + disabled: ({ doc }) => doc.readonly, + action: ({ blockComponent, doc, close }) => { + doc.deleteBlock(blockComponent.model); + close(); + }, + }, + ], + }, +]; diff --git a/blocksuite/blocks/src/root-block/widgets/surface-ref-toolbar/context.ts b/blocksuite/blocks/src/root-block/widgets/surface-ref-toolbar/context.ts new file mode 100644 index 0000000000..7fba935eab --- /dev/null +++ b/blocksuite/blocks/src/root-block/widgets/surface-ref-toolbar/context.ts @@ -0,0 +1,44 @@ +import type { SurfaceRefBlockComponent } from '../../../surface-ref-block/surface-ref-block.js'; +import { MenuContext } from '../../configs/toolbar.js'; + +export class SurfaceRefToolbarContext extends MenuContext { + override close = () => { + this.abortController.abort(); + }; + + get doc() { + return this.blockComponent.doc; + } + + get host() { + return this.blockComponent.host; + } + + get selectedBlockModels() { + if (this.blockComponent) return [this.blockComponent.model]; + return []; + } + + get std() { + return this.host.std; + } + + constructor( + public blockComponent: SurfaceRefBlockComponent, + public abortController: AbortController + ) { + super(); + } + + isEmpty() { + return !this.blockComponent; + } + + isMultiple() { + return false; + } + + isSingle() { + return true; + } +} diff --git a/blocksuite/blocks/src/root-block/widgets/surface-ref-toolbar/surface-ref-toolbar.ts b/blocksuite/blocks/src/root-block/widgets/surface-ref-toolbar/surface-ref-toolbar.ts new file mode 100644 index 0000000000..cce075508a --- /dev/null +++ b/blocksuite/blocks/src/root-block/widgets/surface-ref-toolbar/surface-ref-toolbar.ts @@ -0,0 +1,216 @@ +import { HoverController } from '@blocksuite/affine-components/hover'; +import { + CaptionIcon, + CenterPeekIcon, + EdgelessModeIcon, + MoreVerticalIcon, + OpenIcon, + SmallArrowDownIcon, +} from '@blocksuite/affine-components/icons'; +import { isPeekable, peek } from '@blocksuite/affine-components/peek'; +import { + cloneGroups, + type MenuItem, + type MenuItemGroup, + renderGroups, + renderToolbarSeparator, +} from '@blocksuite/affine-components/toolbar'; +import type { SurfaceRefBlockModel } from '@blocksuite/affine-model'; +import { WidgetComponent } from '@blocksuite/block-std'; +import { offset, shift } from '@floating-ui/dom'; +import { html, nothing } from 'lit'; +import { ifDefined } from 'lit/directives/if-defined.js'; +import { join } from 'lit/directives/join.js'; +import { repeat } from 'lit/directives/repeat.js'; + +import { PAGE_HEADER_HEIGHT } from '../../../_common/consts.js'; +import type { SurfaceRefBlockComponent } from '../../../surface-ref-block/index.js'; +import { getMoreMenuConfig } from '../../configs/toolbar.js'; +import { BUILT_IN_GROUPS } from './config.js'; +import { SurfaceRefToolbarContext } from './context.js'; + +export const AFFINE_SURFACE_REF_TOOLBAR = 'affine-surface-ref-toolbar'; + +export class AffineSurfaceRefToolbar extends WidgetComponent< + SurfaceRefBlockModel, + SurfaceRefBlockComponent +> { + /* + * Caches the more menu items. + * Currently only supports configuring more menu. + */ + moreGroups: MenuItemGroup<SurfaceRefToolbarContext>[] = + cloneGroups(BUILT_IN_GROUPS); + + private _hoverController = new HoverController( + this, + ({ abortController }) => { + const surfaceRefBlock = this.block; + const selection = this.host.selection; + + const textSelection = selection.find('text'); + if ( + !!textSelection && + (!!textSelection.to || !!textSelection.from.length) + ) { + return null; + } + + const blockSelections = selection.filter('block'); + if ( + blockSelections.length > 1 || + (blockSelections.length === 1 && + blockSelections[0].blockId !== surfaceRefBlock.blockId) + ) { + return null; + } + + return { + template: SurfaceRefToolbarOptions({ + context: new SurfaceRefToolbarContext(this.block, abortController), + groups: this.moreGroups, + }), + computePosition: { + referenceElement: this.block, + placement: 'top-start', + middleware: [ + offset({ + mainAxis: 12, + crossAxis: 10, + }), + shift({ + crossAxis: true, + padding: { + top: PAGE_HEADER_HEIGHT + 12, + bottom: 12, + right: 12, + }, + }), + ], + autoUpdate: true, + }, + }; + } + ); + + override connectedCallback() { + super.connectedCallback(); + + this.moreGroups = getMoreMenuConfig(this.std).configure(this.moreGroups); + this._hoverController.setReference(this.block); + } +} + +declare global { + interface HTMLElementTagNameMap { + [AFFINE_SURFACE_REF_TOOLBAR]: AffineSurfaceRefToolbar; + } +} + +function SurfaceRefToolbarOptions({ + context, + groups, +}: { + context: SurfaceRefToolbarContext; + groups: MenuItemGroup<SurfaceRefToolbarContext>[]; +}) { + const { blockComponent, abortController } = context; + const readonly = blockComponent.model.doc.readonly; + const hasValidReference = !!blockComponent.referenceModel; + + const openMenuActions: MenuItem[] = []; + if (hasValidReference) { + openMenuActions.push({ + type: 'open-in-edgeless', + label: 'Open in edgeless', + icon: EdgelessModeIcon, + action: () => blockComponent.viewInEdgeless(), + disabled: readonly, + }); + + if (isPeekable(blockComponent)) { + openMenuActions.push({ + type: 'open-in-center-peek', + label: 'Open in center peek', + icon: CenterPeekIcon, + action: () => peek(blockComponent), + }); + } + } + + const moreMenuActions = renderGroups(groups, context); + + const buttons = [ + openMenuActions.length + ? html` + <editor-menu-button + .contentPadding=${'8px'} + .button=${html` + <editor-icon-button + aria-label="Open doc" + .justify=${'space-between'} + .labelHeight=${'20px'} + > + ${OpenIcon}${SmallArrowDownIcon} + </editor-icon-button> + `} + > + <div data-size="large" data-orientation="vertical"> + ${repeat( + openMenuActions, + button => button.label, + ({ label, icon, action, disabled }) => html` + <editor-menu-action + aria-label=${ifDefined(label)} + ?disabled=${disabled} + @click=${action} + > + ${icon}<span class="label">${label}</span> + </editor-menu-action> + ` + )} + </div> + </editor-menu-button> + ` + : nothing, + + readonly + ? nothing + : html` + <editor-icon-button + aria-label="Caption" + .tooltip=${'Add Caption'} + @click=${() => { + abortController.abort(); + blockComponent.captionElement.show(); + }} + > + ${CaptionIcon} + </editor-icon-button> + `, + + html` + <editor-menu-button + .contentPadding=${'8px'} + .button=${html` + <editor-icon-button aria-label="More" .tooltip=${'More'}> + ${MoreVerticalIcon} + </editor-icon-button> + `} + > + <div data-size="large" data-orientation="vertical"> + ${moreMenuActions} + </div> + </editor-menu-button> + `, + ]; + + return html` + <editor-toolbar class="surface-ref-toolbar-container"> + ${join( + buttons.filter(button => button !== nothing), + renderToolbarSeparator + )} + </editor-toolbar> + `; +} diff --git a/blocksuite/blocks/src/root-block/widgets/surface-ref-toolbar/utils.ts b/blocksuite/blocks/src/root-block/widgets/surface-ref-toolbar/utils.ts new file mode 100644 index 0000000000..d566cadb13 --- /dev/null +++ b/blocksuite/blocks/src/root-block/widgets/surface-ref-toolbar/utils.ts @@ -0,0 +1,50 @@ +import type { CanvasRenderer } from '@blocksuite/affine-block-surface'; +import type { EditorHost } from '@blocksuite/block-std'; +import { assertExists, Bound } from '@blocksuite/global/utils'; + +import { ExportManager } from '../../../_common/export-manager/export-manager.js'; +import { isTopLevelBlock } from '../../../root-block/edgeless/utils/query.js'; +import type { SurfaceRefBlockComponent } from '../../../surface-ref-block/surface-ref-block.js'; + +export const edgelessToBlob = async ( + host: EditorHost, + options: { + surfaceRefBlock: SurfaceRefBlockComponent; + surfaceRenderer: CanvasRenderer; + edgelessElement: BlockSuite.EdgelessModel; + } +): Promise<Blob> => { + const { edgelessElement } = options; + const exportManager = host.std.get(ExportManager); + const bound = Bound.deserialize(edgelessElement.xywh); + const isBlock = isTopLevelBlock(edgelessElement); + + return exportManager + .edgelessToCanvas( + options.surfaceRenderer, + bound, + undefined, + isBlock ? [edgelessElement] : undefined, + isBlock ? undefined : [edgelessElement], + { zoom: options.surfaceRenderer.viewport.zoom } + ) + .then(canvas => { + assertExists(canvas); + return new Promise((resolve, reject) => { + canvas.toBlob( + blob => (blob ? resolve(blob) : reject(null)), + 'image/png' + ); + }); + }); +}; + +export const writeImageBlobToClipboard = async (blob: Blob) => { + // @ts-expect-error FIXME: ts error + if (window.apis?.clipboard?.copyAsImageFromString) { + // @ts-expect-error FIXME: ts error + await window.apis.clipboard?.copyAsImageFromString(blob); + } else { + await navigator.clipboard.write([new ClipboardItem({ [blob.type]: blob })]); + } +}; diff --git a/blocksuite/blocks/src/root-block/widgets/viewport-overlay/viewport-overlay.ts b/blocksuite/blocks/src/root-block/widgets/viewport-overlay/viewport-overlay.ts new file mode 100644 index 0000000000..bed5a7854e --- /dev/null +++ b/blocksuite/blocks/src/root-block/widgets/viewport-overlay/viewport-overlay.ts @@ -0,0 +1,88 @@ +import type { RootBlockModel } from '@blocksuite/affine-model'; +import { WidgetComponent } from '@blocksuite/block-std'; +import { css, html } from 'lit'; +import { state } from 'lit/decorators.js'; +import { classMap } from 'lit/directives/class-map.js'; +import { styleMap } from 'lit/directives/style-map.js'; + +import type { PageRootBlockComponent } from '../../index.js'; + +export const AFFINE_VIEWPORT_OVERLAY_WIDGET = 'affine-viewport-overlay-widget'; + +export class AffineViewportOverlayWidget extends WidgetComponent< + RootBlockModel, + PageRootBlockComponent +> { + static override styles = css` + .affine-viewport-overlay-widget { + position: absolute; + top: 0; + left: 0; + background: transparent; + pointer-events: none; + z-index: calc(var(--affine-z-index-popover) - 1); + } + + .affine-viewport-overlay-widget.lock { + pointer-events: auto; + } + `; + + override connectedCallback() { + super.connectedCallback(); + this.handleEvent( + 'dragStart', + () => { + return this._lockViewport; + }, + { global: true } + ); + this.handleEvent( + 'pointerDown', + () => { + return this._lockViewport; + }, + { global: true } + ); + this.handleEvent( + 'click', + () => { + return this._lockViewport; + }, + { global: true } + ); + } + + lock() { + this._lockViewport = true; + } + + override render() { + const classes = classMap({ + 'affine-viewport-overlay-widget': true, + lock: this._lockViewport, + }); + const style = styleMap({ + width: `${this._lockViewport ? '100vw' : '0'}`, + height: `${this._lockViewport ? '100%' : '0'}`, + }); + return html` <div class=${classes} style=${style}></div> `; + } + + toggleLock() { + this._lockViewport = !this._lockViewport; + } + + unlock() { + this._lockViewport = false; + } + + @state() + private accessor _lockViewport = false; +} + +declare global { + interface HTMLElementTagNameMap { + [AFFINE_VIEWPORT_OVERLAY_WIDGET]: AffineViewportOverlayWidget; + } +} diff --git a/blocksuite/blocks/src/schemas.ts b/blocksuite/blocks/src/schemas.ts new file mode 100644 index 0000000000..8a1d887cb9 --- /dev/null +++ b/blocksuite/blocks/src/schemas.ts @@ -0,0 +1,56 @@ +// Import models only, the bundled file should not include anything else. +import { SurfaceBlockSchema } from '@blocksuite/affine-block-surface'; +import { + AttachmentBlockSchema, + BookmarkBlockSchema, + CodeBlockSchema, + DatabaseBlockSchema, + DividerBlockSchema, + EdgelessTextBlockSchema, + EmbedFigmaBlockSchema, + EmbedGithubBlockSchema, + EmbedHtmlBlockSchema, + EmbedLinkedDocBlockSchema, + EmbedLoomBlockSchema, + EmbedSyncedDocBlockSchema, + EmbedYoutubeBlockSchema, + FrameBlockSchema, + ImageBlockSchema, + LatexBlockSchema, + ListBlockSchema, + NoteBlockSchema, + ParagraphBlockSchema, + RootBlockSchema, + SurfaceRefBlockSchema, +} from '@blocksuite/affine-model'; +import type { BlockSchema } from '@blocksuite/store'; +import type { z } from 'zod'; + +import { DataViewBlockSchema } from './data-view-block/data-view-model.js'; + +/** Built-in first party block models built for affine */ +export const AffineSchemas: z.infer<typeof BlockSchema>[] = [ + CodeBlockSchema, + ParagraphBlockSchema, + RootBlockSchema, + ListBlockSchema, + NoteBlockSchema, + DividerBlockSchema, + ImageBlockSchema, + SurfaceBlockSchema, + BookmarkBlockSchema, + FrameBlockSchema, + DatabaseBlockSchema, + SurfaceRefBlockSchema, + DataViewBlockSchema, + AttachmentBlockSchema, + EmbedYoutubeBlockSchema, + EmbedFigmaBlockSchema, + EmbedGithubBlockSchema, + EmbedHtmlBlockSchema, + EmbedLinkedDocBlockSchema, + EmbedSyncedDocBlockSchema, + EmbedLoomBlockSchema, + EdgelessTextBlockSchema, + LatexBlockSchema, +]; diff --git a/blocksuite/blocks/src/surface-block/mini-mindmap/index.ts b/blocksuite/blocks/src/surface-block/mini-mindmap/index.ts new file mode 100644 index 0000000000..40d70e5dcd --- /dev/null +++ b/blocksuite/blocks/src/surface-block/mini-mindmap/index.ts @@ -0,0 +1,4 @@ +export { markdownToMindmap, MiniMindmapPreview } from './mindmap-preview.js'; +export { MindmapRootBlock } from './mindmap-root-block.js'; +export { MindmapService } from './minmap-service.js'; +export { MindmapSurfaceBlock } from './surface-block.js'; diff --git a/blocksuite/blocks/src/surface-block/mini-mindmap/mindmap-preview.ts b/blocksuite/blocks/src/surface-block/mini-mindmap/mindmap-preview.ts new file mode 100644 index 0000000000..957a5428a8 --- /dev/null +++ b/blocksuite/blocks/src/surface-block/mini-mindmap/mindmap-preview.ts @@ -0,0 +1,297 @@ +/* eslint-disable @typescript-eslint/no-non-null-assertion */ +import type { SurfaceBlockModel } from '@blocksuite/affine-block-surface'; +import { + MindmapStyleFour, + MindmapStyleOne, + MindmapStyleThree, + MindmapStyleTwo, +} from '@blocksuite/affine-components/icons'; +import { + type MindmapElementModel, + MindmapStyle, +} from '@blocksuite/affine-model'; +import { BlockStdScope, type EditorHost } from '@blocksuite/block-std'; +import { WithDisposable } from '@blocksuite/global/utils'; +import { + type Doc, + DocCollection, + type DocCollectionOptions, + IdGeneratorType, + Job, + Schema, +} from '@blocksuite/store'; +import { css, html, LitElement, 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 { MarkdownAdapter } from '../../_common/adapters/markdown/index.js'; +import { MiniMindmapSchema, MiniMindmapSpecs } from './spec.js'; + +const mindmapStyles = [ + [MindmapStyle.ONE, MindmapStyleOne], + [MindmapStyle.TWO, MindmapStyleTwo], + [MindmapStyle.THREE, MindmapStyleThree], + [MindmapStyle.FOUR, MindmapStyleFour], +]; + +type Unpacked<T> = T extends (infer U)[] ? U : T; + +export class MiniMindmapPreview extends WithDisposable(LitElement) { + static override styles = css` + mini-mindmap-root-block, + mini-mindmap-surface-block, + editor-host { + display: block; + width: 100%; + height: 100%; + } + + .select-template-title { + align-self: stretch; + + color: var( + --light-textColor-textSecondaryColor, + var(--textColor-textSecondaryColor, #8e8d91) + ); + + font-family: Inter; + font-size: 12px; + font-style: normal; + font-weight: 500; + line-height: 20px; + + margin-bottom: 4px; + } + + .template { + display: flex; + gap: 12px; + } + + .template-item { + box-sizing: border-box; + border: 2px solid var(--affine-border-color); + border-radius: 4px; + padding: 4px 6px; + } + + .template-item.active, + .template-item:hover { + border-color: var(--affine-brand-color); + } + + .template-item > svg { + display: block; + } + `; + + doc?: Doc; + + mindmapId?: string; + + surface?: SurfaceBlockModel; + + get _mindmap(): MindmapElementModel | null { + return ( + (this.surface?.getElementById( + this.mindmapId || '' + ) as MindmapElementModel) ?? null + ); + } + + private _createTemporaryDoc() { + const schema = new Schema(); + schema.register(MiniMindmapSchema); + const options: DocCollectionOptions = { + id: 'MINI_MINDMAP_TEMPORARY', + schema, + idGenerator: IdGeneratorType.NanoID, + awarenessSources: [], + }; + + const collection = new DocCollection(options); + collection.meta.initialize(); + collection.start(); + + const doc = collection.createDoc({ id: 'doc:home' }).load(); + const rootId = doc.addBlock('affine:page', {}); + const surfaceId = doc.addBlock('affine:surface', {}, rootId); + const surface = doc.getBlockById(surfaceId) as SurfaceBlockModel; + doc.resetHistory(); + + return { + doc, + surface, + }; + } + + private _switchStyle(style: MindmapStyle) { + if (!this._mindmap || !this.doc) { + return; + } + + this.doc.transact(() => { + this._mindmap!.style = style; + }); + + this.ctx.set({ style }); + this.requestUpdate(); + } + + private _toMindmapNode(answer: string, doc: Doc) { + return markdownToMindmap(answer, doc); + } + + override connectedCallback(): void { + super.connectedCallback(); + + const tempDoc = this._createTemporaryDoc(); + const mindmapNode = this._toMindmapNode(this.answer, tempDoc.doc); + + if (!mindmapNode) { + return; + } + + this.doc = tempDoc.doc; + this.surface = tempDoc.surface; + this.mindmapId = this.surface.addElement({ + type: 'mindmap', + children: mindmapNode, + style: this.mindmapStyle ?? MindmapStyle.FOUR, + }); + this.surface.getElementById(this.mindmapId) as MindmapElementModel; + + const centerPosition = this._mindmap?.tree.element.xywh; + + this.ctx.set({ + node: mindmapNode, + style: MindmapStyle.FOUR, + centerPosition, + }); + } + + override render() { + if (!this.doc || !this.surface || !this._mindmap) return nothing; + + const curStyle = this._mindmap.style; + + return html` <div> + <div + style=${styleMap({ + height: this.height + 'px', + border: '1px solid var(--affine-border-color)', + borderRadius: '4px', + })} + > + ${new BlockStdScope({ + doc: this.doc, + extensions: MiniMindmapSpecs, + }).render()} + </div> + + ${this.templateShow + ? html` <div class="select-template-title">Select template</div> + <div class="template"> + ${repeat( + mindmapStyles, + ([style]) => style, + ([style, icon]) => { + return html`<div + class=${`template-item ${curStyle === style ? 'active' : ''}`} + @click=${() => this._switchStyle(style as MindmapStyle)} + > + ${icon} + </div>`; + } + )} + </div>` + : nothing} + </div>`; + } + + @property({ attribute: false }) + accessor answer!: string; + + @property({ attribute: false }) + accessor ctx!: { + get(): Record<string, unknown>; + set(data: Record<string, unknown>): void; + }; + + @property({ attribute: false }) + accessor height = 400; + + @property({ attribute: false }) + accessor host!: EditorHost; + + @property({ attribute: false }) + accessor mindmapStyle: MindmapStyle | undefined = undefined; + + @query('editor-host') + accessor portalHost!: EditorHost; + + @property({ attribute: false }) + accessor templateShow = true; +} + +type Node = { + text: string; + children: Node[]; +}; + +export const markdownToMindmap = (answer: string, doc: Doc) => { + let result: Node | null = null; + const job = new Job({ collection: doc.collection }); + const markdown = new MarkdownAdapter(job); + const ast = markdown['_markdownToAst'](answer); + const traverse = ( + markdownNode: Unpacked<(typeof ast)['children']>, + firstLevel = false + ): Node | null => { + switch (markdownNode.type) { + case 'list': + { + const listItems = markdownNode.children + .map(child => traverse(child)) + .filter(val => val); + + if (firstLevel) { + return listItems[0]; + } + } + break; + case 'listItem': { + const paragraph = markdownNode.children[0]; + const list = markdownNode.children[1]; + const node: Node = { + text: '', + children: [], + }; + + if ( + paragraph?.type === 'paragraph' && + paragraph.children[0]?.type === 'text' + ) { + node.text = paragraph.children[0].value; + } + + if (list?.type === 'list') { + node.children = list.children + .map(child => traverse(child)) + .filter(val => val) as Node[]; + } + + return node; + } + } + + return null; + }; + + if (ast?.children?.[0]?.type === 'list') { + result = traverse(ast.children[0], true); + } + + return result; +}; diff --git a/blocksuite/blocks/src/surface-block/mini-mindmap/mindmap-root-block.ts b/blocksuite/blocks/src/surface-block/mini-mindmap/mindmap-root-block.ts new file mode 100644 index 0000000000..affbeb1635 --- /dev/null +++ b/blocksuite/blocks/src/surface-block/mini-mindmap/mindmap-root-block.ts @@ -0,0 +1,27 @@ +import type { RootBlockModel } from '@blocksuite/affine-model'; +import { BlockComponent } from '@blocksuite/block-std'; +import { html } from 'lit'; + +export class MindmapRootBlock extends BlockComponent<RootBlockModel> { + override render() { + return html` + <style> + .affine-mini-mindmap-root { + display: block; + width: 100%; + height: 100%; + + background-size: 20px 20px; + background-color: var(--affine-background-primary-color); + background-image: radial-gradient( + var(--affine-edgeless-grid-color) 1px, + var(--affine-background-primary-color) 1px + ); + } + </style> + <div class="affine-mini-mindmap-root"> + ${this.host.renderChildren(this.model)} + </div> + `; + } +} diff --git a/blocksuite/blocks/src/surface-block/mini-mindmap/minmap-service.ts b/blocksuite/blocks/src/surface-block/mini-mindmap/minmap-service.ts new file mode 100644 index 0000000000..032a97add0 --- /dev/null +++ b/blocksuite/blocks/src/surface-block/mini-mindmap/minmap-service.ts @@ -0,0 +1,15 @@ +import { RootBlockSchema } from '@blocksuite/affine-model'; +import { BlockService } from '@blocksuite/block-std'; +import { Slot } from '@blocksuite/store'; + +export class MindmapService extends BlockService { + static override readonly flavour = RootBlockSchema.model.flavour; + + requestCenter = new Slot(); + + center() { + this.requestCenter.emit(); + } + + override mounted(): void {} +} diff --git a/blocksuite/blocks/src/surface-block/mini-mindmap/spec.ts b/blocksuite/blocks/src/surface-block/mini-mindmap/spec.ts new file mode 100644 index 0000000000..aa8b42c4f4 --- /dev/null +++ b/blocksuite/blocks/src/surface-block/mini-mindmap/spec.ts @@ -0,0 +1,33 @@ +import { SurfaceBlockSchema } from '@blocksuite/affine-block-surface'; +import { RootBlockSchema } from '@blocksuite/affine-model'; +import { + DocModeService, + ThemeService, +} from '@blocksuite/affine-shared/services'; +import { + BlockViewExtension, + type ExtensionType, + FlavourExtension, +} from '@blocksuite/block-std'; +import type { BlockSchema } from '@blocksuite/store'; +import { literal } from 'lit/static-html.js'; +import type { z } from 'zod'; + +import { MindmapService } from './minmap-service.js'; +import { MindmapSurfaceBlockService } from './surface-service.js'; + +export const MiniMindmapSpecs: ExtensionType[] = [ + DocModeService, + ThemeService, + FlavourExtension('affine:page'), + MindmapService, + BlockViewExtension('affine:page', literal`mini-mindmap-root-block`), + FlavourExtension('affine:surface'), + MindmapSurfaceBlockService, + BlockViewExtension('affine:surface', literal`mini-mindmap-surface-block`), +]; + +export const MiniMindmapSchema: z.infer<typeof BlockSchema>[] = [ + RootBlockSchema, + SurfaceBlockSchema, +]; diff --git a/blocksuite/blocks/src/surface-block/mini-mindmap/surface-block.ts b/blocksuite/blocks/src/surface-block/mini-mindmap/surface-block.ts new file mode 100644 index 0000000000..6419548fc2 --- /dev/null +++ b/blocksuite/blocks/src/surface-block/mini-mindmap/surface-block.ts @@ -0,0 +1,155 @@ +/* eslint-disable @typescript-eslint/no-non-null-assertion */ +import type { SurfaceBlockModel } from '@blocksuite/affine-block-surface'; +import { + CanvasRenderer, + elementRenderers, + fitContent, +} from '@blocksuite/affine-block-surface'; +import type { Color, ShapeElementModel } from '@blocksuite/affine-model'; +import { ThemeProvider } from '@blocksuite/affine-shared/services'; +import { BlockComponent } from '@blocksuite/block-std'; +import { GfxControllerIdentifier } from '@blocksuite/block-std/gfx'; +import type { Bound } from '@blocksuite/global/utils'; +import { html } from 'lit'; +import { query } from 'lit/decorators.js'; + +import type { MindmapService } from './minmap-service.js'; + +export class MindmapSurfaceBlock extends BlockComponent<SurfaceBlockModel> { + renderer?: CanvasRenderer; + + private get _grid() { + return this.std.get(GfxControllerIdentifier).grid; + } + + private get _layer() { + return this.std.get(GfxControllerIdentifier).layer; + } + + get mindmapService() { + return this.std.getService('affine:page') as unknown as MindmapService; + } + + get viewport() { + return this.std.get(GfxControllerIdentifier).viewport; + } + + constructor() { + super(); + } + + private _adjustNodeWidth() { + this.model.doc.transact(() => { + this.model.elementModels.forEach(element => { + if (element.type === 'shape') { + fitContent(element as ShapeElementModel); + } + }); + }); + } + + private _resizeEffect() { + const observer = new ResizeObserver(() => { + this.viewport.onResize(); + }); + + observer.observe(this.editorContainer); + this._disposables.add(() => { + observer.disconnect(); + }); + } + + private _setupCenterEffect() { + this._disposables.add( + this.mindmapService.requestCenter.on(() => { + let bound: Bound; + + this.model.elementModels.forEach(el => { + if (!bound) { + bound = el.elementBound; + } else { + bound = bound.unite(el.elementBound); + } + }); + + if (bound!) { + this.viewport.setViewportByBound(bound, [10, 10, 10, 10]); + } + }) + ); + } + + private _setupRenderer() { + this._disposables.add( + this.model.elementUpdated.on(() => { + this.mindmapService.center(); + }) + ); + + this.viewport.ZOOM_MIN = 0.01; + } + + override connectedCallback(): void { + super.connectedCallback(); + + const themeService = this.std.get(ThemeProvider); + this.renderer = new CanvasRenderer({ + viewport: this.viewport, + layerManager: this._layer, + gridManager: this._grid, + enableStackingCanvas: true, + provider: { + selectedElements: () => [], + getColorScheme: () => themeService.edgelessTheme, + getColorValue: (color: Color, fallback?: string, real?: boolean) => + themeService.getColorValue( + color, + fallback, + real, + themeService.edgelessTheme + ), + generateColorProperty: (color: Color, fallback: string) => + themeService.generateColorProperty( + color, + fallback, + themeService.edgelessTheme + ), + getPropertyValue: (property: string) => + themeService.getCssVariableColor( + property, + themeService.edgelessTheme + ), + }, + elementRenderers, + surfaceModel: this.model, + }); + this._disposables.add(this.renderer); + } + + override firstUpdated(_changedProperties: Map<PropertyKey, unknown>): void { + this.renderer?.attach(this.editorContainer); + + this._resizeEffect(); + this._setupCenterEffect(); + this._setupRenderer(); + this._adjustNodeWidth(); + this.mindmapService.center(); + } + + override render() { + return html` + <style> + .affine-mini-mindmap-surface { + width: 100%; + height: 100%; + } + </style> + <div class="affine-mini-mindmap-surface"> + <!-- attach cavnas later in renderer --> + </div> + `; + } + + @query('.affine-mini-mindmap-surface') + accessor editorContainer!: HTMLDivElement; +} diff --git a/blocksuite/blocks/src/surface-block/mini-mindmap/surface-service.ts b/blocksuite/blocks/src/surface-block/mini-mindmap/surface-service.ts new file mode 100644 index 0000000000..2e84d4d14d --- /dev/null +++ b/blocksuite/blocks/src/surface-block/mini-mindmap/surface-service.ts @@ -0,0 +1,6 @@ +import { SurfaceBlockSchema } from '@blocksuite/affine-block-surface'; +import { BlockService } from '@blocksuite/block-std'; + +export class MindmapSurfaceBlockService extends BlockService { + static override readonly flavour = SurfaceBlockSchema.model.flavour; +} diff --git a/blocksuite/blocks/src/surface-ref-block/commands.ts b/blocksuite/blocks/src/surface-ref-block/commands.ts new file mode 100644 index 0000000000..202818e90a --- /dev/null +++ b/blocksuite/blocks/src/surface-ref-block/commands.ts @@ -0,0 +1,64 @@ +import type { SurfaceRefProps } from '@blocksuite/affine-model'; +import { matchFlavours } from '@blocksuite/affine-shared/utils'; +import type { BlockCommands, Command } from '@blocksuite/block-std'; + +import { getSurfaceBlock } from './utils.js'; + +export const insertSurfaceRefBlockCommand: Command< + 'selectedModels', + 'insertedSurfaceRefBlockId', + { + reference: string; + place: 'after' | 'before'; + removeEmptyLine?: boolean; + } +> = (ctx, next) => { + const { selectedModels, reference, place, removeEmptyLine, std } = ctx; + if (!selectedModels?.length) return; + + const targetModel = + place === 'before' + ? selectedModels[0] + : selectedModels[selectedModels.length - 1]; + + const surfaceRefProps: Partial<SurfaceRefProps> & { + flavour: 'affine:surface-ref'; + } = { + flavour: 'affine:surface-ref', + reference, + }; + + const surface = getSurfaceBlock(std.doc); + if (!surface) return; + + const element = surface.getElementById(reference); + const blockModel = std.doc.getBlock(reference)?.model ?? null; + + if (element?.type === 'group') { + surfaceRefProps.refFlavour = 'group'; + } else if (matchFlavours(blockModel, ['affine:frame'])) { + surfaceRefProps.refFlavour = 'frame'; + } else { + console.error(`reference not found ${reference}`); + return; + } + + const result = std.doc.addSiblingBlocks( + targetModel, + [surfaceRefProps], + place + ); + if (result.length === 0) return; + + if (removeEmptyLine && targetModel.text?.length === 0) { + std.doc.deleteBlock(targetModel); + } + + next({ + insertedSurfaceRefBlockId: result[0], + }); +}; + +export const commands: BlockCommands = { + insertSurfaceRefBlock: insertSurfaceRefBlockCommand, +}; diff --git a/blocksuite/blocks/src/surface-ref-block/effects.ts b/blocksuite/blocks/src/surface-ref-block/effects.ts new file mode 100644 index 0000000000..844e8fdad8 --- /dev/null +++ b/blocksuite/blocks/src/surface-ref-block/effects.ts @@ -0,0 +1,24 @@ +import type { insertSurfaceRefBlockCommand } from './commands.js'; + +export function effects() { + // TODO(@L-Sun): move other effects to this file +} + +declare global { + namespace BlockSuite { + interface CommandContext { + insertedSurfaceRefBlockId?: string; + } + + interface Commands { + /** + * insert a SurfaceRef block after or before the current block selection + * @param reference the reference block id. The block should be group or frame + * @param place where to insert the LaTeX block + * @param removeEmptyLine remove the current block if it is empty + * @returns the id of the inserted SurfaceRef block + */ + insertSurfaceRefBlock: typeof insertSurfaceRefBlockCommand; + } + } +} diff --git a/blocksuite/blocks/src/surface-ref-block/index.ts b/blocksuite/blocks/src/surface-ref-block/index.ts new file mode 100644 index 0000000000..8c9e461758 --- /dev/null +++ b/blocksuite/blocks/src/surface-ref-block/index.ts @@ -0,0 +1,8 @@ +export * from './surface-ref-block.js'; +export * from './surface-ref-block-edgeless.js'; +export * from './surface-ref-service.js'; +export { + EdgelessSurfaceRefBlockSpec, + PageSurfaceRefBlockSpec, +} from './surface-ref-spec.js'; +export * from './utils.js'; diff --git a/blocksuite/blocks/src/surface-ref-block/portal/generic-block.ts b/blocksuite/blocks/src/surface-ref-block/portal/generic-block.ts new file mode 100644 index 0000000000..422f774a7e --- /dev/null +++ b/blocksuite/blocks/src/surface-ref-block/portal/generic-block.ts @@ -0,0 +1,81 @@ +import type { + AttachmentBlockModel, + BookmarkBlockModel, + EmbedFigmaModel, + EmbedGithubModel, + EmbedHtmlModel, + EmbedLinkedDocModel, + EmbedLoomModel, + EmbedSyncedDocModel, + EmbedYoutubeModel, + ImageBlockModel, +} from '@blocksuite/affine-model'; +import { ShadowlessElement } from '@blocksuite/block-std'; +import { Bound, WithDisposable } from '@blocksuite/global/utils'; +import type { BlockModel } from '@blocksuite/store'; +import { css, type TemplateResult } from 'lit'; +import { property } from 'lit/decorators.js'; +import { styleMap } from 'lit/directives/style-map.js'; +import { html } from 'lit/static-html.js'; + +export class SurfaceRefGenericBlockPortal extends WithDisposable( + ShadowlessElement +) { + static override styles = css` + surface-ref-generic-block-portal { + position: relative; + } + `; + + override firstUpdated() { + this.disposables.add( + this.model.propsUpdated.on(() => this.requestUpdate()) + ); + } + + override render() { + const { model, index } = this; + const bound = Bound.deserialize(model.xywh); + const style = { + position: 'absolute', + zIndex: `${index}`, + width: `${bound.w}px`, + height: `${bound.h}px`, + transform: `translate(${bound.x}px, ${bound.y}px)`, + }; + + return html` + <div + style=${styleMap(style)} + data-portal-reference-block-id="${model.id}" + > + ${this.renderModel(model)} + </div> + `; + } + + @property({ attribute: false }) + accessor index!: number; + + @property({ attribute: false }) + accessor model!: + | ImageBlockModel + | AttachmentBlockModel + | BookmarkBlockModel + | EmbedGithubModel + | EmbedYoutubeModel + | EmbedFigmaModel + | EmbedLinkedDocModel + | EmbedSyncedDocModel + | EmbedHtmlModel + | EmbedLoomModel; + + @property({ attribute: false }) + accessor renderModel!: (model: BlockModel) => TemplateResult; +} + +declare global { + interface HTMLElementTagNameMap { + 'surface-ref-generic-block-portal': SurfaceRefGenericBlockPortal; + } +} diff --git a/blocksuite/blocks/src/surface-ref-block/portal/note.ts b/blocksuite/blocks/src/surface-ref-block/portal/note.ts new file mode 100644 index 0000000000..bf2c276a7d --- /dev/null +++ b/blocksuite/blocks/src/surface-ref-block/portal/note.ts @@ -0,0 +1,164 @@ +import type { CanvasRenderer } from '@blocksuite/affine-block-surface'; +import type { NoteBlockModel } from '@blocksuite/affine-model'; +import { + DEFAULT_NOTE_BACKGROUND_COLOR, + NoteDisplayMode, + NoteShadow, +} from '@blocksuite/affine-model'; +import { ThemeProvider } from '@blocksuite/affine-shared/services'; +import { SpecProvider } from '@blocksuite/affine-shared/utils'; +import { + BlockStdScope, + type EditorHost, + RANGE_QUERY_EXCLUDE_ATTR, + ShadowlessElement, +} from '@blocksuite/block-std'; +import { deserializeXYWH, WithDisposable } from '@blocksuite/global/utils'; +import { type BlockModel, BlockViewType, type Query } from '@blocksuite/store'; +import { css, nothing } from 'lit'; +import { property } from 'lit/decorators.js'; +import { styleMap } from 'lit/directives/style-map.js'; +import { html } from 'lit/static-html.js'; + +import { + EDGELESS_BLOCK_CHILD_BORDER_WIDTH, + EDGELESS_BLOCK_CHILD_PADDING, +} from '../../_common/consts.js'; + +export class SurfaceRefNotePortal extends WithDisposable(ShadowlessElement) { + static override styles = css` + surface-ref-note-portal { + position: relative; + } + `; + + ancestors = new Set<string>(); + + query: Query | null = null; + + override connectedCallback() { + super.connectedCallback(); + + const ancestors = new Set<string>(); + let parent: BlockModel | null = this.model; + while (parent) { + this.ancestors.add(parent.id); + parent = this.model.doc.getParent(parent.id); + } + const query: Query = { + mode: 'include', + match: Array.from(ancestors).map(id => ({ + id, + viewType: BlockViewType.Display, + })), + }; + this.query = query; + + const doc = this.model.doc; + this._disposables.add(() => { + doc.blockCollection.clearQuery(query, true); + }); + } + + override firstUpdated() { + this.disposables.add( + this.model.propsUpdated.on(() => this.requestUpdate()) + ); + } + + override render() { + const { model, index } = this; + const { displayMode, edgeless } = model; + if (!!displayMode && displayMode === NoteDisplayMode.DocOnly) + return nothing; + + const backgroundColor = this.host.std + .get(ThemeProvider) + .generateColorProperty(model.background, DEFAULT_NOTE_BACKGROUND_COLOR); + + const [modelX, modelY, modelW, modelH] = deserializeXYWH(model.xywh); + const style = { + zIndex: `${index}`, + width: modelW + 'px', + height: + edgeless.collapse && edgeless.collapsedHeight + ? edgeless.collapsedHeight + 'px' + : undefined, + transform: `translate(${modelX}px, ${modelY}px)`, + padding: `${EDGELESS_BLOCK_CHILD_PADDING}px`, + border: `${EDGELESS_BLOCK_CHILD_BORDER_WIDTH}px none var(--affine-black-10)`, + backgroundColor, + boxShadow: `var(${NoteShadow.Sticker})`, + position: 'absolute', + borderRadius: '0px', + boxSizing: 'border-box', + pointerEvents: 'none', + overflow: 'hidden', + transformOrigin: '0 0', + userSelect: 'none', + }; + + return html` + <div + class="surface-ref-note-portal" + style=${styleMap(style)} + data-model-height="${modelH}" + data-portal-reference-block-id="${model.id}" + > + ${this.renderPreview()} + </div> + `; + } + + renderPreview() { + if (!this.query) { + console.error('Query is not set before rendering note preview'); + return nothing; + } + const doc = this.model.doc.blockCollection.getDoc({ + query: this.query, + readonly: true, + }); + const previewSpec = SpecProvider.getInstance().getSpec('page:preview'); + return new BlockStdScope({ + doc, + extensions: previewSpec.value.slice(), + }).render(); + } + + override updated() { + setTimeout(() => { + const editableElements = Array.from<HTMLDivElement>( + this.querySelectorAll('[contenteditable]') + ); + const blocks = Array.from(this.querySelectorAll(`[data-block-id]`)); + + editableElements.forEach(element => { + if (element.contentEditable === 'true') + element.contentEditable = 'false'; + }); + + blocks.forEach(element => { + element.setAttribute(RANGE_QUERY_EXCLUDE_ATTR, 'true'); + }); + }, 500); + } + + @property({ attribute: false }) + accessor host!: EditorHost; + + @property({ attribute: false }) + accessor index!: number; + + @property({ attribute: false }) + accessor model!: NoteBlockModel; + + @property({ attribute: false }) + accessor renderer!: CanvasRenderer; +} + +declare global { + interface HTMLElementTagNameMap { + 'surface-ref-note-portal': SurfaceRefNotePortal; + } +} diff --git a/blocksuite/blocks/src/surface-ref-block/surface-ref-block-edgeless.ts b/blocksuite/blocks/src/surface-ref-block/surface-ref-block-edgeless.ts new file mode 100644 index 0000000000..f7b6df54de --- /dev/null +++ b/blocksuite/blocks/src/surface-ref-block/surface-ref-block-edgeless.ts @@ -0,0 +1,15 @@ +import type { SurfaceRefBlockModel } from '@blocksuite/affine-model'; +import { BlockComponent } from '@blocksuite/block-std'; +import { nothing } from 'lit'; + +export class EdgelessSurfaceRefBlockComponent extends BlockComponent<SurfaceRefBlockModel> { + override render() { + return nothing; + } +} + +declare global { + interface HTMLElementTagNameMap { + 'affine-edgeless-surface-ref': EdgelessSurfaceRefBlockComponent; + } +} diff --git a/blocksuite/blocks/src/surface-ref-block/surface-ref-block.ts b/blocksuite/blocks/src/surface-ref-block/surface-ref-block.ts new file mode 100644 index 0000000000..98d7a21ddc --- /dev/null +++ b/blocksuite/blocks/src/surface-ref-block/surface-ref-block.ts @@ -0,0 +1,675 @@ +/* eslint-disable @typescript-eslint/no-non-null-assertion */ +import { + type SurfaceBlockModel, + SurfaceElementModel, +} from '@blocksuite/affine-block-surface'; +import type { BlockCaptionEditor } from '@blocksuite/affine-components/caption'; +import { + EdgelessModeIcon, + FrameIcon, + MoreDeleteIcon, +} from '@blocksuite/affine-components/icons'; +import { Peekable } from '@blocksuite/affine-components/peek'; +import { + FrameBlockModel, + GroupElementModel, + type SurfaceRefBlockModel, +} from '@blocksuite/affine-model'; +import { + DocModeProvider, + EditPropsStore, + ThemeProvider, +} from '@blocksuite/affine-shared/services'; +import { requestConnectedFrame } from '@blocksuite/affine-shared/utils'; +import { + type BaseSelection, + BlockComponent, + BlockServiceWatcher, + BlockStdScope, + type EditorHost, + LifeCycleWatcher, +} from '@blocksuite/block-std'; +import { GfxBlockElementModel } from '@blocksuite/block-std/gfx'; +import { BlockSuiteError, ErrorCode } from '@blocksuite/global/exceptions'; +import { + assertExists, + Bound, + deserializeXYWH, + DisposableGroup, + type SerializedXYWH, +} from '@blocksuite/global/utils'; +import type { Doc } from '@blocksuite/store'; +import { css, html, nothing, type TemplateResult } from 'lit'; +import { query, state } from 'lit/decorators.js'; +import { styleMap } from 'lit/directives/style-map.js'; + +import { SpecProvider } from '../_specs/index.js'; +import type { EdgelessRootPreviewBlockComponent } from '../root-block/edgeless/edgeless-root-preview-block.js'; +import { EdgelessRootService } from '../root-block/index.js'; +import type { SurfaceRefBlockService } from './surface-ref-service.js'; +import { noContentPlaceholder } from './utils.js'; + +const REF_LABEL_ICON = { + 'affine:frame': FrameIcon, + DEFAULT_NOTE_HEIGHT: EdgelessModeIcon, +} as Record<string, TemplateResult>; + +const NO_CONTENT_TITLE = { + 'affine:frame': 'Frame', + group: 'Group', + DEFAULT: 'Content', +} as Record<string, string>; + +const NO_CONTENT_REASON = { + group: 'This content was ungrouped or deleted on edgeless mode', + DEFAULT: 'This content was deleted on edgeless mode', +} as Record<string, string>; + +@Peekable() +export class SurfaceRefBlockComponent extends BlockComponent< + SurfaceRefBlockModel, + SurfaceRefBlockService +> { + static override styles = css` + .affine-surface-ref { + position: relative; + user-select: none; + margin: 10px 0; + break-inside: avoid; + } + + @media print { + .affine-surface-ref { + outline: none !important; + } + } + + .ref-placeholder { + padding: 26px 0px 0px; + } + + .placeholder-image { + margin: 0 auto; + text-align: center; + } + + .placeholder-text { + margin: 12px auto 0; + text-align: center; + font-size: 28px; + font-weight: 600; + line-height: 36px; + font-family: var(--affine-font-family); + } + + .placeholder-action { + margin: 32px auto 0; + text-align: center; + } + + .delete-button { + width: 204px; + padding: 4px 18px; + + display: inline-flex; + justify-content: center; + align-items: center; + gap: 4px; + + border-radius: 8px; + border: 1px solid var(--affine-border-color); + + font-family: var(--affine-font-family); + font-size: 12px; + font-weight: 500; + line-height: 20px; + + background-color: transparent; + cursor: pointer; + } + + .delete-button > .icon > svg { + color: var(--affine-icon-color); + width: 16px; + height: 16px; + display: block; + } + + .placeholder-reason { + margin: 72px auto 0; + padding: 10px; + + text-align: center; + font-size: 12px; + font-family: var(--affine-font-family); + line-height: 20px; + + color: var(--affine-warning-color); + background-color: var(--affine-background-error-color); + } + + .ref-content { + position: relative; + padding: 20px; + background-color: var(--affine-background-primary-color); + background: radial-gradient( + var(--affine-edgeless-grid-color) 1px, + var(--affine-background-primary-color) 1px + ); + } + + .ref-viewport { + max-width: 100%; + margin: 0 auto; + position: relative; + overflow: hidden; + pointer-events: none; + user-select: none; + } + + .ref-viewport.frame { + border-radius: 2px; + border: 1px solid var(--affine-black-30); + } + + .surface-ref-mask { + position: absolute; + left: 0; + top: 0; + width: 100%; + height: 100%; + break-inside: avoid; + } + + .surface-ref-mask:hover { + background-color: rgba(211, 211, 211, 0.1); + } + + .surface-ref-mask:hover .ref-label { + display: block; + } + + .ref-label { + display: none; + user-select: none; + } + + .ref-label { + position: absolute; + left: 0; + bottom: 0; + + width: 100%; + padding: 8px 16px; + border: 1px solid var(--affine-border-color); + gap: 14px; + + background: var(--affine-background-primary-color); + + font-size: 12px; + + user-select: none; + } + + .ref-label .title { + display: inline-block; + font-weight: 600; + font-family: var(--affine-font-family); + line-height: 20px; + + color: var(--affine-text-secondary-color); + } + + .ref-label .title > svg { + color: var(--affine-icon-secondary); + display: inline-block; + vertical-align: baseline; + width: 20px; + height: 20px; + vertical-align: bottom; + } + + .ref-label .suffix { + display: inline-block; + font-weight: 400; + color: var(--affine-text-disable-color); + line-height: 20px; + } + `; + + private _previewDoc: Doc | null = null; + + private _previewSpec = SpecProvider.getInstance().getSpec('edgeless:preview'); + + private _referencedModel: BlockSuite.EdgelessModel | null = null; + + private _referenceXYWH: SerializedXYWH | null = null; + + private _viewportEditor: EditorHost | null = null; + + private get _shouldRender() { + return ( + this.isConnected && + // prevent surface-ref from render itself in loop + !this.parentComponent?.closest('affine-surface-ref') + ); + } + + get referenceModel() { + return this._referencedModel; + } + + private _deleteThis() { + this.doc.deleteBlock(this.model); + } + + private _focusBlock() { + this.selection.update(() => { + return [this.selection.create('block', { blockId: this.blockId })]; + }); + } + + private _initHotkey() { + const selection = this.host.selection; + const addParagraph = () => { + if (!this.doc.getParent(this.model)) return; + + const [paragraphId] = this.doc.addSiblingBlocks(this.model, [ + { + flavour: 'affine:paragraph', + }, + ]); + const model = this.doc.getBlockById(paragraphId); + assertExists(model, `Failed to add paragraph block.`); + + requestConnectedFrame(() => { + selection.update(selList => { + return selList + .filter<BaseSelection>(sel => !sel.is('block')) + .concat( + selection.create('text', { + from: { + blockId: model.id, + index: 0, + length: 0, + }, + to: null, + }) + ); + }); + }, this); + }; + + this.bindHotKey({ + Enter: () => { + if (!this._focused) return; + addParagraph(); + return true; + }, + }); + } + + private _initReferencedModel() { + const surfaceModel: SurfaceBlockModel | null = + (this.doc.getBlocksByFlavour('affine:surface')[0]?.model as + | SurfaceBlockModel + | undefined) ?? null; + this._surfaceModel = surfaceModel; + + const findReferencedModel = (): [ + BlockSuite.EdgelessModel | null, + string, + ] => { + if (!this.model.reference) return [null, this.doc.id]; + + if (this.doc.getBlock(this.model.reference)) { + return [ + this.doc.getBlock(this.model.reference) + ?.model as GfxBlockElementModel, + this.doc.id, + ]; + } + + if (this._surfaceModel?.getElementById(this.model.reference)) { + return [ + this._surfaceModel.getElementById(this.model.reference), + this.doc.id, + ]; + } + + const doc = [...this.std.collection.docs.values()] + .map(doc => doc.getDoc()) + .find( + doc => + doc.getBlock(this.model.reference) || + ( + doc.getBlocksByFlavour('affine:surface')[0] + .model as SurfaceBlockModel + ).getElementById(this.model.reference) + ); + + if (doc) { + this._surfaceModel = doc.getBlocksByFlavour('affine:surface')[0] + .model as SurfaceBlockModel; + } + + if (doc && doc.getBlock(this.model.reference)) { + return [ + doc.getBlock(this.model.reference)?.model as GfxBlockElementModel, + doc.id, + ]; + } + + if (doc && doc.getBlocksByFlavour('affine:surface')[0]) { + return [ + ( + doc.getBlocksByFlavour('affine:surface')[0] + .model as SurfaceBlockModel + ).getElementById(this.model.reference), + doc.id, + ]; + } + + return [null, this.doc.id]; + }; + + const init = () => { + const [referencedModel, docId] = findReferencedModel(); + + this._referencedModel = + referencedModel && referencedModel.xywh ? referencedModel : null; + this._previewDoc = this.doc.collection.getDoc(docId, { + readonly: true, + }); + this._referenceXYWH = this._referencedModel?.xywh ?? null; + }; + + init(); + + this._disposables.add( + this.model.propsUpdated.on(payload => { + if ( + payload.key === 'reference' && + this.model.reference !== this._referencedModel?.id + ) { + init(); + } + }) + ); + + if (surfaceModel && this._referencedModel instanceof SurfaceElementModel) { + this._disposables.add( + surfaceModel.elementRemoved.on(({ id }) => { + if (this.model.reference === id) { + init(); + } + }) + ); + } + + if (this._referencedModel instanceof GfxBlockElementModel) { + this._disposables.add( + this.doc.slots.blockUpdated.on(({ type, id }) => { + if (type === 'delete' && id === this.model.reference) { + init(); + } + }) + ); + } + } + + private _initSelection() { + const selection = this.host.selection; + this._disposables.add( + selection.slots.changed.on(selList => { + this._focused = selList.some( + sel => sel.blockId === this.blockId && sel.is('block') + ); + }) + ); + } + + private _initSpec() { + const refreshViewport = this._refreshViewport.bind(this); + class PageViewWatcher extends BlockServiceWatcher { + static override readonly flavour = 'affine:page'; + + override mounted() { + this.blockService.disposables.add( + this.blockService.specSlots.viewConnected.once(({ component }) => { + const edgelessBlock = + component as EdgelessRootPreviewBlockComponent; + + edgelessBlock.editorViewportSelector = 'ref-viewport'; + refreshViewport(); + edgelessBlock.service.viewport.sizeUpdated.once(() => { + refreshViewport(); + }); + }) + ); + } + } + this._previewSpec.extend([PageViewWatcher]); + + const referenceId = this.model.reference; + const setReferenceXYWH = (xywh: typeof this._referenceXYWH) => { + this._referenceXYWH = xywh; + }; + + class FrameGroupViewWatcher extends LifeCycleWatcher { + static override readonly key = 'surface-ref-group-view-watcher'; + + private _disposable = new DisposableGroup(); + + override mounted() { + const edgelessService = this.std.get(EdgelessRootService); + const { _disposable } = this; + + const referenceElement = edgelessService.getElementById(referenceId); + if (!referenceElement) { + throw new BlockSuiteError( + ErrorCode.MissingViewModelError, + `can not find element(id:${referenceElement})` + ); + } + + if (referenceElement instanceof FrameBlockModel) { + _disposable.add( + referenceElement.xywh$.subscribe(xywh => { + setReferenceXYWH(xywh); + refreshViewport(); + }) + ); + } else if (referenceElement instanceof GroupElementModel) { + _disposable.add( + edgelessService.surface.elementUpdated.on(({ id, oldValues }) => { + if ( + id === referenceId && + oldValues.xywh !== referenceElement.xywh + ) { + setReferenceXYWH(referenceElement.xywh); + refreshViewport(); + } + }) + ); + } else { + console.warn('Unsupported reference element type'); + } + } + + override unmounted() { + this._disposable.dispose(); + } + } + + this._previewSpec.extend([FrameGroupViewWatcher]); + } + + private _refreshViewport() { + if (!this._referenceXYWH) return; + + const previewEditorHost = this.previewEditor; + + if (!previewEditorHost) return; + + const edgelessService = previewEditorHost.std.getService( + 'affine:page' + ) as EdgelessRootService; + + edgelessService.viewport.setViewportByBound( + Bound.deserialize(this._referenceXYWH) + ); + } + + private _renderMask( + referencedModel: BlockSuite.EdgelessModel, + flavourOrType: string + ) { + const title = 'title' in referencedModel ? referencedModel.title : ''; + + return html` + <div class="surface-ref-mask"> + <div class="ref-label"> + <div class="title"> + ${REF_LABEL_ICON[flavourOrType ?? 'DEFAULT'] ?? + REF_LABEL_ICON.DEFAULT} + <span>${title}</span> + </div> + <div class="suffix">from edgeless mode</div> + </div> + </div> + `; + } + + private _renderRefContent(referencedModel: BlockSuite.EdgelessModel) { + const [, , w, h] = deserializeXYWH(referencedModel.xywh); + const flavourOrType = + 'flavour' in referencedModel + ? referencedModel.flavour + : referencedModel.type; + const _previewSpec = this._previewSpec.value; + + if (!this._viewportEditor) { + this._viewportEditor = new BlockStdScope({ + doc: this._previewDoc!, + extensions: _previewSpec, + }).render(); + } + + return html`<div class="ref-content"> + <div + class="ref-viewport ${flavourOrType === 'affine:frame' ? 'frame' : ''}" + style=${styleMap({ + width: `${w}px`, + aspectRatio: `${w} / ${h}`, + })} + > + ${this._viewportEditor} + </div> + ${this._renderMask(referencedModel, flavourOrType)} + </div>`; + } + + private _renderRefPlaceholder(model: SurfaceRefBlockModel) { + return html`<div class="ref-placeholder"> + <div class="placeholder-image">${noContentPlaceholder}</div> + <div class="placeholder-text"> + No Such + ${NO_CONTENT_TITLE[model.refFlavour ?? 'DEFAULT'] ?? + NO_CONTENT_TITLE.DEFAULT} + </div> + <div class="placeholder-action"> + <button class="delete-button" type="button" @click=${this._deleteThis}> + <span class="icon">${MoreDeleteIcon}</span + ><span>Delete this block</span> + </button> + </div> + <div class="placeholder-reason"> + ${NO_CONTENT_REASON[model.refFlavour ?? 'DEFAULT'] ?? + NO_CONTENT_REASON.DEFAULT} + </div> + </div>`; + } + + override connectedCallback() { + super.connectedCallback(); + + this.contentEditable = 'false'; + + if (!this._shouldRender) return; + + const service = this.service; + assertExists(service, `Surface ref block must run with its service.`); + this._initHotkey(); + this._initSpec(); + this._initReferencedModel(); + this._initSelection(); + } + + override render() { + if (!this._shouldRender) return nothing; + + const { _surfaceModel, _referencedModel, model } = this; + const isEmpty = + !_surfaceModel || !_referencedModel || !_referencedModel.xywh; + const content = isEmpty + ? this._renderRefPlaceholder(model) + : this._renderRefContent(_referencedModel); + const edgelessTheme = this.std.get(ThemeProvider).edgeless$.value; + + return html` + <div + class="affine-surface-ref" + data-theme=${edgelessTheme} + @click=${this._focusBlock} + style=${styleMap({ + outline: this._focused + ? '2px solid var(--affine-primary-color)' + : undefined, + })} + > + ${content} + </div> + + <block-caption-editor></block-caption-editor> + + ${Object.values(this.widgets)} + `; + } + + viewInEdgeless() { + if (!this._referenceXYWH) return; + + const viewport = { + xywh: this._referenceXYWH, + padding: [60, 20, 20, 20] as [number, number, number, number], + }; + + this.std.get(EditPropsStore).setStorage('viewport', viewport); + this.std.get(DocModeProvider).setEditorMode('edgeless'); + } + + override willUpdate(_changedProperties: Map<PropertyKey, unknown>): void { + if (_changedProperties.has('_referencedModel')) { + this._refreshViewport(); + } + } + + @state() + private accessor _focused: boolean = false; + + @state() + private accessor _surfaceModel: SurfaceBlockModel | null = null; + + @query('affine-surface-ref > block-caption-editor') + accessor captionElement!: BlockCaptionEditor; + + @query('editor-host') + accessor previewEditor!: EditorHost | null; +} + +declare global { + interface HTMLElementTagNameMap { + 'affine-surface-ref': SurfaceRefBlockComponent; + } +} diff --git a/blocksuite/blocks/src/surface-ref-block/surface-ref-service.ts b/blocksuite/blocks/src/surface-ref-block/surface-ref-service.ts new file mode 100644 index 0000000000..894f4c2b87 --- /dev/null +++ b/blocksuite/blocks/src/surface-ref-block/surface-ref-service.ts @@ -0,0 +1,6 @@ +import { SurfaceRefBlockSchema } from '@blocksuite/affine-model'; +import { BlockService } from '@blocksuite/block-std'; + +export class SurfaceRefBlockService extends BlockService { + static override readonly flavour = SurfaceRefBlockSchema.model.flavour; +} diff --git a/blocksuite/blocks/src/surface-ref-block/surface-ref-spec.ts b/blocksuite/blocks/src/surface-ref-block/surface-ref-spec.ts new file mode 100644 index 0000000000..80ba7792c6 --- /dev/null +++ b/blocksuite/blocks/src/surface-ref-block/surface-ref-spec.ts @@ -0,0 +1,30 @@ +import { + BlockViewExtension, + CommandExtension, + type ExtensionType, + FlavourExtension, + WidgetViewMapExtension, +} from '@blocksuite/block-std'; +import { literal } from 'lit/static-html.js'; + +import { commands } from './commands.js'; +import { SurfaceRefBlockService } from './surface-ref-service.js'; + +export const PageSurfaceRefBlockSpec: ExtensionType[] = [ + FlavourExtension('affine:surface-ref'), + SurfaceRefBlockService, + CommandExtension(commands), + BlockViewExtension('affine:surface-ref', literal`affine-surface-ref`), + WidgetViewMapExtension('affine:surface-ref', { + surfaceToolbar: literal`affine-surface-ref-toolbar`, + }), +]; + +export const EdgelessSurfaceRefBlockSpec: ExtensionType[] = [ + FlavourExtension('affine:surface-ref'), + SurfaceRefBlockService, + BlockViewExtension( + 'affine:surface-ref', + literal`affine-edgeless-surface-ref` + ), +]; diff --git a/blocksuite/blocks/src/surface-ref-block/utils.ts b/blocksuite/blocks/src/surface-ref-block/utils.ts new file mode 100644 index 0000000000..01fa911a52 --- /dev/null +++ b/blocksuite/blocks/src/surface-ref-block/utils.ts @@ -0,0 +1,106 @@ +import type { SurfaceBlockModel } from '@blocksuite/affine-block-surface'; +import type { Doc } from '@blocksuite/store'; +import { html } from 'lit'; + +export function getSurfaceBlock(doc: Doc) { + const blocks = doc.getBlocksByFlavour('affine:surface'); + return blocks.length !== 0 ? (blocks[0].model as SurfaceBlockModel) : null; +} + +export const noContentPlaceholder = html` + <svg + width="182" + height="182" + viewBox="0 0 182 182" + fill="none" + xmlns="http://www.w3.org/2000/svg" + > + <rect + x="37.645" + y="37.6452" + width="106.71" + height="106.71" + stroke="#D2D2D2" + stroke-width="0.586319" + /> + <path + d="M91 144.234L37.7664 91.0003L91 37.7666L144.234 91.0003L91 144.234Z" + stroke="#D2D2D2" + stroke-width="0.586319" + /> + <path + d="M90.564 37.352C99.4686 32.1345 109.836 29.1436 120.902 29.1436C154.093 29.1436 181 56.0502 181 89.2413C181 113.999 166.03 135.259 144.648 144.466" + stroke="#D2D2D2" + stroke-width="0.586319" + /> + <path + d="M144.465 90.707C149.683 99.6117 152.674 109.979 152.674 121.045C152.674 154.236 125.767 181.143 92.5759 181.143C67.8187 181.143 46.5579 166.173 37.3516 144.791" + stroke="#D2D2D2" + stroke-width="0.586319" + /> + <path + d="M91.436 144.465C82.5314 149.683 72.1639 152.674 61.0978 152.674C27.9068 152.674 1.0001 125.767 1.0001 92.576C1.00011 67.8188 15.9701 46.558 37.3519 37.3518" + stroke="#D2D2D2" + stroke-width="0.586319" + /> + <path + d="M37.3518 91.436C32.1342 82.5314 29.1433 72.1639 29.1433 61.0978C29.1433 27.9067 56.05 1.00002 89.241 1.00001C113.998 1.00001 135.259 15.97 144.465 37.3518" + stroke="#D2D2D2" + stroke-width="0.586319" + /> + <path + d="M37.3518 37.3521L144.648 144.649" + stroke="#D2D2D2" + stroke-width="0.586319" + /> + <path + d="M144.648 37.3521L37.3518 144.649" + stroke="#D2D2D2" + stroke-width="0.586319" + /> + <path d="M91 37.3521V144.649" stroke="#D2D2D2" stroke-width="0.586319" /> + <path d="M144.648 91L37.3518 91" stroke="#D2D2D2" stroke-width="0.586319" /> + <ellipse cx="144.355" cy="37.645" rx="4.39739" ry="4.3974" fill="#5B5B5B" /> + <ellipse + cx="144.355" + cy="144.355" + rx="4.39739" + ry="4.3974" + fill="#5B5B5B" + /> + <ellipse + cx="144.355" + cy="90.9999" + rx="4.39739" + ry="4.3974" + fill="#5B5B5B" + /> + <ellipse cx="37.645" cy="37.645" rx="4.39739" ry="4.3974" fill="#5B5B5B" /> + <ellipse cx="37.645" cy="144.355" rx="4.39739" ry="4.3974" fill="#5B5B5B" /> + <ellipse cx="37.645" cy="90.9999" rx="4.39739" ry="4.3974" fill="#5B5B5B" /> + <ellipse + cx="90.9999" + cy="37.6451" + rx="4.3974" + ry="4.39739" + transform="rotate(-90 90.9999 37.6451)" + fill="#5B5B5B" + /> + <ellipse + cx="90.9999" + cy="90.4136" + rx="4.3974" + ry="4.39739" + transform="rotate(-90 90.9999 90.4136)" + fill="#5B5B5B" + /> + <ellipse + cx="90.9999" + cy="144.356" + rx="4.3974" + ry="4.39739" + transform="rotate(-90 90.9999 144.356)" + fill="#5B5B5B" + /> + </svg> +`; diff --git a/blocksuite/blocks/tsconfig.json b/blocksuite/blocks/tsconfig.json new file mode 100644 index 0000000000..85d22c2936 --- /dev/null +++ b/blocksuite/blocks/tsconfig.json @@ -0,0 +1,50 @@ +{ + "extends": "../tsconfig.json", + "compilerOptions": { + "rootDir": "./src/", + "outDir": "./dist/", + "noEmit": false + }, + "include": ["./src"], + "references": [ + { + "path": "../framework/global" + }, + { + "path": "../framework/store" + }, + { + "path": "../framework/block-std" + }, + { + "path": "../framework/inline" + }, + { + "path": "../affine/model" + }, + { + "path": "../affine/shared" + }, + { + "path": "../affine/components" + }, + { + "path": "../affine/block-paragraph" + }, + { + "path": "../affine/block-list" + }, + { + "path": "../affine/block-embed" + }, + { + "path": "../affine/data-view" + }, + { + "path": "../affine/block-surface" + }, + { + "path": "../affine/widget-scroll-anchoring" + } + ] +} diff --git a/blocksuite/blocks/typedoc.json b/blocksuite/blocks/typedoc.json new file mode 100644 index 0000000000..f593f276c2 --- /dev/null +++ b/blocksuite/blocks/typedoc.json @@ -0,0 +1,4 @@ +{ + "extends": ["../../typedoc.base.json"], + "entryPoints": ["src/index.ts"] +} diff --git a/blocksuite/blocks/vitest.config.ts b/blocksuite/blocks/vitest.config.ts new file mode 100644 index 0000000000..235c0dbde9 --- /dev/null +++ b/blocksuite/blocks/vitest.config.ts @@ -0,0 +1,35 @@ +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') || + log.includes( + `KaTeX doesn't work in quirks mode. Make sure your website has a suitable doctype.` + ) + ) { + return false; + } + console.warn(`Unexpected ${type} log`, log); + throw new Error(log); + }, + environment: 'happy-dom', + }, +}); diff --git a/blocksuite/framework/README.md b/blocksuite/framework/README.md new file mode 100644 index 0000000000..472572461f --- /dev/null +++ b/blocksuite/framework/README.md @@ -0,0 +1,3 @@ +# BlockSuite Framework + +Here are the vanilla framework packages in BlockSuite. diff --git a/blocksuite/framework/block-std/package.json b/blocksuite/framework/block-std/package.json new file mode 100644 index 0000000000..b8d8dcb064 --- /dev/null +++ b/blocksuite/framework/block-std/package.json @@ -0,0 +1,43 @@ +{ + "name": "@blocksuite/block-std", + "description": "Std for blocksuite blocks", + "type": "module", + "scripts": { + "build": "tsc", + "test:unit": "nx vite:test --run", + "test:unit:coverage": "nx vite:test --run --coverage", + "test:unit:ui": "nx vite:test --ui", + "test": "yarn test:unit" + }, + "sideEffects": false, + "keywords": [], + "author": "toeverything", + "license": "MIT", + "dependencies": { + "@blocksuite/global": "workspace:*", + "@blocksuite/inline": "workspace:*", + "@blocksuite/store": "workspace:*", + "@lit/context": "^1.1.2", + "@preact/signals-core": "^1.8.0", + "@types/hast": "^3.0.4", + "fractional-indexing": "^3.2.0", + "lib0": "^0.2.97", + "lit": "^3.2.0", + "lz-string": "^1.5.0", + "rehype-parse": "^9.0.0", + "unified": "^11.0.5", + "w3c-keyname": "^2.2.8", + "zod": "^3.23.8" + }, + "exports": { + ".": "./src/index.ts", + "./gfx": "./src/gfx/index.ts", + "./effects": "./src/effects.ts" + }, + "files": [ + "src", + "dist", + "!src/__tests__", + "!dist/__tests__" + ] +} diff --git a/blocksuite/framework/block-std/src/__tests__/__screenshots__/command.unit.spec.ts/CommandManager-can-execute-a-single-command-with--exec--1.png b/blocksuite/framework/block-std/src/__tests__/__screenshots__/command.unit.spec.ts/CommandManager-can-execute-a-single-command-with--exec--1.png new file mode 100644 index 0000000000..f88c55c1ca Binary files /dev/null and b/blocksuite/framework/block-std/src/__tests__/__screenshots__/command.unit.spec.ts/CommandManager-can-execute-a-single-command-with--exec--1.png differ diff --git a/blocksuite/framework/block-std/src/__tests__/__screenshots__/editor-host.unit.spec.ts/editor-host-editor-host-should-rerender-model-when-view-changes-1.png b/blocksuite/framework/block-std/src/__tests__/__screenshots__/editor-host.unit.spec.ts/editor-host-editor-host-should-rerender-model-when-view-changes-1.png new file mode 100644 index 0000000000..f88c55c1ca Binary files /dev/null and b/blocksuite/framework/block-std/src/__tests__/__screenshots__/editor-host.unit.spec.ts/editor-host-editor-host-should-rerender-model-when-view-changes-1.png differ diff --git a/blocksuite/framework/block-std/src/__tests__/command.unit.spec.ts b/blocksuite/framework/block-std/src/__tests__/command.unit.spec.ts new file mode 100644 index 0000000000..bbb32a6c58 --- /dev/null +++ b/blocksuite/framework/block-std/src/__tests__/command.unit.spec.ts @@ -0,0 +1,506 @@ +import { beforeEach, describe, expect, test, vi } from 'vitest'; + +import type { Command } from '../command/index.js'; +import { CommandManager } from '../command/index.js'; + +type Command1 = Command< + never, + 'commandData1', + { + command1Option?: string; + } +>; + +type Command2 = Command<'commandData1', 'commandData2'>; + +type Command3 = Command<'commandData1' | 'commandData2', 'commandData3'>; + +declare global { + namespace BlockSuite { + interface CommandContext { + commandData1?: string; + commandData2?: string; + commandData3?: string; + } + + interface Commands { + command1: Command1; + command2: Command2; + command3: Command3; + command4: Command; + } + } +} + +describe('CommandManager', () => { + let std: BlockSuite.Std; + let commandManager: CommandManager; + + beforeEach(() => { + // @ts-expect-error FIXME: ts error + std = {}; + commandManager = new CommandManager(std); + }); + + test('can add and execute a command', () => { + const command1: Command = vi.fn((_ctx, next) => next()); + const command2: Command = vi.fn((_ctx, _next) => {}); + commandManager.add('command1', command1); + commandManager.add('command2', command2); + + const [success1] = commandManager.chain().command1().run(); + const [success2] = commandManager.chain().command2().run(); + + expect(command1).toHaveBeenCalled(); + expect(command2).toHaveBeenCalled(); + expect(success1).toBeTruthy(); + expect(success2).toBeFalsy(); + }); + + test('can chain multiple commands', () => { + const command1: Command = vi.fn((_ctx, next) => next()); + const command2: Command = vi.fn((_ctx, next) => next()); + const command3: Command = vi.fn((_ctx, next) => next()); + + commandManager.add('command1', command1); + commandManager.add('command2', command2); + commandManager.add('command3', command3); + + const [success] = commandManager + .chain() + .command1() + .command2() + .command3() + .run(); + + expect(command1).toHaveBeenCalled(); + expect(command2).toHaveBeenCalled(); + expect(command3).toHaveBeenCalled(); + expect(success).toBeTruthy(); + }); + + test('skip commands if there is a command failed before them (`next` not executed)', () => { + const command1: Command = vi.fn((_ctx, next) => next()); + const command2: Command = vi.fn((_ctx, _next) => {}); + const command3: Command = vi.fn((_ctx, next) => next()); + + commandManager.add('command1', command1); + commandManager.add('command2', command2); + commandManager.add('command3', command3); + + const [success] = commandManager + .chain() + .command1() + .command2() + .command3() + .run(); + + expect(command1).toHaveBeenCalled(); + expect(command2).toHaveBeenCalled(); + expect(command3).not.toHaveBeenCalled(); + expect(success).toBeFalsy(); + }); + + test('can handle command failure', () => { + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + + const command1: Command = vi.fn((_ctx, next) => next()); + const command2: Command = vi.fn((_ctx, _next) => { + throw new Error('command2 failed'); + }); + const command3: Command = vi.fn((_ctx, next) => next()); + + commandManager.add('command1', command1); + commandManager.add('command2', command2); + commandManager.add('command3', command3); + + const [success] = commandManager + .chain() + .command1() + .command2() + .command3() + .run(); + + expect(command1).toHaveBeenCalled(); + expect(command2).toHaveBeenCalled(); + expect(command3).not.toHaveBeenCalled(); + expect(success).toBeFalsy(); + expect(errorSpy).toHaveBeenCalledWith(new Error('command2 failed')); + }); + + test('can pass data to command when calling a command', () => { + const command1: Command = vi.fn((_ctx, next) => next()); + + commandManager.add('command1', command1); + + const [success] = commandManager + .chain() + .command1({ command1Option: 'test' }) + .run(); + + expect(command1).toHaveBeenCalledWith( + expect.objectContaining({ command1Option: 'test' }), + expect.any(Function) + ); + expect(success).toBeTruthy(); + }); + + test('can add data to the command chain with `with` method', () => { + const command1: Command = vi.fn((_ctx, next) => next()); + + commandManager.add('command1', command1); + + const [success, ctx] = commandManager + .chain() + .with({ commandData1: 'test' }) + .command1() + .run(); + + expect(command1).toHaveBeenCalledWith( + expect.objectContaining({ commandData1: 'test' }), + expect.any(Function) + ); + expect(success).toBeTruthy(); + expect(ctx.commandData1).toBe('test'); + }); + + test('passes and updates context across commands', () => { + const command1: Command<'std', 'commandData1'> = vi.fn((_ctx, next) => + next({ commandData1: '123' }) + ); + const command2: Command<'commandData1'> = vi.fn((ctx, next) => { + expect(ctx.commandData1).toBe('123'); + next({ commandData1: '456' }); + }); + + commandManager.add('command1', command1); + commandManager.add('command2', command2); + + const [success, ctx] = commandManager.chain().command1().command2().run(); + + expect(command1).toHaveBeenCalled(); + expect(command2).toHaveBeenCalled(); + expect(success).toBeTruthy(); + expect(ctx.commandData1).toBe('456'); + }); + + test('can execute an inline command', () => { + const inlineCommand: Command = vi.fn((_ctx, next) => next()); + + const success = commandManager.chain().inline(inlineCommand).run(); + + expect(inlineCommand).toHaveBeenCalled(); + expect(success).toBeTruthy(); + }); + + test('can execute a single command with `exec`', () => { + const command1: Command1 = vi.fn((_ctx, next) => + next({ commandData1: (_ctx.command1Option ?? '') + '123' }) + ); + const command2: Command2 = vi.fn((_ctx, next) => + next({ commandData2: 'cmd2' }) + ); + const command3: Command3 = vi.fn((_ctx, next) => next()); + + commandManager.add('command1', command1); + commandManager.add('command2', command2); + commandManager.add('command3', command3); + + const result1 = commandManager.exec('command1'); + const result2 = commandManager.exec('command1', { + command1Option: 'test', + }); + const result3 = commandManager.exec('command2'); + const result4 = commandManager.exec('command3'); + + expect(command1).toHaveBeenCalled(); + expect(command2).toHaveBeenCalled(); + expect(command3).toHaveBeenCalled(); + expect(result1).toEqual({ commandData1: '123', success: true }); + expect(result2).toEqual({ commandData1: 'test123', success: true }); + expect(result3).toEqual({ commandData2: 'cmd2', success: true }); + expect(result4).toEqual({ success: true }); + }); + + test('should not continue with the rest of the chain if all commands in `try` fail', () => { + const command1: Command<never, 'commandData1'> = vi.fn((_ctx, _next) => {}); + const command2: Command = vi.fn((_ctx, _next) => {}); + const command3: Command = vi.fn((_ctx, next) => next()); + + commandManager.add('command1', command1); + commandManager.add('command2', command2); + commandManager.add('command3', command3); + + const [success] = commandManager + .chain() + .try(cmd => [cmd.command1(), cmd.command2()]) + .command3() + .run(); + + expect(command1).toHaveBeenCalled(); + expect(command2).toHaveBeenCalled(); + expect(command3).not.toHaveBeenCalled(); + expect(success).toBeFalsy(); + }); + + test('should not re-execute previous commands in the chain before `try`', () => { + const command1: Command1 = vi.fn((_ctx, next) => + next({ commandData1: '123' }) + ); + const command2: Command = vi.fn((_ctx, _next) => {}); + const command3: Command = vi.fn((_ctx, next) => next()); + + commandManager.add('command1', command1); + commandManager.add('command2', command2); + commandManager.add('command3', command3); + + const [success, ctx] = commandManager + .chain() + .command1() + .try(cmd => [cmd.command2(), cmd.command3()]) + .run(); + + expect(command1).toHaveBeenCalledTimes(1); + expect(command2).toHaveBeenCalled(); + expect(command3).toHaveBeenCalled(); + expect(success).toBeTruthy(); + expect(ctx.commandData1).toBe('123'); + }); + + test('should continue with the rest of the chain if one command in `try` succeeds', () => { + const command1: Command1 = vi.fn((_ctx, _next) => {}); + const command2: Command2 = vi.fn((_ctx, next) => + next({ commandData2: '123' }) + ); + const command3: Command3 = vi.fn((_ctx, next) => + next({ commandData3: '456' }) + ); + + commandManager.add('command1', command1); + commandManager.add('command2', command2); + commandManager.add('command3', command3); + + const [success, ctx] = commandManager + .chain() + .try(cmd => [cmd.command1(), cmd.command2()]) + .command3() + .run(); + + expect(command1).toHaveBeenCalled(); + expect(command2).toHaveBeenCalled(); + expect(command3).toHaveBeenCalled(); + expect(success).toBeTruthy(); + expect(ctx.commandData1).toBeUndefined(); + expect(ctx.commandData2).toBe('123'); + expect(ctx.commandData3).toBe('456'); + }); + + test('should not execute any further commands in `try` after one succeeds', () => { + const command1: Command1 = vi.fn((_ctx, next) => + next({ commandData1: '123' }) + ); + const command2: Command2 = vi.fn((_ctx, next) => + next({ commandData2: '456' }) + ); + + commandManager.add('command1', command1); + commandManager.add('command2', command2); + + const [success, ctx] = commandManager + .chain() + .try(cmd => [cmd.command1(), cmd.command2()]) + .run(); + + expect(command1).toHaveBeenCalled(); + expect(command2).not.toHaveBeenCalled(); + expect(success).toBeTruthy(); + expect(ctx.commandData1).toBe('123'); + expect(ctx.commandData2).toBeUndefined(); + }); + + test('should pass context correctly in `try` when a command succeeds', () => { + const command1: Command = vi.fn((_ctx, next) => + next({ commandData1: 'fromCommand1', commandData2: 'fromCommand1' }) + ); + const command2: Command<'commandData1' | 'commandData2'> = vi.fn( + (ctx, next) => { + expect(ctx.commandData1).toBe('fromCommand1'); + expect(ctx.commandData2).toBe('fromCommand1'); + // override commandData2 + next({ commandData2: 'fromCommand2' }); + } + ); + const command3: Command<'commandData1' | 'commandData2'> = vi.fn( + (ctx, next) => { + expect(ctx.commandData1).toBe('fromCommand1'); + expect(ctx.commandData2).toBe('fromCommand2'); + next(); + } + ); + + commandManager.add('command1', command1); + commandManager.add('command2', command2); + commandManager.add('command3', command3); + + const [success] = commandManager + .chain() + .command1() + .try(cmd => [cmd.command2()]) + .command3() + .run(); + + expect(command1).toHaveBeenCalled(); + expect(command2).toHaveBeenCalled(); + expect(command3).toHaveBeenCalled(); + expect(success).toBeTruthy(); + }); + + test('should continue with the rest of the chain if at least one command in `tryAll` succeeds', () => { + const command1: Command = vi.fn((_ctx, _next) => {}); + const command2: Command = vi.fn((_ctx, next) => next()); + const command3: Command = vi.fn((_ctx, next) => next()); + + commandManager.add('command1', command1); + commandManager.add('command2', command2); + commandManager.add('command3', command3); + + const [success] = commandManager + .chain() + .tryAll(cmd => [cmd.command1(), cmd.command2()]) + .command3() + .run(); + + expect(command1).toHaveBeenCalled(); + expect(command2).toHaveBeenCalled(); + expect(command3).toHaveBeenCalled(); + expect(success).toBeTruthy(); + }); + + test('should execute all commands in `tryAll` even if one has already succeeded', () => { + const command1: Command1 = vi.fn((_ctx, next) => + next({ commandData1: '123' }) + ); + const command2: Command2 = vi.fn((_ctx, next) => + next({ commandData2: '456' }) + ); + const command3: Command3 = vi.fn((_ctx, next) => + next({ commandData3: '789' }) + ); + + commandManager.add('command1', command1); + commandManager.add('command2', command2); + commandManager.add('command3', command3); + + const [success, ctx] = commandManager + .chain() + .tryAll(cmd => [cmd.command1(), cmd.command2(), cmd.command3()]) + .run(); + + expect(command1).toHaveBeenCalled(); + expect(command2).toHaveBeenCalled(); + expect(command3).toHaveBeenCalled(); + expect(ctx.commandData1).toBe('123'); + expect(ctx.commandData2).toBe('456'); + expect(ctx.commandData3).toBe('789'); + expect(success).toBeTruthy(); + }); + + test('should not continue with the rest of the chain if all commands in `tryAll` fail', () => { + const command1: Command1 = vi.fn((_ctx, _next) => {}); + const command2: Command2 = vi.fn((_ctx, _next) => {}); + const command3: Command3 = vi.fn((_ctx, next) => + next({ commandData3: '123' }) + ); + + commandManager.add('command1', command1); + commandManager.add('command2', command2); + commandManager.add('command3', command3); + + const [success, ctx] = commandManager + .chain() + .tryAll(cmd => [cmd.command1(), cmd.command2()]) + .command3() + .run(); + + expect(command1).toHaveBeenCalled(); + expect(command2).toHaveBeenCalled(); + expect(command3).not.toHaveBeenCalled(); + expect(ctx.commandData3).toBeUndefined(); + expect(success).toBeFalsy(); + }); + + test('should pass context correctly in `tryAll` when at least one command succeeds', () => { + const command1: Command = vi.fn((_ctx, next) => + next({ commandData1: 'fromCommand1' }) + ); + const command2: Command<'commandData1'> = vi.fn((ctx, next) => { + expect(ctx.commandData1).toBe('fromCommand1'); + // override commandData1 + next({ commandData1: 'fromCommand2', commandData2: 'fromCommand2' }); + }); + const command3: Command<'commandData1' | 'commandData2'> = vi.fn( + (ctx, next) => { + expect(ctx.commandData1).toBe('fromCommand2'); + expect(ctx.commandData2).toBe('fromCommand2'); + next({ + // override commandData2 + commandData2: 'fromCommand3', + commandData3: 'fromCommand3', + }); + } + ); + const command4: Command<'commandData1' | 'commandData2' | 'commandData3'> = + vi.fn((ctx, next) => { + expect(ctx.commandData1).toBe('fromCommand2'); + expect(ctx.commandData2).toBe('fromCommand3'); + expect(ctx.commandData3).toBe('fromCommand3'); + next(); + }); + + commandManager.add('command1', command1); + commandManager.add('command2', command2); + commandManager.add('command3', command3); + commandManager.add('command4', command4); + + const [success, ctx] = commandManager + .chain() + .command1() + .tryAll(cmd => [cmd.command2(), cmd.command3()]) + .command4() + .run(); + + expect(command1).toHaveBeenCalled(); + expect(command2).toHaveBeenCalled(); + expect(command3).toHaveBeenCalled(); + expect(command4).toHaveBeenCalled(); + expect(success).toBeTruthy(); + expect(ctx.commandData1).toBe('fromCommand2'); + expect(ctx.commandData2).toBe('fromCommand3'); + expect(ctx.commandData3).toBe('fromCommand3'); + }); + + test('should not re-execute commands before `tryAll` after executing `tryAll`', () => { + const command1: Command = vi.fn((_ctx, next) => next()); + const command2: Command = vi.fn((_ctx, next) => next()); + const command3: Command = vi.fn((_ctx, _next) => {}); + const command4: Command = vi.fn((_ctx, next) => next()); + + commandManager.add('command1', command1); + commandManager.add('command2', command2); + commandManager.add('command3', command3); + commandManager.add('command4', command4); + + const [success] = commandManager + .chain() + .command1() + .tryAll(cmd => [cmd.command2(), cmd.command3()]) + .command4() + .run(); + + expect(command1).toHaveBeenCalledTimes(1); + expect(command2).toHaveBeenCalled(); + expect(command3).toHaveBeenCalled(); + expect(command4).toHaveBeenCalled(); + expect(success).toBeTruthy(); + }); +}); diff --git a/blocksuite/framework/block-std/src/__tests__/editor-host.unit.spec.ts b/blocksuite/framework/block-std/src/__tests__/editor-host.unit.spec.ts new file mode 100644 index 0000000000..6ec2f6c57b --- /dev/null +++ b/blocksuite/framework/block-std/src/__tests__/editor-host.unit.spec.ts @@ -0,0 +1,56 @@ +import { DocCollection, IdGeneratorType, Schema } from '@blocksuite/store'; +import { describe, expect, test } from 'vitest'; + +import { effects } from '../effects.js'; +import { TestEditorContainer } from './test-editor.js'; +import { + type HeadingBlockModel, + HeadingBlockSchema, + NoteBlockSchema, + RootBlockSchema, +} from './test-schema.js'; +import { testSpecs } from './test-spec.js'; + +effects(); + +function createTestOptions() { + const idGenerator = IdGeneratorType.AutoIncrement; + const schema = new Schema(); + schema.register([RootBlockSchema, NoteBlockSchema, HeadingBlockSchema]); + return { id: 'test-collection', idGenerator, schema }; +} + +function wait(time: number) { + return new Promise(resolve => setTimeout(resolve, time)); +} + +describe('editor host', () => { + test('editor host should rerender model when view changes', async () => { + const collection = new DocCollection(createTestOptions()); + + collection.meta.initialize(); + const doc = collection.createDoc({ id: 'home' }); + doc.load(); + const rootId = doc.addBlock('test:page'); + const noteId = doc.addBlock('test:note', {}, rootId); + const headingId = doc.addBlock('test:heading', { type: 'h1' }, noteId); + const headingBlock = doc.getBlock(headingId)!; + + const editorContainer = new TestEditorContainer(); + editorContainer.doc = doc; + editorContainer.specs = testSpecs; + + document.body.append(editorContainer); + + await wait(50); + let headingElm = editorContainer.std.view.getBlock(headingId); + + expect(headingElm!.tagName).toBe('TEST-H1-BLOCK'); + + (headingBlock.model as HeadingBlockModel).type = 'h2'; + await wait(50); + headingElm = editorContainer.std.view.getBlock(headingId); + + expect(headingElm!.tagName).toBe('TEST-H2-BLOCK'); + }); +}); diff --git a/blocksuite/framework/block-std/src/__tests__/hast.unit.spec.ts b/blocksuite/framework/block-std/src/__tests__/hast.unit.spec.ts new file mode 100644 index 0000000000..3f2e1e9863 --- /dev/null +++ b/blocksuite/framework/block-std/src/__tests__/hast.unit.spec.ts @@ -0,0 +1,65 @@ +import rehypeParse from 'rehype-parse'; +import { unified } from 'unified'; +import { describe, expect, test } from 'vitest'; + +import { onlyContainImgElement } from '../clipboard/index.js'; + +describe('only contains img elements', () => { + test('normal with head', () => { + const htmlAst = unified().use(rehypeParse).parse(`<html> +<head></head> +<body> +<!--StartFragment--><img src="https://files.slack.com/deadbeef.png" alt="image.png"/><!--EndFragment--> +</body> +</html>`); + const isImgOnly = + htmlAst.children.map(onlyContainImgElement).reduce((a, b) => { + if (a === 'no' || b === 'no') { + return 'no'; + } + if (a === 'maybe' && b === 'maybe') { + return 'maybe'; + } + return 'yes'; + }, 'maybe') === 'yes'; + expect(isImgOnly).toBe(true); + }); + + test('normal without head', () => { + const htmlAst = unified().use(rehypeParse).parse(`<html> +<body> +<!--StartFragment--><img src="https://files.slack.com/deadbeef.png" alt="image.png"/><!--EndFragment--> +</body> +</html>`); + const isImgOnly = + htmlAst.children.map(onlyContainImgElement).reduce((a, b) => { + if (a === 'no' || b === 'no') { + return 'no'; + } + if (a === 'maybe' && b === 'maybe') { + return 'maybe'; + } + return 'yes'; + }, 'maybe') === 'yes'; + expect(isImgOnly).toBe(true); + }); + + test('contain spans', () => { + const htmlAst = unified().use(rehypeParse).parse(`<html> +<body> +<!--StartFragment--><img src="https://files.slack.com/deadbeef.png" alt="image.png"/><span></span><!--EndFragment--> +</body> +</html>`); + const isImgOnly = + htmlAst.children.map(onlyContainImgElement).reduce((a, b) => { + if (a === 'no' || b === 'no') { + return 'no'; + } + if (a === 'maybe' && b === 'maybe') { + return 'maybe'; + } + return 'yes'; + }, 'maybe') === 'yes'; + expect(isImgOnly).toBe(false); + }); +}); diff --git a/blocksuite/framework/block-std/src/__tests__/test-block.ts b/blocksuite/framework/block-std/src/__tests__/test-block.ts new file mode 100644 index 0000000000..5d008b0c65 --- /dev/null +++ b/blocksuite/framework/block-std/src/__tests__/test-block.ts @@ -0,0 +1,41 @@ +import { html } from 'lit'; +import { customElement } from 'lit/decorators.js'; + +import { BlockComponent } from '../view/index.js'; +import type { + HeadingBlockModel, + NoteBlockModel, + RootBlockModel, +} from './test-schema.js'; + +@customElement('test-root-block') +export class RootBlockComponent extends BlockComponent<RootBlockModel> { + override renderBlock() { + return html` + <div class="test-root-block">${this.renderChildren(this.model)}</div> + `; + } +} + +@customElement('test-note-block') +export class NoteBlockComponent extends BlockComponent<NoteBlockModel> { + override renderBlock() { + return html` + <div class="test-note-block">${this.renderChildren(this.model)}</div> + `; + } +} + +@customElement('test-h1-block') +export class HeadingH1BlockComponent extends BlockComponent<HeadingBlockModel> { + override renderBlock() { + return html` <div class="test-heading-block h1">${this.model.text}</div> `; + } +} + +@customElement('test-h2-block') +export class HeadingH2BlockComponent extends BlockComponent<HeadingBlockModel> { + override renderBlock() { + return html` <div class="test-heading-block h2">${this.model.text}</div> `; + } +} diff --git a/blocksuite/framework/block-std/src/__tests__/test-editor.ts b/blocksuite/framework/block-std/src/__tests__/test-editor.ts new file mode 100644 index 0000000000..7471ca57b7 --- /dev/null +++ b/blocksuite/framework/block-std/src/__tests__/test-editor.ts @@ -0,0 +1,39 @@ +import { SignalWatcher, WithDisposable } from '@blocksuite/global/utils'; +import type { Doc } from '@blocksuite/store'; +import { html } from 'lit'; +import { customElement, property } from 'lit/decorators.js'; + +import type { ExtensionType } from '../extension/index.js'; +import { BlockStdScope } from '../scope/index.js'; +import { ShadowlessElement } from '../view/index.js'; + +@customElement('test-editor-container') +export class TestEditorContainer extends SignalWatcher( + WithDisposable(ShadowlessElement) +) { + private _std!: BlockStdScope; + + get std() { + return this._std; + } + + override connectedCallback() { + super.connectedCallback(); + this._std = new BlockStdScope({ + doc: this.doc, + extensions: this.specs, + }); + } + + protected override render() { + return html` <div class="test-editor-container"> + ${this._std.render()} + </div>`; + } + + @property({ attribute: false }) + accessor doc!: Doc; + + @property({ attribute: false }) + accessor specs: ExtensionType[] = []; +} diff --git a/blocksuite/framework/block-std/src/__tests__/test-schema.ts b/blocksuite/framework/block-std/src/__tests__/test-schema.ts new file mode 100644 index 0000000000..7285f9ce10 --- /dev/null +++ b/blocksuite/framework/block-std/src/__tests__/test-schema.ts @@ -0,0 +1,56 @@ +import { defineBlockSchema, type SchemaToModel } from '@blocksuite/store'; + +export const RootBlockSchema = defineBlockSchema({ + flavour: 'test:page', + props: internal => ({ + title: internal.Text(), + count: 0, + style: {} as Record<string, unknown>, + items: [] as unknown[], + }), + metadata: { + version: 2, + role: 'root', + children: ['test:note'], + }, +}); + +export type RootBlockModel = SchemaToModel<typeof RootBlockSchema>; + +export const NoteBlockSchema = defineBlockSchema({ + flavour: 'test:note', + props: () => ({}), + metadata: { + version: 1, + role: 'hub', + parent: ['test:page'], + children: ['test:heading'], + }, +}); + +export type NoteBlockModel = SchemaToModel<typeof NoteBlockSchema>; + +export const HeadingBlockSchema = defineBlockSchema({ + flavour: 'test:heading', + props: internal => ({ + type: 'h1', + text: internal.Text(), + }), + metadata: { + version: 1, + role: 'content', + parent: ['test:note'], + }, +}); + +export type HeadingBlockModel = SchemaToModel<typeof HeadingBlockSchema>; + +declare global { + namespace BlockSuite { + interface BlockModels { + 'test:page': RootBlockModel; + 'test:note': NoteBlockModel; + 'test:heading': HeadingBlockModel; + } + } +} diff --git a/blocksuite/framework/block-std/src/__tests__/test-spec.ts b/blocksuite/framework/block-std/src/__tests__/test-spec.ts new file mode 100644 index 0000000000..22ac3953bd --- /dev/null +++ b/blocksuite/framework/block-std/src/__tests__/test-spec.ts @@ -0,0 +1,22 @@ +import './test-block.js'; + +import { literal } from 'lit/static-html.js'; + +import { BlockViewExtension, type ExtensionType } from '../extension/index.js'; +import type { HeadingBlockModel } from './test-schema.js'; + +export const testSpecs: ExtensionType[] = [ + BlockViewExtension('test:page', literal`test-root-block`), + + BlockViewExtension('test:note', literal`test-note-block`), + + BlockViewExtension('test:heading', model => { + const h = (model as HeadingBlockModel).type$.value; + + if (h === 'h1') { + return literal`test-h1-block`; + } + + return literal`test-h2-block`; + }), +]; diff --git a/blocksuite/framework/block-std/src/clipboard/index.ts b/blocksuite/framework/block-std/src/clipboard/index.ts new file mode 100644 index 0000000000..48aee32dc5 --- /dev/null +++ b/blocksuite/framework/block-std/src/clipboard/index.ts @@ -0,0 +1,363 @@ +import { BlockSuiteError, ErrorCode } from '@blocksuite/global/exceptions'; +import type { + BaseAdapter, + BlockSnapshot, + Doc, + JobMiddleware, + Slice, +} from '@blocksuite/store'; +import { Job } from '@blocksuite/store'; +import DOMPurify from 'dompurify'; +import type { RootContentMap } from 'hast'; +import * as lz from 'lz-string'; +import rehypeParse from 'rehype-parse'; +import { unified } from 'unified'; + +import { LifeCycleWatcher } from '../extension/index.js'; + +type AdapterConstructor<T extends BaseAdapter> = new (job: Job) => T; + +type AdapterMap = Map< + string, + { + adapter: AdapterConstructor<BaseAdapter>; + priority: number; + } +>; + +type HastUnionType< + K extends keyof RootContentMap, + V extends RootContentMap[K], +> = V; + +export function onlyContainImgElement( + ast: HastUnionType<keyof RootContentMap, RootContentMap[keyof RootContentMap]> +): 'yes' | 'no' | 'maybe' { + if (ast.type === 'element') { + switch (ast.tagName) { + case 'html': + case 'body': + return ast.children.map(onlyContainImgElement).reduce((a, b) => { + if (a === 'no' || b === 'no') { + return 'no'; + } + if (a === 'maybe' && b === 'maybe') { + return 'maybe'; + } + return 'yes'; + }, 'maybe'); + case 'img': + return 'yes'; + case 'head': + return 'maybe'; + default: + return 'no'; + } + } + return 'maybe'; +} + +export class Clipboard extends LifeCycleWatcher { + static override readonly key = 'clipboard'; + + private _adapterMap: AdapterMap = new Map(); + + // Need to be cloned to a map for later use + private _getDataByType = (clipboardData: DataTransfer) => { + const data = new Map<string, string | File[]>(); + for (const type of clipboardData.types) { + if (type === 'Files') { + data.set(type, Array.from(clipboardData.files)); + } else { + data.set(type, clipboardData.getData(type)); + } + } + if (data.get('Files') && data.get('text/html')) { + const htmlAst = unified() + .use(rehypeParse) + .parse(data.get('text/html') as string); + + const isImgOnly = + htmlAst.children.map(onlyContainImgElement).reduce((a, b) => { + if (a === 'no' || b === 'no') { + return 'no'; + } + if (a === 'maybe' && b === 'maybe') { + return 'maybe'; + } + return 'yes'; + }, 'maybe') === 'yes'; + + if (isImgOnly) { + data.delete('text/html'); + } + } + return (type: string) => { + const item = data.get(type); + if (item) { + return item; + } + const files = (data.get('Files') ?? []) as File[]; + if (files.length > 0) { + return files; + } + return ''; + }; + }; + + private _getSnapshotByPriority = async ( + getItem: (type: string) => string | File[], + doc: Doc, + parent?: string, + index?: number + ) => { + const byPriority = Array.from(this._adapterMap.entries()).sort( + (a, b) => b[1].priority - a[1].priority + ); + for (const [type, { adapter }] of byPriority) { + const item = getItem(type); + if (Array.isArray(item)) { + if (item.length === 0) { + continue; + } + if ( + // if all files are not the same target type, fallback to */* + !item + .map(f => f.type === type || type === '*/*') + .reduce((a, b) => a && b, true) + ) { + continue; + } + } + if (item) { + const job = this._getJob(); + const adapterInstance = new adapter(job); + const payload = { + file: item, + assets: job.assetsManager, + blockVersions: doc.collection.meta.blockVersions, + workspaceId: doc.collection.id, + pageId: doc.id, + }; + const result = await adapterInstance.toSlice( + payload, + doc, + parent, + index + ); + if (result) { + return result; + } + } + } + return null; + }; + + private _jobMiddlewares: JobMiddleware[] = []; + + copy = async (slice: Slice) => { + return this.copySlice(slice); + }; + + // Gated by https://developer.mozilla.org/en-US/docs/Glossary/Transient_activation + copySlice = async (slice: Slice) => { + const adapterKeys = Array.from(this._adapterMap.keys()); + + await this.writeToClipboard(async _items => { + const items = { ..._items }; + + await Promise.all( + adapterKeys.map(async type => { + const item = await this._getClipboardItem(slice, type); + if (typeof item === 'string') { + items[type] = item; + } + }) + ); + return items; + }); + }; + + duplicateSlice = async ( + slice: Slice, + doc: Doc, + parent?: string, + index?: number, + type = 'BLOCKSUITE/SNAPSHOT' + ) => { + const items = { + [type]: await this._getClipboardItem(slice, type), + }; + + await this._getSnapshotByPriority( + type => (items[type] as string | File[]) ?? '', + doc, + parent, + index + ); + }; + + paste = async ( + event: ClipboardEvent, + doc: Doc, + parent?: string, + index?: number + ) => { + const data = event.clipboardData; + if (!data) return; + + try { + const json = this.readFromClipboard(data); + const slice = await this._getSnapshotByPriority( + type => json[type], + doc, + parent, + index + ); + if (!slice) { + throw new BlockSuiteError( + ErrorCode.TransformerError, + 'No snapshot found' + ); + } + return slice; + } catch { + const getDataByType = this._getDataByType(data); + const slice = await this._getSnapshotByPriority( + type => getDataByType(type), + doc, + parent, + index + ); + + return slice; + } + }; + + pasteBlockSnapshot = async ( + snapshot: BlockSnapshot, + doc: Doc, + parent?: string, + index?: number + ) => { + return this._getJob().snapshotToBlock(snapshot, doc, parent, index); + }; + + registerAdapter = <T extends BaseAdapter>( + mimeType: string, + adapter: AdapterConstructor<T>, + priority = 0 + ) => { + this._adapterMap.set(mimeType, { adapter, priority }); + }; + + unregisterAdapter = (mimeType: string) => { + this._adapterMap.delete(mimeType); + }; + + unuse = (middleware: JobMiddleware) => { + this._jobMiddlewares = this._jobMiddlewares.filter(m => m !== middleware); + }; + + use = (middleware: JobMiddleware) => { + this._jobMiddlewares.push(middleware); + }; + + get configs() { + return this._getJob().adapterConfigs; + } + + private async _getClipboardItem(slice: Slice, type: string) { + const job = this._getJob(); + const adapterItem = this._adapterMap.get(type); + if (!adapterItem) { + return; + } + const { adapter } = adapterItem; + const adapterInstance = new adapter(job); + const result = await adapterInstance.fromSlice(slice); + if (!result) { + return; + } + return result.file; + } + + private _getJob() { + return new Job({ + middlewares: this._jobMiddlewares, + collection: this.std.collection, + }); + } + + readFromClipboard(clipboardData: DataTransfer) { + const items = clipboardData.getData('text/html'); + const sanitizedItems = DOMPurify.sanitize(items); + const domParser = new DOMParser(); + const doc = domParser.parseFromString(sanitizedItems, 'text/html'); + const dom = doc.querySelector<HTMLDivElement>('[data-blocksuite-snapshot]'); + if (!dom) { + throw new BlockSuiteError( + ErrorCode.TransformerError, + 'No snapshot found' + ); + } + const json = JSON.parse( + lz.decompressFromEncodedURIComponent( + dom.dataset.blocksuiteSnapshot as string + ) + ); + return json; + } + + sliceToSnapshot(slice: Slice) { + const job = this._getJob(); + return job.sliceToSnapshot(slice); + } + + async writeToClipboard( + updateItems: ( + items: Record<string, unknown> + ) => Promise<Record<string, unknown>> | Record<string, unknown> + ) { + const _items = { + 'text/plain': '', + 'text/html': '', + 'image/png': '', + }; + + const items = await updateItems(_items); + + const text = items['text/plain'] as string; + const innerHTML = items['text/html'] as string; + const png = items['image/png'] as string | Blob; + + delete items['text/plain']; + delete items['text/html']; + delete items['image/png']; + + const snapshot = lz.compressToEncodedURIComponent(JSON.stringify(items)); + const html = `<div data-blocksuite-snapshot='${snapshot}'>${innerHTML}</div>`; + const htmlBlob = new Blob([html], { + type: 'text/html', + }); + const clipboardItems: Record<string, Blob> = { + 'text/html': htmlBlob, + }; + if (text.length > 0) { + const textBlob = new Blob([text], { + type: 'text/plain', + }); + clipboardItems['text/plain'] = textBlob; + } + + if (png instanceof Blob) { + clipboardItems['image/png'] = png; + } else if (png.length > 0) { + const pngBlob = new Blob([png], { + type: 'image/png', + }); + clipboardItems['image/png'] = pngBlob; + } + await navigator.clipboard.write([new ClipboardItem(clipboardItems)]); + } +} diff --git a/blocksuite/framework/block-std/src/command/consts.ts b/blocksuite/framework/block-std/src/command/consts.ts new file mode 100644 index 0000000000..4e9a9a90d0 --- /dev/null +++ b/blocksuite/framework/block-std/src/command/consts.ts @@ -0,0 +1 @@ +export const cmdSymbol = Symbol('cmds'); diff --git a/blocksuite/framework/block-std/src/command/index.ts b/blocksuite/framework/block-std/src/command/index.ts new file mode 100644 index 0000000000..d4a0a3a8db --- /dev/null +++ b/blocksuite/framework/block-std/src/command/index.ts @@ -0,0 +1,3 @@ +export * from './consts.js'; +export * from './manager.js'; +export * from './types.js'; diff --git a/blocksuite/framework/block-std/src/command/manager.ts b/blocksuite/framework/block-std/src/command/manager.ts new file mode 100644 index 0000000000..974bfb22a3 --- /dev/null +++ b/blocksuite/framework/block-std/src/command/manager.ts @@ -0,0 +1,382 @@ +import { BlockSuiteError, ErrorCode } from '@blocksuite/global/exceptions'; + +import { LifeCycleWatcher } from '../extension/index.js'; +import { CommandIdentifier } from '../identifier.js'; +import { cmdSymbol } from './consts.js'; +import type { + Chain, + Command, + ExecCommandResult, + IfAllKeysOptional, + InDataOfCommand, + InitCommandCtx, +} from './types.js'; + +/** + * Command manager to manage all commands + * + * Commands are functions that take a context and a next function as arguments + * + * ```ts + * const myCommand: Command<'count', 'count'> = (ctx, next) => { + * const count = ctx.count || 0; + * + * const success = someOperation(); + * if (success) { + * return next({ count: count + 1 }); + * } + * // if the command is not successful, you can return without calling next + * return; + * ``` + * + * You should always add the command to the global interface `BlockSuite.Commands` + * ```ts + * declare global { + * namespace BlockSuite { + * interface Commands { + * 'myCommand': typeof myCommand + * } + * } + * } + * ``` + * + * Command input and output data can be defined in the `Command` type + * + * ```ts + * // input: ctx.firstName, ctx.lastName + * // output: ctx.fullName + * const myCommand: Command<'firstName' | 'lastName', 'fullName'> = (ctx, next) => { + * const { firstName, lastName } = ctx; + * const fullName = `${firstName} ${lastName}`; + * return next({ fullName }); + * } + * + * declare global { + * namespace BlockSuite { + * interface CommandContext { + * // All command input and output data should be defined here + * // The keys should be optional + * firstName?: string; + * lastName?: string; + * fullName?: string; + * } + * } + * } + * + * ``` + * + * + * --- + * + * Commands can be run in two ways: + * + * 1. Using `exec` method + * `exec` is used to run a single command + * ```ts + * const { success, ...data } = commandManager.exec('myCommand', payload); + * ``` + * + * 2. Using `chain` method + * `chain` is used to run a series of commands + * ```ts + * const chain = commandManager.chain(); + * const [result, data] = chain + * .myCommand1() + * .myCommand2(payload) + * .run(); + * ``` + * + * --- + * + * Command chains will stop running if a command is not successful + * + * ```ts + * const chain = commandManager.chain(); + * const [result, data] = chain + * .myCommand1() <-- if this fail + * .myCommand2(payload) <- this won't run + * .run(); + * + * result <- result will be `false` + * ``` + * + * You can use `try` to run a series of commands and if one of them is successful, it will continue to the next command + * ```ts + * const chain = commandManager.chain(); + * const [result, data] = chain + * .try(chain => [ + * chain.myCommand1(), <- if this fail + * chain.myCommand2(), <- this will run, if this success + * chain.myCommand3(), <- this won't run + * ]) + * .run(); + * ``` + * + * The `tryAll` method is similar to `try`, but it will run all commands even if one of them is successful + * ```ts + * const chain = commandManager.chain(); + * const [result, data] = chain + * .try(chain => [ + * chain.myCommand1(), <- if this success + * chain.myCommand2(), <- this will also run + * chain.myCommand3(), <- so will this + * ]) + * .run(); + * ``` + * + */ +export class CommandManager extends LifeCycleWatcher { + static override readonly key = 'commandManager'; + + private _commands = new Map<string, Command>(); + + private _createChain = ( + methods: Record<BlockSuite.CommandName, unknown>, + _cmds: Command[] + ): Chain => { + const getCommandCtx = this._getCommandCtx; + const createChain = this._createChain; + const chain = this.chain; + + return { + [cmdSymbol]: _cmds, + run: function (this: Chain) { + let ctx = getCommandCtx(); + let success = false; + try { + const cmds = this[cmdSymbol]; + ctx = runCmds(ctx as BlockSuite.CommandContext, [ + ...cmds, + (_, next) => { + success = true; + next(); + }, + ]); + } catch (err) { + console.error(err); + } + + return [success, ctx]; + }, + with: function (this: Chain, value) { + const cmds = this[cmdSymbol]; + return createChain(methods, [ + ...cmds, + (_, next) => next(value), + ]) as never; + }, + inline: function (this: Chain, command) { + const cmds = this[cmdSymbol]; + return createChain(methods, [...cmds, command]) as never; + }, + try: function (this: Chain, fn) { + const cmds = this[cmdSymbol]; + return createChain(methods, [ + ...cmds, + (beforeCtx, next) => { + let ctx = beforeCtx; + const chains = fn(chain()); + + chains.some(chain => { + // inject ctx in the beginning + chain[cmdSymbol] = [ + (_, next) => { + next(ctx); + }, + ...chain[cmdSymbol], + ]; + + const [success] = chain + .inline((branchCtx, next) => { + ctx = { ...ctx, ...branchCtx }; + next(); + }) + .run(); + if (success) { + next(ctx); + return true; + } + return false; + }); + }, + ]) as never; + }, + tryAll: function (this: Chain, fn) { + const cmds = this[cmdSymbol]; + return createChain(methods, [ + ...cmds, + (beforeCtx, next) => { + let ctx = beforeCtx; + const chains = fn(chain()); + + let allFail = true; + chains.forEach(chain => { + // inject ctx in the beginning + chain[cmdSymbol] = [ + (_, next) => { + next(ctx); + }, + ...chain[cmdSymbol], + ]; + + const [success] = chain + .inline((branchCtx, next) => { + ctx = { ...ctx, ...branchCtx }; + next(); + }) + .run(); + if (success) { + allFail = false; + } + }); + if (!allFail) { + next(ctx); + } + }, + ]) as never; + }, + ...methods, + } as Chain; + }; + + private _getCommandCtx = (): InitCommandCtx => { + return { + std: this.std, + }; + }; + + /** + * Create a chain to run a series of commands + * ```ts + * const chain = commandManager.chain(); + * const [result, data] = chain + * .myCommand1() + * .myCommand2(payload) + * .run(); + * ``` + * @returns [success, data] - success is a boolean to indicate if the chain is successful, + * data is the final context after running the chain + */ + chain = (): Chain<InitCommandCtx> => { + const methods = {} as Record< + string, + (data: Record<string, unknown>) => Chain + >; + const createChain = this._createChain; + for (const [name, command] of this._commands.entries()) { + methods[name] = function ( + this: { [cmdSymbol]: Command[] }, + data: Record<string, unknown> + ) { + const cmds = this[cmdSymbol]; + return createChain(methods, [ + ...cmds, + (ctx, next) => command({ ...ctx, ...data }, next), + ]); + }; + } + + return createChain(methods, []) as never; + }; + + /** + * Register a command to the command manager + * @param name + * @param command + * Make sure to also add the command to the global interface `BlockSuite.Commands` + * ```ts + * const myCommand: Command = (ctx, next) => { + * // do something + * } + * + * declare global { + * namespace BlockSuite { + * interface Commands { + * 'myCommand': typeof myCommand + * } + * } + * } + * ``` + */ + add<N extends BlockSuite.CommandName>( + name: N, + command: BlockSuite.Commands[N] + ): CommandManager; + + add(name: string, command: Command) { + this._commands.set(name, command); + return this; + } + + override created() { + const add = this.add.bind(this); + this.std.provider.getAll(CommandIdentifier).forEach((command, key) => { + add(key as keyof BlockSuite.Commands, command); + }); + } + + /** + * Execute a registered command by name + * @param command + * @param payloads + * ```ts + * const { success, ...data } = commandManager.exec('myCommand', { data: 'data' }); + * ``` + * @returns { success, ...data } - success is a boolean to indicate if the command is successful, + * data is the final context after running the command + */ + exec<K extends keyof BlockSuite.Commands>( + command: K, + ...payloads: IfAllKeysOptional< + Omit<InDataOfCommand<BlockSuite.Commands[K]>, keyof InitCommandCtx>, + [ + inData: void | Omit< + InDataOfCommand<BlockSuite.Commands[K]>, + keyof InitCommandCtx + >, + ], + [ + inData: Omit< + InDataOfCommand<BlockSuite.Commands[K]>, + keyof InitCommandCtx + >, + ] + > + ): ExecCommandResult<K> & { success: boolean } { + const cmdFunc = this._commands.get(command); + + if (!cmdFunc) { + throw new BlockSuiteError( + ErrorCode.CommandError, + `The command "${command}" not found` + ); + } + + const inData = payloads[0]; + const ctx = { + ...this._getCommandCtx(), + ...inData, + }; + + let execResult = { + success: false, + } as ExecCommandResult<K> & { success: boolean }; + + cmdFunc(ctx, result => { + // @ts-expect-error FIXME: ts error + execResult = { ...result, success: true }; + }); + + return execResult; + } +} + +function runCmds(ctx: BlockSuite.CommandContext, [cmd, ...rest]: Command[]) { + let _ctx = ctx; + if (cmd) { + cmd(ctx, data => { + _ctx = runCmds({ ...ctx, ...data }, rest); + }); + } + return _ctx; +} diff --git a/blocksuite/framework/block-std/src/command/types.ts b/blocksuite/framework/block-std/src/command/types.ts new file mode 100644 index 0000000000..ddb4c1f2b7 --- /dev/null +++ b/blocksuite/framework/block-std/src/command/types.ts @@ -0,0 +1,80 @@ +// type A = {}; +// type B = { prop?: string }; +// type C = { prop: string }; +// type TestA = MakeOptionalIfEmpty<A>; // void | {} +// type TestB = MakeOptionalIfEmpty<B>; // void | { prop?: string } +// type TestC = MakeOptionalIfEmpty<C>; // { prop: string } +import type { cmdSymbol } from './consts.js'; + +export type IfAllKeysOptional<T, Yes, No> = + Partial<T> extends T ? (T extends Partial<T> ? Yes : No) : No; +type MakeOptionalIfEmpty<T> = IfAllKeysOptional<T, void | T, T>; + +export interface InitCommandCtx { + std: BlockSuite.Std; +} + +export type CommandKeyToData<K extends BlockSuite.CommandDataName> = Pick< + BlockSuite.CommandContext, + K +>; +export type Command< + In extends BlockSuite.CommandDataName = never, + Out extends BlockSuite.CommandDataName = never, + InData extends object = {}, +> = ( + ctx: CommandKeyToData<In> & InitCommandCtx & InData, + next: (ctx?: CommandKeyToData<Out>) => void +) => void; +type Omit1<A, B> = [keyof Omit<A, keyof B>] extends [never] + ? void + : Omit<A, keyof B>; +export type InDataOfCommand<C> = + C extends Command<infer K, any, infer R> ? CommandKeyToData<K> & R : never; +type OutDataOfCommand<C> = + C extends Command<any, infer K, any> ? CommandKeyToData<K> : never; + +type CommonMethods<In extends object = {}> = { + inline: <InlineOut extends BlockSuite.CommandDataName = never>( + command: Command<Extract<keyof In, BlockSuite.CommandDataName>, InlineOut> + ) => Chain<In & CommandKeyToData<InlineOut>>; + try: <InlineOut extends BlockSuite.CommandDataName = never>( + fn: (chain: Chain<In>) => Chain<In & CommandKeyToData<InlineOut>>[] + ) => Chain<In & CommandKeyToData<InlineOut>>; + tryAll: <InlineOut extends BlockSuite.CommandDataName = never>( + fn: (chain: Chain<In>) => Chain<In & CommandKeyToData<InlineOut>>[] + ) => Chain<In & CommandKeyToData<InlineOut>>; + run(): [ + result: boolean, + ctx: CommandKeyToData<Extract<keyof In, BlockSuite.CommandDataName>>, + ]; + with<T extends Partial<BlockSuite.CommandContext>>(value: T): Chain<In & T>; +}; + +type Cmds = { + [cmdSymbol]: Command[]; +}; + +export type Chain<In extends object = {}> = CommonMethods<In> & { + [K in keyof BlockSuite.Commands]: ( + data: MakeOptionalIfEmpty< + Omit1<InDataOfCommand<BlockSuite.Commands[K]>, In> + > + ) => Chain<In & OutDataOfCommand<BlockSuite.Commands[K]>>; +} & Cmds; + +export type ExecCommandResult<K extends keyof BlockSuite.Commands> = + OutDataOfCommand<BlockSuite.Commands[K]>; + +declare global { + namespace BlockSuite { + interface CommandContext extends InitCommandCtx {} + + interface Commands {} + + type CommandName = keyof Commands; + type CommandDataName = keyof CommandContext; + + type CommandChain<In extends object = {}> = Chain<In & InitCommandCtx>; + } +} diff --git a/blocksuite/framework/block-std/src/effects.ts b/blocksuite/framework/block-std/src/effects.ts new file mode 100644 index 0000000000..6406571173 --- /dev/null +++ b/blocksuite/framework/block-std/src/effects.ts @@ -0,0 +1,7 @@ +import { GfxViewportElement } from './gfx/viewport-element.js'; +import { EditorHost } from './view/index.js'; + +export function effects() { + customElements.define('editor-host', EditorHost); + customElements.define('gfx-viewport', GfxViewportElement); +} diff --git a/blocksuite/framework/block-std/src/event/base.ts b/blocksuite/framework/block-std/src/event/base.ts new file mode 100644 index 0000000000..f37e8fb6dc --- /dev/null +++ b/blocksuite/framework/block-std/src/event/base.ts @@ -0,0 +1,62 @@ +import { BlockSuiteError, ErrorCode } from '@blocksuite/global/exceptions'; + +type MatchEvent<T extends string> = T extends UIEventStateType + ? BlockSuiteUIEventState[T] + : UIEventState; + +export class UIEventState { + /** when extends, override it with pattern `xxxState` */ + type = 'defaultState'; + + constructor(public event: Event) {} +} + +export class UIEventStateContext { + private _map: Record<string, UIEventState> = {}; + + add = <State extends UIEventState = UIEventState>(state: State) => { + const name = state.type; + if (this._map[name]) { + console.warn('UIEventStateContext: state name duplicated', name); + } + + this._map[name] = state; + }; + + get = <Type extends UIEventStateType = UIEventStateType>( + type: Type + ): MatchEvent<Type> => { + const state = this._map[type]; + if (!state) { + throw new BlockSuiteError( + ErrorCode.EventDispatcherError, + `UIEventStateContext: state ${type} not found` + ); + } + return state as MatchEvent<Type>; + }; + + has = (type: UIEventStateType) => { + return !!this._map[type]; + }; + + static from(...states: UIEventState[]) { + const context = new UIEventStateContext(); + states.forEach(state => { + context.add(state); + }); + return context; + } +} + +export type UIEventHandler = ( + context: UIEventStateContext +) => boolean | null | undefined | void; + +declare global { + interface BlockSuiteUIEventState { + defaultState: UIEventState; + } + + type UIEventStateType = keyof BlockSuiteUIEventState; +} diff --git a/blocksuite/framework/block-std/src/event/control/clipboard.ts b/blocksuite/framework/block-std/src/event/control/clipboard.ts new file mode 100644 index 0000000000..0f746e85f4 --- /dev/null +++ b/blocksuite/framework/block-std/src/event/control/clipboard.ts @@ -0,0 +1,56 @@ +import { UIEventState, UIEventStateContext } from '../base.js'; +import type { UIEventDispatcher } from '../dispatcher.js'; +import { ClipboardEventState } from '../state/clipboard.js'; +import { EventScopeSourceType, EventSourceState } from '../state/source.js'; + +export class ClipboardControl { + private _copy = (event: ClipboardEvent) => { + const clipboardEventState = new ClipboardEventState({ + event, + }); + this._dispatcher.run( + 'copy', + this._createContext(event, clipboardEventState) + ); + }; + + private _cut = (event: ClipboardEvent) => { + const clipboardEventState = new ClipboardEventState({ + event, + }); + this._dispatcher.run( + 'cut', + this._createContext(event, clipboardEventState) + ); + }; + + private _paste = (event: ClipboardEvent) => { + const clipboardEventState = new ClipboardEventState({ + event, + }); + + this._dispatcher.run( + 'paste', + this._createContext(event, clipboardEventState) + ); + }; + + constructor(private _dispatcher: UIEventDispatcher) {} + + private _createContext(event: Event, clipboardState: ClipboardEventState) { + return UIEventStateContext.from( + new UIEventState(event), + new EventSourceState({ + event, + sourceType: EventScopeSourceType.Selection, + }), + clipboardState + ); + } + + listen() { + this._dispatcher.disposables.addFromEvent(document, 'cut', this._cut); + this._dispatcher.disposables.addFromEvent(document, 'copy', this._copy); + this._dispatcher.disposables.addFromEvent(document, 'paste', this._paste); + } +} diff --git a/blocksuite/framework/block-std/src/event/control/keyboard.ts b/blocksuite/framework/block-std/src/event/control/keyboard.ts new file mode 100644 index 0000000000..e3e70d63cf --- /dev/null +++ b/blocksuite/framework/block-std/src/event/control/keyboard.ts @@ -0,0 +1,112 @@ +import { IS_MAC } from '@blocksuite/global/env'; + +import { + type UIEventHandler, + UIEventState, + UIEventStateContext, +} from '../base.js'; +import type { EventOptions, UIEventDispatcher } from '../dispatcher.js'; +import { bindKeymap } from '../keymap.js'; +import { KeyboardEventState } from '../state/index.js'; +import { EventScopeSourceType, EventSourceState } from '../state/source.js'; + +export class KeyboardControl { + private _down = (event: KeyboardEvent) => { + if (!this._shouldTrigger(event)) { + return; + } + const keyboardEventState = new KeyboardEventState({ + event, + composing: this.composition, + }); + this._dispatcher.run( + 'keyDown', + this._createContext(event, keyboardEventState) + ); + }; + + private _shouldTrigger = (event: KeyboardEvent) => { + if (event.isComposing) { + return false; + } + const mod = IS_MAC ? event.metaKey : event.ctrlKey; + if ( + ['c', 'v', 'x'].includes(event.key) && + mod && + !event.shiftKey && + !event.altKey + ) { + return false; + } + return true; + }; + + private _up = (event: KeyboardEvent) => { + if (!this._shouldTrigger(event)) { + return; + } + const keyboardEventState = new KeyboardEventState({ + event, + composing: this.composition, + }); + + this._dispatcher.run( + 'keyUp', + this._createContext(event, keyboardEventState) + ); + }; + + private composition = false; + + constructor(private _dispatcher: UIEventDispatcher) {} + + private _createContext(event: Event, keyboardState: KeyboardEventState) { + return UIEventStateContext.from( + new UIEventState(event), + new EventSourceState({ + event, + sourceType: EventScopeSourceType.Selection, + }), + keyboardState + ); + } + + bindHotkey(keymap: Record<string, UIEventHandler>, options?: EventOptions) { + return this._dispatcher.add( + 'keyDown', + ctx => { + if (this.composition) { + return false; + } + const binding = bindKeymap(keymap); + return binding(ctx); + }, + options + ); + } + + listen() { + this._dispatcher.disposables.addFromEvent(document, 'keydown', this._down); + this._dispatcher.disposables.addFromEvent(document, 'keyup', this._up); + this._dispatcher.disposables.addFromEvent( + document, + 'compositionstart', + () => { + this.composition = true; + }, + { + capture: true, + } + ); + this._dispatcher.disposables.addFromEvent( + document, + 'compositionend', + () => { + this.composition = false; + }, + { + capture: true, + } + ); + } +} diff --git a/blocksuite/framework/block-std/src/event/control/pointer.ts b/blocksuite/framework/block-std/src/event/control/pointer.ts new file mode 100644 index 0000000000..a4e4458f9d --- /dev/null +++ b/blocksuite/framework/block-std/src/event/control/pointer.ts @@ -0,0 +1,594 @@ +import { IS_IPAD } from '@blocksuite/global/env'; +import { nextTick, Vec } from '@blocksuite/global/utils'; + +import { UIEventState, UIEventStateContext } from '../base.js'; +import type { UIEventDispatcher } from '../dispatcher.js'; +import { + DndEventState, + MultiPointerEventState, + PointerEventState, +} from '../state/index.js'; +import { EventScopeSourceType, EventSourceState } from '../state/source.js'; +import { isFarEnough } from '../utils.js'; + +type PointerId = typeof PointerEvent.prototype.pointerId; + +function createContext( + event: Event, + state: PointerEventState | MultiPointerEventState +) { + return UIEventStateContext.from( + new UIEventState(event), + new EventSourceState({ + event, + sourceType: EventScopeSourceType.Target, + }), + state + ); +} + +const POLL_INTERVAL = 1000; + +abstract class PointerControllerBase { + constructor( + protected _dispatcher: UIEventDispatcher, + protected _getRect: () => DOMRect + ) {} + + abstract listen(): void; +} + +class PointerEventForward extends PointerControllerBase { + private _down = (event: PointerEvent) => { + const { pointerId } = event; + + const pointerState = new PointerEventState({ + event, + rect: this._getRect(), + startX: -Infinity, + startY: -Infinity, + last: null, + }); + this._startStates.set(pointerId, pointerState); + this._lastStates.set(pointerId, pointerState); + this._dispatcher.run('pointerDown', createContext(event, pointerState)); + }; + + private _lastStates = new Map<PointerId, PointerEventState>(); + + private _move = (event: PointerEvent) => { + const { pointerId } = event; + + const start = this._startStates.get(pointerId) ?? null; + const last = this._lastStates.get(pointerId) ?? null; + + const state = new PointerEventState({ + event, + rect: this._getRect(), + startX: start?.x ?? -Infinity, + startY: start?.y ?? -Infinity, + last, + }); + this._lastStates.set(pointerId, state); + + this._dispatcher.run('pointerMove', createContext(event, state)); + }; + + private _startStates = new Map<PointerId, PointerEventState>(); + + private _upOrOut = (up: boolean) => (event: PointerEvent) => { + const { pointerId } = event; + + const start = this._startStates.get(pointerId) ?? null; + const last = this._lastStates.get(pointerId) ?? null; + + const state = new PointerEventState({ + event, + rect: this._getRect(), + startX: start?.x ?? -Infinity, + startY: start?.y ?? -Infinity, + last, + }); + + this._startStates.delete(pointerId); + this._lastStates.delete(pointerId); + + this._dispatcher.run( + up ? 'pointerUp' : 'pointerOut', + createContext(event, state) + ); + }; + + listen() { + const { host, disposables } = this._dispatcher; + disposables.addFromEvent(host, 'pointerdown', this._down); + disposables.addFromEvent(host, 'pointermove', this._move); + disposables.addFromEvent(host, 'pointerup', this._upOrOut(true)); + disposables.addFromEvent(host, 'pointerout', this._upOrOut(false)); + } +} + +class ClickController extends PointerControllerBase { + private _down = (event: PointerEvent) => { + // disable for secondary pointer + if (event.isPrimary === false) return; + + if ( + this._downPointerState && + event.pointerId === this._downPointerState.raw.pointerId && + event.timeStamp - this._downPointerState.raw.timeStamp < 500 && + !isFarEnough(event, this._downPointerState.raw) + ) { + this._pointerDownCount++; + } else { + this._pointerDownCount = 1; + } + + this._downPointerState = new PointerEventState({ + event, + rect: this._getRect(), + startX: -Infinity, + startY: -Infinity, + last: null, + }); + }; + + private _downPointerState: PointerEventState | null = null; + + private _pointerDownCount = 0; + + private _up = (event: PointerEvent) => { + if (!this._downPointerState) return; + + if (isFarEnough(this._downPointerState.raw, event)) { + this._pointerDownCount = 0; + this._downPointerState = null; + return; + } + + const state = new PointerEventState({ + event, + rect: this._getRect(), + startX: -Infinity, + startY: -Infinity, + last: null, + }); + const context = createContext(event, state); + + const run = () => { + this._dispatcher.run('click', context); + if (this._pointerDownCount === 2) { + this._dispatcher.run('doubleClick', context); + } + if (this._pointerDownCount === 3) { + this._dispatcher.run('tripleClick', context); + } + }; + + run(); + }; + + listen() { + const { host, disposables } = this._dispatcher; + + disposables.addFromEvent(host, 'pointerdown', this._down); + disposables.addFromEvent(host, 'pointerup', this._up); + } +} + +class DragController extends PointerControllerBase { + private _down = (event: PointerEvent) => { + if (this._nativeDragging) return; + + if (!event.isPrimary) { + if (this._dragging && this._lastPointerState) { + this._up(this._lastPointerState.raw); + } + this._reset(); + return; + } + + const pointerState = new PointerEventState({ + event, + rect: this._getRect(), + startX: -Infinity, + startY: -Infinity, + last: null, + }); + this._startPointerState = pointerState; + + this._dispatcher.disposables.addFromEvent( + document, + 'pointermove', + this._move + ); + this._dispatcher.disposables.addFromEvent(document, 'pointerup', this._up); + }; + + private _dragging = false; + + private _lastPointerState: PointerEventState | null = null; + + private _move = (event: PointerEvent) => { + if ( + this._startPointerState === null || + this._startPointerState.raw.pointerId !== event.pointerId + ) + return; + + const start = this._startPointerState; + const last = this._lastPointerState ?? start; + + const state = new PointerEventState({ + event, + rect: this._getRect(), + startX: start.x, + startY: start.y, + last, + }); + + this._lastPointerState = state; + + if ( + !this._nativeDragging && + !this._dragging && + isFarEnough(event, this._startPointerState.raw) + ) { + this._dragging = true; + this._dispatcher.run('dragStart', createContext(event, start)); + } + + if (this._dragging) { + this._dispatcher.run('dragMove', createContext(event, state)); + } + }; + + private _nativeDragEnd = (event: DragEvent) => { + this._nativeDragging = false; + const dndEventState = new DndEventState({ event }); + this._dispatcher.run( + 'nativeDragEnd', + this._createContext(event, dndEventState) + ); + }; + + private _nativeDragging = false; + + private _nativeDragMove = (event: DragEvent) => { + const dndEventState = new DndEventState({ event }); + this._dispatcher.run( + 'nativeDragMove', + this._createContext(event, dndEventState) + ); + }; + + private _nativeDragStart = (event: DragEvent) => { + this._reset(); + this._nativeDragging = true; + const dndEventState = new DndEventState({ event }); + this._dispatcher.run( + 'nativeDragStart', + this._createContext(event, dndEventState) + ); + }; + + private _nativeDrop = (event: DragEvent) => { + this._reset(); + this._nativeDragging = false; + const dndEventState = new DndEventState({ event }); + this._dispatcher.run( + 'nativeDrop', + this._createContext(event, dndEventState) + ); + }; + + private _reset = () => { + this._dragging = false; + this._startPointerState = null; + this._lastPointerState = null; + + document.removeEventListener('pointermove', this._move); + document.removeEventListener('pointerup', this._up); + }; + + private _startPointerState: PointerEventState | null = null; + + private _up = (event: PointerEvent) => { + if ( + !this._startPointerState || + this._startPointerState.raw.pointerId !== event.pointerId + ) + return; + + const start = this._startPointerState; + const last = this._lastPointerState; + + const state = new PointerEventState({ + event, + rect: this._getRect(), + startX: start.x, + startY: start.y, + last, + }); + + if (this._dragging) { + this._dispatcher.run('dragEnd', createContext(event, state)); + } + + this._reset(); + }; + + // https://mikepk.com/2020/10/iOS-safari-scribble-bug/ + private _applyScribblePatch() { + if (!IS_IPAD) return; + + const { host, disposables } = this._dispatcher; + disposables.addFromEvent(host, 'touchmove', (event: TouchEvent) => { + if ( + this._dragging && + this._startPointerState && + this._startPointerState.raw.pointerType === 'pen' + ) { + event.preventDefault(); + } + }); + } + + private _createContext(event: Event, dndState: DndEventState) { + return UIEventStateContext.from( + new UIEventState(event), + new EventSourceState({ + event, + sourceType: EventScopeSourceType.Target, + }), + dndState + ); + } + + listen() { + const { host, disposables } = this._dispatcher; + disposables.addFromEvent(host, 'pointerdown', this._down); + this._applyScribblePatch(); + + disposables.addFromEvent(host, 'dragstart', this._nativeDragStart); + disposables.addFromEvent(host, 'dragend', this._nativeDragEnd); + disposables.addFromEvent(host, 'drag', this._nativeDragMove); + disposables.addFromEvent(host, 'drop', this._nativeDrop); + } +} + +abstract class DualDragControllerBase extends PointerControllerBase { + private _down = (event: PointerEvent) => { + // Another pointer down + if ( + this._startPointerStates.primary !== null && + this._startPointerStates.secondary !== null + ) { + this._reset(); + } + + if (this._startPointerStates.primary === null && !event.isPrimary) { + return; + } + + const state = new PointerEventState({ + event, + rect: this._getRect(), + startX: -Infinity, + startY: -Infinity, + last: null, + }); + + if (event.isPrimary) { + this._startPointerStates.primary = state; + } else { + this._startPointerStates.secondary = state; + } + }; + + private _lastPointerStates: { + primary: PointerEventState | null; + secondary: PointerEventState | null; + } = { + primary: null, + secondary: null, + }; + + private _move = (event: PointerEvent) => { + if ( + this._startPointerStates.primary === null || + this._startPointerStates.secondary === null + ) { + return; + } + + const { isPrimary } = event; + const startPrimaryState = this._startPointerStates.primary; + let lastPrimaryState = this._lastPointerStates.primary; + + const startSecondaryState = this._startPointerStates.secondary; + let lastSecondaryState = this._lastPointerStates.secondary; + + if (isPrimary) { + lastPrimaryState = new PointerEventState({ + event, + rect: this._getRect(), + startX: startPrimaryState.x, + startY: startPrimaryState.y, + last: lastPrimaryState, + }); + } else { + lastSecondaryState = new PointerEventState({ + event, + rect: this._getRect(), + startX: startSecondaryState.x, + startY: startSecondaryState.y, + last: lastSecondaryState, + }); + } + + const multiPointerState = new MultiPointerEventState(event, [ + lastPrimaryState ?? startPrimaryState, + lastSecondaryState ?? startSecondaryState, + ]); + + this._handleMove(event, multiPointerState); + + this._lastPointerStates = { + primary: lastPrimaryState, + secondary: lastSecondaryState, + }; + }; + + private _reset = () => { + this._startPointerStates = { + primary: null, + secondary: null, + }; + this._lastPointerStates = { + primary: null, + secondary: null, + }; + }; + + private _startPointerStates: { + primary: PointerEventState | null; + secondary: PointerEventState | null; + } = { + primary: null, + secondary: null, + }; + + private _upOrOut = (event: PointerEvent) => { + const { pointerId } = event; + if ( + pointerId === this._startPointerStates.primary?.raw.pointerId || + pointerId === this._startPointerStates.secondary?.raw.pointerId + ) { + this._reset(); + } + }; + + abstract _handleMove( + event: PointerEvent, + state: MultiPointerEventState + ): void; + + override listen(): void { + const { host, disposables } = this._dispatcher; + disposables.addFromEvent(host, 'pointerdown', this._down); + disposables.addFromEvent(host, 'pointermove', this._move); + disposables.addFromEvent(host, 'pointerup', this._upOrOut); + disposables.addFromEvent(host, 'pointerout', this._upOrOut); + } +} + +class PinchController extends DualDragControllerBase { + override _handleMove(event: PointerEvent, state: MultiPointerEventState) { + if (event.pointerType !== 'touch') return; + + const deltaFirstPointer = state.pointers[0].delta; + const deltaSecondPointer = state.pointers[1].delta; + + const deltaFirstPointerVec = Vec.toVec(deltaFirstPointer); + const deltaSecondPointerVec = Vec.toVec(deltaSecondPointer); + + const deltaFirstPointerValue = Vec.len(deltaFirstPointerVec); + const deltaSecondPointerValue = Vec.len(deltaSecondPointerVec); + + const deltaDotProduct = Vec.dpr( + deltaFirstPointerVec, + deltaSecondPointerVec + ); + + const deltaValueThreshold = 0.1; + + // the changes of distance between two pointers is not far enough + if ( + !isFarEnough(deltaFirstPointer, deltaSecondPointer) || + deltaDotProduct > 0 || + deltaFirstPointerValue < deltaValueThreshold || + deltaSecondPointerValue < deltaValueThreshold + ) + return; + + this._dispatcher.run('pinch', createContext(event, state)); + } +} + +class PanController extends DualDragControllerBase { + override _handleMove(event: PointerEvent, state: MultiPointerEventState) { + if (event.pointerType !== 'touch') return; + + const deltaFirstPointer = state.pointers[0].delta; + const deltaSecondPointer = state.pointers[1].delta; + + const deltaDotProduct = Vec.dpr( + Vec.toVec(deltaFirstPointer), + Vec.toVec(deltaSecondPointer) + ); + + // the center move distance is not far enough + if ( + !isFarEnough(deltaFirstPointer, deltaSecondPointer) && + deltaDotProduct < 0 + ) + return; + + this._dispatcher.run('pan', createContext(event, state)); + } +} + +export class PointerControl { + private _cachedRect: DOMRect | null = null; + + private _getRect = () => { + if (this._cachedRect === null) { + this._updateRect(); + } + return this._cachedRect as DOMRect; + }; + + // XXX: polling is used instead of MutationObserver + // due to potential performance issues + private _pollingInterval: number | null = null; + + private controllers: PointerControllerBase[]; + + constructor(private _dispatcher: UIEventDispatcher) { + this.controllers = [ + new PointerEventForward(_dispatcher, this._getRect), + new ClickController(_dispatcher, this._getRect), + new DragController(_dispatcher, this._getRect), + new PanController(_dispatcher, this._getRect), + new PinchController(_dispatcher, this._getRect), + ]; + } + + private _startPolling() { + const poll = () => { + nextTick() + .then(() => this._updateRect()) + .catch(console.error); + }; + this._pollingInterval = window.setInterval(poll, POLL_INTERVAL); + poll(); + } + + protected _updateRect() { + if (!this._dispatcher.host) return; + this._cachedRect = this._dispatcher.host.getBoundingClientRect(); + } + + dispose() { + if (this._pollingInterval !== null) { + clearInterval(this._pollingInterval); + this._pollingInterval = null; + } + } + + listen() { + this._startPolling(); + this.controllers.forEach(controller => controller.listen()); + } +} diff --git a/blocksuite/framework/block-std/src/event/control/range.ts b/blocksuite/framework/block-std/src/event/control/range.ts new file mode 100644 index 0000000000..c70bcb6878 --- /dev/null +++ b/blocksuite/framework/block-std/src/event/control/range.ts @@ -0,0 +1,156 @@ +import type { BlockComponent } from '../../view/index.js'; +import { UIEventState, UIEventStateContext } from '../base.js'; +import type { + EventHandlerRunner, + EventName, + UIEventDispatcher, +} from '../dispatcher.js'; +import { EventScopeSourceType, EventSourceState } from '../state/source.js'; + +export class RangeControl { + private _buildScope = (eventName: EventName) => { + let scope: EventHandlerRunner[] | undefined; + const selection = document.getSelection(); + if (selection && selection.rangeCount > 0) { + const range = selection.getRangeAt(0); + scope = this._buildEventScopeByNativeRange(eventName, range); + this._prev = range; + } else if (this._prev !== null) { + scope = this._buildEventScopeByNativeRange(eventName, this._prev); + this._prev = null; + } + + return scope; + }; + + private _compositionEnd = (event: Event) => { + const scope = this._buildScope('compositionEnd'); + + this._dispatcher.run('compositionEnd', this._createContext(event), scope); + }; + + private _compositionStart = (event: Event) => { + const scope = this._buildScope('compositionStart'); + + this._dispatcher.run('compositionStart', this._createContext(event), scope); + }; + + private _compositionUpdate = (event: Event) => { + const scope = this._buildScope('compositionUpdate'); + + this._dispatcher.run( + 'compositionUpdate', + this._createContext(event), + scope + ); + }; + + private _prev: Range | null = null; + + private _selectionChange = (event: Event) => { + const selection = document.getSelection(); + if (!selection) return; + + if (!selection.containsNode(this._dispatcher.host, true)) return; + if (selection.containsNode(this._dispatcher.host)) return; + + const scope = this._buildScope('selectionChange'); + + this._dispatcher.run('selectionChange', this._createContext(event), scope); + }; + + constructor(private _dispatcher: UIEventDispatcher) {} + + private _buildEventScopeByNativeRange(name: EventName, range: Range) { + const blockIds = this._findBlockComponentPath(range); + + return this._dispatcher.buildEventScope(name, blockIds); + } + + private _createContext(event: Event) { + return UIEventStateContext.from( + new UIEventState(event), + new EventSourceState({ + event, + sourceType: EventScopeSourceType.Selection, + }) + ); + } + + private _findBlockComponentPath(range: Range): string[] { + const start = range.startContainer; + const end = range.endContainer; + const ancestor = range.commonAncestorContainer; + const getBlockView = (node: Node): BlockComponent | null => { + const el = node instanceof Element ? node : node.parentElement; + // TODO(mirone/#6534): find a better way to get block element from a node + return el?.closest<BlockComponent>('[data-block-id]') ?? null; + }; + if (ancestor.nodeType === Node.TEXT_NODE) { + const leaf = getBlockView(ancestor); + if (leaf) { + return [leaf.blockId]; + } + } + const nodes = new Set<Node>(); + + let startRecorded = false; + const dfsDOMSearch = (current: Node | null, ancestor: Node) => { + if (!current) { + return; + } + if (current === ancestor) { + return; + } + if (current === end) { + nodes.add(current); + startRecorded = false; + return; + } + if (current === start) { + startRecorded = true; + } + // eslint-disable-next-line sonarjs/no-collapsible-if + if (startRecorded) { + if ( + current.nodeType === Node.TEXT_NODE || + current.nodeType === Node.ELEMENT_NODE + ) { + nodes.add(current); + } + } + dfsDOMSearch(current.firstChild, ancestor); + dfsDOMSearch(current.nextSibling, ancestor); + }; + dfsDOMSearch(ancestor.firstChild, ancestor); + + const blocks = new Set<string>(); + nodes.forEach(node => { + const blockView = getBlockView(node); + if (!blockView) { + return; + } + if (blocks.has(blockView.blockId)) { + return; + } + blocks.add(blockView.blockId); + }); + return Array.from(blocks); + } + + listen() { + const { host, disposables } = this._dispatcher; + disposables.addFromEvent( + document, + 'selectionchange', + this._selectionChange + ); + disposables.addFromEvent(host, 'compositionstart', this._compositionStart); + disposables.addFromEvent(host, 'compositionend', this._compositionEnd); + disposables.addFromEvent( + host, + 'compositionupdate', + this._compositionUpdate + ); + } +} diff --git a/blocksuite/framework/block-std/src/event/dispatcher.ts b/blocksuite/framework/block-std/src/event/dispatcher.ts new file mode 100644 index 0000000000..05502bc409 --- /dev/null +++ b/blocksuite/framework/block-std/src/event/dispatcher.ts @@ -0,0 +1,405 @@ +import { BlockSuiteError, ErrorCode } from '@blocksuite/global/exceptions'; +import { DisposableGroup } from '@blocksuite/global/utils'; + +import { LifeCycleWatcher } from '../extension/index.js'; +import { KeymapIdentifier } from '../identifier.js'; +import { type BlockComponent, EditorHost } from '../view/index.js'; +import { + type UIEventHandler, + UIEventState, + UIEventStateContext, +} from './base.js'; +import { ClipboardControl } from './control/clipboard.js'; +import { KeyboardControl } from './control/keyboard.js'; +import { PointerControl } from './control/pointer.js'; +import { RangeControl } from './control/range.js'; +import { EventScopeSourceType, EventSourceState } from './state/source.js'; +import { toLowerCase } from './utils.js'; + +const bypassEventNames = [ + 'beforeInput', + + 'blur', + 'focus', + 'contextMenu', + 'wheel', +] as const; + +const eventNames = [ + 'click', + 'doubleClick', + 'tripleClick', + + 'pointerDown', + 'pointerMove', + 'pointerUp', + 'pointerOut', + + 'dragStart', + 'dragMove', + 'dragEnd', + + 'pinch', + 'pan', + + 'keyDown', + 'keyUp', + + 'selectionChange', + 'compositionStart', + 'compositionUpdate', + 'compositionEnd', + + 'cut', + 'copy', + 'paste', + + 'nativeDragStart', + 'nativeDragMove', + 'nativeDragEnd', + 'nativeDrop', + + ...bypassEventNames, +] as const; + +export type EventName = (typeof eventNames)[number]; +export type EventOptions = { + flavour?: string; + blockId?: string; +}; +export type EventHandlerRunner = { + fn: UIEventHandler; + flavour?: string; + blockId?: string; +}; + +export class UIEventDispatcher extends LifeCycleWatcher { + private static _activeDispatcher: UIEventDispatcher | null = null; + + static override readonly key = 'UIEventDispatcher'; + + private _active = false; + + private _clipboardControl: ClipboardControl; + + private _handlersMap = Object.fromEntries( + eventNames.map((name): [EventName, Array<EventHandlerRunner>] => [name, []]) + ) as Record<EventName, Array<EventHandlerRunner>>; + + private _keyboardControl: KeyboardControl; + + private _pointerControl: PointerControl; + + private _rangeControl: RangeControl; + + bindHotkey = (...args: Parameters<KeyboardControl['bindHotkey']>) => + this._keyboardControl.bindHotkey(...args); + + disposables = new DisposableGroup(); + + private get _currentSelections() { + return this.std.selection.value; + } + + get active() { + return this._active; + } + + get host() { + return this.std.host; + } + + constructor(std: BlockSuite.Std) { + super(std); + this._pointerControl = new PointerControl(this); + this._keyboardControl = new KeyboardControl(this); + this._rangeControl = new RangeControl(this); + this._clipboardControl = new ClipboardControl(this); + this.disposables.add(this._pointerControl); + } + + private _bindEvents() { + bypassEventNames.forEach(eventName => { + this.disposables.addFromEvent( + this.host, + toLowerCase(eventName), + event => { + this.run( + eventName, + UIEventStateContext.from( + new UIEventState(event), + new EventSourceState({ + event, + sourceType: EventScopeSourceType.Selection, + }) + ) + ); + }, + eventName === 'wheel' + ? { + passive: false, + } + : undefined + ); + }); + + this._pointerControl.listen(); + this._keyboardControl.listen(); + this._rangeControl.listen(); + this._clipboardControl.listen(); + + let _dragging = false; + this.disposables.addFromEvent(this.host, 'pointerdown', () => { + _dragging = true; + this._setActive(true); + }); + this.disposables.addFromEvent(this.host, 'pointerup', () => { + _dragging = false; + }); + this.disposables.addFromEvent(this.host, 'click', () => { + this._setActive(true); + }); + this.disposables.addFromEvent(this.host, 'focusin', () => { + this._setActive(true); + }); + this.disposables.addFromEvent(this.host, 'focusout', e => { + if (e.relatedTarget && !this.host.contains(e.relatedTarget as Node)) { + this._setActive(false); + } + }); + this.disposables.addFromEvent(this.host, 'blur', () => { + if (_dragging) { + return; + } + + this._setActive(false); + }); + this.disposables.addFromEvent(this.host, 'dragover', () => { + this._setActive(true); + }); + this.disposables.addFromEvent(this.host, 'dragend', () => { + this._setActive(false); + }); + this.disposables.addFromEvent(this.host, 'drop', () => { + this._setActive(true); + }); + this.disposables.addFromEvent(this.host, 'pointerenter', () => { + if (this._isActiveElementOutsideHost()) { + return; + } + + this._setActive(true); + }); + this.disposables.addFromEvent(this.host, 'pointerleave', () => { + if ( + (document.activeElement && + this.host.contains(document.activeElement)) || + _dragging + ) { + return; + } + + this._setActive(false); + }); + } + + private _buildEventScopeBySelection(name: EventName) { + const handlers = this._handlersMap[name]; + if (!handlers) return; + + const selections = this._currentSelections; + const ids = selections.map(selection => selection.blockId); + + return this.buildEventScope(name, ids); + } + + private _buildEventScopeByTarget(name: EventName, target: Node) { + const handlers = this._handlersMap[name]; + if (!handlers) return; + + // TODO(mirone/#6534): find a better way to get block element from a node + const el = target instanceof Element ? target : target.parentElement; + const block = el?.closest<BlockComponent>('[data-block-id]'); + + const blockId = block?.blockId; + if (!blockId) { + return this._buildEventScopeBySelection(name); + } + + return this.buildEventScope(name, [blockId]); + } + + private _getDeepActiveElement(): Element | null { + let active = document.activeElement; + while (active && active.shadowRoot && active.shadowRoot.activeElement) { + active = active.shadowRoot.activeElement; + } + return active; + } + + private _getEventScope(name: EventName, state: EventSourceState) { + const handlers = this._handlersMap[name]; + if (!handlers) return; + + let output: EventHandlerRunner[] | undefined; + + switch (state.sourceType) { + case EventScopeSourceType.Selection: { + output = this._buildEventScopeBySelection(name); + break; + } + case EventScopeSourceType.Target: { + output = this._buildEventScopeByTarget( + name, + state.event.target as Node + ); + break; + } + default: { + throw new BlockSuiteError( + ErrorCode.EventDispatcherError, + `Unknown event scope source: ${state.sourceType}` + ); + } + } + + return output; + } + + private _isActiveElementOutsideHost(): boolean { + const activeElement = this._getDeepActiveElement(); + return ( + activeElement !== null && + this._isEditableElementActive(activeElement) && + !this.host.contains(activeElement) + ); + } + + private _isEditableElementActive(element: Element | null): boolean { + if (!element) return false; + return ( + element instanceof HTMLInputElement || + element instanceof HTMLTextAreaElement || + (element instanceof EditorHost && !element.doc.readonly) || + (element as HTMLElement).isContentEditable + ); + } + + private _setActive(active: boolean) { + if (active) { + if (UIEventDispatcher._activeDispatcher !== this) { + if (UIEventDispatcher._activeDispatcher) { + UIEventDispatcher._activeDispatcher._active = false; + } + UIEventDispatcher._activeDispatcher = this; + } + this._active = true; + } else { + if (UIEventDispatcher._activeDispatcher === this) { + UIEventDispatcher._activeDispatcher = null; + } + this._active = false; + } + } + + add(name: EventName, handler: UIEventHandler, options?: EventOptions) { + const runner: EventHandlerRunner = { + fn: handler, + flavour: options?.flavour, + blockId: options?.blockId, + }; + this._handlersMap[name].unshift(runner); + return () => { + if (this._handlersMap[name].includes(runner)) { + this._handlersMap[name] = this._handlersMap[name].filter( + x => x !== runner + ); + } + }; + } + + buildEventScope( + name: EventName, + blocks: string[] + ): EventHandlerRunner[] | undefined { + const handlers = this._handlersMap[name]; + if (!handlers) return; + + const globalEvents = handlers.filter( + handler => handler.flavour === undefined && handler.blockId === undefined + ); + + let blockIds: string[] = blocks; + const events: EventHandlerRunner[] = []; + const flavourSeen: Record<string, boolean> = {}; + while (blockIds.length > 0) { + const idHandlers = handlers.filter( + handler => handler.blockId && blockIds.includes(handler.blockId) + ); + + const flavourHandlers = blockIds + .map(blockId => this.std.doc.getBlock(blockId)?.flavour) + .filter((flavour): flavour is string => { + if (!flavour) return false; + if (flavourSeen[flavour]) return false; + flavourSeen[flavour] = true; + return true; + }) + .flatMap(flavour => { + return handlers.filter(handler => handler.flavour === flavour); + }); + + events.push(...idHandlers, ...flavourHandlers); + blockIds = blockIds + .map(blockId => { + const parent = this.std.doc.getParent(blockId); + return parent?.id; + }) + .filter((id): id is string => !!id); + } + + return events.concat(globalEvents); + } + + override mounted() { + if (this.disposables.disposed) { + this.disposables = new DisposableGroup(); + } + this._bindEvents(); + + const std = this.std; + this.std.provider + .getAll(KeymapIdentifier) + .forEach(({ getter, options }) => { + this.bindHotkey(getter(std), options); + }); + } + + run( + name: EventName, + context: UIEventStateContext, + runners?: EventHandlerRunner[] + ) { + if (!this.active) return; + + const sourceState = context.get('sourceState'); + if (!runners) { + runners = this._getEventScope(name, sourceState); + if (!runners) { + return; + } + } + for (const runner of runners) { + const { fn } = runner; + const result = fn(context); + if (result) { + context.get('defaultState').event.stopPropagation(); + return; + } + } + } + + override unmounted() { + this.disposables.dispose(); + } +} diff --git a/blocksuite/framework/block-std/src/event/index.ts b/blocksuite/framework/block-std/src/event/index.ts new file mode 100644 index 0000000000..304c6d7ae3 --- /dev/null +++ b/blocksuite/framework/block-std/src/event/index.ts @@ -0,0 +1,4 @@ +export * from './base.js'; +export * from './dispatcher.js'; +export * from './keymap.js'; +export * from './state/index.js'; diff --git a/blocksuite/framework/block-std/src/event/keymap.ts b/blocksuite/framework/block-std/src/event/keymap.ts new file mode 100644 index 0000000000..82a86ee7d4 --- /dev/null +++ b/blocksuite/framework/block-std/src/event/keymap.ts @@ -0,0 +1,109 @@ +import { BlockSuiteError, ErrorCode } from '@blocksuite/global/exceptions'; +import { base, keyName } from 'w3c-keyname'; + +import type { UIEventHandler } from './base.js'; + +const mac = + typeof navigator !== 'undefined' + ? /Mac|iP(hone|[oa]d)/.test(navigator.platform) + : false; + +function normalizeKeyName(name: string) { + const parts = name.split(/-(?!$)/); + let result = parts.at(-1); + if (result === 'Space') { + result = ' '; + } + let alt, ctrl, shift, meta; + parts.slice(0, -1).forEach(mod => { + if (/^(cmd|meta|m)$/i.test(mod)) { + meta = true; + return; + } + if (/^a(lt)?$/i.test(mod)) { + alt = true; + return; + } + if (/^(c|ctrl|control)$/i.test(mod)) { + ctrl = true; + return; + } + if (/^s(hift)?$/i.test(mod)) { + shift = true; + return; + } + if (/^mod$/i.test(mod)) { + if (mac) { + meta = true; + } else { + ctrl = true; + } + return; + } + + throw new BlockSuiteError( + ErrorCode.EventDispatcherError, + 'Unrecognized modifier name: ' + mod + ); + }); + if (alt) result = 'Alt-' + result; + if (ctrl) result = 'Ctrl-' + result; + if (meta) result = 'Meta-' + result; + if (shift) result = 'Shift-' + result; + return result as string; +} + +function modifiers(name: string, event: KeyboardEvent, shift = true) { + if (event.altKey) name = 'Alt-' + name; + if (event.ctrlKey) name = 'Ctrl-' + name; + if (event.metaKey) name = 'Meta-' + name; + if (shift && event.shiftKey) name = 'Shift-' + name; + return name; +} + +function normalize(map: Record<string, UIEventHandler>) { + const copy: Record<string, UIEventHandler> = Object.create(null); + for (const prop in map) copy[normalizeKeyName(prop)] = map[prop]; + return copy; +} + +export function bindKeymap( + bindings: Record<string, UIEventHandler> +): UIEventHandler { + const map = normalize(bindings); + return ctx => { + const state = ctx.get('keyboardState'); + const event = state.raw; + const name = keyName(event); + const direct = map[modifiers(name, event)]; + if (direct && direct(ctx)) { + return true; + } + if (name.length !== 1 || name === ' ') { + return false; + } + + if (event.shiftKey) { + const noShift = map[modifiers(name, event, false)]; + if (noShift && noShift(ctx)) { + return true; + } + } + + // none standard keyboard, fallback to keyCode + const special = + event.shiftKey || + event.altKey || + event.metaKey || + name.charCodeAt(0) > 127; + const baseName = base[event.keyCode]; + if (special && baseName && baseName !== name) { + const fromCode = map[modifiers(baseName, event)]; + if (fromCode && fromCode(ctx)) { + return true; + } + } + + return false; + }; +} diff --git a/blocksuite/framework/block-std/src/event/state/clipboard.ts b/blocksuite/framework/block-std/src/event/state/clipboard.ts new file mode 100644 index 0000000000..493415f9c4 --- /dev/null +++ b/blocksuite/framework/block-std/src/event/state/clipboard.ts @@ -0,0 +1,23 @@ +import { UIEventState } from '../base.js'; + +type ClipboardEventStateOptions = { + event: ClipboardEvent; +}; + +export class ClipboardEventState extends UIEventState { + raw: ClipboardEvent; + + override type = 'clipboardState'; + + constructor({ event }: ClipboardEventStateOptions) { + super(event); + + this.raw = event; + } +} + +declare global { + interface BlockSuiteUIEventState { + clipboardState: ClipboardEventState; + } +} diff --git a/blocksuite/framework/block-std/src/event/state/dnd.ts b/blocksuite/framework/block-std/src/event/state/dnd.ts new file mode 100644 index 0000000000..a477dfcf99 --- /dev/null +++ b/blocksuite/framework/block-std/src/event/state/dnd.ts @@ -0,0 +1,23 @@ +import { UIEventState } from '../base.js'; + +type DndEventStateOptions = { + event: DragEvent; +}; + +export class DndEventState extends UIEventState { + raw: DragEvent; + + override type = 'dndState'; + + constructor({ event }: DndEventStateOptions) { + super(event); + + this.raw = event; + } +} + +declare global { + interface BlockSuiteUIEventState { + dndState: DndEventState; + } +} diff --git a/blocksuite/framework/block-std/src/event/state/index.ts b/blocksuite/framework/block-std/src/event/state/index.ts new file mode 100644 index 0000000000..9bb34f2ff0 --- /dev/null +++ b/blocksuite/framework/block-std/src/event/state/index.ts @@ -0,0 +1,5 @@ +export * from './clipboard.js'; +export * from './dnd.js'; +export * from './keyboard.js'; +export * from './pointer.js'; +export * from './source.js'; diff --git a/blocksuite/framework/block-std/src/event/state/keyboard.ts b/blocksuite/framework/block-std/src/event/state/keyboard.ts new file mode 100644 index 0000000000..f983457460 --- /dev/null +++ b/blocksuite/framework/block-std/src/event/state/keyboard.ts @@ -0,0 +1,27 @@ +import { UIEventState } from '../base.js'; + +type KeyboardEventStateOptions = { + event: KeyboardEvent; + composing: boolean; +}; + +export class KeyboardEventState extends UIEventState { + composing: boolean; + + raw: KeyboardEvent; + + override type = 'keyboardState'; + + constructor({ event, composing }: KeyboardEventStateOptions) { + super(event); + + this.raw = event; + this.composing = composing; + } +} + +declare global { + interface BlockSuiteUIEventState { + keyboardState: KeyboardEventState; + } +} diff --git a/blocksuite/framework/block-std/src/event/state/pointer.ts b/blocksuite/framework/block-std/src/event/state/pointer.ts new file mode 100644 index 0000000000..665676c70e --- /dev/null +++ b/blocksuite/framework/block-std/src/event/state/pointer.ts @@ -0,0 +1,83 @@ +import { UIEventState } from '../base.js'; + +type PointerEventStateOptions = { + event: PointerEvent; + rect: DOMRect; + startX: number; + startY: number; + last: PointerEventState | null; +}; + +type Point = { x: number; y: number }; + +export class PointerEventState extends UIEventState { + button: number; + + containerOffset: Point; + + delta: Point; + + keys: { + shift: boolean; + cmd: boolean; + alt: boolean; + }; + + point: Point; + + pressure: number; + + raw: PointerEvent; + + start: Point; + + override type = 'pointerState'; + + get x() { + return this.point.x; + } + + get y() { + return this.point.y; + } + + constructor({ event, rect, startX, startY, last }: PointerEventStateOptions) { + super(event); + + const offsetX = event.clientX - rect.left; + const offsetY = event.clientY - rect.top; + + this.raw = event; + this.point = { x: offsetX, y: offsetY }; + this.containerOffset = { x: rect.left, y: rect.top }; + this.start = { x: startX, y: startY }; + this.delta = last + ? { x: offsetX - last.point.x, y: offsetY - last.point.y } + : { x: 0, y: 0 }; + this.keys = { + shift: event.shiftKey, + cmd: event.metaKey || event.ctrlKey, + alt: event.altKey, + }; + this.button = last?.button || event.button; + this.pressure = event.pressure; + } +} + +export class MultiPointerEventState extends UIEventState { + pointers: PointerEventState[]; + + override type = 'multiPointerState'; + + constructor(event: PointerEvent, pointers: PointerEventState[]) { + super(event); + this.pointers = pointers; + } +} + +declare global { + interface BlockSuiteUIEventState { + pointerState: PointerEventState; + multiPointerState: MultiPointerEventState; + } +} diff --git a/blocksuite/framework/block-std/src/event/state/source.ts b/blocksuite/framework/block-std/src/event/state/source.ts new file mode 100644 index 0000000000..0f90ca017a --- /dev/null +++ b/blocksuite/framework/block-std/src/event/state/source.ts @@ -0,0 +1,31 @@ +import { UIEventState } from '../base.js'; + +export enum EventScopeSourceType { + // The event scope should be built by selection path + Selection = 'selection', + + // The event scope should be built by event target + Target = 'target', +} + +export type EventSourceStateOptions = { + event: Event; + sourceType: EventScopeSourceType; +}; + +export class EventSourceState extends UIEventState { + readonly sourceType: EventScopeSourceType; + + override type = 'sourceState'; + + constructor({ event, sourceType }: EventSourceStateOptions) { + super(event); + this.sourceType = sourceType; + } +} + +declare global { + interface BlockSuiteUIEventState { + sourceState: EventSourceState; + } +} diff --git a/blocksuite/framework/block-std/src/event/utils.ts b/blocksuite/framework/block-std/src/event/utils.ts new file mode 100644 index 0000000000..cfd25d2144 --- /dev/null +++ b/blocksuite/framework/block-std/src/event/utils.ts @@ -0,0 +1,17 @@ +import type { IPoint } from '@blocksuite/global/utils'; + +export function isFarEnough(a: IPoint, b: IPoint) { + const dx = a.x - b.x; + const dy = a.y - b.y; + return Math.pow(dx, 2) + Math.pow(dy, 2) > 4; +} + +export function center(a: IPoint, b: IPoint) { + return { + x: (a.x + b.x) / 2, + y: (a.y + b.y) / 2, + }; +} + +export const toLowerCase = <T extends string>(str: T): Lowercase<T> => + str.toLowerCase() as Lowercase<T>; diff --git a/blocksuite/framework/block-std/src/extension/block-view.ts b/blocksuite/framework/block-std/src/extension/block-view.ts new file mode 100644 index 0000000000..b2808150b2 --- /dev/null +++ b/blocksuite/framework/block-std/src/extension/block-view.ts @@ -0,0 +1,32 @@ +import { BlockViewIdentifier } from '../identifier.js'; +import type { BlockViewType } from '../spec/type.js'; +import type { ExtensionType } from './extension.js'; + +/** + * Create a block view extension. + * + * @param flavour The flavour of the block that the view is for. + * @param view Lit literal template for the view. Example: `my-list-block` + * + * The view is a lit template that is used to render the block. + * + * @example + * ```ts + * import { BlockViewExtension } from '@blocksuite/block-std'; + * + * const MyListBlockViewExtension = BlockViewExtension( + * 'affine:list', + * literal`my-list-block` + * ); + * ``` + */ +export function BlockViewExtension( + flavour: BlockSuite.Flavour, + view: BlockViewType +): ExtensionType { + return { + setup: di => { + di.addImpl(BlockViewIdentifier(flavour), () => view); + }, + }; +} diff --git a/blocksuite/framework/block-std/src/extension/command.ts b/blocksuite/framework/block-std/src/extension/command.ts new file mode 100644 index 0000000000..1c122be8e1 --- /dev/null +++ b/blocksuite/framework/block-std/src/extension/command.ts @@ -0,0 +1,27 @@ +import { CommandIdentifier } from '../identifier.js'; +import type { BlockCommands } from '../spec/index.js'; +import type { ExtensionType } from './extension.js'; + +/** + * Create a command extension. + * + * @param commands A map of command names to command implementations. + * + * @example + * ```ts + * import { CommandExtension } from '@blocksuite/block-std'; + * + * const MyCommandExtension = CommandExtension({ + * 'my-command': MyCommand + * }); + * ``` + */ +export function CommandExtension(commands: BlockCommands): ExtensionType { + return { + setup: di => { + Object.entries(commands).forEach(([name, command]) => { + di.addImpl(CommandIdentifier(name), () => command); + }); + }, + }; +} diff --git a/blocksuite/framework/block-std/src/extension/config.ts b/blocksuite/framework/block-std/src/extension/config.ts new file mode 100644 index 0000000000..fbb7527207 --- /dev/null +++ b/blocksuite/framework/block-std/src/extension/config.ts @@ -0,0 +1,30 @@ +import { ConfigIdentifier } from '../identifier.js'; +import type { ExtensionType } from './extension.js'; + +/** + * Create a config extension. + * A config extension provides a configuration object for a block flavour. + * The configuration object can be used like: + * ```ts + * const config = std.provider.get(ConfigIdentifier('my-flavour')); + * ``` + * + * @param flavor The flavour of the block that the config is for. + * @param config The configuration object. + * + * @example + * ```ts + * import { ConfigExtension } from '@blocksuite/block-std'; + * const MyConfigExtension = ConfigExtension('my-flavour', config); + * ``` + */ +export function ConfigExtension( + flavor: BlockSuite.Flavour, + config: Record<string, unknown> +): ExtensionType { + return { + setup: di => { + di.addImpl(ConfigIdentifier(flavor), () => config); + }, + }; +} diff --git a/blocksuite/framework/block-std/src/extension/extension.ts b/blocksuite/framework/block-std/src/extension/extension.ts new file mode 100644 index 0000000000..925c543a53 --- /dev/null +++ b/blocksuite/framework/block-std/src/extension/extension.ts @@ -0,0 +1,17 @@ +import type { Container } from '@blocksuite/global/di'; + +/** + * Generic extension. + * Extensions are used to set up the dependency injection container. + * In most cases, you won't need to use this class directly. + * We provide helper classes like `CommandExtension` and `BlockViewExtension` to make it easier to create extensions. + */ +export abstract class Extension { + static setup(_di: Container): void { + // do nothing + } +} + +export interface ExtensionType { + setup(di: Container): void; +} diff --git a/blocksuite/framework/block-std/src/extension/flavour.ts b/blocksuite/framework/block-std/src/extension/flavour.ts new file mode 100644 index 0000000000..53c6855207 --- /dev/null +++ b/blocksuite/framework/block-std/src/extension/flavour.ts @@ -0,0 +1,25 @@ +import { BlockFlavourIdentifier } from '../identifier.js'; +import type { ExtensionType } from './extension.js'; + +/** + * Create a flavour extension. + * + * @param flavour + * The flavour of the block that the extension is for. + * + * @example + * ```ts + * import { FlavourExtension } from '@blocksuite/block-std'; + * + * const MyFlavourExtension = FlavourExtension('my-flavour'); + * ``` + */ +export function FlavourExtension(flavour: string): ExtensionType { + return { + setup: di => { + di.addImpl(BlockFlavourIdentifier(flavour), () => ({ + flavour, + })); + }, + }; +} diff --git a/blocksuite/framework/block-std/src/extension/index.ts b/blocksuite/framework/block-std/src/extension/index.ts new file mode 100644 index 0000000000..6365406bce --- /dev/null +++ b/blocksuite/framework/block-std/src/extension/index.ts @@ -0,0 +1,11 @@ +export * from './block-view.js'; +export * from './command.js'; +export * from './config.js'; +export * from './extension.js'; +export * from './flavour.js'; +export * from './keymap.js'; +export * from './lifecycle-watcher.js'; +export * from './selection.js'; +export * from './service.js'; +export * from './service-watcher.js'; +export * from './widget-view-map.js'; diff --git a/blocksuite/framework/block-std/src/extension/keymap.ts b/blocksuite/framework/block-std/src/extension/keymap.ts new file mode 100644 index 0000000000..5b75ffee92 --- /dev/null +++ b/blocksuite/framework/block-std/src/extension/keymap.ts @@ -0,0 +1,48 @@ +import type { EventOptions, UIEventHandler } from '../event/index.js'; +import { KeymapIdentifier } from '../identifier.js'; +import type { BlockStdScope } from '../scope/index.js'; +import type { ExtensionType } from './extension.js'; + +let id = 1; + +/** + * Create a keymap extension. + * + * @param keymapFactory + * Create keymap of the extension. + * It should return an object with `keymap` and `options`. + * + * `keymap` is a record of keymap. + * + * @param options + * `options` is an optional object that restricts the event to be handled. + * + * @example + * ```ts + * import { KeymapExtension } from '@blocksuite/block-std'; + * + * const MyKeymapExtension = KeymapExtension(std => { + * return { + * keymap: { + * 'mod-a': SelectAll + * } + * options: { + * flavour: 'affine:paragraph' + * } + * } + * }); + * ``` + */ +export function KeymapExtension( + keymapFactory: (std: BlockStdScope) => Record<string, UIEventHandler>, + options?: EventOptions +): ExtensionType { + return { + setup: di => { + di.addImpl(KeymapIdentifier(`Keymap-${id++}`), { + getter: keymapFactory, + options, + }); + }, + }; +} diff --git a/blocksuite/framework/block-std/src/extension/lifecycle-watcher.ts b/blocksuite/framework/block-std/src/extension/lifecycle-watcher.ts new file mode 100644 index 0000000000..e9c822aa27 --- /dev/null +++ b/blocksuite/framework/block-std/src/extension/lifecycle-watcher.ts @@ -0,0 +1,70 @@ +import type { Container } from '@blocksuite/global/di'; +import { BlockSuiteError, ErrorCode } from '@blocksuite/global/exceptions'; + +import { LifeCycleWatcherIdentifier, StdIdentifier } from '../identifier.js'; +import type { BlockStdScope } from '../scope/index.js'; +import { Extension } from './extension.js'; + +/** + * A life cycle watcher is an extension that watches the life cycle of the editor. + * It is used to perform actions when the editor is created, mounted, rendered, or unmounted. + * + * When creating a life cycle watcher, you must define a key that is unique to the watcher. + * The key is used to identify the watcher in the dependency injection container. + * ```ts + * class MyLifeCycleWatcher extends LifeCycleWatcher { + * static override readonly key = 'my-life-cycle-watcher'; + * ``` + * + * In the life cycle watcher, the methods will be called in the following order: + * 1. `created`: Called when the std is created. + * 2. `rendered`: Called when `std.render` is called. + * 3. `mounted`: Called when the editor host is mounted. + * 4. `unmounted`: Called when the editor host is unmounted. + */ +export abstract class LifeCycleWatcher extends Extension { + static key: string; + + constructor(readonly std: BlockStdScope) { + super(); + } + + static override setup(di: Container) { + if (!this.key) { + throw new BlockSuiteError( + ErrorCode.ValueNotExists, + 'Key is not defined in the LifeCycleWatcher' + ); + } + + di.add(this as unknown as { new (std: BlockStdScope): LifeCycleWatcher }, [ + StdIdentifier, + ]); + + di.addImpl(LifeCycleWatcherIdentifier(this.key), provider => + provider.get(this) + ); + } + + /** + * Called when std is created. + */ + created() {} + + /** + * Called when editor host is mounted. + * Which means the editor host emit the `connectedCallback` lifecycle event. + */ + mounted() {} + + /** + * Called when `std.render` is called. + */ + rendered() {} + + /** + * Called when editor host is unmounted. + * Which means the editor host emit the `disconnectedCallback` lifecycle event. + */ + unmounted() {} +} diff --git a/blocksuite/framework/block-std/src/extension/selection.ts b/blocksuite/framework/block-std/src/extension/selection.ts new file mode 100644 index 0000000000..98b1fcde83 --- /dev/null +++ b/blocksuite/framework/block-std/src/extension/selection.ts @@ -0,0 +1,13 @@ +import { SelectionIdentifier } from '../identifier.js'; +import type { SelectionConstructor } from '../selection/index.js'; +import type { ExtensionType } from './extension.js'; + +export function SelectionExtension( + selectionCtor: SelectionConstructor +): ExtensionType { + return { + setup: di => { + di.addImpl(SelectionIdentifier(selectionCtor.type), () => selectionCtor); + }, + }; +} diff --git a/blocksuite/framework/block-std/src/extension/service-watcher.ts b/blocksuite/framework/block-std/src/extension/service-watcher.ts new file mode 100644 index 0000000000..6804e2e3fe --- /dev/null +++ b/blocksuite/framework/block-std/src/extension/service-watcher.ts @@ -0,0 +1,47 @@ +import type { Container } from '@blocksuite/global/di'; +import { BlockSuiteError, ErrorCode } from '@blocksuite/global/exceptions'; + +import { + BlockServiceIdentifier, + LifeCycleWatcherIdentifier, + StdIdentifier, +} from '../identifier.js'; +import type { BlockStdScope } from '../scope/index.js'; +import { LifeCycleWatcher } from './lifecycle-watcher.js'; +import type { BlockService } from './service.js'; + +const idMap = new Map<string, number>(); + +/** + * @deprecated + * BlockServiceWatcher is deprecated. You should reconsider where to put your feature. + * + * BlockServiceWatcher is a legacy extension that is used to watch the slots registered on block service. + * However, we recommend using the new extension system. + */ +export abstract class BlockServiceWatcher extends LifeCycleWatcher { + static flavour: string; + + constructor( + std: BlockStdScope, + readonly blockService: BlockService + ) { + super(std); + } + + static override setup(di: Container) { + if (!this.flavour) { + throw new BlockSuiteError( + ErrorCode.ValueNotExists, + 'Flavour is not defined in the BlockServiceWatcher' + ); + } + const id = idMap.get(this.flavour) ?? 0; + idMap.set(this.flavour, id + 1); + di.addImpl( + LifeCycleWatcherIdentifier(`${this.flavour}-watcher-${id}`), + this, + [StdIdentifier, BlockServiceIdentifier(this.flavour)] + ); + } +} diff --git a/blocksuite/framework/block-std/src/extension/service.ts b/blocksuite/framework/block-std/src/extension/service.ts new file mode 100644 index 0000000000..76786792d6 --- /dev/null +++ b/blocksuite/framework/block-std/src/extension/service.ts @@ -0,0 +1,120 @@ +import type { Container } from '@blocksuite/global/di'; +import { BlockSuiteError, ErrorCode } from '@blocksuite/global/exceptions'; +import { DisposableGroup } from '@blocksuite/global/utils'; + +import type { EventName, UIEventHandler } from '../event/index.js'; +import { + BlockFlavourIdentifier, + BlockServiceIdentifier, + StdIdentifier, +} from '../identifier.js'; +import type { BlockStdScope } from '../scope/index.js'; +import { getSlots } from '../spec/index.js'; +import { Extension } from './extension.js'; + +/** + * @deprecated + * BlockService is deprecated. You should reconsider where to put your feature. + * + * BlockService is a legacy extension that is used to provide services to the block. + * In the previous version of BlockSuite, block service provides a way to extend the block. + * However, in the new version, we recommend using the new extension system. + */ +export abstract class BlockService extends Extension { + static flavour: string; + + readonly disposables = new DisposableGroup(); + + readonly flavour: string; + + readonly specSlots = getSlots(); + + get collection() { + return this.std.collection; + } + + get doc() { + return this.std.doc; + } + + get host() { + return this.std.host; + } + + get selectionManager() { + return this.std.selection; + } + + get uiEventDispatcher() { + return this.std.event; + } + + constructor( + readonly std: BlockStdScope, + readonly flavourProvider: { flavour: string } + ) { + super(); + this.flavour = flavourProvider.flavour; + } + + static override setup(di: Container) { + if (!this.flavour) { + throw new BlockSuiteError( + ErrorCode.ValueNotExists, + 'Flavour is not defined in the BlockService' + ); + } + di.add( + this as unknown as { + new ( + std: BlockStdScope, + flavourProvider: { flavour: string } + ): BlockService; + }, + [StdIdentifier, BlockFlavourIdentifier(this.flavour)] + ); + di.addImpl(BlockServiceIdentifier(this.flavour), provider => + provider.get(this) + ); + } + + bindHotKey( + keymap: Record<string, UIEventHandler>, + options?: { global: boolean } + ) { + this.disposables.add( + this.uiEventDispatcher.bindHotkey(keymap, { + flavour: options?.global ? undefined : this.flavour, + }) + ); + } + + // life cycle start + dispose() { + this.disposables.dispose(); + } + + // event handlers start + handleEvent( + name: EventName, + fn: UIEventHandler, + options?: { global: boolean } + ) { + this.disposables.add( + this.uiEventDispatcher.add(name, fn, { + flavour: options?.global ? undefined : this.flavour, + }) + ); + } + // life cycle end + + mounted() { + this.specSlots.mounted.emit({ service: this }); + } + + unmounted() { + this.dispose(); + this.specSlots.unmounted.emit({ service: this }); + } + // event handlers end +} diff --git a/blocksuite/framework/block-std/src/extension/widget-view-map.ts b/blocksuite/framework/block-std/src/extension/widget-view-map.ts new file mode 100644 index 0000000000..47d75efa05 --- /dev/null +++ b/blocksuite/framework/block-std/src/extension/widget-view-map.ts @@ -0,0 +1,31 @@ +import { WidgetViewMapIdentifier } from '../identifier.js'; +import type { WidgetViewMapType } from '../spec/type.js'; +import type { ExtensionType } from './extension.js'; + +/** + * Create a widget view map extension. + * + * @param flavour The flavour of the block that the widget view map is for. + * @param widgetViewMap A map of widget names to widget view lit literal. + * + * A widget view map is to provide a map of widgets to a block. + * For every target block, it's view will be rendered with the widget views. + * + * @example + * ```ts + * import { WidgetViewMapExtension } from '@blocksuite/block-std'; + * + * const MyWidgetViewMapExtension = WidgetViewMapExtension('my-flavour', { + * 'my-widget': literal`my-widget-view` + * }); + */ +export function WidgetViewMapExtension( + flavour: BlockSuite.Flavour, + widgetViewMap: WidgetViewMapType +): ExtensionType { + return { + setup: di => { + di.addImpl(WidgetViewMapIdentifier(flavour), () => widgetViewMap); + }, + }; +} diff --git a/blocksuite/framework/block-std/src/gfx/controller.ts b/blocksuite/framework/block-std/src/gfx/controller.ts new file mode 100644 index 0000000000..c62ad73c24 --- /dev/null +++ b/blocksuite/framework/block-std/src/gfx/controller.ts @@ -0,0 +1,303 @@ +import { + assertType, + Bound, + DisposableGroup, + getCommonBoundWithRotation, + type IBound, + last, +} from '@blocksuite/global/utils'; +import type { BlockModel } from '@blocksuite/store'; +import { Signal } from '@preact/signals-core'; + +import { LifeCycleWatcher } from '../extension/lifecycle-watcher.js'; +import type { BlockStdScope } from '../scope/block-std-scope.js'; +import { onSurfaceAdded } from '../utils/gfx.js'; +import type { BlockComponent } from '../view/index.js'; +import type { CursorType } from './cursor.js'; +import { + GfxClassExtenderIdentifier, + GfxExtensionIdentifier, +} from './extension.js'; +import { GridManager } from './grid.js'; +import { gfxControllerKey } from './identifiers.js'; +import { KeyboardController } from './keyboard.js'; +import { LayerManager } from './layer.js'; +import type { PointTestOptions } from './model/base.js'; +import { GfxBlockElementModel } from './model/gfx-block-model.js'; +import type { GfxModel } from './model/model.js'; +import { + GfxGroupLikeElementModel, + GfxPrimitiveElementModel, +} from './model/surface/element-model.js'; +import type { SurfaceBlockModel } from './model/surface/surface-model.js'; +import { Viewport } from './viewport.js'; + +export class GfxController extends LifeCycleWatcher { + static override key = gfxControllerKey; + + private _disposables: DisposableGroup = new DisposableGroup(); + + private _surface: SurfaceBlockModel | null = null; + + readonly cursor$ = new Signal<CursorType>(); + + readonly grid: GridManager; + + readonly keyboard: KeyboardController; + + readonly layer: LayerManager; + + readonly viewport: Viewport = new Viewport(); + + get doc() { + return this.std.doc; + } + + get elementsBound() { + return getCommonBoundWithRotation(this.gfxElements); + } + + get gfxElements(): GfxModel[] { + return [...this.layer.blocks, ...this.layer.canvasElements]; + } + + get surface() { + return this._surface; + } + + get surfaceComponent(): BlockComponent | null { + return this.surface + ? (this.std.view.getBlock(this.surface.id) ?? null) + : null; + } + + constructor(std: BlockStdScope) { + super(std); + + this.grid = new GridManager(); + this.layer = new LayerManager(this.doc, null); + this.keyboard = new KeyboardController(std); + + this._disposables.add( + onSurfaceAdded(this.doc, surface => { + this._surface = surface; + + if (surface) { + this._disposables.add(this.grid.watch({ surface })); + this.layer.watch({ surface }); + } + }) + ); + this._disposables.add(this.grid.watch({ doc: this.doc })); + this._disposables.add(this.layer); + this._disposables.add(this.viewport); + this._disposables.add(this.keyboard); + + this.std.provider.getAll(GfxClassExtenderIdentifier).forEach(ext => { + ext.extendFn(this); + }); + } + + deleteElement(element: GfxModel | BlockModel<object> | string): void { + element = typeof element === 'string' ? element : element.id; + + assertType<string>(element); + + if (this.surface?.hasElementById(element)) { + this.surface.deleteElement(element); + } else { + const block = this.doc.getBlock(element)?.model; + block && this.doc.deleteBlock(block); + } + } + + /** + * Get a block or element by its id. + * Note that non-gfx block can also be queried in this method. + * @param id + * @returns + */ + getElementById< + T extends GfxModel | BlockModel<object> = GfxModel | BlockModel<object>, + >(id: string): T | null { + // @ts-expect-error FIXME: ts error + return ( + this.surface?.getElementById(id) ?? this.doc.getBlock(id)?.model ?? null + ); + } + + /** + * Get elements on a specific point. + * @param x + * @param y + * @param options + */ + getElementByPoint( + x: number, + y: number, + options: { all: true } & PointTestOptions + ): GfxModel[]; + getElementByPoint( + x: number, + y: number, + options?: { all?: false } & PointTestOptions + ): GfxModel | null; + getElementByPoint( + x: number, + y: number, + options: PointTestOptions & { + all?: boolean; + } = { all: false, hitThreshold: 10 } + ): GfxModel | GfxModel[] | null { + options.zoom = this.viewport.zoom; + options.hitThreshold ??= 10; + + const hitThreshold = options.hitThreshold; + const responsePadding = options.responsePadding ?? [ + hitThreshold / 2, + hitThreshold / 2, + ]; + const all = options.all ?? false; + const hitTestBound = { + x: x - responsePadding[0], + y: y - responsePadding[1], + w: responsePadding[0] * 2, + h: responsePadding[1] * 2, + }; + + const candidates = this.grid.search(hitTestBound); + const picked = candidates.filter( + elm => + elm.includesPoint(x, y, options as PointTestOptions, this.std.host) || + elm.externalBound?.isPointInBound([x, y]) + ); + + picked.sort(this.layer.compare); + + if (all) { + return picked; + } + + return last(picked) ?? null; + } + + getElementInGroup( + x: number, + y: number, + options?: PointTestOptions + ): GfxModel | null { + const selectionManager = this.selection; + const results = this.getElementByPoint(x, y, { + ...options, + all: true, + }); + + let picked = last(results) ?? null; + const { activeGroup } = selectionManager; + const first = picked; + + if (activeGroup && picked && activeGroup.hasDescendant(picked)) { + let index = results.length - 1; + + while ( + picked === activeGroup || + (picked instanceof GfxGroupLikeElementModel && + picked.hasDescendant(activeGroup)) + ) { + picked = results[--index]; + } + } else if (picked) { + let index = results.length - 1; + + while (picked.group instanceof GfxGroupLikeElementModel) { + if (--index < 0) { + picked = null; + break; + } + picked = results[index]; + } + } + + return (picked ?? first) as GfxModel | null; + } + + /** + * Query all elements in an area. + * @param bound + * @param options + */ + getElementsByBound( + bound: IBound | Bound, + options?: { type: 'all' } + ): GfxModel[]; + + getElementsByBound( + bound: IBound | Bound, + options: { type: 'canvas' } + ): GfxPrimitiveElementModel[]; + + getElementsByBound( + bound: IBound | Bound, + options: { type: 'block' } + ): GfxBlockElementModel[]; + + getElementsByBound( + bound: IBound | Bound, + options: { type: 'block' | 'canvas' | 'all' } = { + type: 'all', + } + ): GfxModel[] { + bound = bound instanceof Bound ? bound : Bound.from(bound); + + let candidates = this.grid.search(bound); + + if (options.type !== 'all') { + const filter = + options.type === 'block' + ? (elm: GfxModel) => elm instanceof GfxBlockElementModel + : (elm: GfxModel) => elm instanceof GfxPrimitiveElementModel; + + candidates = candidates.filter(filter); + } + + candidates.sort(this.layer.compare); + + return candidates; + } + + getElementsByType(type: string): (GfxModel | BlockModel<object>)[] { + return ( + this.surface?.getElementsByType(type) ?? + this.doc.getBlocksByFlavour(type).map(b => b.model) + ); + } + + override mounted() { + this.viewport.setViewportElement(this.std.host); + this.std.provider.getAll(GfxExtensionIdentifier).forEach(ext => { + ext.mounted(); + }); + } + + override unmounted() { + this.std.provider.getAll(GfxExtensionIdentifier).forEach(ext => { + ext.unmounted(); + }); + this.viewport.clearViewportElement(); + this._disposables.dispose(); + } + + updateElement( + element: GfxModel | string, + props: Record<string, unknown> + ): void { + const elemId = typeof element === 'string' ? element : element.id; + + if (this.surface?.hasElementById(elemId)) { + this.surface.updateElement(elemId, props); + } else { + const block = this.doc.getBlock(elemId); + block && this.doc.updateBlock(block.model, props); + } + } +} diff --git a/blocksuite/framework/block-std/src/gfx/cursor.ts b/blocksuite/framework/block-std/src/gfx/cursor.ts new file mode 100644 index 0000000000..ecd8ae11ca --- /dev/null +++ b/blocksuite/framework/block-std/src/gfx/cursor.ts @@ -0,0 +1,54 @@ +export type StandardCursor = + | 'default' + | 'pointer' + | 'move' + | 'text' + | 'crosshair' + | 'not-allowed' + | 'grab' + | 'grabbing' + | 'nwse-resize' + | 'nesw-resize' + | 'ew-resize' + | 'ns-resize' + | 'n-resize' + | 's-resize' + | 'w-resize' + | 'e-resize' + | 'ne-resize' + | 'se-resize' + | 'sw-resize' + | 'nw-resize' + | 'zoom-in' + | 'zoom-out' + | 'help' + | 'wait' + | 'progress' + | 'copy' + | 'alias' + | 'context-menu' + | 'cell' + | 'vertical-text' + | 'no-drop' + | 'not-allowed' + | 'all-scroll' + | 'col-resize' + | 'row-resize' + | 'none' + | 'inherit' + | 'initial' + | 'unset'; + +export type URLCursor = `url(${string})`; + +export type URLCursorWithCoords = `url(${string}) ${number} ${number}`; + +export type URLCursorWithFallback = + | `${URLCursor}, ${StandardCursor}` + | `${URLCursorWithCoords}, ${StandardCursor}`; + +export type CursorType = + | StandardCursor + | URLCursor + | URLCursorWithCoords + | URLCursorWithFallback; diff --git a/blocksuite/framework/block-std/src/gfx/extension.ts b/blocksuite/framework/block-std/src/gfx/extension.ts new file mode 100644 index 0000000000..ee99878497 --- /dev/null +++ b/blocksuite/framework/block-std/src/gfx/extension.ts @@ -0,0 +1,53 @@ +import { type Container, createIdentifier } from '@blocksuite/global/di'; +import { BlockSuiteError, ErrorCode } from '@blocksuite/global/exceptions'; + +import { Extension } from '../extension/extension.js'; +import type { GfxController } from './controller.js'; +import { GfxControllerIdentifier } from './identifiers.js'; + +export const GfxExtensionIdentifier = + createIdentifier<GfxExtension>('GfxExtension'); + +export const GfxClassExtenderIdentifier = createIdentifier<{ + extendFn: (gfx: GfxController) => void; +}>('GfxClassExtender'); + +export abstract class GfxExtension extends Extension { + static key: string; + + get std() { + return this.gfx.std; + } + + constructor(protected readonly gfx: GfxController) { + super(); + } + + // This method is used to extend the GfxController + static extendGfx(_: GfxController) {} + + static override setup(di: Container) { + if (!this.key) { + throw new BlockSuiteError( + ErrorCode.ValueNotExists, + 'key is not defined in the GfxExtension' + ); + } + + di.addImpl(GfxClassExtenderIdentifier(this.key), { + extendFn: this.extendGfx, + }); + + di.add(this as unknown as { new (gfx: GfxController): GfxExtension }, [ + GfxControllerIdentifier, + ]); + + di.addImpl(GfxExtensionIdentifier(this.key), provider => + provider.get(this) + ); + } + + mounted() {} + + unmounted() {} +} diff --git a/blocksuite/framework/block-std/src/gfx/grid.ts b/blocksuite/framework/block-std/src/gfx/grid.ts new file mode 100644 index 0000000000..e997cccd84 --- /dev/null +++ b/blocksuite/framework/block-std/src/gfx/grid.ts @@ -0,0 +1,471 @@ +import type { IBound } from '@blocksuite/global/utils'; +import { + Bound, + getBoundWithRotation, + intersects, +} from '@blocksuite/global/utils'; +import type { BlockModel, Doc } from '@blocksuite/store'; + +import { compare } from '../utils/layer.js'; +import { GfxBlockElementModel } from './model/gfx-block-model.js'; +import type { GfxModel } from './model/model.js'; +import { GfxPrimitiveElementModel } from './model/surface/element-model.js'; +import { GfxLocalElementModel } from './model/surface/local-element-model.js'; +import { SurfaceBlockModel } from './model/surface/surface-model.js'; + +function getGridIndex(val: number) { + return Math.ceil(val / DEFAULT_GRID_SIZE) - 1; +} + +function rangeFromBound(a: IBound): number[] { + if (a.rotate) a = getBoundWithRotation(a); + const minRow = getGridIndex(a.x); + const maxRow = getGridIndex(a.x + a.w); + const minCol = getGridIndex(a.y); + const maxCol = getGridIndex(a.y + a.h); + return [minRow, maxRow, minCol, maxCol]; +} + +function rangeFromElement(ele: GfxModel | GfxLocalElementModel): number[] { + const bound = ele.elementBound; + + bound.w += ele.responseExtension[0] * 2; + bound.h += ele.responseExtension[1] * 2; + bound.x -= ele.responseExtension[0]; + bound.y -= ele.responseExtension[1]; + + const minRow = getGridIndex(bound.x); + const maxRow = getGridIndex(bound.maxX); + const minCol = getGridIndex(bound.y); + const maxCol = getGridIndex(bound.maxY); + return [minRow, maxRow, minCol, maxCol]; +} + +function rangeFromElementExternal(ele: GfxModel): number[] | null { + if (!ele.externalXYWH) return null; + + const bound = Bound.deserialize(ele.externalXYWH); + const minRow = getGridIndex(bound.x); + const maxRow = getGridIndex(bound.maxX); + const minCol = getGridIndex(bound.y); + const maxCol = getGridIndex(bound.maxY); + return [minRow, maxRow, minCol, maxCol]; +} + +export const DEFAULT_GRID_SIZE = 3000; + +const typeFilters = { + block: (model: GfxModel | GfxLocalElementModel) => + model instanceof GfxBlockElementModel, + canvas: (model: GfxModel | GfxLocalElementModel) => + model instanceof GfxPrimitiveElementModel, + local: (model: GfxModel | GfxLocalElementModel) => + model instanceof GfxLocalElementModel, +}; + +type FilterFunc = (model: GfxModel | GfxLocalElementModel) => boolean; + +export class GridManager { + private _elementToGrids = new Map< + GfxModel | GfxLocalElementModel, + Set<Set<GfxModel | GfxLocalElementModel>> + >(); + + private _externalElementToGrids = new Map<GfxModel, Set<Set<GfxModel>>>(); + + private _externalGrids = new Map<string, Set<GfxModel>>(); + + private _grids = new Map<string, Set<GfxModel | GfxLocalElementModel>>(); + + get isEmpty() { + return this._grids.size === 0; + } + + private _addToExternalGrids(element: GfxModel) { + const range = rangeFromElementExternal(element); + + if (!range) { + this._removeFromExternalGrids(element); + return; + } + + const [minRow, maxRow, minCol, maxCol] = range; + const grids = new Set<Set<GfxModel>>(); + this._externalElementToGrids.set(element, grids); + + for (let i = minRow; i <= maxRow; i++) { + for (let j = minCol; j <= maxCol; j++) { + let grid = this._getExternalGrid(i, j); + if (!grid) { + grid = this._createExternalGrid(i, j); + } + grid.add(element); + grids.add(grid); + } + } + } + + private _createExternalGrid(row: number, col: number) { + const id = row + '|' + col; + const elements = new Set<GfxModel>(); + this._externalGrids.set(id, elements); + return elements; + } + + private _createGrid(row: number, col: number) { + const id = row + '|' + col; + const elements = new Set<GfxModel>(); + this._grids.set(id, elements); + return elements; + } + + private _getExternalGrid(row: number, col: number) { + const id = row + '|' + col; + return this._externalGrids.get(id); + } + + private _getGrid(row: number, col: number) { + const id = row + '|' + col; + return this._grids.get(id); + } + + private _removeFromExternalGrids(element: GfxModel) { + const grids = this._externalElementToGrids.get(element); + if (grids) { + for (const grid of grids) { + grid.delete(element); + } + } + } + + private _searchExternal( + bound: IBound, + options: { filterFunc: FilterFunc; strict: boolean } + ): Set<GfxModel> { + const [minRow, maxRow, minCol, maxCol] = rangeFromBound(bound); + const results = new Set<GfxModel>(); + const b = Bound.from(bound); + + for (let i = minRow; i <= maxRow; i++) { + for (let j = minCol; j <= maxCol; j++) { + const gridElements = this._getExternalGrid(i, j); + if (!gridElements) continue; + + for (const element of gridElements) { + const externalBound = element.externalBound; + if ( + options.filterFunc(element) && + externalBound && + (options.strict + ? b.contains(externalBound) + : intersects(externalBound, bound)) + ) { + results.add(element); + } + } + } + } + + return results; + } + + private _toFilterFunc(filters: (keyof typeof typeFilters | FilterFunc)[]) { + const filterFuncs: FilterFunc[] = filters.map(filter => { + if (typeof filter === 'function') { + return filter; + } + return typeFilters[filter]; + }); + + return (model: GfxModel | GfxLocalElementModel) => + filterFuncs.some(filter => filter(model)); + } + + add(element: GfxModel | GfxLocalElementModel) { + if (!(element instanceof GfxLocalElementModel)) { + this._addToExternalGrids(element); + } + + const [minRow, maxRow, minCol, maxCol] = rangeFromElement(element); + const grids = new Set<Set<GfxModel | GfxLocalElementModel>>(); + this._elementToGrids.set(element, grids); + + for (let i = minRow; i <= maxRow; i++) { + for (let j = minCol; j <= maxCol; j++) { + let grid = this._getGrid(i, j); + if (!grid) { + grid = this._createGrid(i, j); + } + grid.add(element); + grids.add(grid); + } + } + } + + boundHasChanged(a: IBound, b: IBound) { + const [minRow, maxRow, minCol, maxCol] = rangeFromBound(a); + const [minRow2, maxRow2, minCol2, maxCol2] = rangeFromBound(b); + return ( + minRow !== minRow2 || + maxRow !== maxRow2 || + minCol !== minCol2 || + maxCol !== maxCol2 + ); + } + + /** + * + * @param bound + * @param strict + * @param reverseChecking If true, check if the bound is inside the elements instead of checking if the elements are inside the bound + * @returns + */ + has( + bound: IBound, + strict: boolean = false, + reverseChecking: boolean = false, + filter?: (model: GfxModel | GfxLocalElementModel) => boolean + ) { + const [minRow, maxRow, minCol, maxCol] = rangeFromBound(bound); + const b = Bound.from(bound); + const check = reverseChecking + ? (target: Bound) => { + return strict ? target.contains(b) : intersects(b, target); + } + : (target: Bound) => { + return strict ? b.contains(target) : intersects(target, b); + }; + + for (let i = minRow; i <= maxRow; i++) { + for (let j = minCol; j <= maxCol; j++) { + const gridElements = this._getGrid(i, j); + if (!gridElements) continue; + for (const element of gridElements) { + if ((!filter || filter(element)) && check(element.elementBound)) { + return true; + } + } + } + } + + return false; + } + + remove(element: GfxModel | GfxLocalElementModel) { + const grids = this._elementToGrids.get(element); + if (grids) { + for (const grid of grids) { + grid.delete(element); + } + } + this._elementToGrids.delete(element); + + if (!(element instanceof GfxLocalElementModel)) { + this._removeFromExternalGrids(element); + } + } + + /** + * Search for elements in a bound. + * @param bound + * @param options + */ + search<T extends keyof typeof typeFilters>( + bound: IBound, + options?: { + /** + * If true, only return elements that are completely inside the bound. + * Default is false. + */ + strict?: boolean; + /** + * If true, return a set of elements instead of an array + */ + useSet?: false; + /** + * Use this to filter the elements, if not provided, it will return blocks and canvas elements by default + */ + filter?: (T | FilterFunc)[] | FilterFunc; + } + ): T extends 'local'[] ? (GfxModel | GfxLocalElementModel)[] : GfxModel[]; + search<T extends keyof typeof typeFilters>( + bound: IBound, + options: { + strict?: boolean | undefined; + useSet: true; + filter?: (T | FilterFunc)[] | FilterFunc; + } + ): T extends 'local'[] ? Set<GfxModel | GfxLocalElementModel> : Set<GfxModel>; + search<T extends keyof typeof typeFilters>( + bound: IBound, + options: { + strict?: boolean; + useSet?: boolean; + filter?: (T | FilterFunc)[] | FilterFunc; + } = { + useSet: false, + } + ): + | (GfxModel | GfxLocalElementModel)[] + | Set<GfxModel | GfxLocalElementModel> { + const strict = options.strict ?? false; + const [minRow, maxRow, minCol, maxCol] = rangeFromBound(bound); + const b = Bound.from(bound); + const returnSet = options.useSet ?? false; + const filterFunc = + (Array.isArray(options.filter) + ? this._toFilterFunc(options.filter) + : options.filter) ?? this._toFilterFunc(['canvas', 'block']); + const results: Set<GfxModel | GfxLocalElementModel> = this._searchExternal( + bound, + { + filterFunc, + strict, + } + ); + + for (let i = minRow; i <= maxRow; i++) { + for (let j = minCol; j <= maxCol; j++) { + const gridElements = this._getGrid(i, j); + if (!gridElements) continue; + for (const element of gridElements) { + if ( + !(element as GfxPrimitiveElementModel).hidden && + filterFunc(element) && + (strict + ? b.contains(element.elementBound) + : intersects(element.responseBound, b)) + ) { + results.add(element); + } + } + } + } + + if (returnSet) return results; + + // sort elements in set based on index + const sorted = Array.from(results).sort(compare); + + return sorted; + } + + update(element: GfxModel | GfxLocalElementModel) { + this.remove(element); + this.add(element); + } + + watch(blocks: { doc?: Doc; surface?: SurfaceBlockModel | null }) { + const disposables: { dispose: () => void }[] = []; + const { doc, surface } = blocks; + const isRenderableBlock = ( + block: BlockModel + ): block is GfxBlockElementModel => { + return ( + block instanceof GfxBlockElementModel && + (block.parent?.role === 'root' || + block.parent instanceof SurfaceBlockModel) + ); + }; + + if (doc) { + disposables.push( + doc.slots.blockUpdated.on(payload => { + if (payload.type === 'add' && isRenderableBlock(payload.model)) { + this.add(payload.model); + } + + if (payload.type === 'update') { + const model = doc.getBlock(payload.id) + ?.model as GfxBlockElementModel; + + if (!model) { + return; + } + + if (this._elementToGrids.has(model) && !isRenderableBlock(model)) { + this.remove(model as GfxBlockElementModel); + } else if ( + payload.props.key === 'xywh' && + isRenderableBlock(model) + ) { + this.update( + doc.getBlock(payload.id)?.model as GfxBlockElementModel + ); + } + } + + if ( + payload.type === 'delete' && + payload.model instanceof GfxBlockElementModel + ) { + this.remove(payload.model); + } + }) + ); + + Object.values(doc.blocks.peek()).forEach(block => { + if (isRenderableBlock(block.model)) { + this.add(block.model); + } + }); + } + + if (surface) { + disposables.push( + surface.elementAdded.on(payload => { + this.add(surface.getElementById(payload.id)!); + }) + ); + + disposables.push( + surface.elementRemoved.on(payload => { + this.remove(payload.model); + }) + ); + + disposables.push( + surface.elementUpdated.on(payload => { + if ( + payload.props['xywh'] || + payload.props['externalXYWH'] || + payload.props['responseExtension'] + ) { + this.update(surface.getElementById(payload.id)!); + } + }) + ); + + disposables.push( + surface.localElementAdded.on(elm => { + this.add(elm); + }) + ); + + disposables.push( + surface.localElementUpdated.on(payload => { + if (payload.props['xywh'] || payload.props['responseExtension']) { + this.update(payload.model); + } + }) + ); + + disposables.push( + surface.localElementDeleted.on(elm => { + this.remove(elm); + }) + ); + + surface.elementModels.forEach(model => { + this.add(model); + }); + surface.localElementModels.forEach(model => { + this.add(model); + }); + } + + return () => { + disposables.forEach(d => d.dispose()); + }; + } +} diff --git a/blocksuite/framework/block-std/src/gfx/identifiers.ts b/blocksuite/framework/block-std/src/gfx/identifiers.ts new file mode 100644 index 0000000000..15978b85db --- /dev/null +++ b/blocksuite/framework/block-std/src/gfx/identifiers.ts @@ -0,0 +1,10 @@ +import type { ServiceIdentifier } from '@blocksuite/global/di'; + +import { LifeCycleWatcherIdentifier } from '../identifier.js'; +import type { GfxController } from './controller.js'; + +export const gfxControllerKey = 'GfxController'; + +export const GfxControllerIdentifier = LifeCycleWatcherIdentifier( + gfxControllerKey +) as ServiceIdentifier<GfxController>; diff --git a/blocksuite/framework/block-std/src/gfx/index.ts b/blocksuite/framework/block-std/src/gfx/index.ts new file mode 100644 index 0000000000..c5264aa8b0 --- /dev/null +++ b/blocksuite/framework/block-std/src/gfx/index.ts @@ -0,0 +1,86 @@ +export { generateKeyBetweenV2 } from '../utils/fractional-indexing.js'; +export { + compare as compareLayer, + renderableInEdgeless, + SortOrder, +} from '../utils/layer.js'; +export { + canSafeAddToContainer, + descendantElementsImpl, + getTopElements, + hasDescendantElementImpl, +} from '../utils/tree.js'; +export { GfxController } from './controller.js'; +export type { CursorType, StandardCursor } from './cursor.js'; +export { GfxExtension, GfxExtensionIdentifier } from './extension.js'; +export { GridManager } from './grid.js'; +export { GfxControllerIdentifier } from './identifiers.js'; +export { LayerManager, type ReorderingDirection } from './layer.js'; +export type { + GfxCompatibleInterface, + GfxElementGeometry, + GfxGroupCompatibleInterface, + PointTestOptions, +} from './model/base.js'; +export { + gfxGroupCompatibleSymbol, + isGfxGroupCompatibleModel, +} from './model/base.js'; +export { + GfxBlockElementModel, + type GfxCommonBlockProps, + GfxCompatibleBlockModel as GfxCompatible, + type GfxCompatibleProps, +} from './model/gfx-block-model.js'; +export { type GfxModel } from './model/model.js'; +export { + convert, + convertProps, + derive, + field, + getDerivedProps, + getFieldPropsSet, + initializeObservers, + initializeWatchers, + local, + observe, + updateDerivedProps, + watch, +} from './model/surface/decorators/index.js'; +export { + type BaseElementProps, + GfxGroupLikeElementModel, + GfxPrimitiveElementModel, + type SerializedElement, +} from './model/surface/element-model.js'; +export { + GfxLocalElementModel, + prop, +} from './model/surface/local-element-model.js'; +export { + SurfaceBlockModel, + type SurfaceBlockProps, + type SurfaceMiddleware, +} from './model/surface/surface-model.js'; +export { GfxSelectionManager } from './selection.js'; +export { + SurfaceMiddlewareBuilder, + SurfaceMiddlewareExtension, +} from './surface-middleware.js'; +export { + BaseTool, + type GfxToolsFullOption, + type GfxToolsFullOptionValue, + type GfxToolsMap, + type GfxToolsOption, +} from './tool/tool.js'; +export { MouseButton, ToolController } from './tool/tool-controller.js'; +export { + type EventsHandlerMap, + GfxElementModelView, + type SupportedEvent, +} from './view/view.js'; +export { ViewManager } from './view/view-manager.js'; +export * from './viewport.js'; +export { GfxViewportElement } from './viewport-element.js'; +export { generateKeyBetween, generateNKeysBetween } from 'fractional-indexing'; diff --git a/blocksuite/framework/block-std/src/gfx/keyboard.ts b/blocksuite/framework/block-std/src/gfx/keyboard.ts new file mode 100644 index 0000000000..0eda0e4196 --- /dev/null +++ b/blocksuite/framework/block-std/src/gfx/keyboard.ts @@ -0,0 +1,51 @@ +import { DisposableGroup } from '@blocksuite/global/utils'; +import { Signal } from '@preact/signals-core'; + +import type { BlockStdScope } from '../scope/block-std-scope.js'; + +export class KeyboardController { + private _disposable = new DisposableGroup(); + + shiftKey$ = new Signal<boolean>(false); + + spaceKey$ = new Signal<boolean>(false); + + constructor(readonly std: BlockStdScope) { + this._init(); + } + + private _init() { + this._disposable.add( + this._listenKeyboard('keydown', evt => { + this.shiftKey$.value = evt.shiftKey && evt.key === 'Shift'; + this.spaceKey$.value = evt.code === 'Space'; + }) + ); + + this._disposable.add( + this._listenKeyboard('keyup', evt => { + this.shiftKey$.value = + evt.shiftKey && evt.key === 'Shift' ? true : false; + + if (evt.code === 'Space') { + this.spaceKey$.value = false; + } + }) + ); + } + + private _listenKeyboard( + event: 'keydown' | 'keyup', + callback: (keyboardEvt: KeyboardEvent) => void + ) { + document.addEventListener(event, callback, false); + + return () => { + document.removeEventListener(event, callback, false); + }; + } + + dispose() { + this._disposable.dispose(); + } +} diff --git a/blocksuite/framework/block-std/src/gfx/layer.ts b/blocksuite/framework/block-std/src/gfx/layer.ts new file mode 100644 index 0000000000..c77a7f8f02 --- /dev/null +++ b/blocksuite/framework/block-std/src/gfx/layer.ts @@ -0,0 +1,871 @@ +import { + assertType, + Bound, + DisposableGroup, + last, + Slot, +} from '@blocksuite/global/utils'; +import type { Doc } from '@blocksuite/store'; +import { generateKeyBetween } from 'fractional-indexing'; + +import { + compare, + getElementIndex, + getLayerEndZIndex, + insertToOrderedArray, + isInRange, + removeFromOrderedArray, + SortOrder, + ungroupIndex, + updateLayersZIndex, +} from '../utils/layer.js'; +import { + type GfxGroupCompatibleInterface, + isGfxGroupCompatibleModel, +} from './model/base.js'; +import { GfxBlockElementModel } from './model/gfx-block-model.js'; +import type { GfxModel } from './model/model.js'; +import { GfxPrimitiveElementModel } from './model/surface/element-model.js'; +import { GfxLocalElementModel } from './model/surface/local-element-model.js'; +import { SurfaceBlockModel } from './model/surface/surface-model.js'; + +export type ReorderingDirection = 'front' | 'forward' | 'backward' | 'back'; + +type BaseLayer<T> = { + set: Set<T>; + + elements: Array<T>; + + /** + * fractional indexing range + */ + indexes: [string, string]; +}; + +export type BlockLayer = BaseLayer<GfxBlockElementModel> & { + type: 'block'; + + /** + * The z-index of the first block in this layer. + * + * A block layer may contains multiple blocks, + * the block should be rendered with this `zIndex` + "its index in the layer" as the z-index property. + */ + zIndex: number; +}; + +export type CanvasLayer = BaseLayer<GfxPrimitiveElementModel> & { + type: 'canvas'; + + /** + * The z-index of canvas layer. + * + * A canvas layer renders all the elements in a single canvas, + * this property is used to render the canvas with correct z-index. + */ + zIndex: number; +}; + +export type Layer = BlockLayer | CanvasLayer; + +export class LayerManager { + static INITIAL_INDEX = 'a0'; + + private _disposable = new DisposableGroup(); + + blocks: GfxBlockElementModel[] = []; + + canvasElements: GfxPrimitiveElementModel[] = []; + + canvasLayers: { + set: Set<GfxPrimitiveElementModel>; + /** + * fractional index + */ + indexes: [string, string]; + /** + * z-index, used for actual rendering + */ + zIndex: number; + elements: Array<GfxPrimitiveElementModel>; + }[] = []; + + layers: Layer[] = []; + + slots = { + layerUpdated: new Slot<{ + type: 'delete' | 'add' | 'update'; + initiatingElement: GfxModel | GfxLocalElementModel; + }>(), + }; + + constructor( + private _doc: Doc, + private _surface: SurfaceBlockModel | null, + options: { + watch: boolean; + } = { watch: true } + ) { + this._reset(); + + if (options?.watch) { + this.watch({ + doc: _doc, + surface: _surface, + }); + } + } + + private _buildCanvasLayers() { + const canvasLayers = this.layers + .filter<CanvasLayer>( + (layer): layer is CanvasLayer => layer.type === 'canvas' + ) + .map(layer => { + return { + set: layer.set, + elements: layer.elements, + zIndex: layer.zIndex, + indexes: layer.indexes, + }; + }) as LayerManager['canvasLayers']; + + if (!canvasLayers.length || last(this.layers)?.type !== 'canvas') { + canvasLayers.push({ + set: new Set(), + elements: [], + zIndex: 0, + indexes: [LayerManager.INITIAL_INDEX, LayerManager.INITIAL_INDEX], + }); + } + + this.canvasLayers = canvasLayers; + } + + private _getModelType( + element: GfxModel | GfxLocalElementModel + ): 'block' | 'canvas' { + return element instanceof GfxLocalElementModel || + element instanceof GfxPrimitiveElementModel + ? 'canvas' + : 'block'; + } + + private _initLayers() { + let blockIdx = 0; + let canvasIdx = 0; + const layers: LayerManager['layers'] = []; + let curLayer: LayerManager['layers'][number] | undefined; + let currentCSSZindex = 1; + + const pushCurLayer = () => { + if (curLayer) { + curLayer.indexes = [ + getElementIndex(curLayer.elements[0]), + getElementIndex(last(curLayer.elements)!), + ]; + curLayer.zIndex = currentCSSZindex; + layers.push(curLayer as LayerManager['layers'][number]); + + currentCSSZindex += + curLayer.type === 'block' ? curLayer.elements.length : 1; + } + }; + const addLayer = (type: 'canvas' | 'block') => { + pushCurLayer(); + curLayer = + type === 'canvas' + ? ({ + type, + indexes: [LayerManager.INITIAL_INDEX, LayerManager.INITIAL_INDEX], + zIndex: 0, + set: new Set(), + elements: [], + bound: new Bound(), + } as CanvasLayer) + : ({ + type, + indexes: [LayerManager.INITIAL_INDEX, LayerManager.INITIAL_INDEX], + zIndex: 0, + set: new Set(), + elements: [], + } as BlockLayer); + }; + + while ( + blockIdx < this.blocks.length || + canvasIdx < this.canvasElements.length + ) { + const curBlock = this.blocks[blockIdx]; + const curCanvas = this.canvasElements[canvasIdx]; + + if (!curBlock && !curCanvas) { + break; + } + + if (!curBlock) { + if (curLayer?.type !== 'canvas') { + addLayer('canvas'); + } + assertType<CanvasLayer>(curLayer); + + const remains = this.canvasElements.slice(canvasIdx); + + curLayer!.elements = curLayer.elements.concat(remains); + remains.forEach(element => (curLayer as CanvasLayer).set.add(element)); + + break; + } + + if (!curCanvas) { + if (curLayer?.type !== 'block') { + addLayer('block'); + } + + assertType<BlockLayer>(curLayer); + + const remains = this.blocks.slice(blockIdx); + + curLayer.elements = curLayer.elements.concat(remains); + remains.forEach(block => (curLayer as BlockLayer).set.add(block)); + + break; + } + + const order = compare(curBlock, curCanvas); + + switch (order) { + case -1: + if (curLayer?.type !== 'block') { + addLayer('block'); + } + + assertType<BlockLayer>(curLayer); + + curLayer!.set.add(curBlock); + curLayer!.elements.push(curBlock); + + ++blockIdx; + + break; + case 1: + if (curLayer?.type !== 'canvas') { + addLayer('canvas'); + } + + assertType<CanvasLayer>(curLayer); + + curLayer!.set.add(curCanvas); + curLayer!.elements.push(curCanvas); + + ++canvasIdx; + + break; + case 0: + if (!curLayer) { + addLayer('block'); + } + + if (curLayer!.type === 'block') { + curLayer!.set.add(curBlock); + curLayer!.elements.push(curBlock); + + ++blockIdx; + } else { + curLayer!.set.add(curCanvas); + curLayer!.elements.push(curCanvas); + + ++canvasIdx; + } + break; + } + } + + if (curLayer && curLayer.elements.length) { + pushCurLayer(); + } + + this.layers = layers; + this._surface?.localElementModels.forEach(el => this.add(el)); + } + + private _insertIntoLayer(target: GfxModel, type: 'block' | 'canvas') { + const layers = this.layers; + let cur = layers.length - 1; + + const addToLayer = ( + layer: Layer, + element: GfxModel, + position: number | 'tail' + ) => { + assertType<CanvasLayer>(layer); + assertType<GfxPrimitiveElementModel>(element); + + if (position === 'tail') { + layer.elements.push(element); + } else { + layer.elements.splice(position, 0, element); + } + + layer.set.add(element); + + if ( + position === 'tail' || + position === 0 || + position === layer.elements.length - 1 + ) { + layer.indexes = [ + getElementIndex(layer.elements[0]), + getElementIndex(last(layer.elements)!), + ]; + } + }; + const createLayer = ( + type: 'block' | 'canvas', + targets: GfxModel[], + curZIndex: number + ): Layer => { + const newLayer = { + type, + set: new Set(targets), + indexes: [ + getElementIndex(targets[0]), + getElementIndex(last(targets)!), + ] as [string, string], + zIndex: curZIndex + 1, + elements: targets, + } as BlockLayer; + + return newLayer as Layer; + }; + + if ( + !last(this.layers) || + [SortOrder.AFTER, SortOrder.SAME].includes( + compare(target, last(last(this.layers)!.elements)!) + ) + ) { + const layer = last(this.layers); + + if (layer?.type === type) { + addToLayer(layer, target, 'tail'); + updateLayersZIndex(layers, cur); + } else { + this.layers.push( + createLayer( + type, + [target], + getLayerEndZIndex(layers, layers.length - 1) + ) + ); + } + } else { + while (cur > -1) { + const layer = layers[cur]; + const layerElements = layer.elements; + + if (isInRange([layerElements[0], last(layerElements)!], target)) { + const insertIdx = layerElements.findIndex((_, idx) => { + const pre = layerElements[idx - 1]; + return ( + compare(target, layerElements[idx]) < 0 && + (!pre || compare(target, pre) >= 0) + ); + }); + + if (layer.type === type) { + addToLayer(layer, target, insertIdx); + updateLayersZIndex(layers, cur); + } else { + const splicedElements = layer.elements.splice(insertIdx); + layer.set = new Set(layer.elements as GfxPrimitiveElementModel[]); + + layers.splice( + cur + 1, + 0, + createLayer(layer.type, splicedElements, 1) + ); + layers.splice(cur + 1, 0, createLayer(type, [target], 1)); + updateLayersZIndex(layers, cur); + } + break; + } else { + const nextLayer = layers[cur - 1]; + + if (!nextLayer || compare(target, last(nextLayer.elements)!) >= 0) { + if (layer.type === type) { + addToLayer(layer, target, 0); + updateLayersZIndex(layers, cur); + } else { + if (nextLayer) { + addToLayer(nextLayer, target, 'tail'); + updateLayersZIndex(layers, cur - 1); + } else { + layers.unshift(createLayer(type, [target], 1)); + updateLayersZIndex(layers, 0); + } + } + + break; + } + } + + --cur; + } + } + } + + private _removeFromLayer( + target: GfxModel | GfxLocalElementModel, + type: 'block' | 'canvas' + ) { + const layers = this.layers; + const index = layers.findIndex(layer => { + if (layer.type !== type) return false; + + assertType<CanvasLayer>(layer); + assertType<GfxPrimitiveElementModel>(target); + + if (layer.set.has(target)) { + layer.set.delete(target); + const idx = layer.elements.indexOf(target); + if (idx !== -1) { + layer.elements.splice(layer.elements.indexOf(target), 1); + + if (layer.elements.length) { + layer.indexes = [ + getElementIndex(layer.elements[0]), + getElementIndex(last(layer.elements)!), + ]; + } + } + + return true; + } + + return false; + }); + + if (index === -1) return; + + const isDeletedAtEdge = index === 0 || index === layers.length - 1; + + if (layers[index].set.size === 0) { + if (isDeletedAtEdge) { + layers.splice(index, 1); + + if (layers[index]) { + updateLayersZIndex(layers, index); + } + } else { + const lastLayer = layers[index - 1] as CanvasLayer; + const nextLayer = layers[index + 1] as CanvasLayer; + + lastLayer.elements = lastLayer.elements.concat(nextLayer.elements); + lastLayer.set = new Set(lastLayer.elements); + + layers.splice(index, 2); + updateLayersZIndex(layers, index - 1); + } + return; + } + + updateLayersZIndex(layers, index); + } + + private _reset() { + const elements = ( + this._doc + .getBlocks() + .filter( + model => + model instanceof GfxBlockElementModel && + (model.parent instanceof SurfaceBlockModel || + model.parent?.role === 'root') + ) as GfxModel[] + ).concat(this._surface?.elementModels ?? []); + + this.canvasElements = []; + this.blocks = []; + + elements.forEach(element => { + if (element instanceof GfxPrimitiveElementModel) { + this.canvasElements.push(element); + } else { + this.blocks.push(element); + } + }); + + this.canvasElements.sort(compare); + this.blocks.sort(compare); + + this._initLayers(); + this._buildCanvasLayers(); + } + + /** + * @returns a boolean value to indicate whether the layers have been updated + */ + private _updateLayer( + element: GfxModel | GfxLocalElementModel, + props?: Record<string, unknown> + ) { + const modelType = this._getModelType(element); + const isLocalElem = element instanceof GfxLocalElementModel; + + const indexChanged = !props || 'index' in props; + const childIdsChanged = props && 'childIds' in props; + const shouldUpdateGroupChildren = + isGfxGroupCompatibleModel(element) && (indexChanged || childIdsChanged); + const updateArray = (array: GfxModel[], element: GfxModel) => { + if (!indexChanged) return; + removeFromOrderedArray(array, element); + insertToOrderedArray(array, element); + }; + + if (shouldUpdateGroupChildren) { + this._reset(); + return true; + } + + if (!isLocalElem) { + if (modelType === 'canvas') { + updateArray(this.canvasElements, element); + } else { + updateArray(this.blocks, element); + } + } + + if (indexChanged || childIdsChanged) { + this._removeFromLayer(element as GfxModel, modelType); + this._insertIntoLayer(element as GfxModel, modelType); + return true; + } + + return false; + } + + add(element: GfxModel | GfxLocalElementModel) { + const modelType = this._getModelType(element); + const isContainer = isGfxGroupCompatibleModel(element); + const isLocalElem = element instanceof GfxLocalElementModel; + + if (isContainer) { + element.childElements.forEach(child => { + const childModelType = this._getModelType(child); + removeFromOrderedArray( + childModelType === 'canvas' ? this.canvasElements : this.blocks, + child + ); + }); + } + + if (!isLocalElem) { + insertToOrderedArray( + modelType === 'canvas' ? this.canvasElements : this.blocks, + element + ); + } + this._insertIntoLayer(element as GfxModel, modelType); + + if (isContainer) { + element.childElements.forEach(child => child && this._updateLayer(child)); + } + this._buildCanvasLayers(); + this.slots.layerUpdated.emit({ + type: 'add', + initiatingElement: element, + }); + } + + /** + * Pass to the `Array.sort` to sort the elements by their index + */ + compare(a: GfxModel, b: GfxModel) { + return compare(a, b); + } + + /** + * In some cases, we need to generate a bunch of indexes in advance before acutally adding the elements to the layer manager. + * Eg. when importing a template. The `generateIndex` is a function only depends on the current state of the manager. + * So we cannot use it because it will always return the same index if the element is not added to manager. + * + * This function return a index generator that can "remember" the index it generated without actually adding the element to the manager. + * + * @note The generator cannot work with `group` element. + * + * @returns + */ + createIndexGenerator() { + const manager = new LayerManager(this._doc, this._surface, { + watch: false, + }); + + return () => { + const idx = manager.generateIndex(); + const bound = new Bound(0, 0, 10, 10); + + const mockedFakeElement = { + index: idx, + type: 'shape', + x: 0, + y: 0, + w: 10, + h: 10, + elementBound: bound, + xywh: '[0, 0, 10, 10]', + get group() { + return null; + }, + get groups() { + return []; + }, + }; + + manager.add(mockedFakeElement as unknown as GfxModel); + + return idx; + }; + } + + delete(element: GfxModel | GfxLocalElementModel) { + let deleteType: 'canvas' | 'block' | undefined = undefined; + const isGroup = isGfxGroupCompatibleModel(element); + const isLocalElem = element instanceof GfxLocalElementModel; + + if (isGroup) { + this._reset(); + this.slots.layerUpdated.emit({ + type: 'delete', + initiatingElement: element as GfxModel, + }); + return; + } + + if ( + element instanceof GfxPrimitiveElementModel || + element instanceof GfxLocalElementModel + ) { + deleteType = 'canvas'; + if (!isLocalElem) { + removeFromOrderedArray(this.canvasElements, element); + } + } else { + deleteType = 'block'; + removeFromOrderedArray(this.blocks, element); + } + + this._removeFromLayer(element, deleteType); + + this._buildCanvasLayers(); + this.slots.layerUpdated.emit({ + type: 'delete', + initiatingElement: element, + }); + } + + dispose() { + this.slots.layerUpdated.dispose(); + } + + /** + * @param reverse - if true, generate the index in reverse order + * @returns + */ + generateIndex(reverse = false): string { + if (reverse) { + const firstIndex = this.layers[0]?.indexes[0]; + + return firstIndex + ? generateKeyBetween(null, ungroupIndex(firstIndex)) + : LayerManager.INITIAL_INDEX; + } else { + const lastIndex = last(this.layers)?.indexes[1]; + + return lastIndex + ? generateKeyBetween(ungroupIndex(lastIndex), null) + : LayerManager.INITIAL_INDEX; + } + } + + getCanvasLayers() { + return this.canvasLayers; + } + + getReorderedIndex(element: GfxModel, direction: ReorderingDirection): string { + const group = (element.group as GfxGroupCompatibleInterface) || null; + + let elements: GfxModel[]; + + if (group !== null) { + elements = group.childElements; + + elements.sort(compare); + } else { + elements = this.layers.reduce( + (pre: GfxModel[], current) => + pre.concat(current.elements.filter(element => element.group == null)), + [] + ); + } + + const currentIdx = elements.indexOf(element); + + switch (direction) { + case 'forward': + case 'front': + if (currentIdx === -1 || currentIdx === elements.length - 1) + return element.index; + + { + const next = + direction === 'forward' + ? elements[currentIdx + 1] + : elements[elements.length - 1]; + const next2 = + direction === 'forward' ? elements[currentIdx + 2] : null; + + return generateKeyBetween( + next.index, + next2?.index + ? next.index < next2.index + ? next2.index + : null + : null + ); + } + case 'backward': + case 'back': + if (currentIdx === -1 || currentIdx === 0) return element.index; + + { + const pre = + direction === 'backward' ? elements[currentIdx - 1] : elements[0]; + const pre2 = + direction === 'backward' ? elements[currentIdx - 2] : null; + + return generateKeyBetween( + !pre2 || pre2?.index >= pre.index ? null : pre2.index, + pre.index + ); + } + } + } + + getZIndex(element: GfxModel): number { + // @ts-expect-error FIXME: ts error + const layer = this.layers.find(layer => layer.set.has(element)); + + if (!layer) return -1; + + // @ts-expect-error FIXME: ts error + return layer.zIndex + layer.elements.indexOf(element); + } + + update( + element: GfxModel | GfxLocalElementModel, + props?: Record<string, unknown> + ) { + if (this._updateLayer(element, props)) { + this._buildCanvasLayers(); + this.slots.layerUpdated.emit({ + type: 'update', + initiatingElement: element, + }); + } + } + + watch(blocks: { doc?: Doc; surface: SurfaceBlockModel | null }) { + const { doc, surface } = blocks; + + if (doc) { + this._disposable.add( + doc.slots.blockUpdated.on(payload => { + if (payload.type === 'add') { + const block = doc.getBlockById(payload.id)!; + + if ( + block instanceof GfxBlockElementModel && + (block.parent instanceof SurfaceBlockModel || + block.parent?.role === 'root') && + this.blocks.indexOf(block) === -1 + ) { + this.add(block as GfxBlockElementModel); + } + } + if (payload.type === 'update') { + const block = doc.getBlockById(payload.id)!; + + if ( + (payload.props.key === 'index' || + payload.props.key === 'childIds') && + block instanceof GfxBlockElementModel && + (block.parent instanceof SurfaceBlockModel || + block.parent?.role === 'root') + ) { + this.update(block as GfxBlockElementModel, { + [payload.props.key]: true, + }); + } else if ( + this.blocks.includes(block as GfxBlockElementModel) && + !( + block.parent instanceof SurfaceBlockModel || + block.parent?.role === 'root' + ) + ) { + this.delete(block as GfxBlockElementModel); + } + } + if (payload.type === 'delete') { + const block = doc.getBlockById(payload.id); + + if (block instanceof GfxBlockElementModel) { + this.delete(block as GfxBlockElementModel); + } + } + }) + ); + } + + if (surface) { + if (this._surface !== surface) { + this._surface = surface; + } + + this._disposable.add( + surface.elementAdded.on(payload => + this.add(surface.getElementById(payload.id)!) + ) + ); + this._disposable.add( + surface.elementUpdated.on(payload => { + if (payload.props['index'] || payload.props['childIds']) { + this.update(surface.getElementById(payload.id)!, payload.props); + } + }) + ); + this._disposable.add( + surface.elementRemoved.on(payload => this.delete(payload.model!)) + ); + this._disposable.add( + surface.localElementAdded.on(elm => { + this.add(elm); + }) + ); + this._disposable.add( + this._surface.localElementUpdated.on(payload => { + if (payload.props['index'] || payload.props['groupId']) { + this.update(payload.model, payload.props); + } + }) + ); + this._disposable.add( + surface.localElementDeleted.on(elm => { + this.delete(elm); + }) + ); + + surface.elementModels.forEach(el => this.add(el)); + } + } +} diff --git a/blocksuite/framework/block-std/src/gfx/model/base.ts b/blocksuite/framework/block-std/src/gfx/model/base.ts new file mode 100644 index 0000000000..66ceee9216 --- /dev/null +++ b/blocksuite/framework/block-std/src/gfx/model/base.ts @@ -0,0 +1,167 @@ +import type { + Bound, + IBound, + IVec, + PointLocation, + SerializedXYWH, + XYWH, +} from '@blocksuite/global/utils'; + +import type { EditorHost } from '../../view/element/lit-host.js'; +import type { GfxGroupModel, GfxModel } from './model.js'; + +/** + * The methods that a graphic element should implement. + * It is already included in the `GfxCompatibleInterface` interface. + */ +export interface GfxElementGeometry { + containsBound(bound: Bound): boolean; + getNearestPoint(point: IVec): IVec; + getLineIntersections(start: IVec, end: IVec): PointLocation[] | null; + getRelativePointLocation(point: IVec): PointLocation; + includesPoint( + x: number, + y: number, + options: PointTestOptions, + host: EditorHost + ): boolean; + intersectsBound(bound: Bound): boolean; +} + +/** + * All the model that can be rendered in graphics mode should implement this interface. + */ +export interface GfxCompatibleInterface extends IBound, GfxElementGeometry { + xywh: SerializedXYWH; + index: string; + + /** + * Defines the extension of the response area beyond the element's bounding box. + * This tuple specifies the horizontal and vertical margins to be added to the element's bound. + * + * The first value represents the horizontal extension (added to both left and right sides), + * and the second value represents the vertical extension (added to both top and bottom sides). + * + * The response area is computed as: + * `[x - horizontal, y - vertical, w + 2 * horizontal, h + 2 * vertical]`. + * + * Example: + * - xywh: `[0, 0, 100, 100]`, `responseExtension: [10, 20]` + * Resulting response area: `[-10, -20, 120, 140]`. + * - `responseExtension: [0, 0]` keeps the response area equal to the bounding box. + */ + responseExtension: [number, number]; + + readonly group: GfxGroupCompatibleInterface | null; + + readonly groups: GfxGroupCompatibleInterface[]; + + readonly deserializedXYWH: XYWH; + + /** + * The bound of the element without considering the response extension. + */ + readonly elementBound: Bound; + + /** + * The bound of the element considering the response extension. + */ + readonly responseBound: Bound; + + /** + * Indicates whether the current block is explicitly locked by self. + * For checking the lock status of the element, use `isLocked` instead. + * For (un)locking the element, use `(un)lock` instead. + */ + lockedBySelf?: boolean; + + /** + * Check if the element is locked. It will check the lock status of the element and its ancestors. + */ + isLocked(): boolean; + isLockedBySelf(): boolean; + isLockedByAncestor(): boolean; + + lock(): void; + unlock(): void; +} + +/** + * The symbol to mark a model as a container. + */ +export const gfxGroupCompatibleSymbol = Symbol('GfxGroupCompatible'); + +/** + * Check if the element is a container element. + */ +export const isGfxGroupCompatibleModel = ( + elm: unknown +): elm is GfxGroupModel => { + if (typeof elm !== 'object' || elm === null) return false; + return ( + gfxGroupCompatibleSymbol in elm && elm[gfxGroupCompatibleSymbol] === true + ); +}; + +/** + * GfxGroupCompatibleElement is a model that can contain other models. + * It just like a group that in common graphic software. + */ +export interface GfxGroupCompatibleInterface extends GfxCompatibleInterface { + [gfxGroupCompatibleSymbol]: true; + + /** + * All child ids of this container. + */ + childIds: string[]; + + /** + * All child element models of this container. + * Note that the `childElements` may not contains all the children in `childIds`, + * because some children may not be loaded. + */ + childElements: GfxModel[]; + + descendantElements: GfxModel[]; + + addChild(element: GfxCompatibleInterface): void; + removeChild(element: GfxCompatibleInterface): void; + hasChild(element: GfxCompatibleInterface): boolean; + + hasDescendant(element: GfxCompatibleInterface): boolean; +} + +/** + * The options for the hit testing of a point. + */ +export interface PointTestOptions { + /** + * The threshold of the hit test. The unit is pixel. + */ + hitThreshold?: number; + + /** + * If true, the element bound will be used for the hit testing. + * By default, the response bound will be used. + */ + useElementBound?: boolean; + + /** + * The padding of the response area for each element when do the hit testing. The unit is pixel. + * The first value is the padding for the x-axis, and the second value is the padding for the y-axis. + */ + responsePadding?: [number, number]; + + /** + * If true, the transparent area of the element will be ignored during the point inclusion test. + * Otherwise, the transparent area will be considered as filled area. + * + * Default is true. + */ + ignoreTransparent?: boolean; + + /** + * The zoom level of current view when do the hit testing. + */ + zoom?: number; +} diff --git a/blocksuite/framework/block-std/src/gfx/model/gfx-block-model.ts b/blocksuite/framework/block-std/src/gfx/model/gfx-block-model.ts new file mode 100644 index 0000000000..4174a53eb0 --- /dev/null +++ b/blocksuite/framework/block-std/src/gfx/model/gfx-block-model.ts @@ -0,0 +1,271 @@ +import { BlockSuiteError, ErrorCode } from '@blocksuite/global/exceptions'; +import type { + Constructor, + IVec, + SerializedXYWH, + XYWH, +} from '@blocksuite/global/utils'; +import { + Bound, + deserializeXYWH, + getBoundWithRotation, + getPointsFromBoundWithRotation, + linePolygonIntersects, + PointLocation, + polygonGetPointTangent, + polygonNearestPoint, + rotatePoints, +} from '@blocksuite/global/utils'; +import { BlockModel } from '@blocksuite/store'; + +import { + isLockedByAncestorImpl, + isLockedBySelfImpl, + isLockedImpl, + lockElementImpl, + unlockElementImpl, +} from '../../utils/tree.js'; +import type { EditorHost } from '../../view/index.js'; +import type { GfxCompatibleInterface, PointTestOptions } from './base.js'; +import type { GfxGroupModel } from './model.js'; +import type { SurfaceBlockModel } from './surface/surface-model.js'; + +/** + * The props that a graphics block model should have. + */ +export type GfxCompatibleProps = { + xywh: SerializedXYWH; + index: string; + lockedBySelf?: boolean; +}; + +/** + * This type include the common props for the graphic block model. + * You can use this type with Omit to define the props of a graphic block model. + */ +export type GfxCommonBlockProps = GfxCompatibleProps & { + rotate: number; + scale: number; +}; + +/** + * The graphic block model that can be rendered in the graphics mode. + * All the graphic block model should extend this class. + * You can use `GfxCompatibleBlockModel` to convert a BlockModel to a subclass that extends it. + */ +export class GfxBlockElementModel< + Props extends GfxCompatibleProps = GfxCompatibleProps, + > + extends BlockModel<Props> + implements GfxCompatibleInterface +{ + private _cacheDeserKey: string | null = null; + + private _cacheDeserXYWH: XYWH | null = null; + + private _externalXYWH: SerializedXYWH | undefined = undefined; + + connectable = true; + + /** + * Defines the extension of the response area beyond the element's bounding box. + * This tuple specifies the horizontal and vertical margins to be added to the element's [x, y, width, height]. + * + * The first value represents the horizontal extension (added to both left and right sides), + * and the second value represents the vertical extension (added to both top and bottom sides). + * + * The response area is computed as: + * `[x - horizontal, y - vertical, width + 2 * horizontal, height + 2 * vertical]`. + * + * Example: + * - Bounding box: `[0, 0, 100, 100]`, `responseExtension: [10, 20]` + * Resulting response area: `[-10, -20, 120, 140]`. + * - `responseExtension: [0, 0]` keeps the response area equal to the bounding box. + */ + responseExtension: [number, number] = [0, 0]; + + rotate = 0; + + get deserializedXYWH() { + if (this._cacheDeserKey !== this.xywh || !this._cacheDeserXYWH) { + this._cacheDeserKey = this.xywh; + this._cacheDeserXYWH = deserializeXYWH(this.xywh); + } + + return this._cacheDeserXYWH; + } + + get elementBound() { + return Bound.from(getBoundWithRotation(this)); + } + + get externalBound(): Bound | null { + return this._externalXYWH ? Bound.deserialize(this._externalXYWH) : null; + } + + get externalXYWH(): SerializedXYWH | undefined { + return this._externalXYWH; + } + + set externalXYWH(xywh: SerializedXYWH | undefined) { + this._externalXYWH = xywh; + } + + get group(): GfxGroupModel | null { + if (!this.surface) return null; + + return this.surface.getGroup(this.id) ?? null; + } + + get groups(): GfxGroupModel[] { + if (!this.surface) return []; + + return this.surface.getGroups(this.id); + } + + get h() { + return this.deserializedXYWH[3]; + } + + get responseBound() { + return this.elementBound.expand(this.responseExtension); + } + + get surface(): SurfaceBlockModel | null { + const result = this.doc.getBlocksByFlavour('affine:surface'); + if (result.length === 0) return null; + return result[0].model as SurfaceBlockModel; + } + + get w() { + return this.deserializedXYWH[2]; + } + + get x() { + return this.deserializedXYWH[0]; + } + + get y() { + return this.deserializedXYWH[1]; + } + + containsBound(bounds: Bound): boolean { + const bound = Bound.deserialize(this.xywh); + const points = getPointsFromBoundWithRotation({ + x: bound.x, + y: bound.y, + w: bound.w, + h: bound.h, + rotate: this.rotate, + }); + return points.some(point => bounds.containsPoint(point)); + } + + getLineIntersections(start: IVec, end: IVec): PointLocation[] | null { + const bound = Bound.deserialize(this.xywh); + + return linePolygonIntersects( + start, + end, + rotatePoints(bound.points, bound.center, this.rotate ?? 0) + ); + } + + getNearestPoint(point: IVec): IVec { + const bound = Bound.deserialize(this.xywh); + return polygonNearestPoint( + rotatePoints(bound.points, bound.center, this.rotate ?? 0), + point + ); + } + + getRelativePointLocation(relativePoint: IVec): PointLocation { + const bound = Bound.deserialize(this.xywh); + const point = bound.getRelativePoint(relativePoint); + const rotatePoint = rotatePoints( + [point], + bound.center, + this.rotate ?? 0 + )[0]; + const points = rotatePoints(bound.points, bound.center, this.rotate ?? 0); + const tangent = polygonGetPointTangent(points, rotatePoint); + + return new PointLocation(rotatePoint, tangent); + } + + includesPoint( + x: number, + y: number, + opt: PointTestOptions, + __: EditorHost + ): boolean { + const bound = opt.useElementBound ? this.elementBound : this.responseBound; + return bound.isPointInBound([x, y], 0); + } + + intersectsBound(bound: Bound): boolean { + return ( + this.containsBound(bound) || + bound.points.some((point, i, points) => + this.getLineIntersections(point, points[(i + 1) % points.length]) + ) + ); + } + + isLocked(): boolean { + return isLockedImpl(this); + } + + isLockedByAncestor(): boolean { + return isLockedByAncestorImpl(this); + } + + isLockedBySelf(): boolean { + return isLockedBySelfImpl(this); + } + + lock() { + lockElementImpl(this.doc, this); + } + + unlock() { + unlockElementImpl(this.doc, this); + } +} + +/** + * Convert a BlockModel to a GfxBlockElementModel. + * @param BlockModelSuperClass The BlockModel class to be converted. + * @returns The returned class is a subclass of the GfxBlockElementModel class and the given BlockModelSuperClass. + */ +export function GfxCompatibleBlockModel< + Props extends GfxCompatibleProps, + T extends Constructor<BlockModel<Props>> = Constructor<BlockModel<Props>>, +>(BlockModelSuperClass: T) { + if (BlockModelSuperClass === BlockModel) { + return GfxBlockElementModel as unknown as typeof GfxBlockElementModel<Props>; + } else { + let currentClass = BlockModelSuperClass; + + while ( + Object.getPrototypeOf(currentClass.prototype) !== BlockModel.prototype && + Object.getPrototypeOf(currentClass.prototype) !== null + ) { + currentClass = Object.getPrototypeOf(currentClass.prototype).constructor; + } + + if (Object.getPrototypeOf(currentClass.prototype) === null) { + throw new BlockSuiteError( + ErrorCode.GfxBlockElementError, + 'The SuperClass is not a subclass of BlockModel' + ); + } + + Object.setPrototypeOf( + currentClass.prototype, + GfxBlockElementModel.prototype + ); + } + + return BlockModelSuperClass as unknown as typeof GfxBlockElementModel<Props>; +} diff --git a/blocksuite/framework/block-std/src/gfx/model/model.ts b/blocksuite/framework/block-std/src/gfx/model/model.ts new file mode 100644 index 0000000000..0e6c38d577 --- /dev/null +++ b/blocksuite/framework/block-std/src/gfx/model/model.ts @@ -0,0 +1,12 @@ +import type { GfxGroupCompatibleInterface } from './base.js'; +import type { GfxBlockElementModel } from './gfx-block-model.js'; +import type { + GfxGroupLikeElementModel, + GfxPrimitiveElementModel, +} from './surface/element-model.js'; + +export type GfxModel = GfxBlockElementModel | GfxPrimitiveElementModel; + +export type GfxGroupModel = + | (GfxGroupCompatibleInterface & GfxBlockElementModel) + | GfxGroupLikeElementModel; diff --git a/blocksuite/framework/block-std/src/gfx/model/surface/decorators/common.ts b/blocksuite/framework/block-std/src/gfx/model/surface/decorators/common.ts new file mode 100644 index 0000000000..d75c94f83c --- /dev/null +++ b/blocksuite/framework/block-std/src/gfx/model/surface/decorators/common.ts @@ -0,0 +1,53 @@ +import type { SurfaceBlockModel } from '../surface-model.js'; + +/** + * Set metadata for a property + * @param symbol Unique symbol for the metadata + * @param target The target object to set metadata on, usually the prototype + * @param prop The property name + * @param val The value to set + */ +export function setObjectPropMeta( + symbol: symbol, + target: unknown, + prop: string | symbol, + val: unknown +) { + // @ts-expect-error FIXME: ts error + target[symbol] = target[symbol] ?? {}; + // @ts-expect-error FIXME: ts error + target[symbol][prop] = val; +} + +/** + * Get metadata for a property + * @param target The target object to retrieve metadata from, usually the prototype + * @param symbol Unique symbol for the metadata + * @param prop The property name, if not provided, returns all metadata for that symbol + * @returns + */ +export function getObjectPropMeta( + target: unknown, + symbol: symbol, + prop?: string | symbol +) { + if (prop) { + // @ts-expect-error FIXME: ts error + return target[symbol]?.[prop] ?? null; + } + + // @ts-expect-error FIXME: ts error + return target[symbol] ?? {}; +} + +export function getDecoratorState(surface: SurfaceBlockModel) { + return surface['_decoratorState']; +} + +export function createDecoratorState() { + return { + creating: false, + deriving: false, + skipField: false, + }; +} diff --git a/blocksuite/framework/block-std/src/gfx/model/surface/decorators/convert.ts b/blocksuite/framework/block-std/src/gfx/model/surface/decorators/convert.ts new file mode 100644 index 0000000000..5e646afd4d --- /dev/null +++ b/blocksuite/framework/block-std/src/gfx/model/surface/decorators/convert.ts @@ -0,0 +1,49 @@ +import type { GfxPrimitiveElementModel } from '../element-model.js'; +import { getObjectPropMeta, setObjectPropMeta } from './common.js'; + +const convertSymbol = Symbol('convert'); + +/** + * The convert decorator is used to convert the property value before it's + * set to the Y map. + * + * Note: + * 1. This decorator function will not execute in model initialization. + * @param fn + * @returns + */ +export function convert<V, T extends GfxPrimitiveElementModel>( + fn: (propValue: V, instance: T) => unknown +) { + return function convertDecorator( + _: unknown, + context: ClassAccessorDecoratorContext + ) { + const prop = String(context.name); + return { + init(this: T, v: V) { + const proto = Object.getPrototypeOf(this); + setObjectPropMeta(convertSymbol, proto, prop, fn); + return v; + }, + } as ClassAccessorDecoratorResult<T, V>; + }; +} + +function getConvertMeta( + proto: unknown, + prop: string | symbol +): null | ((propValue: unknown, instance: unknown) => unknown) { + return getObjectPropMeta(proto, convertSymbol, prop); +} + +export function convertProps( + propName: string | symbol, + propValue: unknown, + receiver: unknown +) { + const proto = Object.getPrototypeOf(receiver); + const convertFn = getConvertMeta(proto, propName as string)!; + + return convertFn ? convertFn(propValue, receiver) : propValue; +} diff --git a/blocksuite/framework/block-std/src/gfx/model/surface/decorators/derive.ts b/blocksuite/framework/block-std/src/gfx/model/surface/decorators/derive.ts new file mode 100644 index 0000000000..8beef84ef7 --- /dev/null +++ b/blocksuite/framework/block-std/src/gfx/model/surface/decorators/derive.ts @@ -0,0 +1,103 @@ +import type { GfxPrimitiveElementModel } from '../element-model.js'; +import { + getDecoratorState, + getObjectPropMeta, + setObjectPropMeta, +} from './common.js'; + +const deriveSymbol = Symbol('derive'); + +const keys = Object.keys; + +function getDerivedMeta( + proto: unknown, + prop: string | symbol +): + | null + | ((propValue: unknown, instance: unknown) => Record<string, unknown>)[] { + return getObjectPropMeta(proto, deriveSymbol, prop); +} + +export function getDerivedProps( + prop: string | symbol, + propValue: unknown, + receiver: GfxPrimitiveElementModel +) { + const prototype = Object.getPrototypeOf(receiver); + const decoratorState = getDecoratorState(receiver.surface); + + if (decoratorState.deriving || decoratorState.creating) { + return null; + } + + const deriveFns = getDerivedMeta(prototype, prop as string)!; + + return deriveFns + ? deriveFns.reduce( + (derivedProps, fn) => { + const props = fn(propValue, receiver); + + Object.entries(props).forEach(([key, value]) => { + derivedProps[key] = value; + }); + + return derivedProps; + }, + {} as Record<string, unknown> + ) + : null; +} + +export function updateDerivedProps( + derivedProps: Record<string, unknown> | null, + receiver: GfxPrimitiveElementModel +) { + if (derivedProps) { + const decoratorState = getDecoratorState(receiver.surface); + decoratorState.deriving = true; + keys(derivedProps).forEach(key => { + // @ts-expect-error FIXME: ts error + receiver[key] = derivedProps[key]; + }); + decoratorState.deriving = false; + } +} + +/** + * The derive decorator is used to derive other properties' update when the + * decorated property is updated through assignment in the local. + * + * Note: + * 1. The first argument of the function is the new value of the decorated property + * before the `convert` decorator is called. + * 2. The decorator function will execute after the decorated property has been updated. + * 3. The decorator function will not execute during model creation. + * 4. The decorator function will not execute if the decorated property is updated through + * the Y map. That is to say, if other peers update the property will not trigger this decorator + * @param fn + * @returns + */ +export function derive<V, T extends GfxPrimitiveElementModel>( + fn: (propValue: any, instance: T) => Record<string, unknown> +) { + return function deriveDecorator( + _: unknown, + context: ClassAccessorDecoratorContext + ) { + const prop = String(context.name); + return { + init(this: GfxPrimitiveElementModel, v: V) { + const proto = Object.getPrototypeOf(this); + const derived = getDerivedMeta(proto, prop); + + if (Array.isArray(derived)) { + derived.push(fn as (typeof derived)[0]); + } else { + setObjectPropMeta(deriveSymbol, proto, prop as string, [fn]); + } + + return v; + }, + } as ClassAccessorDecoratorResult<GfxPrimitiveElementModel, V>; + }; +} diff --git a/blocksuite/framework/block-std/src/gfx/model/surface/decorators/field.ts b/blocksuite/framework/block-std/src/gfx/model/surface/decorators/field.ts new file mode 100644 index 0000000000..2d0c829f68 --- /dev/null +++ b/blocksuite/framework/block-std/src/gfx/model/surface/decorators/field.ts @@ -0,0 +1,88 @@ +import type { GfxPrimitiveElementModel } from '../element-model.js'; +import { getDecoratorState } from './common.js'; +import { convertProps } from './convert.js'; +import { getDerivedProps, updateDerivedProps } from './derive.js'; +import { startObserve } from './observer.js'; + +const yPropsSetSymbol = Symbol('yProps'); + +export function getFieldPropsSet(target: unknown): Set<string | symbol> { + const proto = Object.getPrototypeOf(target); + if (!Object.hasOwn(proto, yPropsSetSymbol)) { + proto[yPropsSetSymbol] = new Set(); + } + + return proto[yPropsSetSymbol] as Set<string | symbol>; +} + +export function field<V, T extends GfxPrimitiveElementModel>(fallback?: V) { + return function yDecorator( + _: ClassAccessorDecoratorTarget<T, V>, + context: ClassAccessorDecoratorContext + ) { + const prop = context.name; + + return { + init(this: GfxPrimitiveElementModel, v: V) { + const yProps = getFieldPropsSet(this); + + yProps.add(prop); + + if ( + getDecoratorState( + this.surface ?? Object.getPrototypeOf(this).constructor + )?.skipField + ) { + return; + } + + if (this.yMap) { + if (this.yMap.doc) { + this.surface.doc.transact(() => { + this.yMap.set(prop as string, v); + }); + } else { + this.yMap.set(prop as string, v); + this._preserved.set(prop as string, v); + } + } + + return v; + }, + get(this: GfxPrimitiveElementModel) { + return ( + (this.yMap.doc ? this.yMap.get(prop as string) : null) ?? + this._preserved.get(prop as string) ?? + fallback + ); + }, + set(this: T, originalVal: V) { + const isCreating = getDecoratorState(this.surface)?.creating; + + if (getDecoratorState(this.surface)?.skipField) { + return; + } + + const derivedProps = getDerivedProps(prop, originalVal, this); + const val = isCreating + ? originalVal + : convertProps(prop, originalVal, this); + + if (this.yMap.doc) { + this.surface.doc.transact(() => { + this.yMap.set(prop as string, val); + }); + } else { + this.yMap.set(prop as string, val); + this._preserved.set(prop as string, val); + } + + startObserve(prop as string, this); + + if (!isCreating) { + updateDerivedProps(derivedProps, this); + } + }, + } as ClassAccessorDecoratorResult<T, V>; + }; +} diff --git a/blocksuite/framework/block-std/src/gfx/model/surface/decorators/index.ts b/blocksuite/framework/block-std/src/gfx/model/surface/decorators/index.ts new file mode 100644 index 0000000000..ecf8a6d55c --- /dev/null +++ b/blocksuite/framework/block-std/src/gfx/model/surface/decorators/index.ts @@ -0,0 +1,6 @@ +export { convert, convertProps } from './convert.js'; +export { derive, getDerivedProps, updateDerivedProps } from './derive.js'; +export { field, getFieldPropsSet } from './field.js'; +export { local } from './local.js'; +export { initializeObservers, observe } from './observer.js'; +export { initializeWatchers, watch } from './watch.js'; diff --git a/blocksuite/framework/block-std/src/gfx/model/surface/decorators/local.ts b/blocksuite/framework/block-std/src/gfx/model/surface/decorators/local.ts new file mode 100644 index 0000000000..84e6464567 --- /dev/null +++ b/blocksuite/framework/block-std/src/gfx/model/surface/decorators/local.ts @@ -0,0 +1,58 @@ +import type { GfxPrimitiveElementModel } from '../element-model.js'; +import { getDecoratorState } from './common.js'; +import { convertProps } from './convert.js'; +import { getDerivedProps, updateDerivedProps } from './derive.js'; + +/** + * A decorator to mark the property as a local property. + * + * The local property act like it is a field property, but it's not synced to the Y map. + * Updating local property will also trigger the `elementUpdated` slot of the surface model + */ +export function local<V, T extends GfxPrimitiveElementModel>() { + return function localDecorator( + _target: ClassAccessorDecoratorTarget<T, V>, + context: ClassAccessorDecoratorContext + ) { + const prop = context.name; + + return { + init(this: T, v: V) { + this._local.set(prop, v); + + return v; + }, + get(this: T) { + return this._local.get(prop); + }, + set(this: T, originalValue: unknown) { + const isCreating = getDecoratorState(this.surface)?.creating; + const oldValue = this._local.get(prop); + // When state is creating, the value is considered as default value + // hence there's no need to convert it + const newVal = isCreating + ? originalValue + : convertProps(prop, originalValue, this); + + const derivedProps = getDerivedProps(prop, originalValue, this); + + this._local.set(prop, newVal); + + // During creating, no need to invoke an update event and derive another update + if (!isCreating) { + updateDerivedProps(derivedProps, this); + + this._onChange({ + props: { + [prop]: newVal, + }, + oldValues: { + [prop]: oldValue, + }, + local: true, + }); + } + }, + } as ClassAccessorDecoratorResult<T, V>; + }; +} diff --git a/blocksuite/framework/block-std/src/gfx/model/surface/decorators/observer.ts b/blocksuite/framework/block-std/src/gfx/model/surface/decorators/observer.ts new file mode 100644 index 0000000000..821dee43bc --- /dev/null +++ b/blocksuite/framework/block-std/src/gfx/model/surface/decorators/observer.ts @@ -0,0 +1,122 @@ +import type { Y } from '@blocksuite/store'; + +import type { GfxPrimitiveElementModel } from '../element-model.js'; +import { getObjectPropMeta, setObjectPropMeta } from './common.js'; + +const observeSymbol = Symbol('observe'); +const observerDisposableSymbol = Symbol('observerDisposable'); + +type ObserveFn< + E extends Y.YEvent<any> = Y.YEvent<any>, + T extends GfxPrimitiveElementModel = GfxPrimitiveElementModel, +> = ( + /** + * The event object of the Y.Map or Y.Array, the `null` value means the observer is initializing. + */ + event: E | null, + instance: T, + /** + * The transaction object of the Y.Map or Y.Array, the `null` value means the observer is initializing. + */ + transaction: Y.Transaction | null +) => void; + +/** + * A decorator to observe the y type property. + * You can think of it is just a decorator version of 'observe' method of Y.Array and Y.Map. + * + * The observer function start to observe the property when the model is mounted. And it will + * re-observe the property automatically when the value is altered. + * @param fn + * @returns + */ +export function observe< + V, + E extends Y.YEvent<any>, + T extends GfxPrimitiveElementModel, +>(fn: ObserveFn<E, T>) { + return function observeDecorator( + _: unknown, + context: ClassAccessorDecoratorContext + ) { + const prop = context.name; + return { + init(this: T, v: V) { + setObjectPropMeta(observeSymbol, Object.getPrototypeOf(this), prop, fn); + return v; + }, + } as ClassAccessorDecoratorResult<GfxPrimitiveElementModel, V>; + }; +} + +function getObserveMeta( + proto: unknown, + prop: string | symbol +): null | ObserveFn { + return getObjectPropMeta(proto, observeSymbol, prop); +} + +export function startObserve( + prop: string | symbol, + receiver: GfxPrimitiveElementModel +) { + const proto = Object.getPrototypeOf(receiver); + const observeFn = getObserveMeta(proto, prop as string)!; + // @ts-expect-error FIXME: ts error + const observerDisposable = receiver[observerDisposableSymbol] ?? {}; + + // @ts-expect-error FIXME: ts error + receiver[observerDisposableSymbol] = observerDisposable; + + if (observerDisposable[prop]) { + observerDisposable[prop](); + delete observerDisposable[prop]; + } + + if (!observeFn) { + return; + } + + const value = receiver[prop as keyof GfxPrimitiveElementModel] as + | Y.Map<unknown> + | Y.Array<unknown> + | null; + + observeFn(null, receiver, null); + + const fn = (event: Y.YEvent<any>, transaction: Y.Transaction) => { + observeFn(event, receiver, transaction); + }; + + if (value && 'observe' in value) { + value.observe(fn); + + observerDisposable[prop] = () => { + value.unobserve(fn); + }; + } else { + console.warn( + `Failed to observe "${prop.toString()}" of ${ + receiver.type + } element, make sure it's a Y type.` + ); + } +} + +export function initializeObservers( + proto: unknown, + receiver: GfxPrimitiveElementModel +) { + const observers = getObjectPropMeta(proto, observeSymbol); + + Object.keys(observers).forEach(prop => { + startObserve(prop, receiver); + }); + + receiver['_disposable'].add(() => { + // @ts-expect-error FIXME: ts error + Object.values(receiver[observerDisposableSymbol] ?? {}).forEach(dispose => + (dispose as () => void)() + ); + }); +} diff --git a/blocksuite/framework/block-std/src/gfx/model/surface/decorators/watch.ts b/blocksuite/framework/block-std/src/gfx/model/surface/decorators/watch.ts new file mode 100644 index 0000000000..aa87c37bc6 --- /dev/null +++ b/blocksuite/framework/block-std/src/gfx/model/surface/decorators/watch.ts @@ -0,0 +1,59 @@ +import type { GfxPrimitiveElementModel } from '../element-model.js'; +import { getObjectPropMeta, setObjectPropMeta } from './common.js'; + +type WatchFn<T extends GfxPrimitiveElementModel = GfxPrimitiveElementModel> = ( + oldValue: unknown, + instance: T, + local: boolean +) => void; + +const watchSymbol = Symbol('watch'); + +/** + * The watch decorator is used to watch the property change of the element. + * You can thinks of it as a decorator version of `elementUpdated` slot of the surface model. + */ +export function watch<V, T extends GfxPrimitiveElementModel>(fn: WatchFn<T>) { + return function watchDecorator( + _: unknown, + context: ClassAccessorDecoratorContext + ) { + const prop = context.name; + return { + init(this: GfxPrimitiveElementModel, v: V) { + setObjectPropMeta(watchSymbol, Object.getPrototypeOf(this), prop, fn); + return v; + }, + } as ClassAccessorDecoratorResult<GfxPrimitiveElementModel, V>; + }; +} + +function getWatchMeta(proto: unknown, prop: string | symbol): null | WatchFn { + return getObjectPropMeta(proto, watchSymbol, prop); +} + +function startWatch(prop: string | symbol, receiver: GfxPrimitiveElementModel) { + const proto = Object.getPrototypeOf(receiver); + const watchFn = getWatchMeta(proto, prop as string)!; + + if (!watchFn) return; + + receiver['_disposable'].add( + receiver.surface.elementUpdated.on(payload => { + if (payload.id === receiver.id && prop in payload.props) { + watchFn(payload.oldValues[prop as string], receiver, payload.local); + } + }) + ); +} + +export function initializeWatchers( + prototype: unknown, + receiver: GfxPrimitiveElementModel +) { + const watchers = getObjectPropMeta(prototype, watchSymbol); + + Object.keys(watchers).forEach(prop => { + startWatch(prop, receiver); + }); +} diff --git a/blocksuite/framework/block-std/src/gfx/model/surface/element-model.ts b/blocksuite/framework/block-std/src/gfx/model/surface/element-model.ts new file mode 100644 index 0000000000..1c5a1c5d25 --- /dev/null +++ b/blocksuite/framework/block-std/src/gfx/model/surface/element-model.ts @@ -0,0 +1,604 @@ +import { + Bound, + deserializeXYWH, + DisposableGroup, + getBoundWithRotation, + getPointsFromBoundWithRotation, + isEqual, + type IVec, + linePolygonIntersects, + PointLocation, + polygonGetPointTangent, + polygonNearestPoint, + randomSeed, + rotatePoints, + type SerializedXYWH, + Slot, + type XYWH, +} from '@blocksuite/global/utils'; +import { DocCollection, type Y } from '@blocksuite/store'; +import { createMutex } from 'lib0/mutex'; + +import { + descendantElementsImpl, + hasDescendantElementImpl, + isLockedByAncestorImpl, + isLockedBySelfImpl, + isLockedImpl, + lockElementImpl, + unlockElementImpl, +} from '../../../utils/tree.js'; +import type { EditorHost } from '../../../view/index.js'; +import type { + GfxCompatibleInterface, + GfxGroupCompatibleInterface, + PointTestOptions, +} from '../base.js'; +import { gfxGroupCompatibleSymbol } from '../base.js'; +import type { GfxBlockElementModel } from '../gfx-block-model.js'; +import type { GfxGroupModel, GfxModel } from '../model.js'; +import { + convertProps, + field, + getDerivedProps, + getFieldPropsSet, + local, + updateDerivedProps, + watch, +} from './decorators/index.js'; +import type { SurfaceBlockModel } from './surface-model.js'; + +export type BaseElementProps = { + index: string; + seed: number; + lockedBySelf?: boolean; +}; + +export type SerializedElement = Record<string, unknown> & { + type: string; + xywh: SerializedXYWH; + id: string; + index: string; + lockedBySelf?: boolean; + props: Record<string, unknown>; +}; +export abstract class GfxPrimitiveElementModel< + Props extends BaseElementProps = BaseElementProps, +> implements GfxCompatibleInterface +{ + private _lastXYWH!: SerializedXYWH; + + protected _disposable = new DisposableGroup(); + + protected _id: string; + + protected _local = new Map<string | symbol, unknown>(); + + protected _onChange: (payload: { + props: Record<string, unknown>; + oldValues: Record<string, unknown>; + local: boolean; + }) => void; + + /** + * Used to store a copy of data in the yMap. + */ + protected _preserved = new Map<string, unknown>(); + + protected _stashed: Map<keyof Props | string, unknown>; + + propsUpdated = new Slot<{ key: string }>(); + + abstract rotate: number; + + surface!: SurfaceBlockModel; + + abstract xywh: SerializedXYWH; + + yMap: Y.Map<unknown>; + + get connectable() { + return true; + } + + get deserializedXYWH() { + if (!this._lastXYWH || this.xywh !== this._lastXYWH) { + const xywh = this.xywh; + this._local.set('deserializedXYWH', deserializeXYWH(xywh)); + this._lastXYWH = xywh; + } + + return (this._local.get('deserializedXYWH') as XYWH) ?? [0, 0, 0, 0]; + } + + /** + * The bound of the element after rotation. + * The bound without rotation should be created by `Bound.deserialize(this.xywh)`. + */ + get elementBound() { + if (this.rotate) { + return Bound.from(getBoundWithRotation(this)); + } + + return Bound.deserialize(this.xywh); + } + + get externalBound(): Bound | null { + if (!this._local.has('externalBound')) { + const bound = this.externalXYWH + ? Bound.deserialize(this.externalXYWH) + : null; + + this._local.set('externalBound', bound); + } + + return this._local.get('externalBound') as Bound | null; + } + + get group(): GfxGroupModel | null { + return this.surface.getGroup(this.id); + } + + /** + * Return the ancestor elements in order from the most recent to the earliest. + */ + get groups(): GfxGroupModel[] { + return this.surface.getGroups(this.id); + } + + get h() { + return this.deserializedXYWH[3]; + } + + get id() { + return this._id; + } + + get isConnected() { + return this.surface.hasElementById(this.id); + } + + get responseBound() { + return this.elementBound.expand(this.responseExtension); + } + + abstract get type(): string; + + get w() { + return this.deserializedXYWH[2]; + } + + get x() { + return this.deserializedXYWH[0]; + } + + get y() { + return this.deserializedXYWH[1]; + } + + constructor(options: { + id: string; + yMap: Y.Map<unknown>; + model: SurfaceBlockModel; + stashedStore: Map<unknown, unknown>; + onChange: (payload: { + props: Record<string, unknown>; + oldValues: Record<string, unknown>; + local: boolean; + }) => void; + }) { + const { id, yMap, model, stashedStore, onChange } = options; + + this._id = id; + this.yMap = yMap; + this.surface = model; + this._stashed = stashedStore as Map<keyof Props, unknown>; + this._onChange = onChange; + + this.index = 'a0'; + this.seed = randomSeed(); + } + + static propsToY(props: Record<string, unknown>) { + return props; + } + + containsBound(bounds: Bound): boolean { + return getPointsFromBoundWithRotation(this).some(point => + bounds.containsPoint(point) + ); + } + + getLineIntersections(start: IVec, end: IVec) { + const points = getPointsFromBoundWithRotation(this); + return linePolygonIntersects(start, end, points); + } + + getNearestPoint(point: IVec) { + const points = getPointsFromBoundWithRotation(this); + return polygonNearestPoint(points, point); + } + + getRelativePointLocation(relativePoint: IVec) { + const bound = Bound.deserialize(this.xywh); + const point = bound.getRelativePoint(relativePoint); + const rotatePoint = rotatePoints([point], bound.center, this.rotate)[0]; + const points = rotatePoints(bound.points, bound.center, this.rotate); + const tangent = polygonGetPointTangent(points, rotatePoint); + return new PointLocation(rotatePoint, tangent); + } + + includesPoint( + x: number, + y: number, + opt: PointTestOptions, + __: EditorHost + ): boolean { + const bound = opt.useElementBound ? this.elementBound : this.responseBound; + return bound.isPointInBound([x, y]); + } + + intersectsBound(bound: Bound): boolean { + return ( + this.containsBound(bound) || + bound.points.some((point, i, points) => + this.getLineIntersections(point, points[(i + 1) % points.length]) + ) + ); + } + + isLocked(): boolean { + return isLockedImpl(this); + } + + isLockedByAncestor(): boolean { + return isLockedByAncestorImpl(this); + } + + isLockedBySelf(): boolean { + return isLockedBySelfImpl(this); + } + + lock() { + lockElementImpl(this.surface.doc, this); + } + + onCreated() {} + + onDestroyed() { + this._disposable.dispose(); + this.propsUpdated.dispose(); + } + + pop(prop: keyof Props | string) { + if (!this._stashed.has(prop)) { + return; + } + + const value = this._stashed.get(prop); + this._stashed.delete(prop); + // @ts-expect-error FIXME: ts error + delete this[prop]; + + if (getFieldPropsSet(this).has(prop as string)) { + if (!isEqual(value, this.yMap.get(prop as string))) { + this.surface.doc.transact(() => { + this.yMap.set(prop as string, value); + }); + } + } else { + console.warn('pop a prop that is not field or local:', prop); + } + } + + serialize() { + const result = this.yMap.toJSON(); + result.xywh = this.xywh; + return result as SerializedElement; + } + + stash(prop: keyof Props | string) { + if (this._stashed.has(prop)) { + return; + } + + if (!getFieldPropsSet(this).has(prop as string)) { + return; + } + + const curVal = this[prop as unknown as keyof GfxPrimitiveElementModel]; + + this._stashed.set(prop, curVal); + + Object.defineProperty(this, prop, { + configurable: true, + enumerable: true, + get: () => this._stashed.get(prop), + set: (original: unknown) => { + const value = convertProps(prop as string, original, this); + const oldValue = this._stashed.get(prop); + const derivedProps = getDerivedProps( + prop as string, + original, + this as unknown as GfxPrimitiveElementModel + ); + + this._stashed.set(prop, value); + this._onChange({ + props: { + [prop]: value, + }, + oldValues: { + [prop]: oldValue, + }, + local: true, + }); + + updateDerivedProps( + derivedProps, + this as unknown as GfxPrimitiveElementModel + ); + }, + }); + } + + unlock() { + unlockElementImpl(this.surface.doc, this); + } + + @local() + accessor display: boolean = true; + + /** + * In some cases, you need to draw something related to the element, but it does not belong to the element itself. + * And it is also interactive, you can select element by clicking on it. E.g. the title of the group element. + * In this case, we need to store this kind of external xywh in order to do hit test. This property should not be synced to the doc. + * This property should be updated every time it gets rendered. + */ + @watch((_, instance) => { + instance['_local'].delete('externalBound'); + }) + @local() + accessor externalXYWH: SerializedXYWH | undefined = undefined; + + @field(false) + accessor hidden: boolean = false; + + @field() + accessor index!: string; + + @field() + accessor lockedBySelf: boolean | undefined = false; + + @local() + accessor opacity: number = 1; + + @local() + accessor responseExtension: [number, number] = [0, 0]; + + @field() + accessor seed!: number; +} + +export abstract class GfxGroupLikeElementModel< + Props extends BaseElementProps = BaseElementProps, + > + extends GfxPrimitiveElementModel<Props> + implements GfxGroupCompatibleInterface +{ + private _childIds: string[] = []; + + private _mutex = createMutex(); + + abstract children: Y.Map<any>; + + [gfxGroupCompatibleSymbol] = true as const; + + get childElements() { + const elements: GfxModel[] = []; + + for (const key of this.childIds) { + const element = + this.surface.getElementById(key) || + (this.surface.doc.getBlockById(key) as GfxBlockElementModel); + + element && elements.push(element); + } + + return elements; + } + + /** + * The ids of the children. Its role is to provide a unique way to access the children. + * You should update this field through `setChildIds` when the children are added or removed. + */ + get childIds() { + return this._childIds; + } + + get descendantElements(): GfxModel[] { + return descendantElementsImpl(this); + } + + get xywh() { + this._mutex(() => { + const curXYWH = + (this._local.get('xywh') as SerializedXYWH) ?? '[0,0,0,0]'; + const newXYWH = this._getXYWH().serialize(); + + if (curXYWH !== newXYWH || !this._local.has('xywh')) { + this._local.set('xywh', newXYWH); + + if (curXYWH !== newXYWH) { + this._onChange({ + props: { + xywh: newXYWH, + }, + oldValues: { + xywh: curXYWH, + }, + local: true, + }); + } + } + }); + + return (this._local.get('xywh') as SerializedXYWH) ?? '[0,0,0,0]'; + } + + set xywh(_) {} + + protected _getXYWH(): Bound { + let bound: Bound | undefined; + + this.childElements.forEach(child => { + if (child instanceof GfxPrimitiveElementModel && child.hidden) { + return; + } + + bound = bound ? bound.unite(child.elementBound) : child.elementBound; + }); + + if (bound) { + this._local.set('xywh', bound.serialize()); + } else { + this._local.delete('xywh'); + } + + return bound ?? new Bound(0, 0, 0, 0); + } + + abstract addChild(element: GfxModel): void; + + /** + * The actual field that stores the children of the group. + * It should be a ymap decorated with `@field`. + */ + hasChild(element: GfxCompatibleInterface) { + return this.childElements.includes(element as GfxModel); + } + + /** + * Check if the group has the given descendant. + */ + hasDescendant(element: GfxCompatibleInterface): boolean { + return hasDescendantElementImpl(this, element); + } + + /** + * Remove the child from the group + */ + abstract removeChild(element: GfxCompatibleInterface): void; + + /** + * Set the new value of the childIds + * @param value the new value of the childIds + * @param fromLocal if true, the change is happened in the local + */ + setChildIds(value: string[], fromLocal: boolean) { + const oldChildIds = this.childIds; + this._childIds = value; + + this._onChange({ + props: { + childIds: value, + }, + oldValues: { + childIds: oldChildIds, + }, + local: fromLocal, + }); + } +} + +export function syncElementFromY( + model: GfxPrimitiveElementModel, + callback: (payload: { + props: Record<string, unknown>; + oldValues: Record<string, unknown>; + local: boolean; + }) => void +) { + const disposables: Record<string, () => void> = {}; + const observer = ( + event: Y.YMapEvent<unknown>, + transaction: Y.Transaction + ) => { + const props: Record<string, unknown> = {}; + const oldValues: Record<string, unknown> = {}; + + event.keysChanged.forEach(key => { + const type = event.changes.keys.get(key); + const oldValue = event.changes.keys.get(key)?.oldValue; + + if (!type) { + return; + } + + if (type.action === 'update' || type.action === 'add') { + const value = model.yMap.get(key); + + if (value instanceof DocCollection.Y.Text) { + disposables[key]?.(); + disposables[key] = watchText(key, value, callback); + } + + model['_preserved'].set(key, value); + props[key] = value; + oldValues[key] = oldValue; + } else { + model['_preserved'].delete(key); + oldValues[key] = oldValue; + } + }); + + callback({ + props, + oldValues, + local: transaction.local, + }); + }; + + Array.from(model.yMap.entries()).forEach(([key, value]) => { + if (value instanceof DocCollection.Y.Text) { + disposables[key] = watchText(key, value, callback); + } + + model['_preserved'].set(key, value); + }); + + model.yMap.observe(observer); + disposables['ymap'] = () => { + model.yMap.unobserve(observer); + }; + + return () => { + Object.values(disposables).forEach(fn => fn()); + }; +} + +function watchText( + key: string, + value: Y.Text, + callback: (payload: { + props: Record<string, unknown>; + oldValues: Record<string, unknown>; + local: boolean; + }) => void +) { + const fn = (_: Y.YTextEvent, transaction: Y.Transaction) => { + callback({ + props: { + [key]: value, + }, + oldValues: {}, + local: transaction.local, + }); + }; + + value.observe(fn); + + return () => { + value.unobserve(fn); + }; +} diff --git a/blocksuite/framework/block-std/src/gfx/model/surface/local-element-model.ts b/blocksuite/framework/block-std/src/gfx/model/surface/local-element-model.ts new file mode 100644 index 0000000000..31f7638b2a --- /dev/null +++ b/blocksuite/framework/block-std/src/gfx/model/surface/local-element-model.ts @@ -0,0 +1,252 @@ +import type { IVec, SerializedXYWH, XYWH } from '@blocksuite/global/utils'; +import { + Bound, + deserializeXYWH, + getPointsFromBoundWithRotation, + linePolygonIntersects, + PointLocation, + polygonGetPointTangent, + polygonNearestPoint, + rotatePoints, +} from '@blocksuite/global/utils'; +import { mutex } from 'lib0'; + +import type { EditorHost } from '../../../view/index.js'; +import type { GfxCompatibleInterface, PointTestOptions } from '../base.js'; +import type { GfxGroupModel } from '../model.js'; +import type { SurfaceBlockModel } from './surface-model.js'; + +export function prop<V, T extends GfxLocalElementModel>() { + return function propDecorator( + _target: ClassAccessorDecoratorTarget<T, V>, + context: ClassAccessorDecoratorContext + ) { + const prop = context.name; + + return { + init(this: T, val: unknown) { + this._props.add(prop); + this._local.set(prop, val); + }, + get(this: T) { + return this._local.get(prop); + }, + set(this: T, val: V) { + this._local.set(prop, val); + }, + } as ClassAccessorDecoratorResult<T, V>; + }; +} + +export abstract class GfxLocalElementModel implements GfxCompatibleInterface { + private _mutex: mutex.mutex = mutex.createMutex(); + + protected _local = new Map<string | symbol, unknown>(); + + /** + * Used to store all the name of the properties that have been decorated + * with the `@prop` + */ + protected _props = new Set<string | symbol>(); + + protected _surface: SurfaceBlockModel; + + /** + * used to store the properties' cache key + * when the properties required heavy computation + */ + cache = new Map<string | symbol, unknown>(); + + id: string = ''; + + abstract readonly type: string; + + get deserializedXYWH() { + if (!this._local.has('deserializedXYWH')) { + const xywh = this.xywh; + const deserialized = deserializeXYWH(xywh); + + this._local.set('deserializedXYWH', deserialized); + } + + return this._local.get('deserializedXYWH') as XYWH; + } + + get elementBound() { + return new Bound(this.x, this.y, this.w, this.h); + } + + get group() { + return ( + this.groupId ? this._surface.getElementById(this.groupId) : null + ) as GfxGroupModel | null; + } + + get groups() { + if (this.group) { + const groups = this._surface.getGroups(this.group.id); + groups.unshift(this.group); + + return groups; + } + + return []; + } + + get h() { + return this.deserializedXYWH[3]; + } + + get responseBound() { + return this.elementBound.expand(this.responseExtension); + } + + get surface() { + return this._surface; + } + + get w() { + return this.deserializedXYWH[2]; + } + + get x() { + return this.deserializedXYWH[0]; + } + + get y() { + return this.deserializedXYWH[1]; + } + + constructor(surfaceModel: SurfaceBlockModel) { + this._surface = surfaceModel; + + const p = new Proxy(this, { + set: (target, prop, value) => { + if (prop === 'xywh') { + this._local.delete('deserializedXYWH'); + } + + // @ts-expect-error FIXME: ts error + const oldValue = target[prop as string]; + + if (oldValue === value) { + return true; + } + + // @ts-expect-error FIXME: ts error + target[prop as string] = value; + + if (!this._props.has(prop)) { + return true; + } + + if (surfaceModel.localElementModels.has(p)) { + this._mutex(() => { + surfaceModel.localElementUpdated.emit({ + model: p, + props: { + [prop as string]: value, + }, + oldValues: { + [prop as string]: oldValue, + }, + }); + }); + } + + return true; + }, + }); + + // eslint-disable-next-line no-constructor-return + return p; + } + + containsBound(bounds: Bound): boolean { + return getPointsFromBoundWithRotation(this).some(point => + bounds.containsPoint(point) + ); + } + + getLineIntersections(start: IVec, end: IVec) { + const points = getPointsFromBoundWithRotation(this); + return linePolygonIntersects(start, end, points); + } + + getNearestPoint(point: IVec) { + const points = getPointsFromBoundWithRotation(this); + return polygonNearestPoint(points, point); + } + + getRelativePointLocation(relativePoint: IVec) { + const bound = Bound.deserialize(this.xywh); + const point = bound.getRelativePoint(relativePoint); + const rotatePoint = rotatePoints([point], bound.center, this.rotate)[0]; + const points = rotatePoints(bound.points, bound.center, this.rotate); + const tangent = polygonGetPointTangent(points, rotatePoint); + return new PointLocation(rotatePoint, tangent); + } + + includesPoint( + x: number, + y: number, + opt: PointTestOptions, + __: EditorHost + ): boolean { + const bound = opt.useElementBound ? this.elementBound : this.responseBound; + return bound.isPointInBound([x, y]); + } + + intersectsBound(bound: Bound): boolean { + return ( + this.containsBound(bound) || + bound.points.some((point, i, points) => + this.getLineIntersections(point, points[(i + 1) % points.length]) + ) + ); + } + + isLocked() { + return false; + } + + isLockedByAncestor() { + return false; + } + + isLockedBySelf() { + return false; + } + + lock() { + return; + } + + unlock() { + return; + } + + @prop() + accessor groupId: string = ''; + + @prop() + accessor hidden: boolean = false; + + @prop() + accessor index: string = 'a0'; + + @prop() + accessor opacity: number = 1; + + @prop() + accessor responseExtension: [number, number] = [0, 0]; + + @prop() + accessor rotate: number = 0; + + @prop() + accessor seed: number = Math.random(); + + @prop() + accessor xywh: SerializedXYWH = '[0,0,0,0]'; +} diff --git a/blocksuite/framework/block-std/src/gfx/model/surface/surface-model.ts b/blocksuite/framework/block-std/src/gfx/model/surface/surface-model.ts new file mode 100644 index 0000000000..0060866a10 --- /dev/null +++ b/blocksuite/framework/block-std/src/gfx/model/surface/surface-model.ts @@ -0,0 +1,620 @@ +import { assertType, type Constructor, Slot } from '@blocksuite/global/utils'; +import type { Boxed, Y } from '@blocksuite/store'; +import { BlockModel, DocCollection, nanoid } from '@blocksuite/store'; + +import { + type GfxGroupCompatibleInterface, + isGfxGroupCompatibleModel, +} from '../base.js'; +import type { GfxGroupModel, GfxModel } from '../model.js'; +import { createDecoratorState } from './decorators/common.js'; +import { initializeObservers, initializeWatchers } from './decorators/index.js'; +import { + GfxGroupLikeElementModel, + GfxPrimitiveElementModel, + syncElementFromY, +} from './element-model.js'; +import type { GfxLocalElementModel } from './local-element-model.js'; + +export type SurfaceBlockProps = { + elements: Boxed<Y.Map<Y.Map<unknown>>>; +}; + +export interface ElementUpdatedData { + id: string; + props: Record<string, unknown>; + oldValues: Record<string, unknown>; + local: boolean; +} + +export type MiddlewareCtx = { + type: 'beforeAdd'; + payload: { + type: string; + props: Record<string, unknown>; + }; +}; + +export type SurfaceMiddleware = (ctx: MiddlewareCtx) => void; + +export class SurfaceBlockModel extends BlockModel<SurfaceBlockProps> { + protected _decoratorState = createDecoratorState(); + + protected _elementCtorMap: Record< + string, + Constructor< + GfxPrimitiveElementModel, + ConstructorParameters<typeof GfxPrimitiveElementModel> + > + > = Object.create(null); + + protected _elementModels = new Map< + string, + { + mount: () => void; + unmount: () => void; + model: GfxPrimitiveElementModel; + } + >(); + + protected _elementTypeMap = new Map<string, GfxPrimitiveElementModel[]>(); + + protected _groupLikeModels = new Map<string, GfxGroupModel>(); + + protected _middlewares: SurfaceMiddleware[] = []; + + protected _surfaceBlockModel = true; + + elementAdded = new Slot<{ id: string; local: boolean }>(); + + elementRemoved = new Slot<{ + id: string; + type: string; + model: GfxPrimitiveElementModel; + local: boolean; + }>(); + + elementUpdated = new Slot<ElementUpdatedData>(); + + localElementAdded = new Slot<GfxLocalElementModel>(); + + localElementDeleted = new Slot<GfxLocalElementModel>(); + + protected localElements = new Set<GfxLocalElementModel>(); + + localElementUpdated = new Slot<{ + model: GfxLocalElementModel; + props: Record<string, unknown>; + oldValues: Record<string, unknown>; + }>(); + + get elementModels() { + const models: GfxPrimitiveElementModel[] = []; + this._elementModels.forEach(model => models.push(model.model)); + return models; + } + + get localElementModels() { + return this.localElements; + } + + get registeredElementTypes() { + return Object.keys(this._elementCtorMap); + } + + constructor() { + super(); + this.created.once(() => this._init()); + } + + private _createElementFromProps( + props: Record<string, unknown>, + options: { + onChange: (payload: { + id: string; + props: Record<string, unknown>; + oldValues: Record<string, unknown>; + local: boolean; + }) => void; + } + ) { + const { type, id, ...rest } = props; + + if (!id) { + throw new Error('Cannot find id in props'); + } + + const yMap = new DocCollection.Y.Map(); + const elementModel = this._createElementFromYMap( + type as string, + id as string, + yMap, + { + ...options, + newCreate: true, + } + ); + + props = this._propsToY(type as string, props); + + yMap.set('type', type); + yMap.set('id', id); + + Object.keys(rest).forEach(key => { + if (props[key] !== undefined) { + // @ts-expect-error FIXME: ts error + elementModel.model[key] = props[key]; + } + }); + + return elementModel; + } + + private _createElementFromYMap( + type: string, + id: string, + yMap: Y.Map<unknown>, + options: { + onChange: (payload: { + id: string; + props: Record<string, unknown>; + oldValues: Record<string, unknown>; + local: boolean; + }) => void; + skipFieldInit?: boolean; + newCreate?: boolean; + } + ) { + const stashed = new Map<string | symbol, unknown>(); + const Ctor = this._elementCtorMap[type]; + + if (!Ctor) { + throw new Error(`Invalid element type: ${yMap.get('type')}`); + } + const state = this._decoratorState; + + state.creating = true; + state.skipField = options.skipFieldInit ?? false; + + let mounted = false; + // @ts-expect-error FIXME: ts error + Ctor['_decoratorState'] = state; + + const elementModel = new Ctor({ + id, + yMap, + model: this, + stashedStore: stashed, + onChange: payload => mounted && options.onChange({ id, ...payload }), + }) as GfxPrimitiveElementModel; + + // @ts-expect-error FIXME: ts error + delete Ctor['_decoratorState']; + state.creating = false; + state.skipField = false; + + const unmount = () => { + mounted = false; + elementModel.onDestroyed(); + }; + + const mount = () => { + initializeObservers(Ctor.prototype, elementModel); + initializeWatchers(Ctor.prototype, elementModel); + elementModel['_disposable'].add( + syncElementFromY(elementModel, payload => { + mounted && + options.onChange({ + id, + ...payload, + }); + }) + ); + mounted = true; + elementModel.onCreated(); + }; + + return { + model: elementModel, + mount, + unmount, + }; + } + + private _initElementModels() { + const elementsYMap = this.elements.getValue()!; + const addToType = (type: string, model: GfxPrimitiveElementModel) => { + const sameTypeElements = this._elementTypeMap.get(type) || []; + + if (sameTypeElements.indexOf(model) === -1) { + sameTypeElements.push(model); + } + + this._elementTypeMap.set(type, sameTypeElements); + + if (isGfxGroupCompatibleModel(model)) { + this._groupLikeModels.set(model.id, model); + } + }; + const removeFromType = (type: string, model: GfxPrimitiveElementModel) => { + const sameTypeElements = this._elementTypeMap.get(type) || []; + const index = sameTypeElements.indexOf(model); + + if (index !== -1) { + sameTypeElements.splice(index, 1); + } + + if (this._groupLikeModels.has(model.id)) { + this._groupLikeModels.delete(model.id); + } + }; + const onElementsMapChange = ( + event: Y.YMapEvent<Y.Map<unknown>>, + transaction: Y.Transaction + ) => { + const { changes, keysChanged } = event; + const addedElements: { + mount: () => void; + model: GfxPrimitiveElementModel; + }[] = []; + const deletedElements: { + unmount: () => void; + model: GfxPrimitiveElementModel; + }[] = []; + + keysChanged.forEach(id => { + const change = changes.keys.get(id); + const element = this.elements.getValue()!.get(id); + + switch (change?.action) { + case 'add': + if (element) { + const hasModel = this._elementModels.has(id); + const model = hasModel + ? this._elementModels.get(id)! + : this._createElementFromYMap( + element.get('type') as string, + element.get('id') as string, + element, + { + onChange: payload => { + this.elementUpdated.emit(payload); + Object.keys(payload.props).forEach(key => { + model.model.propsUpdated.emit({ key }); + }); + }, + skipFieldInit: true, + } + ); + + !hasModel && this._elementModels.set(id, model); + addToType(model.model.type, model.model); + addedElements.push(model); + } + break; + case 'delete': + if (this._elementModels.has(id)) { + const { model, unmount } = this._elementModels.get(id)!; + removeFromType(model.type, model); + this._elementModels.delete(id); + deletedElements.push({ model, unmount }); + } + break; + } + }); + + addedElements.forEach(({ mount, model }) => { + mount(); + this.elementAdded.emit({ id: model.id, local: transaction.local }); + }); + deletedElements.forEach(({ unmount, model }) => { + unmount(); + this.elementRemoved.emit({ + id: model.id, + type: model.type, + model, + local: transaction.local, + }); + }); + }; + + elementsYMap.forEach((val, key) => { + const model = this._createElementFromYMap( + val.get('type') as string, + val.get('id') as string, + val, + { + onChange: payload => { + this.elementUpdated.emit(payload), + Object.keys(payload.props).forEach(key => { + model.model.propsUpdated.emit({ key }); + }); + }, + skipFieldInit: true, + } + ); + + this._elementModels.set(key, model); + }); + + this._elementModels.forEach(({ mount, model }) => { + addToType(model.type, model); + mount(); + }); + + Object.values(this.doc.blocks.peek()).forEach(block => { + if (isGfxGroupCompatibleModel(block.model)) { + this._groupLikeModels.set(block.id, block.model); + } + }); + + elementsYMap.observe(onElementsMapChange); + + const disposable = this.doc.slots.blockUpdated.on(payload => { + switch (payload.type) { + case 'add': + if (isGfxGroupCompatibleModel(payload.model)) { + this._groupLikeModels.set(payload.id, payload.model); + } + break; + case 'delete': + if (isGfxGroupCompatibleModel(payload.model)) { + this._groupLikeModels.delete(payload.id); + } + { + const group = this.getGroup(payload.id); + if (group) { + // eslint-disable-next-line unicorn/prefer-dom-node-remove + group.removeChild(payload.model as GfxModel); + } + } + break; + } + }); + + this.deleted.on(() => { + elementsYMap.unobserve(onElementsMapChange); + disposable.dispose(); + }); + } + + private _propsToY(type: string, props: Record<string, unknown>) { + const ctor = this._elementCtorMap[type]; + + if (!ctor) { + throw new Error(`Invalid element type: ${type}`); + } + + // @ts-expect-error FIXME: ts error + return (ctor.propsToY ?? GfxPrimitiveElementModel.propsToY)(props); + } + + private _watchGroupRelationChange() { + const isGroup = ( + element: GfxPrimitiveElementModel + ): element is GfxGroupLikeElementModel => + element instanceof GfxGroupLikeElementModel; + + const disposable = this.elementUpdated.on(({ id, oldValues }) => { + const element = this.getElementById(id)!; + + if ( + isGroup(element) && + oldValues['childIds'] && + element.childIds.length === 0 + ) { + this.deleteElement(id); + } + }); + this.deleted.on(() => { + disposable.dispose(); + }); + } + + protected _extendElement( + ctorMap: Record< + string, + Constructor< + GfxPrimitiveElementModel, + ConstructorParameters<typeof GfxPrimitiveElementModel> + > + > + ) { + Object.assign(this._elementCtorMap, ctorMap); + } + + protected _init() { + this._initElementModels(); + this._watchGroupRelationChange(); + } + + addElement<T extends object = Record<string, unknown>>( + props: Partial<T> & { type: string } + ) { + if (this.doc.readonly) { + throw new Error('Cannot add element in readonly mode'); + } + + const middlewareCtx: MiddlewareCtx = { + type: 'beforeAdd', + payload: { + type: props.type, + props, + }, + }; + + this._middlewares.forEach(mid => mid(middlewareCtx)); + + props = middlewareCtx.payload.props as Partial<T> & { type: string }; + + const id = nanoid(); + + // @ts-expect-error FIXME: ts error + props.id = id; + + const elementModel = this._createElementFromProps(props, { + onChange: payload => { + this.elementUpdated.emit(payload); + Object.keys(payload.props).forEach(key => { + elementModel.model.propsUpdated.emit({ key }); + }); + }, + }); + + this._elementModels.set(id, elementModel); + + this.doc.transact(() => { + this.elements.getValue()!.set(id, elementModel.model.yMap); + }); + + return id; + } + + addLocalElement(elem: GfxLocalElementModel) { + this.localElements.add(elem); + this.localElementAdded.emit(elem); + } + + applyMiddlewares(middlewares: SurfaceMiddleware[]) { + this._middlewares = middlewares; + } + + deleteElement(id: string) { + if (this.doc.readonly) { + throw new Error('Cannot remove element in readonly mode'); + } + + if (!this.hasElementById(id)) { + return; + } + + this.doc.transact(() => { + const element = this.getElementById(id)!; + const group = this.getGroup(id); + + if (element instanceof GfxGroupLikeElementModel) { + element.childIds.forEach(childId => { + if (this.hasElementById(childId)) { + this.deleteElement(childId); + } else if (this.doc.hasBlock(childId)) { + this.doc.deleteBlock(this.doc.getBlock(childId)!.model); + } + }); + } + + // eslint-disable-next-line unicorn/prefer-dom-node-remove + group?.removeChild(element as GfxModel); + + this.elements.getValue()!.delete(id); + }); + } + + deleteLocalElement(elem: GfxLocalElementModel) { + if (this.localElements.delete(elem)) { + this.localElementDeleted.emit(elem); + } + } + + override dispose(): void { + super.dispose(); + + this.elementAdded.dispose(); + this.elementRemoved.dispose(); + this.elementUpdated.dispose(); + + this._elementModels.forEach(({ unmount }) => unmount()); + this._elementModels.clear(); + } + + getElementById(id: string): GfxPrimitiveElementModel | null { + return this._elementModels.get(id)?.model ?? null; + } + + getElementsByType(type: string): GfxPrimitiveElementModel[] { + return this._elementTypeMap.get(type) || []; + } + + getGroup(elem: string | GfxModel): GfxGroupModel | null { + elem = + typeof elem === 'string' + ? ((this.getElementById(elem) ?? + this.doc.getBlock(elem)?.model) as GfxModel) + : elem; + + if (!elem) return null; + + assertType<GfxModel>(elem); + + for (const group of this._groupLikeModels.values()) { + if (group.hasChild(elem)) { + return group; + } + } + + return null; + } + + getGroups(id: string): GfxGroupModel[] { + const groups: GfxGroupModel[] = []; + const visited = new Set<GfxGroupModel>(); + let group = this.getGroup(id); + + while (group) { + if (visited.has(group)) { + console.warn('Exists a cycle in group relation'); + break; + } + visited.add(group); + groups.push(group); + group = this.getGroup(group.id); + } + + return groups; + } + + hasElementById(id: string): boolean { + return this._elementModels.has(id); + } + + isGroup(element: GfxModel): element is GfxModel & GfxGroupCompatibleInterface; + isGroup(id: string): boolean; + isGroup(element: string | GfxModel): boolean { + if (typeof element === 'string') { + const el = this.getElementById(element); + if (el) return isGfxGroupCompatibleModel(el); + + const blockModel = this.doc.getBlock(element)?.model; + if (blockModel) return isGfxGroupCompatibleModel(blockModel); + + return false; + } else { + return isGfxGroupCompatibleModel(element); + } + } + + updateElement<T extends object = Record<string, unknown>>( + id: string, + props: Partial<T> + ) { + if (this.doc.readonly) { + throw new Error('Cannot update element in readonly mode'); + } + + const elementModel = this.getElementById(id); + + if (!elementModel) { + throw new Error(`Element ${id} is not found`); + } + + this.doc.transact(() => { + props = this._propsToY( + elementModel.type, + props as Record<string, unknown> + ) as T; + Object.entries(props).forEach(([key, value]) => { + // @ts-expect-error FIXME: ts error + elementModel[key] = value; + }); + }); + } +} diff --git a/blocksuite/framework/block-std/src/gfx/selection.ts b/blocksuite/framework/block-std/src/gfx/selection.ts new file mode 100644 index 0000000000..387e3c5c12 --- /dev/null +++ b/blocksuite/framework/block-std/src/gfx/selection.ts @@ -0,0 +1,385 @@ +import { + assertType, + DisposableGroup, + getCommonBoundWithRotation, + groupBy, + type IPoint, + Slot, +} from '@blocksuite/global/utils'; + +import type { CursorSelection, SurfaceSelection } from '../selection/index.js'; +import type { GfxController } from './controller.js'; +import { GfxExtension, GfxExtensionIdentifier } from './extension.js'; +import type { GfxModel } from './model/model.js'; +import { GfxGroupLikeElementModel } from './model/surface/element-model.js'; + +export interface SurfaceSelectionState { + /** + * The selected elements. Could be blocks or canvas elements + */ + elements: string[]; + + /** + * Indicate whether the selected element is in editing mode + */ + editing?: boolean; + + /** + * Cannot be operated, only box is displayed + */ + inoperable?: boolean; +} + +/** + * GfxSelectionManager is just a wrapper of std selection providing + * convenient method and states in gfx + */ +export class GfxSelectionManager extends GfxExtension { + static override key = 'gfxSelection'; + + private _activeGroup: GfxGroupLikeElementModel | null = null; + + private _cursorSelection: CursorSelection | null = null; + + private _lastSurfaceSelections: SurfaceSelection[] = []; + + private _remoteCursorSelectionMap = new Map<number, CursorSelection>(); + + private _remoteSelectedSet = new Set<string>(); + + private _remoteSurfaceSelectionsMap = new Map<number, SurfaceSelection[]>(); + + private _selectedSet = new Set<string>(); + + private _surfaceSelections: SurfaceSelection[] = []; + + disposable: DisposableGroup = new DisposableGroup(); + + readonly slots = { + updated: new Slot<SurfaceSelection[]>(), + remoteUpdated: new Slot(), + + cursorUpdated: new Slot<CursorSelection>(), + remoteCursorUpdated: new Slot(), + }; + + get activeGroup() { + return this._activeGroup; + } + + get cursorSelection() { + return this._cursorSelection; + } + + get editing() { + return this.surfaceSelections.some(sel => sel.editing); + } + + get empty() { + return this.surfaceSelections.every(sel => sel.elements.length === 0); + } + + get firstElement() { + return this.selectedElements[0]; + } + + get inoperable() { + return this.surfaceSelections.some(sel => sel.inoperable); + } + + get lastSurfaceSelections() { + return this._lastSurfaceSelections; + } + + get remoteCursorSelectionMap() { + return this._remoteCursorSelectionMap; + } + + get remoteSelectedSet() { + return this._remoteSelectedSet; + } + + get remoteSurfaceSelectionsMap() { + return this._remoteSurfaceSelectionsMap; + } + + get selectedBound() { + return getCommonBoundWithRotation(this.selectedElements); + } + + get selectedElements() { + const elements: GfxModel[] = []; + + this.selectedIds.forEach(id => { + const el = this.gfx.getElementById(id) as GfxModel; + el && elements.push(el); + }); + + return elements; + } + + get selectedIds() { + return [...this._selectedSet]; + } + + get selectedSet() { + return this._selectedSet; + } + + get stdSelection() { + return this.std.selection; + } + + get surfaceModel() { + return this.gfx.surface; + } + + get surfaceSelections() { + return this._surfaceSelections; + } + + static override extendGfx(gfx: GfxController): void { + Object.defineProperty(gfx, 'selection', { + get() { + return this.std.get(GfxExtensionIdentifier('gfxSelection')); + }, + }); + } + + clear() { + this.stdSelection.clear(); + + this.set({ + elements: [], + editing: false, + }); + } + + clearLast() { + this._lastSurfaceSelections = []; + } + + equals(selection: SurfaceSelection[]) { + let count = 0; + let editing = false; + const exist = selection.every(sel => { + const exist = sel.elements.every(id => this._selectedSet.has(id)); + + if (exist) { + count += sel.elements.length; + } + + if (sel.editing) editing = true; + + return exist; + }); + + return ( + exist && count === this._selectedSet.size && editing === this.editing + ); + } + + /** + * check if the element is selected in local + * @param element + */ + has(element: string) { + return this._selectedSet.has(element); + } + + /** + * check if element is selected by remote peers + * @param element + */ + hasRemote(element: string) { + return this._remoteSelectedSet.has(element); + } + + isEmpty(selections: SurfaceSelection[]) { + return selections.every(sel => sel.elements.length === 0); + } + + isInSelectedRect(viewX: number, viewY: number) { + const selected = this.selectedElements; + if (!selected.length) return false; + + const commonBound = getCommonBoundWithRotation(selected); + const [modelX, modelY] = this.gfx.viewport.toModelCoord(viewX, viewY); + if (commonBound && commonBound.isPointInBound([modelX, modelY])) { + return true; + } + return false; + } + + override mounted() { + this.disposable.add( + this.stdSelection.slots.changed.on(selections => { + const { cursor = [], surface = [] } = groupBy(selections, sel => { + if (sel.is('surface')) { + return 'surface'; + } else if (sel.is('cursor')) { + return 'cursor'; + } + + return 'none'; + }); + + assertType<CursorSelection[]>(cursor); + assertType<SurfaceSelection[]>(surface); + + if (cursor[0] && !this.cursorSelection?.equals(cursor[0])) { + this._cursorSelection = cursor[0]; + this.slots.cursorUpdated.emit(cursor[0]); + } + + if ((surface.length === 0 && this.empty) || this.equals(surface)) { + return; + } + + this._lastSurfaceSelections = this.surfaceSelections; + this._surfaceSelections = surface; + this._selectedSet = new Set<string>(); + + surface.forEach(sel => + sel.elements.forEach(id => { + this._selectedSet.add(id); + }) + ); + + this.slots.updated.emit(this.surfaceSelections); + }) + ); + + this.disposable.add( + this.stdSelection.slots.remoteChanged.on(states => { + const surfaceMap = new Map<number, SurfaceSelection[]>(); + const cursorMap = new Map<number, CursorSelection>(); + const selectedSet = new Set<string>(); + + states.forEach((selections, id) => { + let hasTextSelection = false; + let hasBlockSelection = false; + + selections.forEach(selection => { + if (selection.is('text')) { + hasTextSelection = true; + } + + if (selection.is('block')) { + hasBlockSelection = true; + } + + if (selection.is('surface')) { + const surfaceSelections = surfaceMap.get(id) ?? []; + surfaceSelections.push(selection); + surfaceMap.set(id, surfaceSelections); + + selection.elements.forEach(id => selectedSet.add(id)); + } + + if (selection.is('cursor')) { + cursorMap.set(id, selection); + } + }); + + if (hasBlockSelection || hasTextSelection) { + surfaceMap.delete(id); + } + + if (hasTextSelection) { + cursorMap.delete(id); + } + }); + + this._remoteCursorSelectionMap = cursorMap; + this._remoteSurfaceSelectionsMap = surfaceMap; + this._remoteSelectedSet = selectedSet; + + this.slots.remoteUpdated.emit(); + this.slots.remoteCursorUpdated.emit(); + }) + ); + } + + set(selection: SurfaceSelectionState | SurfaceSelection[]) { + if (Array.isArray(selection)) { + this.stdSelection.setGroup( + 'gfx', + this.cursorSelection ? [...selection, this.cursorSelection] : selection + ); + return; + } + + const { blocks = [], elements = [] } = groupBy(selection.elements, id => { + return this.std.doc.getBlockById(id) ? 'blocks' : 'elements'; + }); + let instances: (SurfaceSelection | CursorSelection)[] = []; + + if (elements.length > 0 && this.surfaceModel) { + instances.push( + this.stdSelection.create( + 'surface', + this.surfaceModel.id, + elements, + selection.editing ?? false, + selection.inoperable + ) + ); + } + + if (blocks.length > 0) { + instances = instances.concat( + blocks.map(blockId => + this.stdSelection.create( + 'surface', + blockId, + [blockId], + selection.editing ?? false, + selection.inoperable + ) + ) + ); + } + + this.stdSelection.setGroup( + 'gfx', + this.cursorSelection + ? instances.concat([this.cursorSelection]) + : instances + ); + + if (instances.length > 0) { + this.stdSelection.setGroup('note', []); + } + + if ( + selection.elements.length === 1 && + this.firstElement instanceof GfxGroupLikeElementModel + ) { + this._activeGroup = this.firstElement; + } else { + if ( + this.selectedElements.some(ele => ele.group !== this._activeGroup) || + this.selectedElements.length === 0 + ) { + this._activeGroup = null; + } + } + } + + setCursor(cursor: CursorSelection | IPoint) { + const instance = this.stdSelection.create('cursor', cursor.x, cursor.y); + + this.stdSelection.setGroup('gfx', [...this.surfaceSelections, instance]); + } + + override unmounted() { + this.disposable.dispose(); + } +} + +declare module './controller.js' { + interface GfxController { + readonly selection: GfxSelectionManager; + } +} diff --git a/blocksuite/framework/block-std/src/gfx/surface-middleware.ts b/blocksuite/framework/block-std/src/gfx/surface-middleware.ts new file mode 100644 index 0000000000..c0be95e49d --- /dev/null +++ b/blocksuite/framework/block-std/src/gfx/surface-middleware.ts @@ -0,0 +1,61 @@ +import { type Container, createIdentifier } from '@blocksuite/global/di'; +import { BlockSuiteError, ErrorCode } from '@blocksuite/global/exceptions'; + +import { Extension } from '../extension/extension.js'; +import { LifeCycleWatcher } from '../extension/lifecycle-watcher.js'; +import { StdIdentifier } from '../identifier.js'; +import type { BlockStdScope } from '../scope/block-std-scope.js'; +import { onSurfaceAdded } from '../utils/gfx.js'; +import { GfxControllerIdentifier } from './identifiers.js'; +import type { SurfaceMiddleware } from './model/surface/surface-model.js'; + +export abstract class SurfaceMiddlewareBuilder extends Extension { + static key: string = ''; + + abstract middleware: SurfaceMiddleware; + + get gfx() { + return this.std.provider.get(GfxControllerIdentifier); + } + + constructor(protected std: BlockStdScope) { + super(); + } + + static override setup(di: Container) { + if (!this.key) { + throw new BlockSuiteError( + ErrorCode.ValueNotExists, + 'The surface middleware builder should have a static key property.' + ); + } + + di.addImpl(SurfaceMiddlewareBuilderIdentifier(this.key), this, [ + StdIdentifier, + ]); + } + + mounted(): void {} + + unmounted(): void {} +} + +export const SurfaceMiddlewareBuilderIdentifier = + createIdentifier<SurfaceMiddlewareBuilder>('SurfaceMiddlewareBuilder'); + +export class SurfaceMiddlewareExtension extends LifeCycleWatcher { + static override key: string = 'surfaceMiddleware'; + + override mounted(): void { + const builders = Array.from( + this.std.provider.getAll(SurfaceMiddlewareBuilderIdentifier).values() + ); + + const dispose = onSurfaceAdded(this.std.doc, surface => { + if (surface) { + surface.applyMiddlewares(builders.map(builder => builder.middleware)); + queueMicrotask(() => dispose()); + } + }); + } +} diff --git a/blocksuite/framework/block-std/src/gfx/tool/tool-controller.ts b/blocksuite/framework/block-std/src/gfx/tool/tool-controller.ts new file mode 100644 index 0000000000..7d72e95d64 --- /dev/null +++ b/blocksuite/framework/block-std/src/gfx/tool/tool-controller.ts @@ -0,0 +1,556 @@ +import type { ServiceIdentifier } from '@blocksuite/global/di'; +import { BlockSuiteError, ErrorCode } from '@blocksuite/global/exceptions'; +import { + DisposableGroup, + type IBound, + type IPoint, + Slot, +} from '@blocksuite/global/utils'; +import { Signal } from '@preact/signals-core'; + +import type { PointerEventState } from '../../event/index.js'; +import type { GfxController } from '../controller.js'; +import { GfxExtension, GfxExtensionIdentifier } from '../extension.js'; +import { + type BaseTool, + type GfxToolsFullOptionValue, + type GfxToolsMap, + type GfxToolsOption, + ToolIdentifier, +} from './tool.js'; + +type BuiltInHookEvent<T> = { + data: T; + preventDefault(): void; +}; + +type BuiltInEventMap = { + beforeToolUpdate: BuiltInHookEvent<{ + toolName: keyof GfxToolsMap; + }>; + toolUpdate: BuiltInHookEvent<{ toolName: keyof GfxToolsMap }>; +}; + +type BuiltInSlotContext = { + [K in keyof BuiltInEventMap]: { event: K } & BuiltInEventMap[K]; +}[SupportedHooks]; + +export type SupportedHooks = keyof BuiltInEventMap; + +const supportedEvents = [ + 'dragStart', + 'dragEnd', + 'dragMove', + 'pointerMove', + 'contextMenu', + 'pointerDown', + 'pointerUp', + 'click', + 'doubleClick', + 'tripleClick', + 'pointerOut', +] as const; + +export type SupportedEvents = (typeof supportedEvents)[number]; + +export enum MouseButton { + FIFTH = 4, + FOURTH = 3, + MAIN = 0, + MIDDLE = 1, + SECONDARY = 2, +} + +export interface ToolEventTarget { + /** + * Add a hook before the event is handled by the tool. + * Return false to prevent the tool from handling the event. + * @param evtName + * @param handler + */ + addHook<K extends SupportedHooks | SupportedEvents>( + evtName: K, + handler: ( + evtState: K extends SupportedHooks + ? BuiltInEventMap[K] + : PointerEventState + ) => void | boolean + ): void; +} + +export const eventTarget = Symbol('eventTarget'); + +export class ToolController extends GfxExtension { + static override key = 'ToolController'; + + private _builtInHookSlot = new Slot<BuiltInSlotContext>(); + + private _disposableGroup = new DisposableGroup(); + + private _toolOption$ = new Signal<GfxToolsFullOptionValue>( + {} as GfxToolsFullOptionValue + ); + + private _tools = new Map<string, BaseTool>(); + + readonly currentToolName$ = new Signal<keyof GfxToolsMap>(); + + readonly dragging$ = new Signal<boolean>(false); + + /** + * The area that is being dragged. + * The coordinates are in browser space. + */ + readonly draggingViewArea$ = new Signal< + IBound & { + startX: number; + startY: number; + endX: number; + endY: number; + } + >({ + startX: 0, + startY: 0, + x: 0, + y: 0, + w: 0, + h: 0, + endX: 0, + endY: 0, + }); + + /** + * The last mouse move position + * The coordinates are in browser space + */ + readonly lastMousePos$ = new Signal<IPoint>({ + x: 0, + y: 0, + }); + + get currentTool$() { + // eslint-disable-next-line @typescript-eslint/no-this-alias + const self = this; + + return { + get value() { + return self._tools.get(self.currentToolName$.value); + }, + peek() { + return self._tools.get(self.currentToolName$.peek()); + }, + }; + } + + get currentToolOption$() { + // eslint-disable-next-line @typescript-eslint/no-this-alias + const self = this; + + return { + peek() { + const option = self._toolOption$.peek() as unknown as { type: string }; + + if (!option.type) { + option.type = ''; + } + + return option as GfxToolsFullOptionValue; + }, + + get value(): GfxToolsFullOptionValue { + const option = self._toolOption$.value as unknown as { type: string }; + + if (!option.type) { + option.type = ''; + } + + return option as GfxToolsFullOptionValue; + }, + }; + } + + /** + * The area that is being dragged. + * The coordinates are in model space. + */ + get draggingArea$() { + const compute = (peek: boolean) => { + const area = peek + ? this.draggingViewArea$.peek() + : this.draggingViewArea$.value; + const [startX, startY] = this.gfx.viewport.toModelCoord( + area.startX, + area.startY + ); + const [endX, endY] = this.gfx.viewport.toModelCoord(area.endX, area.endY); + + return { + x: Math.min(startX, endX), + y: Math.min(startY, endY), + w: Math.abs(endX - startX), + h: Math.abs(endY - startY), + startX, + startY, + endX, + endY, + }; + }; + + return { + value() { + return compute(false); + }, + peek() { + return compute(true); + }, + }; + } + + static override extendGfx(gfx: GfxController) { + Object.defineProperty(gfx, 'tool', { + get() { + return this.std.provider.get(ToolControllerIdentifier); + }, + }); + } + + private _createBuiltInHookCtx<K extends keyof BuiltInEventMap>( + eventName: K, + data: BuiltInEventMap[K]['data'] + ): { + prevented: boolean; + slotCtx: BuiltInSlotContext; + } { + const ctx = { + prevented: false, + slotCtx: { + event: eventName, + data, + preventDefault() { + ctx.prevented = true; + }, + } as BuiltInSlotContext, + }; + + return ctx; + } + + private _initializeEvents() { + const hooks: Record< + string, + (( + evtState: PointerEventState | BuiltInSlotContext + ) => undefined | boolean)[] + > = {}; + /** + * Invoke the hook and the tool handler. + * @returns false if the handler is prevented by the hook + */ + const invokeToolHandler = ( + evtName: SupportedEvents, + evt: PointerEventState, + tool?: BaseTool + ) => { + const evtHooks = hooks[evtName]; + const stopHandler = evtHooks?.reduce((pre, hook) => { + return pre || hook(evt) === false; + }, false); + + tool = tool ?? this.currentTool$.peek(); + + if (stopHandler) { + return false; + } + + try { + tool?.[evtName](evt); + return true; + } catch (e) { + throw new BlockSuiteError( + ErrorCode.ExecutionError, + `Error occurred while executing ${evtName} handler of tool "${tool?.toolName}"`, + { + cause: e as Error, + } + ); + } + }; + + /** + * Hook into the event lifecycle. + * All hooks will be executed despite the current active tool. + * This is useful for tools that need to perform some action before an event is handled. + * @param evtName + * @param handler + */ + const addHook: ToolEventTarget['addHook'] = (evtName, handler) => { + hooks[evtName] = hooks[evtName] ?? []; + hooks[evtName].push( + handler as ( + evtState: PointerEventState | BuiltInSlotContext + ) => undefined | boolean + ); + + return () => { + const idx = hooks[evtName].indexOf( + handler as ( + evtState: PointerEventState | BuiltInSlotContext + ) => undefined | boolean + ); + if (idx !== -1) { + hooks[evtName].splice(idx, 1); + } + }; + }; + + let dragContext: { + tool: BaseTool; + } | null = null; + + this._disposableGroup.add( + this.std.event.add('dragStart', ctx => { + const evt = ctx.get('pointerState'); + + if ( + evt.button === MouseButton.SECONDARY && + !this.currentTool$.peek()?.allowDragWithRightButton + ) { + return; + } + + if (evt.button === MouseButton.MIDDLE) { + evt.raw.preventDefault(); + } + + this.dragging$.value = true; + this.draggingViewArea$.value = { + startX: evt.x, + startY: evt.y, + endX: evt.x, + endY: evt.y, + x: evt.x, + y: evt.y, + w: 0, + h: 0, + }; + + // this means the dragEnd event is not even fired + // so we need to manually call the dragEnd method + if (dragContext?.tool) { + dragContext.tool.dragEnd(evt); + dragContext = null; + } + + if (invokeToolHandler('dragStart', evt)) { + dragContext = this.currentTool$.peek() + ? { + tool: this.currentTool$.peek()!, + } + : null; + } + }) + ); + + this._disposableGroup.add( + this.std.event.add('dragMove', ctx => { + if (!this.dragging$.peek()) { + return; + } + + const evt = ctx.get('pointerState'); + const draggingStart = { + x: this.draggingArea$.peek().startX, + y: this.draggingArea$.peek().startY, + originX: this.draggingViewArea$.peek().startX, + originY: this.draggingViewArea$.peek().startY, + }; + + this.draggingViewArea$.value = { + ...this.draggingViewArea$.peek(), + w: Math.abs(evt.x - draggingStart.originX), + h: Math.abs(evt.y - draggingStart.originY), + x: Math.min(evt.x, draggingStart.originX), + y: Math.min(evt.y, draggingStart.originY), + endX: evt.x, + endY: evt.y, + }; + + invokeToolHandler('dragMove', evt, dragContext?.tool); + }) + ); + + this._disposableGroup.add( + this.std.event.add('dragEnd', ctx => { + if (!this.dragging$.peek()) { + return; + } + + this.dragging$.value = false; + const evt = ctx.get('pointerState'); + + // if the tool dragEnd is prevented by the hook, call the dragEnd method manually + // this guarantee the dragStart and dragEnd events are always called together + if ( + !invokeToolHandler('dragEnd', evt, dragContext?.tool) && + dragContext?.tool + ) { + dragContext.tool.dragEnd(evt); + } + + dragContext = null; + this.draggingViewArea$.value = { + x: 0, + y: 0, + startX: 0, + startY: 0, + endX: 0, + endY: 0, + w: 0, + h: 0, + }; + }) + ); + + this._disposableGroup.add( + this.std.event.add('pointerMove', ctx => { + const evt = ctx.get('pointerState'); + + this.lastMousePos$.value = { + x: evt.x, + y: evt.y, + }; + + invokeToolHandler('pointerMove', evt); + }) + ); + + this._disposableGroup.add( + this.std.event.add('contextMenu', ctx => { + const evt = ctx.get('defaultState'); + + // when in editing mode, allow context menu to pop up + if (this.gfx.selection.editing) return; + + evt.event.preventDefault(); + }) + ); + + supportedEvents.slice(5).forEach(evtName => { + this._disposableGroup.add( + this.std.event.add(evtName, ctx => { + const evt = ctx.get('pointerState'); + + invokeToolHandler(evtName, evt); + }) + ); + }); + + this._builtInHookSlot.on(evt => { + hooks[evt.event]?.forEach(hook => hook(evt)); + }); + + return { + addHook, + }; + } + + private _register(tools: BaseTool) { + if (this._tools.has(tools.toolName)) { + this._tools.get(tools.toolName)?.unmounted(); + } + + this._tools.set(tools.toolName, tools); + tools.mounted(); + } + + get<K extends keyof GfxToolsMap>(key: K): GfxToolsMap[K] { + return this._tools.get(key) as GfxToolsMap[K]; + } + + override mounted(): void { + const { addHook } = this._initializeEvents(); + + const eventTarget: ToolEventTarget = { + addHook, + }; + + this.std.provider.getAll(ToolIdentifier).forEach(tool => { + // @ts-expect-error FIXME: ts error + tool['eventTarget'] = eventTarget; + this._register(tool); + }); + } + + setTool(toolName: GfxToolsFullOptionValue, ...args: [void]): void; + setTool<K extends keyof GfxToolsMap>( + toolName: K, + ...args: K extends keyof GfxToolsOption + ? [option: GfxToolsOption[K]] + : [void] + ): void; + setTool<K extends keyof GfxToolsMap>( + toolName: K | GfxToolsFullOptionValue, + ...args: K extends keyof GfxToolsOption + ? [option: GfxToolsOption[K]] + : [void] + ): void { + const option = typeof toolName === 'string' ? args[0] : toolName; + const toolNameStr = + typeof toolName === 'string' + ? toolName + : ((toolName as { type: string }).type as K); + + const beforeUpdateCtx = this._createBuiltInHookCtx('beforeToolUpdate', { + toolName: toolNameStr, + }); + this._builtInHookSlot.emit(beforeUpdateCtx.slotCtx); + + if (beforeUpdateCtx.prevented) { + return; + } + + this.gfx.selection.set({ elements: [] }); + + this.currentTool$.peek()?.deactivate(); + this.currentToolName$.value = toolNameStr; + + const currentTool = this.currentTool$.peek(); + if (!currentTool) { + throw new BlockSuiteError( + ErrorCode.ValueNotExists, + `Tool "${this.currentToolName$.value}" is not defined` + ); + } + + currentTool.activatedOption = option ?? {}; + this._toolOption$.value = { + ...currentTool.activatedOption, + type: toolNameStr, + } as GfxToolsFullOptionValue; + currentTool.activate(currentTool.activatedOption); + + const afterUpdateCtx = this._createBuiltInHookCtx('toolUpdate', { + toolName: toolNameStr, + }); + this._builtInHookSlot.emit(afterUpdateCtx.slotCtx); + } + + override unmounted(): void { + this.currentTool$.peek()?.deactivate(); + this._tools.forEach(tool => { + tool.unmounted(); + tool['disposable'].dispose(); + }); + this._builtInHookSlot.dispose(); + } +} + +export const ToolControllerIdentifier = GfxExtensionIdentifier( + 'ToolController' +) as ServiceIdentifier<ToolController>; + +declare module '../controller.js' { + interface GfxController { + readonly tool: ToolController; + } +} diff --git a/blocksuite/framework/block-std/src/gfx/tool/tool.ts b/blocksuite/framework/block-std/src/gfx/tool/tool.ts new file mode 100644 index 0000000000..31af3c083a --- /dev/null +++ b/blocksuite/framework/block-std/src/gfx/tool/tool.ts @@ -0,0 +1,125 @@ +import { type Container, createIdentifier } from '@blocksuite/global/di'; +import { BlockSuiteError, ErrorCode } from '@blocksuite/global/exceptions'; +import { DisposableGroup } from '@blocksuite/global/utils'; + +import type { PointerEventState } from '../../event/index.js'; +import { Extension } from '../../extension/extension.js'; +import type { GfxController } from '../controller.js'; +import { GfxControllerIdentifier } from '../identifiers.js'; +import type { ToolEventTarget } from './tool-controller.js'; + +export abstract class BaseTool< + Option = Record<string, unknown>, +> extends Extension { + static toolName: string = ''; + + private readonly eventTarget!: ToolEventTarget; + + activatedOption: Option = {} as Option; + + addHook: ToolEventTarget['addHook'] = (evtName, handler) => { + this.eventTarget.addHook(evtName, handler); + }; + + /** + * The `disposable` will be disposed when the tool is unloaded. + */ + protected readonly disposable = new DisposableGroup(); + + get active() { + return this.gfx.tool.currentTool$.peek() === this; + } + + get allowDragWithRightButton() { + return false; + } + + get controller() { + return this.gfx.tool; + } + + get doc() { + return this.gfx.doc; + } + + get std() { + return this.gfx.std; + } + + get toolName() { + return (this.constructor as typeof BaseTool).toolName; + } + + constructor(readonly gfx: GfxController) { + super(); + } + + static override setup(di: Container): void { + if (!this.toolName) { + throw new BlockSuiteError( + ErrorCode.ValueNotExists, + `The tool constructor '${this.name}' should have a static 'toolName' property.` + ); + } + + di.addImpl(ToolIdentifier(this.toolName), this, [GfxControllerIdentifier]); + } + + /** + * Called when the tool is activated. + * @param _ - The data passed as second argument when calling `ToolController.use`. + */ + activate(_: Option): void {} + + click(_: PointerEventState): void {} + + contextMenu(_: PointerEventState): void {} + + /** + * Called when the tool is deactivated. + */ + deactivate(): void {} + + doubleClick(_: PointerEventState): void {} + + dragEnd(_: PointerEventState): void {} + + dragMove(_: PointerEventState): void {} + + dragStart(_: PointerEventState): void {} + + /** + * Called when the tool is registered. + */ + mounted(): void {} + + pointerDown(_: PointerEventState): void {} + + pointerMove(_: PointerEventState): void {} + + pointerOut(_: PointerEventState): void {} + + pointerUp(_: PointerEventState): void {} + + tripleClick(_: PointerEventState): void {} + + /** + * Called when the tool is unloaded, usually when the whole `ToolController` is destroyed. + */ + unmounted(): void {} +} + +export const ToolIdentifier = createIdentifier<BaseTool>('GfxTool'); + +export interface GfxToolsMap {} + +export interface GfxToolsOption {} + +export type GfxToolsFullOption = { + [Key in keyof GfxToolsMap]: Key extends keyof GfxToolsOption + ? { type: Key } & GfxToolsOption[Key] + : { type: Key }; +}; + +export type GfxToolsFullOptionValue = + GfxToolsFullOption[keyof GfxToolsFullOption]; diff --git a/blocksuite/framework/block-std/src/gfx/view/view-manager.ts b/blocksuite/framework/block-std/src/gfx/view/view-manager.ts new file mode 100644 index 0000000000..d3484a3401 --- /dev/null +++ b/blocksuite/framework/block-std/src/gfx/view/view-manager.ts @@ -0,0 +1,130 @@ +import { DisposableGroup } from '@blocksuite/global/utils'; + +import { onSurfaceAdded } from '../../utils/gfx.js'; +import type { GfxController } from '../controller.js'; +import { GfxExtension, GfxExtensionIdentifier } from '../extension.js'; +import { GfxBlockElementModel } from '../model/gfx-block-model.js'; +import type { GfxModel } from '../model/model.js'; +import type { GfxLocalElementModel } from '../model/surface/local-element-model.js'; +import type { SurfaceBlockModel } from '../model/surface/surface-model.js'; +import { + GfxElementModelView, + GfxElementModelViewExtIdentifier, +} from './view.js'; + +export class ViewManager extends GfxExtension { + static override key = 'viewManager'; + + private _disposable = new DisposableGroup(); + + private _viewCtorMap = new Map<string, typeof GfxElementModelView>(); + + private _viewMap = new Map<string, GfxElementModelView>(); + + constructor(gfx: GfxController) { + super(gfx); + } + + static override extendGfx(gfx: GfxController): void { + Object.defineProperty(gfx, 'view', { + get() { + return this.std.get(GfxExtensionIdentifier('viewManager')); + }, + }); + } + + get(model: GfxModel | GfxLocalElementModel | string) { + if (typeof model === 'string') { + if (this._viewMap.has(model)) { + return this._viewMap.get(model); + } + + return this.std.view.getBlock(model) ?? null; + } else { + if (model instanceof GfxBlockElementModel) { + return this.std.view.getBlock(model.id) ?? null; + } else { + return this._viewMap.get(model.id) ?? null; + } + } + } + + override mounted(): void { + this.std.provider + .getAll(GfxElementModelViewExtIdentifier) + .forEach(viewCtor => { + this._viewCtorMap.set(viewCtor.type, viewCtor); + }); + + const updateViewOnElementChange = (surface: SurfaceBlockModel) => { + this._disposable.add( + surface.elementAdded.on(payload => { + const model = surface.getElementById(payload.id)!; + const View = this._viewCtorMap.get(model.type) ?? GfxElementModelView; + + this._viewMap.set(model.id, new View(model, this.gfx)); + }) + ); + + this._disposable.add( + surface.elementRemoved.on(elem => { + const view = this._viewMap.get(elem.id); + this._viewMap.delete(elem.id); + view?.onDestroyed(); + }) + ); + + this._disposable.add( + surface.localElementAdded.on(model => { + const View = this._viewCtorMap.get(model.type) ?? GfxElementModelView; + + this._viewMap.set(model.id, new View(model, this.gfx)); + }) + ); + + this._disposable.add( + surface.localElementDeleted.on(model => { + const view = this._viewMap.get(model.id); + this._viewMap.delete(model.id); + view?.onDestroyed(); + }) + ); + + surface.localElementModels.forEach(model => { + const View = this._viewCtorMap.get(model.type) ?? GfxElementModelView; + + this._viewMap.set(model.id, new View(model, this.gfx)); + }); + + surface.elementModels.forEach(model => { + const View = this._viewCtorMap.get(model.type) ?? GfxElementModelView; + + this._viewMap.set(model.id, new View(model, this.gfx)); + }); + }; + + if (this.gfx.surface) { + updateViewOnElementChange(this.gfx.surface); + } else { + this._disposable.add( + onSurfaceAdded(this.std.doc, surface => { + if (surface) { + updateViewOnElementChange(surface); + } + }) + ); + } + } + + override unmounted(): void { + this._disposable.dispose(); + this._viewMap.forEach(view => view.onDestroyed()); + this._viewMap.clear(); + } +} + +declare module '../controller.js' { + interface GfxController { + readonly view: ViewManager; + } +} diff --git a/blocksuite/framework/block-std/src/gfx/view/view.ts b/blocksuite/framework/block-std/src/gfx/view/view.ts new file mode 100644 index 0000000000..8dac129765 --- /dev/null +++ b/blocksuite/framework/block-std/src/gfx/view/view.ts @@ -0,0 +1,181 @@ +import { type Container, createIdentifier } from '@blocksuite/global/di'; +import { BlockSuiteError, ErrorCode } from '@blocksuite/global/exceptions'; +import { + type Bound, + DisposableGroup, + type IVec, +} from '@blocksuite/global/utils'; + +import type { PointerEventState } from '../../event/index.js'; +import type { Extension } from '../../extension/extension.js'; +import type { EditorHost } from '../../view/index.js'; +import type { GfxController } from '../index.js'; +import type { GfxElementGeometry, PointTestOptions } from '../model/base.js'; +import type { GfxPrimitiveElementModel } from '../model/surface/element-model.js'; +import type { GfxLocalElementModel } from '../model/surface/local-element-model.js'; + +export type EventsHandlerMap = { + click: PointerEventState; + dblclick: PointerEventState; + pointerdown: PointerEventState; + pointerenter: PointerEventState; + pointerleave: PointerEventState; + pointermove: PointerEventState; + pointerup: PointerEventState; +}; + +export type SupportedEvent = keyof EventsHandlerMap; + +export const GfxElementModelViewExtIdentifier = createIdentifier< + typeof GfxElementModelView +>('GfxElementModelView'); + +export class GfxElementModelView< + T extends GfxLocalElementModel | GfxPrimitiveElementModel = + | GfxPrimitiveElementModel + | GfxLocalElementModel, + RendererContext = object, + > + implements GfxElementGeometry, Extension +{ + static type: string; + + private _handlers = new Map<keyof EventsHandlerMap, ((evt: any) => void)[]>(); + + private _isConnected = true; + + protected disposable = new DisposableGroup(); + + readonly model: T; + + get isConnected() { + return this._isConnected; + } + + get rotate() { + return this.model.rotate; + } + + get surface() { + return this.model.surface; + } + + get type() { + return this.model.type; + } + + constructor( + model: T, + readonly gfx: GfxController + ) { + this.model = model; + this.onCreated(); + } + + static setup(di: Container): void { + if (!this.type) { + throw new BlockSuiteError( + ErrorCode.ValueNotExists, + 'The GfxElementModelView should have a static `type` property.' + ); + } + + di.addImpl(GfxElementModelViewExtIdentifier(this.type), () => this); + } + + containsBound(bounds: Bound): boolean { + return this.model.containsBound(bounds); + } + + dispatch<K extends keyof EventsHandlerMap>( + event: K, + evt: EventsHandlerMap[K] + ) { + this._handlers.get(event)?.forEach(callback => callback(evt)); + } + + getLineIntersections(start: IVec, end: IVec) { + return this.model.getLineIntersections(start, end); + } + + getNearestPoint(point: IVec) { + return this.model.getNearestPoint(point); + } + + getRelativePointLocation(relativePoint: IVec) { + return this.model.getRelativePointLocation(relativePoint); + } + + includesPoint( + x: number, + y: number, + _: PointTestOptions, + __: EditorHost + ): boolean { + return this.model.includesPoint(x, y, _, __); + } + + intersectsBound(bound: Bound): boolean { + return ( + this.containsBound(bound) || + bound.points.some((point, i, points) => + this.getLineIntersections(point, points[(i + 1) % points.length]) + ) + ); + } + + off<K extends keyof EventsHandlerMap>( + event: K, + callback: (evt: EventsHandlerMap[K]) => void + ) { + if (!this._handlers.has(event)) { + return; + } + + const callbacks = this._handlers.get(event)!; + const index = callbacks.indexOf(callback); + + if (index !== -1) { + callbacks.splice(index, 1); + } + } + + on<K extends keyof EventsHandlerMap>( + event: K, + callback: (evt: EventsHandlerMap[K]) => void + ) { + if (!this._handlers.has(event)) { + this._handlers.set(event, []); + } + + this._handlers.get(event)!.push(callback); + + return () => this.off(event, callback); + } + + once<K extends keyof EventsHandlerMap>( + event: K, + callback: (evt: EventsHandlerMap[K]) => void + ) { + const off = this.on(event, evt => { + off(); + callback(evt); + }); + + return off; + } + + onCreated() {} + + /** + * Called when the view is destroyed. + * Override this method requires calling `super.onDestroyed()`. + */ + onDestroyed() { + this._isConnected = false; + this.disposable.dispose(); + this._handlers.clear(); + } + + render(_: RendererContext) {} +} diff --git a/blocksuite/framework/block-std/src/gfx/viewport-element.ts b/blocksuite/framework/block-std/src/gfx/viewport-element.ts new file mode 100644 index 0000000000..438a2abe6b --- /dev/null +++ b/blocksuite/framework/block-std/src/gfx/viewport-element.ts @@ -0,0 +1,159 @@ +import { WithDisposable } from '@blocksuite/global/utils'; +import { css, html } from 'lit'; +import { property } from 'lit/decorators.js'; + +import { PropTypes, requiredProperties } from '../view/decorators/required.js'; +import { type EditorHost, ShadowlessElement } from '../view/index.js'; +import type { GfxBlockElementModel } from './model/gfx-block-model.js'; +import { Viewport } from './viewport.js'; + +/** + * A wrapper around `requestConnectedFrame` that only calls at most once in one frame + */ +export function requestThrottledConnectedFrame< + T extends (...args: unknown[]) => void, +>(func: T, element?: HTMLElement): T { + let raqId: number | undefined = undefined; + let latestArgs: unknown[] = []; + + return ((...args: unknown[]) => { + latestArgs = args; + + if (raqId === undefined) { + raqId = requestAnimationFrame(() => { + raqId = undefined; + + if (!element || element.isConnected) { + func(...latestArgs); + } + }); + } + }) as T; +} + +@requiredProperties({ + viewport: PropTypes.instanceOf(Viewport), +}) +export class GfxViewportElement extends WithDisposable(ShadowlessElement) { + static override styles = css` + gfx-viewport { + position: absolute; + left: 0; + top: 0; + contain: size layout style; + display: block; + transform: none; + } + `; + + private _hideOutsideBlock = requestThrottledConnectedFrame(() => { + if (this.getModelsInViewport && this.host) { + const host = this.host; + const modelsInViewport = this.getModelsInViewport(); + + modelsInViewport.forEach(model => { + const view = host.std.view.getBlock(model.id); + + if (view) { + view.style.display = ''; + } + + if (this._lastVisibleModels?.has(model)) { + this._lastVisibleModels!.delete(model); + } + }); + + this._lastVisibleModels?.forEach(model => { + const view = host.std.view.getBlock(model.id); + + if (view) { + view.style.display = 'none'; + } + }); + + this._lastVisibleModels = modelsInViewport; + } + }, this); + + private _lastVisibleModels?: Set<GfxBlockElementModel>; + + private _pendingChildrenUpdates: { + id: string; + resolve: () => void; + }[] = []; + + private _refreshViewport = requestThrottledConnectedFrame(() => { + this._hideOutsideBlock(); + }, this); + + private _updatingChildrenFlag = false; + + renderingBlocks = new Set<string>(); + + override connectedCallback(): void { + super.connectedCallback(); + + const viewportUpdateCallback = () => { + this._refreshViewport(); + this._hideOutsideBlock(); + }; + + viewportUpdateCallback(); + this.disposables.add( + this.viewport.viewportUpdated.on(() => viewportUpdateCallback()) + ); + this.disposables.add( + this.viewport.sizeUpdated.on(() => viewportUpdateCallback()) + ); + } + + override render() { + return html``; + } + + scheduleUpdateChildren(id: string) { + const { promise, resolve } = Promise.withResolvers<void>(); + + this._pendingChildrenUpdates.push({ id, resolve }); + + if (!this._updatingChildrenFlag) { + this._updatingChildrenFlag = true; + const schedule = () => { + if (this._pendingChildrenUpdates.length) { + const childToUpdates = this._pendingChildrenUpdates.splice( + 0, + this.maxConcurrentRenders + ); + + childToUpdates.forEach(({ resolve }) => resolve()); + + if (this._pendingChildrenUpdates.length) { + requestAnimationFrame(() => { + this.isConnected && schedule(); + }); + } else { + this._updatingChildrenFlag = false; + } + } + }; + + requestAnimationFrame(() => { + this.isConnected && schedule(); + }); + } + + return promise; + } + + @property({ attribute: false }) + accessor getModelsInViewport: undefined | (() => Set<GfxBlockElementModel>); + + @property({ attribute: false }) + accessor host: undefined | EditorHost; + + @property({ type: Number }) + accessor maxConcurrentRenders: number = 2; + + @property({ attribute: false }) + accessor viewport!: Viewport; +} diff --git a/blocksuite/framework/block-std/src/gfx/viewport.ts b/blocksuite/framework/block-std/src/gfx/viewport.ts new file mode 100644 index 0000000000..6d73b8241d --- /dev/null +++ b/blocksuite/framework/block-std/src/gfx/viewport.ts @@ -0,0 +1,413 @@ +import { + Bound, + clamp, + type IPoint, + type IVec, + Slot, + Vec, +} from '@blocksuite/global/utils'; + +function cutoff(value: number, ref: number, sign: number) { + if (sign > 0 && value > ref) return ref; + if (sign < 0 && value < ref) return ref; + return value; +} + +export const ZOOM_MAX = 6.0; +export const ZOOM_MIN = 0.1; + +export class Viewport { + private _cachedBoundingClientRect: DOMRect | null = null; + + private _cachedOffsetWidth: number | null = null; + + private _resizeObserver: ResizeObserver | null = null; + + protected _center: IPoint = { x: 0, y: 0 }; + + protected _el: HTMLElement | null = null; + + protected _height = 0; + + protected _left = 0; + + protected _locked = false; + + protected _rafId: number | null = null; + + protected _top = 0; + + protected _width = 0; + + protected _zoom: number = 1.0; + + sizeUpdated = new Slot<{ + width: number; + height: number; + left: number; + top: number; + }>(); + + viewportMoved = new Slot<IVec>(); + + viewportUpdated = new Slot<{ zoom: number; center: IVec }>(); + + ZOOM_MAX = ZOOM_MAX; + + ZOOM_MIN = ZOOM_MIN; + + get boundingClientRect() { + if (!this._el) return new DOMRect(0, 0, 0, 0); + if (!this._cachedBoundingClientRect) { + this._cachedBoundingClientRect = this._el.getBoundingClientRect(); + } + return this._cachedBoundingClientRect; + } + + get center() { + return this._center; + } + + get centerX() { + return this._center.x; + } + + get centerY() { + return this._center.y; + } + + get height() { + return this.boundingClientRect.height; + } + + get left() { + return this._left; + } + + // Does not allow the user to move and zoom the canvas in copilot tool + get locked() { + return this._locked; + } + + set locked(locked: boolean) { + this._locked = locked; + } + + /** + * Note this is different from the zoom property. + * The editor itself may be scaled by outer container which is common in nested editor scenarios. + * This property is used to calculate the scale of the editor. + */ + get scale() { + if (!this._el || this._cachedOffsetWidth === null) return 1; + return this.boundingClientRect.width / this._cachedOffsetWidth; + } + + get top() { + return this._top; + } + + get translateX() { + return -this.viewportX * this.zoom; + } + + get translateY() { + return -this.viewportY * this.zoom; + } + + get viewportBounds() { + const { viewportMinXY, viewportMaxXY } = this; + + return Bound.from({ + ...viewportMinXY, + w: viewportMaxXY.x - viewportMinXY.x, + h: viewportMaxXY.y - viewportMinXY.y, + }); + } + + get viewportMaxXY() { + const { centerX, centerY, width, height, zoom } = this; + return { + x: centerX + width / 2 / zoom, + y: centerY + height / 2 / zoom, + }; + } + + get viewportMinXY() { + const { centerX, centerY, width, height, zoom } = this; + return { + x: centerX - width / 2 / zoom, + y: centerY - height / 2 / zoom, + }; + } + + get viewportX() { + const { centerX, width, zoom } = this; + return centerX - width / 2 / zoom; + } + + get viewportY() { + const { centerY, height, zoom } = this; + return centerY - height / 2 / zoom; + } + + get width() { + return this.boundingClientRect.width; + } + + get zoom() { + return this._zoom; + } + + applyDeltaCenter(deltaX: number, deltaY: number) { + this.setCenter(this.centerX + deltaX, this.centerY + deltaY); + } + + clearViewportElement() { + if (this._resizeObserver && this._el) { + this._resizeObserver.unobserve(this._el); + this._resizeObserver.disconnect(); + } + this._resizeObserver = null; + this._el = null; + this._cachedBoundingClientRect = null; + this._cachedOffsetWidth = null; + } + + dispose() { + this.clearViewportElement(); + this.sizeUpdated.dispose(); + this.viewportMoved.dispose(); + this.viewportUpdated.dispose(); + } + + getFitToScreenData( + bounds?: Bound | null, + padding: [number, number, number, number] = [0, 0, 0, 0], + maxZoom = ZOOM_MAX, + fitToScreenPadding = 100 + ) { + let { centerX, centerY, zoom } = this; + + if (!bounds) { + return { zoom, centerX, centerY }; + } + + const { x, y, w, h } = bounds; + const [pt, pr, pb, pl] = padding; + const { width, height } = this; + + zoom = Math.min( + (width - fitToScreenPadding - (pr + pl)) / w, + (height - fitToScreenPadding - (pt + pb)) / h + ); + zoom = clamp(zoom, ZOOM_MIN, clamp(maxZoom, ZOOM_MIN, ZOOM_MAX)); + + centerX = x + (w + pr / zoom) / 2 - pl / zoom / 2; + centerY = y + (h + pb / zoom) / 2 - pt / zoom / 2; + + return { zoom, centerX, centerY }; + } + + isInViewport(bound: Bound) { + const viewportBounds = Bound.from(this.viewportBounds); + return ( + viewportBounds.contains(bound) || + viewportBounds.isIntersectWithBound(bound) + ); + } + + onResize() { + if (!this._el) return; + const { centerX, centerY, zoom, width: oldWidth, height: oldHeight } = this; + const { left, top, width, height } = this.boundingClientRect; + this._cachedOffsetWidth = this._el.offsetWidth; + + this.setRect(left, top, width, height); + this.setCenter( + centerX - (oldWidth - width) / zoom / 2, + centerY - (oldHeight - height) / zoom / 2 + ); + + this._width = width; + this._height = height; + } + + setCenter(centerX: number, centerY: number) { + this._center.x = centerX; + this._center.y = centerY; + this.viewportUpdated.emit({ + zoom: this.zoom, + center: Vec.toVec(this.center) as IVec, + }); + } + + setRect(left: number, top: number, width: number, height: number) { + this._left = left; + this._top = top; + this.sizeUpdated.emit({ + left, + top, + width, + height, + }); + } + + setViewport( + newZoom: number, + newCenter = Vec.toVec(this.center), + smooth = false + ) { + const preZoom = this._zoom; + if (smooth) { + const cofficient = preZoom / newZoom; + if (cofficient === 1) { + this.smoothTranslate(newCenter[0], newCenter[1]); + } else { + const center = [this.centerX, this.centerY] as IVec; + const focusPoint = Vec.mul( + Vec.sub(newCenter, Vec.mul(center, cofficient)), + 1 / (1 - cofficient) + ); + this.smoothZoom(newZoom, Vec.toPoint(focusPoint)); + } + } else { + this._center.x = newCenter[0]; + this._center.y = newCenter[1]; + this.setZoom(newZoom); + } + } + + setViewportByBound( + bound: Bound, + padding: [number, number, number, number] = [0, 0, 0, 0], + smooth = false + ) { + const [pt, pr, pb, pl] = padding; + const zoom = clamp( + (this.width - (pr + pl)) / bound.w, + this.ZOOM_MIN, + (this.height - (pt + pb)) / bound.h + ); + const center = [ + bound.x + (bound.w + pr / zoom) / 2 - pl / zoom / 2, + bound.y + (bound.h + pb / zoom) / 2 - pt / zoom / 2, + ] as IVec; + + this.setViewport(zoom, center, smooth); + } + + setViewportElement(el: HTMLElement) { + this._el = el; + this._cachedBoundingClientRect = el.getBoundingClientRect(); + this._cachedOffsetWidth = el.offsetWidth; + + const { left, top, width, height } = this._cachedBoundingClientRect; + this.setRect(left, top, width, height); + + this._resizeObserver = new ResizeObserver(() => { + this._cachedBoundingClientRect = null; + this._cachedOffsetWidth = null; + this.onResize(); + }); + this._resizeObserver.observe(el); + } + + setZoom(zoom: number, focusPoint?: IPoint) { + const prevZoom = this.zoom; + focusPoint = (focusPoint ?? this._center) as IPoint; + this._zoom = clamp(zoom, this.ZOOM_MIN, this.ZOOM_MAX); + const newZoom = this.zoom; + + const offset = Vec.sub(Vec.toVec(this.center), Vec.toVec(focusPoint)); + const newCenter = Vec.add( + Vec.toVec(focusPoint), + Vec.mul(offset, prevZoom / newZoom) + ); + this.setCenter(newCenter[0], newCenter[1]); + this.viewportUpdated.emit({ + zoom: this.zoom, + center: Vec.toVec(this.center) as IVec, + }); + } + + smoothTranslate(x: number, y: number) { + const { center } = this; + const delta = { x: x - center.x, y: y - center.y }; + const innerSmoothTranslate = () => { + if (this._rafId) cancelAnimationFrame(this._rafId); + this._rafId = requestAnimationFrame(() => { + const rate = 10; + const step = { x: delta.x / rate, y: delta.y / rate }; + const nextCenter = { + x: this.centerX + step.x, + y: this.centerY + step.y, + }; + const signX = delta.x > 0 ? 1 : -1; + const signY = delta.y > 0 ? 1 : -1; + nextCenter.x = cutoff(nextCenter.x, x, signX); + nextCenter.y = cutoff(nextCenter.y, y, signY); + this.setCenter(nextCenter.x, nextCenter.y); + + if (nextCenter.x != x || nextCenter.y != y) innerSmoothTranslate(); + }); + }; + innerSmoothTranslate(); + } + + smoothZoom(zoom: number, focusPoint?: IPoint) { + const delta = zoom - this.zoom; + if (this._rafId) cancelAnimationFrame(this._rafId); + + const innerSmoothZoom = () => { + this._rafId = requestAnimationFrame(() => { + const sign = delta > 0 ? 1 : -1; + const total = 10; + const step = delta / total; + const nextZoom = cutoff(this.zoom + step, zoom, sign); + + this.setZoom(nextZoom, focusPoint); + + if (nextZoom != zoom) innerSmoothZoom(); + }); + }; + innerSmoothZoom(); + } + + toModelBound(bound: Bound) { + const { w, h } = bound; + const [x, y] = this.toModelCoord(bound.x, bound.y); + + return new Bound(x, y, w / this.zoom, h / this.zoom); + } + + toModelCoord(viewX: number, viewY: number): IVec { + const { viewportX, viewportY, zoom, scale } = this; + return [viewportX + viewX / zoom / scale, viewportY + viewY / zoom / scale]; + } + + toModelCoordFromClientCoord([x, y]: IVec): IVec { + const { left, top } = this; + return this.toModelCoord(x - left, y - top); + } + + toViewBound(bound: Bound) { + const { w, h } = bound; + const [x, y] = this.toViewCoord(bound.x, bound.y); + + return new Bound(x, y, w * this.zoom, h * this.zoom); + } + + toViewCoord(modelX: number, modelY: number): IVec { + const { viewportX, viewportY, zoom, scale } = this; + return [ + (modelX - viewportX) * zoom * scale, + (modelY - viewportY) * zoom * scale, + ]; + } + + toViewCoordFromClientCoord([x, y]: IVec): IVec { + const { left, top } = this; + return [x - left, y - top]; + } +} diff --git a/blocksuite/framework/block-std/src/identifier.ts b/blocksuite/framework/block-std/src/identifier.ts new file mode 100644 index 0000000000..4adf0e4d2d --- /dev/null +++ b/blocksuite/framework/block-std/src/identifier.ts @@ -0,0 +1,38 @@ +import { createIdentifier } from '@blocksuite/global/di'; + +import type { Command } from './command/index.js'; +import type { EventOptions, UIEventHandler } from './event/index.js'; +import type { BlockService, LifeCycleWatcher } from './extension/index.js'; +import type { BlockStdScope } from './scope/index.js'; +import type { SelectionConstructor } from './selection/index.js'; +import type { BlockViewType, WidgetViewMapType } from './spec/type.js'; + +export const BlockServiceIdentifier = + createIdentifier<BlockService>('BlockService'); + +export const BlockFlavourIdentifier = createIdentifier<{ flavour: string }>( + 'BlockFlavour' +); + +export const CommandIdentifier = createIdentifier<Command>('Commands'); + +export const ConfigIdentifier = + createIdentifier<Record<string, unknown>>('Config'); + +export const BlockViewIdentifier = createIdentifier<BlockViewType>('BlockView'); + +export const WidgetViewMapIdentifier = + createIdentifier<WidgetViewMapType>('WidgetViewMap'); + +export const LifeCycleWatcherIdentifier = + createIdentifier<LifeCycleWatcher>('LifeCycleWatcher'); + +export const StdIdentifier = createIdentifier<BlockStdScope>('Std'); + +export const KeymapIdentifier = createIdentifier<{ + getter: (std: BlockStdScope) => Record<string, UIEventHandler>; + options?: EventOptions; +}>('Keymap'); + +export const SelectionIdentifier = + createIdentifier<SelectionConstructor>('Selection'); diff --git a/blocksuite/framework/block-std/src/index.ts b/blocksuite/framework/block-std/src/index.ts new file mode 100644 index 0000000000..28169939b5 --- /dev/null +++ b/blocksuite/framework/block-std/src/index.ts @@ -0,0 +1,12 @@ +export * from './clipboard/index.js'; +export * from './command/index.js'; +export * from './event/index.js'; +export * from './extension/index.js'; +export * from './identifier.js'; +export * from './range/index.js'; +export * from './scope/index.js'; +export * from './selection/index.js'; +export * from './service/index.js'; +export * from './spec/index.js'; +export * from './utils/index.js'; +export * from './view/index.js'; diff --git a/blocksuite/framework/block-std/src/range/consts.ts b/blocksuite/framework/block-std/src/range/consts.ts new file mode 100644 index 0000000000..e28c2c307a --- /dev/null +++ b/blocksuite/framework/block-std/src/range/consts.ts @@ -0,0 +1,9 @@ +/** + * Used to exclude certain elements when using `getSelectedBlockComponentsByRange`. + */ +export const RANGE_QUERY_EXCLUDE_ATTR = 'data-range-query-exclude'; + +/** + * Used to mark certain elements so that they are excluded when synchronizing the native range and text selection (such as database block). + */ +export const RANGE_SYNC_EXCLUDE_ATTR = 'data-range-sync-exclude'; diff --git a/blocksuite/framework/block-std/src/range/index.ts b/blocksuite/framework/block-std/src/range/index.ts new file mode 100644 index 0000000000..9b9b1f3078 --- /dev/null +++ b/blocksuite/framework/block-std/src/range/index.ts @@ -0,0 +1,4 @@ +export * from './consts.js'; +export * from './inline-range-provider.js'; +export * from './range-binding.js'; +export * from './range-manager.js'; diff --git a/blocksuite/framework/block-std/src/range/inline-range-provider.ts b/blocksuite/framework/block-std/src/range/inline-range-provider.ts new file mode 100644 index 0000000000..53902b7fbb --- /dev/null +++ b/blocksuite/framework/block-std/src/range/inline-range-provider.ts @@ -0,0 +1,104 @@ +import type { InlineRange, InlineRangeProvider } from '@blocksuite/inline'; +import { signal } from '@preact/signals-core'; + +import type { TextSelection } from '../selection/index.js'; +import type { BlockComponent } from '../view/element/block-component.js'; + +export const getInlineRangeProvider: ( + element: BlockComponent +) => InlineRangeProvider | null = element => { + const editorHost = element.host; + const selectionManager = editorHost.selection; + const rangeManager = editorHost.range; + + if (!selectionManager || !rangeManager) { + return null; + } + + const calculateInlineRange = ( + range: Range, + textSelection: TextSelection + ): InlineRange | null => { + const { from, to } = textSelection; + + if (from.blockId === element.blockId) { + return { + index: from.index, + length: from.length, + }; + } + + if (to && to.blockId === element.blockId) { + return { + index: to.index, + length: to.length, + }; + } + + if (!element.model.text) { + return null; + } + + const elementRange = rangeManager.textSelectionToRange( + selectionManager.create('text', { + from: { + index: 0, + blockId: element.blockId, + length: element.model.text.length, + }, + to: null, + }) + ); + + if ( + elementRange && + elementRange.compareBoundaryPoints(Range.START_TO_START, range) > -1 && + elementRange.compareBoundaryPoints(Range.END_TO_END, range) < 1 + ) { + return { + index: 0, + length: element.model.text.length, + }; + } + + return null; + }; + + const setInlineRange = (inlineRange: InlineRange | null) => { + // skip `setInlineRange` from `inlineEditor` when composing happens across blocks, + // selection will be updated in `range-binding` + if (rangeManager.binding?.isComposing) return; + + if (!inlineRange) { + selectionManager.clear(['text']); + } else { + const textSelection = selectionManager.create('text', { + from: { + blockId: element.blockId, + index: inlineRange.index, + length: inlineRange.length, + }, + to: null, + }); + selectionManager.setGroup('note', [textSelection]); + } + }; + const inlineRange$: InlineRangeProvider['inlineRange$'] = signal(null); + selectionManager.slots.changed.on(selections => { + const textSelection = selections.find(s => s.type === 'text') as + | TextSelection + | undefined; + const range = rangeManager.value; + if (!range || !textSelection) { + inlineRange$.value = null; + return; + } + const inlineRange = calculateInlineRange(range, textSelection); + inlineRange$.value = inlineRange; + }); + + return { + setInlineRange, + inlineRange$, + }; +}; diff --git a/blocksuite/framework/block-std/src/range/range-binding.ts b/blocksuite/framework/block-std/src/range/range-binding.ts new file mode 100644 index 0000000000..fd66f9ca49 --- /dev/null +++ b/blocksuite/framework/block-std/src/range/range-binding.ts @@ -0,0 +1,343 @@ +import { throttle } from '@blocksuite/global/utils'; +import type { BlockModel } from '@blocksuite/store'; + +import type { BaseSelection, TextSelection } from '../selection/index.js'; +import type { BlockComponent } from '../view/element/block-component.js'; +import { BLOCK_ID_ATTR } from '../view/index.js'; +import { RANGE_SYNC_EXCLUDE_ATTR } from './consts.js'; +import type { RangeManager } from './range-manager.js'; + +/** + * Two-way binding between native range and text selection + */ +export class RangeBinding { + private _compositionStartCallback: + | ((event: CompositionEvent) => Promise<void>) + | null = null; + + private _computePath = (modelId: string) => { + const block = this.host.std.doc.getBlock(modelId)?.model; + if (!block) return []; + + const path: string[] = []; + let parent: BlockModel | null = block; + while (parent) { + path.unshift(parent.id); + parent = this.host.doc.getParent(parent); + } + + return path; + }; + + private _onBeforeInput = (event: InputEvent) => { + const selection = this.selectionManager.find('text'); + if (!selection) return; + + if (event.isComposing) return; + + const { from, to } = selection; + if (!to || from.blockId === to.blockId) return; + + const range = this.rangeManager?.value; + if (!range) return; + + const blocks = this.rangeManager.getSelectedBlockComponentsByRange(range, { + mode: 'flat', + }); + + const start = blocks.at(0); + const end = blocks.at(-1); + if (!start || !end) return; + + const startText = start.model.text; + const endText = end.model.text; + if (!startText || !endText) return; + + event.preventDefault(); + + this.host.doc.transact(() => { + startText.delete(from.index, from.length); + startText.insert(event.data ?? '', from.index); + endText.delete(0, to.length); + startText.join(endText); + + blocks + .slice(1) + // delete from lowest to highest + .reverse() + .forEach(block => { + const parent = this.host.doc.getParent(block.model); + if (!parent) return; + this.host.doc.deleteBlock(block.model, { + bringChildrenTo: parent, + }); + }); + }); + + const newSelection = this.selectionManager.create('text', { + from: { + blockId: from.blockId, + index: from.index + (event.data?.length ?? 0), + length: 0, + }, + to: null, + }); + this.selectionManager.setGroup('note', [newSelection]); + }; + + private _onCompositionEnd = (event: CompositionEvent) => { + if (this._compositionStartCallback) { + event.preventDefault(); + event.stopPropagation(); + this._compositionStartCallback(event).catch(console.error); + this._compositionStartCallback = null; + } + }; + + private _onCompositionStart = () => { + const selection = this.selectionManager.find('text'); + if (!selection) return; + + const { from, to } = selection; + if (!to) return; + + this.isComposing = true; + + const range = this.rangeManager?.value; + if (!range) return; + + const blocks = this.rangeManager.getSelectedBlockComponentsByRange(range, { + mode: 'flat', + }); + + const start = blocks.at(0); + const end = blocks.at(-1); + if (!start || !end) return; + + const startText = start.model.text; + const endText = end.model.text; + if (!startText || !endText) return; + + this._compositionStartCallback = async event => { + this.isComposing = false; + + this.host.renderRoot.replaceChildren(); + // Because we bypassed Lit and disrupted the DOM structure, this will cause an inconsistency in the original state of `ChildPart`. + // Therefore, we need to remove the original `ChildPart`. + // https://github.com/lit/lit/blob/a2cd76cfdea4ed717362bb1db32710d70550469d/packages/lit-html/src/lit-html.ts#L2248 + + delete (this.host.renderRoot as any)['_$litPart$']; + this.host.requestUpdate(); + await this.host.updateComplete; + + this.host.doc.captureSync(); + + this.host.doc.transact(() => { + endText.delete(0, to.length); + startText.delete(from.index, from.length); + startText.insert(event.data, from.index); + startText.join(endText); + + blocks + .slice(1) + // delete from lowest to highest + .reverse() + .forEach(block => { + const parent = this.host.doc.getParent(block.model); + if (!parent) return; + this.host.doc.deleteBlock(block.model, { + bringChildrenTo: parent, + }); + }); + }); + + await this.host.updateComplete; + + const selection = this.selectionManager.create('text', { + from: { + blockId: from.blockId, + index: from.index + (event.data?.length ?? 0), + length: 0, + }, + to: null, + }); + this.host.selection.setGroup('note', [selection]); + this.rangeManager?.syncTextSelectionToRange(selection); + }; + }; + + private _onNativeSelectionChanged = async () => { + if (this.isComposing) return; + if (!this.host) return; // Unstable when switching views, card <-> embed + + await this.host.updateComplete; + + const selection = document.getSelection(); + if (!selection) { + this.selectionManager.clear(['text']); + return; + } + const range = selection.rangeCount > 0 ? selection.getRangeAt(0) : null; + + if (!range) { + this._prevTextSelection = null; + this.selectionManager.clear(['text']); + return; + } + + if (!this.host.contains(range.commonAncestorContainer)) { + return; + } + + // range is in a non-editable element + // ex. placeholder + const isRangeOutNotEditable = + range.startContainer instanceof HTMLElement && + range.startContainer.contentEditable === 'false' && + range.endContainer instanceof HTMLElement && + range.endContainer.contentEditable === 'false'; + if (isRangeOutNotEditable) { + this._prevTextSelection = null; + this.selectionManager.clear(['text']); + + // force clear native selection to break inline editor input + selection.removeRange(range); + return; + } + + const el = + range.commonAncestorContainer instanceof Element + ? range.commonAncestorContainer + : range.commonAncestorContainer.parentElement; + if (!el) return; + const block = el.closest<BlockComponent>(`[${BLOCK_ID_ATTR}]`); + if (block?.getAttribute(RANGE_SYNC_EXCLUDE_ATTR) === 'true') return; + + const inlineEditor = this.rangeManager?.getClosestInlineEditor( + range.commonAncestorContainer + ); + if (inlineEditor?.isComposing) return; + + const isRangeReversed = + !!selection.anchorNode && + !!selection.focusNode && + (selection.anchorNode === selection.focusNode + ? selection.anchorOffset > selection.focusOffset + : selection.anchorNode.compareDocumentPosition(selection.focusNode) === + Node.DOCUMENT_POSITION_PRECEDING); + const textSelection = this.rangeManager?.rangeToTextSelection( + range, + isRangeReversed + ); + if (!textSelection) { + this._prevTextSelection = null; + this.selectionManager.clear(['text']); + return; + } + + const model = this.host.doc.getBlockById(textSelection.blockId); + // If the model is not found, the selection maybe in another editor + if (!model) return; + + this._prevTextSelection = { + selection: textSelection, + path: this._computePath(model.id), + }; + this.rangeManager?.syncRangeToTextSelection(range, isRangeReversed); + }; + + private _onStdSelectionChanged = (selections: BaseSelection[]) => { + const text = + selections.find((selection): selection is TextSelection => + selection.is('text') + ) ?? null; + + if (text === this._prevTextSelection) { + return; + } + // wait for lit updated + this.host.updateComplete + .then(() => { + const id = text?.blockId; + const path = id && this._computePath(id); + + if (this.host.event.active) { + const eq = + text && this._prevTextSelection && path + ? text.equals(this._prevTextSelection.selection) && + path.join('') === this._prevTextSelection.path.join('') + : false; + + if (eq) return; + } + + this._prevTextSelection = + text && path + ? { + selection: text, + path, + } + : null; + if (text) { + this.rangeManager?.syncTextSelectionToRange(text); + } else { + this.rangeManager?.clear(); + } + }) + .catch(console.error); + }; + + private _prevTextSelection: { + selection: TextSelection; + path: string[]; + } | null = null; + + isComposing = false; + + get host() { + return this.manager.std.host; + } + + get rangeManager() { + return this.host.range; + } + + get selectionManager() { + return this.host.selection; + } + + constructor(public manager: RangeManager) { + this.host.disposables.add( + this.selectionManager.slots.changed.on(this._onStdSelectionChanged) + ); + + this.host.disposables.addFromEvent( + document, + 'selectionchange', + throttle(() => { + this._onNativeSelectionChanged().catch(console.error); + }, 10) + ); + + this.host.disposables.add( + this.host.event.add('beforeInput', ctx => { + const event = ctx.get('defaultState').event as InputEvent; + this._onBeforeInput(event); + }) + ); + + this.host.disposables.addFromEvent( + this.host, + 'compositionstart', + this._onCompositionStart + ); + this.host.disposables.addFromEvent( + this.host, + 'compositionend', + this._onCompositionEnd, + { + capture: true, + } + ); + } +} diff --git a/blocksuite/framework/block-std/src/range/range-manager.ts b/blocksuite/framework/block-std/src/range/range-manager.ts new file mode 100644 index 0000000000..3c5125c359 --- /dev/null +++ b/blocksuite/framework/block-std/src/range/range-manager.ts @@ -0,0 +1,254 @@ +import { INLINE_ROOT_ATTR, type InlineRootElement } from '@blocksuite/inline'; + +import { LifeCycleWatcher } from '../extension/index.js'; +import type { TextSelection } from '../selection/index.js'; +import type { BlockComponent } from '../view/element/block-component.js'; +import { BLOCK_ID_ATTR } from '../view/index.js'; +import { RANGE_QUERY_EXCLUDE_ATTR, RANGE_SYNC_EXCLUDE_ATTR } from './consts.js'; +import { RangeBinding } from './range-binding.js'; + +/** + * CRUD for Range and TextSelection + */ +export class RangeManager extends LifeCycleWatcher { + static override readonly key = 'rangeManager'; + + binding: RangeBinding | null = null; + + get value() { + const selection = document.getSelection(); + if (!selection) { + return; + } + if (selection.rangeCount === 0) return null; + return selection.getRangeAt(0); + } + + private _isRangeSyncExcluded(el: Element) { + return !!el.closest(`[${RANGE_SYNC_EXCLUDE_ATTR}="true"]`); + } + + clear() { + const selection = document.getSelection(); + if (!selection) return; + selection.removeAllRanges(); + + const topContenteditableElement = this.std.host.querySelector( + '[contenteditable="true"]' + ); + if (topContenteditableElement instanceof HTMLElement) { + topContenteditableElement.blur(); + } + if (document.activeElement instanceof HTMLElement) { + document.activeElement.blur(); + } + } + + getClosestBlock(node: Node) { + const el = node instanceof Element ? node : node.parentElement; + if (!el) return null; + const block = el.closest<BlockComponent>(`[${BLOCK_ID_ATTR}]`); + if (!block) return null; + if (this._isRangeSyncExcluded(block)) return null; + return block; + } + + getClosestInlineEditor(node: Node) { + const el = node instanceof Element ? node : node.parentElement; + if (!el) return null; + const inlineRoot = el.closest<InlineRootElement>(`[${INLINE_ROOT_ATTR}]`); + if (!inlineRoot) return null; + + if (this._isRangeSyncExcluded(inlineRoot)) return null; + + return inlineRoot.inlineEditor; + } + + /** + * @example + * aaa + * b[bb + * ccc + * ddd + * ee]e + * + * all mode: [aaa, bbb, ccc, ddd, eee] + * flat mode: [bbb, ccc, ddd, eee] + * highest mode: [bbb, ddd] + * + * match function will be evaluated before filtering using mode + */ + getSelectedBlockComponentsByRange( + range: Range, + options: { + match?: (el: BlockComponent) => boolean; + mode?: 'all' | 'flat' | 'highest'; + } = {} + ): BlockComponent[] { + const { mode = 'all', match = () => true } = options; + + let result = Array.from<BlockComponent>( + this.std.host.querySelectorAll( + `[${BLOCK_ID_ATTR}]:not([${RANGE_QUERY_EXCLUDE_ATTR}="true"])` + ) + ).filter(el => range.intersectsNode(el) && match(el)); + + if (result.length === 0) { + return []; + } + + const firstElement = this.getClosestBlock(range.startContainer); + if (!firstElement) return []; + const firstElementIndex = result.indexOf(firstElement); + if (firstElementIndex === -1) return []; + + if (mode === 'flat') { + result = result.slice(firstElementIndex); + } else if (mode === 'highest') { + result = result.slice(firstElementIndex); + let parent = result[0]; + result = result.filter((node, index) => { + if (index === 0) return true; + if ( + parent.compareDocumentPosition(node) & + Node.DOCUMENT_POSITION_CONTAINED_BY + ) { + return false; + } else { + parent = node; + return true; + } + }); + } + + return result; + } + + override mounted() { + this.binding = new RangeBinding(this); + } + + queryInlineEditorByPath(path: string) { + const block = this.std.host.view.getBlock(path); + if (!block) return null; + + const inlineRoot = block.querySelector<InlineRootElement>( + `[${INLINE_ROOT_ATTR}]` + ); + if (!inlineRoot) return null; + + if (this._isRangeSyncExcluded(inlineRoot)) return null; + + return inlineRoot.inlineEditor; + } + + rangeToTextSelection(range: Range, reverse = false): TextSelection | null { + const { startContainer, endContainer } = range; + + const startBlock = this.getClosestBlock(startContainer); + const endBlock = this.getClosestBlock(endContainer); + if (!startBlock || !endBlock) { + return null; + } + + const startInlineEditor = this.getClosestInlineEditor(startContainer); + const endInlineEditor = this.getClosestInlineEditor(endContainer); + if (!startInlineEditor || !endInlineEditor) { + return null; + } + + const startInlineRange = startInlineEditor.toInlineRange(range); + const endInlineRange = endInlineEditor.toInlineRange(range); + if (!startInlineRange || !endInlineRange) { + return null; + } + + return this.std.host.selection.create('text', { + from: { + blockId: startBlock.blockId, + index: startInlineRange.index, + length: startInlineRange.length, + }, + to: + startBlock === endBlock + ? null + : { + blockId: endBlock.blockId, + index: endInlineRange.index, + length: endInlineRange.length, + }, + reverse, + }); + } + + set(range: Range) { + const selection = document.getSelection(); + if (!selection) return; + selection.removeAllRanges(); + selection.addRange(range); + } + + syncRangeToTextSelection(range: Range, isRangeReversed: boolean) { + const selectionManager = this.std.host.selection; + + if (!range) { + selectionManager.clear(['text']); + return; + } + + const textSelection = this.rangeToTextSelection(range, isRangeReversed); + if (textSelection) { + selectionManager.setGroup('note', [textSelection]); + } else { + selectionManager.clear(['text']); + } + } + + syncTextSelectionToRange(selection: TextSelection) { + const range = this.textSelectionToRange(selection); + if (range) { + this.set(range); + } else { + this.clear(); + } + } + + textSelectionToRange(selection: TextSelection): Range | null { + const { from, to } = selection; + + const fromInlineEditor = this.queryInlineEditorByPath(from.blockId); + if (!fromInlineEditor) return null; + + if (selection.isInSameBlock()) { + return fromInlineEditor.toDomRange({ + index: from.index, + length: from.length, + }); + } + + if (!to) return null; + const toInlineEditor = this.queryInlineEditorByPath(to.blockId); + if (!toInlineEditor) return null; + + const fromRange = fromInlineEditor.toDomRange({ + index: from.index, + length: from.length, + }); + const toRange = toInlineEditor.toDomRange({ + index: to.index, + length: to.length, + }); + + if (!fromRange || !toRange) return null; + + const range = document.createRange(); + const startContainer = fromRange.startContainer; + const startOffset = fromRange.startOffset; + const endContainer = toRange.endContainer; + const endOffset = toRange.endOffset; + range.setStart(startContainer, startOffset); + range.setEnd(endContainer, endOffset); + + return range; + } +} diff --git a/blocksuite/framework/block-std/src/scope/block-std-scope.ts b/blocksuite/framework/block-std/src/scope/block-std-scope.ts new file mode 100644 index 0000000000..f5cc61f8b3 --- /dev/null +++ b/blocksuite/framework/block-std/src/scope/block-std-scope.ts @@ -0,0 +1,207 @@ +import type { ServiceProvider } from '@blocksuite/global/di'; +import { Container } from '@blocksuite/global/di'; +import { BlockSuiteError, ErrorCode } from '@blocksuite/global/exceptions'; +import type { Doc } from '@blocksuite/store'; + +import { Clipboard } from '../clipboard/index.js'; +import { CommandManager } from '../command/index.js'; +import { UIEventDispatcher } from '../event/index.js'; +import type { BlockService, ExtensionType } from '../extension/index.js'; +import { GfxController } from '../gfx/controller.js'; +import { GfxSelectionManager } from '../gfx/selection.js'; +import { SurfaceMiddlewareExtension } from '../gfx/surface-middleware.js'; +import { ViewManager } from '../gfx/view/view-manager.js'; +import { + BlockServiceIdentifier, + BlockViewIdentifier, + ConfigIdentifier, + LifeCycleWatcherIdentifier, + StdIdentifier, +} from '../identifier.js'; +import { RangeManager } from '../range/index.js'; +import { + BlockSelectionExtension, + CursorSelectionExtension, + SelectionManager, + SurfaceSelectionExtension, + TextSelectionExtension, +} from '../selection/index.js'; +import { ServiceManager } from '../service/index.js'; +import { EditorHost } from '../view/element/index.js'; +import { ViewStore } from '../view/view-store.js'; + +export interface BlockStdOptions { + doc: Doc; + extensions: ExtensionType[]; +} + +const internalExtensions = [ + ServiceManager, + CommandManager, + UIEventDispatcher, + SelectionManager, + RangeManager, + ViewStore, + Clipboard, + GfxController, + BlockSelectionExtension, + TextSelectionExtension, + SurfaceSelectionExtension, + CursorSelectionExtension, + GfxSelectionManager, + SurfaceMiddlewareExtension, + ViewManager, +]; + +export class BlockStdScope { + static internalExtensions = internalExtensions; + + private _getHost: () => EditorHost; + + readonly container: Container; + + readonly doc: Doc; + + readonly provider: ServiceProvider; + + readonly userExtensions: ExtensionType[]; + + private get _lifeCycleWatchers() { + return this.provider.getAll(LifeCycleWatcherIdentifier); + } + + get clipboard() { + return this.get(Clipboard); + } + + get collection() { + return this.doc.collection; + } + + get command() { + return this.get(CommandManager); + } + + get event() { + return this.get(UIEventDispatcher); + } + + get get() { + return this.provider.get.bind(this.provider); + } + + get getOptional() { + return this.provider.getOptional.bind(this.provider); + } + + get host() { + return this._getHost(); + } + + get range() { + return this.get(RangeManager); + } + + get selection() { + return this.get(SelectionManager); + } + + get view() { + return this.get(ViewStore); + } + + constructor(options: BlockStdOptions) { + this._getHost = () => { + throw new BlockSuiteError( + ErrorCode.ValueNotExists, + 'Host is not ready to use, the `render` method should be called first' + ); + }; + this.doc = options.doc; + this.userExtensions = options.extensions; + this.container = new Container(); + this.container.addImpl(StdIdentifier, () => this); + + internalExtensions.forEach(ext => { + const container = this.container; + ext.setup(container); + }); + + this.userExtensions.forEach(ext => { + const container = this.container; + ext.setup(container); + }); + + this.provider = this.container.provider(); + + this._lifeCycleWatchers.forEach(watcher => { + watcher.created.call(watcher); + }); + } + + getConfig<Key extends BlockSuite.ConfigKeys>( + flavour: Key + ): BlockSuite.BlockConfigs[Key] | null; + + getConfig(flavour: string) { + const config = this.provider.getOptional(ConfigIdentifier(flavour)); + if (!config) { + return null; + } + + return config; + } + + /** + * @deprecated + * BlockService will be removed in the future. + */ + getService<Key extends BlockSuite.ServiceKeys>( + flavour: Key + ): BlockSuite.BlockServices[Key] | null; + getService<Service extends BlockService>(flavour: string): Service | null; + getService(flavour: string): BlockService | null { + return this.getOptional(BlockServiceIdentifier(flavour)); + } + + getView(flavour: string) { + return this.getOptional(BlockViewIdentifier(flavour)); + } + + mount() { + this._lifeCycleWatchers.forEach(watcher => { + watcher.mounted.call(watcher); + }); + } + + render() { + const element = new EditorHost(); + element.std = this; + element.doc = this.doc; + this._getHost = () => element; + this._lifeCycleWatchers.forEach(watcher => { + watcher.rendered.call(watcher); + }); + + return element; + } + + unmount() { + this._lifeCycleWatchers.forEach(watcher => { + watcher.unmounted.call(watcher); + }); + this._getHost = () => null as unknown as EditorHost; + } +} + +declare global { + namespace BlockSuite { + interface BlockServices {} + interface BlockConfigs {} + + type ServiceKeys = string & keyof BlockServices; + type ConfigKeys = string & keyof BlockConfigs; + + type Std = BlockStdScope; + } +} diff --git a/blocksuite/framework/block-std/src/scope/index.ts b/blocksuite/framework/block-std/src/scope/index.ts new file mode 100644 index 0000000000..f077ade791 --- /dev/null +++ b/blocksuite/framework/block-std/src/scope/index.ts @@ -0,0 +1 @@ +export * from './block-std-scope.js'; diff --git a/blocksuite/framework/block-std/src/selection/base.ts b/blocksuite/framework/block-std/src/selection/base.ts new file mode 100644 index 0000000000..012b39f5f5 --- /dev/null +++ b/blocksuite/framework/block-std/src/selection/base.ts @@ -0,0 +1,49 @@ +import { BlockSuiteError, ErrorCode } from '@blocksuite/global/exceptions'; + +type SelectionConstructor<T = unknown> = { + type: string; + group: string; + new (...args: unknown[]): T; +}; + +export type BaseSelectionOptions = { + blockId: string; +}; + +export abstract class BaseSelection { + static readonly group: string; + + static readonly type: string; + + readonly blockId: string; + + get group(): string { + return (this.constructor as SelectionConstructor).group; + } + + get type(): BlockSuite.SelectionType { + return (this.constructor as SelectionConstructor) + .type as BlockSuite.SelectionType; + } + + constructor({ blockId }: BaseSelectionOptions) { + this.blockId = blockId; + } + + static fromJSON(_: Record<string, unknown>): BaseSelection { + throw new BlockSuiteError( + ErrorCode.SelectionError, + 'You must override this method' + ); + } + + abstract equals(other: BaseSelection): boolean; + + is<T extends BlockSuite.SelectionType>( + type: T + ): this is BlockSuite.SelectionInstance[T] { + return this.type === type; + } + + abstract toJSON(): Record<string, unknown>; +} diff --git a/blocksuite/framework/block-std/src/selection/index.ts b/blocksuite/framework/block-std/src/selection/index.ts new file mode 100644 index 0000000000..dd335a9b90 --- /dev/null +++ b/blocksuite/framework/block-std/src/selection/index.ts @@ -0,0 +1,27 @@ +import type { + BlockSelection, + CursorSelection, + SurfaceSelection, + TextSelection, +} from './variants/index.js'; + +export * from './base.js'; +export * from './manager.js'; +export * from './variants/index.js'; + +declare global { + namespace BlockSuite { + interface Selection { + block: typeof BlockSelection; + cursor: typeof CursorSelection; + surface: typeof SurfaceSelection; + text: typeof TextSelection; + } + + type SelectionType = keyof Selection; + + type SelectionInstance = { + [P in SelectionType]: InstanceType<Selection[P]>; + }; + } +} diff --git a/blocksuite/framework/block-std/src/selection/manager.ts b/blocksuite/framework/block-std/src/selection/manager.ts new file mode 100644 index 0000000000..8678510f02 --- /dev/null +++ b/blocksuite/framework/block-std/src/selection/manager.ts @@ -0,0 +1,248 @@ +import { BlockSuiteError, ErrorCode } from '@blocksuite/global/exceptions'; +import { DisposableGroup, Slot } from '@blocksuite/global/utils'; +import { nanoid, type StackItem } from '@blocksuite/store'; +import { computed, signal } from '@preact/signals-core'; + +import { LifeCycleWatcher } from '../extension/index.js'; +import { SelectionIdentifier } from '../identifier.js'; +import type { BlockStdScope } from '../scope/index.js'; +import type { BaseSelection } from './base.js'; + +export interface SelectionConstructor { + type: string; + + new (...args: any[]): BaseSelection; + fromJSON(json: Record<string, unknown>): BaseSelection; +} + +export class SelectionManager extends LifeCycleWatcher { + static override readonly key = 'selectionManager'; + + private _id: string; + + private _itemAdded = (event: { stackItem: StackItem }) => { + event.stackItem.meta.set('selection-state', this.value); + }; + + private _itemPopped = (event: { stackItem: StackItem }) => { + const selection = event.stackItem.meta.get('selection-state'); + if (selection) { + this.set(selection as BaseSelection[]); + } + }; + + private _jsonToSelection = (json: Record<string, unknown>) => { + const ctor = this._selectionConstructors[json.type as string]; + if (!ctor) { + throw new BlockSuiteError( + ErrorCode.SelectionError, + `Unknown selection type: ${json.type}` + ); + } + return ctor.fromJSON(json); + }; + + private _remoteSelections = signal<Map<number, BaseSelection[]>>(new Map()); + + private _selectionConstructors: Record<string, SelectionConstructor> = {}; + + private _selections = signal<BaseSelection[]>([]); + + disposables = new DisposableGroup(); + + slots = { + changed: new Slot<BaseSelection[]>(), + remoteChanged: new Slot<Map<number, BaseSelection[]>>(), + }; + + private get _store() { + return this.std.collection.awarenessStore; + } + + get id() { + return this._id; + } + + get remoteSelections() { + return this._remoteSelections.value; + } + + get value() { + return this._selections.value; + } + + constructor(std: BlockStdScope) { + super(std); + this._id = `${this.std.doc.blockCollection.id}:${nanoid()}`; + this._setupDefaultSelections(); + this._store.awareness.on( + 'change', + (change: { updated: number[]; added: number[]; removed: number[] }) => { + const all = change.updated.concat(change.added).concat(change.removed); + const localClientID = this._store.awareness.clientID; + const exceptLocal = all.filter(id => id !== localClientID); + const hasLocal = all.includes(localClientID); + if (hasLocal) { + const localSelectionJson = this._store.getLocalSelection(this.id); + const localSelection = localSelectionJson.map(json => { + return this._jsonToSelection(json); + }); + this._selections.value = localSelection; + } + + // Only consider remote selections from other clients + if (exceptLocal.length > 0) { + const map = new Map<number, BaseSelection[]>(); + this._store.getStates().forEach((state, id) => { + if (id === this._store.awareness.clientID) return; + // selection id starts with the same block collection id from others clients would be considered as remote selections + const selection = Object.entries(state.selectionV2) + .filter(([key]) => + key.startsWith(this.std.doc.blockCollection.id) + ) + .flatMap(([_, selection]) => selection); + + const selections = selection + .map(json => { + try { + return this._jsonToSelection(json); + } catch (error) { + console.error( + 'Parse remote selection failed:', + id, + json, + error + ); + return null; + } + }) + .filter((sel): sel is BaseSelection => !!sel); + + map.set(id, selections); + }); + this._remoteSelections.value = map; + } + } + ); + } + + private _setupDefaultSelections() { + this.std.provider.getAll(SelectionIdentifier).forEach(ctor => { + this.register(ctor); + }); + } + + clear(types?: string[]) { + if (types) { + const values = this.value.filter( + selection => !types.includes(selection.type) + ); + this.set(values); + } else { + this.set([]); + } + } + + create<T extends BlockSuite.SelectionType>( + type: T, + ...args: ConstructorParameters<BlockSuite.Selection[T]> + ): BlockSuite.SelectionInstance[T] { + const ctor = this._selectionConstructors[type]; + if (!ctor) { + throw new BlockSuiteError( + ErrorCode.SelectionError, + `Unknown selection type: ${type}` + ); + } + return new ctor(...args) as BlockSuite.SelectionInstance[T]; + } + + dispose() { + Object.values(this.slots).forEach(slot => slot.dispose()); + this.disposables.dispose(); + } + + filter<T extends BlockSuite.SelectionType>(type: T) { + return this.filter$(type).value; + } + + filter$<T extends BlockSuite.SelectionType>(type: T) { + return computed(() => + this.value.filter((sel): sel is BlockSuite.SelectionInstance[T] => + sel.is(type) + ) + ); + } + + find<T extends BlockSuite.SelectionType>(type: T) { + return this.find$(type).value; + } + + find$<T extends BlockSuite.SelectionType>(type: T) { + return computed(() => + this.value.find((sel): sel is BlockSuite.SelectionInstance[T] => + sel.is(type) + ) + ); + } + + fromJSON(json: Record<string, unknown>[]) { + const selections = json.map(json => { + return this._jsonToSelection(json); + }); + return this.set(selections); + } + + getGroup(group: string) { + return this.value.filter(s => s.group === group); + } + + override mounted() { + if (this.disposables.disposed) { + this.disposables = new DisposableGroup(); + } + this.std.doc.history.on('stack-item-added', this._itemAdded); + this.std.doc.history.on('stack-item-popped', this._itemPopped); + this.disposables.add( + this._store.slots.update.on(({ id }) => { + if (id === this._store.awareness.clientID) { + return; + } + this.slots.remoteChanged.emit(this.remoteSelections); + }) + ); + } + + register(ctor: SelectionConstructor | SelectionConstructor[]) { + [ctor].flat().forEach(ctor => { + this._selectionConstructors[ctor.type] = ctor; + }); + return this; + } + + set(selections: BaseSelection[]) { + this._store.setLocalSelection( + this.id, + selections.map(s => s.toJSON()) + ); + this.slots.changed.emit(selections); + } + + setGroup(group: string, selections: BaseSelection[]) { + const current = this.value.filter(s => s.group !== group); + this.set([...current, ...selections]); + } + + override unmounted() { + this.std.doc.history.off('stack-item-added', this._itemAdded); + this.std.doc.history.off('stack-item-popped', this._itemPopped); + this.slots.changed.dispose(); + this.disposables.dispose(); + this.clear(); + } + + update(fn: (currentSelections: BaseSelection[]) => BaseSelection[]) { + const selections = fn(this.value); + this.set(selections); + } +} diff --git a/blocksuite/framework/block-std/src/selection/variants/block.ts b/blocksuite/framework/block-std/src/selection/variants/block.ts new file mode 100644 index 0000000000..dbd75a28ec --- /dev/null +++ b/blocksuite/framework/block-std/src/selection/variants/block.ts @@ -0,0 +1,35 @@ +import z from 'zod'; + +import { SelectionExtension } from '../../extension/selection.js'; +import { BaseSelection } from '../base.js'; + +const BlockSelectionSchema = z.object({ + blockId: z.string(), +}); + +export class BlockSelection extends BaseSelection { + static override group = 'note'; + + static override type = 'block'; + + static override fromJSON(json: Record<string, unknown>): BlockSelection { + const result = BlockSelectionSchema.parse(json); + return new BlockSelection(result); + } + + override equals(other: BaseSelection): boolean { + if (other instanceof BlockSelection) { + return this.blockId === other.blockId; + } + return false; + } + + override toJSON(): Record<string, unknown> { + return { + type: 'block', + blockId: this.blockId, + }; + } +} + +export const BlockSelectionExtension = SelectionExtension(BlockSelection); diff --git a/blocksuite/framework/block-std/src/selection/variants/cursor.ts b/blocksuite/framework/block-std/src/selection/variants/cursor.ts new file mode 100644 index 0000000000..a6ede90552 --- /dev/null +++ b/blocksuite/framework/block-std/src/selection/variants/cursor.ts @@ -0,0 +1,47 @@ +import z from 'zod'; + +import { SelectionExtension } from '../../extension/selection.js'; +import { BaseSelection } from '../base.js'; + +const CursorSelectionSchema = z.object({ + x: z.number(), + y: z.number(), +}); + +export class CursorSelection extends BaseSelection { + static override group = 'gfx'; + + static override type = 'cursor'; + + readonly x: number; + + readonly y: number; + + constructor(x: number, y: number) { + super({ blockId: '[gfx-cursor]' }); + this.x = x; + this.y = y; + } + + static override fromJSON(json: Record<string, unknown>): CursorSelection { + const { x, y } = CursorSelectionSchema.parse(json); + return new CursorSelection(x, y); + } + + override equals(other: BaseSelection): boolean { + if (other instanceof CursorSelection) { + return this.x === other.x && this.y === other.y; + } + return false; + } + + override toJSON(): Record<string, unknown> { + return { + type: 'cursor', + x: this.x, + y: this.y, + }; + } +} + +export const CursorSelectionExtension = SelectionExtension(CursorSelection); diff --git a/blocksuite/framework/block-std/src/selection/variants/index.ts b/blocksuite/framework/block-std/src/selection/variants/index.ts new file mode 100644 index 0000000000..80fdcf7a70 --- /dev/null +++ b/blocksuite/framework/block-std/src/selection/variants/index.ts @@ -0,0 +1,4 @@ +export * from './block.js'; +export * from './cursor.js'; +export * from './surface.js'; +export * from './text.js'; diff --git a/blocksuite/framework/block-std/src/selection/variants/surface.ts b/blocksuite/framework/block-std/src/selection/variants/surface.ts new file mode 100644 index 0000000000..1fe7092e81 --- /dev/null +++ b/blocksuite/framework/block-std/src/selection/variants/surface.ts @@ -0,0 +1,72 @@ +import z from 'zod'; + +import { SelectionExtension } from '../../extension/selection.js'; +import { BaseSelection } from '../base.js'; + +const SurfaceSelectionSchema = z.object({ + blockId: z.string(), + elements: z.array(z.string()), + editing: z.boolean(), + inoperable: z.boolean().optional(), +}); + +export class SurfaceSelection extends BaseSelection { + static override group = 'gfx'; + + static override type = 'surface'; + + readonly editing: boolean; + + readonly elements: string[]; + + readonly inoperable: boolean; + + constructor( + blockId: string, + elements: string[], + editing: boolean, + inoperable = false + ) { + super({ blockId }); + + this.elements = elements; + this.editing = editing; + this.inoperable = inoperable; + } + + static override fromJSON(json: Record<string, unknown>): SurfaceSelection { + const { blockId, elements, editing, inoperable } = + SurfaceSelectionSchema.parse(json); + return new SurfaceSelection(blockId, elements, editing, inoperable); + } + + override equals(other: BaseSelection): boolean { + if (other instanceof SurfaceSelection) { + return ( + this.blockId === other.blockId && + this.editing === other.editing && + this.inoperable === other.inoperable && + this.elements.length === other.elements.length && + this.elements.every((id, idx) => id === other.elements[idx]) + ); + } + + return false; + } + + isEmpty() { + return this.elements.length === 0 && !this.editing; + } + + override toJSON(): Record<string, unknown> { + return { + type: 'surface', + blockId: this.blockId, + elements: this.elements, + editing: this.editing, + inoperable: this.inoperable, + }; + } +} + +export const SurfaceSelectionExtension = SelectionExtension(SurfaceSelection); diff --git a/blocksuite/framework/block-std/src/selection/variants/text.ts b/blocksuite/framework/block-std/src/selection/variants/text.ts new file mode 100644 index 0000000000..29217ead00 --- /dev/null +++ b/blocksuite/framework/block-std/src/selection/variants/text.ts @@ -0,0 +1,117 @@ +import z from 'zod'; + +import { SelectionExtension } from '../../extension/selection.js'; +import { BaseSelection } from '../base.js'; + +export type TextRangePoint = { + blockId: string; + index: number; + length: number; +}; + +export type TextSelectionProps = { + from: TextRangePoint; + to: TextRangePoint | null; + reverse?: boolean; +}; + +const TextSelectionSchema = z.object({ + from: z.object({ + blockId: z.string(), + index: z.number(), + length: z.number(), + }), + to: z + .object({ + blockId: z.string(), + index: z.number(), + length: z.number(), + }) + .nullable(), + // The `optional()` is for backward compatibility, + // since `reverse` may not exist in remote selection. + reverse: z.boolean().optional(), +}); + +export class TextSelection extends BaseSelection { + static override group = 'note'; + + static override type = 'text'; + + from: TextRangePoint; + + reverse: boolean; + + to: TextRangePoint | null; + + get end(): TextRangePoint { + return this.reverse ? this.from : (this.to ?? this.from); + } + + get start(): TextRangePoint { + return this.reverse ? (this.to ?? this.from) : this.from; + } + + constructor({ from, to, reverse }: TextSelectionProps) { + super({ + blockId: from.blockId, + }); + this.from = from; + + this.to = this._equalPoint(from, to) ? null : to; + + this.reverse = !!reverse; + } + + static override fromJSON(json: Record<string, unknown>): TextSelection { + const result = TextSelectionSchema.parse(json); + return new TextSelection(result); + } + + private _equalPoint( + a: TextRangePoint | null, + b: TextRangePoint | null + ): boolean { + if (a && b) { + return ( + a.blockId === b.blockId && a.index === b.index && a.length === b.length + ); + } + + return a === b; + } + + empty(): boolean { + return !!this.to; + } + + override equals(other: BaseSelection): boolean { + if (other instanceof TextSelection) { + return ( + this.blockId === other.blockId && + this._equalPoint(other.from, this.from) && + this._equalPoint(other.to, this.to) + ); + } + return false; + } + + isCollapsed(): boolean { + return this.to === null && this.from.length === 0; + } + + isInSameBlock(): boolean { + return this.to === null || this.from.blockId === this.to.blockId; + } + + override toJSON(): Record<string, unknown> { + return { + type: 'text', + from: this.from, + to: this.to, + reverse: this.reverse, + }; + } +} + +export const TextSelectionExtension = SelectionExtension(TextSelection); diff --git a/blocksuite/framework/block-std/src/service/index.ts b/blocksuite/framework/block-std/src/service/index.ts new file mode 100644 index 0000000000..a598750785 --- /dev/null +++ b/blocksuite/framework/block-std/src/service/index.ts @@ -0,0 +1,22 @@ +import { LifeCycleWatcher } from '../extension/index.js'; +import { BlockServiceIdentifier } from '../identifier.js'; + +export class ServiceManager extends LifeCycleWatcher { + static override readonly key = 'serviceManager'; + + override mounted() { + super.mounted(); + + this.std.provider.getAll(BlockServiceIdentifier).forEach(service => { + service.mounted(); + }); + } + + override unmounted() { + super.unmounted(); + + this.std.provider.getAll(BlockServiceIdentifier).forEach(service => { + service.unmounted(); + }); + } +} diff --git a/blocksuite/framework/block-std/src/spec/index.ts b/blocksuite/framework/block-std/src/spec/index.ts new file mode 100644 index 0000000000..2782fbabdb --- /dev/null +++ b/blocksuite/framework/block-std/src/spec/index.ts @@ -0,0 +1,2 @@ +export * from './slots.js'; +export * from './type.js'; diff --git a/blocksuite/framework/block-std/src/spec/slots.ts b/blocksuite/framework/block-std/src/spec/slots.ts new file mode 100644 index 0000000000..63a41b7663 --- /dev/null +++ b/blocksuite/framework/block-std/src/spec/slots.ts @@ -0,0 +1,27 @@ +import { Slot } from '@blocksuite/global/utils'; + +import type { BlockService } from '../extension/service.js'; +import type { BlockComponent, WidgetComponent } from '../view/index.js'; + +export type BlockSpecSlots<Service extends BlockService = BlockService> = { + mounted: Slot<{ service: Service }>; + unmounted: Slot<{ service: Service }>; + viewConnected: Slot<{ component: BlockComponent; service: Service }>; + viewDisconnected: Slot<{ component: BlockComponent; service: Service }>; + widgetConnected: Slot<{ component: WidgetComponent; service: Service }>; + widgetDisconnected: Slot<{ + component: WidgetComponent; + service: Service; + }>; +}; + +export const getSlots = (): BlockSpecSlots => { + return { + mounted: new Slot(), + unmounted: new Slot(), + viewConnected: new Slot(), + viewDisconnected: new Slot(), + widgetConnected: new Slot(), + widgetDisconnected: new Slot(), + }; +}; diff --git a/blocksuite/framework/block-std/src/spec/type.ts b/blocksuite/framework/block-std/src/spec/type.ts new file mode 100644 index 0000000000..3bf2b70efb --- /dev/null +++ b/blocksuite/framework/block-std/src/spec/type.ts @@ -0,0 +1,6 @@ +import type { BlockModel } from '@blocksuite/store'; +import type { StaticValue } from 'lit/static-html.js'; + +export type BlockCommands = Partial<BlockSuite.Commands>; +export type BlockViewType = StaticValue | ((model: BlockModel) => StaticValue); +export type WidgetViewMapType = Record<string, StaticValue>; diff --git a/blocksuite/framework/block-std/src/utils/fractional-indexing.ts b/blocksuite/framework/block-std/src/utils/fractional-indexing.ts new file mode 100644 index 0000000000..64b148105b --- /dev/null +++ b/blocksuite/framework/block-std/src/utils/fractional-indexing.ts @@ -0,0 +1,66 @@ +import { generateKeyBetween } from 'fractional-indexing'; + +function hasSamePrefix(a: string, b: string) { + return a.startsWith(b) || b.startsWith(a); +} +/** + * generate a key between a and b, the result key is always satisfied with a < result < b. + * the key always has a random suffix, so there is no need to worry about collision. + * + * make sure a and b are generated by this function. + * + * @param customPostfix custom postfix for the key, only letters and numbers are allowed + */ +export function generateKeyBetweenV2(a: string | null, b: string | null) { + const randomSize = 32; + function postfix(length: number = randomSize) { + const chars = + '123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz'; + const values = new Uint8Array(length); + crypto.getRandomValues(values); + let result = ''; + for (let i = 0; i < length; i++) { + result += chars.charAt(values[i] % chars.length); + } + return result; + } + if (a !== null && b !== null && a >= b) { + throw new Error('a should be smaller than b'); + } + // get the subkey in full key + // e.g. + // a0xxxx -> a + // a0x0xxxx -> a0x + function subkey(key: string | null) { + if (key === null) { + return null; + } + if (key.length <= randomSize + 1) { + // no subkey + return key; + } + const splitAt = key.substring(0, key.length - randomSize - 1); + return splitAt; + } + const aSubkey = subkey(a); + const bSubkey = subkey(b); + if (aSubkey === null && bSubkey === null) { + // generate a new key + return generateKeyBetween(null, null) + '0' + postfix(); + } else if (aSubkey === null && bSubkey !== null) { + // generate a key before b + return generateKeyBetween(null, bSubkey) + '0' + postfix(); + } else if (bSubkey === null && aSubkey !== null) { + // generate a key after a + return generateKeyBetween(aSubkey, null) + '0' + postfix(); + } else if (aSubkey !== null && bSubkey !== null) { + // generate a key between a and b + if (hasSamePrefix(aSubkey, bSubkey) && a !== null && b !== null) { + // conflict, if the subkeys are the same, generate a key between fullkeys + return generateKeyBetween(a, b) + '0' + postfix(); + } else { + return generateKeyBetween(aSubkey, bSubkey) + '0' + postfix(); + } + } + throw new Error('Never reach here'); +} diff --git a/blocksuite/framework/block-std/src/utils/gfx.ts b/blocksuite/framework/block-std/src/utils/gfx.ts new file mode 100644 index 0000000000..33452f59c5 --- /dev/null +++ b/blocksuite/framework/block-std/src/utils/gfx.ts @@ -0,0 +1,32 @@ +import type { Doc } from '@blocksuite/store'; +import { effect } from '@preact/signals-core'; + +import { SurfaceBlockModel } from '../gfx/model/surface/surface-model.js'; + +export function onSurfaceAdded( + doc: Doc, + callback: (model: SurfaceBlockModel | null) => void +) { + let found = false; + let foundId = ''; + + const dispose = effect(() => { + // if the surface is already found, no need to search again + if (found && doc.getBlock(foundId)) { + return; + } + + for (const block of Object.values(doc.blocks.value)) { + if (block.model instanceof SurfaceBlockModel) { + callback(block.model); + found = true; + foundId = block.id; + return; + } + } + + callback(null); + }); + + return dispose; +} diff --git a/blocksuite/framework/block-std/src/utils/index.ts b/blocksuite/framework/block-std/src/utils/index.ts new file mode 100644 index 0000000000..f3a720d4f7 --- /dev/null +++ b/blocksuite/framework/block-std/src/utils/index.ts @@ -0,0 +1 @@ +export * from './path-finder.js'; diff --git a/blocksuite/framework/block-std/src/utils/layer.ts b/blocksuite/framework/block-std/src/utils/layer.ts new file mode 100644 index 0000000000..f6c3f65fef --- /dev/null +++ b/blocksuite/framework/block-std/src/utils/layer.ts @@ -0,0 +1,138 @@ +import { nToLast } from '@blocksuite/global/utils'; +import type { Doc } from '@blocksuite/store'; + +import type { GfxLocalElementModel } from '../gfx/index.js'; +import type { Layer } from '../gfx/layer.js'; +import { + type GfxGroupCompatibleInterface, + isGfxGroupCompatibleModel, +} from '../gfx/model/base.js'; +import type { GfxBlockElementModel } from '../gfx/model/gfx-block-model.js'; +import type { GfxModel } from '../gfx/model/model.js'; +import type { SurfaceBlockModel } from '../gfx/model/surface/surface-model.js'; + +export function getLayerEndZIndex(layers: Layer[], layerIndex: number) { + const layer = layers[layerIndex]; + return layer + ? layer.type === 'block' + ? layer.zIndex + layer.elements.length - 1 + : layer.zIndex + : 0; +} + +export function updateLayersZIndex(layers: Layer[], startIdx: number) { + const startLayer = layers[startIdx]; + let curIndex = startLayer.zIndex; + + for (let i = startIdx; i < layers.length; ++i) { + const curLayer = layers[i]; + + curLayer.zIndex = curIndex; + curIndex += curLayer.type === 'block' ? curLayer.elements.length : 1; + } +} + +export function getElementIndex(indexable: GfxModel) { + const groups = indexable.groups as GfxGroupCompatibleInterface[]; + + if (groups.length) { + const groupIndexes = groups + .map(group => group.index) + .reverse() + .join('-'); + + return `${groupIndexes}-${indexable.index}`; + } + + return indexable.index; +} + +export function ungroupIndex(index: string) { + return index.split('-')[0]; +} + +export function insertToOrderedArray(array: GfxModel[], element: GfxModel) { + let idx = 0; + while ( + idx < array.length && + [SortOrder.BEFORE, SortOrder.SAME].includes(compare(array[idx], element)) + ) { + ++idx; + } + + array.splice(idx, 0, element); +} + +export function removeFromOrderedArray(array: GfxModel[], element: GfxModel) { + const idx = array.indexOf(element); + + if (idx !== -1) { + array.splice(idx, 1); + } +} + +export enum SortOrder { + AFTER = 1, + BEFORE = -1, + SAME = 0, +} + +export function isInRange(edges: [GfxModel, GfxModel], target: GfxModel) { + return compare(target, edges[0]) >= 0 && compare(target, edges[1]) < 0; +} + +export function renderableInEdgeless( + doc: Doc, + surface: SurfaceBlockModel, + block: GfxBlockElementModel +) { + const parent = doc.getParent(block); + + return parent === doc.root || parent === surface; +} + +/** + * A comparator function for sorting elements in the surface. + * SortOrder.AFTER means a should be rendered after b and so on. + * @returns + */ +export function compare( + a: GfxModel | GfxLocalElementModel, + b: GfxModel | GfxLocalElementModel +) { + if (isGfxGroupCompatibleModel(a) && b.groups.includes(a)) { + return SortOrder.BEFORE; + } else if (isGfxGroupCompatibleModel(b) && a.groups.includes(b)) { + return SortOrder.AFTER; + } else { + const aGroups = a.groups as GfxGroupCompatibleInterface[]; + const bGroups = b.groups as GfxGroupCompatibleInterface[]; + + let i = 1; + let aGroup: + | GfxModel + | GfxGroupCompatibleInterface + | GfxLocalElementModel + | undefined = nToLast(aGroups, i); + let bGroup: + | GfxModel + | GfxGroupCompatibleInterface + | GfxLocalElementModel + | undefined = nToLast(bGroups, i); + + while (aGroup === bGroup && aGroup) { + ++i; + aGroup = nToLast(aGroups, i); + bGroup = nToLast(bGroups, i); + } + + aGroup = aGroup ?? a; + bGroup = bGroup ?? b; + + return aGroup.index === bGroup.index + ? SortOrder.SAME + : aGroup.index < bGroup.index + ? SortOrder.BEFORE + : SortOrder.AFTER; + } +} diff --git a/blocksuite/framework/block-std/src/utils/path-finder.ts b/blocksuite/framework/block-std/src/utils/path-finder.ts new file mode 100644 index 0000000000..1083dea56c --- /dev/null +++ b/blocksuite/framework/block-std/src/utils/path-finder.ts @@ -0,0 +1,30 @@ +export class PathFinder { + static equals = (path1: readonly string[], path2: readonly string[]) => { + return PathFinder.pathToKey(path1) === PathFinder.pathToKey(path2); + }; + + static id = (path: readonly string[]) => { + return path[path.length - 1]; + }; + + // check if path1 includes path2 + static includes = (path1: string[], path2: string[]) => { + return PathFinder.pathToKey(path1).startsWith(PathFinder.pathToKey(path2)); + }; + + static keyToPath = (key: string) => { + return key.split('|'); + }; + + static parent = (path: readonly string[]) => { + return path.slice(0, path.length - 1); + }; + + static pathToKey = (path: readonly string[]) => { + return path.join('|'); + }; + + private constructor() { + // this is a static class + } +} diff --git a/blocksuite/framework/block-std/src/utils/tree.ts b/blocksuite/framework/block-std/src/utils/tree.ts new file mode 100644 index 0000000000..1cc8f69c63 --- /dev/null +++ b/blocksuite/framework/block-std/src/utils/tree.ts @@ -0,0 +1,137 @@ +import type { Doc } from '@blocksuite/store'; + +import { + type GfxCompatibleInterface, + type GfxGroupCompatibleInterface, + isGfxGroupCompatibleModel, +} from '../gfx/model/base.js'; +import type { GfxGroupModel, GfxModel } from '../gfx/model/model.js'; + +/** + * Get the top elements from the list of elements, which are in some tree structures. + * + * For example: a list `[G1, E1, G2, E2, E3, E4, G4, E5, E6]`, + * and they are in the elements tree like: + * ``` + * G1 G4 E6 + * / \ | + * E1 G2 E5 + * / \ + * E2 G3* + * / \ + * E3 E4 + * ``` + * where the star symbol `*` denote it is not in the list. + * + * The result should be `[G1, G4, E6]` + */ +export function getTopElements(elements: GfxModel[]): GfxModel[] { + const results = new Set(elements); + + elements = [...new Set(elements)]; + + elements.forEach(e1 => { + elements.forEach(e2 => { + if (isGfxGroupCompatibleModel(e1) && e1.hasDescendant(e2)) { + results.delete(e2); + } + }); + }); + + return [...results]; +} + +function traverse( + element: GfxModel, + preCallback?: (element: GfxModel) => void | boolean, + postCallBack?: (element: GfxModel) => void +) { + // avoid infinite loop caused by circular reference + const visited = new Set<GfxModel>(); + + const innerTraverse = (element: GfxModel) => { + if (visited.has(element)) return; + visited.add(element); + + if (preCallback) { + const interrupt = preCallback(element); + if (interrupt) return; + } + + if (isGfxGroupCompatibleModel(element)) { + element.childElements.forEach(child => { + innerTraverse(child); + }); + } + + postCallBack && postCallBack(element); + }; + + innerTraverse(element); +} + +export function descendantElementsImpl( + container: GfxGroupCompatibleInterface +): GfxModel[] { + const results: GfxModel[] = []; + container.childElements.forEach(child => { + traverse(child, element => { + results.push(element); + }); + }); + return results; +} + +export function hasDescendantElementImpl( + container: GfxGroupCompatibleInterface, + element: GfxCompatibleInterface +): boolean { + let _container = element.group; + while (_container) { + if (_container === container) return true; + _container = _container.group; + } + return false; +} + +/** + * This checker is used to prevent circular reference, when adding a child element to a container. + */ +export function canSafeAddToContainer( + container: GfxGroupModel, + element: GfxCompatibleInterface +) { + if ( + element === container || + (isGfxGroupCompatibleModel(element) && element.hasDescendant(container)) + ) { + return false; + } + return true; +} + +export function isLockedByAncestorImpl( + element: GfxCompatibleInterface +): boolean { + return element.groups.some(isLockedBySelfImpl); +} + +export function isLockedBySelfImpl(element: GfxCompatibleInterface): boolean { + return element.lockedBySelf ?? false; +} + +export function isLockedImpl(element: GfxCompatibleInterface): boolean { + return isLockedBySelfImpl(element) || isLockedByAncestorImpl(element); +} + +export function lockElementImpl(doc: Doc, element: GfxCompatibleInterface) { + doc.transact(() => { + element.lockedBySelf = true; + }); +} + +export function unlockElementImpl(doc: Doc, element: GfxCompatibleInterface) { + doc.transact(() => { + element.lockedBySelf = false; + }); +} diff --git a/blocksuite/framework/block-std/src/view/decorators/index.ts b/blocksuite/framework/block-std/src/view/decorators/index.ts new file mode 100644 index 0000000000..e254c1b44e --- /dev/null +++ b/blocksuite/framework/block-std/src/view/decorators/index.ts @@ -0,0 +1 @@ +export { PropTypes, requiredProperties } from './required.js'; diff --git a/blocksuite/framework/block-std/src/view/decorators/required.ts b/blocksuite/framework/block-std/src/view/decorators/required.ts new file mode 100644 index 0000000000..96f3198518 --- /dev/null +++ b/blocksuite/framework/block-std/src/view/decorators/required.ts @@ -0,0 +1,57 @@ +import { BlockSuiteError, ErrorCode } from '@blocksuite/global/exceptions'; +import type { Constructor } from '@blocksuite/global/utils'; +import type { LitElement } from 'lit'; + +type ValidatorFunction = (value: unknown) => boolean; + +export const PropTypes = { + string: (value: unknown) => typeof value === 'string', + number: (value: unknown) => typeof value === 'number', + boolean: (value: unknown) => typeof value === 'boolean', + object: (value: unknown) => typeof value === 'object', + array: (value: unknown) => Array.isArray(value), + instanceOf: (expectedClass: Constructor) => (value: unknown) => + value instanceof expectedClass, + arrayOf: (validator: ValidatorFunction) => (value: unknown) => + Array.isArray(value) && value.every(validator), + recordOf: (validator: ValidatorFunction) => (value: unknown) => { + if (typeof value !== 'object' || value === null) return false; + return Object.values(value).every(validator); + }, +}; + +function validatePropTypes<T extends InstanceType<Constructor>>( + instance: T, + propTypes: Record<string, ValidatorFunction> +) { + for (const [propName, validator] of Object.entries(propTypes)) { + const key = propName as keyof T; + if (instance[key] === undefined) { + throw new BlockSuiteError( + ErrorCode.DefaultRuntimeError, + `Property ${propName} is required to ${instance.constructor.name}.` + ); + } + if (validator && !validator(instance[key])) { + throw new BlockSuiteError( + ErrorCode.DefaultRuntimeError, + `Property ${propName} is invalid to ${instance.constructor.name}.` + ); + } + } +} + +export function requiredProperties( + propTypes: Record<string, ValidatorFunction> +) { + return function (constructor: Constructor<LitElement>) { + const connectedCallback = constructor.prototype.connectedCallback; + + constructor.prototype.connectedCallback = function () { + if (connectedCallback) { + connectedCallback.call(this); + } + validatePropTypes(this, propTypes); + }; + }; +} diff --git a/blocksuite/framework/block-std/src/view/element/block-component.ts b/blocksuite/framework/block-std/src/view/element/block-component.ts new file mode 100644 index 0000000000..1c1c87e3b5 --- /dev/null +++ b/blocksuite/framework/block-std/src/view/element/block-component.ts @@ -0,0 +1,329 @@ +import { BlockSuiteError, ErrorCode } from '@blocksuite/global/exceptions'; +import { SignalWatcher, WithDisposable } from '@blocksuite/global/utils'; +import { type BlockModel, BlockViewType, Doc } from '@blocksuite/store'; +import { consume, provide } from '@lit/context'; +import { computed } from '@preact/signals-core'; +import { nothing, type TemplateResult } from 'lit'; +import { property, state } from 'lit/decorators.js'; +import { choose } from 'lit/directives/choose.js'; +import { when } from 'lit/directives/when.js'; +import { html } from 'lit/static-html.js'; + +import type { EventName, UIEventHandler } from '../../event/index.js'; +import type { BlockService } from '../../extension/index.js'; +import type { BlockStdScope } from '../../scope/index.js'; +import { PropTypes, requiredProperties } from '../decorators/index.js'; +import { + blockComponentSymbol, + modelContext, + serviceContext, +} from './consts.js'; +import { docContext, stdContext } from './lit-host.js'; +import { ShadowlessElement } from './shadowless-element.js'; +import type { WidgetComponent } from './widget-component.js'; + +@requiredProperties({ + doc: PropTypes.instanceOf(Doc), + std: PropTypes.object, + widgets: PropTypes.recordOf(PropTypes.object), +}) +export class BlockComponent< + Model extends BlockModel = BlockModel, + Service extends BlockService = BlockService, + WidgetName extends string = string, +> extends SignalWatcher(WithDisposable(ShadowlessElement)) { + @consume({ context: stdContext }) + accessor std!: BlockStdScope; + + private _selected = computed(() => { + const selection = this.std.selection.value.find(selection => { + return selection.blockId === this.model?.id; + }); + + if (!selection) { + return null; + } + + return selection; + }); + + [blockComponentSymbol] = true; + + handleEvent = ( + name: EventName, + handler: UIEventHandler, + options?: { global?: boolean; flavour?: boolean } + ) => { + this._disposables.add( + this.host.event.add(name, handler, { + flavour: options?.global + ? undefined + : options?.flavour + ? this.model?.flavour + : undefined, + blockId: options?.global || options?.flavour ? undefined : this.blockId, + }) + ); + }; + + get blockId() { + return this.dataset.blockId as string; + } + + get childBlocks() { + const childModels = this.model.children; + return childModels + .map(child => { + return this.std.view.getBlock(child.id); + }) + .filter((x): x is BlockComponent => !!x); + } + + get flavour(): string { + return this.model.flavour; + } + + get host() { + return this.std.host; + } + + get isVersionMismatch() { + const schema = this.doc.schema.flavourSchemaMap.get(this.model.flavour); + if (!schema) { + console.warn( + `Schema not found for block ${this.model.id}, flavour ${this.model.flavour}` + ); + return true; + } + const expectedVersion = schema.version; + const actualVersion = this.model.version; + if (expectedVersion !== actualVersion) { + console.warn( + `Version mismatch for block ${this.model.id}, expected ${expectedVersion}, actual ${actualVersion}` + ); + return true; + } + + return false; + } + + get model() { + if (this._model) { + return this._model; + } + const model = this.doc.getBlockById<Model>(this.blockId); + if (!model) { + throw new BlockSuiteError( + ErrorCode.MissingViewModelError, + `Cannot find block model for id ${this.blockId}` + ); + } + this._model = model; + return model; + } + + get parentComponent(): BlockComponent | null { + const parent = this.model.parent; + if (!parent) return null; + return this.std.view.getBlock(parent.id); + } + + get renderChildren() { + return this.host.renderChildren.bind(this); + } + + get rootComponent(): BlockComponent | null { + const rootId = this.doc.root?.id; + if (!rootId) { + return null; + } + const rootComponent = this.host.view.getBlock(rootId); + return rootComponent ?? null; + } + + get selected() { + return this._selected.value; + } + + get selection() { + return this.host.selection; + } + + get service(): Service { + if (this._service) { + return this._service; + } + const service = this.std.getService(this.model.flavour) as Service; + this._service = service; + return service; + } + + get topContenteditableElement(): BlockComponent | null { + return this.rootComponent; + } + + get widgetComponents(): Partial<Record<WidgetName, WidgetComponent>> { + return Object.keys(this.widgets).reduce( + (mapping, key) => ({ + ...mapping, + [key]: this.host.view.getWidget(key, this.blockId), + }), + {} + ); + } + + private _renderMismatchBlock(content: unknown) { + return when( + this.isVersionMismatch, + () => { + const actualVersion = this.model.version; + const schema = this.doc.schema.flavourSchemaMap.get(this.model.flavour); + const expectedVersion = schema?.version ?? -1; + return this.renderVersionMismatch(expectedVersion, actualVersion); + }, + () => content + ); + } + + private _renderViewType(content: unknown) { + return choose(this.viewType, [ + [BlockViewType.Display, () => content], + [BlockViewType.Hidden, () => nothing], + [BlockViewType.Bypass, () => this.renderChildren(this.model)], + ]); + } + + addRenderer(renderer: (content: unknown) => unknown) { + this._renderers.push(renderer); + } + + bindHotKey( + keymap: Record<string, UIEventHandler>, + options?: { global?: boolean; flavour?: boolean } + ) { + const dispose = this.host.event.bindHotkey(keymap, { + flavour: options?.global + ? undefined + : options?.flavour + ? this.model.flavour + : undefined, + blockId: options?.global || options?.flavour ? undefined : this.blockId, + }); + this._disposables.add(dispose); + return dispose; + } + + override connectedCallback() { + super.connectedCallback(); + + this.std.view.setBlock(this); + + const disposable = this.std.doc.slots.blockUpdated.on(({ type, id }) => { + if (id === this.model.id && type === 'delete') { + this.std.view.deleteBlock(this); + disposable.dispose(); + } + }); + this._disposables.add(disposable); + + this._disposables.add( + this.model.propsUpdated.on(() => { + this.requestUpdate(); + }) + ); + + this.service?.specSlots.viewConnected.emit({ + service: this.service, + component: this, + }); + } + + override disconnectedCallback() { + super.disconnectedCallback(); + + this.service?.specSlots.viewDisconnected.emit({ + service: this.service, + component: this, + }); + } + + protected override async getUpdateComplete(): Promise<boolean> { + const result = await super.getUpdateComplete(); + await Promise.all(this.childBlocks.map(el => el.updateComplete)); + return result; + } + + override render() { + return this._renderers.reduce( + (acc, cur) => cur.call(this, acc), + nothing as unknown + ); + } + + renderBlock(): unknown { + return nothing; + } + + /** + * Render a warning message when the block version is mismatched. + * @param expectedVersion If the schema is not found, the expected version is -1. + * Which means the block is not supported in the current editor. + * @param actualVersion The version of the block's crdt data. + */ + renderVersionMismatch( + expectedVersion: number, + actualVersion: number + ): TemplateResult { + return html` + <dl class="version-mismatch-warning" contenteditable="false"> + <dt> + <h4>Block Version Mismatched</h4> + </dt> + <dd> + <p> + We can not render this <var>${this.model.flavour}</var> block + because the version is mismatched. + </p> + <p>Editor version: <var>${expectedVersion}</var></p> + <p>Data version: <var>${actualVersion}</var></p> + </dd> + </dl> + `; + } + + @provide({ context: modelContext as never }) + @state() + private accessor _model: Model | null = null; + + @state() + protected accessor _renderers: Array<(content: unknown) => unknown> = [ + this.renderBlock, + this._renderMismatchBlock, + this._renderViewType, + ]; + + @provide({ context: serviceContext as never }) + @state() + private accessor _service: Service | null = null; + + @consume({ context: docContext }) + accessor doc!: Doc; + + @property({ attribute: false }) + accessor viewType: BlockViewType = BlockViewType.Display; + + @property({ + attribute: false, + hasChanged(value, oldValue) { + if (!value || !oldValue) { + return value !== oldValue; + } + // Is empty object + if (!Object.keys(value).length && !Object.keys(oldValue).length) { + return false; + } + return value !== oldValue; + }, + }) + accessor widgets!: Record<WidgetName, TemplateResult>; +} diff --git a/blocksuite/framework/block-std/src/view/element/consts.ts b/blocksuite/framework/block-std/src/view/element/consts.ts new file mode 100644 index 0000000000..d169405fd2 --- /dev/null +++ b/blocksuite/framework/block-std/src/view/element/consts.ts @@ -0,0 +1,10 @@ +import type { BlockModel } from '@blocksuite/store'; +import { createContext } from '@lit/context'; + +import type { BlockService } from '../../extension/index.js'; + +export const modelContext = createContext<BlockModel>('model'); +export const serviceContext = createContext<BlockService>('service'); +export const blockComponentSymbol = Symbol('blockComponent'); +export const WIDGET_ID_ATTR = 'data-widget-id'; +export const BLOCK_ID_ATTR = 'data-block-id'; diff --git a/blocksuite/framework/block-std/src/view/element/gfx-block-component.ts b/blocksuite/framework/block-std/src/view/element/gfx-block-component.ts new file mode 100644 index 0000000000..031f9696a7 --- /dev/null +++ b/blocksuite/framework/block-std/src/view/element/gfx-block-component.ts @@ -0,0 +1,230 @@ +import { BlockSuiteError, ErrorCode } from '@blocksuite/global/exceptions'; +import { Bound } from '@blocksuite/global/utils'; +import { nothing } from 'lit'; + +import type { BlockService } from '../../extension/index.js'; +import { GfxControllerIdentifier } from '../../gfx/identifiers.js'; +import type { GfxBlockElementModel } from '../../gfx/index.js'; +import { BlockComponent } from './block-component.js'; + +export function isGfxBlockComponent( + element: unknown +): element is GfxBlockComponent { + return (element as GfxBlockComponent)?.[GfxElementSymbol] === true; +} + +export const GfxElementSymbol = Symbol('GfxElement'); + +function updateTransform(element: GfxBlockComponent) { + element.style.transformOrigin = '0 0'; + element.style.transform = element.getCSSTransform(); +} + +function handleGfxConnection(instance: GfxBlockComponent) { + instance.style.position = 'absolute'; + + instance.disposables.add( + instance.gfx.viewport.viewportUpdated.on(() => { + updateTransform(instance); + }) + ); + + instance.disposables.add( + instance.doc.slots.blockUpdated.on(({ type, id }) => { + if (id === instance.model.id && type === 'update') { + updateTransform(instance); + } + }) + ); + + updateTransform(instance); +} + +export abstract class GfxBlockComponent< + Model extends GfxBlockElementModel = GfxBlockElementModel, + Service extends BlockService = BlockService, + WidgetName extends string = string, +> extends BlockComponent<Model, Service, WidgetName> { + [GfxElementSymbol] = true; + + get gfx() { + return this.std.get(GfxControllerIdentifier); + } + + override connectedCallback(): void { + super.connectedCallback(); + handleGfxConnection(this); + } + + getCSSTransform() { + const viewport = this.gfx.viewport; + const { translateX, translateY, zoom } = viewport; + const bound = Bound.deserialize(this.model.xywh); + + const scaledX = bound.x * zoom; + const scaledY = bound.y * zoom; + const deltaX = scaledX - bound.x; + const deltaY = scaledY - bound.y; + + return `translate(${translateX + deltaX}px, ${translateY + deltaY}px) scale(${zoom})`; + } + + getRenderingRect() { + const { xywh$ } = this.model; + + if (!xywh$) { + throw new BlockSuiteError( + ErrorCode.GfxBlockElementError, + `Error on rendering '${this.model.flavour}': Gfx block's model should have 'xywh' property.` + ); + } + + const [x, y, w, h] = JSON.parse(xywh$.value); + + return { x, y, w, h, zIndex: this.toZIndex() }; + } + + override renderBlock() { + const { x, y, w, h, zIndex } = this.getRenderingRect(); + + this.style.left = `${x}px`; + this.style.top = `${y}px`; + this.style.width = `${w}px`; + this.style.height = `${h}px`; + this.style.zIndex = zIndex; + + return this.renderGfxBlock(); + } + + renderGfxBlock(): unknown { + return nothing; + } + + renderPageContent(): unknown { + return nothing; + } + + override async scheduleUpdate() { + const parent = this.parentElement; + + if (this.hasUpdated || !parent || !('scheduleUpdateChildren' in parent)) { + super.scheduleUpdate(); + } else { + await (parent.scheduleUpdateChildren as (id: string) => Promise<void>)( + this.model.id + ); + + super.scheduleUpdate(); + } + } + + toZIndex(): string { + return this.gfx.layer.getZIndex(this.model).toString() ?? '0'; + } + + updateZIndex(): void { + this.style.zIndex = this.toZIndex(); + } +} + +export function toGfxBlockComponent< + Model extends GfxBlockElementModel, + Service extends BlockService, + WidgetName extends string, + B extends typeof BlockComponent<Model, Service, WidgetName>, +>(CustomBlock: B) { + // @ts-expect-error FIXME: ts error + return class extends CustomBlock { + [GfxElementSymbol] = true; + + get gfx() { + return this.std.get(GfxControllerIdentifier); + } + + override connectedCallback(): void { + super.connectedCallback(); + handleGfxConnection(this); + } + + // eslint-disable-next-line sonarjs/no-identical-functions + getCSSTransform() { + const viewport = this.gfx.viewport; + const { translateX, translateY, zoom } = viewport; + const bound = Bound.deserialize(this.model.xywh); + + const scaledX = bound.x * zoom; + const scaledY = bound.y * zoom; + const deltaX = scaledX - bound.x; + const deltaY = scaledY - bound.y; + + return `translate(${translateX + deltaX}px, ${translateY + deltaY}px) scale(${zoom})`; + } + + // eslint-disable-next-line sonarjs/no-identical-functions + getRenderingRect(): { + x: number; + y: number; + w: number | string; + h: number | string; + zIndex: string; + } { + const { xywh$ } = this.model; + + if (!xywh$) { + throw new BlockSuiteError( + ErrorCode.GfxBlockElementError, + `Error on rendering '${this.model.flavour}': Gfx block's model should have 'xywh' property.` + ); + } + + const [x, y, w, h] = JSON.parse(xywh$.value); + + return { x, y, w, h, zIndex: this.toZIndex() }; + } + + override renderBlock() { + const { x, y, w, h, zIndex } = this.getRenderingRect(); + + this.style.left = `${x}px`; + this.style.top = `${y}px`; + this.style.width = typeof w === 'number' ? `${w}px` : w; + this.style.height = typeof h === 'number' ? `${h}px` : h; + this.style.zIndex = zIndex; + + return this.renderGfxBlock(); + } + + renderGfxBlock(): unknown { + return this.renderPageContent(); + } + + renderPageContent() { + return super.renderBlock(); + } + + // eslint-disable-next-line sonarjs/no-identical-functions + override async scheduleUpdate() { + const parent = this.parentElement; + + if (this.hasUpdated || !parent || !('scheduleUpdateChildren' in parent)) { + super.scheduleUpdate(); + } else { + await (parent.scheduleUpdateChildren as (id: string) => Promise<void>)( + this.model.id + ); + + super.scheduleUpdate(); + } + } + + toZIndex(): string { + return this.gfx.layer.getZIndex(this.model).toString() ?? '0'; + } + + updateZIndex(): void { + this.style.zIndex = this.toZIndex(); + } + } as B & { + new (...args: any[]): GfxBlockComponent; + }; +} diff --git a/blocksuite/framework/block-std/src/view/element/index.ts b/blocksuite/framework/block-std/src/view/element/index.ts new file mode 100644 index 0000000000..a14635172d --- /dev/null +++ b/blocksuite/framework/block-std/src/view/element/index.ts @@ -0,0 +1,6 @@ +export * from './block-component.js'; +export * from './consts.js'; +export * from './gfx-block-component.js'; +export * from './lit-host.js'; +export * from './shadowless-element.js'; +export * from './widget-component.js'; diff --git a/blocksuite/framework/block-std/src/view/element/lit-host.ts b/blocksuite/framework/block-std/src/view/element/lit-host.ts new file mode 100644 index 0000000000..0cc55bd25b --- /dev/null +++ b/blocksuite/framework/block-std/src/view/element/lit-host.ts @@ -0,0 +1,203 @@ +import { + BlockSuiteError, + ErrorCode, + handleError, +} from '@blocksuite/global/exceptions'; +import { SignalWatcher, Slot, WithDisposable } from '@blocksuite/global/utils'; +import { type BlockModel, BlockViewType, Doc } from '@blocksuite/store'; +import { createContext, provide } from '@lit/context'; +import { css, LitElement, nothing, type TemplateResult } from 'lit'; +import { property } from 'lit/decorators.js'; +import { repeat } from 'lit/directives/repeat.js'; +import { html, type StaticValue, unsafeStatic } from 'lit/static-html.js'; + +import type { CommandManager } from '../../command/index.js'; +import type { UIEventDispatcher } from '../../event/index.js'; +import { WidgetViewMapIdentifier } from '../../identifier.js'; +import type { RangeManager } from '../../range/index.js'; +import type { BlockStdScope } from '../../scope/block-std-scope.js'; +import type { SelectionManager } from '../../selection/index.js'; +import { PropTypes, requiredProperties } from '../decorators/index.js'; +import type { ViewStore } from '../view-store.js'; +import { BLOCK_ID_ATTR, WIDGET_ID_ATTR } from './consts.js'; +import { ShadowlessElement } from './shadowless-element.js'; + +export const docContext = createContext<Doc>('doc'); +export const stdContext = createContext<BlockStdScope>('std'); + +@requiredProperties({ + doc: PropTypes.instanceOf(Doc), + std: PropTypes.object, +}) +export class EditorHost extends SignalWatcher( + WithDisposable(ShadowlessElement) +) { + static override styles = css` + editor-host { + outline: none; + isolation: isolate; + display: block; + height: 100%; + } + `; + + private _renderModel = (model: BlockModel): TemplateResult => { + const { flavour } = model; + const block = this.doc.getBlock(model.id); + if (!block || block.blockViewType === BlockViewType.Hidden) { + return html`${nothing}`; + } + const schema = this.doc.schema.flavourSchemaMap.get(flavour); + const view = this.std.getView(flavour); + if (!schema || !view) { + console.warn(`Cannot find render flavour ${flavour}.`); + return html`${nothing}`; + } + const widgetViewMap = this.std.getOptional( + WidgetViewMapIdentifier(flavour) + ); + + const tag = typeof view === 'function' ? view(model) : view; + const widgets: Record<string, TemplateResult> = widgetViewMap + ? Object.entries(widgetViewMap).reduce((mapping, [key, tag]) => { + const template = html`<${tag} ${unsafeStatic(WIDGET_ID_ATTR)}=${key}></${tag}>`; + + return { + ...mapping, + [key]: template, + }; + }, {}) + : {}; + + return html`<${tag} + ${unsafeStatic(BLOCK_ID_ATTR)}=${model.id} + .widgets=${widgets} + .viewType=${block.blockViewType} + ></${tag}>`; + }; + + /** + * @deprecated + * Render a block model manually instead of let blocksuite render it. + * If you render the same block model multiple times, + * the event flow and data binding will be broken. + * Only use this method as a last resort. + */ + dangerouslyRenderModel = (model: BlockModel): TemplateResult => { + return this._renderModel(model); + }; + + renderChildren = ( + model: BlockModel, + filter?: (model: BlockModel) => boolean + ): TemplateResult => { + return html`${repeat( + model.children.filter(filter ?? (() => true)), + child => child.id, + child => this._renderModel(child) + )}`; + }; + + readonly slots = { + unmounted: new Slot(), + }; + + get command(): CommandManager { + return this.std.command; + } + + get event(): UIEventDispatcher { + return this.std.event; + } + + get range(): RangeManager { + return this.std.range; + } + + get selection(): SelectionManager { + return this.std.selection; + } + + get view(): ViewStore { + return this.std.view; + } + + override connectedCallback() { + super.connectedCallback(); + + if (!this.doc.root) { + throw new BlockSuiteError( + ErrorCode.NoRootModelError, + 'This doc is missing root block. Please initialize the default block structure before connecting the editor to DOM.' + ); + } + + this.std.mount(); + this.tabIndex = 0; + } + + override disconnectedCallback() { + super.disconnectedCallback(); + this.std.unmount(); + this.slots.unmounted.emit(); + this.slots.unmounted.dispose(); + } + + override async getUpdateComplete(): Promise<boolean> { + try { + const result = await super.getUpdateComplete(); + const rootModel = this.doc.root; + if (!rootModel) return result; + + const view = this.std.getView(rootModel.flavour); + if (!view) return result; + + const widgetViewMap = this.std.getOptional( + WidgetViewMapIdentifier(rootModel.flavour) + ); + const widgetTags = Object.values(widgetViewMap ?? {}); + const elementsTags: StaticValue[] = [ + typeof view === 'function' ? view(rootModel) : view, + ...widgetTags, + ]; + await Promise.all( + elementsTags.map(tag => { + const element = this.renderRoot.querySelector(tag._$litStatic$); + if (element instanceof LitElement) { + return element.updateComplete; + } + return null; + }) + ); + return result; + } catch (e) { + if (e instanceof Error) { + handleError(e); + } else { + console.error(e); + } + return true; + } + } + + override render() { + const { root } = this.doc; + if (!root) return nothing; + + return this._renderModel(root); + } + + @provide({ context: docContext }) + @property({ attribute: false }) + accessor doc!: Doc; + + @provide({ context: stdContext }) + @property({ attribute: false }) + accessor std!: BlockSuite.Std; +} + +declare global { + interface HTMLElementTagNameMap { + 'editor-host': EditorHost; + } +} diff --git a/blocksuite/framework/block-std/src/view/element/shadowless-element.ts b/blocksuite/framework/block-std/src/view/element/shadowless-element.ts new file mode 100644 index 0000000000..a13446b677 --- /dev/null +++ b/blocksuite/framework/block-std/src/view/element/shadowless-element.ts @@ -0,0 +1,92 @@ +import type { Constructor } from '@blocksuite/global/utils'; +import type { CSSResultGroup, CSSResultOrNative } from 'lit'; +import { CSSResult, LitElement } from 'lit'; + +export class ShadowlessElement extends LitElement { + // Map of the number of styles injected into a node + // A reference count of the number of ShadowlessElements that are still connected + static connectedCount = new WeakMap< + Constructor, // class + WeakMap<Node, number> + >(); + + static onDisconnectedMap = new WeakMap< + Constructor, // class + (() => void) | null + >(); + + // styles registered in ShadowlessElement will be available globally + // even if the element is not being rendered + protected static override finalizeStyles( + styles?: CSSResultGroup + ): CSSResultOrNative[] { + const elementStyles = super.finalizeStyles(styles); + // XXX: This breaks component encapsulation and applies styles to the document. + // These styles should be manually scoped. + elementStyles.forEach((s: CSSResultOrNative) => { + if (s instanceof CSSResult && typeof document !== 'undefined') { + const styleRoot = document.head; + const style = document.createElement('style'); + style.textContent = s.cssText; + styleRoot.append(style); + } + }); + return elementStyles; + } + + private getConnectedCount() { + const SE = this.constructor as typeof ShadowlessElement; + return SE.connectedCount.get(SE)?.get(this.getRootNode()) ?? 0; + } + + private setConnectedCount(count: number) { + const SE = this.constructor as typeof ShadowlessElement; + + if (!SE.connectedCount.has(SE)) { + SE.connectedCount.set(SE, new WeakMap()); + } + + SE.connectedCount.get(SE)?.set(this.getRootNode(), count); + } + + override connectedCallback(): void { + super.connectedCallback(); + const parentRoot = this.getRootNode(); + const SE = this.constructor as typeof ShadowlessElement; + const insideShadowRoot = parentRoot instanceof ShadowRoot; + const styleInjectedCount = this.getConnectedCount(); + + if (styleInjectedCount === 0 && insideShadowRoot) { + const elementStyles = SE.elementStyles; + const injectedStyles: HTMLStyleElement[] = []; + elementStyles.forEach((s: CSSResultOrNative) => { + if (s instanceof CSSResult && typeof document !== 'undefined') { + const style = document.createElement('style'); + style.textContent = s.cssText; + parentRoot.prepend(style); + injectedStyles.push(style); + } + }); + SE.onDisconnectedMap.set(SE, () => { + injectedStyles.forEach(style => style.remove()); + }); + } + this.setConnectedCount(styleInjectedCount + 1); + } + + override createRenderRoot() { + return this; + } + + override disconnectedCallback(): void { + super.disconnectedCallback(); + const SE = this.constructor as typeof ShadowlessElement; + let styleInjectedCount = this.getConnectedCount(); + styleInjectedCount--; + this.setConnectedCount(styleInjectedCount); + + if (styleInjectedCount === 0) { + SE.onDisconnectedMap.get(SE)?.(); + } + } +} diff --git a/blocksuite/framework/block-std/src/view/element/widget-component.ts b/blocksuite/framework/block-std/src/view/element/widget-component.ts new file mode 100644 index 0000000000..6e0f1de165 --- /dev/null +++ b/blocksuite/framework/block-std/src/view/element/widget-component.ts @@ -0,0 +1,107 @@ +import { SignalWatcher, WithDisposable } from '@blocksuite/global/utils'; +import type { BlockModel, Doc } from '@blocksuite/store'; +import { consume } from '@lit/context'; +import { LitElement } from 'lit'; + +import type { EventName, UIEventHandler } from '../../event/index.js'; +import type { BlockService } from '../../extension/index.js'; +import type { BlockStdScope } from '../../scope/index.js'; +import type { BlockComponent } from './block-component.js'; +import { modelContext, serviceContext } from './consts.js'; +import { docContext, stdContext } from './lit-host.js'; + +export class WidgetComponent< + Model extends BlockModel = BlockModel, + B extends BlockComponent = BlockComponent, + S extends BlockService = BlockService, +> extends SignalWatcher(WithDisposable(LitElement)) { + handleEvent = ( + name: EventName, + handler: UIEventHandler, + options?: { global?: boolean } + ) => { + this._disposables.add( + this.host.event.add(name, handler, { + flavour: options?.global ? undefined : this.flavour, + }) + ); + }; + + get block() { + return this.std.view.getBlock(this.model.id) as B; + } + + get doc() { + return this._doc; + } + + get flavour(): string { + return this.model.flavour; + } + + get host() { + return this.std.host; + } + + get model() { + return this._model; + } + + get service() { + return this._service; + } + + get std() { + return this._std; + } + + get widgetId() { + return this.dataset.widgetId as string; + } + + bindHotKey( + keymap: Record<string, UIEventHandler>, + options?: { global: boolean } + ) { + this._disposables.add( + this.host.event.bindHotkey(keymap, { + flavour: options?.global ? undefined : this.flavour, + }) + ); + } + + override connectedCallback() { + super.connectedCallback(); + this.std.view.setWidget(this); + + this.service.specSlots.widgetConnected.emit({ + service: this.service, + component: this, + }); + } + + override disconnectedCallback() { + super.disconnectedCallback(); + this.std?.view.deleteWidget(this); + this.service.specSlots.widgetDisconnected.emit({ + service: this.service, + component: this, + }); + } + + override render(): unknown { + return null; + } + + @consume({ context: docContext }) + private accessor _doc!: Doc; + + @consume({ context: modelContext }) + private accessor _model!: Model; + + @consume({ context: serviceContext as never }) + private accessor _service!: S; + + @consume({ context: stdContext }) + private accessor _std!: BlockStdScope; +} diff --git a/blocksuite/framework/block-std/src/view/index.ts b/blocksuite/framework/block-std/src/view/index.ts new file mode 100644 index 0000000000..8f07ada0af --- /dev/null +++ b/blocksuite/framework/block-std/src/view/index.ts @@ -0,0 +1,3 @@ +export * from './decorators/index.js'; +export * from './element/index.js'; +export * from './view-store.js'; diff --git a/blocksuite/framework/block-std/src/view/view-store.ts b/blocksuite/framework/block-std/src/view/view-store.ts new file mode 100644 index 0000000000..7ea9a34b53 --- /dev/null +++ b/blocksuite/framework/block-std/src/view/view-store.ts @@ -0,0 +1,93 @@ +import { LifeCycleWatcher } from '../extension/index.js'; +import type { BlockComponent, WidgetComponent } from './element/index.js'; + +export class ViewStore extends LifeCycleWatcher { + static override readonly key = 'viewStore'; + + private readonly _blockMap = new Map<string, BlockComponent>(); + + private _fromId = ( + blockId: string | undefined | null + ): BlockComponent | null => { + const id = blockId ?? this.std.doc.root?.id; + if (!id) { + return null; + } + return this._blockMap.get(id) ?? null; + }; + + private readonly _widgetMap = new Map<string, WidgetComponent>(); + + deleteBlock = (node: BlockComponent) => { + this._blockMap.delete(node.id); + }; + + deleteWidget = (node: WidgetComponent) => { + const id = node.dataset.widgetId as string; + const widgetIndex = `${node.model.id}|${id}`; + this._widgetMap.delete(widgetIndex); + }; + + getBlock = (id: string): BlockComponent | null => { + return this._blockMap.get(id) ?? null; + }; + + getWidget = ( + widgetName: string, + hostBlockId: string + ): WidgetComponent | null => { + const widgetIndex = `${hostBlockId}|${widgetName}`; + return this._widgetMap.get(widgetIndex) ?? null; + }; + + setBlock = (node: BlockComponent) => { + this._blockMap.set(node.model.id, node); + }; + + setWidget = (node: WidgetComponent) => { + const id = node.dataset.widgetId as string; + const widgetIndex = `${node.model.id}|${id}`; + this._widgetMap.set(widgetIndex, node); + }; + + walkThrough = ( + fn: ( + nodeView: BlockComponent, + index: number, + parent: BlockComponent + ) => undefined | null | true, + blockId?: string | undefined | null + ) => { + const top = this._fromId(blockId); + if (!top) { + return; + } + + const iterate = + (parent: BlockComponent) => (node: BlockComponent, index: number) => { + const result = fn(node, index, parent); + if (result === true) { + return; + } + const children = node.model.children; + children.forEach(child => { + const childNode = this._blockMap.get(child.id); + if (childNode) { + iterate(node)(childNode, children.indexOf(child)); + } + }); + }; + + top.model.children.forEach(child => { + const childNode = this._blockMap.get(child.id); + if (childNode) { + iterate(childNode)(childNode, top.model.children.indexOf(child)); + } + }); + }; + + override unmounted() { + this._blockMap.clear(); + this._widgetMap.clear(); + } +} diff --git a/blocksuite/framework/block-std/tsconfig.json b/blocksuite/framework/block-std/tsconfig.json new file mode 100644 index 0000000000..9651613fc9 --- /dev/null +++ b/blocksuite/framework/block-std/tsconfig.json @@ -0,0 +1,20 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "rootDir": "./src/", + "outDir": "./dist/", + "noEmit": false + }, + "include": ["./src"], + "references": [ + { + "path": "../global" + }, + { + "path": "../store" + }, + { + "path": "../inline" + } + ] +} diff --git a/blocksuite/framework/block-std/typedoc.json b/blocksuite/framework/block-std/typedoc.json new file mode 100644 index 0000000000..101e923dba --- /dev/null +++ b/blocksuite/framework/block-std/typedoc.json @@ -0,0 +1,4 @@ +{ + "extends": ["../../../typedoc.base.json"], + "entryPoints": ["src/index.ts"] +} diff --git a/blocksuite/framework/block-std/vitest.config.ts b/blocksuite/framework/block-std/vitest.config.ts new file mode 100644 index 0000000000..e1f297c280 --- /dev/null +++ b/blocksuite/framework/block-std/vitest.config.ts @@ -0,0 +1,25 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + esbuild: { + target: 'es2018', + }, + test: { + browser: { + enabled: true, + headless: true, + name: 'chromium', + provider: 'playwright', + isolate: false, + providerOptions: {}, + }, + include: ['src/__tests__/**/*.unit.spec.ts'], + testTimeout: 500, + coverage: { + provider: 'istanbul', // or 'c8' + reporter: ['lcov'], + reportsDirectory: '../../../.coverage/block-std', + }, + restoreMocks: true, + }, +}); diff --git a/blocksuite/framework/global/package.json b/blocksuite/framework/global/package.json new file mode 100644 index 0000000000..1cc9a6d739 --- /dev/null +++ b/blocksuite/framework/global/package.json @@ -0,0 +1,51 @@ +{ + "name": "@blocksuite/global", + "types": "./index.d.ts", + "type": "module", + "scripts": { + "test:unit": "nx vite:test --run", + "test:unit:coverage": "nx vite:test --run --coverage", + "test:unit:ui": "nx vite:test --ui", + "build": "tsc" + }, + "sideEffects": false, + "exports": { + ".": "./src/index.ts", + "./utils": "./src/utils/index.ts", + "./env": "./src/env/index.ts", + "./exceptions": "./src/exceptions/index.ts", + "./di": "./src/di/index.ts", + "./types": "./src/types/index.ts" + }, + "typesVersions": { + "*": { + "utils": [ + "dist/utils/index.d.ts" + ], + "env": [ + "dist/env/index.d.ts" + ], + "exceptions": [ + "dist/exceptions/index.d.ts" + ], + "di": [ + "dist/di/index.d.ts" + ] + } + }, + "author": "toeverything", + "license": "MIT", + "files": [ + "src", + "dist", + "index.d.ts", + "!src/__tests__", + "!dist/__tests__" + ], + "dependencies": { + "@preact/signals-core": "^1.8.0", + "lib0": "^0.2.97", + "lit": "^3.2.0", + "zod": "^3.23.8" + } +} diff --git a/blocksuite/framework/global/src/__tests__/di.unit.spec.ts b/blocksuite/framework/global/src/__tests__/di.unit.spec.ts new file mode 100644 index 0000000000..60c7ad6bcd --- /dev/null +++ b/blocksuite/framework/global/src/__tests__/di.unit.spec.ts @@ -0,0 +1,362 @@ +import { describe, expect, test } from 'vitest'; + +import { + CircularDependencyError, + Container, + createIdentifier, + createScope, + DuplicateServiceDefinitionError, + MissingDependencyError, + RecursionLimitError, + ServiceNotFoundError, + ServiceProvider, +} from '../di/index.js'; + +describe('di', () => { + test('basic', () => { + const container = new Container(); + class TestService { + a = 'b'; + } + + container.add(TestService); + + const provider = container.provider(); + expect(provider.get(TestService)).toEqual({ a: 'b' }); + }); + + test('size', () => { + const container = new Container(); + class TestService { + a = 'b'; + } + + container.add(TestService); + + expect(container.size).toEqual(1); + }); + + test('dependency', () => { + const container = new Container(); + + class A { + value = 'hello world'; + } + + class B { + constructor(public a: A) {} + } + + class C { + constructor(public b: B) {} + } + + container.add(A).add(B, [A]).add(C, [B]); + + const provider = container.provider(); + + expect(provider.get(C).b.a.value).toEqual('hello world'); + }); + + test('identifier', () => { + interface Animal { + name: string; + } + const Animal = createIdentifier<Animal>('Animal'); + + class Cat { + name = 'cat'; + + constructor() {} + } + + class Zoo { + constructor(public animal: Animal) {} + } + + const container = new Container(); + container.addImpl(Animal, Cat).add(Zoo, [Animal]); + + const provider = container.provider(); + expect(provider.get(Zoo).animal.name).toEqual('cat'); + }); + + test('variant', () => { + const container = new Container(); + + interface USB { + speed: number; + } + + const USB = createIdentifier<USB>('USB'); + + class TypeA implements USB { + speed = 100; + } + class TypeC implements USB { + speed = 300; + } + + class PC { + constructor( + public typeA: USB, + public ports: USB[] + ) {} + } + + container + .addImpl(USB('A'), TypeA) + .addImpl(USB('C'), TypeC) + .add(PC, [USB('A'), [USB]]); + + const provider = container.provider(); + expect(provider.get(USB('A')).speed).toEqual(100); + expect(provider.get(USB('C')).speed).toEqual(300); + expect(provider.get(PC).typeA.speed).toEqual(100); + expect(provider.get(PC).ports.length).toEqual(2); + }); + + test('lazy initialization', () => { + const container = new Container(); + interface Command { + shortcut: string; + callback: () => void; + } + const Command = createIdentifier<Command>('command'); + + let pageSystemInitialized = false; + + class PageSystem { + mode = 'page'; + + name = 'helloworld'; + + constructor() { + pageSystemInitialized = true; + } + + rename() { + this.name = 'foobar'; + } + + switchToEdgeless() { + this.mode = 'edgeless'; + } + } + + class CommandSystem { + constructor(public commands: Command[]) {} + + execute(shortcut: string) { + const command = this.commands.find(c => c.shortcut === shortcut); + if (command) { + command.callback(); + } + } + } + + container.add(PageSystem); + container.add(CommandSystem, [[Command]]); + container.addImpl(Command('switch'), p => ({ + shortcut: 'option+s', + callback: () => p.get(PageSystem).switchToEdgeless(), + })); + container.addImpl(Command('rename'), p => ({ + shortcut: 'f2', + callback: () => p.get(PageSystem).rename(), + })); + + const provider = container.provider(); + const commandSystem = provider.get(CommandSystem); + + expect( + pageSystemInitialized, + "PageSystem won't be initialized until command executed" + ).toEqual(false); + + commandSystem.execute('option+s'); + expect(pageSystemInitialized).toEqual(true); + expect(provider.get(PageSystem).mode).toEqual('edgeless'); + + expect(provider.get(PageSystem).name).toEqual('helloworld'); + expect(commandSystem.commands.length).toEqual(2); + commandSystem.execute('f2'); + expect(provider.get(PageSystem).name).toEqual('foobar'); + }); + + test('duplicate, override', () => { + const container = new Container(); + + const something = createIdentifier<any>('USB'); + + class A { + a = 'i am A'; + } + + class B { + b = 'i am B'; + } + + container.addImpl(something, A).override(something, B); + + const provider = container.provider(); + expect(provider.get(something)).toEqual({ b: 'i am B' }); + }); + + test('scope', () => { + const container = new Container(); + + const workspaceScope = createScope('workspace'); + const pageScope = createScope('page', workspaceScope); + const editorScope = createScope('editor', pageScope); + + class System { + appName = 'affine'; + } + + container.add(System); + + class Workspace { + name = 'workspace'; + + constructor(public system: System) {} + } + + container.scope(workspaceScope).add(Workspace, [System]); + class Page { + name = 'page'; + + constructor( + public system: System, + public workspace: Workspace + ) {} + } + + container.scope(pageScope).add(Page, [System, Workspace]); + + class Editor { + name = 'editor'; + + constructor(public page: Page) {} + } + + container.scope(editorScope).add(Editor, [Page]); + + const root = container.provider(); + expect(root.get(System).appName).toEqual('affine'); + expect(() => root.get(Workspace)).toThrowError(ServiceNotFoundError); + + const workspace = container.provider(workspaceScope, root); + expect(workspace.get(Workspace).name).toEqual('workspace'); + expect(workspace.get(System).appName).toEqual('affine'); + expect(() => root.get(Page)).toThrowError(ServiceNotFoundError); + + const page = container.provider(pageScope, workspace); + expect(page.get(Page).name).toEqual('page'); + expect(page.get(Workspace).name).toEqual('workspace'); + expect(page.get(System).appName).toEqual('affine'); + + const editor = container.provider(editorScope, page); + expect(editor.get(Editor).name).toEqual('editor'); + }); + + test('service not found', () => { + const container = new Container(); + + const provider = container.provider(); + expect(() => provider.get(createIdentifier('SomeService'))).toThrowError( + ServiceNotFoundError + ); + }); + + test('missing dependency', () => { + const container = new Container(); + + class A { + value = 'hello world'; + } + + class B { + constructor(public a: A) {} + } + + container.add(B, [A]); + + const provider = container.provider(); + expect(() => provider.get(B)).toThrowError(MissingDependencyError); + }); + + test('circular dependency', () => { + const container = new Container(); + + class A { + constructor(public c: C) {} + } + + class B { + constructor(public a: A) {} + } + + class C { + constructor(public b: B) {} + } + + container.add(A, [C]).add(B, [A]).add(C, [B]); + + const provider = container.provider(); + expect(() => provider.get(A)).toThrowError(CircularDependencyError); + expect(() => provider.get(B)).toThrowError(CircularDependencyError); + expect(() => provider.get(C)).toThrowError(CircularDependencyError); + }); + + test('duplicate service definition', () => { + const container = new Container(); + + class A {} + + container.add(A); + expect(() => container.add(A)).toThrowError( + DuplicateServiceDefinitionError + ); + + class B {} + const Something = createIdentifier('something'); + container.addImpl(Something, A); + expect(() => container.addImpl(Something, B)).toThrowError( + DuplicateServiceDefinitionError + ); + }); + + test('recursion limit', () => { + // maxmium resolve depth is 100 + const container = new Container(); + const Something = createIdentifier('something'); + let i = 0; + for (; i < 100; i++) { + const next = i + 1; + + class Test { + constructor(_next: any) {} + } + + container.addImpl(Something(i.toString()), Test, [ + Something(next.toString()), + ]); + } + + class Final { + a = 'b'; + } + container.addImpl(Something(i.toString()), Final); + const provider = container.provider(); + expect(() => provider.get(Something('0'))).toThrowError( + RecursionLimitError + ); + }); + + test('self resolve', () => { + const container = new Container(); + const provider = container.provider(); + expect(provider.get(ServiceProvider)).toEqual(provider); + }); +}); diff --git a/blocksuite/framework/global/src/__tests__/slot.unit.spec.ts b/blocksuite/framework/global/src/__tests__/slot.unit.spec.ts new file mode 100644 index 0000000000..fb238dbe5d --- /dev/null +++ b/blocksuite/framework/global/src/__tests__/slot.unit.spec.ts @@ -0,0 +1,58 @@ +import { describe, expect, test, vi } from 'vitest'; + +import { Slot } from '../utils.js'; + +describe('slot', () => { + test('init', () => { + const slot = new Slot(); + expect(slot).is.toBeDefined(); + }); + + test('emit', () => { + const slot = new Slot<void>(); + const callback = vi.fn(); + slot.on(callback); + slot.emit(); + expect(callback).toBeCalled(); + }); + + test('emit with value', () => { + const slot = new Slot<number>(); + const callback = vi.fn(v => expect(v).toBe(5)); + slot.on(callback); + slot.emit(5); + expect(callback).toBeCalled(); + }); + + test('listen once', () => { + const slot = new Slot<number>(); + const callback = vi.fn(v => expect(v).toBe(5)); + slot.once(callback); + slot.emit(5); + slot.emit(6); + expect(callback).toBeCalledTimes(1); + }); + + test('listen once with dispose', () => { + const slot = new Slot<void>(); + const callback = vi.fn(() => { + throw new Error(''); + }); + const disposable = slot.once(callback); + disposable.dispose(); + slot.emit(); + expect(callback).toBeCalledTimes(0); + }); + + test('subscribe', () => { + type Data = { + name: string; + age: number; + }; + const slot = new Slot<Data>(); + const callback = vi.fn(v => expect(v).toBe('田所')); + slot.subscribe(v => v.name, callback); + slot.emit({ name: '田所', age: 24 }); + expect(callback).toBeCalledTimes(1); + }); +}); diff --git a/blocksuite/framework/global/src/__tests__/utils.unit.spec.ts b/blocksuite/framework/global/src/__tests__/utils.unit.spec.ts new file mode 100644 index 0000000000..9652ecea62 --- /dev/null +++ b/blocksuite/framework/global/src/__tests__/utils.unit.spec.ts @@ -0,0 +1,69 @@ +import { describe, expect, test } from 'vitest'; + +import { isEqual } from '../utils.js'; + +describe('isEqual', () => { + test('number', () => { + expect(isEqual(1, 1)).toBe(true); + expect(isEqual(1, 114514)).toBe(false); + expect(isEqual(NaN, NaN)).toBe(true); + expect(isEqual(0, -0)).toBe(false); + }); + + test('string', () => { + expect(isEqual('', '')).toBe(true); + expect(isEqual('', ' ')).toBe(false); + }); + + test('array', () => { + expect(isEqual([], [])).toBe(true); + expect(isEqual([1, 1, 4, 5, 1, 4], [])).toBe(false); + expect(isEqual([1, 1, 4, 5, 1, 4], [1, 1, 4, 5, 1, 4])).toBe(true); + }); + + test('object', () => { + expect(isEqual({}, {})).toBe(true); + expect( + isEqual( + { + f: 1, + g: { + o: '', + }, + }, + { + f: 1, + g: { + o: '', + }, + } + ) + ).toBe(true); + expect(isEqual({}, { foo: 1 })).toBe(false); + // @ts-expect-error FIXME: ts error + expect(isEqual({ foo: 1 }, {})).toBe(false); + }); + + test('nested', () => { + const nested = { + string: 'this is a string', + integer: 42, + array: [19, 19, 810, 'test', NaN], + nestedArray: [ + [1, 2], + [3, 4], + ], + float: 114.514, + undefined, + object: { + 'first-child': true, + 'second-child': false, + 'last-child': null, + }, + bigint: 110101195306153019n, + }; + expect(isEqual(nested, nested)).toBe(true); + // @ts-expect-error FIXME: ts error + expect(isEqual({ foo: [] }, { foo: '' })).toBe(false); + }); +}); diff --git a/blocksuite/framework/global/src/di/consts.ts b/blocksuite/framework/global/src/di/consts.ts new file mode 100644 index 0000000000..095a8212bc --- /dev/null +++ b/blocksuite/framework/global/src/di/consts.ts @@ -0,0 +1,4 @@ +import type { ServiceVariant } from './types.js'; + +export const DEFAULT_SERVICE_VARIANT: ServiceVariant = 'default'; +export const ROOT_SCOPE = []; diff --git a/blocksuite/framework/global/src/di/container.ts b/blocksuite/framework/global/src/di/container.ts new file mode 100644 index 0000000000..683fb2d122 --- /dev/null +++ b/blocksuite/framework/global/src/di/container.ts @@ -0,0 +1,459 @@ +import { DEFAULT_SERVICE_VARIANT, ROOT_SCOPE } from './consts.js'; +import { DuplicateServiceDefinitionError } from './error.js'; +import { parseIdentifier } from './identifier.js'; +import type { ServiceProvider } from './provider.js'; +import { BasicServiceProvider } from './provider.js'; +import { stringifyScope } from './scope.js'; +import type { + GeneralServiceIdentifier, + ServiceFactory, + ServiceIdentifier, + ServiceIdentifierType, + ServiceIdentifierValue, + ServiceScope, + ServiceVariant, + Type, + TypesToDeps, +} from './types.js'; + +/** + * A container of services. + * + * Container basically is a tuple of `[scope, identifier, variant, factory]` with some helper methods. + * It just stores the definitions of services. It never holds any instances of services. + * + * # Usage + * + * ```ts + * const services = new Container(); + * class ServiceA { + * // ... + * } + * // add a service + * services.add(ServiceA); + * + * class ServiceB { + * constructor(serviceA: ServiceA) {} + * } + * // add a service with dependency + * services.add(ServiceB, [ServiceA]); + * ^ dependency class/identifier, match ServiceB's constructor + * + * const FeatureA = createIdentifier<FeatureA>('Config'); + * + * // add a implementation for a service identifier + * services.addImpl(FeatureA, ServiceA); + * + * // override a service + * services.override(ServiceA, NewServiceA); + * + * // create a service provider + * const provider = services.provider(); + * ``` + * + * # The data structure + * + * The data structure of Container is a three-layer nested Map, used to represent the tuple of + * `[scope, identifier, variant, factory]`. + * Such a data structure ensures that a service factory can be uniquely determined by `[scope, identifier, variant]`. + * + * When a service added: + * + * ```ts + * services.add(ServiceClass) + * ``` + * + * The data structure will be: + * + * ```ts + * Map { + * '': Map { // scope + * 'ServiceClass': Map { // identifier + * 'default': // variant + * () => new ServiceClass() // factory + * } + * } + * ``` + * + * # Dependency relationship + * + * The dependency relationships of services are not actually stored in the Container, + * but are transformed into a factory function when the service is added. + * + * For example: + * + * ```ts + * services.add(ServiceB, [ServiceA]); + * + * // is equivalent to + * services.addFactory(ServiceB, (provider) => new ServiceB(provider.get(ServiceA))); + * ``` + * + * For multiple implementations of the same service identifier, can be defined as: + * + * ```ts + * services.add(ServiceB, [[FeatureA]]); + * + * // is equivalent to + * services.addFactory(ServiceB, (provider) => new ServiceB(provider.getAll(FeatureA))); + * ``` + */ +export class Container { + private readonly services = new Map< + string, + Map<string, Map<ServiceVariant, ServiceFactory>> + >(); + + /** + * @see {@link ContainerEditor.add} + */ + get add() { + return new ContainerEditor(this).add; + } + + /** + * @see {@link ContainerEditor.addImpl} + */ + get addImpl() { + return new ContainerEditor(this).addImpl; + } + + /** + * Create an empty service container. + * + * same as `new Container()` + */ + static get EMPTY() { + return new Container(); + } + + /** + * @see {@link ContainerEditor.scope} + */ + get override() { + return new ContainerEditor(this).override; + } + + /** + * @see {@link ContainerEditor.scope} + */ + get scope() { + return new ContainerEditor(this).scope; + } + + /** + * The number of services in the container. + */ + get size() { + let size = 0; + for (const [, identifiers] of this.services) { + for (const [, variants] of identifiers) { + size += variants.size; + } + } + return size; + } + + /** + * @internal Use {@link addImpl} instead. + */ + addFactory<T>( + identifier: GeneralServiceIdentifier<T>, + factory: ServiceFactory<T>, + { scope, override }: { scope?: ServiceScope; override?: boolean } = {} + ) { + // convert scope to string + const normalizedScope = stringifyScope(scope ?? ROOT_SCOPE); + const normalizedIdentifier = parseIdentifier(identifier); + const normalizedVariant = + normalizedIdentifier.variant ?? DEFAULT_SERVICE_VARIANT; + + const services = + this.services.get(normalizedScope) ?? + new Map<string, Map<ServiceVariant, ServiceFactory>>(); + + const variants = + services.get(normalizedIdentifier.identifierName) ?? + new Map<ServiceVariant, ServiceFactory>(); + + // throw if service already exists, unless it is an override + if (variants.has(normalizedVariant) && !override) { + throw new DuplicateServiceDefinitionError(normalizedIdentifier); + } + variants.set(normalizedVariant, factory); + services.set(normalizedIdentifier.identifierName, variants); + this.services.set(normalizedScope, services); + } + + /** + * @internal Use {@link addImpl} instead. + */ + addValue<T>( + identifier: GeneralServiceIdentifier<T>, + value: T, + { scope, override }: { scope?: ServiceScope; override?: boolean } = {} + ) { + this.addFactory( + parseIdentifier(identifier) as ServiceIdentifier<T>, + () => value, + { + scope, + override, + } + ); + } + + /** + * Clone the entire service container. + * + * This method is quite cheap as it only clones the references. + * + * @returns A new service container with the same services. + */ + clone(): Container { + const di = new Container(); + for (const [scope, identifiers] of this.services) { + const s = new Map(); + for (const [identifier, variants] of identifiers) { + s.set(identifier, new Map(variants)); + } + di.services.set(scope, s); + } + return di; + } + + /** + * @internal + */ + getFactory( + identifier: ServiceIdentifierValue, + scope: ServiceScope = ROOT_SCOPE + ): ServiceFactory | undefined { + return this.services + .get(stringifyScope(scope)) + ?.get(identifier.identifierName) + ?.get(identifier.variant ?? DEFAULT_SERVICE_VARIANT); + } + + /** + * @internal + */ + getFactoryAll( + identifier: ServiceIdentifierValue, + scope: ServiceScope = ROOT_SCOPE + ): Map<ServiceVariant, ServiceFactory> { + return new Map( + this.services.get(stringifyScope(scope))?.get(identifier.identifierName) + ); + } + + /** + * Create a service provider from the container. + * + * @example + * ```ts + * provider() // create a service provider for root scope + * provider(ScopeA, parentProvider) // create a service provider for scope A + * ``` + * + * @param scope The scope of the service provider, default to the root scope. + * @param parent The parent service provider, it is required if the scope is not the root scope. + */ + provider( + scope: ServiceScope = ROOT_SCOPE, + parent: ServiceProvider | null = null + ): ServiceProvider { + return new BasicServiceProvider(this, scope, parent); + } +} + +/** + * A helper class to edit a service container. + */ +class ContainerEditor { + private currentScope: ServiceScope = ROOT_SCOPE; + + /** + * Add a service to the container. + * + * @see {@link Container} + * + * @example + * ```ts + * add(ServiceClass, [dependencies, ...]) + * ``` + */ + add = < + T extends new (...args: any) => any, + const Deps extends TypesToDeps<ConstructorParameters<T>> = TypesToDeps< + ConstructorParameters<T> + >, + >( + cls: T, + ...[deps]: Deps extends [] ? [] : [Deps] + ): this => { + this.container.addFactory<any>( + cls as any, + dependenciesToFactory(cls, deps as any), + { scope: this.currentScope } + ); + + return this; + }; + + /** + * Add an implementation for identifier to the container. + * + * @see {@link Container} + * + * @example + * ```ts + * addImpl(ServiceIdentifier, ServiceClass, [dependencies, ...]) + * or + * addImpl(ServiceIdentifier, Instance) + * or + * addImpl(ServiceIdentifier, Factory) + * ``` + */ + addImpl = < + Arg1 extends ServiceIdentifier<any>, + Arg2 extends Type<Trait> | ServiceFactory<Trait> | Trait, + Trait = ServiceIdentifierType<Arg1>, + Deps extends Arg2 extends Type<Trait> + ? TypesToDeps<ConstructorParameters<Arg2>> + : [] = Arg2 extends Type<Trait> + ? TypesToDeps<ConstructorParameters<Arg2>> + : [], + Arg3 extends Deps = Deps, + >( + identifier: Arg1, + arg2: Arg2, + ...[arg3]: Arg3 extends [] ? [] : [Arg3] + ): this => { + if (arg2 instanceof Function) { + this.container.addFactory<any>( + identifier, + dependenciesToFactory(arg2, arg3 as any[]), + { scope: this.currentScope } + ); + } else { + this.container.addValue(identifier, arg2 as any, { + scope: this.currentScope, + }); + } + + return this; + }; + + /** + * same as {@link addImpl} but this method will override the service if it exists. + * + * @see {@link Container} + * + * @example + * ```ts + * override(OriginServiceClass, NewServiceClass, [dependencies, ...]) + * or + * override(ServiceIdentifier, ServiceClass, [dependencies, ...]) + * or + * override(ServiceIdentifier, Instance) + * or + * override(ServiceIdentifier, Factory) + * ``` + */ + override = < + Arg1 extends ServiceIdentifier<any>, + Arg2 extends Type<Trait> | ServiceFactory<Trait> | Trait, + Trait = ServiceIdentifierType<Arg1>, + Deps extends Arg2 extends Type<Trait> + ? TypesToDeps<ConstructorParameters<Arg2>> + : [] = Arg2 extends Type<Trait> + ? TypesToDeps<ConstructorParameters<Arg2>> + : [], + Arg3 extends Deps = Deps, + >( + identifier: Arg1, + arg2: Arg2, + ...[arg3]: Arg3 extends [] ? [] : [Arg3] + ): this => { + if (arg2 instanceof Function) { + this.container.addFactory<any>( + identifier, + dependenciesToFactory(arg2, arg3 as any[]), + { scope: this.currentScope, override: true } + ); + } else { + this.container.addValue(identifier, arg2 as any, { + scope: this.currentScope, + override: true, + }); + } + + return this; + }; + + /** + * Set the scope for the service registered subsequently + * + * @example + * + * ```ts + * const ScopeA = createScope('a'); + * + * services.scope(ScopeA).add(XXXService, ...); + * ``` + */ + scope = (scope: ServiceScope): ContainerEditor => { + this.currentScope = scope; + return this; + }; + + constructor(private readonly container: Container) {} +} + +/** + * Convert dependencies definition to a factory function. + */ +function dependenciesToFactory( + cls: any, + deps: any[] = [] +): ServiceFactory<any> { + return (provider: ServiceProvider) => { + const args = []; + for (const dep of deps) { + let isAll; + let identifier; + if (Array.isArray(dep)) { + if (dep.length !== 1) { + throw new Error('Invalid dependency'); + } + isAll = true; + identifier = dep[0]; + } else { + isAll = false; + identifier = dep; + } + if (isAll) { + args.push(Array.from(provider.getAll(identifier).values())); + } else { + args.push(provider.get(identifier)); + } + } + if (isConstructor(cls)) { + return new cls(...args, provider); + } else { + return cls(...args, provider); + } + }; +} + +// a hack to check if a function is a constructor +// https://github.com/zloirock/core-js/blob/232c8462c26c75864b4397b7f643a4f57c6981d5/packages/core-js/internals/is-constructor.js#L15 +function isConstructor(cls: unknown) { + try { + Reflect.construct(function () {}, [], cls as never); + return true; + } catch { + return false; + } +} diff --git a/blocksuite/framework/global/src/di/error.ts b/blocksuite/framework/global/src/di/error.ts new file mode 100644 index 0000000000..6f3f00cbb1 --- /dev/null +++ b/blocksuite/framework/global/src/di/error.ts @@ -0,0 +1,59 @@ +import { DEFAULT_SERVICE_VARIANT } from './consts.js'; +import type { ServiceIdentifierValue } from './types.js'; + +export class RecursionLimitError extends Error { + constructor() { + super('Dynamic resolve recursion limit reached'); + } +} + +export class CircularDependencyError extends Error { + constructor(readonly dependencyStack: ServiceIdentifierValue[]) { + super( + `A circular dependency was detected.\n` + + stringifyDependencyStack(dependencyStack) + ); + } +} + +export class ServiceNotFoundError extends Error { + constructor(readonly identifier: ServiceIdentifierValue) { + super(`Service ${stringifyIdentifier(identifier)} not found in container`); + } +} + +export class MissingDependencyError extends Error { + constructor( + readonly from: ServiceIdentifierValue, + readonly target: ServiceIdentifierValue, + readonly dependencyStack: ServiceIdentifierValue[] + ) { + super( + `Missing dependency ${stringifyIdentifier( + target + )} in creating service ${stringifyIdentifier( + from + )}.\n${stringifyDependencyStack(dependencyStack)}` + ); + } +} + +export class DuplicateServiceDefinitionError extends Error { + constructor(readonly identifier: ServiceIdentifierValue) { + super(`Service ${stringifyIdentifier(identifier)} already exists`); + } +} + +function stringifyIdentifier(identifier: ServiceIdentifierValue) { + return `[${identifier.identifierName}]${ + identifier.variant !== DEFAULT_SERVICE_VARIANT + ? `(${identifier.variant})` + : '' + }`; +} + +function stringifyDependencyStack(dependencyStack: ServiceIdentifierValue[]) { + return dependencyStack + .map(identifier => `${stringifyIdentifier(identifier)}`) + .join(' -> '); +} diff --git a/blocksuite/framework/global/src/di/identifier.ts b/blocksuite/framework/global/src/di/identifier.ts new file mode 100644 index 0000000000..872913f301 --- /dev/null +++ b/blocksuite/framework/global/src/di/identifier.ts @@ -0,0 +1,113 @@ +import { DEFAULT_SERVICE_VARIANT } from './consts.js'; +import { stableHash } from './stable-hash.js'; +import type { + ServiceIdentifier, + ServiceIdentifierValue, + ServiceVariant, + Type, +} from './types.js'; + +/** + * create a ServiceIdentifier. + * + * ServiceIdentifier is used to identify a certain type of service. With the identifier, you can reference one or more services + * without knowing the specific implementation, thereby achieving + * [inversion of control](https://en.wikipedia.org/wiki/Inversion_of_control). + * + * @example + * ```ts + * // define a interface + * interface Storage { + * get(key: string): string | null; + * set(key: string, value: string): void; + * } + * + * // create a identifier + * // NOTICE: Highly recommend to use the interface name as the identifier name, + * // so that it is easy to understand. and it is legal to do so in TypeScript. + * const Storage = createIdentifier<Storage>('Storage'); + * + * // create a implementation + * class LocalStorage implements Storage { + * get(key: string): string | null { + * return localStorage.getItem(key); + * } + * set(key: string, value: string): void { + * localStorage.setItem(key, value); + * } + * } + * + * // register the implementation to the identifier + * services.addImpl(Storage, LocalStorage); + * + * // get the implementation from the identifier + * const storage = services.provider().get(Storage); + * storage.set('foo', 'bar'); + * ``` + * + * With identifier: + * + * * You can easily replace the implementation of a `Storage` without changing the code that uses it. + * * You can easily mock a `Storage` for testing. + * + * # Variant + * + * Sometimes, you may want to register multiple implementations for the same interface. + * For example, you may want have both `LocalStorage` and `SessionStorage` for `Storage`, + * and use them in same time. + * + * In this case, you can use `variant` to distinguish them. + * + * ```ts + * const Storage = createIdentifier<Storage>('Storage'); + * const LocalStorage = Storage('local'); + * const SessionStorage = Storage('session'); + * + * services.addImpl(LocalStorage, LocalStorageImpl); + * services.addImpl(SessionStorage, SessionStorageImpl); + * + * // get the implementation from the identifier + * const localStorage = services.provider().get(LocalStorage); + * const sessionStorage = services.provider().get(SessionStorage); + * const storage = services.provider().getAll(Storage); // { local: LocalStorageImpl, session: SessionStorageImpl } + * ``` + * + * @param name unique name of the identifier. + * @param variant The default variant name of the identifier, can be overridden by `identifier("variant")`. + */ +export function createIdentifier<T>( + name: string, + variant: ServiceVariant = DEFAULT_SERVICE_VARIANT +): ServiceIdentifier<T> & ((variant: ServiceVariant) => ServiceIdentifier<T>) { + return Object.assign( + (variant: ServiceVariant) => { + return createIdentifier<T>(name, variant); + }, + { + identifierName: name, + variant, + } + ) as never; +} + +/** + * Convert the constructor into a ServiceIdentifier. + * As we always deal with ServiceIdentifier in the DI container. + * + * @internal + */ +export function createIdentifierFromConstructor<T>( + target: Type<T> +): ServiceIdentifier<T> { + return createIdentifier<T>(`${target.name}${stableHash(target)}`); +} + +export function parseIdentifier(input: any): ServiceIdentifierValue { + if (input.identifierName) { + return input as ServiceIdentifierValue; + } else if (typeof input === 'function' && input.name) { + return createIdentifierFromConstructor(input); + } else { + throw new Error('Input is not a service identifier.'); + } +} diff --git a/blocksuite/framework/global/src/di/index.ts b/blocksuite/framework/global/src/di/index.ts new file mode 100644 index 0000000000..ea8195aab1 --- /dev/null +++ b/blocksuite/framework/global/src/di/index.ts @@ -0,0 +1,7 @@ +export * from './consts.js'; +export * from './container.js'; +export * from './error.js'; +export * from './identifier.js'; +export * from './provider.js'; +export * from './scope.js'; +export * from './types.js'; diff --git a/blocksuite/framework/global/src/di/provider.ts b/blocksuite/framework/global/src/di/provider.ts new file mode 100644 index 0000000000..9205c48087 --- /dev/null +++ b/blocksuite/framework/global/src/di/provider.ts @@ -0,0 +1,219 @@ +import type { Container } from './container.js'; +import { + CircularDependencyError, + MissingDependencyError, + RecursionLimitError, + ServiceNotFoundError, +} from './error.js'; +import { parseIdentifier } from './identifier.js'; +import type { + GeneralServiceIdentifier, + ServiceIdentifierValue, + ServiceVariant, +} from './types.js'; + +export interface ResolveOptions { + sameScope?: boolean; + optional?: boolean; +} + +export abstract class ServiceProvider { + abstract container: Container; + + get<T>(identifier: GeneralServiceIdentifier<T>, options?: ResolveOptions): T { + return this.getRaw(parseIdentifier(identifier), { + ...options, + optional: false, + }); + } + + getAll<T>( + identifier: GeneralServiceIdentifier<T>, + options?: ResolveOptions + ): Map<ServiceVariant, T> { + return this.getAllRaw(parseIdentifier(identifier), { + ...options, + }); + } + + abstract getAllRaw( + identifier: ServiceIdentifierValue, + options?: ResolveOptions + ): Map<ServiceVariant, any>; + + getOptional<T>( + identifier: GeneralServiceIdentifier<T>, + options?: ResolveOptions + ): T | null { + return this.getRaw(parseIdentifier(identifier), { + ...options, + optional: true, + }); + } + + abstract getRaw( + identifier: ServiceIdentifierValue, + options?: ResolveOptions + ): any; +} + +export class ServiceCachePool { + cache = new Map<string, Map<ServiceVariant, any>>(); + + getOrInsert(identifier: ServiceIdentifierValue, insert: () => any) { + const cache = this.cache.get(identifier.identifierName) ?? new Map(); + if (!cache.has(identifier.variant)) { + cache.set(identifier.variant, insert()); + } + const cached = cache.get(identifier.variant); + this.cache.set(identifier.identifierName, cache); + return cached; + } +} + +export class ServiceResolver extends ServiceProvider { + container = this.provider.container; + + constructor( + readonly provider: BasicServiceProvider, + readonly depth = 0, + readonly stack: ServiceIdentifierValue[] = [] + ) { + super(); + } + + getAllRaw( + identifier: ServiceIdentifierValue, + { sameScope = false }: ResolveOptions = {} + ): Map<ServiceVariant, any> { + const vars = this.provider.container.getFactoryAll( + identifier, + this.provider.scope + ); + + if (vars === undefined) { + if (this.provider.parent && !sameScope) { + return this.provider.parent.getAllRaw(identifier); + } + + return new Map(); + } + + const result = new Map<ServiceVariant, any>(); + + for (const [variant, factory] of vars) { + const service = this.provider.cache.getOrInsert( + { identifierName: identifier.identifierName, variant }, + () => { + const nextResolver = this.track(identifier); + try { + return factory(nextResolver); + } catch (err) { + if (err instanceof ServiceNotFoundError) { + throw new MissingDependencyError( + identifier, + err.identifier, + this.stack + ); + } + throw err; + } + } + ); + result.set(variant, service); + } + + return result; + } + + getRaw( + identifier: ServiceIdentifierValue, + { sameScope = false, optional = false }: ResolveOptions = {} + ) { + const factory = this.provider.container.getFactory( + identifier, + this.provider.scope + ); + if (!factory) { + if (this.provider.parent && !sameScope) { + return this.provider.parent.getRaw(identifier, { + sameScope, + optional, + }); + } + + if (optional) { + return undefined; + } + throw new ServiceNotFoundError(identifier); + } + + return this.provider.cache.getOrInsert(identifier, () => { + const nextResolver = this.track(identifier); + try { + return factory(nextResolver); + } catch (err) { + if (err instanceof ServiceNotFoundError) { + throw new MissingDependencyError( + identifier, + err.identifier, + this.stack + ); + } + throw err; + } + }); + } + + track(identifier: ServiceIdentifierValue): ServiceResolver { + const depth = this.depth + 1; + if (depth >= 100) { + throw new RecursionLimitError(); + } + const circular = this.stack.find( + i => + i.identifierName === identifier.identifierName && + i.variant === identifier.variant + ); + if (circular) { + throw new CircularDependencyError([...this.stack, identifier]); + } + + return new ServiceResolver(this.provider, depth, [ + ...this.stack, + identifier, + ]); + } +} + +export class BasicServiceProvider extends ServiceProvider { + readonly cache = new ServiceCachePool(); + + readonly container: Container; + + constructor( + container: Container, + readonly scope: string[], + readonly parent: ServiceProvider | null + ) { + super(); + this.container = container.clone(); + this.container.addValue(ServiceProvider, this, { + scope: scope, + override: true, + }); + } + + getAllRaw( + identifier: ServiceIdentifierValue, + options?: ResolveOptions + ): Map<ServiceVariant, any> { + const resolver = new ServiceResolver(this); + return resolver.getAllRaw(identifier, options); + } + + getRaw(identifier: ServiceIdentifierValue, options?: ResolveOptions) { + const resolver = new ServiceResolver(this); + return resolver.getRaw(identifier, options); + } +} diff --git a/blocksuite/framework/global/src/di/scope.ts b/blocksuite/framework/global/src/di/scope.ts new file mode 100644 index 0000000000..6d84d512ee --- /dev/null +++ b/blocksuite/framework/global/src/di/scope.ts @@ -0,0 +1,13 @@ +import { ROOT_SCOPE } from './consts.js'; +import type { ServiceScope } from './types.js'; + +export function createScope( + name: string, + base: ServiceScope = ROOT_SCOPE +): ServiceScope { + return [...base, name]; +} + +export function stringifyScope(scope: ServiceScope): string { + return scope.join('/'); +} diff --git a/blocksuite/framework/global/src/di/stable-hash.ts b/blocksuite/framework/global/src/di/stable-hash.ts new file mode 100644 index 0000000000..406a944211 --- /dev/null +++ b/blocksuite/framework/global/src/di/stable-hash.ts @@ -0,0 +1,59 @@ +// copied from https://github.com/shuding/stable-hash + +// Use WeakMap to store the object-key mapping so the objects can still be +// garbage collected. WeakMap uses a hashtable under the hood, so the lookup +// complexity is almost O(1). +const table = new WeakMap<object, string>(); + +// A counter of the key. +let counter = 0; + +// A stable hash implementation that supports: +// - Fast and ensures unique hash properties +// - Handles unserializable values +// - Handles object key ordering +// - Generates short results +// +// This is not a serialization function, and the result is not guaranteed to be +// parsable. +export function stableHash(arg: any): string { + const type = typeof arg; + const constructor = arg && arg.constructor; + const isDate = constructor === Date; + + if (Object(arg) === arg && !isDate && constructor !== RegExp) { + // Object/function, not null/date/regexp. Use WeakMap to store the id first. + // If it's already hashed, directly return the result. + let result = table.get(arg); + if (result) return result; + // Store the hash first for circular reference detection before entering the + // recursive `stableHash` calls. + // For other objects like set and map, we use this id directly as the hash. + result = ++counter + '~'; + table.set(arg, result); + let index: any; + + if (constructor === Array) { + // Array. + result = '@'; + for (index = 0; index < arg.length; index++) { + result += stableHash(arg[index]) + ','; + } + table.set(arg, result); + } else if (constructor === Object) { + // Object, sort keys. + result = '#'; + const keys = Object.keys(arg).sort(); + while ((index = keys.pop() as string) !== undefined) { + if (arg[index] !== undefined) { + result += index + ':' + stableHash(arg[index]) + ','; + } + } + table.set(arg, result); + } + return result; + } + if (isDate) return arg.toJSON(); + if (type === 'symbol') return arg.toString(); + return type === 'string' ? JSON.stringify(arg) : '' + arg; +} diff --git a/blocksuite/framework/global/src/di/types.ts b/blocksuite/framework/global/src/di/types.ts new file mode 100644 index 0000000000..40b0939bb3 --- /dev/null +++ b/blocksuite/framework/global/src/di/types.ts @@ -0,0 +1,37 @@ +import type { ServiceProvider } from './provider.js'; + +export type Type<T = any> = abstract new (...args: any) => T; + +export type ServiceFactory<T = any> = (provider: ServiceProvider) => T; +export type ServiceVariant = string; + +/** + * + */ +export type ServiceScope = string[]; + +export type ServiceIdentifierValue = { + identifierName: string; + variant: ServiceVariant; +}; + +export type GeneralServiceIdentifier<T = any> = ServiceIdentifier<T> | Type<T>; + +export type ServiceIdentifier<T> = { + identifierName: string; + variant: ServiceVariant; + __TYPE__: T; +}; + +export type ServiceIdentifierType<T> = + T extends ServiceIdentifier<infer R> + ? R + : T extends Type<infer R> + ? R + : never; + +export type TypesToDeps<T extends any[]> = { + [index in keyof T]: + | GeneralServiceIdentifier<T[index]> + | (T[index] extends (infer I)[] ? [GeneralServiceIdentifier<I>] : never); +}; diff --git a/blocksuite/framework/global/src/env/index.ts b/blocksuite/framework/global/src/env/index.ts new file mode 100644 index 0000000000..98a62649f7 --- /dev/null +++ b/blocksuite/framework/global/src/env/index.ts @@ -0,0 +1,29 @@ +const agent = globalThis.navigator?.userAgent ?? ''; +const platform = globalThis.navigator?.platform; + +export const IS_WEB = + typeof window !== 'undefined' && typeof document !== 'undefined'; + +export const IS_NODE = typeof process !== 'undefined' && !IS_WEB; + +export const IS_SAFARI = /Apple Computer/.test(globalThis.navigator?.vendor); + +export const IS_FIREFOX = + IS_WEB && navigator.userAgent.toLowerCase().indexOf('firefox') > -1; + +export const IS_ANDROID = /Android \d/.test(agent); + +export const IS_IOS = + IS_SAFARI && + (/Mobile\/\w+/.test(agent) || globalThis.navigator?.maxTouchPoints > 2); + +export const IS_MAC = /Mac/i.test(platform); + +export const IS_IPAD = + /iPad/i.test(platform) || + /iPad/i.test(agent) || + (/Macintosh/i.test(agent) && globalThis.navigator?.maxTouchPoints > 2); + +export const IS_WINDOWS = /Win/.test(platform); + +export const IS_MOBILE = IS_IOS || IS_IPAD || IS_ANDROID; diff --git a/blocksuite/framework/global/src/exceptions/code.ts b/blocksuite/framework/global/src/exceptions/code.ts new file mode 100644 index 0000000000..bcfa95276e --- /dev/null +++ b/blocksuite/framework/global/src/exceptions/code.ts @@ -0,0 +1,30 @@ +export enum ErrorCode { + DefaultRuntimeError = 1, + ReactiveProxyError, + DocCollectionError, + ModelCRUDError, + ValueNotExists, + ValueNotInstanceOf, + ValueNotEqual, + MigrationError, + SchemaValidateError, + TransformerError, + InlineEditorError, + TransformerNotImplementedError, + EdgelessExportError, + CommandError, + EventDispatcherError, + SelectionError, + GfxBlockElementError, + MissingViewModelError, + DatabaseBlockError, + ParsingError, + UserAbortError, + ExecutionError, + + // Fatal error should be greater than 10000 + DefaultFatalError = 10000, + NoRootModelError, + NoSurfaceModelError, + NoneSupportedSSRError, +} diff --git a/blocksuite/framework/global/src/exceptions/index.ts b/blocksuite/framework/global/src/exceptions/index.ts new file mode 100644 index 0000000000..bf453b43b7 --- /dev/null +++ b/blocksuite/framework/global/src/exceptions/index.ts @@ -0,0 +1,34 @@ +import type { ErrorCode } from './code.js'; + +export class BlockSuiteError extends Error { + code: ErrorCode; + + isFatal: boolean; + + constructor(code: ErrorCode, message: string, options?: { cause: Error }) { + super(message, options); + this.name = 'BlockSuiteError'; + this.code = code; + this.isFatal = code >= 10000; + } +} + +export function handleError(error: Error) { + if (!(error instanceof BlockSuiteError)) { + throw error; + } + + if (error.isFatal) { + throw new Error( + 'A fatal error for BlockSuite occurs, please contact the team if you find this.', + { cause: error } + ); + } + + console.error( + "A runtime error for BlockSuite occurs, you can ignore this error if it won't break the user experience." + ); + console.error(error.stack); +} + +export * from './code.js'; diff --git a/blocksuite/framework/global/src/index.ts b/blocksuite/framework/global/src/index.ts new file mode 100644 index 0000000000..cb0ff5c3b5 --- /dev/null +++ b/blocksuite/framework/global/src/index.ts @@ -0,0 +1 @@ +export {}; diff --git a/blocksuite/framework/global/src/types/index.ts b/blocksuite/framework/global/src/types/index.ts new file mode 100644 index 0000000000..fa1db53051 --- /dev/null +++ b/blocksuite/framework/global/src/types/index.ts @@ -0,0 +1,22 @@ +export interface BlockSuiteFlags { + enable_synced_doc_block: boolean; + enable_pie_menu: boolean; + enable_database_number_formatting: boolean; + enable_database_attachment_note: boolean; + enable_database_full_width: boolean; + enable_block_query: boolean; + enable_legacy_validation: boolean; + enable_lasso_tool: boolean; + enable_edgeless_text: boolean; + enable_ai_onboarding: boolean; + enable_ai_chat_block: boolean; + enable_color_picker: boolean; + enable_mind_map_import: boolean; + enable_advanced_block_visibility: boolean; + enable_shape_shadow_blur: boolean; + enable_new_dnd: boolean; + enable_mobile_keyboard_toolbar: boolean; + enable_mobile_linked_doc_menu: boolean; + readonly: Record<string, boolean>; +} +export * from './virtual-keyboard.js'; diff --git a/blocksuite/framework/global/src/types/virtual-keyboard.ts b/blocksuite/framework/global/src/types/virtual-keyboard.ts new file mode 100644 index 0000000000..5198d76749 --- /dev/null +++ b/blocksuite/framework/global/src/types/virtual-keyboard.ts @@ -0,0 +1,43 @@ +declare global { + interface Navigator { + readonly virtualKeyboard?: VirtualKeyboard; + } + + interface VirtualKeyboard extends EventTarget { + readonly boundingRect: DOMRect; + overlaysContent: boolean; + hide: () => void; + show: () => void; + ongeometrychange: ((this: VirtualKeyboard, ev: Event) => any) | null; + addEventListener<K extends keyof VirtualKeyboardEventMap>( + type: K, + listener: (this: VirtualKeyboard, ev: VirtualKeyboardEventMap[K]) => any, + options?: boolean | AddEventListenerOptions + ): void; + addEventListener( + type: string, + listener: EventListenerOrEventListenerObject, + options?: boolean | AddEventListenerOptions + ): void; + removeEventListener<K extends keyof VirtualKeyboardEventMap>( + type: K, + listener: (this: VirtualKeyboard, ev: VirtualKeyboardEventMap[K]) => any, + options?: boolean | EventListenerOptions + ): void; + removeEventListener( + type: string, + listener: EventListenerOrEventListenerObject, + options?: boolean | EventListenerOptions + ): void; + } + + interface VirtualKeyboardEventMap { + geometrychange: Event; + } + + interface ElementContentEditable { + virtualKeyboardPolicy: string; + } +} + +export {}; diff --git a/blocksuite/framework/global/src/utils.ts b/blocksuite/framework/global/src/utils.ts new file mode 100644 index 0000000000..3ac870a71a --- /dev/null +++ b/blocksuite/framework/global/src/utils.ts @@ -0,0 +1 @@ +export * from './utils/index.js'; diff --git a/blocksuite/framework/global/src/utils/assert.ts b/blocksuite/framework/global/src/utils/assert.ts new file mode 100644 index 0000000000..e2ed55c778 --- /dev/null +++ b/blocksuite/framework/global/src/utils/assert.ts @@ -0,0 +1,107 @@ +// https://stackoverflow.com/questions/31538010/test-if-a-variable-is-a-primitive-rather-than-an-object +import { ErrorCode } from '../exceptions/code.js'; +import { BlockSuiteError } from '../exceptions/index.js'; + +export function isPrimitive( + a: unknown +): a is null | undefined | boolean | number | string { + return a !== Object(a); +} + +export function assertType<T>(_: unknown): asserts _ is T {} + +/** + * @deprecated Avoid using this util as escape hatch of error handling. + * For non-framework code, please handle error in application level instead. + */ +export function assertExists<T>( + val: T | null | undefined, + message: string | Error = 'val does not exist', + errorCode = ErrorCode.ValueNotExists +): asserts val is T { + if (val === null || val === undefined) { + if (message instanceof Error) { + throw message; + } + throw new BlockSuiteError(errorCode, message); + } +} + +export function assertNotExists<T>( + val: T | null | undefined, + message = 'val exists', + errorCode = ErrorCode.ValueNotExists +): asserts val is null | undefined { + if (val !== null && val !== undefined) { + throw new BlockSuiteError(errorCode, message); + } +} + +export type Equals<X, Y> = + /// + (<T>() => T extends X ? 1 : 2) extends <T>() => T extends Y ? 1 : 2 + ? true + : false; + +type Allowed = + | unknown + | void + | null + | undefined + | boolean + | number + | string + | unknown[] + | object; +export function isEqual<T extends Allowed, U extends T>( + val: T, + expected: U +): Equals<T, U> { + const a = isPrimitive(val); + const b = isPrimitive(expected); + if (a && b) { + if (!Object.is(val, expected)) { + return false as Equals<T, U>; + } + } else if (a !== b) { + return false as Equals<T, U>; + } else { + if (Array.isArray(val) && Array.isArray(expected)) { + if (val.length !== expected.length) { + return false as Equals<T, U>; + } + return val.every((x, i) => isEqual(x, expected[i])) as Equals<T, U>; + } else if (typeof val === 'object' && typeof expected === 'object') { + const obj1 = Object.entries(val as Record<string, unknown>); + const obj2 = Object.entries(expected as Record<string, unknown>); + if (obj1.length !== obj2.length) { + return false as Equals<T, U>; + } + return obj1.every((x, i) => isEqual(x, obj2[i])) as Equals<T, U>; + } + } + return true as Equals<T, U>; +} +export function assertEquals<T extends Allowed, U extends T>( + val: T, + expected: U, + message = 'val is not same as expected', + errorCode = ErrorCode.ValueNotEqual +): asserts val is U { + if (!isEqual(val, expected)) { + throw new BlockSuiteError(errorCode, message); + } +} + +type Class<T> = new (...args: any[]) => T; + +export function assertInstanceOf<T>( + val: unknown, + expected: Class<T>, + message = 'val is not instance of expected', + errorCode = ErrorCode.ValueNotInstanceOf +): asserts val is T { + if (!(val instanceof expected)) { + throw new BlockSuiteError(errorCode, message); + } +} diff --git a/blocksuite/framework/global/src/utils/bound.ts b/blocksuite/framework/global/src/utils/bound.ts new file mode 100644 index 0000000000..32fd5bf466 --- /dev/null +++ b/blocksuite/framework/global/src/utils/bound.ts @@ -0,0 +1,175 @@ +import { Bound, getIBoundFromPoints, type IBound } from './model/bound.js'; +import { type IVec } from './model/vec.js'; + +function getExpandedBound(a: IBound, b: IBound): IBound { + const minX = Math.min(a.x, b.x); + const minY = Math.min(a.y, b.y); + const maxX = Math.max(a.x + a.w, b.x + b.w); + const maxY = Math.max(a.y + a.h, b.y + b.h); + const width = Math.abs(maxX - minX); + const height = Math.abs(maxY - minY); + + return { + x: minX, + y: minY, + w: width, + h: height, + }; +} + +export function getPointsFromBoundWithRotation( + bounds: IBound, + getPoints: (bounds: IBound) => IVec[] = ({ x, y, w, h }: IBound) => [ + // left-top + [x, y], + // right-top + [x + w, y], + // right-bottom + [x + w, y + h], + // left-bottom + [x, y + h], + ], + resPadding: [number, number] = [0, 0] +): IVec[] { + const { rotate } = bounds; + let points = getPoints({ + x: bounds.x - resPadding[1], + y: bounds.y - resPadding[0], + w: bounds.w + resPadding[1] * 2, + h: bounds.h + resPadding[0] * 2, + }); + + if (rotate) { + const { x, y, w, h } = bounds; + const cx = x + w / 2; + const cy = y + h / 2; + + const m = new DOMMatrix() + .translateSelf(cx, cy) + .rotateSelf(rotate) + .translateSelf(-cx, -cy); + + points = points.map(point => { + const { x, y } = new DOMPoint(...point).matrixTransform(m); + return [x, y]; + }); + } + + return points; +} + +export function getQuadBoundWithRotation(bounds: IBound): DOMRect { + const { x, y, w, h, rotate } = bounds; + const rect = new DOMRect(x, y, w, h); + + if (!rotate) return rect; + + return new DOMQuad( + ...getPointsFromBoundWithRotation(bounds).map( + point => new DOMPoint(...point) + ) + ).getBounds(); +} + +export function getBoundWithRotation(bound: IBound): IBound { + const { x, y, width: w, height: h } = getQuadBoundWithRotation(bound); + return { x, y, w, h }; +} + +/** + * Returns the common bound of the given bounds. + * The rotation of the bounds is not considered. + * @param bounds + * @returns + */ +export function getCommonBound(bounds: IBound[]): Bound | null { + if (!bounds.length) { + return null; + } + + if (bounds.length === 1) { + const { x, y, w, h } = bounds[0]; + return new Bound(x, y, w, h); + } + + let result = bounds[0]; + + for (let i = 1; i < bounds.length; i++) { + result = getExpandedBound(result, bounds[i]); + } + + return new Bound(result.x, result.y, result.w, result.h); +} + +/** + * Like `getCommonBound`, but considers the rotation of the bounds. + * @returns + */ +export function getCommonBoundWithRotation(bounds: IBound[]): Bound { + if (bounds.length === 0) { + return new Bound(0, 0, 0, 0); + } + + return bounds.reduce( + (pre, bound) => { + return pre.unite( + bound instanceof Bound ? bound : Bound.from(getBoundWithRotation(bound)) + ); + }, + Bound.from(getBoundWithRotation(bounds[0])) + ); +} + +export function getBoundFromPoints(points: IVec[]) { + return Bound.from(getIBoundFromPoints(points)); +} + +export function inflateBound(bound: IBound, delta: number) { + const half = delta / 2; + + const newBound = new Bound( + bound.x - half, + bound.y - half, + bound.w + delta, + bound.h + delta + ); + + if (newBound.w <= 0 || newBound.h <= 0) { + throw new Error('Invalid delta range or bound size.'); + } + + return newBound; +} + +export function transformPointsToNewBound<T extends { x: number; y: number }>( + points: T[], + oldBound: IBound, + oldMargin: number, + newBound: IBound, + newMargin: number +) { + const wholeOldMargin = oldMargin * 2; + const wholeNewMargin = newMargin * 2; + const oldW = Math.max(oldBound.w - wholeOldMargin, 1); + const oldH = Math.max(oldBound.h - wholeOldMargin, 1); + const newW = Math.max(newBound.w - wholeNewMargin, 1); + const newH = Math.max(newBound.h - wholeNewMargin, 1); + + const transformedPoints = points.map(p => { + return { + ...p, + x: newW * ((p.x - oldMargin) / oldW) + newMargin, + y: newH * ((p.y - oldMargin) / oldH) + newMargin, + } as T; + }); + + return { + points: transformedPoints, + bound: new Bound( + newBound.x, + newBound.y, + newW + wholeNewMargin, + newH + wholeNewMargin + ), + }; +} diff --git a/blocksuite/framework/global/src/utils/crypto.ts b/blocksuite/framework/global/src/utils/crypto.ts new file mode 100644 index 0000000000..074e7aa197 --- /dev/null +++ b/blocksuite/framework/global/src/utils/crypto.ts @@ -0,0 +1,12 @@ +import { toBase64 } from 'lib0/buffer.js'; +import { digest } from 'lib0/hash/sha256'; + +export async function sha(input: ArrayBuffer): Promise<string> { + const hash = + crypto.subtle === undefined // crypto.subtle is not available without a secure context (HTTPS) + ? digest(new Uint8Array(input)) + : await crypto.subtle.digest('SHA-256', input); + + // faster conversion from ArrayBuffer to base64 in browser + return toBase64(new Uint8Array(hash)).replace(/\+/g, '-').replace(/\//g, '_'); +} diff --git a/blocksuite/framework/global/src/utils/curve.ts b/blocksuite/framework/global/src/utils/curve.ts new file mode 100644 index 0000000000..16637cb2e4 --- /dev/null +++ b/blocksuite/framework/global/src/utils/curve.ts @@ -0,0 +1,404 @@ +// control coords are not relative to start or end +import { assertExists } from './assert.js'; +import { CURVETIME_EPSILON, isZero } from './math.js'; +import { Bound, type IVec, PointLocation, Vec } from './model/index.js'; + +export type BezierCurveParameters = [ + start: IVec, + control1: IVec, + control2: IVec, + end: IVec, +]; + +function evaluate( + v: BezierCurveParameters, + t: number, + type: number, + normalized: boolean +): IVec | null { + if (t == null || t < 0 || t > 1) return null; + const x0 = v[0][0], + y0 = v[0][1], + x3 = v[3][0], + y3 = v[3][1]; + let x1 = v[1][0], + y1 = v[1][1], + x2 = v[2][0], + y2 = v[2][1]; + + if (isZero(x1 - x0) && isZero(y1 - y0)) { + x1 = x0; + y1 = y0; + } + if (isZero(x2 - x3) && isZero(y2 - y3)) { + x2 = x3; + y2 = y3; + } + // Calculate the polynomial coefficients. + const cx = 3 * (x1 - x0), + bx = 3 * (x2 - x1) - cx, + ax = x3 - x0 - cx - bx, + cy = 3 * (y1 - y0), + by = 3 * (y2 - y1) - cy, + ay = y3 - y0 - cy - by; + let x, y; + if (type === 0) { + // type === 0: getPoint() + x = t === 0 ? x0 : t === 1 ? x3 : ((ax * t + bx) * t + cx) * t + x0; + y = t === 0 ? y0 : t === 1 ? y3 : ((ay * t + by) * t + cy) * t + y0; + } else { + // type === 1: getTangent() + // type === 2: getNormal() + // type === 3: getCurvature() + const tMin = CURVETIME_EPSILON, + tMax = 1 - tMin; + if (t < tMin) { + x = cx; + y = cy; + } else if (t > tMax) { + x = 3 * (x3 - x2); + y = 3 * (y3 - y2); + } else { + x = (3 * ax * t + 2 * bx) * t + cx; + y = (3 * ay * t + 2 * by) * t + cy; + } + if (normalized) { + if (x === 0 && y === 0 && (t < tMin || t > tMax)) { + x = x2 - x1; + y = y2 - y1; + } + const len = Math.sqrt(x * x + y * y); + if (len) { + x /= len; + y /= len; + } + } + if (type === 3) { + const x2 = 6 * ax * t + 2 * bx, + y2 = 6 * ay * t + 2 * by, + d = Math.pow(x * x + y * y, 3 / 2); + x = d !== 0 ? (x * y2 - y * x2) / d : 0; + y = 0; + } + } + return type === 2 ? [y, -x] : [x, y]; +} + +export function getBezierPoint(values: BezierCurveParameters, t: number) { + return evaluate(values, t, 0, false); +} + +export function getBezierTangent(values: BezierCurveParameters, t: number) { + return evaluate(values, t, 1, true); +} + +export function getBezierNormal(values: BezierCurveParameters, t: number) { + return evaluate(values, t, 2, true); +} + +export function getBezierCurvature(values: BezierCurveParameters, t: number) { + return evaluate(values, t, 3, false)?.[0]; +} + +export function getBezierNearestTime( + values: BezierCurveParameters, + point: IVec +) { + const count = 100; + let minDist = Infinity, + minT = 0; + + function refine(t: number) { + if (t >= 0 && t <= 1) { + const tmpPoint = getBezierPoint(values, t); + assertExists(tmpPoint); + const dist = Vec.dist2(point, tmpPoint); + if (dist < minDist) { + minDist = dist; + minT = t; + return true; + } + } + return false; + } + + for (let i = 0; i <= count; i++) refine(i / count); + + let step = 1 / (count * 2); + while (step > CURVETIME_EPSILON) { + if (!refine(minT - step) && !refine(minT + step)) step /= 2; + } + return minT; +} + +export function getBezierNearestPoint( + values: BezierCurveParameters, + point: IVec +) { + const t = getBezierNearestTime(values, point); + const pointOnCurve = getBezierPoint(values, t); + assertExists(pointOnCurve); + return pointOnCurve; +} + +export function getBezierParameters( + points: PointLocation[] +): BezierCurveParameters { + // Fallback for degenerate Bezier curve (all points are at the same position) + if (points.length === 1) { + const point = points[0]; + return [point, point, point, point]; + } + + return [points[0], points[0].absOut, points[1].absIn, points[1]]; +} + +// https://stackoverflow.com/questions/2587751/an-algorithm-to-find-bounding-box-of-closed-bezier-curves +export function getBezierCurveBoundingBox(values: BezierCurveParameters) { + const [start, controlPoint1, controlPoint2, end] = values; + + const [x0, y0] = start; + const [x1, y1] = controlPoint1; + const [x2, y2] = controlPoint2; + const [x3, y3] = end; + + const points = []; // local extremes + const tvalues = []; // t values of local extremes + const bounds: [number[], number[]] = [[], []]; + + let a; + let b; + let c; + let t; + let t1; + let t2; + let b2ac; + let sqrtb2ac; + + for (let i = 0; i < 2; i += 1) { + if (i === 0) { + b = 6 * x0 - 12 * x1 + 6 * x2; + a = -3 * x0 + 9 * x1 - 9 * x2 + 3 * x3; + c = 3 * x1 - 3 * x0; + } else { + b = 6 * y0 - 12 * y1 + 6 * y2; + a = -3 * y0 + 9 * y1 - 9 * y2 + 3 * y3; + c = 3 * y1 - 3 * y0; + } + + if (Math.abs(a) < 1e-12) { + if (Math.abs(b) < 1e-12) { + continue; + } + + t = -c / b; + if (t > 0 && t < 1) tvalues.push(t); + + continue; + } + + b2ac = b * b - 4 * c * a; + sqrtb2ac = Math.sqrt(b2ac); + + if (b2ac < 0) continue; + + t1 = (-b + sqrtb2ac) / (2 * a); + if (t1 > 0 && t1 < 1) tvalues.push(t1); + + t2 = (-b - sqrtb2ac) / (2 * a); + if (t2 > 0 && t2 < 1) tvalues.push(t2); + } + + let x; + let y; + let mt; + let j = tvalues.length; + const jlen = j; + + while (j) { + j -= 1; + t = tvalues[j]; + mt = 1 - t; + + x = + mt * mt * mt * x0 + + 3 * mt * mt * t * x1 + + 3 * mt * t * t * x2 + + t * t * t * x3; + bounds[0][j] = x; + + y = + mt * mt * mt * y0 + + 3 * mt * mt * t * y1 + + 3 * mt * t * t * y2 + + t * t * t * y3; + + bounds[1][j] = y; + points[j] = { X: x, Y: y }; + } + + tvalues[jlen] = 0; + tvalues[jlen + 1] = 1; + + points[jlen] = { X: x0, Y: y0 }; + points[jlen + 1] = { X: x3, Y: y3 }; + + bounds[0][jlen] = x0; + bounds[1][jlen] = y0; + + bounds[0][jlen + 1] = x3; + bounds[1][jlen + 1] = y3; + + tvalues.length = jlen + 2; + bounds[0].length = jlen + 2; + bounds[1].length = jlen + 2; + points.length = jlen + 2; + + const left = Math.min.apply(null, bounds[0]); + const top = Math.min.apply(null, bounds[1]); + const right = Math.max.apply(null, bounds[0]); + const bottom = Math.max.apply(null, bounds[1]); + + return new Bound(left, top, right - left, bottom - top); +} + +// https://pomax.github.io/bezierjs/#intersect-line +// MIT Licence + +// cube root function yielding real roots +function crt(v: number) { + return v < 0 ? -Math.pow(-v, 1 / 3) : Math.pow(v, 1 / 3); +} + +function align(points: BezierCurveParameters, [start, end]: IVec[]) { + const tx = start[0], + ty = start[1], + a = -Math.atan2(end[1] - ty, end[0] - tx), + d = function ([x, y]: IVec) { + return [ + (x - tx) * Math.cos(a) - (y - ty) * Math.sin(a), + (x - tx) * Math.sin(a) + (y - ty) * Math.cos(a), + ]; + }; + return points.map(d); +} + +function between(v: number, min: number, max: number) { + return ( + (min <= v && v <= max) || approximately(v, min) || approximately(v, max) + ); +} + +function approximately( + a: number, + b: number, + precision?: number, + epsilon = 0.000001 +) { + return Math.abs(a - b) <= (precision || epsilon); +} + +function roots(points: BezierCurveParameters, line: IVec[]) { + const order = points.length - 1; + const aligned = align(points, line); + const reduce = function (t: number) { + return 0 <= t && t <= 1; + }; + + if (order === 2) { + const a = aligned[0][1], + b = aligned[1][1], + c = aligned[2][1], + d = a - 2 * b + c; + if (d !== 0) { + const m1 = -Math.sqrt(b * b - a * c), + m2 = -a + b, + v1 = -(m1 + m2) / d, + v2 = -(-m1 + m2) / d; + return [v1, v2].filter(reduce); + } else if (b !== c && d === 0) { + return [(2 * b - c) / (2 * b - 2 * c)].filter(reduce); + } + return []; + } + + // see http://www.trans4mind.com/personal_development/mathematics/polynomials/cubicAlgebra.htm + const pa = aligned[0][1], + pb = aligned[1][1], + pc = aligned[2][1], + pd = aligned[3][1]; + + const d = -pa + 3 * pb - 3 * pc + pd; + let a = 3 * pa - 6 * pb + 3 * pc, + b = -3 * pa + 3 * pb, + c = pa; + + if (approximately(d, 0)) { + // this is not a cubic curve. + if (approximately(a, 0)) { + // in fact, this is not a quadratic curve either. + if (approximately(b, 0)) { + // in fact in fact, there are no solutions. + return []; + } + // linear solution: + return [-c / b].filter(reduce); + } + // quadratic solution: + const q = Math.sqrt(b * b - 4 * a * c), + a2 = 2 * a; + return [(q - b) / a2, (-b - q) / a2].filter(reduce); + } + + // at this point, we know we need a cubic solution: + + a /= d; + b /= d; + c /= d; + + const p = (3 * b - a * a) / 3, + p3 = p / 3, + q = (2 * a * a * a - 9 * a * b + 27 * c) / 27, + q2 = q / 2, + discriminant = q2 * q2 + p3 * p3 * p3; + + let u1, v1, x1, x2, x3; + if (discriminant < 0) { + const mp3 = -p / 3, + mp33 = mp3 * mp3 * mp3, + r = Math.sqrt(mp33), + t = -q / (2 * r), + cosphi = t < -1 ? -1 : t > 1 ? 1 : t, + phi = Math.acos(cosphi), + crtr = crt(r), + t1 = 2 * crtr; + x1 = t1 * Math.cos(phi / 3) - a / 3; + x2 = t1 * Math.cos((phi + Math.PI * 2) / 3) - a / 3; + x3 = t1 * Math.cos((phi + 2 * Math.PI * 2) / 3) - a / 3; + return [x1, x2, x3].filter(reduce); + } else if (discriminant === 0) { + u1 = q2 < 0 ? crt(-q2) : -crt(q2); + x1 = 2 * u1 - a / 3; + x2 = -u1 - a / 3; + return [x1, x2].filter(reduce); + } else { + const sd = Math.sqrt(discriminant); + u1 = crt(-q2 + sd); + v1 = crt(q2 + sd); + return [u1 - v1 - a / 3].filter(reduce); + } +} + +export function curveIntersects(path: PointLocation[], line: [IVec, IVec]) { + const { minX, maxX, minY, maxY } = Bound.fromPoints(line); + const points = getBezierParameters(path); + const intersectedPoints = roots(points, line) + .map(t => getBezierPoint(points, t)) + .filter(point => + point + ? between(point[0], minX, maxX) && between(point[1], minY, maxY) + : false + ) + .map(point => new PointLocation(point!)); + return intersectedPoints.length > 0 ? intersectedPoints : null; +} diff --git a/blocksuite/framework/global/src/utils/disposable.ts b/blocksuite/framework/global/src/utils/disposable.ts new file mode 100644 index 0000000000..73081c6a2d --- /dev/null +++ b/blocksuite/framework/global/src/utils/disposable.ts @@ -0,0 +1,100 @@ +type DisposeCallback = () => void; + +export interface Disposable { + dispose: DisposeCallback; +} + +export interface DisposableManager extends Disposable { + add(d: Disposable | DisposeCallback): void; +} + +export class DisposableGroup implements DisposableManager { + private _disposables: Disposable[] = []; + + private _disposed = false; + + get disposed() { + return this._disposed; + } + + /** + * Add to group to be disposed with others. + * This will be immediately disposed if this group has already been disposed. + */ + add(d: Disposable | DisposeCallback) { + if (typeof d === 'function') { + if (this._disposed) d(); + else this._disposables.push({ dispose: d }); + } else { + if (this._disposed) d.dispose(); + else this._disposables.push(d); + } + } + + addFromEvent<N extends keyof WindowEventMap>( + element: Window, + eventName: N, + handler: (e: WindowEventMap[N]) => void, + options?: boolean | AddEventListenerOptions + ): void; + addFromEvent<N extends keyof DocumentEventMap>( + element: Document, + eventName: N, + handler: (e: DocumentEventMap[N]) => void, + eventOptions?: boolean | AddEventListenerOptions + ): void; + addFromEvent<N extends keyof HTMLElementEventMap>( + element: HTMLElement, + eventName: N, + handler: (e: HTMLElementEventMap[N]) => void, + eventOptions?: boolean | AddEventListenerOptions + ): void; + addFromEvent<N extends keyof VisualViewportEventMap>( + element: VisualViewport, + eventName: N, + handler: (e: VisualViewportEventMap[N]) => void, + eventOptions?: boolean | AddEventListenerOptions + ): void; + addFromEvent<N extends keyof VirtualKeyboardEventMap>( + element: VirtualKeyboard, + eventName: N, + handler: (e: VirtualKeyboardEventMap[N]) => void, + eventOptions?: boolean | AddEventListenerOptions + ): void; + + addFromEvent( + target: HTMLElement | Window | Document | VisualViewport | VirtualKeyboard, + type: string, + handler: (e: Event) => void, + eventOptions?: boolean | AddEventListenerOptions + ) { + this.add({ + dispose: () => { + target.removeEventListener(type, handler as () => void, eventOptions); + }, + }); + target.addEventListener(type, handler as () => void, eventOptions); + } + + dispose() { + disposeAll(this._disposables); + this._disposables = []; + this._disposed = true; + } +} + +export function flattenDisposables(disposables: Disposable[]): Disposable { + return { + dispose: () => disposeAll(disposables), + }; +} + +function disposeAll(disposables: Disposable[]) { + for (const disposable of disposables) { + try { + disposable.dispose(); + } catch (err) { + console.error(err); + } + } +} diff --git a/blocksuite/framework/global/src/utils/figma-squircle/distribute.ts b/blocksuite/framework/global/src/utils/figma-squircle/distribute.ts new file mode 100644 index 0000000000..bd7f822b91 --- /dev/null +++ b/blocksuite/framework/global/src/utils/figma-squircle/distribute.ts @@ -0,0 +1,154 @@ +interface RoundedRectangle { + topLeftCornerRadius: number; + topRightCornerRadius: number; + bottomRightCornerRadius: number; + bottomLeftCornerRadius: number; + width: number; + height: number; +} + +interface NormalizedCorner { + radius: number; + roundingAndSmoothingBudget: number; +} + +interface NormalizedCorners { + topLeft: NormalizedCorner; + topRight: NormalizedCorner; + bottomLeft: NormalizedCorner; + bottomRight: NormalizedCorner; +} + +type Corner = keyof NormalizedCorners; + +type Side = 'top' | 'left' | 'right' | 'bottom'; + +interface Adjacent { + side: Side; + corner: Corner; +} + +export function distributeAndNormalize({ + topLeftCornerRadius, + topRightCornerRadius, + bottomRightCornerRadius, + bottomLeftCornerRadius, + width, + height, +}: RoundedRectangle): NormalizedCorners { + const roundingAndSmoothingBudgetMap: Record<Corner, number> = { + topLeft: -1, + topRight: -1, + bottomLeft: -1, + bottomRight: -1, + }; + + const cornerRadiusMap: Record<Corner, number> = { + topLeft: topLeftCornerRadius, + topRight: topRightCornerRadius, + bottomLeft: bottomLeftCornerRadius, + bottomRight: bottomRightCornerRadius, + }; + + Object.entries(cornerRadiusMap) + // Let the bigger corners choose first + .sort(([, radius1], [, radius2]) => { + return radius2 - radius1; + }) + .forEach(([cornerName, radius]) => { + const corner = cornerName as Corner; + const adjacents = adjacentsByCorner[corner]; + + // Look at the 2 adjacent sides, figure out how much space we can have on both sides, + // then take the smaller one + const budget = Math.min( + ...adjacents.map(adjacent => { + const adjacentCornerRadius = cornerRadiusMap[adjacent.corner]; + if (radius === 0 && adjacentCornerRadius === 0) { + return 0; + } + + const adjacentCornerBudget = + roundingAndSmoothingBudgetMap[adjacent.corner]; + + const sideLength = + adjacent.side === 'top' || adjacent.side === 'bottom' + ? width + : height; + + // If the adjacent corner's already been given the rounding and smoothing budget, + // we'll just take the rest + if (adjacentCornerBudget >= 0) { + return sideLength - roundingAndSmoothingBudgetMap[adjacent.corner]; + } else { + return (radius / (radius + adjacentCornerRadius)) * sideLength; + } + }) + ); + + roundingAndSmoothingBudgetMap[corner] = budget; + cornerRadiusMap[corner] = Math.min(radius, budget); + }); + + return { + topLeft: { + radius: cornerRadiusMap.topLeft, + roundingAndSmoothingBudget: roundingAndSmoothingBudgetMap.topLeft, + }, + topRight: { + radius: cornerRadiusMap.topRight, + roundingAndSmoothingBudget: roundingAndSmoothingBudgetMap.topRight, + }, + bottomLeft: { + radius: cornerRadiusMap.bottomLeft, + roundingAndSmoothingBudget: roundingAndSmoothingBudgetMap.bottomLeft, + }, + bottomRight: { + radius: cornerRadiusMap.bottomRight, + roundingAndSmoothingBudget: roundingAndSmoothingBudgetMap.bottomRight, + }, + }; +} + +const adjacentsByCorner: Record<Corner, Array<Adjacent>> = { + topLeft: [ + { + corner: 'topRight', + side: 'top', + }, + { + corner: 'bottomLeft', + side: 'left', + }, + ], + topRight: [ + { + corner: 'topLeft', + side: 'top', + }, + { + corner: 'bottomRight', + side: 'right', + }, + ], + bottomLeft: [ + { + corner: 'bottomRight', + side: 'bottom', + }, + { + corner: 'topLeft', + side: 'left', + }, + ], + bottomRight: [ + { + corner: 'bottomLeft', + side: 'bottom', + }, + { + corner: 'topRight', + side: 'right', + }, + ], +}; diff --git a/blocksuite/framework/global/src/utils/figma-squircle/draw.ts b/blocksuite/framework/global/src/utils/figma-squircle/draw.ts new file mode 100644 index 0000000000..36f35f2f15 --- /dev/null +++ b/blocksuite/framework/global/src/utils/figma-squircle/draw.ts @@ -0,0 +1,232 @@ +interface CornerPathParams { + a: number; + b: number; + c: number; + d: number; + p: number; + cornerRadius: number; + arcSectionLength: number; +} + +interface CornerParams { + cornerRadius: number; + cornerSmoothing: number; + preserveSmoothing: boolean; + roundingAndSmoothingBudget: number; +} + +// The article from figma's blog +// https://www.figma.com/blog/desperately-seeking-squircles/ +// +// The original code by MartinRGB +// https://github.com/MartinRGB/Figma_Squircles_Approximation/blob/bf29714aab58c54329f3ca130ffa16d39a2ff08c/js/rounded-corners.js#L64 +export function getPathParamsForCorner({ + cornerRadius, + cornerSmoothing, + preserveSmoothing, + roundingAndSmoothingBudget, +}: CornerParams): CornerPathParams { + // From figure 12.2 in the article + // p = (1 + cornerSmoothing) * q + // in this case q = R because theta = 90deg + let p = (1 + cornerSmoothing) * cornerRadius; + + // When there's not enough space left (p > roundingAndSmoothingBudget), there are 2 options: + // + // 1. What figma's currently doing: limit the smoothing value to make sure p <= roundingAndSmoothingBudget + // But what this means is that at some point when cornerRadius is large enough, + // increasing the smoothing value wouldn't do anything + // + // 2. Keep the original smoothing value and use it to calculate the bezier curve normally, + // then adjust the control points to achieve similar curvature profile + // + // preserveSmoothing is a new option I added + // + // If preserveSmoothing is on then we'll just keep using the original smoothing value + // and adjust the bezier curve later + if (!preserveSmoothing) { + const maxCornerSmoothing = roundingAndSmoothingBudget / cornerRadius - 1; + cornerSmoothing = Math.min(cornerSmoothing, maxCornerSmoothing); + p = Math.min(p, roundingAndSmoothingBudget); + } + + // In a normal rounded rectangle (cornerSmoothing = 0), this is 90 + // The larger the smoothing, the smaller the arc + const arcMeasure = 90 * (1 - cornerSmoothing); + const arcSectionLength = + Math.sin(toRadians(arcMeasure / 2)) * cornerRadius * Math.sqrt(2); + + // In the article this is the distance between 2 control points: P3 and P4 + const angleAlpha = (90 - arcMeasure) / 2; + const p3ToP4Distance = cornerRadius * Math.tan(toRadians(angleAlpha / 2)); + + // a, b, c and d are from figure 11.1 in the article + const angleBeta = 45 * cornerSmoothing; + const c = p3ToP4Distance * Math.cos(toRadians(angleBeta)); + const d = c * Math.tan(toRadians(angleBeta)); + + let b = (p - arcSectionLength - c - d) / 3; + let a = 2 * b; + + // Adjust the P1 and P2 control points if there's not enough space left + if (preserveSmoothing && p > roundingAndSmoothingBudget) { + const p1ToP3MaxDistance = + roundingAndSmoothingBudget - d - arcSectionLength - c; + + // Try to maintain some distance between P1 and P2 so the curve wouldn't look weird + const minA = p1ToP3MaxDistance / 6; + const maxB = p1ToP3MaxDistance - minA; + + b = Math.min(b, maxB); + a = p1ToP3MaxDistance - b; + p = Math.min(p, roundingAndSmoothingBudget); + } + + return { + a, + b, + c, + d, + p, + arcSectionLength, + cornerRadius, + }; +} + +interface SVGPathInput { + width: number; + height: number; + topRightPathParams: CornerPathParams; + bottomRightPathParams: CornerPathParams; + bottomLeftPathParams: CornerPathParams; + topLeftPathParams: CornerPathParams; +} + +export function getSVGPathFromPathParams({ + width, + height, + topLeftPathParams, + topRightPathParams, + bottomLeftPathParams, + bottomRightPathParams, +}: SVGPathInput) { + return ` + M ${width - topRightPathParams.p} 0 + ${drawTopRightPath(topRightPathParams)} + L ${width} ${height - bottomRightPathParams.p} + ${drawBottomRightPath(bottomRightPathParams)} + L ${bottomLeftPathParams.p} ${height} + ${drawBottomLeftPath(bottomLeftPathParams)} + L 0 ${topLeftPathParams.p} + ${drawTopLeftPath(topLeftPathParams)} + Z + ` + .replace(/[\t\s\n]+/g, ' ') + .trim(); +} + +function drawTopRightPath({ + cornerRadius, + a, + b, + c, + d, + p, + arcSectionLength, +}: CornerPathParams) { + if (cornerRadius) { + return rounded` + c ${a} 0 ${a + b} 0 ${a + b + c} ${d} + a ${cornerRadius} ${cornerRadius} 0 0 1 ${arcSectionLength} ${arcSectionLength} + c ${d} ${c} + ${d} ${b + c} + ${d} ${a + b + c}`; + } else { + return rounded`l ${p} 0`; + } +} + +function drawBottomRightPath({ + cornerRadius, + a, + b, + c, + d, + p, + arcSectionLength, +}: CornerPathParams) { + if (cornerRadius) { + return rounded` + c 0 ${a} + 0 ${a + b} + ${-d} ${a + b + c} + a ${cornerRadius} ${cornerRadius} 0 0 1 -${arcSectionLength} ${arcSectionLength} + c ${-c} ${d} + ${-(b + c)} ${d} + ${-(a + b + c)} ${d}`; + } else { + return rounded`l 0 ${p}`; + } +} + +function drawBottomLeftPath({ + cornerRadius, + a, + b, + c, + d, + p, + arcSectionLength, +}: CornerPathParams) { + if (cornerRadius) { + return rounded` + c ${-a} 0 + ${-(a + b)} 0 + ${-(a + b + c)} ${-d} + a ${cornerRadius} ${cornerRadius} 0 0 1 -${arcSectionLength} -${arcSectionLength} + c ${-d} ${-c} + ${-d} ${-(b + c)} + ${-d} ${-(a + b + c)}`; + } else { + return rounded`l ${-p} 0`; + } +} + +function drawTopLeftPath({ + cornerRadius, + a, + b, + c, + d, + p, + arcSectionLength, +}: CornerPathParams) { + if (cornerRadius) { + return rounded` + c 0 ${-a} + 0 ${-(a + b)} + ${d} ${-(a + b + c)} + a ${cornerRadius} ${cornerRadius} 0 0 1 ${arcSectionLength} -${arcSectionLength} + c ${c} ${-d} + ${b + c} ${-d} + ${a + b + c} ${-d}`; + } else { + return rounded`l 0 ${-p}`; + } +} + +function toRadians(degrees: number) { + return (degrees * Math.PI) / 180; +} + +function rounded(strings: TemplateStringsArray, ...values: number[]): string { + return strings.reduce((acc, str, i) => { + const value = values[i]; + + if (typeof value === 'number') { + return acc + str + value.toFixed(4); + } else { + return acc + str + (value ?? ''); + } + }, ''); +} diff --git a/blocksuite/framework/global/src/utils/figma-squircle/index.ts b/blocksuite/framework/global/src/utils/figma-squircle/index.ts new file mode 100644 index 0000000000..836f089da6 --- /dev/null +++ b/blocksuite/framework/global/src/utils/figma-squircle/index.ts @@ -0,0 +1,105 @@ +/** + * Copyright (c) + * https://github.com/phamfoo/figma-squircle + */ + +import { distributeAndNormalize } from './distribute.js'; +import { getPathParamsForCorner, getSVGPathFromPathParams } from './draw.js'; + +export interface FigmaSquircleParams { + cornerRadius?: number; + topLeftCornerRadius?: number; + topRightCornerRadius?: number; + bottomRightCornerRadius?: number; + bottomLeftCornerRadius?: number; + cornerSmoothing: number; + width: number; + height: number; + preserveSmoothing?: boolean; +} + +export function getSvgPath({ + cornerRadius = 0, + topLeftCornerRadius, + topRightCornerRadius, + bottomRightCornerRadius, + bottomLeftCornerRadius, + cornerSmoothing, + width, + height, + preserveSmoothing = false, +}: FigmaSquircleParams) { + topLeftCornerRadius = topLeftCornerRadius ?? cornerRadius; + topRightCornerRadius = topRightCornerRadius ?? cornerRadius; + bottomLeftCornerRadius = bottomLeftCornerRadius ?? cornerRadius; + bottomRightCornerRadius = bottomRightCornerRadius ?? cornerRadius; + + if ( + topLeftCornerRadius === topRightCornerRadius && + topRightCornerRadius === bottomRightCornerRadius && + bottomRightCornerRadius === bottomLeftCornerRadius && + bottomLeftCornerRadius === topLeftCornerRadius + ) { + const roundingAndSmoothingBudget = Math.min(width, height) / 2; + const cornerRadius = Math.min( + topLeftCornerRadius, + roundingAndSmoothingBudget + ); + + const pathParams = getPathParamsForCorner({ + cornerRadius, + cornerSmoothing, + preserveSmoothing, + roundingAndSmoothingBudget, + }); + + return getSVGPathFromPathParams({ + width, + height, + topLeftPathParams: pathParams, + topRightPathParams: pathParams, + bottomLeftPathParams: pathParams, + bottomRightPathParams: pathParams, + }); + } + + const { topLeft, topRight, bottomLeft, bottomRight } = distributeAndNormalize( + { + topLeftCornerRadius, + topRightCornerRadius, + bottomRightCornerRadius, + bottomLeftCornerRadius, + width, + height, + } + ); + + return getSVGPathFromPathParams({ + width, + height, + topLeftPathParams: getPathParamsForCorner({ + cornerSmoothing, + preserveSmoothing, + cornerRadius: topLeft.radius, + roundingAndSmoothingBudget: topLeft.roundingAndSmoothingBudget, + }), + topRightPathParams: getPathParamsForCorner({ + cornerSmoothing, + preserveSmoothing, + cornerRadius: topRight.radius, + roundingAndSmoothingBudget: topRight.roundingAndSmoothingBudget, + }), + bottomRightPathParams: getPathParamsForCorner({ + cornerSmoothing, + preserveSmoothing, + cornerRadius: bottomRight.radius, + roundingAndSmoothingBudget: bottomRight.roundingAndSmoothingBudget, + }), + bottomLeftPathParams: getPathParamsForCorner({ + cornerSmoothing, + preserveSmoothing, + cornerRadius: bottomLeft.radius, + roundingAndSmoothingBudget: bottomLeft.roundingAndSmoothingBudget, + }), + }); +} diff --git a/blocksuite/framework/global/src/utils/function.ts b/blocksuite/framework/global/src/utils/function.ts new file mode 100644 index 0000000000..b862926193 --- /dev/null +++ b/blocksuite/framework/global/src/utils/function.ts @@ -0,0 +1,126 @@ +export async function sleep(ms: number, signal?: AbortSignal): Promise<void> { + return new Promise(resolve => { + if (signal?.aborted) { + resolve(); + return; + } + let resolved = false; + signal?.addEventListener('abort', () => { + if (!resolved) { + clearTimeout(timeId); + resolve(); + } + }); + + const timeId = setTimeout(() => { + resolved = true; + resolve(); + }, ms); + }); +} + +export function noop(_?: unknown) { + return; +} + +/** + * @example + * ```ts + * const log = (message: string) => console.log(`[${new Date().toISOString()}] ${message}`); + * + * const throttledLog = throttle(log, 1000); + * + * throttledLog("Hello, world!"); + * throttledLog("Hello, world!"); + * throttledLog("Hello, world!"); + * throttledLog("Hello, world!"); + * throttledLog("Hello, world!"); + * ``` + */ + +export function throttle<T extends (...args: any[]) => any>( + fn: T, + limit: number, + options?: { leading?: boolean; trailing?: boolean } +): T; +export function throttle< + Args extends unknown[], + T extends (...args: Args) => void, +>( + fn: (...args: Args) => void, + limit: number, + options?: { leading?: boolean; trailing?: boolean } +): T; +export function throttle< + Args extends unknown[], + T extends (this: unknown, ...args: Args) => void, +>(fn: T, limit: number, { leading = true, trailing = true } = {}): T { + let timer: ReturnType<typeof setTimeout> | null = null; + let lastArgs: Args | null = null; + + const setTimer = () => { + if (lastArgs && trailing) { + fn(...lastArgs); + lastArgs = null; + timer = setTimeout(setTimer, limit); + } else { + timer = null; + } + }; + + return function (this: unknown, ...args: Parameters<T>) { + if (timer) { + // in throttle + lastArgs = args; + return; + } + // Execute the function on the leading edge + if (leading) { + fn.apply(this, args); + } + timer = setTimeout(setTimer, limit); + } as T; +} + +export const debounce = <T extends (...args: any[]) => void>( + fn: T, + limit: number, + { leading = true, trailing = true } = {} +): T => { + let timer: ReturnType<typeof setTimeout> | null = null; + let lastArgs: Parameters<T> | null = null; + + // eslint-disable-next-line sonarjs/no-identical-functions + const setTimer = () => { + if (lastArgs && trailing) { + fn(...lastArgs); + lastArgs = null; + timer = setTimeout(setTimer, limit); + } else { + timer = null; + } + }; + + return function (...args: Parameters<T>) { + if (timer) { + lastArgs = args; + clearTimeout(timer); + } + if (leading && !timer) { + fn(...args); + } + timer = setTimeout(setTimer, limit); + } as T; +}; + +export async function nextTick() { + // @ts-expect-error FIXME: ts error + if ('scheduler' in window && 'yield' in window.scheduler) { + // @ts-expect-error FIXME: ts error + return window.scheduler.yield(); + } else if (typeof requestIdleCallback !== 'undefined') { + return new Promise(resolve => requestIdleCallback(resolve)); + } else { + return new Promise(resolve => setTimeout(resolve, 0)); + } +} diff --git a/blocksuite/framework/global/src/utils/index.ts b/blocksuite/framework/global/src/utils/index.ts new file mode 100644 index 0000000000..3f47a5de6b --- /dev/null +++ b/blocksuite/framework/global/src/utils/index.ts @@ -0,0 +1,19 @@ +export * from './assert.js'; +export * from './bound.js'; +export * from './crypto.js'; +export * from './curve.js'; +export * from './disposable.js'; +export { getSvgPath as getFigmaSquircleSvgPath } from './figma-squircle/index.js'; +export * from './function.js'; +export * from './iterable.js'; +export * from './logger.js'; +export * from './math.js'; +export * from './model/index.js'; +export * from './perfect-freehand/index.js'; +export * from './polyline.js'; +export * from './signal-watcher.js'; +export * from './slot.js'; +export * from './types.js'; +export * from './with-disposable.js'; +export type { SerializedXYWH, XYWH } from './xywh.js'; +export { deserializeXYWH, serializeXYWH } from './xywh.js'; diff --git a/blocksuite/framework/global/src/utils/iterable.ts b/blocksuite/framework/global/src/utils/iterable.ts new file mode 100644 index 0000000000..724d09b7f3 --- /dev/null +++ b/blocksuite/framework/global/src/utils/iterable.ts @@ -0,0 +1,218 @@ +/** + * + * @example + * ```ts + * const items = [ + * {name: 'a', classroom: 'c1'}, + * {name: 'b', classroom: 'c2'}, + * {name: 'a', classroom: 't0'} + * ] + * const counted = countBy(items1, i => i.name); + * // counted: { a: 2, b: 1} + * ``` + */ +export function countBy<T>( + items: T[], + key: (item: T) => string | number | null +): Record<string, number> { + const count: Record<string, number> = {}; + items.forEach(item => { + const k = key(item); + if (k === null) return; + if (!count[k]) { + count[k] = 0; + } + count[k] += 1; + }); + return count; +} + +/** + * @example + * ```ts + * const items = [{n: 1}, {n: 2}] + * const max = maxBy(items, i => i.n); + * // max: {n: 2} + * ``` + */ +export function maxBy<T>(items: T[], value: (item: T) => number): T | null { + if (!items.length) { + return null; + } + let maxItem = items[0]; + let max = value(maxItem); + + for (let i = 1; i < items.length; i++) { + const item = items[i]; + const v = value(item); + if (v > max) { + max = v; + maxItem = item; + } + } + + return maxItem; +} + +/** + * Checks if there are at least `n` elements in the array that match the given condition. + * + * @param arr - The input array of elements. + * @param matchFn - A function that takes an element of the array and returns a boolean value + * indicating if the element matches the desired condition. + * @param n - The minimum number of matching elements required. + * @returns A boolean value indicating if there are at least `n` matching elements in the array. + * + * @example + * const arr = [1, 2, 3, 4, 5]; + * const isEven = (num: number): boolean => num % 2 === 0; + * console.log(atLeastNMatches(arr, isEven, 2)); // Output: true + */ +export function atLeastNMatches<T>( + arr: T[], + matchFn: (element: T) => boolean, + n: number +): boolean { + let count = 0; + + // eslint-disable-next-line @typescript-eslint/prefer-for-of + for (let i = 0; i < arr.length; i++) { + if (matchFn(arr[i])) { + count++; + + if (count >= n) { + return true; + } + } + } + + return false; +} + +/** + * Groups an array of elements based on a provided key function. + * + * @example + * interface Student { + * name: string; + * age: number; + * } + * const students: Student[] = [ + * { name: 'Alice', age: 25 }, + * { name: 'Bob', age: 23 }, + * { name: 'Cathy', age: 25 }, + * ]; + * const groupedByAge = groupBy(students, (student) => student.age.toString()); + * console.log(groupedByAge); + * // Output: { + * '23': [ { name: 'Bob', age: 23 } ], + * '25': [ { name: 'Alice', age: 25 }, { name: 'Cathy', age: 25 } ] + * } + */ +export function groupBy<T, K extends string>( + arr: T[], + key: K | ((item: T) => K) +): Record<K, T[]> { + const result = {} as Record<string, T[]>; + + for (const item of arr) { + const groupKey = ( + typeof key === 'function' ? key(item) : (item as any)[key] + ) as string; + + if (!result[groupKey]) { + result[groupKey] = []; + } + + result[groupKey].push(item); + } + + return result; +} + +export function pickArray<T>(target: Array<T>, keys: number[]): Array<T> { + return keys.reduce((pre, key) => { + pre.push(target[key]); + return pre; + }, [] as T[]); +} + +export function pick<T, K extends keyof T>( + target: T, + keys: K[] +): Record<K, T[K]> { + return keys.reduce( + (pre, key) => { + pre[key] = target[key]; + return pre; + }, + {} as Record<K, T[K]> + ); +} + +export function pickValues<T, K extends keyof T>( + target: T, + keys: K[] +): Array<T[K]> { + return keys.reduce( + (pre, key) => { + pre.push(target[key]); + return pre; + }, + [] as Array<T[K]> + ); +} + +export function lastN<T>(target: Array<T>, n: number) { + return target.slice(target.length - n, target.length); +} + +export function isEmpty(obj: unknown) { + if (Object.getPrototypeOf(obj) === Object.prototype) { + return Object.keys(obj as object).length === 0; + } + + if (Array.isArray(obj) || typeof obj === 'string') { + return (obj as Array<unknown>).length === 0; + } + + return false; +} + +export function keys<T>(obj: T): (keyof T)[] { + return Object.keys(obj as object) as (keyof T)[]; +} + +export function values<T>(obj: T): T[keyof T][] { + return Object.values(obj as object); +} + +type IterableType<T> = T extends Array<infer U> ? U : T; + +export function last<T extends Iterable<unknown>>( + iterable: T +): IterableType<T> | undefined { + if (Array.isArray(iterable)) { + return iterable[iterable.length - 1]; + } + + let last: unknown | undefined; + for (const item of iterable) { + last = item; + } + + return last as IterableType<T>; +} + +export function nToLast<T extends Iterable<unknown>>( + iterable: T, + n: number +): IterableType<T> | undefined { + if (Array.isArray(iterable)) { + return iterable[iterable.length - n]; + } + + const arr = [...iterable]; + + return arr[arr.length - n] as IterableType<T>; +} diff --git a/blocksuite/framework/global/src/utils/logger.ts b/blocksuite/framework/global/src/utils/logger.ts new file mode 100644 index 0000000000..3897f2d1ca --- /dev/null +++ b/blocksuite/framework/global/src/utils/logger.ts @@ -0,0 +1,34 @@ +export interface Logger { + debug: (message: string, ...args: unknown[]) => void; + info: (message: string, ...args: unknown[]) => void; + warn: (message: string, ...args: unknown[]) => void; + error: (message: string, ...args: unknown[]) => void; +} + +export class ConsoleLogger implements Logger { + debug(message: string, ...args: unknown[]) { + console.debug(message, ...args); + } + + error(message: string, ...args: unknown[]) { + console.error(message, ...args); + } + + info(message: string, ...args: unknown[]) { + console.info(message, ...args); + } + + warn(message: string, ...args: unknown[]) { + console.warn(message, ...args); + } +} + +export class NoopLogger implements Logger { + debug() {} + + error() {} + + info() {} + + warn() {} +} diff --git a/blocksuite/framework/global/src/utils/math.ts b/blocksuite/framework/global/src/utils/math.ts new file mode 100644 index 0000000000..faa2a36f11 --- /dev/null +++ b/blocksuite/framework/global/src/utils/math.ts @@ -0,0 +1,538 @@ +import type { Bound, IBound } from './model/bound.js'; +import { PointLocation } from './model/point-location.js'; +import { type IVec, Vec } from './model/vec.js'; + +export const EPSILON = 1e-12; +export const MACHINE_EPSILON = 1.12e-16; +export const PI2 = Math.PI * 2; +export const CURVETIME_EPSILON = 1e-8; + +export function randomSeed(): number { + return Math.floor(Math.random() * 2 ** 31); +} + +export function lineIntersects( + sp: IVec, + ep: IVec, + sp2: IVec, + ep2: IVec, + infinite = false +): IVec | null { + const v1 = Vec.sub(ep, sp); + const v2 = Vec.sub(ep2, sp2); + const cross = Vec.cpr(v1, v2); + // Avoid divisions by 0, and errors when getting too close to 0 + if (almostEqual(cross, 0, MACHINE_EPSILON)) return null; + const d = Vec.sub(sp, sp2); + let u1 = Vec.cpr(v2, d) / cross; + const u2 = Vec.cpr(v1, d) / cross, + // Check the ranges of the u parameters if the line is not + // allowed to extend beyond the definition points, but + // compare with EPSILON tolerance over the [0, 1] bounds. + epsilon = /*#=*/ EPSILON, + uMin = -epsilon, + uMax = 1 + epsilon; + + if (infinite || (uMin < u1 && u1 < uMax && uMin < u2 && u2 < uMax)) { + // Address the tolerance at the bounds by clipping to + // the actual range. + if (!infinite) { + u1 = clamp(u1, 0, 1); + } + return Vec.lrp(sp, ep, u1); + } + + return null; +} + +export function polygonNearestPoint(points: IVec[], point: IVec) { + const len = points.length; + let rst: IVec; + let dis = Infinity; + for (let i = 0; i < len; i++) { + const p = points[i]; + const p2 = points[(i + 1) % len]; + const temp = Vec.nearestPointOnLineSegment(p, p2, point, true); + const curDis = Vec.dist(temp, point); + if (curDis < dis) { + dis = curDis; + rst = temp; + } + } + return rst!; +} + +export function polygonPointDistance(points: IVec[], point: IVec) { + const nearest = polygonNearestPoint(points, point); + return Vec.dist(nearest, point); +} + +export function rotatePoints<T extends IVec>( + points: T[], + center: IVec, + rotate: number +): T[] { + const rad = toRadian(rotate); + return points.map(p => Vec.rotWith(p, center, rad)) as T[]; +} + +export function rotatePoint( + point: [number, number], + center: IVec, + rotate: number +): [number, number] { + const rad = toRadian(rotate); + return Vec.add(center, Vec.rot(Vec.sub(point, center), rad)) as [ + number, + number, + ]; +} + +export function toRadian(angle: number) { + return (angle * Math.PI) / 180; +} + +export function isPointOnLineSegment(point: IVec, line: IVec[]) { + const [sp, ep] = line; + const v1 = Vec.sub(point, sp); + const v2 = Vec.sub(point, ep); + return almostEqual(Vec.cpr(v1, v2), 0, 0.01) && Vec.dpr(v1, v2) <= 0; +} + +export function polygonGetPointTangent(points: IVec[], point: IVec): IVec { + const len = points.length; + for (let i = 0; i < len; i++) { + const p = points[i]; + const p2 = points[(i + 1) % len]; + if (isPointOnLineSegment(point, [p, p2])) { + return Vec.normalize(Vec.sub(p2, p)); + } + } + return [0, 0]; +} + +export function linePolygonIntersects( + sp: IVec, + ep: IVec, + points: IVec[] +): PointLocation[] | null { + const result: PointLocation[] = []; + const len = points.length; + + for (let i = 0; i < len; i++) { + const p = points[i]; + const p2 = points[(i + 1) % len]; + const rst = lineIntersects(sp, ep, p, p2); + if (rst) { + const v = new PointLocation(rst); + v.tangent = Vec.normalize(Vec.sub(p2, p)); + result.push(v); + } + } + + return result.length ? result : null; +} + +export function linePolylineIntersects( + sp: IVec, + ep: IVec, + points: IVec[] +): PointLocation[] | null { + const result: PointLocation[] = []; + const len = points.length; + + for (let i = 0; i < len - 1; i++) { + const p = points[i]; + const p2 = points[i + 1]; + const rst = lineIntersects(sp, ep, p, p2); + if (rst) { + result.push(new PointLocation(rst, Vec.normalize(Vec.sub(p2, p)))); + } + } + + return result.length ? result : null; +} + +export function polyLineNearestPoint(points: IVec[], point: IVec) { + const len = points.length; + let rst: IVec; + let dis = Infinity; + for (let i = 0; i < len - 1; i++) { + const p = points[i]; + const p2 = points[i + 1]; + const temp = Vec.nearestPointOnLineSegment(p, p2, point, true); + const curDis = Vec.dist(temp, point); + if (curDis < dis) { + dis = curDis; + rst = temp; + } + } + return rst!; +} + +export function isPointOnlines( + element: Bound, + points: readonly [number, number][], + rotate: number, + hitPoint: [number, number], + threshold: number +): boolean { + // credit to Excalidraw hitTestFreeDrawElement + + let x: number; + let y: number; + + if (rotate === 0) { + x = hitPoint[0] - element.x; + y = hitPoint[1] - element.y; + } else { + // Counter-rotate the point around center before testing + const { minX, minY, maxX, maxY } = element; + const rotatedPoint = rotatePoint( + hitPoint, + [minX + (maxX - minX) / 2, minY + (maxY - minY) / 2], + -rotate + ) as [number, number]; + x = rotatedPoint[0] - element.x; + y = rotatedPoint[1] - element.y; + } + + let [A, B] = points; + let P: readonly [number, number]; + + // For freedraw dots + if ( + distance2d(A[0], A[1], x, y) < threshold || + distance2d(B[0], B[1], x, y) < threshold + ) { + return true; + } + + // For freedraw lines + for (let i = 0; i < points.length; i++) { + const delta = [B[0] - A[0], B[1] - A[1]]; + const length = Math.hypot(delta[1], delta[0]); + + const U = [delta[0] / length, delta[1] / length]; + const C = [x - A[0], y - A[1]]; + const d = (C[0] * U[0] + C[1] * U[1]) / Math.hypot(U[1], U[0]); + P = [A[0] + U[0] * d, A[1] + U[1] * d]; + + const da = distance2d(P[0], P[1], A[0], A[1]); + const db = distance2d(P[0], P[1], B[0], B[1]); + + P = db < da && da > length ? B : da < db && db > length ? A : P; + + if (Math.hypot(y - P[1], x - P[0]) < threshold) { + return true; + } + + A = B; + B = points[i + 1]; + } + + return false; +} + +export const distance2d = (x1: number, y1: number, x2: number, y2: number) => { + const xd = x2 - x1; + const yd = y2 - y1; + return Math.hypot(xd, yd); +}; + +function square(num: number) { + return num * num; +} + +function sumSqr(v: IVec, w: IVec) { + return square(v[0] - w[0]) + square(v[1] - w[1]); +} + +function distToSegmentSquared(p: IVec, v: IVec, w: IVec) { + const l2 = sumSqr(v, w); + + if (l2 == 0) return sumSqr(p, v); + let t = ((p[0] - v[0]) * (w[0] - v[0]) + (p[1] - v[1]) * (w[1] - v[1])) / l2; + + t = Math.max(0, Math.min(1, t)); + + return sumSqr(p, [v[0] + t * (w[0] - v[0]), v[1] + t * (w[1] - v[1])]); +} + +function distToSegment(p: IVec, v: IVec, w: IVec) { + return Math.sqrt(distToSegmentSquared(p, v, w)); +} + +export function isPointIn(a: IBound, x: number, y: number): boolean { + return a.x <= x && x <= a.x + a.w && a.y <= y && y <= a.y + a.h; +} + +export function intersects(a: IBound, b: IBound): boolean { + return ( + a.x < b.x + b.w && a.x + a.w > b.x && a.y < b.y + b.h && a.y + a.h > b.y + ); +} + +export function almostEqual(a: number, b: number, epsilon = 0.0001) { + return Math.abs(a - b) < epsilon; +} + +export function isVecZero(v: IVec) { + return v.every(n => isZero(n)); +} + +export function isZero(x: number) { + return x >= -EPSILON && x <= EPSILON; +} + +export function pointAlmostEqual(a: IVec, b: IVec, _epsilon = 0.0001) { + return a.length === b.length && a.every((v, i) => almostEqual(v, b[i])); +} + +export function clamp(n: number, min: number, max?: number): number { + return Math.max(min, max !== undefined ? Math.min(n, max) : n); +} + +export function pointInEllipse( + A: IVec, + C: IVec, + rx: number, + ry: number, + rotation = 0 +): boolean { + const cos = Math.cos(rotation); + const sin = Math.sin(rotation); + const delta = Vec.sub(A, C); + const tdx = cos * delta[0] + sin * delta[1]; + const tdy = sin * delta[0] - cos * delta[1]; + + return (tdx * tdx) / (rx * rx) + (tdy * tdy) / (ry * ry) <= 1; +} + +export function pointInPolygon(p: IVec, points: IVec[]): boolean { + let wn = 0; // winding number + + points.forEach((a, i) => { + const b = points[(i + 1) % points.length]; + if (a[1] <= p[1]) { + if (b[1] > p[1] && Vec.cross(a, b, p) > 0) { + wn += 1; + } + } else if (b[1] <= p[1] && Vec.cross(a, b, p) < 0) { + wn -= 1; + } + }); + + return wn !== 0; +} + +export function pointOnEllipse( + point: IVec, + rx: number, + ry: number, + threshold: number +): boolean { + // slope of point + const t = point[1] / point[0]; + const squaredX = + (square(rx) * square(ry)) / (square(rx) * square(t) + square(ry)); + const squaredY = + (square(rx) * square(ry) - square(ry) * squaredX) / square(rx); + + return ( + Math.abs( + Math.sqrt(square(point[1]) + square(point[0])) - + Math.sqrt(squaredX + squaredY) + ) < threshold + ); +} + +export function pointOnPolygonStoke( + p: IVec, + points: IVec[], + threshold: number +): boolean { + for (let i = 0; i < points.length; ++i) { + const next = i + 1 === points.length ? 0 : i + 1; + if (distToSegment(p, points[i], points[next]) <= threshold) { + return true; + } + } + + return false; +} + +export function getPolygonPathFromPoints( + points: IVec[], + closed = true +): string { + const len = points.length; + if (len < 2) return ``; + + const a = points[0]; + const b = points[1]; + + let res = `M${a[0].toFixed(2)},${a[1].toFixed()}L${b[0].toFixed(2)},${b[1].toFixed()}`; + + for (let i = 2; i < len; i++) { + const a = points[i]; + res += `L${a[0].toFixed(2)},${a[1].toFixed()}`; + } + + if (closed) res += 'Z'; + return res; +} +export function getSvgPathFromStroke(points: IVec[], closed = true): string { + const len = points.length; + + if (len < 4) { + return ``; + } + + let a = points[0]; + let b = points[1]; + const c = points[2]; + + let result = `M${a[0].toFixed(2)},${a[1].toFixed(2)} Q${b[0].toFixed( + 2 + )},${b[1].toFixed(2)} ${average(b[0], c[0]).toFixed(2)},${average( + b[1], + c[1] + ).toFixed(2)} T`; + + for (let i = 2, max = len - 1; i < max; i++) { + a = points[i]; + b = points[i + 1]; + result += `${average(a[0], b[0]).toFixed(2)},${average(a[1], b[1]).toFixed( + 2 + )} `; + } + + if (closed) { + result += 'Z'; + } + + return result; +} + +function average(a: number, b: number): number { + return (a + b) / 2; +} + +//reference https://www.xarg.org/book/computer-graphics/line-segment-ellipse-intersection/ +export function lineEllipseIntersects( + A: IVec, + B: IVec, + C: IVec, + rx: number, + ry: number, + rad = 0 +) { + A = Vec.rot(Vec.sub(A, C), -rad); + B = Vec.rot(Vec.sub(B, C), -rad); + + rx *= rx; + ry *= ry; + + const rst: IVec[] = []; + + const v = Vec.sub(B, A); + + const a = rx * v[1] * v[1] + ry * v[0] * v[0]; + const b = 2 * (rx * A[1] * v[1] + ry * A[0] * v[0]); + const c = rx * A[1] * A[1] + ry * A[0] * A[0] - rx * ry; + + const D = b * b - 4 * a * c; // Discriminant + + if (D >= 0) { + const sqrtD = Math.sqrt(D); + const t1 = (-b + sqrtD) / (2 * a); + const t2 = (-b - sqrtD) / (2 * a); + + if (0 <= t1 && t1 <= 1) + rst.push(Vec.add(Vec.rot(Vec.add(Vec.mul(v, t1), A), rad), C)); + + if (0 <= t2 && t2 <= 1 && Math.abs(t1 - t2) > 1e-16) + rst.push(Vec.add(Vec.rot(Vec.add(Vec.mul(v, t2), A), rad), C)); + } + + if (rst.length === 0) return null; + + return rst.map(v => { + const pl = new PointLocation(v); + const normalVector = Vec.uni(Vec.divV(Vec.sub(v, C), [rx * rx, ry * ry])); + pl.tangent = [-normalVector[1], normalVector[0]]; + return pl; + }); +} + +export function sign(number: number) { + return number > 0 ? 1 : -1; +} + +export function getPointFromBoundsWithRotation( + bounds: IBound, + point: IVec +): IVec { + const { x, y, w, h, rotate } = bounds; + + if (!rotate) return point; + + const cx = x + w / 2; + const cy = y + h / 2; + + const m = new DOMMatrix() + .translateSelf(cx, cy) + .rotateSelf(rotate) + .translateSelf(-cx, -cy); + + const p = new DOMPoint(...point).matrixTransform(m); + return [p.x, p.y]; +} + +export function normalizeDegAngle(angle: number) { + if (angle < 0) angle += 360; + angle %= 360; + return angle; +} + +export function toDegree(radian: number) { + return (radian * 180) / Math.PI; +} + +// 0 means x axis, 1 means y axis +export function isOverlap( + line1: IVec[], + line2: IVec[], + axis: 0 | 1, + strict = true +) { + const less = strict + ? (a: number, b: number) => a < b + : (a: number, b: number) => a <= b; + return !( + less( + Math.max(line1[0][axis], line1[1][axis]), + Math.min(line2[0][axis], line2[1][axis]) + ) || + less( + Math.max(line2[0][axis], line2[1][axis]), + Math.min(line1[0][axis], line1[1][axis]) + ) + ); +} + +export function getCenterAreaBounds(bounds: IBound, ratio: number) { + const { x, y, w, h, rotate } = bounds; + const cx = x + w / 2; + const cy = y + h / 2; + const nw = w * ratio; + const nh = h * ratio; + return { + x: cx - nw / 2, + y: cy - nh / 2, + w: nw, + h: nh, + rotate, + }; +} diff --git a/blocksuite/framework/global/src/utils/model/bound.ts b/blocksuite/framework/global/src/utils/model/bound.ts new file mode 100644 index 0000000000..277676e187 --- /dev/null +++ b/blocksuite/framework/global/src/utils/model/bound.ts @@ -0,0 +1,366 @@ +import { EPSILON, lineIntersects, polygonPointDistance } from '../math.js'; +import type { SerializedXYWH, XYWH } from '../xywh.js'; +import { deserializeXYWH, serializeXYWH } from '../xywh.js'; +import { type IVec, Vec } from './vec.js'; + +export function getIBoundFromPoints( + points: IVec[], + rotation = 0 +): IBound & { + maxX: number; + maxY: number; + minX: number; + minY: number; +} { + let minX = Infinity; + let minY = Infinity; + let maxX = -Infinity; + let maxY = -Infinity; + + if (points.length < 1) { + minX = 0; + minY = 0; + maxX = 1; + maxY = 1; + } else { + for (const [x, y] of points) { + minX = Math.min(x, minX); + minY = Math.min(y, minY); + maxX = Math.max(x, maxX); + maxY = Math.max(y, maxY); + } + } + + if (rotation !== 0) { + return getIBoundFromPoints( + points.map(pt => + Vec.rotWith(pt, [(minX + maxX) / 2, (minY + maxY) / 2], rotation) + ) + ); + } + + return { + minX, + minY, + maxX, + maxY, + x: minX, + y: minY, + w: maxX - minX, + h: maxY - minY, + }; +} + +/** + * Represents the x, y, width, and height of a block that can be easily accessed. + */ +export interface IBound { + x: number; + y: number; + w: number; + h: number; + rotate?: number; +} + +export class Bound implements IBound { + h: number; + + w: number; + + x: number; + + y: number; + + get bl() { + return [this.x, this.y + this.h]; + } + + get br() { + return [this.x + this.w, this.y + this.h]; + } + + get center(): IVec { + return [this.x + this.w / 2, this.y + this.h / 2]; + } + + set center([cx, cy]: IVec) { + const [px, py] = this.center; + this.x += cx - px; + this.y += cy - py; + } + + get horizontalLine(): IVec[] { + return [ + [this.x, this.y + this.h / 2], + [this.x + this.w, this.y + this.h / 2], + ]; + } + + get leftLine(): IVec[] { + return [ + [this.x, this.y], + [this.x, this.y + this.h], + ]; + } + + get lowerLine(): IVec[] { + return [ + [this.x, this.y + this.h], + [this.x + this.w, this.y + this.h], + ]; + } + + get maxX() { + return this.x + this.w; + } + + get maxY() { + return this.y + this.h; + } + + get midPoints(): IVec[] { + return [ + [this.x + this.w / 2, this.y], + [this.x + this.w, this.y + this.h / 2], + [this.x + this.w / 2, this.y + this.h], + [this.x, this.y + this.h / 2], + ]; + } + + get minX() { + return this.x; + } + + get minY() { + return this.y; + } + + get points(): IVec[] { + return [ + [this.x, this.y], + [this.x + this.w, this.y], + [this.x + this.w, this.y + this.h], + [this.x, this.y + this.h], + ]; + } + + get rightLine(): IVec[] { + return [ + [this.x + this.w, this.y], + [this.x + this.w, this.y + this.h], + ]; + } + + get tl(): IVec { + return [this.x, this.y]; + } + + get tr() { + return [this.x + this.w, this.y]; + } + + get upperLine(): IVec[] { + return [ + [this.x, this.y], + [this.x + this.w, this.y], + ]; + } + + get verticalLine(): IVec[] { + return [ + [this.x + this.w / 2, this.y], + [this.x + this.w / 2, this.y + this.h], + ]; + } + + constructor(x = 0, y = 0, w = 0, h = 0) { + this.x = x; + this.y = y; + this.w = w; + this.h = h; + } + + static deserialize(s: string) { + const [x, y, w, h] = deserializeXYWH(s); + return new Bound(x, y, w, h); + } + + static from(arg1: IBound) { + return new Bound(arg1.x, arg1.y, arg1.w, arg1.h); + } + + static fromCenter(center: IVec, width: number, height: number) { + const [x, y] = center; + return new Bound(x - width / 2, y - height / 2, width, height); + } + + static fromDOMRect({ left, top, width, height }: DOMRect) { + return new Bound(left, top, width, height); + } + + static fromPoints(points: IVec[]) { + return Bound.from(getIBoundFromPoints(points)); + } + + static fromXYWH(xywh: XYWH) { + return new Bound(xywh[0], xywh[1], xywh[2], xywh[3]); + } + + static serialize(bound: IBound) { + return serializeXYWH(bound.x, bound.y, bound.w, bound.h); + } + + clone(): Bound { + return new Bound(this.x, this.y, this.w, this.h); + } + + contains(bound: Bound) { + return ( + bound.x >= this.x && + bound.y >= this.y && + bound.maxX <= this.maxX && + bound.maxY <= this.maxY + ); + } + + containsPoint([x, y]: IVec): boolean { + const { minX, minY, maxX, maxY } = this; + return minX <= x && x <= maxX && minY <= y && y <= maxY; + } + + expand(margin: [number, number]): Bound; + expand(left: number, top?: number, right?: number, bottom?: number): Bound; + expand( + left: number | [number, number], + top?: number, + right?: number, + bottom?: number + ) { + if (Array.isArray(left)) { + const [x, y] = left; + return new Bound(this.x - x, this.y - y, this.w + x * 2, this.h + y * 2); + } + + top ??= left; + right ??= left; + bottom ??= top; + + return new Bound( + this.x - left, + this.y - top, + this.w + left + right, + this.h + top + bottom + ); + } + + getRelativePoint([x, y]: IVec): IVec { + return [this.x + x * this.w, this.y + y * this.h]; + } + + getVerticesAndMidpoints() { + return [...this.points, ...this.midPoints]; + } + + horizontalDistance(bound: Bound) { + return Math.min( + Math.abs(this.minX - bound.maxX), + Math.abs(this.maxX - bound.minX) + ); + } + + include(point: IVec) { + const x1 = Math.min(this.x, point[0]), + y1 = Math.min(this.y, point[1]), + x2 = Math.max(this.maxX, point[0]), + y2 = Math.max(this.maxY, point[1]); + return new Bound(x1, y1, x2 - x1, y2 - y1); + } + + intersectLine(sp: IVec, ep: IVec, infinite = false) { + const rst: IVec[] = []; + ( + [ + [this.tl, this.tr], + [this.tl, this.bl], + [this.tr, this.br], + [this.bl, this.br], + ] as IVec[][] + ).forEach(([p1, p2]) => { + const p = lineIntersects(sp, ep, p1, p2, infinite); + if (p) rst.push(p); + }); + return rst.length === 0 ? null : rst; + } + + isHorizontalCross(bound: Bound) { + return !(this.maxY < bound.minY || this.minY > bound.maxY); + } + + isIntersectWithBound(bound: Bound, epsilon = EPSILON) { + return ( + bound.maxX > this.minX - epsilon && + bound.maxY > this.minY - epsilon && + bound.minX < this.maxX + epsilon && + bound.minY < this.maxY + epsilon && + !this.contains(bound) && + !bound.contains(this) + ); + } + + isOverlapWithBound(bound: Bound, epsilon = EPSILON) { + return ( + bound.maxX > this.minX - epsilon && + bound.maxY > this.minY - epsilon && + bound.minX < this.maxX + epsilon && + bound.minY < this.maxY + epsilon + ); + } + + isPointInBound([x, y]: IVec, tolerance = 0.01) { + return ( + x > this.minX + tolerance && + x < this.maxX - tolerance && + y > this.minY + tolerance && + y < this.maxY - tolerance + ); + } + + isPointNearBound([x, y]: IVec, tolerance = 0.01) { + return polygonPointDistance(this.points, [x, y]) < tolerance; + } + + isVerticalCross(bound: Bound) { + return !(this.maxX < bound.minX || this.minX > bound.maxX); + } + + moveDelta(dx: number, dy: number) { + return new Bound(this.x + dx, this.y + dy, this.w, this.h); + } + + serialize(): SerializedXYWH { + return serializeXYWH(this.x, this.y, this.w, this.h); + } + + toRelative([x, y]: IVec): IVec { + return [(x - this.x) / this.w, (y - this.y) / this.h]; + } + + toXYWH(): XYWH { + return [this.x, this.y, this.w, this.h]; + } + + unite(bound: Bound) { + const x1 = Math.min(this.x, bound.x), + y1 = Math.min(this.y, bound.y), + x2 = Math.max(this.maxX, bound.maxX), + y2 = Math.max(this.maxY, bound.maxY); + return new Bound(x1, y1, x2 - x1, y2 - y1); + } + + verticalDistance(bound: Bound) { + return Math.min( + Math.abs(this.minY - bound.maxY), + Math.abs(this.maxY - bound.minY) + ); + } +} diff --git a/blocksuite/framework/global/src/utils/model/index.ts b/blocksuite/framework/global/src/utils/model/index.ts new file mode 100644 index 0000000000..22374d3d5b --- /dev/null +++ b/blocksuite/framework/global/src/utils/model/index.ts @@ -0,0 +1,4 @@ +export * from './bound.js'; +export * from './point.js'; +export * from './point-location.js'; +export * from './vec.js'; diff --git a/blocksuite/framework/global/src/utils/model/point-location.ts b/blocksuite/framework/global/src/utils/model/point-location.ts new file mode 100644 index 0000000000..69b0093ce0 --- /dev/null +++ b/blocksuite/framework/global/src/utils/model/point-location.ts @@ -0,0 +1,94 @@ +import { type IVec, Vec } from './vec.js'; + +/** + * PointLocation is an implementation of IVec with in/out vectors and tangent. + * This is useful when dealing with path. + */ +export class PointLocation extends Array<number> implements IVec { + _in: IVec = [0, 0]; + + _out: IVec = [0, 0]; + + // the tangent belongs to the point on the element outline + _tangent: IVec = [0, 0]; + + [0]: number; + + [1]: number; + + get absIn() { + return Vec.add(this, this._in); + } + + get absOut() { + return Vec.add(this, this._out); + } + + get in() { + return this._in; + } + + set in(value: IVec) { + this._in = value; + } + + override get length() { + return super.length as 2; + } + + get out() { + return this._out; + } + + set out(value: IVec) { + this._out = value; + } + + get tangent() { + return this._tangent; + } + + set tangent(value: IVec) { + this._tangent = value; + } + + constructor( + point: IVec = [0, 0], + tangent: IVec = [0, 0], + inVec: IVec = [0, 0], + outVec: IVec = [0, 0] + ) { + super(2); + this[0] = point[0]; + this[1] = point[1]; + this._tangent = tangent; + this._in = inVec; + this._out = outVec; + } + + static fromVec(vec: IVec) { + const point = new PointLocation(); + point[0] = vec[0]; + point[1] = vec[1]; + return point; + } + + clone() { + return new PointLocation( + this as unknown as IVec, + this._tangent, + this._in, + this._out + ); + } + + setVec(vec: IVec) { + this[0] = vec[0]; + this[1] = vec[1]; + return this; + } + + toVec(): IVec { + return [this[0], this[1]]; + } +} diff --git a/blocksuite/framework/global/src/utils/model/point.ts b/blocksuite/framework/global/src/utils/model/point.ts new file mode 100644 index 0000000000..3b31b9afb4 --- /dev/null +++ b/blocksuite/framework/global/src/utils/model/point.ts @@ -0,0 +1,268 @@ +import { clamp } from '../math.js'; + +export interface IPoint { + x: number; + y: number; +} + +export class Point { + x: number; + + y: number; + + constructor(x = 0, y = 0) { + this.x = x; + this.y = y; + } + + /** + * Restrict a value to a certain interval. + */ + static clamp(p: Point, min: Point, max: Point) { + return new Point(clamp(p.x, min.x, max.x), clamp(p.y, min.y, max.y)); + } + + static from(point: IPoint | number[] | number, y?: number) { + if (Array.isArray(point)) { + return new Point(point[0], point[1]); + } + if (typeof point === 'number') { + return new Point(point, y ?? point); + } + return new Point(point.x, point.y); + } + + /** + * Compares and returns the maximum of two points. + */ + static max(a: Point, b: Point) { + return new Point(Math.max(a.x, b.x), Math.max(a.y, b.y)); + } + + /** + * Compares and returns the minimum of two points. + */ + static min(a: Point, b: Point) { + return new Point(Math.min(a.x, b.x), Math.min(a.y, b.y)); + } + + add(point: IPoint): Point { + return new Point(this.x + point.x, this.y + point.y); + } + + /** + * Returns a copy of the point. + */ + clone() { + return new Point(this.x, this.y); + } + + cross(point: IPoint): number { + return this.x * point.y - this.y * point.x; + } + + equals({ x, y }: Point) { + return this.x === x && this.y === y; + } + + lerp(point: IPoint, t: number): Point { + return new Point( + this.x + (point.x - this.x) * t, + this.y + (point.y - this.y) * t + ); + } + + scale(factor: number): Point { + return new Point(this.x * factor, this.y * factor); + } + + set(x: number, y: number) { + this.x = x; + this.y = y; + } + + subtract(point: IPoint): Point { + return new Point(this.x - point.x, this.y - point.y); + } + + toArray() { + return [this.x, this.y]; + } +} + +export class Rect { + // `[right, bottom]` + max: Point; + + // `[left, top]` + min: Point; + + get bottom() { + return this.max.y; + } + + set bottom(y: number) { + this.max.y = y; + } + + get height() { + return this.max.y - this.min.y; + } + + set height(h: number) { + this.max.y = this.min.y + h; + } + + get left() { + return this.min.x; + } + + set left(x: number) { + this.min.x = x; + } + + get right() { + return this.max.x; + } + + set right(x: number) { + this.max.x = x; + } + + get top() { + return this.min.y; + } + + set top(y: number) { + this.min.y = y; + } + + get width() { + return this.max.x - this.min.x; + } + + set width(w: number) { + this.max.x = this.min.x + w; + } + + constructor(left: number, top: number, right: number, bottom: number) { + const [minX, maxX] = left <= right ? [left, right] : [right, left]; + const [minY, maxY] = top <= bottom ? [top, bottom] : [bottom, top]; + this.min = new Point(minX, minY); + this.max = new Point(maxX, maxY); + } + + static fromDOM(dom: Element) { + return Rect.fromDOMRect(dom.getBoundingClientRect()); + } + + static fromDOMRect({ left, top, right, bottom }: DOMRect) { + return Rect.fromLTRB(left, top, right, bottom); + } + + static fromLTRB(left: number, top: number, right: number, bottom: number) { + return new Rect(left, top, right, bottom); + } + + static fromLWTH(left: number, width: number, top: number, height: number) { + return new Rect(left, top, left + width, top + height); + } + + static fromPoint(point: Point) { + return Rect.fromPoints(point.clone(), point); + } + + static fromPoints(start: Point, end: Point) { + const width = Math.abs(end.x - start.x); + const height = Math.abs(end.y - start.y); + const left = Math.min(end.x, start.x); + const top = Math.min(end.y, start.y); + return Rect.fromLWTH(left, width, top, height); + } + + static fromXY(x: number, y: number) { + return Rect.fromPoint(new Point(x, y)); + } + + center() { + return new Point( + (this.left + this.right) / 2, + (this.top + this.bottom) / 2 + ); + } + + clamp(p: Point) { + return Point.clamp(p, this.min, this.max); + } + + clone() { + const { left, top, right, bottom } = this; + return new Rect(left, top, right, bottom); + } + + contains({ min, max }: Rect) { + return this.isPointIn(min) && this.isPointIn(max); + } + + equals({ min, max }: Rect) { + return this.min.equals(min) && this.max.equals(max); + } + + extend_with(point: Point) { + this.min = Point.min(this.min, point); + this.max = Point.max(this.max, point); + } + + extend_with_x(x: number) { + this.min.x = Math.min(this.min.x, x); + this.max.x = Math.max(this.max.x, x); + } + + extend_with_y(y: number) { + this.min.y = Math.min(this.min.y, y); + this.max.y = Math.max(this.max.y, y); + } + + intersect(other: Rect) { + return Rect.fromPoints( + Point.max(this.min, other.min), + Point.min(this.max, other.max) + ); + } + + intersects({ left, top, right, bottom }: Rect) { + return ( + this.left <= right && + left <= this.right && + this.top <= bottom && + top <= this.bottom + ); + } + + isPointDown({ x, y }: Point) { + return this.bottom < y && this.left <= x && this.right >= x; + } + + isPointIn({ x, y }: Point) { + return ( + this.left <= x && x <= this.right && this.top <= y && y <= this.bottom + ); + } + + isPointLeft({ x, y }: Point) { + return x < this.left && this.top <= y && this.bottom >= y; + } + + isPointRight({ x, y }: Point) { + return x > this.right && this.top <= y && this.bottom >= y; + } + + isPointUp({ x, y }: Point) { + return y < this.top && this.left <= x && this.right >= x; + } + + toDOMRect() { + const { left, top, width, height } = this; + return new DOMRect(left, top, width, height); + } +} diff --git a/blocksuite/framework/global/src/utils/model/vec.ts b/blocksuite/framework/global/src/utils/model/vec.ts new file mode 100644 index 0000000000..fa1ca1f3be --- /dev/null +++ b/blocksuite/framework/global/src/utils/model/vec.ts @@ -0,0 +1,599 @@ +// Inlined from https://raw.githubusercontent.com/tldraw/tldraw/24cad6959f59f93e20e556d018c391fd89d4ecca/packages/vec/src/index.ts +// Credits to tldraw + +export type IVec = [number, number]; + +export type IVec3 = [number, number, number]; + +export class Vec { + /** + * Absolute value of a vector. + * @param A + * @returns + */ + static abs = (A: number[]): number[] => { + return [Math.abs(A[0]), Math.abs(A[1])]; + }; + + /** + * Add vectors. + * @param A + * @param B + */ + static add = (A: number[], B: number[]): IVec => { + return [A[0] + B[0], A[1] + B[1]]; + }; + + /** + * Add scalar to vector. + * @param A + * @param B + */ + static addScalar = (A: number[], n: number): IVec => { + return [A[0] + n, A[1] + n]; + }; + + /** + * Angle between vector A and vector B in radians + * @param A + * @param B + */ + static ang = (A: number[], B: number[]): number => { + return Math.atan2(Vec.cpr(A, B), Vec.dpr(A, B)); + }; + + /** + * Get the angle between the three vectors A, B, and C. + * @param p1 + * @param pc + * @param p2 + */ + static ang3 = (p1: IVec, pc: IVec, p2: IVec): number => { + // this, + const v1 = Vec.vec(pc, p1); + const v2 = Vec.vec(pc, p2); + return Vec.ang(v1, v2); + }; + + /** + * Angle between vector A and vector B in radians + * @param A + * @param B + */ + static angle = (A: IVec, B: IVec): number => { + return Math.atan2(B[1] - A[1], B[0] - A[0]); + }; + + /** + * Get whether p1 is left of p2, relative to pc. + * @param p1 + * @param pc + * @param p2 + */ + static clockwise = (p1: number[], pc: number[], p2: number[]): boolean => { + return Vec.isLeft(p1, pc, p2) > 0; + }; + + /** + * Cross product (outer product) | A X B | + * @param A + * @param B + */ + static cpr = (A: number[], B: number[]): number => { + return A[0] * B[1] - B[0] * A[1]; + }; + + /** + * Dist length from A to B + * @param A + * @param B + */ + static dist = (A: number[], B: number[]): number => { + return Math.hypot(A[1] - B[1], A[0] - B[0]); + }; + + /** + * Dist length from A to B squared. + * @param A + * @param B + */ + static dist2 = (A: IVec, B: IVec): number => { + return Vec.len2(Vec.sub(A, B)); + }; + + /** + * Distance between a point and the nearest point on a bounding box. + * @param bounds The bounding box. + * @param P The point + * @returns + */ + static distanceToBounds = ( + bounds: { + minX: number; + minY: number; + maxX: number; + maxY: number; + }, + P: number[] + ): number => { + return Vec.dist(P, Vec.nearestPointOnBounds(bounds, P)); + }; + + /** + * Distance between a point and the nearest point on a line segment between A and B + * @param A The start of the line segment + * @param B The end of the line segment + * @param P The off-line point + * @param clamp Whether to clamp the point between A and B. + * @returns + */ + static distanceToLineSegment = ( + A: IVec, + B: IVec, + P: IVec, + clamp = true + ): number => { + return Vec.dist(P, Vec.nearestPointOnLineSegment(A, B, P, clamp)); + }; + + /** + * Distance between a point and a line with a known unit vector that passes through a point. + * @param A Any point on the line + * @param u The unit vector for the line. + * @param P A point not on the line to test. + * @returns + */ + static distanceToLineThroughPoint = (A: IVec, u: IVec, P: IVec): number => { + return Vec.dist(P, Vec.nearestPointOnLineThroughPoint(A, u, P)); + }; + + /** + * Vector division by scalar. + * @param A + * @param n + */ + static div = (A: IVec, n: number): IVec => { + return [A[0] / n, A[1] / n]; + }; + + /** + * Vector division by vector. + * @param A + * @param n + */ + static divV = (A: IVec, B: IVec): IVec => { + return [A[0] / B[0], A[1] / B[1]]; + }; + + /** + * Dot product + * @param A + * @param B + */ + static dpr = (A: number[], B: number[]): number => { + return A[0] * B[0] + A[1] * B[1]; + }; + + /** + * A faster, though less accurate method for testing distances. Maybe faster? + * @param A + * @param B + * @returns + */ + static fastDist = (A: number[], B: number[]): number[] => { + const V = [B[0] - A[0], B[1] - A[1]]; + const aV = [Math.abs(V[0]), Math.abs(V[1])]; + let r = 1 / Math.max(aV[0], aV[1]); + r = r * (1.29289 - (aV[0] + aV[1]) * r * 0.29289); + return [V[0] * r, V[1] * r]; + }; + + /** + * Interpolate from A to B when curVAL goes fromVAL: number[] => to + * @param A + * @param B + * @param from Starting value + * @param to Ending value + * @param s Strength + */ + static int = (A: IVec, B: IVec, from: number, to: number, s = 1): IVec => { + const t = (Vec.clamp(from, to) - from) / (to - from); + return Vec.add(Vec.mul(A, 1 - t), Vec.mul(B, s)); + }; + + /** + * Check of two vectors are identical. + * @param A + * @param B + */ + static isEqual = (A: number[], B: number[]): boolean => { + return A[0] === B[0] && A[1] === B[1]; + }; + + /** + * Get whether p1 is left of p2, relative to pc. + * @param p1 + * @param pc + * @param p2 + */ + static isLeft = (p1: number[], pc: number[], p2: number[]): number => { + // isLeft: >0 for counterclockwise + // =0 for none (degenerate) + // <0 for clockwise + return ( + (pc[0] - p1[0]) * (p2[1] - p1[1]) - (p2[0] - p1[0]) * (pc[1] - p1[1]) + ); + }; + + /** + * Length of the vector + * @param A + */ + static len = (A: number[]): number => { + return Math.hypot(A[0], A[1]); + }; + + /** + * Length of the vector squared + * @param A + */ + static len2 = (A: number[]): number => { + return A[0] * A[0] + A[1] * A[1]; + }; + + /** + * Interpolate vector A to B with a scalar t + * @param A + * @param B + * @param t scalar + */ + static lrp = (A: IVec, B: IVec, t: number): IVec => { + return Vec.add(A, Vec.mul(Vec.sub(B, A), t)); + }; + + /** + * Get a vector comprised of the maximum of two or more vectors. + */ + static max = (...v: number[][]) => { + return [Math.max(...v.map(a => a[0])), Math.max(...v.map(a => a[1]))]; + }; + + /** + * Mean between two vectors or mid vector between two vectors + * @param A + * @param B + */ + static med = (A: IVec, B: IVec): IVec => { + return Vec.mul(Vec.add(A, B), 0.5); + }; + + /** + * Get a vector comprised of the minimum of two or more vectors. + */ + static min = (...v: number[][]) => { + return [Math.min(...v.map(a => a[0])), Math.min(...v.map(a => a[1]))]; + }; + + /** + * Vector multiplication by scalar + * @param A + * @param n + */ + static mul = (A: IVec, n: number): IVec => { + return [A[0] * n, A[1] * n]; + }; + + /** + * Multiple two vectors. + * @param A + * @param B + */ + static mulV = (A: IVec, B: IVec): IVec => { + return [A[0] * B[0], A[1] * B[1]]; + }; + + /** + * Get the nearest point on a bounding box to a point P. + * @param bounds The bounding box + * @param P The point point + * @returns + */ + static nearestPointOnBounds = ( + bounds: { + minX: number; + minY: number; + maxX: number; + maxY: number; + }, + P: number[] + ): number[] => { + return [ + Vec.clamp(P[0], bounds.minX, bounds.maxX), + Vec.clamp(P[1], bounds.minY, bounds.maxY), + ]; + }; + + /** + * Get the nearest point on a line segment between A and B + * @param A The start of the line segment + * @param B The end of the line segment + * @param P The off-line point + * @param clamp Whether to clamp the point between A and B. + * @returns + */ + static nearestPointOnLineSegment = ( + A: IVec, + B: IVec, + P: IVec, + clamp = true + ): IVec => { + const u = Vec.uni(Vec.sub(B, A)); + const C = Vec.add(A, Vec.mul(u, Vec.pry(Vec.sub(P, A), u))); + + if (clamp) { + if (C[0] < Math.min(A[0], B[0])) return A[0] < B[0] ? A : B; + if (C[0] > Math.max(A[0], B[0])) return A[0] > B[0] ? A : B; + if (C[1] < Math.min(A[1], B[1])) return A[1] < B[1] ? A : B; + if (C[1] > Math.max(A[1], B[1])) return A[1] > B[1] ? A : B; + } + + return C; + }; + + /** + * Get the nearest point on a line with a known unit vector that passes through point A + * @param A Any point on the line + * @param u The unit vector for the line. + * @param P A point not on the line to test. + * @returns + */ + static nearestPointOnLineThroughPoint = (A: IVec, u: IVec, P: IVec): IVec => { + return Vec.add(A, Vec.mul(u, Vec.pry(Vec.sub(P, A), u))); + }; + + /** + * Negate a vector. + * @param A + */ + static neg = (A: number[]): number[] => { + return [-A[0], -A[1]]; + }; + + /** + * Get normalized / unit vector. + * @param A + */ + static normalize = (A: IVec): IVec => { + return Vec.uni(A); + }; + + /** + * Push a point A towards point B by a given distance. + * @param A + * @param B + * @param d + * @returns + */ + static nudge = (A: IVec, B: IVec, d: number): number[] => { + if (Vec.isEqual(A, B)) return A; + return Vec.add(A, Vec.mul(Vec.uni(Vec.sub(B, A)), d)); + }; + + /** + * Push a point in a given angle by a given distance. + * @param A + * @param B + * @param d + */ + static nudgeAtAngle = (A: number[], a: number, d: number): number[] => { + return [Math.cos(a) * d + A[0], Math.sin(a) * d + A[1]]; + }; + + /** + * Perpendicular rotation of a vector A + * @param A + */ + static per = (A: IVec): IVec => { + return [A[1], -A[0]]; + }; + + static pointOffset = (A: IVec, B: IVec, offset: number): IVec => { + let u = Vec.uni(Vec.sub(B, A)); + if (Vec.isEqual(A, B)) u = A; + return Vec.add(A, Vec.mul(u, offset)); + }; + + /** + * Get an array of points between two points. + * @param A The first point. + * @param B The second point. + * @param steps The number of points to return. + */ + static pointsBetween = (A: IVec, B: IVec, steps = 6): number[][] => { + return Array.from({ length: steps }).map((_, i) => { + const t = i / (steps - 1); + const k = Math.min(1, 0.5 + Math.abs(0.5 - t)); + return [...Vec.lrp(A, B, t), k]; + }); + }; + + /** + * Project A over B + * @param A + * @param B + */ + static pry = (A: number[], B: number[]): number => { + return Vec.dpr(A, B) / Vec.len(B); + }; + + static rescale = (a: number[], n: number): number[] => { + const l = Vec.len(a); + return [(n * a[0]) / l, (n * a[1]) / l]; + }; + + /** + * Vector rotation by r (radians) + * @param A + * @param r rotation in radians + */ + static rot = (A: number[], r = 0): IVec => { + return [ + A[0] * Math.cos(r) - A[1] * Math.sin(r), + A[0] * Math.sin(r) + A[1] * Math.cos(r), + ]; + }; + + /** + * Rotate a vector around another vector by r (radians) + * @param A vector + * @param C center + * @param r rotation in radians + */ + static rotWith = (A: IVec, C: IVec, r = 0): IVec => { + if (r === 0) return A; + + const s = Math.sin(r); + const c = Math.cos(r); + + const px = A[0] - C[0]; + const py = A[1] - C[1]; + + const nx = px * c - py * s; + const ny = px * s + py * c; + + return [nx + C[0], ny + C[1]]; + }; + + /** + * Get the slope between two points. + * @param A + * @param B + */ + static slope = (A: number[], B: number[]) => { + if (A[0] === B[0]) return NaN; + return (A[1] - B[1]) / (A[0] - B[0]); + }; + + /** + * Subtract vectors. + * @param A + * @param B + */ + static sub = (A: IVec, B: IVec): IVec => { + return [A[0] - B[0], A[1] - B[1]]; + }; + + /** + * Subtract scalar from vector. + * @param A + * @param B + */ + static subScalar = (A: IVec, n: number): IVec => { + return [A[0] - n, A[1] - n]; + }; + + /** + * Get the tangent between two vectors. + * @param A + * @param B + * @returns + */ + static tangent = (A: IVec, B: IVec): IVec => { + return Vec.uni(Vec.sub(A, B)); + }; + + /** + * Round a vector to two decimal places. + * @param a + */ + static toFixed = (a: number[]): number[] => { + return a.map(v => Math.round(v * 100) / 100); + }; + + static toPoint = (v: IVec) => { + return { + x: v[0], + y: v[1], + }; + }; + + /** + * Round a vector to a precision length. + * @param a + * @param n + */ + static toPrecision = (a: number[], n = 4): number[] => { + return [+a[0].toPrecision(n), +a[1].toPrecision(n)]; + }; + + static toVec = (v: { x: number; y: number }): IVec => [v.x, v.y]; + + /** + * Get normalized / unit vector. + * @param A + */ + static uni = (A: IVec): IVec => { + return Vec.div(A, Vec.len(A)); + }; + + /** + * Get the vector from vectors A to B. + * @param A + * @param B + */ + static vec = (A: IVec, B: IVec): IVec => { + // A, B as vectors get the vector from A to B + return [B[0] - A[0], B[1] - A[1]]; + }; + + /** + * Clamp a value into a range. + * @param n + * @param min + */ + static clamp(n: number, min: number): number; + + // eslint-disable-next-line @typescript-eslint/unified-signatures + static clamp(n: number, min: number, max: number): number; + + static clamp(n: number, min: number, max?: number): number { + return Math.max(min, max !== undefined ? Math.min(n, max) : n); + } + + /** + * Clamp a value into a range. + * @param n + * @param min + */ + static clampV(A: number[], min: number): number[]; + + // eslint-disable-next-line @typescript-eslint/unified-signatures + static clampV(A: number[], min: number, max: number): number[]; + + static clampV(A: number[], min: number, max?: number): number[] { + return A.map(n => + max !== undefined ? Vec.clamp(n, min, max) : Vec.clamp(n, min) + ); + } + + /** + * Cross (for point in polygon) + * + */ + static cross(x: number[], y: number[], z: number[]): number { + return (y[0] - x[0]) * (z[1] - x[1]) - (z[0] - x[0]) * (y[1] - x[1]); + } + + /** + * Snap vector to nearest step. + * @param A + * @param step + * @example + * ```ts + * Vec.snap([10.5, 28], 10) // [10, 30] + * ``` + */ + static snap(a: number[], step = 1) { + return [Math.round(a[0] / step) * step, Math.round(a[1] / step) * step]; + } +} diff --git a/blocksuite/framework/global/src/utils/perfect-freehand/LICENSE b/blocksuite/framework/global/src/utils/perfect-freehand/LICENSE new file mode 100644 index 0000000000..bdcc8b850f --- /dev/null +++ b/blocksuite/framework/global/src/utils/perfect-freehand/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2021 Stephen Ruiz Ltd + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/blocksuite/framework/global/src/utils/perfect-freehand/get-solid-stroke-points.ts b/blocksuite/framework/global/src/utils/perfect-freehand/get-solid-stroke-points.ts new file mode 100644 index 0000000000..0c41dc53e6 --- /dev/null +++ b/blocksuite/framework/global/src/utils/perfect-freehand/get-solid-stroke-points.ts @@ -0,0 +1,16 @@ +import type { IVec, IVec3 } from '../model/index.js'; +import { getStroke } from './get-stroke.js'; + +export function getSolidStrokePoints( + points: (IVec | IVec3)[], + lineWidth: number +) { + return getStroke(points, { + size: lineWidth, + thinning: 0.6, + streamline: 0.5, + smoothing: 0.5, + easing: t => Math.sin((t * Math.PI) / 2), + simulatePressure: points[0]?.length === 2, + }); +} diff --git a/blocksuite/framework/global/src/utils/perfect-freehand/get-stroke-outline-points.ts b/blocksuite/framework/global/src/utils/perfect-freehand/get-stroke-outline-points.ts new file mode 100644 index 0000000000..4150104ba0 --- /dev/null +++ b/blocksuite/framework/global/src/utils/perfect-freehand/get-stroke-outline-points.ts @@ -0,0 +1,398 @@ +import type { IVec } from '../model/index.js'; +import { getStrokeRadius } from './get-stroke-radius.js'; +import type { StrokeOptions, StrokePoint } from './types.js'; +import { + add, + dist2, + dpr, + lrp, + mul, + neg, + per, + prj, + rotAround, + sub, + uni, +} from './vec.js'; + +const { min, PI } = Math; + +// This is the rate of change for simulated pressure. It could be an option. +const RATE_OF_PRESSURE_CHANGE = 0.275; + +// Browser strokes seem to be off if PI is regular, a tiny offset seems to fix it +const FIXED_PI = PI + 0.0001; + +/** + * ## getStrokeOutlinePoints + * @description Get an array of points (as `[x, y]`) representing the outline of a stroke. + * @param points An array of StrokePoints as returned from `getStrokePoints`. + * @param options (optional) An object with options. + * @param options.size The base size (diameter) of the stroke. + * @param options.thinning The effect of pressure on the stroke's size. + * @param options.smoothing How much to soften the stroke's edges. + * @param options.easing An easing function to apply to each point's pressure. + * @param options.simulatePressure Whether to simulate pressure based on velocity. + * @param options.start Cap, taper and easing for the start of the line. + * @param options.end Cap, taper and easing for the end of the line. + * @param options.last Whether to handle the points as a completed stroke. + */ +export function getStrokeOutlinePoints( + points: StrokePoint[], + options: Partial<StrokeOptions> = {} as Partial<StrokeOptions> +): IVec[] { + const { + size = 16, + smoothing = 0.5, + thinning = 0.5, + simulatePressure = true, + easing = t => t, + start = {}, + end = {}, + last: isComplete = false, + } = options; + + const { cap: capStart = true, easing: taperStartEase = t => t * (2 - t) } = + start; + + const { cap: capEnd = true, easing: taperEndEase = t => --t * t * t + 1 } = + end; + + // We can't do anything with an empty array or a stroke with negative size. + if (points.length === 0 || size <= 0) { + return []; + } + + // The total length of the line + const totalLength = points[points.length - 1].runningLength; + + const taperStart = + start.taper === false + ? 0 + : start.taper === true + ? Math.max(size, totalLength) + : (start.taper as number); + + const taperEnd = + end.taper === false + ? 0 + : end.taper === true + ? Math.max(size, totalLength) + : (end.taper as number); + + // The minimum allowed distance between points (squared) + const minDistance = Math.pow(size * smoothing, 2); + + // Our collected left and right points + const leftPts: IVec[] = []; + const rightPts: IVec[] = []; + + // Previous pressure (start with average of first five pressures, + // in order to prevent fat starts for every line. Drawn lines + // almost always start slow! + let prevPressure = points.slice(0, 10).reduce((acc, curr) => { + let pressure = curr.pressure; + + if (simulatePressure) { + // Speed of change - how fast should the the pressure changing? + const sp = min(1, curr.distance / size); + // Rate of change - how much of a change is there? + const rp = min(1, 1 - sp); + // Accelerate the pressure + pressure = min(1, acc + (rp - acc) * (sp * RATE_OF_PRESSURE_CHANGE)); + } + + return (acc + pressure) / 2; + }, points[0].pressure); + + // The current radius + let radius = getStrokeRadius( + size, + thinning, + points[points.length - 1].pressure, + easing + ); + + // The radius of the first saved point + let firstRadius: number | undefined = undefined; + + // Previous vector + let prevVector = points[0].vector; + + // Previous left and right points + let pl = points[0].point; + let pr = pl; + + // Temporary left and right points + let tl = pl; + let tr = pr; + + // Keep track of whether the previous point is a sharp corner + // ... so that we don't detect the same corner twice + let isPrevPointSharpCorner = false; + + // let short = true + + /* + Find the outline's left and right points + + Iterating through the points and populate the rightPts and leftPts arrays, + skipping the first and last pointsm, which will get caps later on. + */ + + for (let i = 0; i < points.length; i++) { + let { pressure } = points[i]; + const { point, vector, distance, runningLength } = points[i]; + + // Removes noise from the end of the line + if (i < points.length - 1 && totalLength - runningLength < 3) { + continue; + } + + /* + Calculate the radius + + If not thinning, the current point's radius will be half the size; or + otherwise, the size will be based on the current (real or simulated) + pressure. + */ + + if (thinning) { + if (simulatePressure) { + // If we're simulating pressure, then do so based on the distance + // between the current point and the previous point, and the size + // of the stroke. Otherwise, use the input pressure. + const sp = min(1, distance / size); + const rp = min(1, 1 - sp); + pressure = min( + 1, + prevPressure + (rp - prevPressure) * (sp * RATE_OF_PRESSURE_CHANGE) + ); + } + + radius = getStrokeRadius(size, thinning, pressure, easing); + } else { + radius = size / 2; + } + + if (firstRadius === undefined) { + firstRadius = radius; + } + + /* + Apply tapering + + If the current length is within the taper distance at either the + start or the end, calculate the taper strengths. Apply the smaller + of the two taper strengths to the radius. + */ + + const ts = + runningLength < taperStart + ? taperStartEase(runningLength / taperStart) + : 1; + + const te = + totalLength - runningLength < taperEnd + ? taperEndEase((totalLength - runningLength) / taperEnd) + : 1; + + radius = Math.max(0.01, radius * Math.min(ts, te)); + + /* Add points to left and right */ + + /* + Handle sharp corners + + Find the difference (dot product) between the current and next vector. + If the next vector is at more than a right angle to the current vector, + draw a cap at the current point. + */ + + const nextVector = (i < points.length - 1 ? points[i + 1] : points[i]) + .vector; + const nextDpr = i < points.length - 1 ? dpr(vector, nextVector) : 1.0; + const prevDpr = dpr(vector, prevVector); + + const isPointSharpCorner = prevDpr < 0 && !isPrevPointSharpCorner; + const isNextPointSharpCorner = nextDpr !== null && nextDpr < 0; + + if (isPointSharpCorner || isNextPointSharpCorner) { + // It's a sharp corner. Draw a rounded cap and move on to the next point + // Considering saving these and drawing them later? So that we can avoid + // crossing future points. + + const offset = mul(per(prevVector), radius); + + for (let step = 1 / 13, t = 0; t <= 1; t += step) { + tl = rotAround(sub(point, offset), point, FIXED_PI * t); + leftPts.push(tl); + + tr = rotAround(add(point, offset), point, FIXED_PI * -t); + rightPts.push(tr); + } + + pl = tl; + pr = tr; + + if (isNextPointSharpCorner) { + isPrevPointSharpCorner = true; + } + continue; + } + + isPrevPointSharpCorner = false; + + // Handle the last point + if (i === points.length - 1) { + const offset = mul(per(vector), radius); + leftPts.push(sub(point, offset)); + rightPts.push(add(point, offset)); + continue; + } + + /* + Add regular points + + Project points to either side of the current point, using the + calculated size as a distance. If a point's distance to the + previous point on that side greater than the minimum distance + (or if the corner is kinda sharp), add the points to the side's + points array. + */ + + const offset = mul(per(lrp(nextVector, vector, nextDpr)), radius); + + tl = sub(point, offset); + + if (i <= 1 || dist2(pl, tl) > minDistance) { + leftPts.push(tl); + pl = tl; + } + + tr = add(point, offset); + + if (i <= 1 || dist2(pr, tr) > minDistance) { + rightPts.push(tr); + pr = tr; + } + + // Set variables for next iteration + prevPressure = pressure; + prevVector = vector; + } + + /* + Drawing caps + + Now that we have our points on either side of the line, we need to + draw caps at the start and end. Tapered lines don't have caps, but + may have dots for very short lines. + */ + + const firstPoint = points[0].point.slice(0, 2) as IVec; + + const lastPoint = + points.length > 1 + ? (points[points.length - 1].point.slice(0, 2) as IVec) + : add(points[0].point, [1, 1]); + + const startCap: IVec[] = []; + + const endCap: IVec[] = []; + + /* + Draw a dot for very short or completed strokes + + If the line is too short to gather left or right points and if the line is + not tapered on either side, draw a dot. If the line is tapered, then only + draw a dot if the line is both very short and complete. If we draw a dot, + we can just return those points. + */ + + if (points.length === 1) { + if (!(taperStart || taperEnd) || isComplete) { + const start = prj( + firstPoint, + uni(per(sub(firstPoint, lastPoint))), + -(firstRadius || radius) + ); + const dotPts: IVec[] = []; + for (let step = 1 / 13, t = step; t <= 1; t += step) { + dotPts.push(rotAround(start, firstPoint, FIXED_PI * 2 * t)); + } + return dotPts; + } + } else { + /* + Draw a start cap + + Unless the line has a tapered start, or unless the line has a tapered end + and the line is very short, draw a start cap around the first point. Use + the distance between the second left and right point for the cap's radius. + Finally remove the first left and right points. :psyduck: + */ + + if (taperStart || (taperEnd && points.length === 1)) { + // The start point is tapered, noop + } else if (capStart) { + // Draw the round cap - add thirteen points rotating the right point around the start point to the left point + for (let step = 1 / 13, t = step; t <= 1; t += step) { + const pt = rotAround(rightPts[0], firstPoint, FIXED_PI * t); + startCap.push(pt); + } + } else { + // Draw the flat cap - add a point to the left and right of the start point + const cornersVector = sub(leftPts[0], rightPts[0]); + const offsetA = mul(cornersVector, 0.5); + const offsetB = mul(cornersVector, 0.51); + + startCap.push( + sub(firstPoint, offsetA), + sub(firstPoint, offsetB), + add(firstPoint, offsetB), + add(firstPoint, offsetA) + ); + } + + /* + Draw an end cap + + If the line does not have a tapered end, and unless the line has a tapered + start and the line is very short, draw a cap around the last point. Finally, + remove the last left and right points. Otherwise, add the last point. Note + that This cap is a full-turn-and-a-half: this prevents incorrect caps on + sharp end turns. + */ + + const direction = per(neg(points[points.length - 1].vector)); + + if (taperEnd || (taperStart && points.length === 1)) { + // Tapered end - push the last point to the line + endCap.push(lastPoint); + } else if (capEnd) { + // Draw the round end cap + const start = prj(lastPoint, direction, radius); + for (let step = 1 / 29, t = step; t < 1; t += step) { + endCap.push(rotAround(start, lastPoint, FIXED_PI * 3 * t)); + } + } else { + // Draw the flat end cap + + endCap.push( + add(lastPoint, mul(direction, radius)), + add(lastPoint, mul(direction, radius * 0.99)), + sub(lastPoint, mul(direction, radius * 0.99)), + sub(lastPoint, mul(direction, radius)) + ); + } + } + + /* + Return the points in the correct winding order: begin on the left side, then + continue around the end cap, then come back along the right side, and finally + complete the start cap. + */ + + return leftPts.concat(endCap, rightPts.reverse(), startCap); +} diff --git a/blocksuite/framework/global/src/utils/perfect-freehand/get-stroke-points.ts b/blocksuite/framework/global/src/utils/perfect-freehand/get-stroke-points.ts new file mode 100644 index 0000000000..ec14f06ae8 --- /dev/null +++ b/blocksuite/framework/global/src/utils/perfect-freehand/get-stroke-points.ts @@ -0,0 +1,132 @@ +import type { IVec, IVec3 } from '../model/index.js'; +import type { StrokeOptions, StrokePoint } from './types.js'; +import { add, dist, isEqual, lrp, sub, uni } from './vec.js'; + +/** + * ## getStrokePoints + * @description Get an array of points as objects with an adjusted point, pressure, vector, distance, and runningLength. + * @param points An array of points (as `[x, y, pressure]` or `{x, y, pressure}`). Pressure is optional in both cases. + * @param options (optional) An object with options. + * @param options.size The base size (diameter) of the stroke. + * @param options.thinning The effect of pressure on the stroke's size. + * @param options.smoothing How much to soften the stroke's edges. + * @param options.easing An easing function to apply to each point's pressure. + * @param options.simulatePressure Whether to simulate pressure based on velocity. + * @param options.start Cap, taper and easing for the start of the line. + * @param options.end Cap, taper and easing for the end of the line. + * @param options.last Whether to handle the points as a completed stroke. + */ +export function getStrokePoints< + T extends IVec | IVec3, + K extends { x: number; y: number; pressure?: number }, +>(points: (T | K)[], options = {} as StrokeOptions): StrokePoint[] { + const { streamline = 0.5, size = 16, last: isComplete = false } = options; + + // If we don't have any points, return an empty array. + if (points.length === 0) return []; + + // Find the interpolation level between points. + const t = 0.15 + (1 - streamline) * 0.85; + + // Whatever the input is, make sure that the points are in number[][]. + let pts: (IVec3 | IVec)[] = Array.isArray(points[0]) + ? (points as T[]) + : (points as K[]).map( + ({ x, y, pressure = 0.5 }) => [x, y, pressure] as IVec3 + ); + + // Add extra points between the two, to help avoid "dash" lines + // for strokes with tapered start and ends. Don't mutate the + // input array! + if (pts.length === 2) { + const last = pts[1]; + pts = pts.slice(0, -1); + for (let i = 1; i < 5; i++) { + pts.push(lrp(pts[0] as IVec, last as IVec, i / 4)); + } + } + + // If there's only one point, add another point at a 1pt offset. + // Don't mutate the input array! + if (pts.length === 1) { + pts = [ + ...pts, + [...add(pts[0] as IVec, [1, 1]), ...pts[0].slice(2)] as IVec, + ]; + } + + // The strokePoints array will hold the points for the stroke. + // Start it out with the first point, which needs no adjustment. + const strokePoints: StrokePoint[] = [ + { + point: [pts[0][0], pts[0][1]], + pressure: (pts[0][2] ?? -1) >= 0 ? pts[0][2]! : 0.25, + vector: [1, 1], + distance: 0, + runningLength: 0, + }, + ]; + + // A flag to see whether we've already reached out minimum length + let hasReachedMinimumLength = false; + + // We use the runningLength to keep track of the total distance + let runningLength = 0; + + // We're set this to the latest point, so we can use it to calculate + // the distance and vector of the next point. + let prev = strokePoints[0]; + + const max = pts.length - 1; + + // Iterate through all of the points, creating StrokePoints. + for (let i = 1; i < pts.length; i++) { + const point = + isComplete && i === max + ? // If we're at the last point, and `options.last` is true, + // then add the actual input point. + (pts[i].slice(0, 2) as IVec) + : // Otherwise, using the t calculated from the streamline + // option, interpolate a new point between the previous + // point the current point. + lrp(prev.point, pts[i] as IVec, t); + + // If the new point is the same as the previous point, skip ahead. + if (isEqual(prev.point, point)) continue; + + // How far is the new point from the previous point? + const distance = dist(point, prev.point); + + // Add this distance to the total "running length" of the line. + runningLength += distance; + + // At the start of the line, we wait until the new point is a + // certain distance away from the original point, to avoid noise + if (i < max && !hasReachedMinimumLength) { + if (runningLength < size) continue; + hasReachedMinimumLength = true; + // TODO: Backfill the missing points so that tapering works correctly. + } + // Create a new strokepoint (it will be the new "previous" one). + prev = { + // The adjusted point + point, + // The input pressure (or .5 if not specified) + pressure: (pts[i][2] ?? -1) >= 0 ? pts[i][2]! : 0.5, + // The vector from the current point to the previous point + vector: uni(sub(prev.point, point)), + // The distance between the current point and the previous point + distance, + // The total distance so far + runningLength, + }; + + // Push it to the strokePoints array. + strokePoints.push(prev); + } + + // Set the vector of the first point to be the same as the second point. + strokePoints[0].vector = strokePoints[1]?.vector || [0, 0]; + + return strokePoints; +} diff --git a/blocksuite/framework/global/src/utils/perfect-freehand/get-stroke-radius.ts b/blocksuite/framework/global/src/utils/perfect-freehand/get-stroke-radius.ts new file mode 100644 index 0000000000..11c41a4a50 --- /dev/null +++ b/blocksuite/framework/global/src/utils/perfect-freehand/get-stroke-radius.ts @@ -0,0 +1,16 @@ +/** + * Compute a radius based on the pressure. + * @param size + * @param thinning + * @param pressure + * @param easing + * @internal + */ +export function getStrokeRadius( + size: number, + thinning: number, + pressure: number, + easing: (t: number) => number = t => t +) { + return size * easing(0.5 - thinning * (0.5 - pressure)); +} diff --git a/blocksuite/framework/global/src/utils/perfect-freehand/get-stroke.ts b/blocksuite/framework/global/src/utils/perfect-freehand/get-stroke.ts new file mode 100644 index 0000000000..74979e8ee0 --- /dev/null +++ b/blocksuite/framework/global/src/utils/perfect-freehand/get-stroke.ts @@ -0,0 +1,26 @@ +import type { IVec, IVec3 } from '../model/index.js'; +import { getStrokeOutlinePoints } from './get-stroke-outline-points.js'; +import { getStrokePoints } from './get-stroke-points.js'; +import type { StrokeOptions } from './types.js'; + +/** + * ## getStroke + * @description Get an array of points describing a polygon that surrounds the input points. + * @param points An array of points (as `[x, y, pressure]` or `{x, y, pressure}`). Pressure is optional in both cases. + * @param options (optional) An object with options. + * @param options.size The base size (diameter) of the stroke. + * @param options.thinning The effect of pressure on the stroke's size. + * @param options.smoothing How much to soften the stroke's edges. + * @param options.easing An easing function to apply to each point's pressure. + * @param options.simulatePressure Whether to simulate pressure based on velocity. + * @param options.start Cap, taper and easing for the start of the line. + * @param options.end Cap, taper and easing for the end of the line. + * @param options.last Whether to handle the points as a completed stroke. + */ + +export function getStroke( + points: (IVec | IVec3 | { x: number; y: number; pressure?: number })[], + options: StrokeOptions = {} as StrokeOptions +) { + return getStrokeOutlinePoints(getStrokePoints(points, options), options); +} diff --git a/blocksuite/framework/global/src/utils/perfect-freehand/index.ts b/blocksuite/framework/global/src/utils/perfect-freehand/index.ts new file mode 100644 index 0000000000..b52b129345 --- /dev/null +++ b/blocksuite/framework/global/src/utils/perfect-freehand/index.ts @@ -0,0 +1,5 @@ +export * from './get-solid-stroke-points.js'; +export * from './get-stroke.js'; +export * from './get-stroke-outline-points.js'; +export * from './get-stroke-points.js'; +export * from './get-stroke-radius.js'; diff --git a/blocksuite/framework/global/src/utils/perfect-freehand/types.ts b/blocksuite/framework/global/src/utils/perfect-freehand/types.ts new file mode 100644 index 0000000000..76dc453add --- /dev/null +++ b/blocksuite/framework/global/src/utils/perfect-freehand/types.ts @@ -0,0 +1,46 @@ +import type { IVec } from '../model/index.js'; + +/** + * The options object for `getStroke` or `getStrokePoints`. + * @param points An array of points (as `[x, y, pressure]` or `{x, y, pressure}`). Pressure is optional in both cases. + * @param options (optional) An object with options. + * @param options.size The base size (diameter) of the stroke. + * @param options.thinning The effect of pressure on the stroke's size. + * @param options.smoothing How much to soften the stroke's edges. + * @param options.easing An easing function to apply to each point's pressure. + * @param options.simulatePressure Whether to simulate pressure based on velocity. + * @param options.start Cap, taper and easing for the start of the line. + * @param options.end Cap, taper and easing for the end of the line. + * @param options.last Whether to handle the points as a completed stroke. + */ +export interface StrokeOptions { + size?: number; + thinning?: number; + smoothing?: number; + streamline?: number; + easing?: (pressure: number) => number; + simulatePressure?: boolean; + start?: { + cap?: boolean; + taper?: number | boolean; + easing?: (distance: number) => number; + }; + end?: { + cap?: boolean; + taper?: number | boolean; + easing?: (distance: number) => number; + }; + // Whether to handle the points as a completed stroke. + last?: boolean; +} + +/** + * The points returned by `getStrokePoints`, and the input for `getStrokeOutlinePoints`. + */ +export interface StrokePoint { + point: IVec; + pressure: number; + distance: number; + vector: IVec; + runningLength: number; +} diff --git a/blocksuite/framework/global/src/utils/perfect-freehand/vec.ts b/blocksuite/framework/global/src/utils/perfect-freehand/vec.ts new file mode 100644 index 0000000000..f792d4b103 --- /dev/null +++ b/blocksuite/framework/global/src/utils/perfect-freehand/vec.ts @@ -0,0 +1,178 @@ +import type { IVec } from '../model/index.js'; + +/** + * Negate a vector. + * @param A + * @internal + */ +export function neg(A: IVec): IVec { + return [-A[0], -A[1]]; +} + +/** + * Add vectors. + * @param A + * @param B + * @internal + */ +export function add(A: IVec, B: IVec): IVec { + return [A[0] + B[0], A[1] + B[1]]; +} + +/** + * Subtract vectors. + * @param A + * @param B + * @internal + */ +export function sub(A: IVec, B: IVec): IVec { + return [A[0] - B[0], A[1] - B[1]]; +} + +/** + * Vector multiplication by scalar + * @param A + * @param n + * @internal + */ +export function mul(A: IVec, n: number): IVec { + return [A[0] * n, A[1] * n]; +} + +/** + * Vector division by scalar. + * @param A + * @param n + * @internal + */ +export function div(A: IVec, n: number): IVec { + return [A[0] / n, A[1] / n]; +} + +/** + * Perpendicular rotation of a vector A + * @param A + * @internal + */ +export function per(A: IVec): IVec { + return [A[1], -A[0]]; +} + +/** + * Dot product + * @param A + * @param B + * @internal + */ +export function dpr(A: IVec, B: IVec) { + return A[0] * B[0] + A[1] * B[1]; +} + +/** + * Get whether two vectors are equal. + * @param A + * @param B + * @internal + */ +export function isEqual(A: IVec, B: IVec) { + return A[0] === B[0] && A[1] === B[1]; +} + +/** + * Length of the vector + * @param A + * @internal + */ +export function len(A: IVec) { + return Math.hypot(A[0], A[1]); +} + +/** + * Length of the vector squared + * @param A + * @internal + */ +export function len2(A: IVec) { + return A[0] * A[0] + A[1] * A[1]; +} + +/** + * Dist length from A to B squared. + * @param A + * @param B + * @internal + */ +export function dist2(A: IVec, B: IVec) { + return len2(sub(A, B)); +} + +/** + * Get normalized / unit vector. + * @param A + * @internal + */ +export function uni(A: IVec) { + return div(A, len(A)); +} + +/** + * Dist length from A to B + * @param A + * @param B + * @internal + */ +export function dist(A: IVec, B: IVec) { + return Math.hypot(A[1] - B[1], A[0] - B[0]); +} + +/** + * Mean between two vectors or mid vector between two vectors + * @param A + * @param B + * @internal + */ +export function med(A: IVec, B: IVec) { + return mul(add(A, B), 0.5); +} + +/** + * Rotate a vector around another vector by r (radians) + * @param A vector + * @param C center + * @param r rotation in radians + * @internal + */ +export function rotAround(A: IVec, C: IVec, r: number): IVec { + const s = Math.sin(r); + const c = Math.cos(r); + + const px = A[0] - C[0]; + const py = A[1] - C[1]; + + const nx = px * c - py * s; + const ny = px * s + py * c; + + return [nx + C[0], ny + C[1]]; +} + +/** + * Interpolate vector A to B with a scalar t + * @param A + * @param B + * @param t scalar + * @internal + */ +export function lrp(A: IVec, B: IVec, t: number) { + return add(A, mul(sub(B, A), t)); +} + +/** + * Project a point A in the direction B by a scalar c + * @param A + * @param B + * @param c + * @internal + */ +export function prj(A: IVec, B: IVec, c: number) { + return add(A, mul(B, c)); +} diff --git a/blocksuite/framework/global/src/utils/polyline.ts b/blocksuite/framework/global/src/utils/polyline.ts new file mode 100644 index 0000000000..869ebae67a --- /dev/null +++ b/blocksuite/framework/global/src/utils/polyline.ts @@ -0,0 +1,136 @@ +import { type IVec, Vec } from './model/index.js'; + +export class Polyline { + static len(points: IVec[]) { + const n = points.length; + + if (n < 2) { + return 0; + } + + let i = 0; + let len = 0; + let curr: IVec; + let prev = points[0]; + + while (++i < n) { + curr = points[i]; + len += Vec.dist(prev, curr); + prev = curr; + } + + return len; + } + + static lenAtPoint(points: IVec[], point: IVec) { + const n = points.length; + let len = n; + + for (let i = 0; i < n - 1; i++) { + const a = points[i]; + const b = points[i + 1]; + + // start + if (a[0] === point[0] && a[1] === point[1]) { + return len; + } + + const aa = Vec.angle(a, point); + const ba = Vec.angle(b, point); + + if ((aa + ba) % Math.PI === 0) { + len += Vec.dist(a, point); + return len; + } + + len += Vec.dist(a, b); + + // end + if (b[0] === point[0] && b[1] === point[1]) { + return len; + } + } + + return len; + } + + static nearestPoint(points: IVec[], point: IVec): IVec { + const n = points.length; + const r: IVec = [0, 0]; + let len = Infinity; + + for (let i = 0; i < n - 1; i++) { + const a = points[i]; + const b = points[i + 1]; + const p = Vec.nearestPointOnLineSegment(a, b, point, true); + const d = Vec.dist(p, point); + if (d < len) { + len = d; + r[0] = p[0]; + r[1] = p[1]; + } + } + + return r; + } + + static pointAt(points: IVec[], ratio: number) { + const n = points.length; + + if (n === 0) { + return null; + } + + if (n === 1) { + return points[0]; + } + + if (ratio <= 0) { + return points[0]; + } + + if (ratio >= 1) { + return points[n - 1]; + } + + const total = Polyline.len(points); + const len = total * ratio; + return Polyline.pointAtLen(points, len); + } + + static pointAtLen(points: IVec[], len: number): IVec | null { + const n = points.length; + + if (n === 0) { + return null; + } + + if (n === 1) { + return points[0]; + } + + let fromStart = true; + if (len < 0) { + fromStart = false; + len = -len; + } + + let tmp = 0; + for (let j = 0, k = n - 1; j < k; j++) { + const i = fromStart ? j : k - 1 - j; + const a = points[i]; + const b = points[i + 1]; + const d = Vec.dist(a, b); + + if (len <= tmp + d) { + const t = ((fromStart ? 1 : -1) * (len - tmp)) / d; + return Vec.lrp(a, b, t) as IVec; + } + + tmp += d; + } + + const lastPoint = fromStart ? points[n - 1] : points[0]; + return lastPoint; + } +} diff --git a/blocksuite/framework/global/src/utils/signal-watcher.ts b/blocksuite/framework/global/src/utils/signal-watcher.ts new file mode 100644 index 0000000000..f164c122b6 --- /dev/null +++ b/blocksuite/framework/global/src/utils/signal-watcher.ts @@ -0,0 +1,73 @@ +/** + * @license + * Copyright 2023 Google LLC + * SPDX-License-Identifier: BSD-3-Clause + */ + +import { effect } from '@preact/signals-core'; +import type { ReactiveElement } from 'lit'; + +type ReactiveElementConstructor = abstract new ( + ...args: any[] +) => ReactiveElement; + +/** + * Adds the ability for a LitElement or other ReactiveElement class to + * watch for access to Preact signals during the update lifecycle and + * trigger a new update when signals values change. + */ +export function SignalWatcher<T extends ReactiveElementConstructor>( + Base: T +): T { + abstract class SignalWatcher extends Base { + private __dispose?: () => void; + + override connectedCallback(): void { + super.connectedCallback(); + // In order to listen for signals again after re-connection, we must + // re-render to capture all the current signal accesses. + this.requestUpdate(); + } + + override disconnectedCallback(): void { + super.disconnectedCallback(); + this.__dispose?.(); + } + + override performUpdate() { + // ReactiveElement.performUpdate() also does this check, so we want to + // also bail early so we don't erroneously appear to not depend on any + // signals. + if (this.isUpdatePending === false || this.isConnected === false) { + return; + } + // If we have a previous effect, dispose it + this.__dispose?.(); + + // Tracks whether the effect callback is triggered by this performUpdate + // call directly, or by a signal change. + let updateFromLit = true; + + // We create a new effect to capture all signal access within the + // performUpdate phase (update, render, updated, etc) of the element. + // Q: Do we need to create a new effect each render? + // TODO: test various combinations of render triggers: + // - from requestUpdate() + // - from signals + // - from both (do we get one or two re-renders) + // and see if we really need a new effect here. + this.__dispose = effect(() => { + if (updateFromLit) { + updateFromLit = false; + super.performUpdate(); + } else { + // This branch is an effect run from Preact signals. + // This will cause another call into performUpdate, which will + // then create a new effect watching that update pass. + this.requestUpdate(); + } + }); + } + } + return SignalWatcher; +} diff --git a/blocksuite/framework/global/src/utils/slot.ts b/blocksuite/framework/global/src/utils/slot.ts new file mode 100644 index 0000000000..665be698a7 --- /dev/null +++ b/blocksuite/framework/global/src/utils/slot.ts @@ -0,0 +1,151 @@ +import { type Disposable, flattenDisposables } from './disposable.js'; + +// Credits to blocky-editor +// https://github.com/vincentdchan/blocky-editor +export class Slot<T = void> implements Disposable { + private _callbacks: ((v: T) => unknown)[] = []; + + private _disposables: Disposable[] = []; + + private _emitting = false; + + subscribe = <U>( + selector: (state: T) => U, + callback: (value: U) => void, + config?: { + equalityFn?: (a: U, b: U) => boolean; + filter?: (state: T) => boolean; + } + ) => { + let prevState: U | undefined; + const { filter, equalityFn = Object.is } = config ?? {}; + return this.on(state => { + if (filter && !filter(state)) { + return; + } + const nextState = selector(state); + if (prevState === undefined || !equalityFn(prevState, nextState)) { + callback(nextState); + prevState = nextState; + } + }); + }; + + dispose() { + flattenDisposables(this._disposables).dispose(); + this._callbacks = []; + this._disposables = []; + } + + emit(v: T) { + const prevEmitting = this._emitting; + this._emitting = true; + this._callbacks.forEach(f => { + try { + f(v); + } catch (err) { + console.error(err); + } + }); + this._emitting = prevEmitting; + } + + filter(testFun: (v: T) => boolean): Slot<T> { + const result = new Slot<T>(); + // if the original slot is disposed, dispose the filtered one + this._disposables.push({ + dispose: () => result.dispose(), + }); + + this.on((v: T) => { + if (testFun(v)) { + result.emit(v); + } + }); + + return result; + } + + flatMap<U>(mapper: (v: T) => U[] | U): Slot<U> { + const result = new Slot<U>(); + this._disposables.push({ + dispose: () => result.dispose(), + }); + + this.on((v: T) => { + const data = mapper(v); + if (Array.isArray(data)) { + data.forEach(v => result.emit(v)); + } else { + result.emit(data); + } + }); + + return result; + } + + on(callback: (v: T) => unknown): Disposable { + if (this._emitting) { + const newCallback = [...this._callbacks, callback]; + this._callbacks = newCallback; + } else { + this._callbacks.push(callback); + } + return { + dispose: () => { + if (this._emitting) { + this._callbacks = this._callbacks.filter(v => v !== callback); + } else { + const index = this._callbacks.indexOf(callback); + if (index > -1) { + this._callbacks.splice(index, 1); // remove one item only + } + } + }, + }; + } + + once(callback: (v: T) => unknown): Disposable { + let dispose: Disposable['dispose'] | undefined = undefined; + const handler = (v: T) => { + callback(v); + if (dispose) { + dispose(); + } + }; + const disposable = this.on(handler); + dispose = disposable.dispose; + return disposable; + } + + pipe(that: Slot<T>): Slot<T> { + this._callbacks.push(v => that.emit(v)); + return this; + } + + toDispose(disposables: Disposable[]): Slot<T> { + disposables.push(this); + return this; + } + + unshift(callback: (v: T) => unknown): Disposable { + if (this._emitting) { + const newCallback = [callback, ...this._callbacks]; + this._callbacks = newCallback; + } else { + this._callbacks.unshift(callback); + } + return { + dispose: () => { + if (this._emitting) { + this._callbacks = this._callbacks.filter(v => v !== callback); + } else { + const index = this._callbacks.indexOf(callback); + if (index > -1) { + this._callbacks.splice(index, 1); // remove one item only + } + } + }, + }; + } +} diff --git a/blocksuite/framework/global/src/utils/types.ts b/blocksuite/framework/global/src/utils/types.ts new file mode 100644 index 0000000000..6617133661 --- /dev/null +++ b/blocksuite/framework/global/src/utils/types.ts @@ -0,0 +1,12 @@ +export type Constructor<T = object, Arguments extends any[] = any[]> = new ( + ...args: Arguments +) => T; + +// Recursive type to make all properties optional +export type DeepPartial<T> = { + [P in keyof T]?: T[P] extends object + ? T[P] extends Array<infer U> + ? Array<DeepPartial<U>> + : DeepPartial<T[P]> + : T[P]; +}; diff --git a/blocksuite/framework/global/src/utils/with-disposable.ts b/blocksuite/framework/global/src/utils/with-disposable.ts new file mode 100644 index 0000000000..869e5f1350 --- /dev/null +++ b/blocksuite/framework/global/src/utils/with-disposable.ts @@ -0,0 +1,53 @@ +import type { LitElement } from 'lit'; + +import { DisposableGroup } from './disposable.js'; +import type { Constructor } from './types.js'; + +// See https://lit.dev/docs/composition/mixins/#mixins-in-typescript +// This definition should be exported, see https://github.com/microsoft/TypeScript/issues/30355#issuecomment-839834550 +export declare class DisposableClass { + protected _disposables: DisposableGroup; + + readonly disposables: DisposableGroup; +} + +/** + * Mixin that adds a `_disposables: DisposableGroup` property to the class. + * + * The `_disposables` property is initialized in `connectedCallback` and disposed in `disconnectedCallback`. + * + * see https://lit.dev/docs/composition/mixins/ + * + * @example + * ```ts + * class MyElement extends WithDisposable(ShadowlessElement) { + * onClick() { + * this._disposables.add(...); + * } + * } + * ``` + */ +export function WithDisposable<T extends Constructor<LitElement>>( + SuperClass: T +) { + class DerivedClass extends SuperClass { + protected _disposables = new DisposableGroup(); + + get disposables() { + return this._disposables; + } + + override connectedCallback() { + super.connectedCallback(); + if (this._disposables.disposed) { + this._disposables = new DisposableGroup(); + } + } + + override disconnectedCallback() { + super.disconnectedCallback(); + this._disposables.dispose(); + } + } + return DerivedClass as unknown as T & Constructor<DisposableClass>; +} diff --git a/blocksuite/framework/global/src/utils/xywh.ts b/blocksuite/framework/global/src/utils/xywh.ts new file mode 100644 index 0000000000..ed54df1bee --- /dev/null +++ b/blocksuite/framework/global/src/utils/xywh.ts @@ -0,0 +1,29 @@ +/** + * XYWH represents the x, y, width, and height of an element or block. + */ +export type XYWH = [number, number, number, number]; + +/** + * SerializedXYWH is a string that represents the x, y, width, and height of a block. + */ +export type SerializedXYWH = `[${number},${number},${number},${number}]`; + +export function serializeXYWH( + x: number, + y: number, + w: number, + h: number +): SerializedXYWH { + return `[${x},${y},${w},${h}]`; +} + +export function deserializeXYWH(xywh: string): XYWH { + try { + return JSON.parse(xywh) as XYWH; + } catch (e) { + console.error('Failed to deserialize xywh', xywh); + console.error(e); + + return [0, 0, 0, 0]; + } +} diff --git a/blocksuite/framework/global/tsconfig.json b/blocksuite/framework/global/tsconfig.json new file mode 100644 index 0000000000..d36e6e78a2 --- /dev/null +++ b/blocksuite/framework/global/tsconfig.json @@ -0,0 +1,11 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "rootDir": "./src/", + "outDir": "./dist/", + "noEmit": false + }, + "include": ["./src", "index.d.ts"], + // This package MUST NOT use any other package. + "references": [] +} diff --git a/blocksuite/framework/global/vitest.config.ts b/blocksuite/framework/global/vitest.config.ts new file mode 100644 index 0000000000..09ab57ed83 --- /dev/null +++ b/blocksuite/framework/global/vitest.config.ts @@ -0,0 +1,22 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + include: ['src/__tests__/**/*.unit.spec.ts'], + testTimeout: 500, + coverage: { + provider: 'istanbul', // or 'c8' + reporter: ['lcov'], + reportsDirectory: '../../../.coverage/global', + }, + /** + * Custom handler for console.log in tests. + * + * Return `false` to ignore the log. + */ + onConsoleLog(log, type) { + console.warn(`Unexpected ${type} log`, log); + throw new Error(log); + }, + }, +}); diff --git a/blocksuite/framework/inline/README.md b/blocksuite/framework/inline/README.md new file mode 100644 index 0000000000..04ab3b19b6 --- /dev/null +++ b/blocksuite/framework/inline/README.md @@ -0,0 +1,3 @@ +# `@blocksuite/inline` + +Inline rich text editing component for BlockSuite. Checkout the docs at [blocksuite.io/inline](https://blocksuite.io/guide/inline.html). diff --git a/blocksuite/framework/inline/package.json b/blocksuite/framework/inline/package.json new file mode 100644 index 0000000000..b8d721fcc0 --- /dev/null +++ b/blocksuite/framework/inline/package.json @@ -0,0 +1,40 @@ +{ + "name": "@blocksuite/inline", + "description": "A micro editor.", + "type": "module", + "scripts": { + "build": "tsc", + "test:unit": "nx vite:test --run", + "test:unit:coverage": "nx vite:test --run --coverage", + "test:unit:ui": "nx vite:test --ui" + }, + "sideEffects": false, + "keywords": [], + "files": [ + "src", + "dist", + "!src/__tests__", + "!dist/__tests__" + ], + "author": "toeverything", + "license": "MIT", + "devDependencies": { + "lit": "^3.2.0", + "yjs": "patch:yjs@npm%3A13.6.18#~/.yarn/patches/yjs-npm-13.6.18-ad0d5f7c43.patch" + }, + "peerDependencies": { + "lit": "^3.2.0", + "yjs": "*" + }, + "exports": { + ".": "./src/index.ts", + "./consts": "./src/consts.ts", + "./effects": "./src/effects.ts", + "./types": "./src/types.ts" + }, + "dependencies": { + "@blocksuite/global": "workspace:*", + "@preact/signals-core": "^1.8.0", + "zod": "^3.23.8" + } +} diff --git a/blocksuite/framework/inline/src/__tests__/convert.unit.spec.ts b/blocksuite/framework/inline/src/__tests__/convert.unit.spec.ts new file mode 100644 index 0000000000..828bd86764 --- /dev/null +++ b/blocksuite/framework/inline/src/__tests__/convert.unit.spec.ts @@ -0,0 +1,145 @@ +import { expect, test } from 'vitest'; + +import { + deltaInsertsToChunks, + transformDelta, +} from '../utils/delta-convert.js'; + +test('transformDelta', () => { + expect( + transformDelta({ + insert: 'aaa', + attributes: { + bold: true, + }, + }) + ).toEqual([ + { + insert: 'aaa', + attributes: { + bold: true, + }, + }, + ]); + + expect( + transformDelta({ + insert: '\n\naaa\n\nbbb\n\n', + attributes: { + bold: true, + }, + }) + ).toEqual([ + '\n', + '\n', + { + insert: 'aaa', + attributes: { + bold: true, + }, + }, + '\n', + '\n', + { + insert: 'bbb', + attributes: { + bold: true, + }, + }, + '\n', + '\n', + ]); +}); + +test('deltaInsertsToChunks', () => { + expect( + deltaInsertsToChunks([ + { + insert: 'aaa', + attributes: { + bold: true, + }, + }, + ]) + ).toEqual([ + [ + { + insert: 'aaa', + attributes: { + bold: true, + }, + }, + ], + ]); + + expect( + deltaInsertsToChunks([ + { + insert: '\n\naaa\nbbb\n\n', + attributes: { + bold: true, + }, + }, + ]) + ).toEqual([ + [], + [], + [ + { + insert: 'aaa', + attributes: { + bold: true, + }, + }, + ], + [ + { + insert: 'bbb', + attributes: { + bold: true, + }, + }, + ], + [], + [], + ]); + + expect( + deltaInsertsToChunks([ + { + insert: '\n\naaa\n', + attributes: { + bold: true, + }, + }, + { + insert: '\nbbb\n\n', + attributes: { + italic: true, + }, + }, + ]) + ).toEqual([ + [], + [], + [ + { + insert: 'aaa', + attributes: { + bold: true, + }, + }, + ], + [], + [ + { + insert: 'bbb', + attributes: { + italic: true, + }, + }, + ], + [], + [], + ]); +}); diff --git a/blocksuite/framework/inline/src/__tests__/editor.unit.spec.ts b/blocksuite/framework/inline/src/__tests__/editor.unit.spec.ts new file mode 100644 index 0000000000..e968454f0e --- /dev/null +++ b/blocksuite/framework/inline/src/__tests__/editor.unit.spec.ts @@ -0,0 +1,526 @@ +import { expect, test } from 'vitest'; +import * as Y from 'yjs'; + +import { InlineEditor } from '../inline-editor.js'; + +test('getDeltaByRangeIndex', () => { + const yDoc = new Y.Doc(); + const yText = yDoc.getText('text'); + yText.applyDelta([ + { + insert: 'aaa', + attributes: { + bold: true, + }, + }, + { + insert: 'bbb', + attributes: { + italic: true, + }, + }, + ]); + const inlineEditor = new InlineEditor(yText); + + expect(inlineEditor.getDeltaByRangeIndex(0)).toEqual({ + insert: 'aaa', + attributes: { + bold: true, + }, + }); + + expect(inlineEditor.getDeltaByRangeIndex(1)).toEqual({ + insert: 'aaa', + attributes: { + bold: true, + }, + }); + + expect(inlineEditor.getDeltaByRangeIndex(3)).toEqual({ + insert: 'aaa', + attributes: { + bold: true, + }, + }); + + expect(inlineEditor.getDeltaByRangeIndex(4)).toEqual({ + insert: 'bbb', + attributes: { + italic: true, + }, + }); +}); + +test('getDeltasByInlineRange', () => { + const yDoc = new Y.Doc(); + const yText = yDoc.getText('text'); + yText.applyDelta([ + { + insert: 'aaa', + attributes: { + bold: true, + }, + }, + { + insert: 'bbb', + attributes: { + italic: true, + }, + }, + { + insert: 'ccc', + attributes: { + underline: true, + }, + }, + ]); + const inlineEditor = new InlineEditor(yText); + + expect( + inlineEditor.getDeltasByInlineRange({ + index: 0, + length: 0, + }) + ).toEqual([ + [ + { + insert: 'aaa', + attributes: { + bold: true, + }, + }, + { + index: 0, + length: 3, + }, + ], + ]); + + expect( + inlineEditor.getDeltasByInlineRange({ + index: 0, + length: 1, + }) + ).toEqual([ + [ + { + insert: 'aaa', + attributes: { + bold: true, + }, + }, + { + index: 0, + length: 3, + }, + ], + ]); + + expect( + inlineEditor.getDeltasByInlineRange({ + index: 0, + length: 3, + }) + ).toEqual([ + [ + { + insert: 'aaa', + attributes: { + bold: true, + }, + }, + { + index: 0, + length: 3, + }, + ], + ]); + + expect( + inlineEditor.getDeltasByInlineRange({ + index: 0, + length: 4, + }) + ).toEqual([ + [ + { + insert: 'aaa', + attributes: { + bold: true, + }, + }, + { + index: 0, + length: 3, + }, + ], + [ + { + insert: 'bbb', + attributes: { + italic: true, + }, + }, + { + index: 3, + length: 3, + }, + ], + ]); + + expect( + inlineEditor.getDeltasByInlineRange({ + index: 3, + length: 1, + }) + ).toEqual([ + [ + { + insert: 'aaa', + attributes: { + bold: true, + }, + }, + { + index: 0, + length: 3, + }, + ], + [ + { + insert: 'bbb', + attributes: { + italic: true, + }, + }, + { + index: 3, + length: 3, + }, + ], + ]); + + expect( + inlineEditor.getDeltasByInlineRange({ + index: 3, + length: 3, + }) + ).toEqual([ + [ + { + insert: 'aaa', + attributes: { + bold: true, + }, + }, + { + index: 0, + length: 3, + }, + ], + [ + { + insert: 'bbb', + attributes: { + italic: true, + }, + }, + { + index: 3, + length: 3, + }, + ], + ]); + + expect( + inlineEditor.getDeltasByInlineRange({ + index: 3, + length: 4, + }) + ).toEqual([ + [ + { + insert: 'aaa', + attributes: { + bold: true, + }, + }, + { + index: 0, + length: 3, + }, + ], + [ + { + insert: 'bbb', + attributes: { + italic: true, + }, + }, + { + index: 3, + length: 3, + }, + ], + [ + { + insert: 'ccc', + attributes: { + underline: true, + }, + }, + { + index: 6, + length: 3, + }, + ], + ]); + + expect( + inlineEditor.getDeltasByInlineRange({ + index: 4, + length: 0, + }) + ).toEqual([ + [ + { + insert: 'bbb', + attributes: { + italic: true, + }, + }, + { + index: 3, + length: 3, + }, + ], + ]); + + expect( + inlineEditor.getDeltasByInlineRange({ + index: 4, + length: 1, + }) + ).toEqual([ + [ + { + insert: 'bbb', + attributes: { + italic: true, + }, + }, + { + index: 3, + length: 3, + }, + ], + ]); + + expect( + inlineEditor.getDeltasByInlineRange({ + index: 4, + length: 2, + }) + ).toEqual([ + [ + { + insert: 'bbb', + attributes: { + italic: true, + }, + }, + { + index: 3, + length: 3, + }, + ], + ]); + + expect( + inlineEditor.getDeltasByInlineRange({ + index: 4, + length: 4, + }) + ).toEqual([ + [ + { + insert: 'bbb', + attributes: { + italic: true, + }, + }, + { + index: 3, + length: 3, + }, + ], + [ + { + insert: 'ccc', + attributes: { + underline: true, + }, + }, + { + index: 6, + length: 3, + }, + ], + ]); +}); + +test('cursor with format', () => { + const yDoc = new Y.Doc(); + const yText = yDoc.getText('text'); + const inlineEditor = new InlineEditor(yText); + + inlineEditor.insertText( + { + index: 0, + length: 0, + }, + 'aaa', + { + bold: true, + } + ); + + inlineEditor.setMarks({ + italic: true, + }); + + inlineEditor.insertText( + { + index: 3, + length: 0, + }, + 'bbb' + ); + + expect(inlineEditor.yText.toDelta()).toEqual([ + { + insert: 'aaa', + attributes: { + bold: true, + }, + }, + { + insert: 'bbb', + attributes: { + italic: true, + }, + }, + ]); +}); + +test('getFormat', () => { + const yDoc = new Y.Doc(); + const yText = yDoc.getText('text'); + const inlineEditor = new InlineEditor(yText); + + inlineEditor.insertText( + { + index: 0, + length: 0, + }, + 'aaa', + { + bold: true, + } + ); + + inlineEditor.insertText( + { + index: 3, + length: 0, + }, + 'bbb', + { + italic: true, + } + ); + + expect(inlineEditor.getFormat({ index: 0, length: 0 })).toEqual({}); + + expect(inlineEditor.getFormat({ index: 0, length: 1 })).toEqual({ + bold: true, + }); + + expect(inlineEditor.getFormat({ index: 0, length: 3 })).toEqual({ + bold: true, + }); + + expect(inlineEditor.getFormat({ index: 3, length: 0 })).toEqual({ + bold: true, + }); + + expect(inlineEditor.getFormat({ index: 3, length: 1 })).toEqual({ + italic: true, + }); + + expect(inlineEditor.getFormat({ index: 3, length: 3 })).toEqual({ + italic: true, + }); + + expect(inlineEditor.getFormat({ index: 6, length: 0 })).toEqual({ + italic: true, + }); +}); + +test('incorrect format value `false`', () => { + const yDoc = new Y.Doc(); + const yText = yDoc.getText('text'); + const inlineEditor = new InlineEditor(yText); + + inlineEditor.insertText( + { + index: 0, + length: 0, + }, + 'aaa', + { + // @ts-expect-error insert incorrect value + bold: false, + italic: true, + } + ); + + inlineEditor.insertText( + { + index: 3, + length: 0, + }, + 'bbb', + { + underline: true, + } + ); + + expect(inlineEditor.yText.toDelta()).toEqual([ + { + insert: 'aaa', + attributes: { + italic: true, + }, + }, + { + insert: 'bbb', + attributes: { + underline: true, + }, + }, + ]); +}); + +test('yText should not contain \r', () => { + const yDoc = new Y.Doc(); + const yText = yDoc.getText('text'); + yText.insert(0, 'aaa\r'); + + expect(yText.toString()).toEqual('aaa\r'); + expect(() => { + new InlineEditor(yText); + }).toThrow( + 'yText must not contain "\\r" because it will break the range synchronization' + ); +}); diff --git a/blocksuite/framework/inline/src/__tests__/inline-range.unit.spec.ts b/blocksuite/framework/inline/src/__tests__/inline-range.unit.spec.ts new file mode 100644 index 0000000000..734f6e5a7c --- /dev/null +++ b/blocksuite/framework/inline/src/__tests__/inline-range.unit.spec.ts @@ -0,0 +1,347 @@ +import { expect, test } from 'vitest'; + +import { + intersectInlineRange, + isInlineRangeAfter, + isInlineRangeBefore, + isInlineRangeContain, + isInlineRangeEdge, + isInlineRangeEdgeAfter, + isInlineRangeEdgeBefore, + isInlineRangeEqual, + isInlineRangeIntersect, + isPoint, + mergeInlineRange, +} from '../utils/inline-range.js'; + +test('isInlineRangeContain', () => { + expect( + isInlineRangeContain({ index: 0, length: 0 }, { index: 0, length: 0 }) + ).toEqual(true); + + expect( + isInlineRangeContain({ index: 0, length: 0 }, { index: 0, length: 2 }) + ).toEqual(false); + + expect( + isInlineRangeContain({ index: 0, length: 2 }, { index: 0, length: 0 }) + ).toEqual(true); + + expect( + isInlineRangeContain({ index: 0, length: 2 }, { index: 0, length: 1 }) + ).toEqual(true); + + expect( + isInlineRangeContain({ index: 0, length: 2 }, { index: 0, length: 2 }) + ).toEqual(true); + + expect( + isInlineRangeContain({ index: 1, length: 3 }, { index: 0, length: 0 }) + ).toEqual(false); + + expect( + isInlineRangeContain({ index: 1, length: 3 }, { index: 0, length: 1 }) + ).toEqual(false); + + expect( + isInlineRangeContain({ index: 1, length: 3 }, { index: 0, length: 2 }) + ).toEqual(false); + + expect( + isInlineRangeContain({ index: 1, length: 4 }, { index: 2, length: 0 }) + ).toEqual(true); + + expect( + isInlineRangeContain({ index: 1, length: 4 }, { index: 2, length: 3 }) + ).toEqual(true); + + expect( + isInlineRangeContain({ index: 1, length: 4 }, { index: 2, length: 4 }) + ).toEqual(false); +}); + +test('isInlineRangeEqual', () => { + expect( + isInlineRangeEqual({ index: 0, length: 0 }, { index: 0, length: 0 }) + ).toEqual(true); + + expect( + isInlineRangeEqual({ index: 0, length: 2 }, { index: 0, length: 1 }) + ).toEqual(false); + + expect( + isInlineRangeEqual({ index: 1, length: 3 }, { index: 1, length: 3 }) + ).toEqual(true); + + expect( + isInlineRangeEqual({ index: 0, length: 0 }, { index: 1, length: 0 }) + ).toEqual(false); + + expect( + isInlineRangeEqual({ index: 2, length: 0 }, { index: 2, length: 0 }) + ).toEqual(true); +}); + +test('isInlineRangeIntersect', () => { + expect( + isInlineRangeIntersect({ index: 0, length: 2 }, { index: 0, length: 0 }) + ).toEqual(true); + + expect( + isInlineRangeIntersect({ index: 0, length: 2 }, { index: 2, length: 0 }) + ).toEqual(true); + + expect( + isInlineRangeIntersect({ index: 0, length: 0 }, { index: 1, length: 0 }) + ).toEqual(false); + + expect( + isInlineRangeIntersect({ index: 1, length: 0 }, { index: 1, length: 0 }) + ).toEqual(true); + + expect( + isInlineRangeIntersect({ index: 1, length: 0 }, { index: 0, length: 1 }) + ).toEqual(true); + + expect( + isInlineRangeIntersect({ index: 1, length: 0 }, { index: 0, length: 0 }) + ).toEqual(false); + + expect( + isInlineRangeIntersect({ index: 1, length: 0 }, { index: 2, length: 0 }) + ).toEqual(false); + + expect( + isInlineRangeIntersect({ index: 1, length: 0 }, { index: 0, length: 2 }) + ).toEqual(true); +}); + +test('isInlineRangeBefore', () => { + expect( + isInlineRangeBefore({ index: 0, length: 1 }, { index: 2, length: 0 }) + ).toEqual(true); + + expect( + isInlineRangeBefore({ index: 2, length: 0 }, { index: 0, length: 1 }) + ).toEqual(false); + + expect( + isInlineRangeBefore({ index: 0, length: 0 }, { index: 1, length: 0 }) + ).toEqual(true); + + expect( + isInlineRangeBefore({ index: 1, length: 0 }, { index: 0, length: 0 }) + ).toEqual(false); + + expect( + isInlineRangeBefore({ index: 0, length: 0 }, { index: 0, length: 0 }) + ).toEqual(true); + + expect( + isInlineRangeBefore({ index: 0, length: 0 }, { index: 0, length: 1 }) + ).toEqual(true); + + expect( + isInlineRangeBefore({ index: 0, length: 1 }, { index: 0, length: 0 }) + ).toEqual(false); +}); + +test('isInlineRangeAfter', () => { + expect( + isInlineRangeAfter({ index: 2, length: 0 }, { index: 0, length: 1 }) + ).toEqual(true); + + expect( + isInlineRangeAfter({ index: 0, length: 1 }, { index: 2, length: 0 }) + ).toEqual(false); + + expect( + isInlineRangeAfter({ index: 1, length: 0 }, { index: 0, length: 0 }) + ).toEqual(true); + + expect( + isInlineRangeAfter({ index: 0, length: 0 }, { index: 1, length: 0 }) + ).toEqual(false); + + expect( + isInlineRangeAfter({ index: 0, length: 0 }, { index: 0, length: 0 }) + ).toEqual(true); + + expect( + isInlineRangeAfter({ index: 0, length: 0 }, { index: 0, length: 1 }) + ).toEqual(false); + + expect( + isInlineRangeAfter({ index: 0, length: 1 }, { index: 0, length: 0 }) + ).toEqual(true); +}); + +test('isInlineRangeEdge', () => { + expect(isInlineRangeEdge(1, { index: 1, length: 0 })).toEqual(true); + + expect(isInlineRangeEdge(1, { index: 0, length: 1 })).toEqual(true); + + expect(isInlineRangeEdge(0, { index: 0, length: 0 })).toEqual(true); + + expect(isInlineRangeEdge(1, { index: 0, length: 0 })).toEqual(false); + + expect(isInlineRangeEdge(0, { index: 1, length: 0 })).toEqual(false); + + expect(isInlineRangeEdge(0, { index: 0, length: 1 })).toEqual(true); +}); + +test('isInlineRangeEdgeBefore', () => { + expect(isInlineRangeEdgeBefore(1, { index: 1, length: 0 })).toEqual(true); + + expect(isInlineRangeEdgeBefore(1, { index: 0, length: 1 })).toEqual(false); + + expect(isInlineRangeEdgeBefore(0, { index: 0, length: 0 })).toEqual(true); + + expect(isInlineRangeEdgeBefore(1, { index: 0, length: 0 })).toEqual(false); + + expect(isInlineRangeEdgeBefore(0, { index: 1, length: 0 })).toEqual(false); + + expect(isInlineRangeEdgeBefore(0, { index: 0, length: 1 })).toEqual(true); +}); + +test('isInlineRangeEdgeAfter', () => { + expect(isInlineRangeEdgeAfter(1, { index: 0, length: 1 })).toEqual(true); + + expect(isInlineRangeEdgeAfter(1, { index: 1, length: 0 })).toEqual(true); + + expect(isInlineRangeEdgeAfter(0, { index: 0, length: 0 })).toEqual(true); + + expect(isInlineRangeEdgeAfter(0, { index: 1, length: 0 })).toEqual(false); + + expect(isInlineRangeEdgeAfter(1, { index: 0, length: 0 })).toEqual(false); + + expect(isInlineRangeEdgeAfter(0, { index: 0, length: 1 })).toEqual(false); + + expect(isInlineRangeEdgeAfter(0, { index: 0, length: 0 })).toEqual(true); +}); + +test('isPoint', () => { + expect(isPoint({ index: 1, length: 0 })).toEqual(true); + + expect(isPoint({ index: 0, length: 2 })).toEqual(false); + + expect(isPoint({ index: 0, length: 0 })).toEqual(true); + + expect(isPoint({ index: 2, length: 0 })).toEqual(true); + + expect(isPoint({ index: 2, length: 2 })).toEqual(false); +}); + +test('mergeInlineRange', () => { + expect( + mergeInlineRange({ index: 0, length: 0 }, { index: 1, length: 0 }) + ).toEqual({ + index: 0, + length: 1, + }); + + expect( + mergeInlineRange({ index: 0, length: 0 }, { index: 0, length: 0 }) + ).toEqual({ + index: 0, + length: 0, + }); + + expect( + mergeInlineRange({ index: 1, length: 0 }, { index: 2, length: 0 }) + ).toEqual({ + index: 1, + length: 1, + }); + + expect( + mergeInlineRange({ index: 2, length: 0 }, { index: 1, length: 0 }) + ).toEqual({ + index: 1, + length: 1, + }); + + expect( + mergeInlineRange({ index: 1, length: 3 }, { index: 2, length: 2 }) + ).toEqual({ + index: 1, + length: 3, + }); + + expect( + mergeInlineRange({ index: 2, length: 2 }, { index: 1, length: 1 }) + ).toEqual({ + index: 1, + length: 3, + }); + + expect( + mergeInlineRange({ index: 3, length: 2 }, { index: 2, length: 1 }) + ).toEqual({ + index: 2, + length: 3, + }); + + expect( + mergeInlineRange({ index: 0, length: 4 }, { index: 1, length: 1 }) + ).toEqual({ + index: 0, + length: 4, + }); + + expect( + mergeInlineRange({ index: 1, length: 1 }, { index: 0, length: 4 }) + ).toEqual({ + index: 0, + length: 4, + }); + + expect( + mergeInlineRange({ index: 0, length: 2 }, { index: 1, length: 3 }) + ).toEqual({ + index: 0, + length: 4, + }); +}); + +test('intersectInlineRange', () => { + expect( + intersectInlineRange({ index: 0, length: 0 }, { index: 1, length: 0 }) + ).toEqual(null); + + expect( + intersectInlineRange({ index: 0, length: 2 }, { index: 1, length: 1 }) + ).toEqual({ index: 1, length: 1 }); + + expect( + intersectInlineRange({ index: 0, length: 2 }, { index: 2, length: 0 }) + ).toEqual({ index: 2, length: 0 }); + + expect( + intersectInlineRange({ index: 1, length: 0 }, { index: 1, length: 0 }) + ).toEqual({ index: 1, length: 0 }); + + expect( + intersectInlineRange({ index: 1, length: 3 }, { index: 2, length: 2 }) + ).toEqual({ index: 2, length: 2 }); + + expect( + intersectInlineRange({ index: 1, length: 2 }, { index: 0, length: 3 }) + ).toEqual({ index: 1, length: 2 }); + + expect( + intersectInlineRange({ index: 1, length: 1 }, { index: 2, length: 2 }) + ).toEqual({ index: 2, length: 0 }); + + expect( + intersectInlineRange({ index: 2, length: 2 }, { index: 1, length: 3 }) + ).toEqual({ index: 2, length: 2 }); + + expect( + intersectInlineRange({ index: 2, length: 1 }, { index: 1, length: 1 }) + ).toEqual({ index: 2, length: 0 }); + + expect( + intersectInlineRange({ index: 0, length: 4 }, { index: 1, length: 2 }) + ).toEqual({ index: 1, length: 2 }); +}); diff --git a/blocksuite/framework/inline/src/__tests__/utils.ts b/blocksuite/framework/inline/src/__tests__/utils.ts new file mode 100644 index 0000000000..aad102ac95 --- /dev/null +++ b/blocksuite/framework/inline/src/__tests__/utils.ts @@ -0,0 +1,172 @@ +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}/` +); + +export async function type(page: Page, content: string) { + await page.keyboard.type(content, { delay: 50 }); +} + +export async function press(page: Page, content: string) { + await page.keyboard.press(content, { delay: 50 }); + await page.waitForTimeout(50); +} + +export async function enterInlineEditorPlayground(page: Page) { + const url = new URL('examples/inline/index.html', defaultPlaygroundURL); + await page.goto(url.toString()); +} + +export async function focusInlineRichText( + page: Page, + index = 0 +): Promise<void> { + await page.evaluate(index => { + const richTexts = document + .querySelector('test-page') + ?.querySelectorAll('test-rich-text'); + + if (!richTexts) { + throw new Error('Cannot find test-rich-text'); + } + + (richTexts[index] as any).inlineEditor.focusEnd(); + }, index); +} + +export async function getDeltaFromInlineRichText( + page: Page, + index = 0 +): Promise<DeltaInsert> { + await page.waitForTimeout(100); + return page.evaluate(index => { + const richTexts = document + .querySelector('test-page') + ?.querySelectorAll('test-rich-text'); + + if (!richTexts) { + throw new Error('Cannot find test-rich-text'); + } + + const editor = (richTexts[index] as any).inlineEditor as InlineEditor; + return editor.yText.toDelta(); + }, index); +} + +export async function getInlineRangeFromInlineRichText( + page: Page, + index = 0 +): Promise<InlineRange | null> { + await page.waitForTimeout(100); + return page.evaluate(index => { + const richTexts = document + .querySelector('test-page') + ?.querySelectorAll('test-rich-text'); + + if (!richTexts) { + throw new Error('Cannot find test-rich-text'); + } + + const editor = (richTexts[index] as any).inlineEditor as InlineEditor; + return editor.getInlineRange(); + }, index); +} + +export async function setInlineRichTextRange( + page: Page, + inlineRange: InlineRange, + index = 0 +): Promise<void> { + await page.evaluate( + ([inlineRange, index]) => { + const richTexts = document + .querySelector('test-page') + ?.querySelectorAll('test-rich-text'); + + if (!richTexts) { + throw new Error('Cannot find test-rich-text'); + } + + const editor = (richTexts[index as number] as any) + .inlineEditor as InlineEditor; + editor.setInlineRange(inlineRange as InlineRange); + }, + [inlineRange, index] + ); +} + +export async function getInlineRichTextLine( + page: Page, + index: number, + i = 0 +): Promise<readonly [string, number]> { + return page.evaluate( + ([index, i]) => { + const richTexts = document.querySelectorAll('test-rich-text'); + + if (!richTexts) { + throw new Error('Cannot find test-rich-text'); + } + + const editor = (richTexts[i] as any).inlineEditor as InlineEditor; + const result = editor.getLine(index); + if (!result) { + throw new Error('Cannot find line'); + } + const { line, rangeIndexRelatedToLine } = result; + return [line.vTextContent, rangeIndexRelatedToLine] as const; + }, + [index, i] + ); +} + +export async function getInlineRangeIndexRect( + page: Page, + [richTextIndex, inlineIndex]: [number, number], + coordOffSet: { x: number; y: number } = { x: 0, y: 0 } +) { + const rect = await page.evaluate( + ({ richTextIndex, inlineIndex: vIndex, coordOffSet }) => { + const richText = document.querySelectorAll('test-rich-text')[ + richTextIndex + ] as any; + const domRange = richText.inlineEditor.toDomRange({ + index: vIndex, + length: 0, + }); + const pointBound = domRange.getBoundingClientRect(); + return { + x: pointBound.left + coordOffSet.x, + y: pointBound.top + pointBound.height / 2 + coordOffSet.y, + }; + }, + { + richTextIndex, + inlineIndex, + coordOffSet, + } + ); + return rect; +} + +export async function assertSelection( + page: Page, + richTextIndex: number, + rangeIndex: number, + rangeLength = 0 +) { + const actual = await page.evaluate( + ([richTextIndex]) => { + const richText = + document?.querySelectorAll('test-rich-text')[richTextIndex]; + // @ts-expect-error FIXME: ts error + const inlineEditor = richText.inlineEditor; + return inlineEditor?.getInlineRange(); + }, + [richTextIndex] + ); + expect(actual).toEqual({ index: rangeIndex, length: rangeLength }); +} diff --git a/blocksuite/framework/inline/src/components/embed-gap.ts b/blocksuite/framework/inline/src/components/embed-gap.ts new file mode 100644 index 0000000000..90f11108b4 --- /dev/null +++ b/blocksuite/framework/inline/src/components/embed-gap.ts @@ -0,0 +1,12 @@ +import { html } from 'lit'; +import { styleMap } from 'lit/directives/style-map.js'; + +export const EmbedGap = html`<span + data-v-embed-gap="true" + style=${styleMap({ + userSelect: 'text', + padding: '0 0.5px', + outline: 'none', + })} + ><v-text></v-text +></span>`; diff --git a/blocksuite/framework/inline/src/components/index.ts b/blocksuite/framework/inline/src/components/index.ts new file mode 100644 index 0000000000..a94d1f8f34 --- /dev/null +++ b/blocksuite/framework/inline/src/components/index.ts @@ -0,0 +1,3 @@ +export * from './v-element.js'; +export * from './v-line.js'; +export * from './v-text.js'; diff --git a/blocksuite/framework/inline/src/components/v-element.ts b/blocksuite/framework/inline/src/components/v-element.ts new file mode 100644 index 0000000000..0cc195560a --- /dev/null +++ b/blocksuite/framework/inline/src/components/v-element.ts @@ -0,0 +1,113 @@ +import { BlockSuiteError, ErrorCode } from '@blocksuite/global/exceptions'; +import { DisposableGroup, SignalWatcher } from '@blocksuite/global/utils'; +import { effect, signal } from '@preact/signals-core'; +import { html, LitElement } from 'lit'; +import { property } from 'lit/decorators.js'; +import { styleMap } from 'lit/directives/style-map.js'; + +import { ZERO_WIDTH_SPACE } from '../consts.js'; +import type { InlineEditor } from '../inline-editor.js'; +import type { DeltaInsert } from '../types.js'; +import type { BaseTextAttributes } from '../utils/base-attributes.js'; +import { isInlineRangeIntersect } from '../utils/inline-range.js'; + +export class VElement< + T extends BaseTextAttributes = BaseTextAttributes, +> extends SignalWatcher(LitElement) { + readonly disposables = new DisposableGroup(); + + readonly selected = signal(false); + + override connectedCallback(): void { + super.connectedCallback(); + + this.disposables.add( + effect(() => { + const inlineRange = this.inlineEditor.inlineRange$.value; + this.selected.value = + !!inlineRange && + isInlineRangeIntersect(inlineRange, { + index: this.startOffset, + length: this.endOffset - this.startOffset, + }); + }) + ); + } + + override createRenderRoot() { + return this; + } + + override async getUpdateComplete(): Promise<boolean> { + const result = await super.getUpdateComplete(); + const span = this.querySelector('[data-v-element="true"]') as HTMLElement; + const el = span.firstElementChild as LitElement; + await el.updateComplete; + const vTexts = Array.from(this.querySelectorAll('v-text')); + await Promise.all(vTexts.map(vText => vText.updateComplete)); + return result; + } + + override render() { + const inlineEditor = this.inlineEditor; + const attributeRenderer = inlineEditor.attributeService.attributeRenderer; + const renderProps: Parameters<typeof attributeRenderer>[0] = { + delta: this.delta, + selected: this.selected.value, + startOffset: this.startOffset, + endOffset: this.endOffset, + lineIndex: this.lineIndex, + editor: inlineEditor, + }; + + const isEmbed = inlineEditor.isEmbed(this.delta); + if (isEmbed) { + if (this.delta.insert.length !== 1) { + throw new BlockSuiteError( + ErrorCode.InlineEditorError, + `The length of embed node should only be 1. + This seems to be an internal issue with inline editor. + Please go to https://github.com/toeverything/blocksuite/issues + to report it.` + ); + } + + return html`<span + data-v-embed="true" + data-v-element="true" + contenteditable="false" + style=${styleMap({ userSelect: 'none' })} + >${attributeRenderer(renderProps)}</span + >`; + } + + // we need to avoid \n appearing before and after the span element, which will + // cause the unexpected space + return html`<span data-v-element="true" + >${attributeRenderer(renderProps)}</span + >`; + } + + @property({ type: Object }) + accessor delta: DeltaInsert<T> = { + insert: ZERO_WIDTH_SPACE, + }; + + @property({ attribute: false }) + accessor endOffset!: number; + + @property({ attribute: false }) + accessor inlineEditor!: InlineEditor; + + @property({ attribute: false }) + accessor lineIndex!: number; + + @property({ attribute: false }) + accessor startOffset!: number; +} + +declare global { + interface HTMLElementTagNameMap { + 'v-element': VElement; + } +} diff --git a/blocksuite/framework/inline/src/components/v-line.ts b/blocksuite/framework/inline/src/components/v-line.ts new file mode 100644 index 0000000000..708e40e9f7 --- /dev/null +++ b/blocksuite/framework/inline/src/components/v-line.ts @@ -0,0 +1,148 @@ +import { BlockSuiteError, ErrorCode } from '@blocksuite/global/exceptions'; +import { assertExists } from '@blocksuite/global/utils'; +import { html, LitElement, type TemplateResult } from 'lit'; +import { property } from 'lit/decorators.js'; +import { styleMap } from 'lit/directives/style-map.js'; + +import { INLINE_ROOT_ATTR, ZERO_WIDTH_SPACE } from '../consts.js'; +import type { InlineRootElement } from '../inline-editor.js'; +import type { DeltaInsert } from '../types.js'; +import { EmbedGap } from './embed-gap.js'; + +export class VLine extends LitElement { + get inlineEditor() { + const rootElement = this.closest( + `[${INLINE_ROOT_ATTR}]` + ) as InlineRootElement; + assertExists(rootElement, 'v-line must be inside a v-root'); + const inlineEditor = rootElement.inlineEditor; + assertExists( + inlineEditor, + 'v-line must be inside a v-root with inline-editor' + ); + + return inlineEditor; + } + + get vElements() { + return Array.from(this.querySelectorAll('v-element')); + } + + get vTextContent() { + return this.vElements.reduce((acc, el) => acc + el.delta.insert, ''); + } + + get vTextLength() { + return this.vElements.reduce((acc, el) => acc + el.delta.insert.length, 0); + } + + // you should use vElements.length or vTextLength because v-element corresponds to the actual delta + get vTexts() { + return Array.from(this.querySelectorAll('v-text')); + } + + override createRenderRoot() { + return this; + } + + protected override firstUpdated(): void { + this.style.display = 'block'; + + this.addEventListener('mousedown', e => { + if (e.detail >= 2 && this.startOffset === this.endOffset) { + e.preventDefault(); + return; + } + + if (e.detail >= 3) { + e.preventDefault(); + this.inlineEditor.setInlineRange({ + index: this.startOffset, + length: this.endOffset - this.startOffset, + }); + } + }); + } + + // vTexts.length > 0 does not mean the line is not empty, + override async getUpdateComplete() { + const result = await super.getUpdateComplete(); + await Promise.all(this.vElements.map(el => el.updateComplete)); + return result; + } + + override render() { + if (!this.isConnected) return; + + if (this.inlineEditor.vLineRenderer) { + return this.inlineEditor.vLineRenderer(this); + } + return this.renderVElements(); + } + + renderVElements() { + if (this.elements.length === 0) { + // don't use v-element because it not correspond to the actual delta + return html`<div><v-text .str=${ZERO_WIDTH_SPACE}></v-text></div>`; + } + + const inlineEditor = this.inlineEditor; + const renderElements = this.elements.flatMap(([template, delta], index) => { + if (inlineEditor.isEmbed(delta)) { + if (delta.insert.length !== 1) { + throw new BlockSuiteError( + ErrorCode.InlineEditorError, + `The length of embed node should only be 1. + This seems to be an internal issue with inline editor. + Please go to https://github.com/toeverything/blocksuite/issues + to report it.` + ); + } + // we add `EmbedGap` to make cursor can be placed between embed elements + if (index === 0) { + const nextDelta = this.elements[index + 1]?.[1]; + if (!nextDelta || inlineEditor.isEmbed(nextDelta)) { + return [EmbedGap, template, EmbedGap]; + } else { + return [EmbedGap, template]; + } + } else { + const nextDelta = this.elements[index + 1]?.[1]; + if (!nextDelta || inlineEditor.isEmbed(nextDelta)) { + return [template, EmbedGap]; + } else { + return [template]; + } + } + } + return template; + }); + + // prettier will generate \n and cause unexpected space and line break + // prettier-ignore + return html`<div style=${styleMap({ + // this padding is used to make cursor can be placed at the + // start and end of the line when the first and last element is embed element + padding: '0 0.5px', + display: 'inline-block', + })}>${renderElements}</div>`; + } + + @property({ attribute: false }) + accessor elements: [TemplateResult<1>, DeltaInsert][] = []; + + @property({ attribute: false }) + accessor endOffset!: number; + + @property({ attribute: false }) + accessor index!: number; + + @property({ attribute: false }) + accessor startOffset!: number; +} + +declare global { + interface HTMLElementTagNameMap { + 'v-line': VLine; + } +} diff --git a/blocksuite/framework/inline/src/components/v-text.ts b/blocksuite/framework/inline/src/components/v-text.ts new file mode 100644 index 0000000000..9e6b2586d1 --- /dev/null +++ b/blocksuite/framework/inline/src/components/v-text.ts @@ -0,0 +1,34 @@ +import { html, LitElement } from 'lit'; +import { property } from 'lit/decorators.js'; +import { styleMap } from 'lit/directives/style-map.js'; + +import { ZERO_WIDTH_SPACE } from '../consts.js'; + +export class VText extends LitElement { + override createRenderRoot() { + return this; + } + + override render() { + // we need to avoid \n appearing before and after the span element, which will + // cause the sync problem about the cursor position + return html`<span + style=${styleMap({ + 'word-break': 'break-word', + 'text-wrap': 'wrap', + 'white-space-collapse': 'break-spaces', + })} + data-v-text="true" + >${this.str}</span + >`; + } + + @property({ attribute: false }) + accessor str: string = ZERO_WIDTH_SPACE; +} + +declare global { + interface HTMLElementTagNameMap { + 'v-text': VText; + } +} diff --git a/blocksuite/framework/inline/src/consts.ts b/blocksuite/framework/inline/src/consts.ts new file mode 100644 index 0000000000..81568c1a02 --- /dev/null +++ b/blocksuite/framework/inline/src/consts.ts @@ -0,0 +1,7 @@ +import { IS_SAFARI } from '@blocksuite/global/env'; + +export const ZERO_WIDTH_SPACE = IS_SAFARI ? '\u200C' : '\u200B'; +// see https://en.wikipedia.org/wiki/Zero-width_non-joiner +export const ZERO_WIDTH_NON_JOINER = '\u200C'; + +export const INLINE_ROOT_ATTR = 'data-v-root'; diff --git a/blocksuite/framework/inline/src/effects.ts b/blocksuite/framework/inline/src/effects.ts new file mode 100644 index 0000000000..73c66f6f1b --- /dev/null +++ b/blocksuite/framework/inline/src/effects.ts @@ -0,0 +1,7 @@ +import { VElement, VLine, VText } from './components/index.js'; + +export function effects() { + customElements.define('v-element', VElement); + customElements.define('v-line', VLine); + customElements.define('v-text', VText); +} diff --git a/blocksuite/framework/inline/src/index.ts b/blocksuite/framework/inline/src/index.ts new file mode 100644 index 0000000000..643d0328de --- /dev/null +++ b/blocksuite/framework/inline/src/index.ts @@ -0,0 +1,6 @@ +export * from './components/index.js'; +export * from './consts.js'; +export * from './inline-editor.js'; +export * from './services/index.js'; +export * from './types.js'; +export * from './utils/index.js'; diff --git a/blocksuite/framework/inline/src/inline-editor.ts b/blocksuite/framework/inline/src/inline-editor.ts new file mode 100644 index 0000000000..5cbf3b29f1 --- /dev/null +++ b/blocksuite/framework/inline/src/inline-editor.ts @@ -0,0 +1,296 @@ +import { BlockSuiteError, ErrorCode } from '@blocksuite/global/exceptions'; +import { assertExists, DisposableGroup, Slot } from '@blocksuite/global/utils'; +import { type Signal, signal } from '@preact/signals-core'; +import { nothing, render, type TemplateResult } from 'lit'; +import type * as Y from 'yjs'; + +import type { VLine } from './components/v-line.js'; +import { INLINE_ROOT_ATTR } from './consts.js'; +import { InlineHookService } from './services/hook.js'; +import { + AttributeService, + DeltaService, + EventService, + RangeService, +} from './services/index.js'; +import { RenderService } from './services/render.js'; +import { InlineTextService } from './services/text.js'; +import type { DeltaInsert, InlineRange } from './types.js'; +import { + type BaseTextAttributes, + nativePointToTextPoint, + textPointToDomPoint, +} from './utils/index.js'; +import { getTextNodesFromElement } from './utils/text.js'; + +export type InlineRootElement< + T extends BaseTextAttributes = BaseTextAttributes, +> = HTMLElement & { + inlineEditor: InlineEditor<T>; +}; + +export interface InlineRangeProvider { + inlineRange$: Signal<InlineRange | null>; + setInlineRange(inlineRange: InlineRange | null): void; +} + +export class InlineEditor< + TextAttributes extends BaseTextAttributes = BaseTextAttributes, +> { + static getTextNodesFromElement = getTextNodesFromElement; + + static nativePointToTextPoint = nativePointToTextPoint; + + static textPointToDomPoint = textPointToDomPoint; + + readonly disposables = new DisposableGroup(); + + readonly attributeService: AttributeService<TextAttributes> = + new AttributeService<TextAttributes>(this); + getFormat = this.attributeService.getFormat; + normalizeAttributes = this.attributeService.normalizeAttributes; + resetMarks = this.attributeService.resetMarks; + setAttributeRenderer = this.attributeService.setAttributeRenderer; + setAttributeSchema = this.attributeService.setAttributeSchema; + setMarks = this.attributeService.setMarks; + get marks() { + return this.attributeService.marks; + } + + readonly textService: InlineTextService<TextAttributes> = + new InlineTextService<TextAttributes>(this); + deleteText = this.textService.deleteText; + formatText = this.textService.formatText; + insertLineBreak = this.textService.insertLineBreak; + insertText = this.textService.insertText; + resetText = this.textService.resetText; + setText = this.textService.setText; + + readonly deltaService: DeltaService<TextAttributes> = + new DeltaService<TextAttributes>(this); + getDeltaByRangeIndex = this.deltaService.getDeltaByRangeIndex; + getDeltasByInlineRange = this.deltaService.getDeltasByInlineRange; + mapDeltasInInlineRange = this.deltaService.mapDeltasInInlineRange; + get embedDeltas() { + return this.deltaService.embedDeltas; + } + + readonly rangeService: RangeService<TextAttributes> = + new RangeService<TextAttributes>(this); + focusEnd = this.rangeService.focusEnd; + focusIndex = this.rangeService.focusIndex; + focusStart = this.rangeService.focusStart; + getInlineRangeFromElement = this.rangeService.getInlineRangeFromElement; + isFirstLine = this.rangeService.isFirstLine; + isLastLine = this.rangeService.isLastLine; + isValidInlineRange = this.rangeService.isValidInlineRange; + selectAll = this.rangeService.selectAll; + syncInlineRange = this.rangeService.syncInlineRange; + toDomRange = this.rangeService.toDomRange; + toInlineRange = this.rangeService.toInlineRange; + getLine = this.rangeService.getLine; + getNativeRange = this.rangeService.getNativeRange; + getNativeSelection = this.rangeService.getNativeSelection; + getTextPoint = this.rangeService.getTextPoint; + get lastStartRelativePosition() { + return this.rangeService.lastStartRelativePosition; + } + get lastEndRelativePosition() { + return this.rangeService.lastEndRelativePosition; + } + + readonly eventService: EventService<TextAttributes> = + new EventService<TextAttributes>(this); + get isComposing() { + return this.eventService.isComposing; + } + + readonly renderService: RenderService<TextAttributes> = + new RenderService<TextAttributes>(this); + waitForUpdate = this.renderService.waitForUpdate; + rerenderWholeEditor = this.renderService.rerenderWholeEditor; + render = this.renderService.render; + get rendering() { + return this.renderService.rendering; + } + + readonly hooksService: InlineHookService<TextAttributes>; + get hooks() { + return this.hooksService.hooks; + } + + private _eventSource: HTMLElement | null = null; + get eventSource() { + return this._eventSource; + } + + private _isReadonly = false; + get isReadonly() { + return this._isReadonly; + } + + private _mounted = false; + get mounted() { + return this._mounted; + } + + private _rootElement: InlineRootElement<TextAttributes> | null = null; + get rootElement() { + assertExists(this._rootElement); + return this._rootElement; + } + + private _inlineRangeProviderOverride = false; + get inlineRangeProviderOverride() { + return this._inlineRangeProviderOverride; + } + readonly inlineRangeProvider: InlineRangeProvider = { + inlineRange$: signal(null), + setInlineRange: inlineRange => { + this.inlineRange$.value = inlineRange; + }, + }; + get inlineRange$() { + return this.inlineRangeProvider.inlineRange$; + } + setInlineRange = (inlineRange: InlineRange | null) => { + this.inlineRangeProvider.setInlineRange(inlineRange); + }; + getInlineRange = () => { + return this.inlineRange$.peek(); + }; + + readonly slots = { + mounted: new Slot(), + unmounted: new Slot(), + renderComplete: new Slot(), + textChange: new Slot(), + inlineRangeSync: new Slot<Range | null>(), + /** + * Corresponding to the `compositionUpdate` and `beforeInput` events, and triggered only when the `inlineRange` is not null. + */ + inputting: new Slot(), + /** + * Triggered only when the `inlineRange` is not null. + */ + keydown: new Slot<KeyboardEvent>(), + }; + + readonly vLineRenderer: ((vLine: VLine) => TemplateResult) | null; + + readonly yText: Y.Text; + get yTextDeltas() { + return this.yText.toDelta(); + } + get yTextLength() { + return this.yText.length; + } + get yTextString() { + return this.yText.toString(); + } + + readonly isEmbed: (delta: DeltaInsert<TextAttributes>) => boolean; + + constructor( + yText: InlineEditor['yText'], + ops: { + isEmbed?: (delta: DeltaInsert<TextAttributes>) => boolean; + hooks?: InlineHookService<TextAttributes>['hooks']; + inlineRangeProvider?: InlineRangeProvider; + vLineRenderer?: (vLine: VLine) => TemplateResult; + } = {} + ) { + if (!yText.doc) { + throw new BlockSuiteError( + ErrorCode.InlineEditorError, + 'yText must be attached to a Y.Doc' + ); + } + + if (yText.toString().includes('\r')) { + throw new BlockSuiteError( + ErrorCode.InlineEditorError, + 'yText must not contain "\\r" because it will break the range synchronization' + ); + } + + const { + isEmbed = () => false, + hooks = {}, + inlineRangeProvider, + vLineRenderer = null, + } = ops; + this.yText = yText; + this.isEmbed = isEmbed; + this.vLineRenderer = vLineRenderer; + this.hooksService = new InlineHookService(this, hooks); + if (inlineRangeProvider) { + this.inlineRangeProvider = inlineRangeProvider; + this._inlineRangeProviderOverride = true; + } + } + + mount( + rootElement: HTMLElement, + eventSource: HTMLElement = rootElement, + isReadonly = false + ) { + const inlineRoot = rootElement as InlineRootElement<TextAttributes>; + inlineRoot.inlineEditor = this; + this._rootElement = inlineRoot; + this._eventSource = eventSource; + this._eventSource.style.outline = 'none'; + this._rootElement.dataset.vRoot = 'true'; + this.setReadonly(isReadonly); + + this.rootElement.replaceChildren(); + + delete (this.rootElement as any)['_$litPart$']; + + this.eventService.mount(); + this.rangeService.mount(); + this.renderService.mount(); + + this._mounted = true; + this.slots.mounted.emit(); + + this.render(); + } + + unmount() { + if (this.rootElement.isConnected) { + render(nothing, this.rootElement); + } + this.rootElement.removeAttribute(INLINE_ROOT_ATTR); + this._rootElement = null; + this._mounted = false; + this.disposables.dispose(); + this.slots.unmounted.emit(); + } + + setReadonly(isReadonly: boolean): void { + const value = isReadonly ? 'false' : 'true'; + + if (this.rootElement.contentEditable !== value) { + this.rootElement.contentEditable = value; + } + + if (this.eventSource && this.eventSource.contentEditable !== value) { + this.eventSource.contentEditable = value; + } + + this._isReadonly = isReadonly; + } + + transact(fn: () => void): void { + const doc = this.yText.doc; + if (!doc) { + throw new BlockSuiteError( + ErrorCode.InlineEditorError, + 'yText is not attached to a doc' + ); + } + + doc.transact(fn, doc.clientID); + } +} diff --git a/blocksuite/framework/inline/src/services/attribute.ts b/blocksuite/framework/inline/src/services/attribute.ts new file mode 100644 index 0000000000..cd2d4409ab --- /dev/null +++ b/blocksuite/framework/inline/src/services/attribute.ts @@ -0,0 +1,109 @@ +import type { z, ZodTypeDef } from 'zod'; + +import type { InlineEditor } from '../inline-editor.js'; +import type { AttributeRenderer, InlineRange } from '../types.js'; +import type { BaseTextAttributes } from '../utils/index.js'; +import { + baseTextAttributes, + getDefaultAttributeRenderer, +} from '../utils/index.js'; + +export class AttributeService<TextAttributes extends BaseTextAttributes> { + private _attributeRenderer: AttributeRenderer<TextAttributes> = + getDefaultAttributeRenderer<TextAttributes>(); + + private _attributeSchema: z.ZodSchema<TextAttributes, ZodTypeDef, unknown> = + baseTextAttributes as z.ZodSchema<TextAttributes, ZodTypeDef, unknown>; + + private _marks: TextAttributes | null = null; + + getFormat = (inlineRange: InlineRange, loose = false): TextAttributes => { + const deltas = this.editor.deltaService + .getDeltasByInlineRange(inlineRange) + .filter(([_, position]) => { + const deltaStart = position.index; + const deltaEnd = position.index + position.length; + const inlineStart = inlineRange.index; + const inlineEnd = inlineRange.index + inlineRange.length; + + if (inlineStart === inlineEnd) { + return deltaStart < inlineStart && inlineStart <= deltaEnd; + } else { + return deltaEnd > inlineStart && deltaStart <= inlineEnd; + } + }); + const maybeAttributesList = deltas.map(([delta]) => delta.attributes); + if (loose) { + return maybeAttributesList.reduce( + (acc, cur) => ({ ...acc, ...cur }), + {} + ) as TextAttributes; + } + if ( + !maybeAttributesList.length || + // some text does not have any attribute + maybeAttributesList.some(attributes => !attributes) + ) { + return {} as TextAttributes; + } + const attributesList = maybeAttributesList as TextAttributes[]; + return attributesList.reduce((acc, cur) => { + const newFormat = {} as TextAttributes; + for (const key in acc) { + const typedKey = key as keyof TextAttributes; + // If the given range contains multiple different formats + // such as links with different values, + // we will treat it as having no format + if (acc[typedKey] === cur[typedKey]) { + // This cast is secure because we have checked that the value of the key is the same. + + newFormat[typedKey] = acc[typedKey] as any; + } + } + return newFormat; + }); + }; + + normalizeAttributes = (textAttributes?: TextAttributes) => { + if (!textAttributes) { + return undefined; + } + const attributeResult = this._attributeSchema.safeParse(textAttributes); + if (!attributeResult.success) { + console.error(attributeResult.error); + return undefined; + } + return Object.fromEntries( + // filter out undefined values + Object.entries(attributeResult.data).filter(([_, v]) => v !== undefined) + ) as TextAttributes; + }; + + resetMarks = (): void => { + this._marks = null; + }; + + setAttributeRenderer = (renderer: AttributeRenderer<TextAttributes>) => { + this._attributeRenderer = renderer; + }; + + setAttributeSchema = ( + schema: z.ZodSchema<TextAttributes, ZodTypeDef, unknown> + ) => { + this._attributeSchema = schema; + }; + + setMarks = (marks: TextAttributes): void => { + this._marks = marks; + }; + + get attributeRenderer() { + return this._attributeRenderer; + } + + get marks() { + return this._marks; + } + + constructor(readonly editor: InlineEditor<TextAttributes>) {} +} diff --git a/blocksuite/framework/inline/src/services/delta.ts b/blocksuite/framework/inline/src/services/delta.ts new file mode 100644 index 0000000000..72b5cf5ac3 --- /dev/null +++ b/blocksuite/framework/inline/src/services/delta.ts @@ -0,0 +1,152 @@ +import type { InlineEditor } from '../inline-editor.js'; +import type { DeltaEntry, DeltaInsert, InlineRange } from '../types.js'; +import type { BaseTextAttributes } from '../utils/index.js'; +import { transformDeltasToEmbedDeltas } from '../utils/index.js'; + +export class DeltaService<TextAttributes extends BaseTextAttributes> { + /** + * Here are examples of how this function computes and gets the delta. + * + * We have such a text: + * ``` + * [ + * { + * insert: 'aaa', + * attributes: { bold: true }, + * }, + * { + * insert: 'bbb', + * attributes: { italic: true }, + * }, + * ] + * ``` + * + * `getDeltaByRangeIndex(0)` returns `{ insert: 'aaa', attributes: { bold: true } }`. + * + * `getDeltaByRangeIndex(1)` returns `{ insert: 'aaa', attributes: { bold: true } }`. + * + * `getDeltaByRangeIndex(3)` returns `{ insert: 'aaa', attributes: { bold: true } }`. + * + * `getDeltaByRangeIndex(4)` returns `{ insert: 'bbb', attributes: { italic: true } }`. + */ + getDeltaByRangeIndex = (rangeIndex: number) => { + const deltas = this.editor.embedDeltas; + + let index = 0; + for (const delta of deltas) { + if (index + delta.insert.length >= rangeIndex) { + return delta; + } + index += delta.insert.length; + } + + return null; + }; + + /** + * Here are examples of how this function computes and gets the deltas. + * + * We have such a text: + * ``` + * [ + * { + * insert: 'aaa', + * attributes: { bold: true }, + * }, + * { + * insert: 'bbb', + * attributes: { italic: true }, + * }, + * { + * insert: 'ccc', + * attributes: { underline: true }, + * }, + * ] + * ``` + * + * `getDeltasByInlineRange({ index: 0, length: 0 })` returns + * ``` + * [{ insert: 'aaa', attributes: { bold: true }, }, { index: 0, length: 3, }]] + * ``` + * + * `getDeltasByInlineRange({ index: 0, length: 1 })` returns + * ``` + * [{ insert: 'aaa', attributes: { bold: true }, }, { index: 0, length: 3, }]] + * ``` + * + * `getDeltasByInlineRange({ index: 0, length: 4 })` returns + * ``` + * [{ insert: 'aaa', attributes: { bold: true }, }, { index: 0, length: 3, }], + * [{ insert: 'bbb', attributes: { italic: true }, }, { index: 3, length: 3, }]] + * ``` + * + * `getDeltasByInlineRange({ index: 3, length: 1 })` returns + * ``` + * [{ insert: 'aaa', attributes: { bold: true }, }, { index: 0, length: 3, }], + * [{ insert: 'bbb', attributes: { italic: true }, }, { index: 3, length: 3, }]] + * ``` + * + * `getDeltasByInlineRange({ index: 3, length: 3 })` returns + * ``` + * [{ insert: 'aaa', attributes: { bold: true }, }, { index: 0, length: 3, }], + * [{ insert: 'bbb', attributes: { italic: true }, }, { index: 3, length: 3, }]] + * ``` + * + * `getDeltasByInlineRange({ index: 3, length: 4 })` returns + * ``` + * [{ insert: 'aaa', attributes: { bold: true }, }, { index: 0, length: 3, }], + * [{ insert: 'bbb', attributes: { italic: true }, }, { index: 3, length: 3, }], + * [{ insert: 'ccc', attributes: { underline: true }, }, { index: 6, length: 3, }]] + * ``` + */ + getDeltasByInlineRange = ( + inlineRange: InlineRange + ): DeltaEntry<TextAttributes>[] => { + return this.mapDeltasInInlineRange( + inlineRange, + (delta, index): DeltaEntry<TextAttributes> => [ + delta, + { index, length: delta.insert.length }, + ] + ); + }; + + mapDeltasInInlineRange = <Result>( + inlineRange: InlineRange, + callback: ( + delta: DeltaInsert<TextAttributes>, + rangeIndex: number, + deltaIndex: number + ) => Result + ) => { + const deltas = this.editor.embedDeltas; + const result: Result[] = []; + + // eslint-disable-next-line sonarjs/no-ignored-return + deltas.reduce((rangeIndex, delta, deltaIndex) => { + const length = delta.insert.length; + const from = inlineRange.index - length; + const to = inlineRange.index + inlineRange.length; + + const deltaInRange = + rangeIndex >= from && + (rangeIndex < to || + (inlineRange.length === 0 && rangeIndex === inlineRange.index)); + + if (deltaInRange) { + const value = callback(delta, rangeIndex, deltaIndex); + result.push(value); + } + + return rangeIndex + length; + }, 0); + + return result; + }; + + get embedDeltas() { + return transformDeltasToEmbedDeltas(this.editor, this.editor.yTextDeltas); + } + + constructor(readonly editor: InlineEditor<TextAttributes>) {} +} diff --git a/blocksuite/framework/inline/src/services/event.ts b/blocksuite/framework/inline/src/services/event.ts new file mode 100644 index 0000000000..966a5e19c7 --- /dev/null +++ b/blocksuite/framework/inline/src/services/event.ts @@ -0,0 +1,372 @@ +import type { InlineEditor } from '../inline-editor.js'; +import type { InlineRange } from '../types.js'; +import { + type BaseTextAttributes, + isInEmbedElement, + isInEmbedGap, + isInEmptyLine, +} from '../utils/index.js'; +import { isMaybeInlineRangeEqual } from '../utils/inline-range.js'; +import { transformInput } from '../utils/transform-input.js'; +import type { BeforeinputHookCtx, CompositionEndHookCtx } from './hook.js'; + +export class EventService<TextAttributes extends BaseTextAttributes> { + private _compositionInlineRange: InlineRange | null = null; + + private _isComposing = false; + + private _isRangeCompletelyInRoot = (range: Range) => { + if (range.commonAncestorContainer.ownerDocument !== document) return false; + + const rootElement = this.editor.rootElement; + const rootRange = document.createRange(); + rootRange.selectNode(rootElement); + + if ( + range.startContainer.compareDocumentPosition(range.endContainer) & + Node.DOCUMENT_POSITION_FOLLOWING + ) { + return ( + rootRange.comparePoint(range.startContainer, range.startOffset) >= 0 && + rootRange.comparePoint(range.endContainer, range.endOffset) <= 0 + ); + } else { + return ( + rootRange.comparePoint(range.endContainer, range.startOffset) >= 0 && + rootRange.comparePoint(range.startContainer, range.endOffset) <= 0 + ); + } + }; + + private _onBeforeInput = (event: InputEvent) => { + const range = this.editor.rangeService.getNativeRange(); + if ( + this.editor.isReadonly || + this._isComposing || + !range || + !this._isRangeCompletelyInRoot(range) + ) + return; + + let inlineRange = this.editor.toInlineRange(range); + if (!inlineRange) return; + + let ifHandleTargetRange = true; + + if (event.inputType.startsWith('delete')) { + if ( + isInEmbedGap(range.commonAncestorContainer) && + inlineRange.length === 0 && + inlineRange.index > 0 + ) { + inlineRange = { + index: inlineRange.index - 1, + length: 1, + }; + ifHandleTargetRange = false; + } else if ( + isInEmptyLine(range.commonAncestorContainer) && + inlineRange.length === 0 && + inlineRange.index > 0 + // eslint-disable-next-line sonarjs/no-duplicated-branches + ) { + // do not use target range when deleting across lines + // https://github.com/toeverything/blocksuite/issues/5381 + inlineRange = { + index: inlineRange.index - 1, + length: 1, + }; + ifHandleTargetRange = false; + } + } + + if (ifHandleTargetRange) { + const targetRanges = event.getTargetRanges(); + if (targetRanges.length > 0) { + const staticRange = targetRanges[0]; + const range = document.createRange(); + range.setStart(staticRange.startContainer, staticRange.startOffset); + range.setEnd(staticRange.endContainer, staticRange.endOffset); + const targetInlineRange = this.editor.toInlineRange(range); + + if (!isMaybeInlineRangeEqual(inlineRange, targetInlineRange)) { + inlineRange = targetInlineRange; + } + } + } + + if (!inlineRange) return; + + event.preventDefault(); + + const ctx: BeforeinputHookCtx<TextAttributes> = { + inlineEditor: this.editor, + raw: event, + inlineRange, + data: event.data ?? event.dataTransfer?.getData('text/plain') ?? null, + attributes: {} as TextAttributes, + }; + this.editor.hooks.beforeinput?.(ctx); + + transformInput<TextAttributes>( + ctx.raw.inputType, + ctx.data, + ctx.attributes, + ctx.inlineRange, + this.editor as InlineEditor + ); + + this.editor.slots.inputting.emit(); + }; + + private _onClick = (event: MouseEvent) => { + // select embed element when click on it + if (event.target instanceof Node && isInEmbedElement(event.target)) { + const selection = document.getSelection(); + if (!selection) return; + if (event.target instanceof HTMLElement) { + const vElement = event.target.closest('v-element'); + if (vElement) { + selection.selectAllChildren(vElement); + } + } else { + const vElement = event.target.parentElement?.closest('v-element'); + if (vElement) { + selection.selectAllChildren(vElement); + } + } + } + }; + + private _onCompositionEnd = async (event: CompositionEvent) => { + this._isComposing = false; + if (!this.editor.rootElement.isConnected) return; + + const range = this.editor.rangeService.getNativeRange(); + if ( + this.editor.isReadonly || + !range || + !this._isRangeCompletelyInRoot(range) + ) + return; + + this.editor.rerenderWholeEditor(); + await this.editor.waitForUpdate(); + + const inlineRange = this._compositionInlineRange; + if (!inlineRange) return; + + event.preventDefault(); + + const ctx: CompositionEndHookCtx<TextAttributes> = { + inlineEditor: this.editor, + raw: event, + inlineRange, + data: event.data, + attributes: {} as TextAttributes, + }; + this.editor.hooks.compositionEnd?.(ctx); + + const { inlineRange: newInlineRange, data: newData } = ctx; + if (newData && newData.length > 0) { + this.editor.insertText(newInlineRange, newData, ctx.attributes); + this.editor.setInlineRange({ + index: newInlineRange.index + newData.length, + length: 0, + }); + } + + this.editor.slots.inputting.emit(); + }; + + private _onCompositionStart = () => { + this._isComposing = true; + // embeds is not editable and it will break IME + const embeds = this.editor.rootElement.querySelectorAll( + '[data-v-embed="true"]' + ); + embeds.forEach(embed => { + embed.removeAttribute('contenteditable'); + }); + + const range = this.editor.rangeService.getNativeRange(); + if (range) { + this._compositionInlineRange = this.editor.toInlineRange(range); + } else { + this._compositionInlineRange = null; + } + }; + + private _onCompositionUpdate = () => { + if (!this.editor.rootElement.isConnected) return; + + const range = this.editor.rangeService.getNativeRange(); + if ( + this.editor.isReadonly || + !range || + !this._isRangeCompletelyInRoot(range) + ) + return; + + this.editor.slots.inputting.emit(); + }; + + private _onKeyDown = (event: KeyboardEvent) => { + const inlineRange = this.editor.getInlineRange(); + if (!inlineRange) return; + + this.editor.slots.keydown.emit(event); + + if ( + !event.shiftKey && + (event.key === 'ArrowLeft' || event.key === 'ArrowRight') + ) { + if (inlineRange.length !== 0) return; + + const prevent = () => { + event.preventDefault(); + event.stopPropagation(); + }; + + const deltas = this.editor.getDeltasByInlineRange(inlineRange); + if (deltas.length === 2) { + if (event.key === 'ArrowLeft' && this.editor.isEmbed(deltas[0][0])) { + prevent(); + this.editor.setInlineRange({ + index: inlineRange.index - 1, + length: 1, + }); + } else if ( + event.key === 'ArrowRight' && + this.editor.isEmbed(deltas[1][0]) + ) { + prevent(); + this.editor.setInlineRange({ + index: inlineRange.index, + length: 1, + }); + } + } else if (deltas.length === 1) { + const delta = deltas[0][0]; + if (this.editor.isEmbed(delta)) { + if (event.key === 'ArrowLeft' && inlineRange.index - 1 >= 0) { + prevent(); + this.editor.setInlineRange({ + index: inlineRange.index - 1, + length: 1, + }); + } else if ( + event.key === 'ArrowRight' && + inlineRange.index + 1 <= this.editor.yTextLength + ) { + prevent(); + this.editor.setInlineRange({ + index: inlineRange.index, + length: 1, + }); + } + } + } + } + }; + + private _onSelectionChange = () => { + const rootElement = this.editor.rootElement; + const previousInlineRange = this.editor.getInlineRange(); + if (this._isComposing) { + return; + } + + const selection = document.getSelection(); + if (!selection) return; + if (selection.rangeCount === 0) { + if (previousInlineRange !== null) { + this.editor.setInlineRange(null); + } + + return; + } + + const range = selection.getRangeAt(0); + if (!range.intersectsNode(rootElement)) { + const isContainerSelected = + range.endContainer.contains(rootElement) && + Array.from(range.endContainer.childNodes).filter( + node => node instanceof HTMLElement + ).length === 1 && + range.startContainer.contains(rootElement) && + Array.from(range.startContainer.childNodes).filter( + node => node instanceof HTMLElement + ).length === 1; + if (isContainerSelected) { + this.editor.focusEnd(); + return; + } else { + if (previousInlineRange !== null) { + this.editor.setInlineRange(null); + } + return; + } + } + + const inlineRange = this.editor.toInlineRange(selection.getRangeAt(0)); + if (!isMaybeInlineRangeEqual(previousInlineRange, inlineRange)) { + this.editor.rangeService.lockSyncInlineRange(); + this.editor.setInlineRange(inlineRange); + this.editor.rangeService.unlockSyncInlineRange(); + } + }; + + mount = () => { + const eventSource = this.editor.eventSource; + const rootElement = this.editor.rootElement; + + if (!this.editor.inlineRangeProviderOverride) { + this.editor.disposables.addFromEvent( + document, + 'selectionchange', + this._onSelectionChange + ); + } + + if (!eventSource) { + console.error('Mount inline editor without event source ready'); + return; + } + + this.editor.disposables.addFromEvent( + eventSource, + 'beforeinput', + this._onBeforeInput + ); + this.editor.disposables.addFromEvent( + eventSource, + 'compositionstart', + this._onCompositionStart + ); + this.editor.disposables.addFromEvent( + eventSource, + 'compositionupdate', + this._onCompositionUpdate + ); + this.editor.disposables.addFromEvent( + eventSource, + 'compositionend', + (event: CompositionEvent) => { + this._onCompositionEnd(event).catch(console.error); + } + ); + this.editor.disposables.addFromEvent( + eventSource, + 'keydown', + this._onKeyDown + ); + this.editor.disposables.addFromEvent(rootElement, 'click', this._onClick); + }; + + get isComposing() { + return this._isComposing; + } + + constructor(readonly editor: InlineEditor<TextAttributes>) {} +} diff --git a/blocksuite/framework/inline/src/services/hook.ts b/blocksuite/framework/inline/src/services/hook.ts new file mode 100644 index 0000000000..16c8d596ec --- /dev/null +++ b/blocksuite/framework/inline/src/services/hook.ts @@ -0,0 +1,34 @@ +import type { InlineEditor } from '../inline-editor.js'; +import type { InlineRange } from '../types.js'; +import type { BaseTextAttributes } from '../utils/base-attributes.js'; + +export interface BeforeinputHookCtx<TextAttributes extends BaseTextAttributes> { + inlineEditor: InlineEditor<TextAttributes>; + raw: InputEvent; + inlineRange: InlineRange; + data: string | null; + attributes: TextAttributes; +} +export interface CompositionEndHookCtx< + TextAttributes extends BaseTextAttributes, +> { + inlineEditor: InlineEditor<TextAttributes>; + raw: CompositionEvent; + inlineRange: InlineRange; + data: string | null; + attributes: TextAttributes; +} + +export type HookContext<TextAttributes extends BaseTextAttributes> = + | BeforeinputHookCtx<TextAttributes> + | CompositionEndHookCtx<TextAttributes>; + +export class InlineHookService<TextAttributes extends BaseTextAttributes> { + constructor( + readonly editor: InlineEditor<TextAttributes>, + readonly hooks: { + beforeinput?: (props: BeforeinputHookCtx<TextAttributes>) => void; + compositionEnd?: (props: CompositionEndHookCtx<TextAttributes>) => void; + } = {} + ) {} +} diff --git a/blocksuite/framework/inline/src/services/index.ts b/blocksuite/framework/inline/src/services/index.ts new file mode 100644 index 0000000000..63d58d6154 --- /dev/null +++ b/blocksuite/framework/inline/src/services/index.ts @@ -0,0 +1,6 @@ +export * from './attribute.js'; +export * from './delta.js'; +export * from './event.js'; +export * from './hook.js'; +export * from './range.js'; +export * from './render.js'; diff --git a/blocksuite/framework/inline/src/services/range.ts b/blocksuite/framework/inline/src/services/range.ts new file mode 100644 index 0000000000..4c32a25641 --- /dev/null +++ b/blocksuite/framework/inline/src/services/range.ts @@ -0,0 +1,374 @@ +import { assertExists } from '@blocksuite/global/utils'; +import { effect } from '@preact/signals-core'; +import * as Y from 'yjs'; + +import type { VLine } from '../components/v-line.js'; +import type { InlineEditor } from '../inline-editor.js'; +import type { InlineRange, TextPoint } from '../types.js'; +import type { BaseTextAttributes } from '../utils/base-attributes.js'; +import { isInEmbedGap } from '../utils/embed.js'; +import { isMaybeInlineRangeEqual } from '../utils/inline-range.js'; +import { + domRangeToInlineRange, + inlineRangeToDomRange, +} from '../utils/range-conversion.js'; +import { calculateTextLength, getTextNodesFromElement } from '../utils/text.js'; + +export class RangeService<TextAttributes extends BaseTextAttributes> { + private _lastEndRelativePosition: Y.RelativePosition | null = null; + + private _lastStartRelativePosition: Y.RelativePosition | null = null; + + focusEnd = (): void => { + this.editor.setInlineRange({ + index: this.editor.yTextLength, + length: 0, + }); + }; + + focusIndex = (index: number): void => { + this.editor.setInlineRange({ + index, + length: 0, + }); + }; + + focusStart = (): void => { + this.editor.setInlineRange({ + index: 0, + length: 0, + }); + }; + + getInlineRangeFromElement = (element: Element): InlineRange | null => { + const range = document.createRange(); + const text = element.querySelector('[data-v-text]'); + if (!text) { + return null; + } + const textNode = text.childNodes[1]; + assertExists(textNode instanceof Text); + range.setStart(textNode, 0); + range.setEnd(textNode, textNode.textContent?.length ?? 0); + const inlineRange = this.toInlineRange(range); + return inlineRange; + }; + + // the number is related to the VLine's textLength + getLine = ( + rangeIndex: InlineRange['index'] + ): { + line: VLine; + lineIndex: number; + rangeIndexRelatedToLine: number; + } | null => { + const rootElement = this.editor.rootElement; + const lineElements = Array.from(rootElement.querySelectorAll('v-line')); + + let beforeIndex = 0; + for (const [lineIndex, lineElement] of lineElements.entries()) { + if ( + rangeIndex >= beforeIndex && + rangeIndex < beforeIndex + lineElement.vTextLength + 1 + ) { + return { + line: lineElement, + lineIndex, + rangeIndexRelatedToLine: rangeIndex - beforeIndex, + }; + } + beforeIndex += lineElement.vTextLength + 1; + } + + console.error('failed to find line'); + return null; + }; + + getNativeRange = (): Range | null => { + const selection = this.getNativeSelection(); + if (!selection) return null; + return selection.getRangeAt(0); + }; + + getNativeSelection = (): Selection | null => { + const selection = document.getSelection(); + if (!selection) return null; + if (selection.rangeCount === 0) return null; + + return selection; + }; + + getTextPoint = (rangeIndex: InlineRange['index']): TextPoint | null => { + const rootElement = this.editor.rootElement; + const vLines = Array.from(rootElement.querySelectorAll('v-line')); + + let index = 0; + for (const vLine of vLines) { + const texts = getTextNodesFromElement(vLine); + if (texts.length === 0) { + return null; + } + + for (const text of texts.filter(text => !isInEmbedGap(text))) { + if (!text.textContent) { + return null; + } + if (index + text.textContent.length >= rangeIndex) { + return [text, rangeIndex - index]; + } + index += calculateTextLength(text); + } + + index += 1; + } + + return null; + }; + + /** + * There are two cases to have the second line: + * 1. long text auto wrap in span element + * 2. soft break + */ + isFirstLine = (inlineRange: InlineRange | null): boolean => { + if (!inlineRange || inlineRange.length > 0) return false; + + const range = this.toDomRange(inlineRange); + if (!range) { + console.error('failed to convert inline range to domRange'); + return false; + } + + // check case 1: + const beforeText = this.editor.yTextString.slice(0, inlineRange.index); + if (beforeText.includes('\n')) { + return false; + } + + // check case 2: + // If there is a wrapped text, there are two possible positions for + // cursor: (in first line and in second line) + // aaaaaaaa| or aaaaaaaa + // bb |bb + // We have no way to distinguish them and we just assume that the cursor + // can not in the first line because if we apply the inline ranage manually the + // cursor will jump to the second line. + const container = range.commonAncestorContainer.parentElement; + assertExists(container); + const containerRect = container.getBoundingClientRect(); + // There will be two rects if the cursor is at the edge of the line: + // aaaaaaaa| or aaaaaaaa + // bb |bb + const rangeRects = range.getClientRects(); + // We use last rect here to make sure we get the second rect. + // (Based on the assumption that the cursor can not in the first line) + const rangeRect = rangeRects[rangeRects.length - 1]; + const tolerance = 1; + return Math.abs(rangeRect.top - containerRect.top) < tolerance; + }; + + /** + * There are two cases to have the second line: + * 1. long text auto wrap in span element + * 2. soft break + */ + isLastLine = (inlineRange: InlineRange | null): boolean => { + if (!inlineRange || inlineRange.length > 0) return false; + + // check case 1: + const afterText = this.editor.yTextString.slice(inlineRange.index); + if (afterText.includes('\n')) { + return false; + } + + const range = this.toDomRange(inlineRange); + if (!range) { + console.error('failed to convert inline range to domRange'); + return false; + } + + // check case 2: + // If there is a wrapped text, there are two possible positions for + // cursor: (in first line and in second line) + // aaaaaaaa| or aaaaaaaa + // bb |bb + // We have no way to distinguish them and we just assume that the cursor + // can not in the first line because if we apply the inline range manually the + // cursor will jump to the second line. + const container = range.commonAncestorContainer.parentElement; + assertExists(container); + const containerRect = container.getBoundingClientRect(); + // There will be two rects if the cursor is at the edge of the line: + // aaaaaaaa| or aaaaaaaa + // bb |bb + const rangeRects = range.getClientRects(); + // We use last rect here to make sure we get the second rect. + // (Based on the assumption that the cursor can not be in the first line) + const rangeRect = rangeRects[rangeRects.length - 1]; + + const tolerance = 1; + return Math.abs(rangeRect.bottom - containerRect.bottom) < tolerance; + }; + + isValidInlineRange = (inlineRange: InlineRange | null): boolean => { + return !( + inlineRange && + (inlineRange.index < 0 || + inlineRange.index + inlineRange.length > this.editor.yText.length) + ); + }; + + mount = () => { + const editor = this.editor; + let lastInlineRange: InlineRange | null = editor.inlineRange$.value; + editor.disposables.add( + effect(() => { + const newInlineRange = editor.inlineRange$.value; + if (!editor.mounted) return; + + const eq = isMaybeInlineRangeEqual(lastInlineRange, newInlineRange); + if (eq) return; + lastInlineRange = newInlineRange; + + const yText = editor.yText; + if (newInlineRange) { + this._lastStartRelativePosition = + Y.createRelativePositionFromTypeIndex(yText, newInlineRange.index); + this._lastEndRelativePosition = Y.createRelativePositionFromTypeIndex( + yText, + newInlineRange.index + newInlineRange.length + ); + } else { + this._lastStartRelativePosition = null; + this._lastEndRelativePosition = null; + } + + if (editor.inlineRangeProviderOverride) return; + + if (this.editor.renderService.rendering) { + editor.slots.renderComplete.once(() => { + this.syncInlineRange(newInlineRange); + }); + } else { + this.syncInlineRange(); + } + }) + ); + }; + + selectAll = (): void => { + this.editor.setInlineRange({ + index: 0, + length: this.editor.yTextLength, + }); + }; + + private _syncInlineRangeLock = false; + lockSyncInlineRange = () => { + this._syncInlineRangeLock = true; + }; + unlockSyncInlineRange = () => { + this._syncInlineRangeLock = false; + }; + /** + * sync the dom selection from inline range for **this Editor** + */ + syncInlineRange = (inlineRange?: InlineRange | null) => { + if (!this.editor.mounted || this._syncInlineRangeLock) return; + inlineRange = inlineRange ?? this.editor.getInlineRange(); + + const handler = () => { + const selection = document.getSelection(); + if (!selection) return; + + if (inlineRange === null) { + if (selection.rangeCount > 0) { + const range = selection.getRangeAt(0); + if (range.intersectsNode(this.editor.rootElement)) { + selection.removeAllRanges(); + } + } + } else { + try { + const newRange = this.toDomRange(inlineRange); + if (newRange) { + selection.removeAllRanges(); + selection.addRange(newRange); + this.editor.rootElement.focus(); + + this.editor.slots.inlineRangeSync.emit(newRange); + } else { + this.editor.slots.renderComplete.once(() => { + this.syncInlineRange(inlineRange); + }); + } + } catch (error) { + console.error('failed to apply inline range'); + console.error(error); + } + } + }; + + if (this.editor.renderService.rendering) { + this.editor.slots.renderComplete.once(handler); + } else { + handler(); + } + }; + + /** + * calculate the dom selection from inline ranage for **this Editor** + */ + toDomRange = (inlineRange: InlineRange): Range | null => { + const rootElement = this.editor.rootElement; + return inlineRangeToDomRange(rootElement, inlineRange); + }; + + /** + * calculate the inline ranage from dom selection for **this Editor** + * there are three cases when the inline ranage of this Editor is not null: + * (In the following, "|" mean anchor and focus, each line is a separate Editor) + * 1. anchor and focus are in this Editor + * ``` + * aaaaaa + * b|bbbb|b + * cccccc + * ``` + * the inline ranage of second Editor is `{index: 1, length: 4}`, the others are null + * 2. anchor and focus one in this Editor, one in another Editor + * ``` + * aaa|aaa aaaaaa + * bbbbb|b or bbbbb|b + * cccccc cc|cccc + * ``` + * 2.1 + * the inline ranage of first Editor is `{index: 3, length: 3}`, the second is `{index: 0, length: 5}`, + * the third is null + * 2.2 + * the inline ranage of first Editor is null, the second is `{index: 5, length: 1}`, + * the third is `{index: 0, length: 2}` + * 3. anchor and focus are in another Editor + * ``` + * aa|aaaa + * bbbbbb + * cccc|cc + * ``` + * the inline range of first Editor is `{index: 2, length: 4}`, + * the second is `{index: 0, length: 6}`, the third is `{index: 0, length: 4}` + */ + toInlineRange = (range: Range): InlineRange | null => { + const { rootElement, yText } = this.editor; + + return domRangeToInlineRange(range, rootElement, yText); + }; + + get lastEndRelativePosition() { + return this._lastEndRelativePosition; + } + + get lastStartRelativePosition() { + return this._lastStartRelativePosition; + } + + constructor(readonly editor: InlineEditor<TextAttributes>) {} +} diff --git a/blocksuite/framework/inline/src/services/render.ts b/blocksuite/framework/inline/src/services/render.ts new file mode 100644 index 0000000000..5ae4600117 --- /dev/null +++ b/blocksuite/framework/inline/src/services/render.ts @@ -0,0 +1,179 @@ +import { BlockSuiteError, ErrorCode } from '@blocksuite/global/exceptions'; +import { assertExists } from '@blocksuite/global/utils'; +import { html, render } from 'lit'; +import { repeat } from 'lit/directives/repeat.js'; +import * as Y from 'yjs'; + +import type { VLine } from '../components/v-line.js'; +import type { InlineEditor } from '../inline-editor.js'; +import type { InlineRange } from '../types.js'; +import type { BaseTextAttributes } from '../utils/base-attributes.js'; +import { deltaInsertsToChunks } from '../utils/delta-convert.js'; + +export class RenderService<TextAttributes extends BaseTextAttributes> { + private _onYTextChange = (_: Y.YTextEvent, transaction: Y.Transaction) => { + this.editor.slots.textChange.emit(); + + const yText = this.editor.yText; + + if (yText.toString().includes('\r')) { + throw new BlockSuiteError( + ErrorCode.InlineEditorError, + 'yText must not contain "\\r" because it will break the range synchronization' + ); + } + + this.render(); + + const inlineRange = this.editor.inlineRange$.peek(); + if (!inlineRange || transaction.local) return; + + const lastStartRelativePosition = this.editor.lastStartRelativePosition; + const lastEndRelativePosition = this.editor.lastEndRelativePosition; + if (!lastStartRelativePosition || !lastEndRelativePosition) return; + + const doc = this.editor.yText.doc; + assertExists(doc); + const absoluteStart = Y.createAbsolutePositionFromRelativePosition( + lastStartRelativePosition, + doc + ); + const absoluteEnd = Y.createAbsolutePositionFromRelativePosition( + lastEndRelativePosition, + doc + ); + + const startIndex = absoluteStart?.index; + const endIndex = absoluteEnd?.index; + if (!startIndex || !endIndex) return; + + const newInlineRange: InlineRange = { + index: startIndex, + length: endIndex - startIndex, + }; + if (!this.editor.isValidInlineRange(newInlineRange)) return; + + this.editor.setInlineRange(newInlineRange); + this.editor.syncInlineRange(); + }; + + mount = () => { + const editor = this.editor; + const yText = editor.yText; + + yText.observe(this._onYTextChange); + editor.disposables.add({ + dispose: () => { + yText.unobserve(this._onYTextChange); + }, + }); + }; + + private _rendering = false; + get rendering() { + return this._rendering; + } + // render current deltas to VLines + render = () => { + if (!this.editor.mounted) return; + + this._rendering = true; + + const rootElement = this.editor.rootElement; + const embedDeltas = this.editor.deltaService.embedDeltas; + const chunks = deltaInsertsToChunks(embedDeltas); + + let deltaIndex = 0; + // every chunk is a line + const lines = chunks.map((chunk, lineIndex) => { + if (lineIndex > 0) { + deltaIndex += 1; // for '\n' + } + + const lineStartOffset = deltaIndex; + if (chunk.length > 0) { + const elements: VLine['elements'] = chunk.map(delta => { + const startOffset = deltaIndex; + deltaIndex += delta.insert.length; + const endOffset = deltaIndex; + + return [ + html`<v-element + .inlineEditor=${this.editor} + .delta=${{ + insert: delta.insert, + attributes: this.editor.attributeService.normalizeAttributes( + delta.attributes + ), + }} + .startOffset=${startOffset} + .endOffset=${endOffset} + .lineIndex=${lineIndex} + ></v-element>`, + delta, + ]; + }); + + return html`<v-line + .elements=${elements} + .index=${lineIndex} + .startOffset=${lineStartOffset} + .endOffset=${deltaIndex} + ></v-line>`; + } else { + return html`<v-line + .elements=${[]} + .index=${lineIndex} + .startOffset=${lineStartOffset} + .endOffset=${deltaIndex} + ></v-line>`; + } + }); + + try { + render( + repeat( + lines.map((line, i) => ({ line, index: i })), + entry => entry.index, + entry => entry.line + ), + rootElement + ); + } catch { + // Lit may be crashed by IME input and we need to rerender whole editor for it + this.editor.rerenderWholeEditor(); + } + + this.editor + .waitForUpdate() + .then(() => { + this._rendering = false; + this.editor.slots.renderComplete.emit(); + this.editor.syncInlineRange(); + }) + .catch(console.error); + }; + + rerenderWholeEditor = () => { + const rootElement = this.editor.rootElement; + + if (!rootElement.isConnected) return; + + rootElement.replaceChildren(); + // Because we bypassed Lit and disrupted the DOM structure, this will cause an inconsistency in the original state of `ChildPart`. + // Therefore, we need to remove the original `ChildPart`. + // https://github.com/lit/lit/blob/a2cd76cfdea4ed717362bb1db32710d70550469d/packages/lit-html/src/lit-html.ts#L2248 + + delete (rootElement as any)['_$litPart$']; + this.render(); + }; + + waitForUpdate = async () => { + const vLines = Array.from( + this.editor.rootElement.querySelectorAll('v-line') + ); + await Promise.all(vLines.map(line => line.updateComplete)); + }; + + constructor(readonly editor: InlineEditor<TextAttributes>) {} +} diff --git a/blocksuite/framework/inline/src/services/text.ts b/blocksuite/framework/inline/src/services/text.ts new file mode 100644 index 0000000000..657d2754fd --- /dev/null +++ b/blocksuite/framework/inline/src/services/text.ts @@ -0,0 +1,141 @@ +import { BlockSuiteError, ErrorCode } from '@blocksuite/global/exceptions'; + +import type { InlineEditor } from '../inline-editor.js'; +import type { DeltaInsert, InlineRange } from '../types.js'; +import type { BaseTextAttributes } from '../utils/base-attributes.js'; +import { intersectInlineRange } from '../utils/inline-range.js'; + +export class InlineTextService<TextAttributes extends BaseTextAttributes> { + deleteText = (inlineRange: InlineRange): void => { + if (this.editor.isReadonly) return; + + this.transact(() => { + this.yText.delete(inlineRange.index, inlineRange.length); + }); + }; + + formatText = ( + inlineRange: InlineRange, + attributes: TextAttributes, + options: { + match?: (delta: DeltaInsert, deltaInlineRange: InlineRange) => boolean; + mode?: 'replace' | 'merge'; + } = {} + ): void => { + if (this.editor.isReadonly) return; + + const { match = () => true, mode = 'merge' } = options; + const deltas = this.editor.deltaService.getDeltasByInlineRange(inlineRange); + + deltas + .filter(([delta, deltaInlineRange]) => match(delta, deltaInlineRange)) + .forEach(([_delta, deltaInlineRange]) => { + const normalizedAttributes = + this.editor.attributeService.normalizeAttributes(attributes); + if (!normalizedAttributes) return; + + const targetInlineRange = intersectInlineRange( + inlineRange, + deltaInlineRange + ); + if (!targetInlineRange) return; + + if (mode === 'replace') { + this.resetText(targetInlineRange); + } + + this.transact(() => { + this.yText.format( + targetInlineRange.index, + targetInlineRange.length, + normalizedAttributes + ); + }); + }); + }; + + insertLineBreak = (inlineRange: InlineRange): void => { + if (this.editor.isReadonly) return; + + this.transact(() => { + this.yText.delete(inlineRange.index, inlineRange.length); + this.yText.insert(inlineRange.index, '\n'); + }); + }; + + insertText = ( + inlineRange: InlineRange, + text: string, + attributes: TextAttributes = {} as TextAttributes + ): void => { + if (this.editor.isReadonly) return; + + if (this.editor.attributeService.marks) { + attributes = { ...attributes, ...this.editor.attributeService.marks }; + } + const normalizedAttributes = + this.editor.attributeService.normalizeAttributes(attributes); + + if (!text || !text.length) { + throw new BlockSuiteError( + ErrorCode.InlineEditorError, + 'text must not be empty' + ); + } + + this.transact(() => { + this.yText.delete(inlineRange.index, inlineRange.length); + this.yText.insert(inlineRange.index, text, normalizedAttributes); + }); + }; + + resetText = (inlineRange: InlineRange): void => { + if (this.editor.isReadonly) return; + + const coverDeltas: DeltaInsert[] = []; + for ( + let i = inlineRange.index; + i <= inlineRange.index + inlineRange.length; + i++ + ) { + const delta = this.editor.getDeltaByRangeIndex(i); + if (delta) { + coverDeltas.push(delta); + } + } + + const unset = Object.fromEntries( + coverDeltas.flatMap(delta => + delta.attributes + ? Object.keys(delta.attributes).map(key => [key, null]) + : [] + ) + ); + + this.transact(() => { + this.yText.format(inlineRange.index, inlineRange.length, { + ...unset, + }); + }); + }; + + setText = ( + text: string, + attributes: TextAttributes = {} as TextAttributes + ): void => { + if (this.editor.isReadonly) return; + + this.transact(() => { + this.yText.delete(0, this.yText.length); + this.yText.insert(0, text, attributes); + }); + }; + + readonly transact = this.editor.transact; + + get yText() { + return this.editor.yText; + } + + constructor(readonly editor: InlineEditor<TextAttributes>) {} +} diff --git a/blocksuite/framework/inline/src/types.ts b/blocksuite/framework/inline/src/types.ts new file mode 100644 index 0000000000..533ba2b6b5 --- /dev/null +++ b/blocksuite/framework/inline/src/types.ts @@ -0,0 +1,43 @@ +import type { TemplateResult } from 'lit'; + +import type { InlineEditor } from './inline-editor.js'; +import type { BaseTextAttributes } from './utils/index.js'; + +export type DeltaInsert< + TextAttributes extends BaseTextAttributes = BaseTextAttributes, +> = { + insert: string; + attributes?: TextAttributes; +}; + +export type AttributeRenderer< + TextAttributes extends BaseTextAttributes = BaseTextAttributes, +> = (props: { + editor: InlineEditor<TextAttributes>; + delta: DeltaInsert<TextAttributes>; + selected: boolean; + startOffset: number; + endOffset: number; + lineIndex: number; +}) => TemplateResult<1>; + +export interface InlineRange { + index: number; + length: number; +} + +export type DeltaEntry< + TextAttributes extends BaseTextAttributes = BaseTextAttributes, +> = [delta: DeltaInsert<TextAttributes>, range: InlineRange]; + +// corresponding to [anchorNode/focusNode, anchorOffset/focusOffset] +export type NativePoint = readonly [node: Node, offset: number]; +// the number here is relative to the text node +export type TextPoint = readonly [text: Text, offset: number]; + +export interface DomPoint { + // which text node this point is in + text: Text; + // the index here is relative to the Editor, not text node + index: number; +} diff --git a/blocksuite/framework/inline/src/utils/attribute-renderer.ts b/blocksuite/framework/inline/src/utils/attribute-renderer.ts new file mode 100644 index 0000000000..cc3eac02ca --- /dev/null +++ b/blocksuite/framework/inline/src/utils/attribute-renderer.ts @@ -0,0 +1,49 @@ +import { html } from 'lit'; +import { styleMap } from 'lit/directives/style-map.js'; + +import type { AttributeRenderer } from '../types.js'; +import type { BaseTextAttributes } from './base-attributes.js'; + +function inlineTextStyles( + props: BaseTextAttributes +): ReturnType<typeof styleMap> { + let textDecorations = ''; + if (props.underline) { + textDecorations += 'underline'; + } + if (props.strike) { + textDecorations += ' line-through'; + } + + let inlineCodeStyle = {}; + if (props.code) { + inlineCodeStyle = { + 'font-family': + '"SFMono-Regular", Menlo, Consolas, "PT Mono", "Liberation Mono", Courier, monospace', + 'line-height': 'normal', + background: 'rgba(135,131,120,0.15)', + color: '#EB5757', + 'border-radius': '3px', + 'font-size': '85%', + padding: '0.2em 0.4em', + }; + } + + return styleMap({ + 'font-weight': props.bold ? 'bold' : 'normal', + 'font-style': props.italic ? 'italic' : 'normal', + 'text-decoration': textDecorations.length > 0 ? textDecorations : 'none', + ...inlineCodeStyle, + }); +} + +export const getDefaultAttributeRenderer = + <T extends BaseTextAttributes>(): AttributeRenderer<T> => + ({ delta }) => { + const style = delta.attributes + ? inlineTextStyles(delta.attributes) + : styleMap({}); + return html`<span style=${style} + ><v-text .str=${delta.insert}></v-text + ></span>`; + }; diff --git a/blocksuite/framework/inline/src/utils/base-attributes.ts b/blocksuite/framework/inline/src/utils/base-attributes.ts new file mode 100644 index 0000000000..c776cb44c5 --- /dev/null +++ b/blocksuite/framework/inline/src/utils/base-attributes.ts @@ -0,0 +1,12 @@ +import { z } from 'zod'; + +export const baseTextAttributes = z.object({ + bold: z.literal(true).optional().nullable().catch(undefined), + italic: z.literal(true).optional().nullable().catch(undefined), + underline: z.literal(true).optional().nullable().catch(undefined), + strike: z.literal(true).optional().nullable().catch(undefined), + code: z.literal(true).optional().nullable().catch(undefined), + link: z.string().optional().nullable().catch(undefined), +}); + +export type BaseTextAttributes = z.infer<typeof baseTextAttributes>; diff --git a/blocksuite/framework/inline/src/utils/delta-convert.ts b/blocksuite/framework/inline/src/utils/delta-convert.ts new file mode 100644 index 0000000000..808859b67b --- /dev/null +++ b/blocksuite/framework/inline/src/utils/delta-convert.ts @@ -0,0 +1,64 @@ +import type { DeltaInsert } from '../types.js'; +import type { BaseTextAttributes } from './base-attributes.js'; + +export function transformDelta<TextAttributes extends BaseTextAttributes>( + delta: DeltaInsert<TextAttributes> +): (DeltaInsert<TextAttributes> | '\n')[] { + const result: (DeltaInsert<TextAttributes> | '\n')[] = []; + + let tmpString = delta.insert; + while (tmpString.length > 0) { + const index = tmpString.indexOf('\n'); + if (index === -1) { + result.push({ + insert: tmpString, + attributes: delta.attributes, + }); + break; + } + + if (tmpString.slice(0, index).length > 0) { + result.push({ + insert: tmpString.slice(0, index), + attributes: delta.attributes, + }); + } + + result.push('\n'); + tmpString = tmpString.slice(index + 1); + } + + return result; +} + +/** + * convert a delta insert array to chunks, each chunk is a line + */ +export function deltaInsertsToChunks<TextAttributes extends BaseTextAttributes>( + delta: DeltaInsert<TextAttributes>[] +): DeltaInsert<TextAttributes>[][] { + if (delta.length === 0) { + return [[]]; + } + + const transformedDelta = delta.flatMap(transformDelta); + + function* chunksGenerator(arr: (DeltaInsert<TextAttributes> | '\n')[]) { + let start = 0; + for (let i = 0; i < arr.length; i++) { + if (arr[i] === '\n') { + const chunk = arr.slice(start, i); + start = i + 1; + yield chunk as DeltaInsert<TextAttributes>[]; + } else if (i === arr.length - 1) { + yield arr.slice(start) as DeltaInsert<TextAttributes>[]; + } + } + + if (arr.at(-1) === '\n') { + yield []; + } + } + + return Array.from(chunksGenerator(transformedDelta)); +} diff --git a/blocksuite/framework/inline/src/utils/embed.ts b/blocksuite/framework/inline/src/utils/embed.ts new file mode 100644 index 0000000000..7b2964f80e --- /dev/null +++ b/blocksuite/framework/inline/src/utils/embed.ts @@ -0,0 +1,47 @@ +import { VElement } from '../components/v-element.js'; +import type { InlineEditor } from '../inline-editor.js'; +import type { DeltaInsert } from '../types.js'; +import type { BaseTextAttributes } from './base-attributes.js'; + +export function isInEmbedElement(node: Node): boolean { + if (node instanceof Element) { + if (node instanceof VElement) { + return node.querySelector('[data-v-embed="true"]') !== null; + } + const vElement = node.closest('[data-v-embed="true"]'); + return !!vElement; + } else { + const vElement = node.parentElement?.closest('[data-v-embed="true"]'); + return !!vElement; + } +} + +export function isInEmbedGap(node: Node): boolean { + const el = node instanceof Element ? node : node.parentElement; + if (!el) return false; + return !!el.closest('[data-v-embed-gap="true"]'); +} + +export function transformDeltasToEmbedDeltas< + TextAttributes extends BaseTextAttributes = BaseTextAttributes, +>( + editor: InlineEditor<TextAttributes>, + deltas: DeltaInsert<TextAttributes>[] +): DeltaInsert<TextAttributes>[] { + // According to our regulations, the length of each "embed" node should only be 1. + // Therefore, if the length of an "embed" type node is greater than 1, + // we will divide it into multiple parts. + const result: DeltaInsert<TextAttributes>[] = []; + for (const delta of deltas) { + if (editor.isEmbed(delta)) { + const dividedDeltas = [...delta.insert].map(subInsert => ({ + insert: subInsert, + attributes: delta.attributes, + })); + result.push(...dividedDeltas); + } else { + result.push(delta); + } + } + return result; +} diff --git a/blocksuite/framework/inline/src/utils/guard.ts b/blocksuite/framework/inline/src/utils/guard.ts new file mode 100644 index 0000000000..6af05c9764 --- /dev/null +++ b/blocksuite/framework/inline/src/utils/guard.ts @@ -0,0 +1,29 @@ +import { VElement, VLine } from '../components/index.js'; + +export function isNativeTextInVText(text: unknown): text is Text { + return text instanceof Text && text.parentElement?.dataset.vText === 'true'; +} + +export function isVElement(element: unknown): element is HTMLElement { + return ( + element instanceof HTMLElement && + (element.dataset.vElement === 'true' || element instanceof VElement) + ); +} + +export function isVLine(element: unknown): element is HTMLElement { + return ( + element instanceof HTMLElement && + (element instanceof VLine || element.parentElement instanceof VLine) + ); +} + +export function isInEmptyLine(element: Node) { + const el = element instanceof Element ? element : element.parentElement; + const vLine = el?.closest<VLine>('v-line'); + return !!vLine && vLine.vTextLength === 0; +} + +export function isInlineRoot(element: unknown): element is HTMLElement { + return element instanceof HTMLElement && element.dataset.vRoot === 'true'; +} diff --git a/blocksuite/framework/inline/src/utils/index.ts b/blocksuite/framework/inline/src/utils/index.ts new file mode 100644 index 0000000000..beec1d2927 --- /dev/null +++ b/blocksuite/framework/inline/src/utils/index.ts @@ -0,0 +1,12 @@ +export * from './attribute-renderer.js'; +export * from './base-attributes.js'; +export * from './delta-convert.js'; +export * from './embed.js'; +export * from './guard.js'; +export * from './keyboard.js'; +export * from './point-conversion.js'; +export * from './query.js'; +export * from './range-conversion.js'; +export * from './renderer.js'; +export * from './text.js'; +export * from './transform-input.js'; diff --git a/blocksuite/framework/inline/src/utils/inline-range.ts b/blocksuite/framework/inline/src/utils/inline-range.ts new file mode 100644 index 0000000000..9f64dcaa91 --- /dev/null +++ b/blocksuite/framework/inline/src/utils/inline-range.ts @@ -0,0 +1,74 @@ +import type { InlineRange } from '../types.js'; + +export function isMaybeInlineRangeEqual( + a: InlineRange | null, + b: InlineRange | null +): boolean { + return a === b || (a && b ? isInlineRangeEqual(a, b) : false); +} + +export function isInlineRangeContain(a: InlineRange, b: InlineRange): boolean { + return a.index <= b.index && a.index + a.length >= b.index + b.length; +} + +export function isInlineRangeEqual(a: InlineRange, b: InlineRange): boolean { + return a.index === b.index && a.length === b.length; +} + +export function isInlineRangeIntersect( + a: InlineRange, + b: InlineRange +): boolean { + return a.index <= b.index + b.length && a.index + a.length >= b.index; +} + +export function isInlineRangeBefore(a: InlineRange, b: InlineRange): boolean { + return a.index + a.length <= b.index; +} + +export function isInlineRangeAfter(a: InlineRange, b: InlineRange): boolean { + return a.index >= b.index + b.length; +} + +export function isInlineRangeEdge( + index: InlineRange['index'], + range: InlineRange +): boolean { + return index === range.index || index === range.index + range.length; +} + +export function isInlineRangeEdgeBefore( + index: InlineRange['index'], + range: InlineRange +): boolean { + return index === range.index; +} + +export function isInlineRangeEdgeAfter( + index: InlineRange['index'], + range: InlineRange +): boolean { + return index === range.index + range.length; +} + +export function isPoint(range: InlineRange): boolean { + return range.length === 0; +} + +export function mergeInlineRange(a: InlineRange, b: InlineRange): InlineRange { + const index = Math.min(a.index, b.index); + const length = Math.max(a.index + a.length, b.index + b.length) - index; + return { index, length }; +} + +export function intersectInlineRange( + a: InlineRange, + b: InlineRange +): InlineRange | null { + if (!isInlineRangeIntersect(a, b)) { + return null; + } + const index = Math.max(a.index, b.index); + const length = Math.min(a.index + a.length, b.index + b.length) - index; + return { index, length }; +} diff --git a/blocksuite/framework/inline/src/utils/keyboard.ts b/blocksuite/framework/inline/src/utils/keyboard.ts new file mode 100644 index 0000000000..f7efc04052 --- /dev/null +++ b/blocksuite/framework/inline/src/utils/keyboard.ts @@ -0,0 +1,135 @@ +import { IS_IOS, IS_MAC } from '@blocksuite/global/env'; + +import type { InlineEditor } from '../inline-editor.js'; +import type { InlineRange } from '../types.js'; +import type { BaseTextAttributes } from './base-attributes.js'; + +const SHORT_KEY_PROPERTY = IS_IOS || IS_MAC ? 'metaKey' : 'ctrlKey'; + +export const KEYBOARD_PREVENT_DEFAULT = false; +export const KEYBOARD_ALLOW_DEFAULT = true; + +export interface KeyboardBinding { + key: number | string | string[]; + handler: KeyboardBindingHandler; + prefix?: RegExp; + suffix?: RegExp; + shortKey?: boolean; + shiftKey?: boolean; + altKey?: boolean; + metaKey?: boolean; + ctrlKey?: boolean; +} +export type KeyboardBindingRecord = Record<string, KeyboardBinding>; + +export interface KeyboardBindingContext< + TextAttributes extends BaseTextAttributes = BaseTextAttributes, +> { + inlineRange: InlineRange; + inlineEditor: InlineEditor<TextAttributes>; + collapsed: boolean; + prefixText: string; + suffixText: string; + raw: KeyboardEvent; +} +export type KeyboardBindingHandler = ( + context: KeyboardBindingContext +) => typeof KEYBOARD_PREVENT_DEFAULT | typeof KEYBOARD_ALLOW_DEFAULT; + +export function createInlineKeyDownHandler( + inlineEditor: InlineEditor, + bindings: KeyboardBindingRecord +): (evt: KeyboardEvent) => void { + const bindingStore: Record<string, KeyboardBinding[]> = {}; + + function normalize(binding: KeyboardBinding): KeyboardBinding { + if (binding.shortKey) { + binding[SHORT_KEY_PROPERTY] = binding.shortKey; + delete binding.shortKey; + } + return binding; + } + + function keyMatch(evt: KeyboardEvent, binding: KeyboardBinding) { + if ( + (['altKey', 'ctrlKey', 'metaKey', 'shiftKey'] as const).some( + key => Object.hasOwn(binding, key) && binding[key] !== evt[key] + ) + ) { + return false; + } + return binding.key === evt.key; + } + + function addBinding(keyBinding: KeyboardBinding) { + const binding = normalize(keyBinding); + const keys = Array.isArray(binding.key) ? binding.key : [binding.key]; + keys.forEach(key => { + const singleBinding = { + ...binding, + key, + }; + bindingStore[key] = bindingStore[key] ?? []; + bindingStore[key].push(singleBinding); + }); + } + + Object.values(bindings).forEach(binding => { + addBinding(binding); + }); + + function keyDownHandler(evt: KeyboardEvent) { + if (evt.defaultPrevented || evt.isComposing) return; + const keyBindings = bindingStore[evt.key] ?? []; + + const keyMatches = keyBindings.filter(binding => keyMatch(evt, binding)); + if (keyMatches.length === 0) return; + + const inlineRange = inlineEditor.getInlineRange(); + if (!inlineRange) return; + + const startTextPoint = inlineEditor.getTextPoint(inlineRange.index); + if (!startTextPoint) return; + const [leafStart, offsetStart] = startTextPoint; + let leafEnd: Text; + let offsetEnd: number; + if (inlineRange.length === 0) { + leafEnd = leafStart; + offsetEnd = offsetStart; + } else { + const endTextPoint = inlineEditor.getTextPoint( + inlineRange.index + inlineRange.length + ); + if (!endTextPoint) return; + [leafEnd, offsetEnd] = endTextPoint; + } + const prefixText = leafStart.textContent + ? leafStart.textContent.slice(0, offsetStart) + : ''; + const suffixText = leafEnd.textContent + ? leafEnd.textContent.slice(offsetEnd) + : ''; + const currContext: KeyboardBindingContext = { + inlineRange, + inlineEditor: inlineEditor, + collapsed: inlineRange.length === 0, + prefixText, + suffixText, + raw: evt, + }; + const prevented = keyMatches.some(binding => { + if (binding.prefix && !binding.prefix.test(currContext.prefixText)) { + return false; + } + if (binding.suffix && !binding.suffix.test(currContext.suffixText)) { + return false; + } + return binding.handler(currContext) === KEYBOARD_PREVENT_DEFAULT; + }); + if (prevented) { + evt.preventDefault(); + } + } + + return keyDownHandler; +} diff --git a/blocksuite/framework/inline/src/utils/point-conversion.ts b/blocksuite/framework/inline/src/utils/point-conversion.ts new file mode 100644 index 0000000000..7e891b289a --- /dev/null +++ b/blocksuite/framework/inline/src/utils/point-conversion.ts @@ -0,0 +1,191 @@ +import { BlockSuiteError, ErrorCode } from '@blocksuite/global/exceptions'; + +import type { VElement, VLine } from '../components/index.js'; +import { INLINE_ROOT_ATTR, ZERO_WIDTH_SPACE } from '../consts.js'; +import type { DomPoint, TextPoint } from '../types.js'; +import { + isInlineRoot, + isNativeTextInVText, + isVElement, + isVLine, +} from './guard.js'; +import { calculateTextLength, getTextNodesFromElement } from './text.js'; + +export function nativePointToTextPoint( + node: unknown, + offset: number +): TextPoint | null { + if (isNativeTextInVText(node)) { + return [node, offset]; + } + + if (isVElement(node)) { + const texts = getTextNodesFromElement(node); + const vElement = texts[0].parentElement?.closest('[data-v-element="true"]'); + + if ( + texts.length === 1 && + vElement instanceof HTMLElement && + vElement.dataset.vEmbed === 'true' + ) { + return [texts[0], 0]; + } + + if (texts.length > 0) { + return texts[offset] ? [texts[offset], 0] : null; + } + } + + if (isVLine(node) || isInlineRoot(node)) { + return getTextPointRoughlyFromElementByOffset(node, offset, true); + } + + if (!(node instanceof Node)) { + return null; + } + + const vNodes = getVNodesFromNode(node); + + if (vNodes) { + return getTextPointFromVNodes(vNodes, node, offset); + } + + return null; +} + +export function textPointToDomPoint( + text: Text, + offset: number, + rootElement: HTMLElement +): DomPoint | null { + if (rootElement.dataset.vRoot !== 'true') { + throw new BlockSuiteError( + ErrorCode.InlineEditorError, + 'textRangeToDomPoint should be called with editor root element' + ); + } + + if (!rootElement.contains(text)) return null; + + const texts = getTextNodesFromElement(rootElement); + if (texts.length === 0) return null; + + const goalIndex = texts.indexOf(text); + let index = 0; + for (const text of texts.slice(0, goalIndex)) { + index += calculateTextLength(text); + } + + if (text.wholeText !== ZERO_WIDTH_SPACE) { + index += offset; + } + + const textParentElement = text.parentElement; + if (!textParentElement) { + throw new BlockSuiteError( + ErrorCode.InlineEditorError, + 'text element parent not found' + ); + } + + const lineElement = textParentElement.closest('v-line'); + + if (!lineElement) { + throw new BlockSuiteError( + ErrorCode.InlineEditorError, + 'line element not found' + ); + } + + const lineIndex = Array.from(rootElement.querySelectorAll('v-line')).indexOf( + lineElement + ); + + return { text, index: index + lineIndex }; +} + +function getVNodesFromNode(node: Node): VElement[] | VLine[] | null { + const vLine = node.parentElement?.closest('v-line'); + + if (vLine) { + return Array.from(vLine.querySelectorAll('v-element')); + } + + const container = + node instanceof Element + ? node.closest(`[${INLINE_ROOT_ATTR}]`) + : node.parentElement?.closest(`[${INLINE_ROOT_ATTR}]`); + + if (container) { + return Array.from(container.querySelectorAll('v-line')); + } + + return null; +} + +function getTextPointFromVNodes( + vNodes: VLine[] | VElement[], + node: Node, + offset: number +): TextPoint | null { + const first = vNodes[0]; + for (let i = 0; i < vNodes.length; i++) { + const vNode = vNodes[i]; + + if (i === 0 && AFollowedByB(node, vNode)) { + return getTextPointRoughlyFromElementByOffset(first, offset, true); + } + + if (AInsideB(node, vNode)) { + return getTextPointRoughlyFromElementByOffset(first, offset, false); + } + + if (i === vNodes.length - 1 && APrecededByB(node, vNode)) { + return getTextPointRoughlyFromElement(vNode); + } + + if ( + i < vNodes.length - 1 && + APrecededByB(node, vNode) && + AFollowedByB(node, vNodes[i + 1]) + ) { + return getTextPointRoughlyFromElement(vNode); + } + } + + return null; +} + +function getTextPointRoughlyFromElement(element: Element): TextPoint | null { + const texts = getTextNodesFromElement(element); + if (texts.length === 0) return null; + const text = texts[texts.length - 1]; + return [text, calculateTextLength(text)]; +} + +function getTextPointRoughlyFromElementByOffset( + element: Element, + offset: number, + fromStart: boolean +): TextPoint | null { + const texts = getTextNodesFromElement(element); + if (texts.length === 0) return null; + const text = fromStart ? texts[0] : texts[texts.length - 1]; + return [text, offset === 0 ? offset : text.length]; +} + +function AInsideB(a: Node, b: Node): boolean { + return ( + b.compareDocumentPosition(a) === Node.DOCUMENT_POSITION_CONTAINED_BY || + b.compareDocumentPosition(a) === + (Node.DOCUMENT_POSITION_CONTAINED_BY | Node.DOCUMENT_POSITION_FOLLOWING) + ); +} + +function AFollowedByB(a: Node, b: Node): boolean { + return a.compareDocumentPosition(b) === Node.DOCUMENT_POSITION_FOLLOWING; +} + +function APrecededByB(a: Node, b: Node): boolean { + return a.compareDocumentPosition(b) === Node.DOCUMENT_POSITION_PRECEDING; +} diff --git a/blocksuite/framework/inline/src/utils/query.ts b/blocksuite/framework/inline/src/utils/query.ts new file mode 100644 index 0000000000..8ced258977 --- /dev/null +++ b/blocksuite/framework/inline/src/utils/query.ts @@ -0,0 +1,20 @@ +import { INLINE_ROOT_ATTR } from '../consts.js'; +import type { InlineEditor, InlineRootElement } from '../inline-editor.js'; + +export function getInlineEditorInsideRoot( + element: Element +): InlineEditor | null { + const rootElement = element.closest( + `[${INLINE_ROOT_ATTR}]` + ) as InlineRootElement; + if (!rootElement) { + console.error('element must be inside a v-root'); + return null; + } + const inlineEditor = rootElement.inlineEditor; + if (!inlineEditor) { + console.error('element must be inside a v-root with inline-editor'); + return null; + } + return inlineEditor; +} diff --git a/blocksuite/framework/inline/src/utils/range-conversion.ts b/blocksuite/framework/inline/src/utils/range-conversion.ts new file mode 100644 index 0000000000..02e647efc1 --- /dev/null +++ b/blocksuite/framework/inline/src/utils/range-conversion.ts @@ -0,0 +1,368 @@ +import { BlockSuiteError, ErrorCode } from '@blocksuite/global/exceptions'; +import type * as Y from 'yjs'; + +import { VElement } from '../components/v-element.js'; +import type { InlineRange } from '../types.js'; +import { isInEmbedElement } from './embed.js'; +import { + nativePointToTextPoint, + textPointToDomPoint, +} from './point-conversion.js'; +import { calculateTextLength, getTextNodesFromElement } from './text.js'; + +type InlineRangeRunnerContext = { + rootElement: HTMLElement; + range: Range; + yText: Y.Text; + startNode: Node | null; + startOffset: number; + startText: Text; + startTextOffset: number; + endNode: Node | null; + endOffset: number; + endText: Text; + endTextOffset: number; +}; + +type Predict = (context: InlineRangeRunnerContext) => boolean; +type Handler = (context: InlineRangeRunnerContext) => InlineRange | null; + +const rangeHasAnchorAndFocus: Predict = ({ + rootElement, + startText, + endText, +}) => { + return rootElement.contains(startText) && rootElement.contains(endText); +}; + +const rangeHasAnchorAndFocusHandler: Handler = ({ + rootElement, + startText, + endText, + startTextOffset, + endTextOffset, +}) => { + const anchorDomPoint = textPointToDomPoint( + startText, + startTextOffset, + rootElement + ); + const focusDomPoint = textPointToDomPoint( + endText, + endTextOffset, + rootElement + ); + + if (!anchorDomPoint || !focusDomPoint) { + return null; + } + + return { + index: Math.min(anchorDomPoint.index, focusDomPoint.index), + length: Math.abs(anchorDomPoint.index - focusDomPoint.index), + }; +}; + +const rangeOnlyHasFocus: Predict = ({ rootElement, startText, endText }) => { + return !rootElement.contains(startText) && rootElement.contains(endText); +}; + +const rangeOnlyHasFocusHandler: Handler = ({ + rootElement, + endText, + endTextOffset, +}) => { + const focusDomPoint = textPointToDomPoint( + endText, + endTextOffset, + rootElement + ); + + if (!focusDomPoint) { + return null; + } + + return { + index: 0, + length: focusDomPoint.index, + }; +}; + +const rangeOnlyHasAnchor: Predict = ({ rootElement, startText, endText }) => { + return rootElement.contains(startText) && !rootElement.contains(endText); +}; + +const rangeOnlyHasAnchorHandler: Handler = ({ + yText, + rootElement, + startText, + startTextOffset, +}) => { + const startDomPoint = textPointToDomPoint( + startText, + startTextOffset, + rootElement + ); + + if (!startDomPoint) { + return null; + } + + return { + index: startDomPoint.index, + length: yText.length - startDomPoint.index, + }; +}; + +const rangeHasNoAnchorAndFocus: Predict = ({ + rootElement, + startText, + endText, + range, +}) => { + return ( + !rootElement.contains(startText) && + !rootElement.contains(endText) && + range.intersectsNode(rootElement) + ); +}; + +const rangeHasNoAnchorAndFocusHandler: Handler = ({ yText }) => { + return { + index: 0, + length: yText.length, + }; +}; + +const buildContext = ( + range: Range, + rootElement: HTMLElement, + yText: Y.Text +): InlineRangeRunnerContext | null => { + const { startContainer, startOffset, endContainer, endOffset } = range; + + const startTextPoint = nativePointToTextPoint(startContainer, startOffset); + const endTextPoint = nativePointToTextPoint(endContainer, endOffset); + + if (!startTextPoint || !endTextPoint) { + return null; + } + + const [startText, startTextOffset] = startTextPoint; + const [endText, endTextOffset] = endTextPoint; + + return { + rootElement, + range, + yText, + startNode: startContainer, + startOffset, + endNode: endContainer, + endOffset, + startText, + startTextOffset, + endText, + endTextOffset, + }; +}; + +/** + * calculate the inline range from dom selection for **this Editor** + * there are three cases when the inline range of this Editor is not null: + * (In the following, "|" mean anchor and focus, each line is a separate Editor) + * 1. anchor and focus are in this Editor + * aaaaaa + * b|bbbb|b + * cccccc + * the inline range of second Editor is {index: 1, length: 4}, the others are null + * 2. anchor and focus one in this Editor, one in another Editor + * aaa|aaa aaaaaa + * bbbbb|b or bbbbb|b + * cccccc cc|cccc + * 2.1 + * the inline range of first Editor is {index: 3, length: 3}, the second is {index: 0, length: 5}, + * the third is null + * 2.2 + * the inline range of first Editor is null, the second is {index: 5, length: 1}, + * the third is {index: 0, length: 2} + * 3. anchor and focus are in another Editor + * aa|aaaa + * bbbbbb + * cccc|cc + * the inline range of first Editor is {index: 2, length: 4}, + * the second is {index: 0, length: 6}, the third is {index: 0, length: 4} + */ +export function domRangeToInlineRange( + range: Range, + rootElement: HTMLElement, + yText: Y.Text +): InlineRange | null { + const context = buildContext(range, rootElement, yText); + + if (!context) return null; + + // handle embed + if ( + context.startNode && + context.startNode === context.endNode && + isInEmbedElement(context.startNode) + ) { + const anchorDomPoint = textPointToDomPoint( + context.startText, + context.startTextOffset, + rootElement + ); + + if (anchorDomPoint) { + return { + index: anchorDomPoint.index, + length: 1, + }; + } + } + + // case 1 + if (rangeHasAnchorAndFocus(context)) { + return rangeHasAnchorAndFocusHandler(context); + } + + // case 2.1 + if (rangeOnlyHasFocus(context)) { + return rangeOnlyHasFocusHandler(context); + } + + // case 2.2 + if (rangeOnlyHasAnchor(context)) { + return rangeOnlyHasAnchorHandler(context); + } + + // case 3 + if (rangeHasNoAnchorAndFocus(context)) { + return rangeHasNoAnchorAndFocusHandler(context); + } + + return null; +} + +/** + * calculate the dom selection from inline range for **this Editor** + */ +export function inlineRangeToDomRange( + rootElement: HTMLElement, + inlineRange: InlineRange +): Range | null { + const lineElements = Array.from(rootElement.querySelectorAll('v-line')); + + // calculate anchorNode and focusNode + let startText: Text | null = null; + let endText: Text | null = null; + let anchorOffset = 0; + let focusOffset = 0; + let index = 0; + + // eslint-disable-next-line @typescript-eslint/prefer-for-of + for (let i = 0; i < lineElements.length; i++) { + if (startText && endText) { + break; + } + + const texts = getTextNodesFromElement(lineElements[i]); + if (texts.length === 0) { + return null; + } + + for (const text of texts) { + const textLength = calculateTextLength(text); + + if (!startText && index + textLength >= inlineRange.index) { + startText = text; + anchorOffset = inlineRange.index - index; + } + if ( + !endText && + index + textLength >= inlineRange.index + inlineRange.length + ) { + endText = text; + focusOffset = inlineRange.index + inlineRange.length - index; + } + + if (startText && endText) { + break; + } + + index += textLength; + } + + // the one because of the line break + index += 1; + } + + if (!startText || !endText) { + return null; + } + + if (isInEmbedElement(startText)) { + const anchorVElement = startText.parentElement?.closest('v-element'); + if (!anchorVElement) { + throw new BlockSuiteError( + ErrorCode.InlineEditorError, + 'failed to find vElement for a text note in an embed element' + ); + } + const nextSibling = anchorVElement.nextElementSibling; + if (!nextSibling) { + throw new BlockSuiteError( + ErrorCode.InlineEditorError, + 'failed to find nextSibling sibling of an embed element' + ); + } + + const texts = getTextNodesFromElement(nextSibling); + if (texts.length === 0) { + throw new BlockSuiteError( + ErrorCode.InlineEditorError, + 'text node in v-text not found' + ); + } + if (nextSibling instanceof VElement) { + startText = texts[texts.length - 1]; + anchorOffset = calculateTextLength(startText); + } else { + // nextSibling is a gap + startText = texts[0]; + anchorOffset = 0; + } + } + if (isInEmbedElement(endText)) { + const focusVElement = endText.parentElement?.closest('v-element'); + if (!focusVElement) { + throw new BlockSuiteError( + ErrorCode.InlineEditorError, + 'failed to find vElement for a text note in an embed element' + ); + } + const nextSibling = focusVElement.nextElementSibling; + if (!nextSibling) { + throw new BlockSuiteError( + ErrorCode.InlineEditorError, + 'failed to find nextSibling sibling of an embed element' + ); + } + + const texts = getTextNodesFromElement(nextSibling); + if (texts.length === 0) { + throw new BlockSuiteError( + ErrorCode.InlineEditorError, + 'text node in v-text not found' + ); + } + endText = texts[0]; + focusOffset = 0; + } + + const range = document.createRange(); + range.setStart(startText, anchorOffset); + range.setEnd(endText, focusOffset); + + return range; +} diff --git a/blocksuite/framework/inline/src/utils/renderer.ts b/blocksuite/framework/inline/src/utils/renderer.ts new file mode 100644 index 0000000000..4f1d2bf0e6 --- /dev/null +++ b/blocksuite/framework/inline/src/utils/renderer.ts @@ -0,0 +1,20 @@ +import { html, type TemplateResult } from 'lit'; + +import type { DeltaInsert } from '../types.js'; +import type { BaseTextAttributes } from './base-attributes.js'; + +export function renderElement<TextAttributes extends BaseTextAttributes>( + delta: DeltaInsert<TextAttributes>, + parseAttributes: ( + textAttributes?: TextAttributes + ) => TextAttributes | undefined, + selected: boolean +): TemplateResult<1> { + return html`<v-element + .selected=${selected} + .delta=${{ + insert: delta.insert, + attributes: parseAttributes(delta.attributes), + }} + ></v-element>`; +} diff --git a/blocksuite/framework/inline/src/utils/text.ts b/blocksuite/framework/inline/src/utils/text.ts new file mode 100644 index 0000000000..edbeedcc77 --- /dev/null +++ b/blocksuite/framework/inline/src/utils/text.ts @@ -0,0 +1,25 @@ +import { ZERO_WIDTH_SPACE } from '../consts.js'; + +export function calculateTextLength(text: Text): number { + if (text.wholeText === ZERO_WIDTH_SPACE) { + return 0; + } else { + return text.wholeText.length; + } +} + +export function getTextNodesFromElement(element: Element): Text[] { + const textSpanElements = Array.from( + element.querySelectorAll('[data-v-text="true"]') + ); + const textNodes = textSpanElements.flatMap(textSpanElement => { + const textNode = Array.from(textSpanElement.childNodes).find( + (node): node is Text => node instanceof Text + ); + if (!textNode) return []; + + return textNode; + }); + + return textNodes; +} diff --git a/blocksuite/framework/inline/src/utils/transform-input.ts b/blocksuite/framework/inline/src/utils/transform-input.ts new file mode 100644 index 0000000000..41bd3dc2cd --- /dev/null +++ b/blocksuite/framework/inline/src/utils/transform-input.ts @@ -0,0 +1,77 @@ +import type { InlineEditor } from '../inline-editor.js'; +import type { InlineRange } from '../types.js'; +import type { BaseTextAttributes } from './base-attributes.js'; + +function handleInsertText<TextAttributes extends BaseTextAttributes>( + inlineRange: InlineRange, + data: string | null, + editor: InlineEditor, + attributes: TextAttributes +) { + if (!data) return; + editor.insertText(inlineRange, data, attributes); + editor.setInlineRange({ + index: inlineRange.index + data.length, + length: 0, + }); +} + +function handleInsertReplacementText<TextAttributes extends BaseTextAttributes>( + inlineRange: InlineRange, + data: string | null, + editor: InlineEditor, + attributes: TextAttributes +) { + editor.getDeltasByInlineRange(inlineRange).forEach(deltaEntry => { + attributes = { ...deltaEntry[0].attributes, ...attributes }; + }); + if (data) { + editor.insertText(inlineRange, data, attributes); + editor.setInlineRange({ + index: inlineRange.index + data.length, + length: 0, + }); + } +} + +function handleInsertParagraph(inlineRange: InlineRange, editor: InlineEditor) { + editor.insertLineBreak(inlineRange); + editor.setInlineRange({ + index: inlineRange.index + 1, + length: 0, + }); +} + +function handleDelete(inlineRange: InlineRange, editor: InlineEditor) { + editor.deleteText(inlineRange); + editor.setInlineRange({ + index: inlineRange.index, + length: 0, + }); +} + +export function transformInput<TextAttributes extends BaseTextAttributes>( + inputType: string, + data: string | null, + attributes: TextAttributes, + inlineRange: InlineRange, + editor: InlineEditor +) { + if (!editor.isValidInlineRange(inlineRange)) return; + + if (inputType === 'insertText') { + handleInsertText(inlineRange, data, editor, attributes); + } else if ( + inputType === 'insertParagraph' || + inputType === 'insertLineBreak' + ) { + handleInsertParagraph(inlineRange, editor); + } else if (inputType.startsWith('delete')) { + handleDelete(inlineRange, editor); + } else if (inputType === 'insertReplacementText') { + // Spell Checker + handleInsertReplacementText(inlineRange, data, editor, attributes); + } else { + return; + } +} diff --git a/blocksuite/framework/inline/tsconfig.json b/blocksuite/framework/inline/tsconfig.json new file mode 100644 index 0000000000..043859522f --- /dev/null +++ b/blocksuite/framework/inline/tsconfig.json @@ -0,0 +1,14 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "rootDir": "./src/", + "outDir": "./dist/", + "noEmit": false + }, + "include": ["./src"], + "references": [ + { + "path": "../global" + } + ] +} diff --git a/blocksuite/framework/inline/typedoc.json b/blocksuite/framework/inline/typedoc.json new file mode 100644 index 0000000000..101e923dba --- /dev/null +++ b/blocksuite/framework/inline/typedoc.json @@ -0,0 +1,4 @@ +{ + "extends": ["../../../typedoc.base.json"], + "entryPoints": ["src/index.ts"] +} diff --git a/blocksuite/framework/inline/vitest.config.ts b/blocksuite/framework/inline/vitest.config.ts new file mode 100644 index 0000000000..e9d0ef036a --- /dev/null +++ b/blocksuite/framework/inline/vitest.config.ts @@ -0,0 +1,28 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + esbuild: { + target: 'es2018', + }, + test: { + include: ['src/__tests__/**/*.unit.spec.ts'], + testTimeout: 500, + coverage: { + provider: 'istanbul', // or 'c8' + reporter: ['lcov'], + reportsDirectory: '../../../.coverage/inline', + }, + /** + * 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); + }, + }, +}); diff --git a/blocksuite/framework/store/README.md b/blocksuite/framework/store/README.md new file mode 100644 index 0000000000..f8ee250a57 --- /dev/null +++ b/blocksuite/framework/store/README.md @@ -0,0 +1,7 @@ +# `@blocksuite/store` + +BlockSuite data store built for general purpose state management. Used in [AFFiNE](https://affine.pro/). + +## Documentation + +Checkout [blocksuite.io](https://blocksuite.io/) for comprehensive documentation. diff --git a/blocksuite/framework/store/package.json b/blocksuite/framework/store/package.json new file mode 100644 index 0000000000..00064eac51 --- /dev/null +++ b/blocksuite/framework/store/package.json @@ -0,0 +1,53 @@ +{ + "name": "@blocksuite/store", + "description": "BlockSuite data store built for general purpose state management.", + "type": "module", + "scripts": { + "build": "tsc", + "test:unit": "nx vite:test --run", + "test:unit:coverage": "nx vite:test --run --coverage", + "test:unit:ui": "nx vite:test --ui", + "test": "yarn test:unit" + }, + "sideEffects": false, + "keywords": [], + "author": "toeverything", + "license": "MIT", + "dependencies": { + "@blocksuite/global": "workspace:*", + "@blocksuite/inline": "workspace:*", + "@blocksuite/sync": "workspace:*", + "@preact/signals-core": "^1.8.0", + "@types/flexsearch": "^0.7.6", + "@types/lodash.ismatch": "^4.4.9", + "file-type": "^19.5.0", + "flexsearch": "0.7.43", + "lib0": "^0.2.97", + "lodash.clonedeep": "^4.5.0", + "lodash.ismatch": "^4.4.0", + "lodash.merge": "^4.6.2", + "minimatch": "^10.0.1", + "nanoid": "^5.0.7", + "y-protocols": "^1.0.6", + "zod": "^3.23.8" + }, + "devDependencies": { + "@types/lodash.clonedeep": "^4.5.9", + "@types/lodash.merge": "^4.6.9", + "lit": "^3.2.0", + "yjs": "patch:yjs@npm%3A13.6.18#~/.yarn/patches/yjs-npm-13.6.18-ad0d5f7c43.patch" + }, + "peerDependencies": { + "yjs": "*" + }, + "exports": { + ".": "./src/index.ts" + }, + "files": [ + "src", + "dist", + "!src/__tests__", + "!dist/__tests__", + "shim.d.ts" + ] +} diff --git a/blocksuite/framework/store/shim.d.ts b/blocksuite/framework/store/shim.d.ts new file mode 100644 index 0000000000..0498b36afc --- /dev/null +++ b/blocksuite/framework/store/shim.d.ts @@ -0,0 +1,19 @@ +declare module 'y-protocols/awareness.js' { + import { Awareness as _Awareness } from 'y-protocols/awareness'; + type UnknownRecord = Record<string, unknown>; + export class Awareness< + State extends UnknownRecord = UnknownRecord, + > extends _Awareness { + constructor<State extends UnknownRecord = UnknownRecord>( + doc: Y.Doc + ): Awareness<State>; + + getLocalState(): State; + getStates(): Map<number, State>; + setLocalState(state: State): void; + setLocalStateField<Field extends keyof State>( + field: Field, + value: State[Field] + ): void; + } +} diff --git a/blocksuite/framework/store/src/__tests__/__snapshots__/transformer.unit.spec.ts.snap b/blocksuite/framework/store/src/__tests__/__snapshots__/transformer.unit.spec.ts.snap new file mode 100644 index 0000000000..40619e2ef9 --- /dev/null +++ b/blocksuite/framework/store/src/__tests__/__snapshots__/transformer.unit.spec.ts.snap @@ -0,0 +1,58 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`model to snapshot 1`] = ` +{ + "flavour": "page", + "id": "0", + "props": { + "count": 3, + "items": [ + { + "content": { + "$blocksuite:internal:text$": true, + "delta": [ + { + "insert": "item 1", + }, + ], + }, + "id": 0, + }, + { + "content": { + "$blocksuite:internal:text$": true, + "delta": [ + { + "insert": "item 2", + }, + ], + }, + "id": 1, + }, + { + "content": { + "$blocksuite:internal:text$": true, + "delta": [ + { + "insert": "item 3", + }, + ], + }, + "id": 2, + }, + ], + "style": { + "color": "red", + }, + "title": { + "$blocksuite:internal:text$": true, + "delta": [ + { + "insert": "doc title", + }, + ], + }, + }, + "version": 1, +} +`; diff --git a/blocksuite/framework/store/src/__tests__/assets.unit.spec.ts b/blocksuite/framework/store/src/__tests__/assets.unit.spec.ts new file mode 100644 index 0000000000..7f7a86de4d --- /dev/null +++ b/blocksuite/framework/store/src/__tests__/assets.unit.spec.ts @@ -0,0 +1,77 @@ +import { BlockSuiteError } from '@blocksuite/global/exceptions'; +import { describe, expect, test } from 'vitest'; + +import { getAssetName } from '../adapter/assets.js'; + +describe('getAssetName', () => { + test('should return the name if it exists', () => { + const assets = new Map<string, Blob>([ + ['blobId', new File([], 'image.png', { type: 'image/png' })], + ['blobId2', new File([], 'image', { type: 'image/png' })], + // inconsistent name with type + ['blobId3', new File([], 'image.jpg', { type: 'image/png' })], + // empty name + ['blobId4', new File([], '', { type: 'image/png' })], + ]); + expect(getAssetName(assets, 'blobId')).toBe('image.png'); + expect(getAssetName(assets, 'blobId2')).toBe('image.png'); + // respect the original name + expect(getAssetName(assets, 'blobId3')).toBe('image.jpg'); + expect(getAssetName(assets, 'blobId4')).toBe('blobId4.png'); + }); + + test('should return blobId with extension if name does not exist', () => { + const assets = new Map<string, Blob>([ + ['blobId', new Blob([], { type: 'image/jpeg' })], + ]); + const result = getAssetName(assets, 'blobId'); + expect(result).toBe('blobId.jpeg'); + }); + + test('should return the name if it exists but type is empty', () => { + const assets = new Map<string, Blob>([ + ['blobId', new File([], 'document.test', { type: '' })], + ]); + const result = getAssetName(assets, 'blobId'); + expect(result).toBe('document.test'); + }); + + test('should return the original name even not ext found', () => { + const assets = new Map<string, Blob>([['blobId', new File([], 'blob.')]]); + const result = getAssetName(assets, 'blobId'); + expect(result).toBe('blob.'); + }); + + test('should return blobId with "blob" extension if type is empty', () => { + const assets = new Map<string, Blob>([ + ['blobId', new Blob([])], + ['blobId2', new Blob([], { type: '' })], + ]); + expect(getAssetName(assets, 'blobId')).toBe('blobId.blob'); + expect(getAssetName(assets, 'blobId2')).toBe('blobId2.blob'); + }); + + test('should return blobId with last part of mime type if extension is not found', () => { + const assets = new Map<string, Blob>([ + ['blobId', new Blob([], { type: 'application/unknown' })], + ]); + const result = getAssetName(assets, 'blobId'); + expect(result).toBe('blobId.unknown'); + }); + + test('should return blobId with bin if type is octet-stream', () => { + const assets = new Map<string, Blob>([ + ['blobId', new Blob([], { type: 'application/octet-stream' })], + ]); + const result = getAssetName(assets, 'blobId'); + expect(result).toBe('blobId.bin'); + }); + + test('should throw BlockSuiteError if blob is not found', () => { + const assets = new Map<string, Blob>(); + expect(() => getAssetName(assets, 'blobId')).toThrow(BlockSuiteError); + expect(() => getAssetName(assets, 'blobId')).toThrowError( + 'blob not found for blobId: blobId' + ); + }); +}); diff --git a/blocksuite/framework/store/src/__tests__/block.unit.spec.ts b/blocksuite/framework/store/src/__tests__/block.unit.spec.ts new file mode 100644 index 0000000000..751880beb4 --- /dev/null +++ b/blocksuite/framework/store/src/__tests__/block.unit.spec.ts @@ -0,0 +1,249 @@ +import { computed, effect } from '@preact/signals-core'; +import { describe, expect, test, vi } from 'vitest'; +import * as Y from 'yjs'; + +import { + defineBlockSchema, + internalPrimitives, + Schema, + type SchemaToModel, +} from '../schema/index.js'; +import { Block, type YBlock } from '../store/doc/block/index.js'; +import { DocCollection, IdGeneratorType } from '../store/index.js'; + +const pageSchema = defineBlockSchema({ + flavour: 'page', + props: internal => ({ + title: internal.Text(), + count: 0, + toggle: false, + style: {} as Record<string, unknown>, + boxed: internal.Boxed(new Y.Map()), + }), + metadata: { + role: 'root', + version: 1, + }, +}); +type RootModel = SchemaToModel<typeof pageSchema>; + +function createTestOptions() { + const idGenerator = IdGeneratorType.AutoIncrement; + const schema = new Schema(); + schema.register([pageSchema]); + return { id: 'test-collection', idGenerator, schema }; +} + +const defaultDocId = 'doc:home'; +function createTestDoc(docId = defaultDocId) { + const options = createTestOptions(); + const collection = new DocCollection(options); + collection.meta.initialize(); + const doc = collection.createDoc({ id: docId }); + doc.load(); + return doc; +} + +test('init block without props should add default props', () => { + const doc = createTestDoc(); + const yDoc = new Y.Doc(); + const yBlock = yDoc.getMap('yBlock') as YBlock; + yBlock.set('sys:id', '0'); + yBlock.set('sys:flavour', 'page'); + yBlock.set('sys:children', new Y.Array()); + + const block = new Block(doc.schema, yBlock, doc); + const model = block.model as RootModel; + + expect(yBlock.get('prop:count')).toBe(0); + expect(model.count).toBe(0); + expect(model.style).toEqual({}); +}); + +describe('block model should has signal props', () => { + test('atom', () => { + const doc = createTestDoc(); + const yDoc = new Y.Doc(); + const yBlock = yDoc.getMap('yBlock') as YBlock; + yBlock.set('sys:id', '0'); + yBlock.set('sys:flavour', 'page'); + yBlock.set('sys:children', new Y.Array()); + + const block = new Block(doc.schema, yBlock, doc); + const model = block.model as RootModel; + + const isOdd = computed(() => model.count$.value % 2 === 1); + + expect(model.count$.value).toBe(0); + expect(isOdd.peek()).toBe(false); + + // set prop + model.count = 1; + expect(model.count$.value).toBe(1); + expect(isOdd.peek()).toBe(true); + expect(yBlock.get('prop:count')).toBe(1); + + // set signal + model.count$.value = 2; + expect(model.count).toBe(2); + expect(isOdd.peek()).toBe(false); + expect(yBlock.get('prop:count')).toBe(2); + + // set prop + yBlock.set('prop:count', 3); + expect(model.count).toBe(3); + expect(model.count$.value).toBe(3); + expect(isOdd.peek()).toBe(true); + + const toggleEffect = vi.fn(); + effect(() => { + toggleEffect(model.toggle$.value); + }); + expect(toggleEffect).toHaveBeenCalledTimes(1); + const runToggle = () => { + const next = !model.toggle; + model.toggle = next; + expect(model.toggle$.value).toBe(next); + }; + const times = 10; + for (let i = 0; i < times; i++) { + runToggle(); + } + expect(toggleEffect).toHaveBeenCalledTimes(times + 1); + const runToggleReverse = () => { + const next = !model.toggle; + model.toggle$.value = next; + expect(model.toggle).toBe(next); + }; + for (let i = 0; i < times; i++) { + runToggleReverse(); + } + expect(toggleEffect).toHaveBeenCalledTimes(times * 2 + 1); + }); + + test('nested', () => { + const doc = createTestDoc(); + const yDoc = new Y.Doc(); + const yBlock = yDoc.getMap('yBlock') as YBlock; + yBlock.set('sys:id', '0'); + yBlock.set('sys:flavour', 'page'); + yBlock.set('sys:children', new Y.Array()); + + const block = new Block(doc.schema, yBlock, doc); + const model = block.model as RootModel; + expect(model.style).toEqual({}); + + model.style = { color: 'red' }; + expect((yBlock.get('prop:style') as Y.Map<unknown>).toJSON()).toEqual({ + color: 'red', + }); + expect(model.style$.value).toEqual({ color: 'red' }); + + model.style.color = 'yellow'; + expect((yBlock.get('prop:style') as Y.Map<unknown>).toJSON()).toEqual({ + color: 'yellow', + }); + expect(model.style$.value).toEqual({ color: 'yellow' }); + + model.style$.value = { color: 'blue' }; + expect(model.style.color).toBe('blue'); + expect((yBlock.get('prop:style') as Y.Map<unknown>).toJSON()).toEqual({ + color: 'blue', + }); + + const map = new Y.Map(); + map.set('color', 'green'); + yBlock.set('prop:style', map); + expect(model.style.color).toBe('green'); + expect(model.style$.value).toEqual({ color: 'green' }); + }); + + test('with stash and pop', () => { + const doc = createTestDoc(); + const yDoc = new Y.Doc(); + const yBlock = yDoc.getMap('yBlock') as YBlock; + yBlock.set('sys:id', '0'); + yBlock.set('sys:flavour', 'page'); + yBlock.set('sys:children', new Y.Array()); + + const block = new Block(doc.schema, yBlock, doc); + const model = block.model as RootModel; + + expect(model.count).toBe(0); + model.stash('count'); + + model.count = 1; + expect(model.count$.value).toBe(1); + expect(yBlock.get('prop:count')).toBe(0); + + model.count$.value = 2; + expect(model.count).toBe(2); + expect(yBlock.get('prop:count')).toBe(0); + + model.pop('count'); + expect(yBlock.get('prop:count')).toBe(2); + expect(model.count).toBe(2); + expect(model.count$.value).toBe(2); + + model.stash('count'); + yBlock.set('prop:count', 3); + expect(model.count).toBe(3); + expect(model.count$.value).toBe(3); + + model.count$.value = 4; + expect(yBlock.get('prop:count')).toBe(3); + expect(model.count).toBe(4); + + model.pop('count'); + expect(yBlock.get('prop:count')).toBe(4); + }); +}); + +test('on change', () => { + const doc = createTestDoc(); + const yDoc = new Y.Doc(); + const yBlock = yDoc.getMap('yBlock') as YBlock; + yBlock.set('sys:id', '0'); + yBlock.set('sys:flavour', 'page'); + yBlock.set('sys:children', new Y.Array()); + + const onPropsUpdated = vi.fn(); + const block = new Block(doc.schema, yBlock, doc, { + onChange: onPropsUpdated, + }); + const model = block.model as RootModel; + + model.title = internalPrimitives.Text('abc'); + expect(onPropsUpdated).toHaveBeenCalledWith( + expect.anything(), + 'title', + expect.anything() + ); + expect(model.title$.value.toDelta()).toEqual([{ insert: 'abc' }]); + + onPropsUpdated.mockClear(); + + model.title.insert('d', 1); + expect(onPropsUpdated).toHaveBeenCalledWith( + expect.anything(), + 'title', + expect.anything() + ); + + expect(model.title$.value.toDelta()).toEqual([{ insert: 'adbc' }]); + + onPropsUpdated.mockClear(); + + model.boxed.getValue()!.set('foo', 0); + expect(onPropsUpdated).toHaveBeenCalledWith( + expect.anything(), + 'boxed', + expect.anything() + ); + expect(onPropsUpdated.mock.calls[0][2].toJSON().value).toMatchObject({ + foo: 0, + }); + expect(model.boxed$.value.getValue()!.toJSON()).toEqual({ + foo: 0, + }); +}); diff --git a/blocksuite/framework/store/src/__tests__/collection.unit.spec.ts b/blocksuite/framework/store/src/__tests__/collection.unit.spec.ts new file mode 100644 index 0000000000..8ae4cd0831 --- /dev/null +++ b/blocksuite/framework/store/src/__tests__/collection.unit.spec.ts @@ -0,0 +1,954 @@ +// checkout https://vitest.dev/guide/debugging.html for debugging tests + +import type { Slot } from '@blocksuite/global/utils'; +import { assert, beforeEach, describe, expect, it, vi } from 'vitest'; +import { applyUpdate, encodeStateAsUpdate } from 'yjs'; + +import { COLLECTION_VERSION, PAGE_VERSION } from '../consts.js'; +import type { BlockModel, BlockSchemaType, Doc } from '../index.js'; +import { DocCollection, IdGeneratorType, Schema } from '../index.js'; +import type { DocMeta } from '../store/index.js'; +import type { BlockSuiteDoc } from '../yjs/index.js'; +import { + NoteBlockSchema, + ParagraphBlockSchema, + RootBlockSchema, +} from './test-schema.js'; +import { assertExists } from './test-utils-dom.js'; + +export const BlockSchemas = [ + ParagraphBlockSchema, + RootBlockSchema, + NoteBlockSchema, +] as BlockSchemaType[]; + +function createTestOptions() { + const idGenerator = IdGeneratorType.AutoIncrement; + const schema = new Schema(); + schema.register(BlockSchemas); + return { id: 'test-collection', idGenerator, schema }; +} + +const defaultDocId = 'doc:home'; +const spaceId = defaultDocId; +const spaceMetaId = 'meta'; + +function serializCollection(doc: BlockSuiteDoc): Record<string, any> { + const spaces = {}; + doc.spaces.forEach((subDoc, key) => { + // @ts-expect-error FIXME: ts error + spaces[key] = subDoc.toJSON(); + }); + const json = doc.toJSON(); + delete json.spaces; + + return { + ...json, + spaces, + }; +} + +function waitOnce<T>(slot: Slot<T>) { + return new Promise<T>(resolve => slot.once(val => resolve(val))); +} + +function createRoot(doc: Doc) { + doc.addBlock('affine:page'); + if (!doc.root) throw new Error('root not found'); + return doc.root; +} + +function createTestDoc(docId = defaultDocId) { + const options = createTestOptions(); + const collection = new DocCollection(options); + collection.meta.initialize(); + const doc = collection.createDoc({ id: docId }); + doc.load(); + return doc; +} + +function requestIdleCallbackPolyfill( + callback: IdleRequestCallback, + options?: IdleRequestOptions +) { + const timeout = options?.timeout ?? 1000; + const start = Date.now(); + return setTimeout(function () { + callback({ + didTimeout: false, + timeRemaining: function () { + return Math.max(0, timeout - (Date.now() - start)); + }, + }); + }, timeout) as unknown as number; +} + +beforeEach(() => { + if (globalThis.requestIdleCallback === undefined) { + globalThis.requestIdleCallback = requestIdleCallbackPolyfill; + } +}); + +describe('basic', () => { + it('can init collection', () => { + const options = createTestOptions(); + const collection = new DocCollection(options); + collection.meta.initialize(); + assert.equal(collection.isEmpty, true); + + const doc = collection.createDoc({ id: 'doc:home' }); + doc.load(); + const actual = serializCollection(collection.doc); + const actualDoc = actual[spaceMetaId].pages[0] as DocMeta; + + assert.equal(collection.isEmpty, false); + assert.equal(typeof actualDoc.createDate, 'number'); + // @ts-expect-error FIXME: ts error + delete actualDoc.createDate; + + assert.deepEqual(actual, { + [spaceMetaId]: { + pages: [ + { + id: 'doc:home', + title: '', + tags: [], + }, + ], + workspaceVersion: COLLECTION_VERSION, + pageVersion: PAGE_VERSION, + blockVersions: { + 'affine:note': 1, + 'affine:page': 2, + 'affine:paragraph': 1, + }, + }, + spaces: { + [spaceId]: { + blocks: {}, + }, + }, + }); + }); + + it('init collection with custom id generator', () => { + const options = createTestOptions(); + let id = 100; + const collection = new DocCollection({ + ...options, + idGenerator: () => { + return String(id++); + }, + }); + collection.meta.initialize(); + { + const doc = collection.createDoc(); + assert.equal(doc.id, '100'); + } + { + const doc = collection.createDoc(); + assert.equal(doc.id, '101'); + } + }); + + it('doc ready lifecycle', () => { + const options = createTestOptions(); + const collection = new DocCollection(options); + collection.meta.initialize(); + const doc = collection.createDoc({ + id: 'space:0', + }); + + const readyCallback = vi.fn(); + const rootAddedCallback = vi.fn(); + doc.slots.ready.on(readyCallback); + doc.slots.rootAdded.on(rootAddedCallback); + + doc.load(() => { + expect(doc.ready).toBe(false); + const rootId = doc.addBlock('affine:page', { + title: new doc.Text(), + }); + expect(rootAddedCallback).toBeCalledTimes(1); + expect(doc.ready).toBe(false); + + doc.addBlock('affine:note', {}, rootId); + }); + + expect(doc.ready).toBe(true); + expect(readyCallback).toBeCalledTimes(1); + }); + + it('collection docs with yjs applyUpdate', () => { + const options = createTestOptions(); + const collection = new DocCollection(options); + collection.meta.initialize(); + const collection2 = new DocCollection(options); + const doc = collection.createDoc({ + id: 'space:0', + }); + doc.load(() => { + doc.addBlock('affine:page', { + title: new doc.Text(), + }); + }); + { + const subdocsTester = vi.fn(({ added }) => { + expect(added.size).toBe(1); + }); + // only apply root update + collection2.doc.once('subdocs', subdocsTester); + expect(subdocsTester).toBeCalledTimes(0); + expect(collection2.docs.size).toBe(0); + const update = encodeStateAsUpdate(collection.doc); + applyUpdate(collection2.doc, update); + expect(collection2.doc.toJSON()['spaces']).toEqual({ + 'space:0': { + blocks: {}, + }, + }); + expect(collection2.docs.size).toBe(1); + expect(subdocsTester).toBeCalledTimes(1); + } + { + // apply doc update + const update = encodeStateAsUpdate(doc.spaceDoc); + expect(collection2.docs.size).toBe(1); + const doc2 = collection2.getDoc('space:0'); + assertExists(doc2); + applyUpdate(doc2.spaceDoc, update); + expect(collection2.doc.toJSON()['spaces']).toEqual({ + 'space:0': { + blocks: { + '0': { + 'prop:count': 0, + 'prop:items': [], + 'prop:style': {}, + 'prop:title': '', + 'sys:children': [], + 'sys:flavour': 'affine:page', + 'sys:id': '0', + 'sys:version': 2, + }, + }, + }, + }); + const fn = vi.fn(({ loaded }) => { + expect(loaded.size).toBe(1); + }); + collection2.doc.once('subdocs', fn); + expect(fn).toBeCalledTimes(0); + doc2.load(); + expect(fn).toBeCalledTimes(1); + } + }); +}); + +describe('addBlock', () => { + it('can add single model', () => { + const doc = createTestDoc(); + doc.addBlock('affine:page', { + title: new doc.Text(), + }); + + assert.deepEqual(serializCollection(doc.rootDoc).spaces[spaceId].blocks, { + '0': { + 'prop:count': 0, + 'prop:items': [], + 'prop:style': {}, + 'prop:title': '', + 'sys:children': [], + 'sys:flavour': 'affine:page', + 'sys:id': '0', + 'sys:version': 2, + }, + }); + }); + + it('can add model with props', () => { + const doc = createTestDoc(); + doc.addBlock('affine:page', { title: new doc.Text('hello') }); + + assert.deepEqual(serializCollection(doc.rootDoc).spaces[spaceId].blocks, { + '0': { + 'prop:count': 0, + 'prop:items': [], + 'prop:style': {}, + 'sys:children': [], + 'sys:flavour': 'affine:page', + 'sys:id': '0', + 'prop:title': 'hello', + 'sys:version': 2, + }, + }); + }); + + it('can add multi models', () => { + const doc = createTestDoc(); + const rootId = doc.addBlock('affine:page', { + title: new doc.Text(), + }); + const noteId = doc.addBlock('affine:note', {}, rootId); + doc.addBlock('affine:paragraph', {}, noteId); + doc.addBlocks( + [ + { flavour: 'affine:paragraph', blockProps: { type: 'h1' } }, + { flavour: 'affine:paragraph', blockProps: { type: 'h2' } }, + ], + noteId + ); + + assert.deepEqual(serializCollection(doc.rootDoc).spaces[spaceId].blocks, { + '0': { + 'prop:count': 0, + 'prop:items': [], + 'prop:style': {}, + 'sys:children': ['1'], + 'sys:flavour': 'affine:page', + 'sys:id': '0', + 'prop:title': '', + 'sys:version': 2, + }, + '1': { + 'sys:children': ['2', '3', '4'], + 'sys:flavour': 'affine:note', + 'sys:id': '1', + 'sys:version': 1, + }, + '2': { + 'sys:children': [], + 'sys:flavour': 'affine:paragraph', + 'sys:id': '2', + 'prop:text': '', + 'prop:type': 'text', + 'sys:version': 1, + }, + '3': { + 'sys:children': [], + 'sys:flavour': 'affine:paragraph', + 'sys:id': '3', + 'prop:text': '', + 'prop:type': 'h1', + 'sys:version': 1, + }, + '4': { + 'sys:children': [], + 'sys:flavour': 'affine:paragraph', + 'sys:id': '4', + 'prop:text': '', + 'prop:type': 'h2', + 'sys:version': 1, + }, + }); + }); + + it('can observe slot events', async () => { + const doc = createTestDoc(); + + queueMicrotask(() => + doc.addBlock('affine:page', { + title: new doc.Text(), + }) + ); + const blockId = await waitOnce(doc.slots.rootAdded); + const block = doc.getBlockById(blockId) as BlockModel; + assert.equal(block.flavour, 'affine:page'); + }); + + it('can add block to root', async () => { + const doc = createTestDoc(); + + let noteId: string; + + queueMicrotask(() => { + const rootId = doc.addBlock('affine:page'); + noteId = doc.addBlock('affine:note', {}, rootId); + }); + await waitOnce(doc.slots.rootAdded); + const { root } = doc; + if (!root) throw new Error('root is null'); + + assert.equal(root.flavour, 'affine:page'); + + doc.addBlock('affine:paragraph', {}, noteId!); + assert.equal(root.children[0].flavour, 'affine:note'); + assert.equal(root.children[0].children[0].flavour, 'affine:paragraph'); + assert.equal(root.childMap.value.get('1'), 0); + + const serializedChildren = serializCollection(doc.rootDoc).spaces[spaceId] + .blocks['0']['sys:children']; + assert.deepEqual(serializedChildren, ['1']); + assert.equal(root.children[0].id, '1'); + }); + + it('can add and remove multi docs', async () => { + const options = createTestOptions(); + const collection = new DocCollection(options); + collection.meta.initialize(); + + const doc0 = collection.createDoc({ id: 'doc:home' }); + const doc1 = collection.createDoc({ id: 'space:doc1' }); + await Promise.all([doc0.load(), doc1.load()]); + assert.equal(collection.docs.size, 2); + + doc0.addBlock('affine:page', { + title: new doc0.Text(), + }); + collection.removeDoc(doc0.id); + + assert.equal(collection.docs.size, 1); + assert.equal( + serializCollection(doc0.rootDoc).spaces['doc:home'], + undefined + ); + + collection.removeDoc(doc1.id); + assert.equal(collection.docs.size, 0); + }); + + it('can remove doc that has not been loaded', () => { + const options = createTestOptions(); + const collection = new DocCollection(options); + collection.meta.initialize(); + + const doc0 = collection.createDoc({ id: 'doc:home' }); + + collection.removeDoc(doc0.id); + assert.equal(collection.docs.size, 0); + }); + + it('can set doc state', () => { + const options = createTestOptions(); + const collection = new DocCollection(options); + collection.meta.initialize(); + collection.createDoc({ id: 'doc:home' }); + + assert.deepEqual( + collection.meta.docMetas.map(({ id, title }) => ({ + id, + title, + })), + [ + { + id: 'doc:home', + title: '', + }, + ] + ); + + let called = false; + collection.meta.docMetaUpdated.on(() => { + called = true; + }); + + collection.setDocMeta('doc:home', { favorite: true }); + assert.deepEqual( + collection.meta.docMetas.map(({ id, title, favorite }) => ({ + id, + title, + favorite, + })), + [ + { + id: 'doc:home', + title: '', + favorite: true, + }, + ] + ); + assert.ok(called); + }); + + it('can set collection common meta fields', async () => { + const options = createTestOptions(); + const collection = new DocCollection(options); + + queueMicrotask(() => collection.meta.setName('hello')); + await waitOnce(collection.meta.commonFieldsUpdated); + assert.deepEqual(collection.meta.name, 'hello'); + + queueMicrotask(() => collection.meta.setAvatar('gengar.jpg')); + await waitOnce(collection.meta.commonFieldsUpdated); + assert.deepEqual(collection.meta.avatar, 'gengar.jpg'); + }); +}); + +describe('deleteBlock', () => { + it('delete children recursively by default', () => { + const doc = createTestDoc(); + + const rootId = doc.addBlock('affine:page', {}); + const noteId = doc.addBlock('affine:note', {}, rootId); + doc.addBlock('affine:paragraph', {}, noteId); + doc.addBlock('affine:paragraph', {}, noteId); + assert.deepEqual(serializCollection(doc.rootDoc).spaces[spaceId].blocks, { + '0': { + 'prop:count': 0, + 'prop:items': [], + 'prop:style': {}, + 'prop:title': '', + 'sys:children': ['1'], + 'sys:flavour': 'affine:page', + 'sys:id': '0', + 'sys:version': 2, + }, + '1': { + 'sys:children': ['2', '3'], + 'sys:flavour': 'affine:note', + 'sys:id': '1', + 'sys:version': 1, + }, + '2': { + 'prop:text': '', + 'prop:type': 'text', + 'sys:children': [], + 'sys:flavour': 'affine:paragraph', + 'sys:id': '2', + 'sys:version': 1, + }, + '3': { + 'prop:text': '', + 'prop:type': 'text', + 'sys:children': [], + 'sys:flavour': 'affine:paragraph', + 'sys:id': '3', + 'sys:version': 1, + }, + }); + + const deletedModel = doc.getBlockById('1') as BlockModel; + doc.deleteBlock(deletedModel); + + assert.deepEqual(serializCollection(doc.rootDoc).spaces[spaceId].blocks, { + '0': { + 'prop:count': 0, + 'prop:items': [], + 'prop:style': {}, + 'prop:title': '', + 'sys:children': [], + 'sys:flavour': 'affine:page', + 'sys:id': '0', + 'sys:version': 2, + }, + }); + }); + + it('bring children to parent', () => { + const doc = createTestDoc(); + + const rootId = doc.addBlock('affine:page', {}); + const noteId = doc.addBlock('affine:note', {}, rootId); + const p1 = doc.addBlock('affine:paragraph', {}, noteId); + doc.addBlock('affine:paragraph', {}, p1); + doc.addBlock('affine:paragraph', {}, p1); + + assert.deepEqual(serializCollection(doc.rootDoc).spaces[spaceId].blocks, { + '0': { + 'prop:count': 0, + 'prop:items': [], + 'prop:style': {}, + 'prop:title': '', + 'sys:children': ['1'], + 'sys:flavour': 'affine:page', + 'sys:id': '0', + 'sys:version': 2, + }, + '1': { + 'sys:children': ['2'], + 'sys:flavour': 'affine:note', + 'sys:id': '1', + 'sys:version': 1, + }, + '2': { + 'prop:text': '', + 'prop:type': 'text', + 'sys:children': ['3', '4'], + 'sys:flavour': 'affine:paragraph', + 'sys:id': '2', + 'sys:version': 1, + }, + '3': { + 'prop:text': '', + 'prop:type': 'text', + 'sys:children': [], + 'sys:flavour': 'affine:paragraph', + 'sys:id': '3', + 'sys:version': 1, + }, + '4': { + 'prop:text': '', + 'prop:type': 'text', + 'sys:children': [], + 'sys:flavour': 'affine:paragraph', + 'sys:id': '4', + 'sys:version': 1, + }, + }); + + const deletedModel = doc.getBlockById('2') as BlockModel; + const deletedModelParent = doc.getBlockById('1') as BlockModel; + doc.deleteBlock(deletedModel, { + bringChildrenTo: deletedModelParent, + }); + + assert.deepEqual(serializCollection(doc.rootDoc).spaces[spaceId].blocks, { + '0': { + 'prop:count': 0, + 'prop:items': [], + 'prop:style': {}, + 'prop:title': '', + 'sys:children': ['1'], + 'sys:flavour': 'affine:page', + 'sys:id': '0', + 'sys:version': 2, + }, + '1': { + 'sys:children': ['3', '4'], + 'sys:flavour': 'affine:note', + 'sys:id': '1', + 'sys:version': 1, + }, + '3': { + 'prop:text': '', + 'prop:type': 'text', + 'sys:children': [], + 'sys:flavour': 'affine:paragraph', + 'sys:id': '3', + 'sys:version': 1, + }, + '4': { + 'prop:text': '', + 'prop:type': 'text', + 'sys:children': [], + 'sys:flavour': 'affine:paragraph', + 'sys:id': '4', + 'sys:version': 1, + }, + }); + }); + + it('bring children to other block', () => { + const doc = createTestDoc(); + + const rootId = doc.addBlock('affine:page', {}); + const noteId = doc.addBlock('affine:note', {}, rootId); + const p1 = doc.addBlock('affine:paragraph', {}, noteId); + const p2 = doc.addBlock('affine:paragraph', {}, noteId); + doc.addBlock('affine:paragraph', {}, p1); + doc.addBlock('affine:paragraph', {}, p1); + doc.addBlock('affine:paragraph', {}, p2); + + assert.deepEqual(serializCollection(doc.rootDoc).spaces[spaceId].blocks, { + '0': { + 'prop:count': 0, + 'prop:items': [], + 'prop:style': {}, + 'prop:title': '', + 'sys:children': ['1'], + 'sys:flavour': 'affine:page', + 'sys:id': '0', + 'sys:version': 2, + }, + '1': { + 'sys:children': ['2', '3'], + 'sys:flavour': 'affine:note', + 'sys:id': '1', + 'sys:version': 1, + }, + '2': { + 'prop:text': '', + 'prop:type': 'text', + 'sys:children': ['4', '5'], + 'sys:flavour': 'affine:paragraph', + 'sys:id': '2', + 'sys:version': 1, + }, + '3': { + 'prop:text': '', + 'prop:type': 'text', + 'sys:children': ['6'], + 'sys:flavour': 'affine:paragraph', + 'sys:id': '3', + 'sys:version': 1, + }, + '4': { + 'prop:text': '', + 'prop:type': 'text', + 'sys:children': [], + 'sys:flavour': 'affine:paragraph', + 'sys:id': '4', + 'sys:version': 1, + }, + '5': { + 'prop:text': '', + 'prop:type': 'text', + 'sys:children': [], + 'sys:flavour': 'affine:paragraph', + 'sys:id': '5', + 'sys:version': 1, + }, + '6': { + 'prop:text': '', + 'prop:type': 'text', + 'sys:children': [], + 'sys:flavour': 'affine:paragraph', + 'sys:id': '6', + 'sys:version': 1, + }, + }); + + const deletedModel = doc.getBlockById('2') as BlockModel; + const moveToModel = doc.getBlockById('3') as BlockModel; + doc.deleteBlock(deletedModel, { + bringChildrenTo: moveToModel, + }); + + assert.deepEqual(serializCollection(doc.rootDoc).spaces[spaceId].blocks, { + '0': { + 'prop:count': 0, + 'prop:items': [], + 'prop:style': {}, + 'prop:title': '', + 'sys:children': ['1'], + 'sys:flavour': 'affine:page', + 'sys:id': '0', + 'sys:version': 2, + }, + '1': { + 'sys:children': ['3'], + 'sys:flavour': 'affine:note', + 'sys:id': '1', + 'sys:version': 1, + }, + '3': { + 'prop:text': '', + 'prop:type': 'text', + 'sys:children': ['6', '4', '5'], + 'sys:flavour': 'affine:paragraph', + 'sys:id': '3', + 'sys:version': 1, + }, + '4': { + 'prop:text': '', + 'prop:type': 'text', + 'sys:children': [], + 'sys:flavour': 'affine:paragraph', + 'sys:id': '4', + 'sys:version': 1, + }, + '5': { + 'prop:text': '', + 'prop:type': 'text', + 'sys:children': [], + 'sys:flavour': 'affine:paragraph', + 'sys:id': '5', + 'sys:version': 1, + }, + '6': { + 'prop:text': '', + 'prop:type': 'text', + 'sys:children': [], + 'sys:flavour': 'affine:paragraph', + 'sys:id': '6', + 'sys:version': 1, + }, + }); + }); + + it('can delete model with parent', () => { + const doc = createTestDoc(); + const rootModel = createRoot(doc); + const noteId = doc.addBlock('affine:note', {}, rootModel.id); + + doc.addBlock('affine:paragraph', {}, noteId); + + // before delete + assert.deepEqual(serializCollection(doc.rootDoc).spaces[spaceId].blocks, { + '0': { + 'prop:count': 0, + 'prop:items': [], + 'prop:style': {}, + 'prop:title': '', + 'sys:children': ['1'], + 'sys:flavour': 'affine:page', + 'sys:id': '0', + 'sys:version': 2, + }, + '1': { + 'sys:children': ['2'], + 'sys:flavour': 'affine:note', + 'sys:id': '1', + 'sys:version': 1, + }, + '2': { + 'sys:children': [], + 'sys:flavour': 'affine:paragraph', + 'sys:id': '2', + 'prop:text': '', + 'prop:type': 'text', + 'sys:version': 1, + }, + }); + + doc.deleteBlock(rootModel.children[0].children[0]); + + // after delete + assert.deepEqual(serializCollection(doc.rootDoc).spaces[spaceId].blocks, { + '0': { + 'prop:count': 0, + 'prop:items': [], + 'prop:style': {}, + 'prop:title': '', + 'sys:children': ['1'], + 'sys:flavour': 'affine:page', + 'sys:id': '0', + 'sys:version': 2, + }, + '1': { + 'sys:children': [], + 'sys:flavour': 'affine:note', + 'sys:id': '1', + 'sys:version': 1, + }, + }); + assert.equal(rootModel.children.length, 1); + }); +}); + +describe('getBlock', () => { + it('can get block by id', () => { + const doc = createTestDoc(); + const rootModel = createRoot(doc); + const noteId = doc.addBlock('affine:note', {}, rootModel.id); + + doc.addBlock('affine:paragraph', {}, noteId); + doc.addBlock('affine:paragraph', {}, noteId); + + const text = doc.getBlockById('3') as BlockModel; + assert.equal(text.flavour, 'affine:paragraph'); + assert.equal(rootModel.children[0].children.indexOf(text), 1); + + const invalid = doc.getBlockById('😅'); + assert.equal(invalid, null); + }); + + it('can get parent', () => { + const doc = createTestDoc(); + const rootModel = createRoot(doc); + const noteId = doc.addBlock('affine:note', {}, rootModel.id); + + doc.addBlock('affine:paragraph', {}, noteId); + doc.addBlock('affine:paragraph', {}, noteId); + + const result = doc.getParent( + rootModel.children[0].children[1] + ) as BlockModel; + assert.equal(result, rootModel.children[0]); + + const invalid = doc.getParent(rootModel); + assert.equal(invalid, null); + }); + + it('can get previous sibling', () => { + const doc = createTestDoc(); + const rootModel = createRoot(doc); + const noteId = doc.addBlock('affine:note', {}, rootModel.id); + + doc.addBlock('affine:paragraph', {}, noteId); + doc.addBlock('affine:paragraph', {}, noteId); + + const result = doc.getPrev(rootModel.children[0].children[1]) as BlockModel; + assert.equal(result, rootModel.children[0].children[0]); + + const invalid = doc.getPrev(rootModel.children[0].children[0]); + assert.equal(invalid, null); + }); +}); + +// Inline snapshot is not supported under describe.parallel config +describe('collection.exportJSX works', () => { + it('collection matches snapshot', () => { + const options = createTestOptions(); + const collection = new DocCollection(options); + collection.meta.initialize(); + const doc = collection.createDoc({ id: 'doc:home' }); + + doc.addBlock('affine:page', { title: new doc.Text('hello') }); + + expect(collection.exportJSX()).toMatchInlineSnapshot(` + <affine:page + prop:count={0} + prop:items={[]} + prop:style={{}} + prop:title="hello" + /> + `); + }); + + it('empty collection matches snapshot', () => { + const options = createTestOptions(); + const collection = new DocCollection(options); + collection.meta.initialize(); + collection.createDoc({ id: 'doc:home' }); + + expect(collection.exportJSX()).toMatchInlineSnapshot('null'); + }); + + it('collection with multiple blocks children matches snapshot', () => { + const options = createTestOptions(); + const collection = new DocCollection(options); + collection.meta.initialize(); + const doc = collection.createDoc({ id: 'doc:home' }); + doc.load(() => { + const rootId = doc.addBlock('affine:page', { + title: new doc.Text(), + }); + const noteId = doc.addBlock('affine:note', {}, rootId); + doc.addBlock('affine:paragraph', {}, noteId); + doc.addBlock('affine:paragraph', {}, noteId); + }); + + expect(collection.exportJSX()).toMatchInlineSnapshot(/* xml */ ` + <affine:page + prop:count={0} + prop:items={[]} + prop:style={{}} + > + <affine:note> + <affine:paragraph + prop:type="text" + /> + <affine:paragraph + prop:type="text" + /> + </affine:note> + </affine:page> + `); + }); +}); + +describe('flags', () => { + it('update flags', () => { + const options = createTestOptions(); + const collection = new DocCollection(options); + collection.meta.initialize(); + + const awareness = collection.awarenessStore; + + awareness.setFlag('enable_lasso_tool', false); + expect(awareness.getFlag('enable_lasso_tool')).toBe(false); + + awareness.setFlag('enable_lasso_tool', true); + expect(awareness.getFlag('enable_lasso_tool')).toBe(true); + }); +}); + +declare global { + namespace BlockSuite { + interface BlockModels { + 'affine:page': BlockModel; + 'affine:paragraph': BlockModel; + 'affine:note': BlockModel; + } + } +} diff --git a/blocksuite/framework/store/src/__tests__/doc.unit.spec.ts b/blocksuite/framework/store/src/__tests__/doc.unit.spec.ts new file mode 100644 index 0000000000..72c6ad0966 --- /dev/null +++ b/blocksuite/framework/store/src/__tests__/doc.unit.spec.ts @@ -0,0 +1,272 @@ +import { expect, test, vi } from 'vitest'; +import * as Y from 'yjs'; + +import { Schema } from '../schema/index.js'; +import { + BlockViewType, + DocCollection, + IdGeneratorType, +} from '../store/index.js'; +import { + DividerBlockSchema, + ListBlockSchema, + NoteBlockSchema, + ParagraphBlockSchema, + type RootBlockModel, + RootBlockSchema, +} from './test-schema.js'; + +const BlockSchemas = [ + RootBlockSchema, + ParagraphBlockSchema, + ListBlockSchema, + NoteBlockSchema, + DividerBlockSchema, +]; + +function createTestOptions() { + const idGenerator = IdGeneratorType.AutoIncrement; + const schema = new Schema(); + schema.register(BlockSchemas); + return { id: 'test-collection', idGenerator, schema }; +} + +test('trigger props updated', () => { + const options = createTestOptions(); + const collection = new DocCollection(options); + collection.meta.initialize(); + + const doc = collection.createDoc({ id: 'home' }); + doc.load(); + + doc.addBlock('affine:page'); + + const rootModel = doc.root as RootBlockModel; + + expect(rootModel).not.toBeNull(); + + const onPropsUpdated = vi.fn(); + rootModel.propsUpdated.on(onPropsUpdated); + + const getColor = () => + (rootModel.yBlock.get('prop:style') as Y.Map<string>).get('color'); + + const getItems = () => rootModel.yBlock.get('prop:items') as Y.Array<unknown>; + const getCount = () => rootModel.yBlock.get('prop:count'); + + rootModel.count = 1; + expect(onPropsUpdated).toBeCalledTimes(1); + expect(onPropsUpdated).toHaveBeenNthCalledWith(1, { key: 'count' }); + expect(getCount()).toBe(1); + + rootModel.count = 2; + expect(onPropsUpdated).toBeCalledTimes(2); + expect(onPropsUpdated).toHaveBeenNthCalledWith(2, { key: 'count' }); + expect(getCount()).toBe(2); + + rootModel.style.color = 'blue'; + expect(onPropsUpdated).toBeCalledTimes(3); + expect(onPropsUpdated).toHaveBeenNthCalledWith(3, { key: 'style' }); + expect(getColor()).toBe('blue'); + + rootModel.style = { color: 'red' }; + expect(onPropsUpdated).toBeCalledTimes(4); + expect(onPropsUpdated).toHaveBeenNthCalledWith(4, { key: 'style' }); + expect(getColor()).toBe('red'); + + rootModel.style.color = 'green'; + expect(onPropsUpdated).toBeCalledTimes(5); + expect(onPropsUpdated).toHaveBeenNthCalledWith(5, { key: 'style' }); + expect(getColor()).toBe('green'); + + rootModel.items.push(1); + expect(onPropsUpdated).toBeCalledTimes(6); + expect(onPropsUpdated).toHaveBeenNthCalledWith(6, { key: 'items' }); + expect(getItems().get(0)).toBe(1); + + rootModel.items[0] = { id: '1' }; + expect(onPropsUpdated).toBeCalledTimes(7); + expect(onPropsUpdated).toHaveBeenNthCalledWith(7, { key: 'items' }); + expect(getItems().get(0)).toBeInstanceOf(Y.Map); + expect((getItems().get(0) as Y.Map<unknown>).get('id')).toBe('1'); +}); + +test('stash and pop', () => { + const options = createTestOptions(); + const collection = new DocCollection(options); + collection.meta.initialize(); + + const doc = collection.createDoc({ id: 'home' }); + doc.load(); + + doc.addBlock('affine:page'); + + const rootModel = doc.root as RootBlockModel; + + expect(rootModel).not.toBeNull(); + + const onPropsUpdated = vi.fn(); + rootModel.propsUpdated.on(onPropsUpdated); + + const getCount = () => rootModel.yBlock.get('prop:count'); + const getColor = () => + (rootModel.yBlock.get('prop:style') as Y.Map<string>).get('color'); + + rootModel.count = 1; + expect(onPropsUpdated).toBeCalledTimes(1); + expect(onPropsUpdated).toHaveBeenNthCalledWith(1, { key: 'count' }); + expect(getCount()).toBe(1); + + rootModel.stash('count'); + rootModel.count = 2; + expect(onPropsUpdated).toBeCalledTimes(3); + expect(onPropsUpdated).toHaveBeenNthCalledWith(3, { key: 'count' }); + expect(rootModel.yBlock.get('prop:count')).toBe(1); + + rootModel.pop('count'); + expect(onPropsUpdated).toBeCalledTimes(4); + expect(onPropsUpdated).toHaveBeenNthCalledWith(4, { key: 'count' }); + expect(rootModel.yBlock.get('prop:count')).toBe(2); + + rootModel.style.color = 'blue'; + expect(getColor()).toBe('blue'); + expect(onPropsUpdated).toBeCalledTimes(5); + expect(onPropsUpdated).toHaveBeenNthCalledWith(5, { key: 'style' }); + + rootModel.stash('style'); + rootModel.style = { + color: 'red', + }; + expect(getColor()).toBe('blue'); + expect(onPropsUpdated).toBeCalledTimes(7); + expect(onPropsUpdated).toHaveBeenNthCalledWith(7, { key: 'style' }); + + rootModel.pop('style'); + expect(getColor()).toBe('red'); + expect(onPropsUpdated).toBeCalledTimes(8); + expect(onPropsUpdated).toHaveBeenNthCalledWith(8, { key: 'style' }); + + rootModel.stash('style'); + expect(onPropsUpdated).toBeCalledTimes(9); + expect(onPropsUpdated).toHaveBeenNthCalledWith(9, { key: 'style' }); + + rootModel.style.color = 'green'; + expect(onPropsUpdated).toBeCalledTimes(10); + expect(onPropsUpdated).toHaveBeenNthCalledWith(10, { key: 'style' }); + expect(getColor()).toBe('red'); + + rootModel.pop('style'); + expect(getColor()).toBe('green'); + expect(onPropsUpdated).toBeCalledTimes(11); + expect(onPropsUpdated).toHaveBeenNthCalledWith(11, { key: 'style' }); +}); + +test('always get latest value in onChange', () => { + const options = createTestOptions(); + const collection = new DocCollection(options); + collection.meta.initialize(); + + const doc = collection.createDoc({ id: 'home' }); + doc.load(); + + doc.addBlock('affine:page'); + + const rootModel = doc.root as RootBlockModel; + + expect(rootModel).not.toBeNull(); + + let value: unknown; + rootModel.propsUpdated.on(({ key }) => { + // @ts-expect-error FIXME: ts error + value = rootModel[key]; + }); + + rootModel.count = 1; + expect(value).toBe(1); + + rootModel.stash('count'); + + rootModel.count = 2; + expect(value).toBe(2); + + rootModel.pop('count'); + + rootModel.count = 3; + expect(value).toBe(3); + + rootModel.style.color = 'blue'; + expect(value).toEqual({ color: 'blue' }); + + rootModel.stash('style'); + rootModel.style = { color: 'red' }; + expect(value).toEqual({ color: 'red' }); + rootModel.style.color = 'green'; + expect(value).toEqual({ color: 'green' }); + + rootModel.pop('style'); + rootModel.style.color = 'yellow'; + expect(value).toEqual({ color: 'yellow' }); +}); + +test('query', () => { + const options = createTestOptions(); + const collection = new DocCollection(options); + collection.meta.initialize(); + const doc1 = collection.createDoc({ id: 'home' }); + doc1.load(); + const doc2 = collection.getDoc('home'); + + const doc3 = collection.getDoc('home', { + query: { + mode: 'loose', + match: [ + { + flavour: 'affine:list', + viewType: BlockViewType.Hidden, + }, + ], + }, + }); + expect(doc1).toBe(doc2); + expect(doc1).not.toBe(doc3); + + const page = doc1.addBlock('affine:page'); + const note = doc1.addBlock('affine:note', {}, page); + const paragraph1 = doc1.addBlock('affine:paragraph', {}, note); + const list1 = doc1.addBlock('affine:list' as never, {}, note); + + expect(doc2?.getBlock(paragraph1)?.blockViewType).toBe(BlockViewType.Display); + expect(doc2?.getBlock(list1)?.blockViewType).toBe(BlockViewType.Display); + expect(doc3?.getBlock(list1)?.blockViewType).toBe(BlockViewType.Hidden); + + const list2 = doc1.addBlock('affine:list' as never, {}, note); + + expect(doc2?.getBlock(list2)?.blockViewType).toBe(BlockViewType.Display); + expect(doc3?.getBlock(list2)?.blockViewType).toBe(BlockViewType.Hidden); +}); + +test('local readonly', () => { + const options = createTestOptions(); + const collection = new DocCollection(options); + collection.meta.initialize(); + const doc1 = collection.createDoc({ id: 'home' }); + doc1.load(); + const doc2 = collection.getDoc('home', { readonly: true }); + const doc3 = collection.getDoc('home', { readonly: false }); + + expect(doc1.readonly).toBeFalsy(); + expect(doc2?.readonly).toBeTruthy(); + expect(doc3?.readonly).toBeFalsy(); + + collection.awarenessStore.setReadonly(doc1.blockCollection, true); + + expect(doc1.readonly).toBeTruthy(); + expect(doc2?.readonly).toBeTruthy(); + expect(doc3?.readonly).toBeTruthy(); + + collection.awarenessStore.setReadonly(doc1.blockCollection, false); + + expect(doc1.readonly).toBeFalsy(); + expect(doc2?.readonly).toBeTruthy(); + expect(doc3?.readonly).toBeFalsy(); +}); diff --git a/blocksuite/framework/store/src/__tests__/jsx.unit.spec.ts b/blocksuite/framework/store/src/__tests__/jsx.unit.spec.ts new file mode 100644 index 0000000000..78818f2806 --- /dev/null +++ b/blocksuite/framework/store/src/__tests__/jsx.unit.spec.ts @@ -0,0 +1,133 @@ +// checkout https://vitest.dev/guide/debugging.html for debugging tests + +import { describe, expect, it } from 'vitest'; + +import { yDocToJSXNode } from '../utils/jsx.js'; + +describe('basic', () => { + it('serialized doc match snapshot', () => { + expect( + yDocToJSXNode( + { + '0': { + 'sys:id': '0', + 'sys:children': ['1'], + 'sys:flavour': 'affine:page', + }, + '1': { + 'sys:id': '1', + 'sys:children': [], + 'sys:flavour': 'affine:paragraph', + 'prop:text': [], + 'prop:type': 'text', + }, + }, + '0' + ) + ).toMatchInlineSnapshot(` + <affine:page> + <affine:paragraph + prop:type="text" + /> + </affine:page> + `); + }); + + it('block with plain text should match snapshot', () => { + expect( + yDocToJSXNode( + { + '0': { + 'sys:id': '0', + 'sys:flavour': 'affine:page', + 'sys:children': ['1'], + 'prop:title': 'this is title', + }, + '1': { + 'sys:id': '2', + 'sys:flavour': 'affine:paragraph', + 'sys:children': [], + 'prop:type': 'text', + 'prop:text': [{ insert: 'just plain text' }], + }, + }, + '0' + ) + ).toMatchInlineSnapshot(` + <affine:page + prop:title="this is title" + > + <affine:paragraph + prop:text="just plain text" + prop:type="text" + /> + </affine:page> + `); + }); + + it('doc record match snapshot', () => { + expect( + yDocToJSXNode( + { + '0': { + 'sys:id': '0', + 'sys:flavour': 'affine:page', + 'sys:children': ['1'], + 'prop:title': 'this is title', + }, + '1': { + 'sys:id': '2', + 'sys:flavour': 'affine:paragraph', + 'sys:children': [], + 'prop:type': 'text', + 'prop:text': [ + { insert: 'this is ' }, + { + insert: 'a ', + attributes: { link: 'http://www.example.com' }, + }, + { + insert: 'link', + attributes: { link: 'http://www.example.com', bold: true }, + }, + { insert: ' with', attributes: { bold: true } }, + { insert: ' bold' }, + ], + }, + }, + '0' + ) + ).toMatchInlineSnapshot(` + <affine:page + prop:title="this is title" + > + <affine:paragraph + prop:text={ + <> + <text + insert="this is " + /> + <text + insert="a " + link="http://www.example.com" + /> + <text + bold={true} + insert="link" + link="http://www.example.com" + /> + <text + bold={true} + insert=" with" + /> + <text + insert=" bold" + /> + </> + } + prop:type="text" + /> + </affine:page> + `); + }); +}); diff --git a/blocksuite/framework/store/src/__tests__/schema.unit.spec.ts b/blocksuite/framework/store/src/__tests__/schema.unit.spec.ts new file mode 100644 index 0000000000..a4455e2272 --- /dev/null +++ b/blocksuite/framework/store/src/__tests__/schema.unit.spec.ts @@ -0,0 +1,133 @@ +import { literal } from 'lit/static-html.js'; +import { describe, expect, it, vi } from 'vitest'; + +// import some blocks +import { type BlockModel, defineBlockSchema } from '../schema/base.js'; +import { SchemaValidateError } from '../schema/error.js'; +import { Schema } from '../schema/index.js'; +import { DocCollection, IdGeneratorType } from '../store/index.js'; +import { + DividerBlockSchema, + ListBlockSchema, + NoteBlockSchema, + ParagraphBlockSchema, + RootBlockSchema, +} from './test-schema.js'; + +function createTestOptions() { + const idGenerator = IdGeneratorType.AutoIncrement; + const schema = new Schema(); + schema.register(BlockSchemas); + return { id: 'test-collection', idGenerator, schema }; +} + +const TestCustomNoteBlockSchema = defineBlockSchema({ + flavour: 'affine:note-block-video', + props: internal => ({ + text: internal.Text(), + }), + metadata: { + version: 1, + role: 'content', + tag: literal`affine-note-block-video`, + parent: ['affine:note'], + }, +}); + +const TestInvalidNoteBlockSchema = defineBlockSchema({ + flavour: 'affine:note-invalid-block-video', + props: internal => ({ + text: internal.Text(), + }), + metadata: { + version: 1, + role: 'content', + tag: literal`affine-invalid-note-block-video`, + parent: ['affine:note'], + }, +}); + +const BlockSchemas = [ + RootBlockSchema, + ParagraphBlockSchema, + ListBlockSchema, + NoteBlockSchema, + DividerBlockSchema, + TestCustomNoteBlockSchema, + TestInvalidNoteBlockSchema, +]; + +const defaultDocId = 'doc0'; +function createTestDoc(docId = defaultDocId) { + const options = createTestOptions(); + const collection = new DocCollection(options); + collection.meta.initialize(); + const doc = collection.createDoc({ id: docId }); + doc.load(); + return doc; +} + +describe('schema', () => { + it('should be able to validate schema by role', () => { + const consoleMock = vi + .spyOn(console, 'error') + .mockImplementation(() => undefined); + const doc = createTestDoc(); + const rootId = doc.addBlock('affine:page', {}); + const noteId = doc.addBlock('affine:note', {}, rootId); + const paragraphId = doc.addBlock('affine:paragraph', {}, noteId); + + doc.addBlock('affine:note', {}); + expect(consoleMock.mock.calls[0]).toSatisfy((call: unknown[]) => { + return typeof call[0] === 'string'; + }); + expect(consoleMock.mock.calls[1]).toSatisfy((call: unknown[]) => { + return call[0] instanceof SchemaValidateError; + }); + + consoleMock.mockClear(); + // add paragraph to root should throw + doc.addBlock('affine:paragraph', {}, rootId); + expect(consoleMock.mock.calls[0]).toSatisfy((call: unknown[]) => { + return typeof call[0] === 'string'; + }); + expect(consoleMock.mock.calls[1]).toSatisfy((call: unknown[]) => { + return call[0] instanceof SchemaValidateError; + }); + + consoleMock.mockClear(); + doc.addBlock('affine:note', {}, rootId); + doc.addBlock('affine:paragraph', {}, noteId); + doc.addBlock('affine:paragraph', {}, paragraphId); + expect(consoleMock).not.toBeCalled(); + }); + + it('should glob match works', () => { + const consoleMock = vi + .spyOn(console, 'error') + .mockImplementation(() => undefined); + const doc = createTestDoc(); + const rootId = doc.addBlock('affine:page', {}); + const noteId = doc.addBlock('affine:note', {}, rootId); + + doc.addBlock('affine:note-block-video', {}, noteId); + expect(consoleMock).not.toBeCalled(); + + doc.addBlock('affine:note-invalid-block-video', {}, noteId); + expect(consoleMock.mock.calls[0]).toSatisfy((call: unknown[]) => { + return typeof call[0] === 'string'; + }); + expect(consoleMock.mock.calls[1]).toSatisfy((call: unknown[]) => { + return call[0] instanceof SchemaValidateError; + }); + }); +}); + +declare global { + namespace BlockSuite { + interface BlockModels { + 'affine:note-block-video': BlockModel; + 'affine:note-invalid-block-video': BlockModel; + } + } +} diff --git a/blocksuite/framework/store/src/__tests__/test-schema.ts b/blocksuite/framework/store/src/__tests__/test-schema.ts new file mode 100644 index 0000000000..87349b09d1 --- /dev/null +++ b/blocksuite/framework/store/src/__tests__/test-schema.ts @@ -0,0 +1,88 @@ +import { defineBlockSchema, type SchemaToModel } from '../schema/index.js'; + +export const RootBlockSchema = defineBlockSchema({ + flavour: 'affine:page', + props: internal => ({ + title: internal.Text(), + count: 0, + style: {} as Record<string, unknown>, + items: [] as unknown[], + }), + metadata: { + version: 2, + role: 'root', + }, +}); + +export type RootBlockModel = SchemaToModel<typeof RootBlockSchema>; + +export const NoteBlockSchema = defineBlockSchema({ + flavour: 'affine:note', + props: () => ({}), + metadata: { + version: 1, + role: 'hub', + parent: ['affine:page'], + children: [ + 'affine:paragraph', + 'affine:list', + 'affine:code', + 'affine:divider', + 'affine:database', + 'affine:data-view', + 'affine:image', + 'affine:note-block-*', + 'affine:bookmark', + 'affine:attachment', + 'affine:surface-ref', + 'affine:embed-*', + ], + }, +}); + +export const ParagraphBlockSchema = defineBlockSchema({ + flavour: 'affine:paragraph', + props: internal => ({ + type: 'text', + text: internal.Text(), + }), + metadata: { + version: 1, + role: 'content', + parent: [ + 'affine:note', + 'affine:database', + 'affine:paragraph', + 'affine:list', + ], + }, +}); + +export const ListBlockSchema = defineBlockSchema({ + flavour: 'affine:list', + props: internal => ({ + type: 'bulleted', + text: internal.Text(), + checked: false, + collapsed: false, + }), + metadata: { + version: 1, + role: 'content', + parent: [ + 'affine:note', + 'affine:database', + 'affine:list', + 'affine:paragraph', + ], + }, +}); + +export const DividerBlockSchema = defineBlockSchema({ + flavour: 'affine:divider', + metadata: { + version: 1, + role: 'content', + children: [], + }, +}); diff --git a/blocksuite/framework/store/src/__tests__/test-utils-dom.ts b/blocksuite/framework/store/src/__tests__/test-utils-dom.ts new file mode 100644 index 0000000000..e677cecbc7 --- /dev/null +++ b/blocksuite/framework/store/src/__tests__/test-utils-dom.ts @@ -0,0 +1,122 @@ +import type { DocCollection } from '../store/index.js'; + +declare global { + interface WindowEventMap { + 'test-result': CustomEvent<TestResult>; + } + interface Window { + collection: DocCollection; + } +} + +export interface TestResult { + success: boolean; + messages: string[]; +} + +const testResult: TestResult = { + success: true, + messages: [], +}; + +interface TestCase { + name: string; + callback: () => Promise<boolean>; +} + +let testCases: TestCase[] = []; + +function reportTestResult() { + const event = new CustomEvent<TestResult>('test-result', { + detail: testResult, + }); + window.dispatchEvent(event); +} + +function addMessage(message: string) { + console.log(message); + testResult.messages.push(message); +} + +function reject(message: string) { + testResult.success = false; + addMessage(`❌ ${message}`); +} + +export function testSerial(name: string, callback: () => Promise<boolean>) { + testCases.push({ name, callback }); +} + +function wait(ms: number) { + return new Promise(resolve => setTimeout(resolve, ms)); +} + +export async function runOnce() { + await wait(50); // for correct event sequence + + for (const testCase of testCases) { + const { name, callback } = testCase; + const result = await callback(); + + if (result) addMessage(`✅ ${name}`); + else reject(name); + } + reportTestResult(); + testCases = []; +} + +// XXX: workaround typing issue in blobs/__tests__/test-entry.ts +export function assertExists<T>(val: T | null | undefined): asserts val is T { + if (val === null || val === undefined) { + throw new Error('val does not exist'); + } +} + +export async function nextFrame() { + return new Promise(resolve => requestAnimationFrame(resolve)); +} + +// Test image source: https://en.wikipedia.org/wiki/Test_card +export async function loadTestImageBlob(name: string): Promise<Blob> { + const resp = await fetch(`/${name}.png`); + return resp.blob(); +} + +export async function loadImage(blobUrl: string) { + const img = new Image(); + img.src = blobUrl; + return new Promise<HTMLImageElement>(resolve => { + img.onload = () => resolve(img); + }); +} + +export function assertColor( + img: HTMLImageElement, + x: number, + y: number, + color: [number, number, number] +): boolean { + const canvas = document.createElement('canvas'); + canvas.width = img.width; + canvas.height = img.height; + const ctx = canvas.getContext('2d') as CanvasRenderingContext2D; + ctx.drawImage(img, 0, 0); + + const data = ctx.getImageData(x, y, 1, 1).data; + const r = data[0]; + const g = data[1]; + const b = data[2]; + return r === color[0] && g === color[1] && b === color[2]; +} + +// prevent redundant test runs +export function disableButtonsAfterClick() { + const buttons = document.querySelectorAll('button'); + buttons.forEach(btn => { + btn.addEventListener('click', () => { + buttons.forEach(button => { + button.disabled = true; + }); + }); + }); +} diff --git a/blocksuite/framework/store/src/__tests__/transformer.unit.spec.ts b/blocksuite/framework/store/src/__tests__/transformer.unit.spec.ts new file mode 100644 index 0000000000..3a9a955017 --- /dev/null +++ b/blocksuite/framework/store/src/__tests__/transformer.unit.spec.ts @@ -0,0 +1,143 @@ +import { expect, test } from 'vitest'; +import * as Y from 'yjs'; + +import { MemoryBlobCRUD } from '../adapter/index.js'; +import { Text } from '../reactive/index.js'; +import { + type BlockModel, + defineBlockSchema, + Schema, + type SchemaToModel, +} from '../schema/index.js'; +import { DocCollection, IdGeneratorType } from '../store/index.js'; +import { AssetsManager, BaseBlockTransformer } from '../transformer/index.js'; + +const docSchema = defineBlockSchema({ + flavour: 'page', + props: internal => ({ + title: internal.Text('doc title'), + count: 3, + style: { + color: 'red', + }, + items: [ + { + id: 0, + content: internal.Text('item 1'), + }, + { + id: 1, + content: internal.Text('item 2'), + }, + { + id: 2, + content: internal.Text('item 3'), + }, + ], + }), + metadata: { + role: 'root', + version: 1, + }, +}); + +type RootBlockModel = SchemaToModel<typeof docSchema>; + +function createTestOptions() { + const idGenerator = IdGeneratorType.AutoIncrement; + const schema = new Schema(); + schema.register([docSchema]); + return { id: 'test-collection', idGenerator, schema }; +} + +const transformer = new BaseBlockTransformer(); +const blobCRUD = new MemoryBlobCRUD(); +const assets = new AssetsManager({ blob: blobCRUD }); + +test('model to snapshot', () => { + const options = createTestOptions(); + const collection = new DocCollection(options); + collection.meta.initialize(); + const doc = collection.createDoc({ id: 'home' }); + doc.load(); + doc.addBlock('page'); + const rootModel = doc.root as RootBlockModel; + + expect(rootModel).not.toBeNull(); + const snapshot = transformer.toSnapshot({ + model: rootModel, + assets, + }); + expect(snapshot).toMatchSnapshot(); +}); + +test('snapshot to model', async () => { + const options = createTestOptions(); + const collection = new DocCollection(options); + collection.meta.initialize(); + const doc = collection.createDoc({ id: 'home' }); + doc.load(); + doc.addBlock('page'); + const rootModel = doc.root as RootBlockModel; + + const tempDoc = new Y.Doc(); + const map = tempDoc.getMap('temp'); + + expect(rootModel).not.toBeNull(); + const snapshot = transformer.toSnapshot({ + model: rootModel, + assets, + }); + + const model = await transformer.fromSnapshot({ + json: snapshot, + assets, + children: [], + }); + expect(model.flavour).toBe(rootModel.flavour); + + // @ts-expect-error FIXME: ts error + expect(model.props.title).toBeInstanceOf(Text); + + // @ts-expect-error FIXME: ts error + map.set('title', model.props.title.yText); + // @ts-expect-error FIXME: ts error + expect(model.props.title.toString()).toBe('doc title'); + + // @ts-expect-error FIXME: ts error + expect(model.props.style).toEqual({ + color: 'red', + }); + + // @ts-expect-error FIXME: ts error + expect(model.props.count).toBe(3); + + // @ts-expect-error FIXME: ts error + expect(model.props.items).toMatchObject([ + { + id: 0, + }, + { + id: 1, + }, + { + id: 2, + }, + ]); + + // @ts-expect-error FIXME: ts error + model.props.items.forEach((item, index) => { + expect(item.content).toBeInstanceOf(Text); + const key = `item:${index}:content`; + map.set(key, item.content.yText); + expect(item.content.toString()).toBe(`item ${index + 1}`); + }); +}); + +declare global { + namespace BlockSuite { + interface BlockModels { + page: BlockModel; + } + } +} diff --git a/blocksuite/framework/store/src/__tests__/yjs.unit.spec.ts b/blocksuite/framework/store/src/__tests__/yjs.unit.spec.ts new file mode 100644 index 0000000000..f292bf185a --- /dev/null +++ b/blocksuite/framework/store/src/__tests__/yjs.unit.spec.ts @@ -0,0 +1,177 @@ +import { describe, expect, test } from 'vitest'; +import * as Y from 'yjs'; + +import type { Text } from '../reactive/index.js'; +import { Boxed, createYProxy, popProp, stashProp } from '../reactive/index.js'; + +describe('blocksuite yjs', () => { + describe('array', () => { + test('proxy', () => { + const ydoc = new Y.Doc(); + const arr = ydoc.getArray('arr'); + arr.push([0]); + + const proxy = createYProxy(arr) as unknown[]; + expect(arr.get(0)).toBe(0); + + proxy.push(1); + expect(arr.get(1)).toBe(1); + expect(arr.length).toBe(2); + + proxy.splice(1, 1); + expect(arr.length).toBe(1); + + proxy[0] = 2; + expect(arr.length).toBe(1); + }); + }); + + describe('object', () => { + test('deep', () => { + const ydoc = new Y.Doc(); + const map = ydoc.getMap('map'); + const obj = new Y.Map(); + obj.set('foo', 1); + map.set('obj', obj); + map.set('num', 0); + const map2 = new Y.Map(); + obj.set('map', map2); + map2.set('foo', 40); + + const proxy = createYProxy<Record<string, any>>(map); + + expect(proxy.num).toBe(0); + expect(proxy.obj.foo).toBe(1); + expect(proxy.obj.map.foo).toBe(40); + + proxy.obj.bar = 100; + expect(obj.get('bar')).toBe(100); + + proxy.obj2 = { foo: 2, bar: { num: 3 } }; + expect(map.get('obj2')).toBeInstanceOf(Y.Map); + // @ts-expect-error FIXME: ts error + expect(map.get('obj2').get('bar').get('num')).toBe(3); + + proxy.obj2.bar.str = 'hello'; + // @ts-expect-error FIXME: ts error + expect(map.get('obj2').get('bar').get('str')).toBe('hello'); + + proxy.obj3 = {}; + const { obj3 } = proxy; + obj3.id = 'obj3'; + expect((map.get('obj3') as Y.Map<string>).get('id')).toBe('obj3'); + + proxy.arr = []; + expect(map.get('arr')).toBeInstanceOf(Y.Array); + proxy.arr.push({ counter: 1 }); + expect((map.get('arr') as Y.Array<Y.Map<number>>).get(0)).toBeInstanceOf( + Y.Map + ); + expect( + (map.get('arr') as Y.Array<Y.Map<number>>).get(0).get('counter') + ).toBe(1); + }); + + test('with y text', () => { + const ydoc = new Y.Doc(); + const map = ydoc.getMap('map'); + const inner = new Y.Map(); + map.set('inner', inner); + const text = new Y.Text('hello'); + inner.set('text', text); + + const proxy = createYProxy<{ inner: { text: Text } }>(map); + proxy.inner = { ...proxy.inner }; + expect(proxy.inner.text.yText).toBeInstanceOf(Y.Text); + expect(proxy.inner.text.yText.toJSON()).toBe('hello'); + }); + + test('with native wrapper', () => { + const ydoc = new Y.Doc(); + const map = ydoc.getMap('map'); + const inner = new Y.Map(); + map.set('inner', inner); + const native = new Boxed(['hello', 'world']); + inner.set('native', native.yMap); + + const proxy = createYProxy<{ + inner: { + native: Boxed<string[]>; + native2: Boxed<number>; + }; + }>(map); + + expect(proxy.inner.native.getValue()).toEqual(['hello', 'world']); + + proxy.inner.native.setValue(['hello', 'world', 'foo']); + expect(native.getValue()).toEqual(['hello', 'world', 'foo']); + // @ts-expect-error FIXME: ts error + expect(map.get('inner').get('native').get('value')).toEqual([ + 'hello', + 'world', + 'foo', + ]); + + const native2 = new Boxed(0); + proxy.inner.native2 = native2; + // @ts-expect-error FIXME: ts error + expect(map.get('inner').get('native2').get('value')).toBe(0); + native2.setValue(1); + // @ts-expect-error FIXME: ts error + expect(map.get('inner').get('native2').get('value')).toBe(1); + }); + }); + + describe('stash and pop', () => { + test('object', () => { + const ydoc = new Y.Doc(); + const map = ydoc.getMap('map'); + map.set('num', 0); + + const proxy = createYProxy<Record<string, any>>(map); + + expect(proxy.num).toBe(0); + stashProp(map, 'num'); + proxy.num = 1; + expect(proxy.num).toBe(1); + expect(map.get('num')).toBe(0); + proxy.num = 2; + popProp(map, 'num'); + expect(map.get('num')).toBe(2); + }); + + test('array', () => { + const ydoc = new Y.Doc(); + const arr = ydoc.getArray('arr'); + arr.push([0]); + + const proxy = createYProxy<Record<string, any>>(arr); + + expect(proxy[0]).toBe(0); + stashProp(arr, 0); + proxy[0] = 1; + expect(proxy[0]).toBe(1); + expect(arr.get(0)).toBe(0); + popProp(arr, 0); + expect(arr.get(0)).toBe(1); + }); + + test('nested', () => { + const ydoc = new Y.Doc(); + const map = ydoc.getMap('map'); + const arr = new Y.Array(); + map.set('arr', arr); + arr.push([0]); + + const proxy = createYProxy<Record<string, any>>(map); + + expect(proxy.arr[0]).toBe(0); + stashProp(arr, 0); + proxy.arr[0] = 1; + expect(proxy.arr[0]).toBe(1); + expect(arr.get(0)).toBe(0); + popProp(arr, 0); + expect(arr.get(0)).toBe(1); + }); + }); +}); diff --git a/blocksuite/framework/store/src/adapter/assets.ts b/blocksuite/framework/store/src/adapter/assets.ts new file mode 100644 index 0000000000..416adbf2e8 --- /dev/null +++ b/blocksuite/framework/store/src/adapter/assets.ts @@ -0,0 +1,159 @@ +import { BlockSuiteError, ErrorCode } from '@blocksuite/global/exceptions'; +import { sha } from '@blocksuite/global/utils'; + +/** + * @internal just for test + */ +export class MemoryBlobCRUD { + private readonly _map = new Map<string, Blob>(); + + delete(key: string) { + this._map.delete(key); + } + + get(key: string) { + return this._map.get(key) ?? null; + } + + list() { + return Array.from(this._map.keys()); + } + + async set(value: Blob): Promise<string>; + + async set(key: string, value: Blob): Promise<string>; + + async set(valueOrKey: string | Blob, _value?: Blob) { + const key = + typeof valueOrKey === 'string' + ? valueOrKey + : await sha(await valueOrKey.arrayBuffer()); + const value = typeof valueOrKey === 'string' ? _value : valueOrKey; + + if (!value) { + throw new BlockSuiteError( + ErrorCode.TransformerError, + 'value is required' + ); + } + + this._map.set(key, value); + return key; + } +} + +export const mimeExtMap = new Map([ + ['application/epub+zip', 'epub'], + ['application/gzip', 'gz'], + ['application/java-archive', 'jar'], + ['application/json', 'json'], + ['application/ld+json', 'jsonld'], + ['application/msword', 'doc'], + ['application/octet-stream', 'bin'], + ['application/ogg', 'ogx'], + ['application/pdf', 'pdf'], + ['application/rtf', 'rtf'], + ['application/vnd.amazon.ebook', 'azw'], + ['application/vnd.apple.installer+xml', 'mpkg'], + ['application/vnd.mozilla.xul+xml', 'xul'], + ['application/vnd.ms-excel', 'xls'], + ['application/vnd.ms-fontobject', 'eot'], + ['application/vnd.ms-powerpoint', 'ppt'], + ['application/vnd.oasis.opendocument.presentation', 'odp'], + ['application/vnd.oasis.opendocument.spreadsheet', 'ods'], + ['application/vnd.oasis.opendocument.text', 'odt'], + [ + 'application/vnd.openxmlformats-officedocument.presentationml.presentation', + 'pptx', + ], + ['application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', 'xlsx'], + [ + 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', + 'docx', + ], + ['application/vnd.rar', 'rar'], + ['application/vnd.visio', 'vsd'], + ['application/x-7z-compressed', '7z'], + ['application/x-abiword', 'abw'], + ['application/x-bzip', 'bz'], + ['application/x-bzip2', 'bz2'], + ['application/x-cdf', 'cda'], + ['application/x-csh', 'csh'], + ['application/x-freearc', 'arc'], + ['application/x-httpd-php', 'php'], + ['application/x-sh', 'sh'], + ['application/x-tar', 'tar'], + ['application/xhtml+xml', 'xhtml'], + ['application/xml', 'xml'], + ['application/zip', 'zip'], + ['application/zstd', 'zst'], + ['audio/3gpp', '3gp'], + ['audio/3gpp2', '3g2'], + ['audio/aac', 'aac'], + ['audio/midi', 'mid'], + ['audio/mpeg', 'mp3'], + ['audio/ogg', 'oga'], + ['audio/opus', 'opus'], + ['audio/wav', 'wav'], + ['audio/webm', 'weba'], + ['audio/x-midi', 'midi'], + ['font/otf', 'otf'], + ['font/ttf', 'ttf'], + ['font/woff', 'woff'], + ['font/woff2', 'woff2'], + ['image/apng', 'apng'], + ['image/avif', 'avif'], + ['image/bmp', 'bmp'], + ['image/gif', 'gif'], + ['image/jpeg', 'jpeg'], + ['image/png', 'png'], + ['image/svg+xml', 'svg'], + ['image/tiff', 'tiff'], + ['image/vnd.microsoft.icon', 'ico'], + ['image/webp', 'webp'], + ['text/calendar', 'ics'], + ['text/css', 'css'], + ['text/csv', 'csv'], + ['text/html', 'html'], + ['text/javascript', 'js'], + ['text/plain', 'txt'], + ['text/xml', 'xml'], + ['video/3gpp', '3gp'], + ['video/3gpp2', '3g2'], + ['video/mp2t', 'ts'], + ['video/mp4', 'mp4'], + ['video/mpeg', 'mpeg'], + ['video/ogg', 'ogv'], + ['video/webm', 'webm'], + ['video/x-msvideo', 'avi'], +]); + +export const extMimeMap = new Map( + Array.from(mimeExtMap.entries()).map(([mime, ext]) => [ext, mime]) +); + +const getExt = (type: string) => { + if (type === '') return 'blob'; + const ext = mimeExtMap.get(type); + if (ext) return ext; + const guessExt = type.split('/'); + return guessExt.at(-1) ?? 'blob'; +}; + +export function getAssetName(assets: Map<string, Blob>, blobId: string) { + const blob = assets.get(blobId); + if (!blob) { + throw new BlockSuiteError( + ErrorCode.TransformerError, + `blob not found for blobId: ${blobId}` + ); + } + + const name = + 'name' in blob && typeof blob.name === 'string' ? blob.name : undefined; + if (name) { + if (name.includes('.')) return name; + return `${name}.${getExt(blob.type)}`; + } + return `${blobId}.${getExt(blob.type)}`; +} diff --git a/blocksuite/framework/store/src/adapter/base.ts b/blocksuite/framework/store/src/adapter/base.ts new file mode 100644 index 0000000000..a84e0f5a2b --- /dev/null +++ b/blocksuite/framework/store/src/adapter/base.ts @@ -0,0 +1,310 @@ +import { BlockSuiteError } from '@blocksuite/global/exceptions'; + +import type { Doc } from '../store/index.js'; +import type { AssetsManager } from '../transformer/assets.js'; +import type { DraftModel, Job, Slice } from '../transformer/index.js'; +import type { + BlockSnapshot, + DocSnapshot, + SliceSnapshot, +} from '../transformer/type.js'; +import { ASTWalkerContext } from './context.js'; + +export type FromDocSnapshotPayload = { + snapshot: DocSnapshot; + assets?: AssetsManager; +}; +export type FromBlockSnapshotPayload = { + snapshot: BlockSnapshot; + assets?: AssetsManager; +}; +export type FromSliceSnapshotPayload = { + snapshot: SliceSnapshot; + assets?: AssetsManager; +}; +export type FromDocSnapshotResult<Target> = { + file: Target; + assetsIds: string[]; +}; +export type FromBlockSnapshotResult<Target> = { + file: Target; + assetsIds: string[]; +}; +export type FromSliceSnapshotResult<Target> = { + file: Target; + assetsIds: string[]; +}; +export type ToDocSnapshotPayload<Target> = { + file: Target; + assets?: AssetsManager; +}; +export type ToBlockSnapshotPayload<Target> = { + file: Target; + assets?: AssetsManager; +}; +export type ToSliceSnapshotPayload<Target> = { + file: Target; + assets?: AssetsManager; +}; + +export function wrapFakeNote(snapshot: SliceSnapshot) { + if (snapshot.content[0]?.flavour !== 'affine:note') { + snapshot.content = [ + { + type: 'block', + id: '', + flavour: 'affine:note', + props: {}, + children: snapshot.content, + }, + ]; + } +} + +export abstract class BaseAdapter<AdapterTarget = unknown> { + job: Job; + + get configs() { + return this.job.adapterConfigs; + } + + constructor(job: Job) { + this.job = job; + } + + async fromBlock(model: DraftModel) { + try { + const blockSnapshot = this.job.blockToSnapshot(model); + if (!blockSnapshot) return; + return await this.fromBlockSnapshot({ + snapshot: blockSnapshot, + assets: this.job.assetsManager, + }); + } catch (error) { + console.error('Cannot convert block to snapshot'); + console.error(error); + return; + } + } + + abstract fromBlockSnapshot( + payload: FromBlockSnapshotPayload + ): + | Promise<FromBlockSnapshotResult<AdapterTarget>> + | FromBlockSnapshotResult<AdapterTarget>; + + async fromDoc(doc: Doc) { + try { + const docSnapshot = this.job.docToSnapshot(doc); + if (!docSnapshot) return; + return await this.fromDocSnapshot({ + snapshot: docSnapshot, + assets: this.job.assetsManager, + }); + } catch (error) { + console.error('Cannot convert doc to snapshot'); + console.error(error); + return; + } + } + + abstract fromDocSnapshot( + payload: FromDocSnapshotPayload + ): + | Promise<FromDocSnapshotResult<AdapterTarget>> + | FromDocSnapshotResult<AdapterTarget>; + + async fromSlice(slice: Slice) { + try { + const sliceSnapshot = this.job.sliceToSnapshot(slice); + if (!sliceSnapshot) return; + wrapFakeNote(sliceSnapshot); + return await this.fromSliceSnapshot({ + snapshot: sliceSnapshot, + assets: this.job.assetsManager, + }); + } catch (error) { + console.error('Cannot convert slice to snapshot'); + console.error(error); + return; + } + } + + abstract fromSliceSnapshot( + payload: FromSliceSnapshotPayload + ): + | Promise<FromSliceSnapshotResult<AdapterTarget>> + | FromSliceSnapshotResult<AdapterTarget>; + + async toBlock( + payload: ToBlockSnapshotPayload<AdapterTarget>, + doc: Doc, + parent?: string, + index?: number + ) { + try { + const snapshot = await this.toBlockSnapshot(payload); + if (!snapshot) return; + return await this.job.snapshotToBlock(snapshot, doc, parent, index); + } catch (error) { + console.error('Cannot convert block snapshot to block'); + console.error(error); + return; + } + } + + abstract toBlockSnapshot( + payload: ToBlockSnapshotPayload<AdapterTarget> + ): Promise<BlockSnapshot> | BlockSnapshot; + + async toDoc(payload: ToDocSnapshotPayload<AdapterTarget>) { + try { + const snapshot = await this.toDocSnapshot(payload); + if (!snapshot) return; + return await this.job.snapshotToDoc(snapshot); + } catch (error) { + console.error('Cannot convert doc snapshot to doc'); + console.error(error); + return; + } + } + + abstract toDocSnapshot( + payload: ToDocSnapshotPayload<AdapterTarget> + ): Promise<DocSnapshot> | DocSnapshot; + + async toSlice( + payload: ToSliceSnapshotPayload<AdapterTarget>, + doc: Doc, + parent?: string, + index?: number + ) { + try { + const snapshot = await this.toSliceSnapshot(payload); + if (!snapshot) return; + return await this.job.snapshotToSlice(snapshot, doc, parent, index); + } catch (error) { + console.error('Cannot convert slice snapshot to slice'); + console.error(error); + return; + } + } + + abstract toSliceSnapshot( + payload: ToSliceSnapshotPayload<AdapterTarget> + ): Promise<SliceSnapshot | null> | SliceSnapshot | null; +} + +type Keyof<T> = T extends unknown ? keyof T : never; + +type WalkerFn<ONode extends object, TNode extends object> = ( + o: NodeProps<ONode>, + context: ASTWalkerContext<TNode> +) => Promise<void> | void; + +export type NodeProps<Node extends object> = { + node: Node; + next?: Node | null; + parent: NodeProps<Node> | null; + prop: Keyof<Node> | null; + index: number | null; +}; + +// Ported from https://github.com/Rich-Harris/estree-walker MIT License +export class ASTWalker<ONode extends object, TNode extends object | never> { + private _enter: WalkerFn<ONode, TNode> | undefined; + + private _isONode!: (node: unknown) => node is ONode; + + private _leave: WalkerFn<ONode, TNode> | undefined; + + private _visit = async (o: NodeProps<ONode>) => { + if (!o.node) return; + this.context._skipChildrenNum = 0; + this.context._skip = false; + + if (this._enter) { + await this._enter(o, this.context); + } + + if (this.context._skip) { + return; + } + + for (const key in o.node) { + const value = o.node[key]; + + if (value && typeof value === 'object') { + if (Array.isArray(value)) { + for ( + let i = this.context._skipChildrenNum; + i < value.length; + i += 1 + ) { + const item = value[i]; + if ( + item !== null && + typeof item === 'object' && + this._isONode(item) + ) { + const nextItem = value[i + 1] ?? null; + await this._visit({ + node: item, + next: nextItem, + parent: o, + prop: key as unknown as Keyof<ONode>, + index: i, + }); + } + } + } else if ( + this.context._skipChildrenNum === 0 && + this._isONode(value) + ) { + await this._visit({ + node: value, + next: null, + parent: o, + prop: key as unknown as Keyof<ONode>, + index: null, + }); + } + } + } + + if (this._leave) { + await this._leave(o, this.context); + } + }; + + private context: ASTWalkerContext<TNode>; + + setEnter = (fn: WalkerFn<ONode, TNode>) => { + this._enter = fn; + }; + + setLeave = (fn: WalkerFn<ONode, TNode>) => { + this._leave = fn; + }; + + setONodeTypeGuard = (fn: (node: unknown) => node is ONode) => { + this._isONode = fn; + }; + + walk = async (oNode: ONode, tNode: TNode) => { + this.context.openNode(tNode); + await this._visit({ node: oNode, parent: null, prop: null, index: null }); + if (this.context.stack.length !== 1) { + throw new BlockSuiteError(1, 'There are unclosed nodes'); + } + return this.context.currentNode(); + }; + + walkONode = async (oNode: ONode) => { + await this._visit({ node: oNode, parent: null, prop: null, index: null }); + }; + + constructor() { + this.context = new ASTWalkerContext<TNode>(); + } +} diff --git a/blocksuite/framework/store/src/adapter/context.ts b/blocksuite/framework/store/src/adapter/context.ts new file mode 100644 index 0000000000..d351ab202d --- /dev/null +++ b/blocksuite/framework/store/src/adapter/context.ts @@ -0,0 +1,119 @@ +type Keyof<T> = T extends unknown ? keyof T : never; + +export class ASTWalkerContext<TNode extends object> { + private _defaultProp: Keyof<TNode> = 'children' as unknown as Keyof<TNode>; + + private _globalContext: Record<string, unknown> = Object.create(null); + + private _stack: { + node: TNode; + prop: Keyof<TNode>; + context: Record<string, unknown>; + }[] = []; + + _skip = false; + + _skipChildrenNum = 0; + + setDefaultProp = (parentProp: Keyof<TNode>) => { + this._defaultProp = parentProp; + }; + + get stack() { + return this._stack; + } + + private current() { + return this._stack[this._stack.length - 1]; + } + + cleanGlobalContextStack(key: string) { + if (this._globalContext[key] instanceof Array) { + this._globalContext[key] = []; + } + } + + closeNode() { + const ele = this._stack.pop(); + if (!ele) return this; + const parent = this._stack.pop(); + if (!parent) { + this._stack.push(ele); + return this; + } + if (parent.node[ele.prop] instanceof Array) { + (parent.node[ele.prop] as Array<object>).push(ele.node); + } + this._stack.push(parent); + return this; + } + + currentNode() { + return this.current()?.node; + } + + getGlobalContext(key: string) { + return this._globalContext[key]; + } + + getGlobalContextStack<StackElement>(key: string) { + const stack = this._globalContext[key]; + if (stack instanceof Array) { + return stack as StackElement[]; + } else { + return [] as StackElement[]; + } + } + + getNodeContext(key: string) { + return this.current().context[key]; + } + + getPreviousNodeContext(key: string) { + return this._stack[this._stack.length - 2]?.context[key]; + } + + openNode(node: TNode, parentProp?: Keyof<TNode>) { + this._stack.push({ + node, + prop: parentProp ?? this._defaultProp, + context: Object.create(null), + }); + return this; + } + + previousNode() { + return this._stack[this._stack.length - 2]?.node; + } + + pushGlobalContextStack<StackElement>(key: string, value: StackElement) { + const stack = this._globalContext[key]; + if (stack instanceof Array) { + stack.push(value); + } else { + this._globalContext[key] = [value]; + } + } + + setGlobalContext(key: string, value: unknown) { + this._globalContext[key] = value; + return this; + } + + setGlobalContextStack<StackElement>(key: string, value: StackElement[]) { + this._globalContext[key] = value; + } + + setNodeContext(key: string, value: unknown) { + this._stack[this._stack.length - 1].context[key] = value; + return this; + } + + skipAllChildren() { + this._skip = true; + } + + skipChildren(num = 1) { + this._skipChildrenNum = num; + } +} diff --git a/blocksuite/framework/store/src/adapter/index.ts b/blocksuite/framework/store/src/adapter/index.ts new file mode 100644 index 0000000000..a71c16beb6 --- /dev/null +++ b/blocksuite/framework/store/src/adapter/index.ts @@ -0,0 +1,3 @@ +export * from './assets.js'; +export * from './base.js'; +export * from './context.js'; diff --git a/blocksuite/framework/store/src/consts.ts b/blocksuite/framework/store/src/consts.ts new file mode 100644 index 0000000000..91f85c961e --- /dev/null +++ b/blocksuite/framework/store/src/consts.ts @@ -0,0 +1,11 @@ +export const COLLECTION_VERSION = 2; + +export const PAGE_VERSION = 2; + +export const SCHEMA_NOT_FOUND_MESSAGE = + 'Schema not found. The block flavour may not be registered.'; + +export const TEXT_UNIQ_IDENTIFIER = '$blocksuite:internal:text$'; +export const NATIVE_UNIQ_IDENTIFIER = '$blocksuite:internal:native$'; + +export const SYS_KEYS = new Set(['id', 'flavour', 'children']); diff --git a/blocksuite/framework/store/src/index.ts b/blocksuite/framework/store/src/index.ts new file mode 100644 index 0000000000..557f413466 --- /dev/null +++ b/blocksuite/framework/store/src/index.ts @@ -0,0 +1,47 @@ +// eslint-disable-next-line @typescript-eslint/triple-slash-reference +/// <reference path="../shim.d.ts" /> + +export type { Y }; +export * from './adapter/index.js'; +export * from './reactive/index.js'; +export * from './schema/index.js'; +export * from './store/index.js'; +export * from './transformer/index.js'; +export { + createAutoIncrementIdGenerator, + createAutoIncrementIdGeneratorByClientId, + type IdGenerator, + nanoid, + uuidv4, +} from './utils/id-generator.js'; +export * as Utils from './utils/utils.js'; +export * from './yjs/index.js'; +export { Slot } from '@blocksuite/global/utils'; + +import type * as Y from 'yjs'; + +const env = + typeof globalThis !== 'undefined' + ? globalThis + : typeof window !== 'undefined' + ? window + : // oxlint-disable-next-line + // @ts-ignore FIXME: typecheck error + typeof global !== 'undefined' + ? // oxlint-disable-next-line + // @ts-ignore FIXME: typecheck error + global + : {}; +const importIdentifier = '__ $BLOCKSUITE_STORE$ __'; + +// oxlint-disable-next-line +// @ts-ignore FIXME: typecheck error +if (env[importIdentifier] === true) { + // https://github.com/yjs/yjs/issues/438 + console.error( + '@blocksuite/store was already imported. This breaks constructor checks and will lead to issues!' + ); +} +// oxlint-disable-next-line +// @ts-ignore FIXME: typecheck error +env[importIdentifier] = true; diff --git a/blocksuite/framework/store/src/reactive/boxed.ts b/blocksuite/framework/store/src/reactive/boxed.ts new file mode 100644 index 0000000000..6289dd2537 --- /dev/null +++ b/blocksuite/framework/store/src/reactive/boxed.ts @@ -0,0 +1,55 @@ +import * as Y from 'yjs'; + +import { NATIVE_UNIQ_IDENTIFIER } from '../consts.js'; + +export type OnBoxedChange = (data: unknown) => void; + +export class Boxed<T = unknown> { + static from = <T>(map: Y.Map<T>, onChange?: OnBoxedChange): Boxed<T> => { + return new Boxed<T>(map.get('value') as T, onChange); + }; + + static is = (value: unknown): value is Boxed => { + return ( + value instanceof Y.Map && value.get('type') === NATIVE_UNIQ_IDENTIFIER + ); + }; + + private readonly _map: Y.Map<T>; + + private _onChange?: OnBoxedChange; + + getValue = () => { + return this._map.get('value'); + }; + + setValue = (value: T) => { + return this._map.set('value', value); + }; + + get yMap() { + return this._map; + } + + constructor(value: T, onChange?: OnBoxedChange) { + this._onChange = onChange; + if ( + value instanceof Y.Map && + value.doc && + value.get('type') === NATIVE_UNIQ_IDENTIFIER + ) { + this._map = value; + } else { + this._map = new Y.Map(); + this._map.set('type', NATIVE_UNIQ_IDENTIFIER as T); + this._map.set('value', value); + } + this._map.observeDeep(() => { + this._onChange?.(this.getValue()); + }); + } + + bind(onChange: OnBoxedChange) { + this._onChange = onChange; + } +} diff --git a/blocksuite/framework/store/src/reactive/index.ts b/blocksuite/framework/store/src/reactive/index.ts new file mode 100644 index 0000000000..5e528efc25 --- /dev/null +++ b/blocksuite/framework/store/src/reactive/index.ts @@ -0,0 +1,4 @@ +export * from './boxed.js'; +export * from './proxy.js'; +export * from './text.js'; +export * from './utils.js'; diff --git a/blocksuite/framework/store/src/reactive/proxy.ts b/blocksuite/framework/store/src/reactive/proxy.ts new file mode 100644 index 0000000000..26877d77ea --- /dev/null +++ b/blocksuite/framework/store/src/reactive/proxy.ts @@ -0,0 +1,357 @@ +import { BlockSuiteError, ErrorCode } from '@blocksuite/global/exceptions'; +import type { YArrayEvent, YMapEvent } from 'yjs'; +import { Array as YArray, Map as YMap } from 'yjs'; + +import { Boxed, type OnBoxedChange } from './boxed.js'; +import { type OnTextChange, Text } from './text.js'; +import type { UnRecord } from './utils.js'; +import { BaseReactiveYData, native2Y, y2Native } from './utils.js'; + +export type ProxyOptions<T> = { + onChange?: (data: T) => void; +}; + +const proxies = new WeakMap<any, BaseReactiveYData<any, any>>(); + +export class ReactiveYArray extends BaseReactiveYData< + unknown[], + YArray<unknown> +> { + private _observer = (event: YArrayEvent<unknown>) => { + this._onObserve(event, () => { + let retain = 0; + event.changes.delta.forEach(change => { + if (change.retain) { + retain += change.retain; + return; + } + if (change.delete) { + this._updateWithSkip(() => { + this._source.splice(retain, change.delete); + }); + return; + } + if (change.insert) { + const _arr = [change.insert].flat(); + + const proxyList = _arr.map(value => createYProxy(value)); + + this._updateWithSkip(() => { + this._source.splice(retain, 0, ...proxyList); + }); + + retain += change.insert.length; + } + }); + }); + }; + + protected _getProxy = () => { + return new Proxy(this._source, { + has: (target, p) => { + return Reflect.has(target, p); + }, + set: (target, p, value, receiver) => { + if (typeof p !== 'string') { + throw new BlockSuiteError( + ErrorCode.ReactiveProxyError, + 'key cannot be a symbol' + ); + } + + const index = Number(p); + if (this._skipNext || Number.isNaN(index)) { + return Reflect.set(target, p, value, receiver); + } + + if (this._stashed.has(index)) { + const result = Reflect.set(target, p, value, receiver); + this._options.onChange?.(this._proxy); + return result; + } + + const reactive = proxies.get(this._ySource); + if (!reactive) { + throw new BlockSuiteError( + ErrorCode.ReactiveProxyError, + 'YData is not subscribed before changes' + ); + } + const doc = this._ySource.doc; + if (!doc) { + throw new BlockSuiteError( + ErrorCode.ReactiveProxyError, + 'YData is not bound to a Y.Doc' + ); + } + + const yData = native2Y(value); + this._transact(doc, () => { + if (index < this._ySource.length) { + this._ySource.delete(index, 1); + } + this._ySource.insert(index, [yData]); + }); + const data = createYProxy(yData, this._options); + return Reflect.set(target, p, data, receiver); + }, + get: (target, p, receiver) => { + return Reflect.get(target, p, receiver); + }, + deleteProperty: (target, p): boolean => { + if (typeof p !== 'string') { + throw new BlockSuiteError( + ErrorCode.ReactiveProxyError, + 'key cannot be a symbol' + ); + } + + const proxied = proxies.get(this._ySource); + if (!proxied) { + throw new BlockSuiteError( + ErrorCode.ReactiveProxyError, + 'YData is not subscribed before changes' + ); + } + const doc = this._ySource.doc; + if (!doc) { + throw new BlockSuiteError( + ErrorCode.ReactiveProxyError, + 'YData is not bound to a Y.Doc' + ); + } + + const index = Number(p); + if (this._skipNext || Number.isNaN(index)) { + return Reflect.deleteProperty(target, p); + } + + this._transact(doc, () => { + this._ySource.delete(index, 1); + }); + return Reflect.deleteProperty(target, p); + }, + }); + }; + + protected readonly _proxy: unknown[]; + + constructor( + protected readonly _source: unknown[], + protected readonly _ySource: YArray<unknown>, + protected readonly _options: ProxyOptions<unknown[]> + ) { + super(); + this._proxy = this._getProxy(); + proxies.set(_ySource, this); + _ySource.observe(this._observer); + } + + pop(prop: number) { + const value = this._source[prop]; + this._stashed.delete(prop); + this._proxy[prop] = value; + } + + stash(prop: number) { + this._stashed.add(prop); + } +} + +export class ReactiveYMap extends BaseReactiveYData<UnRecord, YMap<unknown>> { + private _observer = (event: YMapEvent<unknown>) => { + this._onObserve(event, () => { + event.keysChanged.forEach(key => { + const type = event.changes.keys.get(key); + if (!type) { + return; + } + if (type.action === 'delete') { + this._updateWithSkip(() => { + delete this._source[key]; + }); + } else if (type.action === 'add' || type.action === 'update') { + const current = this._ySource.get(key); + this._updateWithSkip(() => { + this._source[key] = proxies.has(current) + ? proxies.get(current) + : createYProxy(current, this._options); + }); + } + }); + }); + }; + + protected _getProxy = () => { + return new Proxy(this._source, { + has: (target, p) => { + return Reflect.has(target, p); + }, + set: (target, p, value, receiver) => { + if (typeof p !== 'string') { + throw new BlockSuiteError( + ErrorCode.ReactiveProxyError, + 'key cannot be a symbol' + ); + } + if (this._skipNext) { + return Reflect.set(target, p, value, receiver); + } + + if (this._stashed.has(p)) { + const result = Reflect.set(target, p, value, receiver); + this._options.onChange?.(this._proxy); + return result; + } + + const reactive = proxies.get(this._ySource); + if (!reactive) { + throw new BlockSuiteError( + ErrorCode.ReactiveProxyError, + 'YData is not subscribed before changes' + ); + } + const doc = this._ySource.doc; + if (!doc) { + throw new BlockSuiteError( + ErrorCode.ReactiveProxyError, + 'YData is not bound to a Y.Doc' + ); + } + + const yData = native2Y(value); + this._transact(doc, () => { + this._ySource.set(p, yData); + }); + const data = createYProxy(yData, this._options); + return Reflect.set(target, p, data, receiver); + }, + get: (target, p, receiver) => { + return Reflect.get(target, p, receiver); + }, + deleteProperty: (target, p) => { + if (typeof p !== 'string') { + throw new BlockSuiteError( + ErrorCode.ReactiveProxyError, + 'key cannot be a symbol' + ); + } + if (this._skipNext) { + return Reflect.deleteProperty(target, p); + } + + const proxied = proxies.get(this._ySource); + if (!proxied) { + throw new BlockSuiteError( + ErrorCode.ReactiveProxyError, + 'YData is not subscribed before changes' + ); + } + const doc = this._ySource.doc; + if (!doc) { + throw new BlockSuiteError( + ErrorCode.ReactiveProxyError, + 'YData is not bound to a Y.Doc' + ); + } + + this._transact(doc, () => { + this._ySource.delete(p); + }); + + return Reflect.deleteProperty(target, p); + }, + }); + }; + + protected readonly _proxy: UnRecord; + + // eslint-disable-next-line sonarjs/no-identical-functions + constructor( + protected readonly _source: UnRecord, + protected readonly _ySource: YMap<unknown>, + protected readonly _options: ProxyOptions<UnRecord> + ) { + super(); + this._proxy = this._getProxy(); + proxies.set(_ySource, this); + _ySource.observe(this._observer); + } + + // eslint-disable-next-line sonarjs/no-identical-functions + pop(prop: string) { + const value = this._source[prop]; + this._stashed.delete(prop); + this._proxy[prop] = value; + } + + stash(prop: string) { + this._stashed.add(prop); + } +} + +export function createYProxy<Data>( + yAbstract: unknown, + options: ProxyOptions<Data> = {} +): Data { + if (proxies.has(yAbstract)) { + return proxies.get(yAbstract)!.proxy as Data; + } + + return y2Native(yAbstract, { + transform: (value, origin) => { + if (value instanceof Text) { + value.bind(options.onChange as OnTextChange); + return value; + } + if (Boxed.is(origin)) { + (value as Boxed).bind(options.onChange as OnBoxedChange); + return value; + } + if (origin instanceof YArray) { + const data = new ReactiveYArray( + value as unknown[], + origin, + options as ProxyOptions<unknown[]> + ); + return data.proxy; + } + if (origin instanceof YMap) { + const data = new ReactiveYMap( + value as UnRecord, + origin, + options as ProxyOptions<UnRecord> + ); + return data.proxy; + } + + return value; + }, + }) as Data; +} + +export function stashProp(yMap: YMap<unknown>, prop: string): void; +export function stashProp(yMap: YArray<unknown>, prop: number): void; +export function stashProp(yAbstract: unknown, prop: string | number) { + const proxy = proxies.get(yAbstract); + if (!proxy) { + throw new BlockSuiteError( + ErrorCode.ReactiveProxyError, + 'YData is not subscribed before changes' + ); + } + proxy.stash(prop); +} + +export function popProp(yMap: YMap<unknown>, prop: string): void; +export function popProp(yMap: YArray<unknown>, prop: number): void; +export function popProp(yAbstract: unknown, prop: string | number) { + const proxy = proxies.get(yAbstract); + if (!proxy) { + throw new BlockSuiteError( + ErrorCode.ReactiveProxyError, + 'YData is not subscribed before changes' + ); + } + proxy.pop(prop); +} diff --git a/blocksuite/framework/store/src/reactive/text.ts b/blocksuite/framework/store/src/reactive/text.ts new file mode 100644 index 0000000000..9fb8af5105 --- /dev/null +++ b/blocksuite/framework/store/src/reactive/text.ts @@ -0,0 +1,336 @@ +import { BlockSuiteError, ErrorCode } from '@blocksuite/global/exceptions'; +import type { BaseTextAttributes, DeltaInsert } from '@blocksuite/inline'; +import { type Signal, signal } from '@preact/signals-core'; +import * as Y from 'yjs'; + +export interface OptionalAttributes { + attributes?: Record<string, any>; +} + +export type DeltaOperation = { + insert?: string; + delete?: number; + retain?: number; +} & OptionalAttributes; + +export type OnTextChange = (data: Y.Text) => void; + +export class Text { + private _deltas$: Signal<DeltaOperation[]>; + + private _length$: Signal<number>; + + private _onChange?: OnTextChange; + + private readonly _yText: Y.Text; + + get deltas$() { + return this._deltas$; + } + + get length() { + return this._length$.value; + } + + get yText() { + return this._yText; + } + + constructor( + input?: Y.Text | string | DeltaInsert[], + onChange?: OnTextChange + ) { + this._onChange = onChange; + let length = 0; + if (typeof input === 'string') { + const text = input.replaceAll('\r\n', '\n'); + length = text.length; + this._yText = new Y.Text(text); + } else if (input instanceof Y.Text) { + this._yText = input; + if (input.doc) { + length = input.length; + } + } else if (input instanceof Array) { + for (const delta of input) { + if (delta.insert) { + delta.insert = delta.insert.replaceAll('\r\n', '\n'); + length += delta.insert.length; + } + } + const yText = new Y.Text(); + yText.applyDelta(input); + this._yText = yText; + } else { + this._yText = new Y.Text(); + } + + this._length$ = signal(length); + this._deltas$ = signal(this._yText.doc ? this._yText.toDelta() : []); + this._yText.observe(() => { + this._length$.value = this._yText.length; + this._deltas$.value = this._yText.toDelta(); + this._onChange?.(this._yText); + }); + } + + private _transact(callback: () => void) { + const doc = this._yText.doc; + if (!doc) { + throw new BlockSuiteError( + ErrorCode.ReactiveProxyError, + 'Failed to transact text! yText is not attached to a doc' + ); + } + doc.transact(() => { + callback(); + }, doc.clientID); + } + + applyDelta(delta: DeltaOperation[]) { + this._transact(() => { + this._yText?.applyDelta(delta); + }); + } + + bind(onChange?: OnTextChange) { + this._onChange = onChange; + } + + clear() { + if (!this._yText.length) { + return; + } + this._transact(() => { + this._yText.delete(0, this._yText.length); + }); + } + + clone() { + return new Text(this._yText.clone(), this._onChange); + } + + delete(index: number, length: number) { + if (length === 0) { + return; + } + if (index < 0 || length < 0 || index + length > this._yText.length) { + throw new BlockSuiteError( + ErrorCode.ReactiveProxyError, + 'Failed to delete text! Index or length out of range, index: ' + + index + + ', length: ' + + length + + ', text length: ' + + this._yText.length + ); + } + this._transact(() => { + this._yText.delete(index, length); + }); + } + + format(index: number, length: number, format: any) { + if (length === 0) { + return; + } + if (index < 0 || length < 0 || index + length > this._yText.length) { + throw new BlockSuiteError( + ErrorCode.ReactiveProxyError, + 'Failed to format text! Index or length out of range, index: ' + + index + + ', length: ' + + length + + ', text length: ' + + this._yText.length + ); + } + this._transact(() => { + this._yText.format(index, length, format); + }); + } + + insert(content: string, index: number, attributes?: Record<string, unknown>) { + if (!content.length) { + return; + } + if (index < 0 || index > this._yText.length) { + throw new BlockSuiteError( + ErrorCode.ReactiveProxyError, + 'Failed to insert text! Index or length out of range, index: ' + + index + + ', length: ' + + length + + ', text length: ' + + this._yText.length + ); + } + this._transact(() => { + this._yText.insert(index, content, attributes); + }); + } + + join(other: Text) { + if (!other || !other.toDelta().length) { + return; + } + this._transact(() => { + const yOther = other._yText; + const delta: DeltaOperation[] = yOther.toDelta(); + delta.unshift({ retain: this._yText.length }); + this._yText.applyDelta(delta); + }); + } + + replace( + index: number, + length: number, + content: string, + attributes?: BaseTextAttributes + ) { + if (index < 0 || length < 0 || index + length > this._yText.length) { + throw new BlockSuiteError( + ErrorCode.ReactiveProxyError, + 'Failed to replace text! The length of the text is' + + this._yText.length + + ', but you are trying to replace from' + + index + + 'to' + + index + + length + ); + } + this._transact(() => { + this._yText.delete(index, length); + this._yText.insert(index, content, attributes); + }); + } + + sliceToDelta(begin: number, end?: number): DeltaOperation[] { + const result: DeltaOperation[] = []; + if (end && begin >= end) { + return result; + } + + if (begin === 0 && end === 0) { + return []; + } + + const delta = this.toDelta(); + if (begin < 1 && !end) { + return delta; + } + + if (delta && delta instanceof Array) { + let charNum = 0; + // eslint-disable-next-line @typescript-eslint/prefer-for-of + for (let i = 0; i < delta.length; i++) { + const content = delta[i]; + let contentText: string = content.insert || ''; + const contentLen = contentText.length; + + const isLastOp = end && charNum + contentLen > end; + const isFirstOp = charNum + contentLen > begin && result.length === 0; + if (isFirstOp && isLastOp) { + contentText = contentText.slice(begin - charNum, end - charNum); + result.push({ + ...content, + insert: contentText, + }); + break; + } else if (isFirstOp || isLastOp) { + contentText = isLastOp + ? contentText.slice(0, end - charNum) + : contentText.slice(begin - charNum); + + result.push({ + ...content, + insert: contentText, + }); + } else { + result.length > 0 && result.push(content); + } + + if (end && charNum + contentLen > end) { + break; + } + + charNum = charNum + contentLen; + } + } + return result; + } + + /** + * NOTE: The string included in [index, index + length) will be deleted. + * + * Here are three cases for point position(index + length): + * [{insert: 'abc', ...}, {insert: 'def', ...}, {insert: 'ghi', ...}] + * 1. abc|de|fghi + * left: [{insert: 'abc', ...}] + * right: [{insert: 'f', ...}, {insert: 'ghi', ...}] + * 2. abc|def|ghi + * left: [{insert: 'abc', ...}] + * right: [{insert: 'ghi', ...}] + * 3. abc|defg|hi + * left: [{insert: 'abc', ...}] + * right: [{insert: 'hi', ...}] + */ + split(index: number, length = 0): Text { + if (index < 0 || length < 0 || index + length > this._yText.length) { + throw new BlockSuiteError( + ErrorCode.ReactiveProxyError, + 'Failed to split text! Index or length out of range, index: ' + + index + + ', length: ' + + length + + ', text length: ' + + this._yText.length + ); + } + const deltas = this._yText.toDelta(); + if (!(deltas instanceof Array)) { + throw new BlockSuiteError( + ErrorCode.ReactiveProxyError, + 'This text cannot be split because we failed to get the deltas of it.' + ); + } + let tmpIndex = 0; + const rightDeltas: DeltaInsert[] = []; + for (let i = 0; i < deltas.length; i++) { + const insert = deltas[i].insert; + if (typeof insert === 'string') { + if (tmpIndex + insert.length >= index + length) { + const insertRight = insert.slice(index + length - tmpIndex); + rightDeltas.push({ + insert: insertRight, + attributes: deltas[i].attributes, + }); + rightDeltas.push(...deltas.slice(i + 1)); + break; + } + tmpIndex += insert.length; + } else { + throw new BlockSuiteError( + ErrorCode.ReactiveProxyError, + 'This text cannot be split because it contains non-string insert.' + ); + } + } + + this.delete(index, this.length - index); + const rightYText = new Y.Text(); + rightYText.applyDelta(rightDeltas); + const rightText = new Text(rightYText, this._onChange); + + return rightText; + } + + toDelta(): DeltaOperation[] { + return this._yText?.toDelta() || []; + } + + toString() { + return this._yText?.toString() || ''; + } +} diff --git a/blocksuite/framework/store/src/reactive/utils.ts b/blocksuite/framework/store/src/reactive/utils.ts new file mode 100644 index 0000000000..d186a4dd03 --- /dev/null +++ b/blocksuite/framework/store/src/reactive/utils.ts @@ -0,0 +1,157 @@ +import type { Doc as YDoc, YEvent } from 'yjs'; +import { Array as YArray, Map as YMap, Text as YText, UndoManager } from 'yjs'; + +import { Boxed } from './boxed.js'; +import type { ProxyOptions } from './proxy.js'; +import { Text } from './text.js'; + +export type Native2Y<T> = + T extends Record<string, infer U> + ? YMap<U> + : T extends Array<infer U> + ? YArray<U> + : T; + +export function isPureObject(value: unknown): value is object { + return ( + value !== null && + typeof value === 'object' && + Object.prototype.toString.call(value) === '[object Object]' && + [Object, undefined, null].some(x => x === value.constructor) + ); +} + +type TransformOptions = { + deep?: boolean; + transform?: (value: unknown, origin: unknown) => unknown; +}; + +export function native2Y<T>( + value: T, + { deep = true, transform = x => x }: TransformOptions = {} +): Native2Y<T> { + if (value instanceof Boxed) { + return value.yMap as Native2Y<T>; + } + if (value instanceof Text) { + if (value.yText.doc) { + return value.yText.clone() as Native2Y<T>; + } + return value.yText as Native2Y<T>; + } + if (Array.isArray(value)) { + const yArray: YArray<unknown> = new YArray<unknown>(); + const result = value.map(item => { + return deep ? native2Y(item, { deep, transform }) : item; + }); + yArray.insert(0, result); + + return yArray as Native2Y<T>; + } + if (isPureObject(value)) { + const yMap = new YMap<unknown>(); + Object.entries(value).forEach(([key, value]) => { + yMap.set(key, deep ? native2Y(value, { deep, transform }) : value); + }); + + return yMap as Native2Y<T>; + } + + return value as Native2Y<T>; +} + +export function y2Native( + yAbstract: unknown, + { deep = true, transform = x => x }: TransformOptions = {} +) { + if (Boxed.is(yAbstract)) { + const data = new Boxed(yAbstract); + return transform(data, yAbstract); + } + if (yAbstract instanceof YText) { + const data = new Text(yAbstract); + return transform(data, yAbstract); + } + if (yAbstract instanceof YArray) { + const data: unknown[] = yAbstract + .toArray() + .map(item => (deep ? y2Native(item, { deep, transform }) : item)); + + return transform(data, yAbstract); + } + if (yAbstract instanceof YMap) { + const data: Record<string, unknown> = Object.fromEntries( + Array.from(yAbstract.entries()).map(([key, value]) => { + return [key, deep ? y2Native(value, { deep, transform }) : value] as [ + string, + unknown, + ]; + }) + ); + return transform(data, yAbstract); + } + + return transform(yAbstract, yAbstract); +} + +export type UnRecord = Record<string, unknown>; + +export abstract class BaseReactiveYData<T, Y> { + protected _getOrigin = ( + doc: YDoc + ): { + doc: YDoc; + proxy: true; + + target: BaseReactiveYData<any, any>; + } => { + return { + doc, + proxy: true, + target: this, + }; + }; + + protected _onObserve = (event: YEvent<any>, handler: () => void) => { + if ( + event.transaction.origin?.proxy !== true && + (!event.transaction.local || + event.transaction.origin instanceof UndoManager) + ) { + handler(); + } + + this._options.onChange?.(this._proxy); + }; + + protected abstract readonly _options: ProxyOptions<T>; + + protected abstract readonly _proxy: T; + + protected _skipNext = false; + + protected abstract readonly _source: T; + + protected readonly _stashed = new Set<string | number>(); + + protected _transact = (doc: YDoc, fn: () => void) => { + doc.transact(fn, this._getOrigin(doc)); + }; + + protected _updateWithSkip = (fn: () => void) => { + this._skipNext = true; + fn(); + this._skipNext = false; + }; + + protected abstract readonly _ySource: Y; + + get proxy() { + return this._proxy; + } + + protected abstract _getProxy(): T; + + abstract pop(prop: string | number): void; + abstract stash(prop: string | number): void; +} diff --git a/blocksuite/framework/store/src/schema/base.ts b/blocksuite/framework/store/src/schema/base.ts new file mode 100644 index 0000000000..a22204f9df --- /dev/null +++ b/blocksuite/framework/store/src/schema/base.ts @@ -0,0 +1,274 @@ +import { type Disposable, Slot } from '@blocksuite/global/utils'; +import type { Signal } from '@preact/signals-core'; +import { computed, signal } from '@preact/signals-core'; +import type * as Y from 'yjs'; +import { z } from 'zod'; + +import { Boxed } from '../reactive/boxed.js'; +import { Text } from '../reactive/text.js'; +import type { YBlock } from '../store/doc/block/index.js'; +import type { Doc } from '../store/index.js'; +import type { BaseBlockTransformer } from '../transformer/base.js'; + +const FlavourSchema = z.string(); +const ParentSchema = z.array(z.string()).optional(); +const ContentSchema = z.array(z.string()).optional(); +const role = ['root', 'hub', 'content'] as const; +const RoleSchema = z.enum(role); + +export type RoleType = (typeof role)[number]; + +export interface InternalPrimitives { + Text: (input?: Y.Text | string) => Text; + Boxed: <T>(input: T) => Boxed<T>; +} + +export const internalPrimitives: InternalPrimitives = Object.freeze({ + Text: (input: Y.Text | string = '') => new Text(input), + Boxed: <T>(input: T) => new Boxed(input), +}); + +export const BlockSchema = z.object({ + version: z.number(), + model: z.object({ + role: RoleSchema, + flavour: FlavourSchema, + parent: ParentSchema, + children: ContentSchema, + props: z + .function() + .args(z.custom<InternalPrimitives>()) + .returns(z.record(z.any())) + .optional(), + toModel: z.function().args().returns(z.custom<BlockModel>()).optional(), + }), + transformer: z + .function() + .args() + .returns(z.custom<BaseBlockTransformer>()) + .optional(), +}); + +export type BlockSchemaType = z.infer<typeof BlockSchema>; + +export type PropsGetter<Props> = ( + internalPrimitives: InternalPrimitives +) => Props; + +export type SchemaToModel< + Schema extends { + model: { + props: PropsGetter<object>; + flavour: string; + }; + }, +> = BlockModel<ReturnType<Schema['model']['props']>> & + ReturnType<Schema['model']['props']> & { + flavour: Schema['model']['flavour']; + }; + +export function defineBlockSchema< + Flavour extends string, + Role extends RoleType, + Props extends object, + Metadata extends Readonly<{ + version: number; + role: Role; + parent?: string[]; + children?: string[]; + }>, + Model extends BlockModel<Props>, + Transformer extends BaseBlockTransformer<Props>, +>(options: { + flavour: Flavour; + metadata: Metadata; + props?: (internalPrimitives: InternalPrimitives) => Props; + toModel?: () => Model; + transformer?: () => Transformer; +}): { + version: number; + model: { + props: PropsGetter<Props>; + flavour: Flavour; + } & Metadata; + transformer?: () => Transformer; +}; + +export function defineBlockSchema({ + flavour, + props, + metadata, + toModel, + transformer, +}: { + flavour: string; + metadata: { + version: number; + role: RoleType; + parent?: string[]; + children?: string[]; + }; + props?: (internalPrimitives: InternalPrimitives) => Record<string, unknown>; + toModel?: () => BlockModel; + transformer?: () => BaseBlockTransformer; +}): BlockSchemaType { + const schema = { + version: metadata.version, + model: { + role: metadata.role, + parent: metadata.parent, + children: metadata.children, + flavour, + props, + toModel, + }, + transformer, + } satisfies z.infer<typeof BlockSchema>; + BlockSchema.parse(schema); + return schema; +} + +type SignaledProps<Props> = Props & { + [P in keyof Props & string as `${P}$`]: Signal<Props[P]>; +}; +/** + * The MagicProps function is used to append the props to the class. + * For example: + * + * ```ts + * class MyBlock extends MagicProps()<{ foo: string }> {} + * const myBlock = new MyBlock(); + * // You'll get type checking for the foo prop + * myBlock.foo = 'bar'; + * ``` + */ +function MagicProps(): { + new <Props>(): Props; +} { + return class {} as never; +} + +const modelLabel = Symbol('model_label'); + +// @ts-expect-error FIXME: ts error +export class BlockModel< + Props extends object = object, + PropsSignal extends object = SignaledProps<Props>, +> extends MagicProps()<PropsSignal> { + private _children = signal<string[]>([]); + + /** + * @deprecated use doc instead + */ + page!: Doc; + + private _childModels = computed(() => { + const value: BlockModel[] = []; + this._children.value.forEach(id => { + const block = this.page.getBlock$(id); + if (block) { + value.push(block.model); + } + }); + return value; + }); + + private _onCreated: Disposable; + + private _onDeleted: Disposable; + + childMap = computed(() => + this._children.value.reduce((map, id, index) => { + map.set(id, index); + return map; + }, new Map<string, number>()) + ); + + created = new Slot(); + + deleted = new Slot(); + + flavour!: string; + + id!: string; + + isEmpty = computed(() => { + return this._children.value.length === 0; + }); + + keys!: string[]; + + // This is used to avoid https://stackoverflow.com/questions/55886792/infer-typescript-generic-class-type + [modelLabel]: Props = 'type_info_label' as never; + + pop!: (prop: keyof Props & string) => void; + + propsUpdated = new Slot<{ key: string }>(); + + role!: RoleType; + + stash!: (prop: keyof Props & string) => void; + + // text is optional + text?: Text; + + version!: number; + + yBlock!: YBlock; + + get children() { + return this._childModels.value; + } + + get doc() { + return this.page; + } + + set doc(doc: Doc) { + this.page = doc; + } + + get parent() { + return this.doc.getParent(this); + } + + constructor() { + super(); + this._onCreated = this.created.once(() => { + this._children.value = this.yBlock.get('sys:children').toArray(); + this.yBlock.get('sys:children').observe(event => { + this._children.value = event.target.toArray(); + }); + this.yBlock.observe(event => { + if (event.keysChanged.has('sys:children')) { + this._children.value = this.yBlock.get('sys:children').toArray(); + } + }); + }); + this._onDeleted = this.deleted.once(() => { + this._onCreated.dispose(); + }); + } + + dispose() { + this.created.dispose(); + this.deleted.dispose(); + this.propsUpdated.dispose(); + } + + firstChild(): BlockModel | null { + return this.children[0] || null; + } + + lastChild(): BlockModel | null { + if (!this.children.length) { + return this; + } + return this.children[this.children.length - 1].lastChild(); + } + + [Symbol.dispose]() { + this._onCreated.dispose(); + this._onDeleted.dispose(); + } +} diff --git a/blocksuite/framework/store/src/schema/error.ts b/blocksuite/framework/store/src/schema/error.ts new file mode 100644 index 0000000000..6b44cab175 --- /dev/null +++ b/blocksuite/framework/store/src/schema/error.ts @@ -0,0 +1,20 @@ +import { BlockSuiteError, ErrorCode } from '@blocksuite/global/exceptions'; + +export class MigrationError extends BlockSuiteError { + constructor(description: string) { + super( + ErrorCode.MigrationError, + `Migration failed. Please report to https://github.com/toeverything/blocksuite/issues + ${description}` + ); + } +} + +export class SchemaValidateError extends BlockSuiteError { + constructor(flavour: string, message: string) { + super( + ErrorCode.SchemaValidateError, + `Invalid schema for ${flavour}: ${message}` + ); + } +} diff --git a/blocksuite/framework/store/src/schema/index.ts b/blocksuite/framework/store/src/schema/index.ts new file mode 100644 index 0000000000..63258f38c7 --- /dev/null +++ b/blocksuite/framework/store/src/schema/index.ts @@ -0,0 +1,2 @@ +export * from './base.js'; +export { Schema } from './schema.js'; diff --git a/blocksuite/framework/store/src/schema/schema.ts b/blocksuite/framework/store/src/schema/schema.ts new file mode 100644 index 0000000000..9b1d04afe2 --- /dev/null +++ b/blocksuite/framework/store/src/schema/schema.ts @@ -0,0 +1,182 @@ +import { minimatch } from 'minimatch'; + +import { SCHEMA_NOT_FOUND_MESSAGE } from '../consts.js'; +import type { BlockSchemaType } from './base.js'; +import { BlockSchema } from './base.js'; +import { SchemaValidateError } from './error.js'; + +export class Schema { + readonly flavourSchemaMap = new Map<string, BlockSchemaType>(); + + validate = ( + flavour: string, + parentFlavour?: string, + childFlavours?: string[] + ): void => { + const schema = this.flavourSchemaMap.get(flavour); + if (!schema) { + throw new SchemaValidateError(flavour, SCHEMA_NOT_FOUND_MESSAGE); + } + + const validateChildren = () => { + childFlavours?.forEach(childFlavour => { + const childSchema = this.flavourSchemaMap.get(childFlavour); + if (!childSchema) { + throw new SchemaValidateError(childFlavour, SCHEMA_NOT_FOUND_MESSAGE); + } + this.validateSchema(childSchema, schema); + }); + }; + + if (schema.model.role === 'root') { + if (parentFlavour) { + throw new SchemaValidateError( + schema.model.flavour, + 'Root block cannot have parent.' + ); + } + + validateChildren(); + return; + } + + if (!parentFlavour) { + throw new SchemaValidateError( + schema.model.flavour, + 'Hub/Content must have parent.' + ); + } + + const parentSchema = this.flavourSchemaMap.get(parentFlavour); + if (!parentSchema) { + throw new SchemaValidateError(parentFlavour, SCHEMA_NOT_FOUND_MESSAGE); + } + this.validateSchema(schema, parentSchema); + validateChildren(); + }; + + get versions() { + return Object.fromEntries( + Array.from(this.flavourSchemaMap.values()).map( + (schema): [string, number] => [schema.model.flavour, schema.version] + ) + ); + } + + private _matchFlavour(childFlavour: string, parentFlavour: string) { + return ( + minimatch(childFlavour, parentFlavour) || + minimatch(parentFlavour, childFlavour) + ); + } + + private _validateParent( + child: BlockSchemaType, + parent: BlockSchemaType + ): boolean { + const _childFlavour = child.model.flavour; + const _parentFlavour = parent.model.flavour; + + const childValidFlavours = child.model.parent || ['*']; + const parentValidFlavours = parent.model.children || ['*']; + + return parentValidFlavours.some(parentValidFlavour => { + return childValidFlavours.some(childValidFlavour => { + if (parentValidFlavour === '*' && childValidFlavour === '*') { + return true; + } + + if (parentValidFlavour === '*') { + return this._matchFlavour(childValidFlavour, _parentFlavour); + } + + if (childValidFlavour === '*') { + return this._matchFlavour(_childFlavour, parentValidFlavour); + } + + return ( + this._matchFlavour(_childFlavour, parentValidFlavour) && + this._matchFlavour(childValidFlavour, _parentFlavour) + ); + }); + }); + } + + private _validateRole(child: BlockSchemaType, parent: BlockSchemaType) { + const childRole = child.model.role; + const parentRole = parent.model.role; + const childFlavour = child.model.flavour; + const parentFlavour = parent.model.flavour; + + if (childRole === 'root') { + throw new SchemaValidateError( + childFlavour, + `Root block cannot have parent: ${parentFlavour}.` + ); + } + + if (childRole === 'hub' && parentRole === 'content') { + throw new SchemaValidateError( + childFlavour, + `Hub block cannot be child of content block: ${parentFlavour}.` + ); + } + + if (childRole === 'content' && parentRole === 'root') { + throw new SchemaValidateError( + childFlavour, + `Content block can only be child of hub block or itself. But get: ${parentFlavour}.` + ); + } + } + + isValid(child: string, parent: string) { + const childSchema = this.flavourSchemaMap.get(child); + const parentSchema = this.flavourSchemaMap.get(parent); + if (!childSchema || !parentSchema) { + return false; + } + try { + this.validateSchema(childSchema, parentSchema); + return true; + } catch { + return false; + } + } + + register(blockSchema: BlockSchemaType[]) { + blockSchema.forEach(schema => { + BlockSchema.parse(schema); + this.flavourSchemaMap.set(schema.model.flavour, schema); + }); + return this; + } + + toJSON() { + return Object.fromEntries( + Array.from(this.flavourSchemaMap.values()).map( + (schema): [string, Record<string, unknown>] => [ + schema.model.flavour, + { + role: schema.model.role, + parent: schema.model.parent, + children: schema.model.children, + }, + ] + ) + ); + } + + validateSchema(child: BlockSchemaType, parent: BlockSchemaType) { + this._validateRole(child, parent); + + const relationCheckSuccess = this._validateParent(child, parent); + + if (!relationCheckSuccess) { + throw new SchemaValidateError( + child.model.flavour, + `Block cannot have parent: ${parent.model.flavour}.` + ); + } + } +} diff --git a/blocksuite/framework/store/src/store/addon/index.ts b/blocksuite/framework/store/src/store/addon/index.ts new file mode 100644 index 0000000000..84e7ac652c --- /dev/null +++ b/blocksuite/framework/store/src/store/addon/index.ts @@ -0,0 +1,2 @@ +export { test } from './test.js'; +export { DocCollectionAddonType } from './type.js'; diff --git a/blocksuite/framework/store/src/store/addon/shared.ts b/blocksuite/framework/store/src/store/addon/shared.ts new file mode 100644 index 0000000000..c5bfec4744 --- /dev/null +++ b/blocksuite/framework/store/src/store/addon/shared.ts @@ -0,0 +1,19 @@ +import type { DocCollection, DocCollectionOptions } from '../collection.js'; + +type DocCollectionConstructor<Keys extends string> = { + new (storeOptions: DocCollectionOptions): Omit<DocCollection, Keys>; +}; + +export type AddOn<Keys extends string> = ( + originalClass: DocCollectionConstructor<Keys>, + context: ClassDecoratorContext +) => { new (storeOptions: DocCollectionOptions): unknown }; + +export type AddOnReturn<Keys extends string> = ( + originalClass: DocCollectionConstructor<Keys>, + context: ClassDecoratorContext +) => typeof DocCollection; + +export function addOnFactory<Keys extends string>(fn: AddOn<Keys>) { + return fn as AddOnReturn<Keys>; +} diff --git a/blocksuite/framework/store/src/store/addon/test.ts b/blocksuite/framework/store/src/store/addon/test.ts new file mode 100644 index 0000000000..cc3a8c486d --- /dev/null +++ b/blocksuite/framework/store/src/store/addon/test.ts @@ -0,0 +1,38 @@ +import { assertExists } from '@blocksuite/global/utils'; + +import type { JSXElement } from '../../utils/jsx.js'; +import { serializeYDoc, yDocToJSXNode } from '../../utils/jsx.js'; +import { addOnFactory } from './shared.js'; + +export interface TestAddon { + importDocSnapshot: (json: unknown, docId: string) => Promise<void>; + exportJSX: (blockId?: string, docId?: string) => JSXElement; +} + +export const test = addOnFactory<keyof TestAddon>( + originalClass => + class extends originalClass { + /** @internal Only for testing */ + exportJSX(blockId?: string, docId = this.meta.docMetas.at(0)?.id) { + assertExists(docId); + const doc = this.doc.spaces.get(docId); + assertExists(doc); + const docJson = serializeYDoc(doc); + if (!docJson) { + throw new Error(`Doc ${docId} doesn't exist`); + } + const blockJson = docJson.blocks as Record<string, unknown>; + if (!blockId) { + const rootId = Object.keys(blockJson).at(0); + if (!rootId) { + return null; + } + blockId = rootId; + } + if (!blockJson[blockId]) { + return null; + } + return yDocToJSXNode(blockJson, blockId); + } + } +); diff --git a/blocksuite/framework/store/src/store/addon/type.ts b/blocksuite/framework/store/src/store/addon/type.ts new file mode 100644 index 0000000000..d6d12553bd --- /dev/null +++ b/blocksuite/framework/store/src/store/addon/type.ts @@ -0,0 +1,7 @@ +import type { TestAddon } from './test.js'; + +export class DocCollectionAddonType implements TestAddon { + exportJSX!: TestAddon['exportJSX']; + + importDocSnapshot!: TestAddon['importDocSnapshot']; +} diff --git a/blocksuite/framework/store/src/store/collection.ts b/blocksuite/framework/store/src/store/collection.ts new file mode 100644 index 0000000000..a5c86f3746 --- /dev/null +++ b/blocksuite/framework/store/src/store/collection.ts @@ -0,0 +1,306 @@ +import { BlockSuiteError, ErrorCode } from '@blocksuite/global/exceptions'; +import type { BlockSuiteFlags } from '@blocksuite/global/types'; +import { type Logger, NoopLogger, Slot } from '@blocksuite/global/utils'; +import { + AwarenessEngine, + type AwarenessSource, + BlobEngine, + type BlobSource, + DocEngine, + type DocSource, + MemoryBlobSource, + NoopDocSource, +} from '@blocksuite/sync'; +import clonedeep from 'lodash.clonedeep'; +import merge from 'lodash.merge'; +import { Awareness } from 'y-protocols/awareness.js'; +import * as Y from 'yjs'; + +import type { Schema } from '../schema/index.js'; +import type { IdGenerator } from '../utils/id-generator.js'; +import { + AwarenessStore, + BlockSuiteDoc, + type RawAwarenessState, +} from '../yjs/index.js'; +import { DocCollectionAddonType, test } from './addon/index.js'; +import { BlockCollection, type GetDocOptions } from './doc/block-collection.js'; +import type { Doc, Query } from './doc/index.js'; +import type { IdGeneratorType } from './id.js'; +import { pickIdGenerator } from './id.js'; +import { DocCollectionMeta, type DocMeta } from './meta.js'; + +export type DocCollectionOptions = { + schema: Schema; + id?: string; + idGenerator?: IdGeneratorType | IdGenerator; + defaultFlags?: Partial<BlockSuiteFlags>; + logger?: Logger; + docSources?: { + main: DocSource; + shadows?: DocSource[]; + }; + blobSources?: { + main: BlobSource; + shadows?: BlobSource[]; + }; + awarenessSources?: AwarenessSource[]; +}; + +const FLAGS_PRESET = { + enable_synced_doc_block: false, + enable_pie_menu: false, + enable_database_number_formatting: false, + enable_database_attachment_note: false, + enable_database_full_width: false, + enable_legacy_validation: true, + enable_block_query: false, + enable_lasso_tool: false, + enable_edgeless_text: true, + enable_ai_onboarding: false, + enable_ai_chat_block: false, + enable_color_picker: false, + enable_mind_map_import: false, + enable_advanced_block_visibility: false, + enable_shape_shadow_blur: false, + enable_new_dnd: true, + enable_mobile_keyboard_toolbar: false, + enable_mobile_linked_doc_menu: false, + readonly: {}, +} satisfies BlockSuiteFlags; + +export interface StackItem { + meta: Map<'cursor-location' | 'selection-state', unknown>; +} + +// oxlint-disable-next-line +// @ts-ignore FIXME: typecheck error +@test +export class DocCollection extends DocCollectionAddonType { + static Y = Y; + + protected readonly _schema: Schema; + + readonly awarenessStore: AwarenessStore; + + readonly awarenessSync: AwarenessEngine; + + readonly blobSync: BlobEngine; + + readonly blockCollections = new Map<string, BlockCollection>(); + + readonly doc: BlockSuiteDoc; + + readonly docSync: DocEngine; + + readonly id: string; + + readonly idGenerator: IdGenerator; + + meta: DocCollectionMeta; + + slots = { + docAdded: new Slot<string>(), + docUpdated: new Slot(), + docRemoved: new Slot<string>(), + docCreated: new Slot<string>(), + }; + + get docs() { + return this.blockCollections; + } + + get isEmpty() { + if (this.doc.store.clients.size === 0) return true; + + let flag = false; + if (this.doc.store.clients.size === 1) { + const items = Array.from(this.doc.store.clients.values())[0]; + // workspaceVersion and pageVersion were set when the collection is initialized + if (items.length <= 2) { + flag = true; + } + } + return flag; + } + + get schema() { + return this._schema; + } + + constructor({ + id, + schema, + idGenerator, + defaultFlags, + awarenessSources = [], + docSources = { + main: new NoopDocSource(), + }, + blobSources = { + main: new MemoryBlobSource(), + }, + logger = new NoopLogger(), + }: DocCollectionOptions) { + super(); + this._schema = schema; + + this.id = id || ''; + this.doc = new BlockSuiteDoc({ guid: id }); + this.awarenessStore = new AwarenessStore( + new Awareness<RawAwarenessState>(this.doc), + merge(clonedeep(FLAGS_PRESET), defaultFlags) + ); + + this.awarenessSync = new AwarenessEngine( + this.awarenessStore.awareness, + awarenessSources + ); + this.docSync = new DocEngine( + this.doc, + docSources.main, + docSources.shadows ?? [], + logger + ); + this.blobSync = new BlobEngine( + blobSources.main, + blobSources.shadows ?? [], + logger + ); + + this.idGenerator = pickIdGenerator(idGenerator, this.doc.clientID); + + this.meta = new DocCollectionMeta(this.doc); + this._bindDocMetaEvents(); + } + + private _bindDocMetaEvents() { + this.meta.docMetaAdded.on(docId => { + const doc = new BlockCollection({ + id: docId, + collection: this, + doc: this.doc, + awarenessStore: this.awarenessStore, + idGenerator: this.idGenerator, + }); + this.blockCollections.set(doc.id, doc); + this.slots.docAdded.emit(doc.id); + }); + + this.meta.docMetaUpdated.on(() => this.slots.docUpdated.emit()); + + this.meta.docMetaRemoved.on(id => { + const space = this.getBlockCollection(id); + if (!space) return; + this.blockCollections.delete(id); + space.remove(); + this.slots.docRemoved.emit(id); + }); + } + + private _hasDoc(docId: string) { + return this.docs.has(docId); + } + + /** + * Verify that all data has been successfully saved to the primary storage. + * Return true if the data transfer is complete and it is secure to terminate the synchronization operation. + */ + canGracefulStop() { + this.docSync.canGracefulStop(); + } + + /** + * By default, only an empty doc will be created. + * If the `init` parameter is passed, a `surface`, `note`, and `paragraph` block + * will be created in the doc simultaneously. + */ + createDoc(options: { id?: string; query?: Query } = {}) { + const { id: docId = this.idGenerator(), query } = options; + if (this._hasDoc(docId)) { + throw new BlockSuiteError( + ErrorCode.DocCollectionError, + 'doc already exists' + ); + } + + this.meta.addDocMeta({ + id: docId, + title: '', + createDate: Date.now(), + tags: [], + }); + this.slots.docCreated.emit(docId); + return this.getDoc(docId, { query }) as Doc; + } + + dispose() { + this.awarenessStore.destroy(); + } + + /** + * Terminate the data sync process forcefully, which may cause data loss. + * It is advised to invoke `canGracefulStop` before calling this method. + */ + forceStop() { + this.docSync.forceStop(); + this.blobSync.stop(); + this.awarenessSync.disconnect(); + } + + getBlockCollection(docId: string): BlockCollection | null { + const space = this.docs.get(docId) as BlockCollection | undefined; + return space ?? null; + } + + getDoc(docId: string, options?: GetDocOptions): Doc | null { + const collection = this.getBlockCollection(docId); + return collection?.getDoc(options) ?? null; + } + + removeDoc(docId: string) { + const docMeta = this.meta.getDocMeta(docId); + if (!docMeta) { + throw new BlockSuiteError( + ErrorCode.DocCollectionError, + `doc meta not found: ${docId}` + ); + } + + const blockCollection = this.getBlockCollection(docId); + if (!blockCollection) return; + + blockCollection.dispose(); + this.meta.removeDocMeta(docId); + this.blockCollections.delete(docId); + } + + /** Update doc meta state. Note that this intentionally does not mutate doc state. */ + setDocMeta( + docId: string, + // You should not update subDocIds directly. + props: Partial<DocMeta> + ) { + this.meta.setDocMeta(docId, props); + } + + /** + * Start the data sync process + */ + start() { + this.docSync.start(); + this.blobSync.start(); + this.awarenessSync.connect(); + } + + /** + * Wait for all data has been successfully saved to the primary storage. + */ + waitForGracefulStop(abort?: AbortSignal) { + return this.docSync.waitForGracefulStop(abort); + } + + waitForSynced() { + return this.docSync.waitForSynced(); + } +} diff --git a/blocksuite/framework/store/src/store/doc/block-collection.ts b/blocksuite/framework/store/src/store/doc/block-collection.ts new file mode 100644 index 0000000000..8c9f211042 --- /dev/null +++ b/blocksuite/framework/store/src/store/doc/block-collection.ts @@ -0,0 +1,452 @@ +import { type Disposable, Slot } from '@blocksuite/global/utils'; +import { signal } from '@preact/signals-core'; +import { uuidv4 } from 'lib0/random.js'; +import * as Y from 'yjs'; + +import { Text } from '../../reactive/text.js'; +import type { BlockModel } from '../../schema/base.js'; +import type { IdGenerator } from '../../utils/id-generator.js'; +import type { AwarenessStore, BlockSuiteDoc } from '../../yjs/index.js'; +import type { DocCollection } from '../collection.js'; +import { DocCRUD } from './crud.js'; +import { Doc } from './doc.js'; +import type { YBlock } from './index.js'; +import type { Query } from './query.js'; + +export type YBlocks = Y.Map<YBlock>; + +/** JSON-serializable properties of a block */ +export type BlockSysProps = { + id: string; + flavour: string; + children?: BlockModel[]; +}; +export type BlockProps = BlockSysProps & Record<string, unknown>; + +type DocOptions = { + id: string; + collection: DocCollection; + doc: BlockSuiteDoc; + awarenessStore: AwarenessStore; + idGenerator?: IdGenerator; +}; + +export type GetDocOptions = { + query?: Query; + readonly?: boolean; +}; + +export class BlockCollection { + private _awarenessUpdateDisposable: Disposable | null = null; + + private readonly _canRedo$ = signal(false); + + private readonly _canUndo$ = signal(false); + + private readonly _collection: DocCollection; + + private readonly _docCRUD: DocCRUD; + + private _docMap = { + undefined: new Map<string, Doc>(), + true: new Map<string, Doc>(), + false: new Map<string, Doc>(), + }; + + // doc/space container. + private _handleYEvents = (events: Y.YEvent<YBlock | Y.Text>[]) => { + events.forEach(event => this._handleYEvent(event)); + }; + + private _history!: Y.UndoManager; + + private _historyObserver = () => { + this._updateCanUndoRedoSignals(); + this.slots.historyUpdated.emit(); + }; + + private readonly _idGenerator: IdGenerator; + + private _initSubDoc = () => { + let subDoc = this.rootDoc.spaces.get(this.id); + if (!subDoc) { + subDoc = new Y.Doc({ + guid: this.id, + }); + this.rootDoc.spaces.set(this.id, subDoc); + this._loaded = true; + this._onLoadSlot.emit(); + } else { + this._loaded = false; + this.rootDoc.on('subdocs', this._onSubdocEvent); + } + + return subDoc; + }; + + private _loaded!: boolean; + + private _onLoadSlot = new Slot(); + + private _onSubdocEvent = ({ loaded }: { loaded: Set<Y.Doc> }): void => { + const result = Array.from(loaded).find( + doc => doc.guid === this._ySpaceDoc.guid + ); + if (!result) { + return; + } + this.rootDoc.off('subdocs', this._onSubdocEvent); + this._loaded = true; + this._onLoadSlot.emit(); + }; + + /** Indicate whether the block tree is ready */ + private _ready = false; + + private _shouldTransact = true; + + private _updateCanUndoRedoSignals = () => { + const canRedo = this.readonly ? false : this._history.canRedo(); + const canUndo = this.readonly ? false : this._history.canUndo(); + if (this._canRedo$.peek() !== canRedo) { + this._canRedo$.value = canRedo; + } + if (this._canUndo$.peek() !== canUndo) { + this._canUndo$.value = canUndo; + } + }; + + protected readonly _yBlocks: Y.Map<YBlock>; + + /** + * @internal Used for convenient access to the underlying Yjs map, + * can be used interchangeably with ySpace + */ + protected readonly _ySpaceDoc: Y.Doc; + + readonly awarenessStore: AwarenessStore; + + readonly id: string; + + readonly rootDoc: BlockSuiteDoc; + + readonly slots = { + historyUpdated: new Slot(), + yBlockUpdated: new Slot< + | { + type: 'add'; + id: string; + } + | { + type: 'delete'; + id: string; + } + >(), + }; + + // So, we apply a listener at the top level for the flat structure of the current + get awarenessSync() { + return this.collection.awarenessSync; + } + + get blobSync() { + return this.collection.blobSync; + } + + get canRedo() { + return this._canRedo$.peek(); + } + + get canRedo$() { + return this._canRedo$; + } + + get canUndo() { + return this._canUndo$.peek(); + } + + get canUndo$() { + return this._canUndo$; + } + + get collection() { + return this._collection; + } + + get crud() { + return this._docCRUD; + } + + get docSync() { + return this.collection.docSync; + } + + get history() { + return this._history; + } + + get isEmpty() { + return this._yBlocks.size === 0; + } + + get loaded() { + return this._loaded; + } + + get meta() { + return this.collection.meta.getDocMeta(this.id); + } + + get readonly() { + return this.awarenessStore.isReadonly(this); + } + + get ready() { + return this._ready; + } + + get schema() { + return this.collection.schema; + } + + get spaceDoc() { + return this._ySpaceDoc; + } + + get Text() { + return Text; + } + + get yBlocks() { + return this._yBlocks; + } + + constructor({ + id, + collection, + doc, + awarenessStore, + idGenerator = uuidv4, + }: DocOptions) { + this.id = id; + this.rootDoc = doc; + this.awarenessStore = awarenessStore; + + this._ySpaceDoc = this._initSubDoc(); + + this._yBlocks = this._ySpaceDoc.getMap('blocks'); + this._collection = collection; + this._idGenerator = idGenerator; + this._docCRUD = new DocCRUD(this._yBlocks, collection.schema); + } + + private _getReadonlyKey(readonly?: boolean): 'true' | 'false' | 'undefined' { + return (readonly?.toString() as 'true' | 'false') ?? 'undefined'; + } + + private _handleVersion() { + // Initialization from empty yDoc, indicating that the document is new. + if (!this.collection.meta.hasVersion) { + this.collection.meta.writeVersion(this.collection); + } else { + // Initialization from existing yDoc, indicating that the document is loaded from storage. + if (this.awarenessStore.getFlag('enable_legacy_validation')) { + this.collection.meta.validateVersion(this.collection); + } + } + } + + private _handleYBlockAdd(id: string) { + this.slots.yBlockUpdated.emit({ type: 'add', id }); + } + + private _handleYBlockDelete(id: string) { + this.slots.yBlockUpdated.emit({ type: 'delete', id }); + } + + private _handleYEvent(event: Y.YEvent<YBlock | Y.Text | Y.Array<unknown>>) { + // event on top-level block store + if (event.target !== this._yBlocks) { + return; + } + event.keys.forEach((value, id) => { + try { + if (value.action === 'add') { + this._handleYBlockAdd(id); + return; + } + if (value.action === 'delete') { + this._handleYBlockDelete(id); + return; + } + } catch (e) { + console.error('An error occurred while handling Yjs event:'); + console.error(e); + } + }); + } + + private _initYBlocks() { + const { _yBlocks } = this; + _yBlocks.observeDeep(this._handleYEvents); + this._history = new Y.UndoManager([_yBlocks], { + trackedOrigins: new Set([this._ySpaceDoc.clientID]), + }); + + this._history.on('stack-cleared', this._historyObserver); + this._history.on('stack-item-added', this._historyObserver); + this._history.on('stack-item-popped', this._historyObserver); + this._history.on('stack-item-updated', this._historyObserver); + } + + /** Capture current operations to undo stack synchronously. */ + captureSync() { + this._history.stopCapturing(); + } + + clear() { + this._yBlocks.clear(); + } + + clearQuery(query: Query, readonly?: boolean) { + const readonlyKey = this._getReadonlyKey(readonly); + + this._docMap[readonlyKey].delete(JSON.stringify(query)); + } + + destroy() { + this._ySpaceDoc.destroy(); + this._onLoadSlot.dispose(); + this._loaded = false; + } + + dispose() { + this.slots.historyUpdated.dispose(); + this._awarenessUpdateDisposable?.dispose(); + + if (this.ready) { + this._yBlocks.unobserveDeep(this._handleYEvents); + this._yBlocks.clear(); + } + } + + generateBlockId() { + return this._idGenerator(); + } + + getDoc({ readonly, query }: GetDocOptions = {}) { + const readonlyKey = this._getReadonlyKey(readonly); + + const key = JSON.stringify(query); + + if (this._docMap[readonlyKey].has(key)) { + return this._docMap[readonlyKey].get(key)!; + } + + const doc = new Doc({ + blockCollection: this, + crud: this._docCRUD, + schema: this.collection.schema, + readonly, + query, + }); + + this._docMap[readonlyKey].set(key, doc); + + return doc; + } + + load(initFn?: () => void): this { + if (this.ready) { + return this; + } + + this._ySpaceDoc.load(); + + if ((this.collection.meta.docs?.length ?? 0) <= 1) { + this._handleVersion(); + } + + this._initYBlocks(); + + this._yBlocks.forEach((_, id) => { + this._handleYBlockAdd(id); + }); + + this._awarenessUpdateDisposable = this.awarenessStore.slots.update.on( + () => { + // change readonly state will affect the undo/redo state + this._updateCanUndoRedoSignals(); + } + ); + + initFn?.(); + + this._ready = true; + + return this; + } + + redo() { + if (this.readonly) { + console.error('cannot modify data in readonly mode'); + return; + } + this._history.redo(); + } + + remove() { + this.destroy(); + this.rootDoc.spaces.delete(this.id); + } + + resetHistory() { + this._history.clear(); + } + + /** + * If `shouldTransact` is `false`, the transaction will not be push to the history stack. + */ + transact(fn: () => void, shouldTransact: boolean = this._shouldTransact) { + this._ySpaceDoc.transact( + () => { + try { + fn(); + } catch (e) { + console.error( + `An error occurred while Y.doc ${this._ySpaceDoc.guid} transacting:` + ); + console.error(e); + } + }, + shouldTransact ? this.rootDoc.clientID : null + ); + } + + // Handle all the events that happen at _any_ level (potentially deep inside the structure). + undo() { + if (this.readonly) { + console.error('cannot modify data in readonly mode'); + return; + } + this._history.undo(); + } + + withoutTransact(callback: () => void) { + this._shouldTransact = false; + callback(); + this._shouldTransact = true; + } +} + +declare global { + namespace BlockSuite { + interface BlockModels {} + + type Flavour = string & keyof BlockModels; + + type ModelProps<Model> = Partial< + Model extends BlockModel<infer U> ? U : never + >; + } +} diff --git a/blocksuite/framework/store/src/store/doc/block/index.ts b/blocksuite/framework/store/src/store/doc/block/index.ts new file mode 100644 index 0000000000..82126de1a1 --- /dev/null +++ b/blocksuite/framework/store/src/store/doc/block/index.ts @@ -0,0 +1,51 @@ +import type { Schema } from '../../../schema/index.js'; +import { BlockViewType } from '../consts.js'; +import type { Doc } from '../doc.js'; +import { SyncController } from './sync-controller.js'; +import type { BlockOptions, YBlock } from './types.js'; + +export * from './types.js'; + +export class Block { + private _syncController: SyncController; + + blockViewType: BlockViewType = BlockViewType.Display; + + get flavour() { + return this._syncController.flavour; + } + + get id() { + return this._syncController.id; + } + + get model() { + return this._syncController.model; + } + + get pop() { + return this._syncController.pop; + } + + get stash() { + return this._syncController.stash; + } + + get version() { + return this._syncController.version; + } + + constructor( + readonly schema: Schema, + readonly yBlock: YBlock, + readonly doc?: Doc, + readonly options: BlockOptions = {} + ) { + const onChange = !options.onChange + ? undefined + : (key: string, value: unknown) => { + options.onChange?.(this, key, value); + }; + this._syncController = new SyncController(schema, yBlock, doc, onChange); + } +} diff --git a/blocksuite/framework/store/src/store/doc/block/sync-controller.ts b/blocksuite/framework/store/src/store/doc/block/sync-controller.ts new file mode 100644 index 0000000000..65e0a67fc7 --- /dev/null +++ b/blocksuite/framework/store/src/store/doc/block/sync-controller.ts @@ -0,0 +1,377 @@ +import { BlockSuiteError, ErrorCode } from '@blocksuite/global/exceptions'; +import { effect, signal } from '@preact/signals-core'; +import { createMutex } from 'lib0/mutex.js'; +import * as Y from 'yjs'; + +import { + Boxed, + createYProxy, + native2Y, + type UnRecord, + y2Native, +} from '../../../reactive/index.js'; +import { BlockModel, internalPrimitives } from '../../../schema/base.js'; +import type { Schema } from '../../../schema/schema.js'; +import type { Doc } from '../doc.js'; +import type { YBlock } from './types.js'; + +/** + * @internal + * SyncController is responsible for syncing the block data with Yjs. + * It creates a proxy model that syncs with Yjs and provides a reactive interface. + * It also handles the stashing and popping of props. + * It will also provide signals for block props. + * + */ +export class SyncController { + private _byPassProxy: boolean = false; + + private readonly _byPassUpdate = (fn: () => void) => { + this._byPassProxy = true; + fn(); + this._byPassProxy = false; + }; + + private readonly _mutex = createMutex(); + + private readonly _observeYBlockChanges = () => { + this.yBlock.observe(event => { + event.keysChanged.forEach(key => { + const type = event.changes.keys.get(key); + if (!type) { + return; + } + if (type.action === 'update' || type.action === 'add') { + const value = this.yBlock.get(key); + const keyName = key.replace('prop:', ''); + const proxy = this._getPropsProxy(keyName, value); + this._byPassUpdate(() => { + // @ts-expect-error FIXME: ts error + this.model[keyName] = proxy; + const signalKey = `${keyName}$`; + this._mutex(() => { + if (signalKey in this.model) { + // @ts-expect-error FIXME: ts error + this.model[signalKey].value = y2Native(value); + } + }); + }); + this.onChange?.(keyName, value); + return; + } + if (type.action === 'delete') { + const keyName = key.replace('prop:', ''); + this._byPassUpdate(() => { + // @ts-expect-error FIXME: ts error + delete this.model[keyName]; + if (`${keyName}$` in this.model) { + // @ts-expect-error FIXME: ts error + this.model[`${keyName}$`].value = undefined; + } + }); + this.onChange?.(keyName, undefined); + return; + } + }); + }); + }; + + private readonly _stashed = new Set<string | number>(); + + readonly flavour: string; + + readonly id: string; + + readonly model: BlockModel; + + readonly pop = (prop: string) => { + if (!this._stashed.has(prop)) return; + this._popProp(prop); + }; + + readonly stash = (prop: string) => { + if (this._stashed.has(prop)) return; + + this._stashed.add(prop); + this._stashProp(prop); + }; + + readonly version: number; + + readonly yChildren: Y.Array<string[]>; + + constructor( + readonly schema: Schema, + readonly yBlock: YBlock, + readonly doc?: Doc, + readonly onChange?: (key: string, value: unknown) => void + ) { + const { id, flavour, version, yChildren, props } = this._parseYBlock(); + + this.id = id; + this.flavour = flavour; + this.yChildren = yChildren; + this.version = version; + + this.model = this._createModel(props); + + this._observeYBlockChanges(); + } + + private _createModel(props: UnRecord) { + const _mutex = this._mutex; + const schema = this.schema.flavourSchemaMap.get(this.flavour); + if (!schema) { + throw new BlockSuiteError( + ErrorCode.ModelCRUDError, + `schema for flavour: ${this.flavour} not found` + ); + } + + const model = schema.model.toModel?.() ?? new BlockModel<object>(); + const signalWithProps = Object.entries(props).reduce( + (acc, [key, value]) => { + const data = signal(value); + const dispose = effect(() => { + const value = data.value; + if (!this.model) return; + _mutex(() => { + // @ts-expect-error FIXME: ts error + this.model[key] = value; + }); + }); + model.deleted.once(dispose); + return { + ...acc, + [`${key}$`]: data, + [key]: value, + }; + }, + {} as Record<string, unknown> + ); + Object.assign(model, signalWithProps); + + model.id = this.id; + model.version = this.version; + model.keys = Object.keys(props); + model.flavour = schema.model.flavour; + model.role = schema.model.role; + model.yBlock = this.yBlock; + model.stash = this.stash; + model.pop = this.pop; + if (this.doc) { + model.doc = this.doc; + } + + const proxy = new Proxy(model, { + has: (target, p) => { + return Reflect.has(target, p); + }, + set: (target, p, value, receiver) => { + if ( + !this._byPassProxy && + typeof p === 'string' && + model.keys.includes(p) + ) { + if (this._stashed.has(p)) { + setValue(target, p, value); + const result = Reflect.set(target, p, value, receiver); + this.onChange?.(p, value); + return result; + } + + const yValue = native2Y(value); + this.yBlock.set(`prop:${p}`, yValue); + const proxy = this._getPropsProxy(p, yValue); + setValue(target, p, value); + return Reflect.set(target, p, proxy, receiver); + } + + return Reflect.set(target, p, value, receiver); + }, + get: (target, p, receiver) => { + return Reflect.get(target, p, receiver); + }, + deleteProperty: (target, p) => { + if ( + !this._byPassProxy && + typeof p === 'string' && + model.keys.includes(p) + ) { + this.yBlock.delete(`prop:${p}`); + setValue(target, p, undefined); + } + + return Reflect.deleteProperty(target, p); + }, + }); + + function setValue(target: BlockModel, p: string, value: unknown) { + _mutex(() => { + // @ts-expect-error FIXME: ts error + target[`${p}$`].value = value; + }); + } + return proxy; + } + + private _getPropsProxy(name: string, value: unknown) { + return createYProxy(value, { + onChange: () => { + this.onChange?.(name, value); + const signalKey = `${name}$`; + if (signalKey in this.model) { + this._mutex(() => { + // @ts-expect-error FIXME: ts error + this.model[signalKey].value = this.model[name]; + }); + } + }, + }); + } + + private _parseYBlock() { + let id: string | undefined; + let flavour: string | undefined; + let version: number | undefined; + let yChildren: Y.Array<string[]> | undefined; + const props: Record<string, unknown> = {}; + + this.yBlock.forEach((value, key) => { + if (key.startsWith('prop:')) { + const keyName = key.replace('prop:', ''); + props[keyName] = this._getPropsProxy(keyName, value); + return; + } + if (key === 'sys:id' && typeof value === 'string') { + id = value; + return; + } + if (key === 'sys:flavour' && typeof value === 'string') { + flavour = value; + return; + } + if (key === 'sys:children' && value instanceof Y.Array) { + yChildren = value; + return; + } + if (key === 'sys:version' && typeof value === 'number') { + version = value; + return; + } + }); + + if (!id) { + throw new BlockSuiteError( + ErrorCode.ModelCRUDError, + 'block id is not found when creating model' + ); + } + if (!flavour) { + throw new BlockSuiteError( + ErrorCode.ModelCRUDError, + 'block flavour is not found when creating model' + ); + } + if (!yChildren) { + throw new BlockSuiteError( + ErrorCode.ModelCRUDError, + 'block children is not found when creating model' + ); + } + + const schema = this.schema.flavourSchemaMap.get(flavour); + if (!schema) { + throw new BlockSuiteError( + ErrorCode.ModelCRUDError, + `schema for flavour: ${flavour} not found` + ); + } + const defaultProps = schema.model.props?.(internalPrimitives); + + if (typeof version !== 'number') { + // no version found in data, set to schema version + version = schema.version; + } + + // Set default props if not exists + if (defaultProps) { + Object.entries(defaultProps).forEach(([key, value]) => { + if (key in props) return; + + const yValue = native2Y(value); + this.yBlock.set(`prop:${key}`, yValue); + props[key] = this._getPropsProxy(key, yValue); + }); + } + + return { + id, + flavour, + version, + props, + yChildren, + }; + } + + private _popProp(prop: string) { + const model = this.model as BlockModel<Record<string, unknown>>; + + const value = model[prop]; + this._stashed.delete(prop); + model[prop] = value; + } + + private _stashProp(prop: string) { + (this.model as BlockModel<Record<string, unknown>>)[prop] = y2Native( + this.yBlock.get(`prop:${prop}`), + { + transform: (value, origin) => { + if (Boxed.is(origin)) { + return value; + } + if (origin instanceof Y.Map) { + return new Proxy(value as UnRecord, { + get: (target, p, receiver) => { + return Reflect.get(target, p, receiver); + }, + set: (target, p, value, receiver) => { + const result = Reflect.set(target, p, value, receiver); + this.onChange?.(prop, value); + return result; + }, + deleteProperty: (target, p) => { + const result = Reflect.deleteProperty(target, p); + this.onChange?.(prop, undefined); + return result; + }, + }); + } + if (origin instanceof Y.Array) { + return new Proxy(value as unknown[], { + get: (target, p, receiver) => { + return Reflect.get(target, p, receiver); + }, + set: (target, p, value, receiver) => { + const index = Number(p); + if (Number.isNaN(index)) { + return Reflect.set(target, p, value, receiver); + } + const result = Reflect.set(target, p, value, receiver); + this.onChange?.(prop, value); + return result; + }, + deleteProperty: (target, p) => { + const result = Reflect.deleteProperty(target, p); + this.onChange?.(p as string, undefined); + return result; + }, + }); + } + + return value; + }, + } + ); + } +} diff --git a/blocksuite/framework/store/src/store/doc/block/types.ts b/blocksuite/framework/store/src/store/doc/block/types.ts new file mode 100644 index 0000000000..db7bfcb234 --- /dev/null +++ b/blocksuite/framework/store/src/store/doc/block/types.ts @@ -0,0 +1,13 @@ +import type * as Y from 'yjs'; + +import type { Block } from './index.js'; + +export type YBlock = Y.Map<unknown> & { + get(prop: 'sys:id' | 'sys:flavour'): string; + get(prop: 'sys:children'): Y.Array<string>; + get<T = unknown>(prop: string): T; +}; + +export type BlockOptions = { + onChange?: (block: Block, key: string, value: unknown) => void; +}; diff --git a/blocksuite/framework/store/src/store/doc/consts.ts b/blocksuite/framework/store/src/store/doc/consts.ts new file mode 100644 index 0000000000..8a5c58f12f --- /dev/null +++ b/blocksuite/framework/store/src/store/doc/consts.ts @@ -0,0 +1,5 @@ +export enum BlockViewType { + Bypass = 'bypass', + Display = 'display', + Hidden = 'hidden', +} diff --git a/blocksuite/framework/store/src/store/doc/crud.ts b/blocksuite/framework/store/src/store/doc/crud.ts new file mode 100644 index 0000000000..8d1cb5ef40 --- /dev/null +++ b/blocksuite/framework/store/src/store/doc/crud.ts @@ -0,0 +1,389 @@ +import { BlockSuiteError, ErrorCode } from '@blocksuite/global/exceptions'; +import * as Y from 'yjs'; + +import { native2Y } from '../../reactive/index.js'; +import { + type BlockModel, + internalPrimitives, + type Schema, +} from '../../schema/index.js'; +import type { YBlock } from './index.js'; + +export class DocCRUD { + get root(): string | null { + let rootId: string | null = null; + this._yBlocks.forEach(yBlock => { + const flavour = yBlock.get('sys:flavour') as string; + const schema = this._schema.flavourSchemaMap.get(flavour); + if (!schema) return; + + if (schema.model.role === 'root') { + rootId = yBlock.get('sys:id') as string; + } + }); + return rootId; + } + + constructor( + private readonly _yBlocks: Y.Map<YBlock>, + private readonly _schema: Schema + ) {} + + private _getSiblings<T>( + id: string, + fn: (index: number, parent: YBlock) => T + ) { + const parentId = this.getParent(id); + if (!parentId) return null; + const parent = this._yBlocks.get(parentId); + if (!parent) return null; + + const children = parent.get('sys:children'); + const index = children.toArray().indexOf(id); + if (index === -1) return null; + + return fn(index, parent); + } + + addBlock( + id: string, + flavour: string, + initialProps: Record<string, unknown> = {}, + parent?: string | null, + parentIndex?: number + ) { + const schema = this._schema.flavourSchemaMap.get(flavour); + if (!schema) { + throw new BlockSuiteError( + ErrorCode.ModelCRUDError, + `schema for flavour: ${flavour} not found` + ); + } + + const parentFlavour = parent + ? this._yBlocks.get(parent)?.get('sys:flavour') + : undefined; + + this._schema.validate(flavour, parentFlavour as string); + + const hasBlock = this._yBlocks.has(id); + + if (hasBlock) { + const yBlock = this._yBlocks.get(id); + const existedParent = this.getParent(id); + if (yBlock && existedParent) { + const yParent = this._yBlocks.get(existedParent) as YBlock; + const yParentChildren = yParent.get('sys:children') as Y.Array<string>; + const index = yParentChildren.toArray().indexOf(id); + yParentChildren.delete(index, 1); + if ( + parentIndex != null && + index != null && + existedParent === parent && + index < parentIndex + ) { + parentIndex--; + } + const props = { + ...initialProps, + }; + delete props.id; + delete props.flavour; + delete props.children; + + Object.entries(props).forEach(([key, value]) => { + if (value === undefined) return; + + yBlock.set(`prop:${key}`, native2Y(value)); + }); + } + } else { + const yBlock = new Y.Map() as YBlock; + this._yBlocks.set(id, yBlock); + + const version = schema.version; + const children = ( + initialProps.children as undefined | (string | BlockModel)[] + )?.map(child => (typeof child === 'string' ? child : child.id)); + + yBlock.set('sys:id', id); + yBlock.set('sys:flavour', flavour); + yBlock.set('sys:version', version); + yBlock.set('sys:children', Y.Array.from(children ?? [])); + + const defaultProps = schema.model.props?.(internalPrimitives) ?? {}; + const props = { + ...defaultProps, + ...initialProps, + }; + + delete props.id; + delete props.flavour; + delete props.children; + + Object.entries(props).forEach(([key, value]) => { + if (value === undefined) return; + + yBlock.set(`prop:${key}`, native2Y(value)); + }); + } + + const parentId = + parent ?? (schema.model.role === 'root' ? null : this.root); + + if (!parentId) return; + + const yParent = this._yBlocks.get(parentId); + if (!yParent) return; + + const yParentChildren = yParent.get('sys:children') as Y.Array<string>; + const index = + parentIndex != null + ? parentIndex > yParentChildren.length + ? yParentChildren.length + : parentIndex + : yParentChildren.length; + yParentChildren.insert(index, [id]); + } + + deleteBlock( + id: string, + options: { + bringChildrenTo?: string; + deleteChildren?: boolean; + } = { + deleteChildren: true, + } + ) { + const { bringChildrenTo, deleteChildren } = options; + if (bringChildrenTo && deleteChildren) { + console.error( + 'Cannot bring children to another block and delete them at the same time' + ); + return; + } + + const yModel = this._yBlocks.get(id); + if (!yModel) return; + + const yModelChildren = yModel.get('sys:children') as Y.Array<string>; + + const parent = this.getParent(id); + + if (!parent) return; + const yParent = this._yBlocks.get(parent) as YBlock; + const yParentChildren = yParent.get('sys:children') as Y.Array<string>; + const modelIndex = yParentChildren.toArray().indexOf(id); + + if (modelIndex > -1) { + yParentChildren.delete(modelIndex, 1); + } + + const apply = () => { + if (bringChildrenTo) { + const bringChildrenToModel = () => { + if (!bringChildrenTo) { + throw new BlockSuiteError( + ErrorCode.ModelCRUDError, + 'bringChildrenTo is not provided when deleting block' + ); + } + const model = this._yBlocks.get(bringChildrenTo); + if (!model) return; + const bringFlavour = model.get('sys:flavour'); + + yModelChildren.forEach(child => { + const childModel = this._yBlocks.get(child); + if (!childModel) return; + this._schema.validate( + childModel.get('sys:flavour') as string, + bringFlavour as string + ); + }); + + if (bringChildrenTo === parent) { + // When bring children to parent, insert children to the original position of model + yParentChildren.insert(modelIndex, yModelChildren.toArray()); + return; + } + + const yBringChildrenTo = this._yBlocks.get(bringChildrenTo); + if (!yBringChildrenTo) return; + + const yBringChildrenToChildren = yBringChildrenTo.get( + 'sys:children' + ) as Y.Array<string>; + yBringChildrenToChildren.push(yModelChildren.toArray()); + }; + + bringChildrenToModel(); + return; + } + + if (deleteChildren) { + // delete children recursively + const deleteById = (id: string) => { + const yBlock = this._yBlocks.get(id) as YBlock; + + const yChildren = yBlock.get('sys:children') as Y.Array<string>; + yChildren.forEach(id => deleteById(id)); + + this._yBlocks.delete(id); + }; + + yModelChildren.forEach(id => deleteById(id)); + } + }; + + apply(); + + this._yBlocks.delete(id); + } + + getNext(id: string) { + return this._getSiblings( + id, + (index, parent) => + parent + .get('sys:children') + .toArray() + .at(index + 1) ?? null + ); + } + + getParent(targetId: string): string | null { + const root = this.root; + if (!root || root === targetId) return null; + + const findParent = (parentId: string): string | null => { + const parentYBlock = this._yBlocks.get(parentId); + if (!parentYBlock) return null; + + const children = parentYBlock.get('sys:children'); + + for (const childId of children.toArray()) { + if (childId === targetId) return parentId; + + const parent = findParent(childId); + if (parent != null) return parent; + } + + return null; + }; + + return findParent(root); + } + + getPrev(id: string) { + return this._getSiblings( + id, + (index, parent) => + parent + .get('sys:children') + .toArray() + .at(index - 1) ?? null + ); + } + + moveBlocks( + blocksToMove: string[], + newParent: string, + targetSibling: string | null = null, + shouldInsertBeforeSibling = true + ) { + // A map to store parent block and their respective child blocks + const childBlocksPerParent = new Map<string, string[]>(); + + const parentBlock = this._yBlocks.get(newParent); + if (!parentBlock) return; + + const parentFlavour = parentBlock.get('sys:flavour'); + + blocksToMove.forEach(blockId => { + const parent = this.getParent(blockId); + if (!parent) return; + + const block = this._yBlocks.get(blockId); + if (!block) return; + + this._schema.validate( + block.get('sys:flavour') as string, + parentFlavour as string + ); + + const children = childBlocksPerParent.get(parent); + if (!children) { + childBlocksPerParent.set(parent, [blockId]); + return; + } + + const last = children[children.length - 1]; + if (this.getNext(last) !== blockId) { + throw new BlockSuiteError( + ErrorCode.ModelCRUDError, + 'The blocks to move are not contiguous under their parent' + ); + } + + children.push(blockId); + }); + + let insertIndex = 0; + Array.from(childBlocksPerParent.entries()).forEach( + ([parentBlock, blocksToMove], index) => { + const targetParentBlock = this._yBlocks.get(newParent); + if (!targetParentBlock) return; + const targetParentChildren = targetParentBlock.get('sys:children'); + const sourceParentBlock = this._yBlocks.get(parentBlock); + if (!sourceParentBlock) return; + const sourceParentChildren = sourceParentBlock.get('sys:children'); + + // Get the IDs of blocks to move + // Remove the blocks from their current parent + const startIndex = sourceParentChildren + .toArray() + .findIndex(id => id === blocksToMove[0]); + sourceParentChildren.delete(startIndex, blocksToMove.length); + + const updateInsertIndex = () => { + const first = index === 0; + if (!first) { + insertIndex++; + return; + } + + if (!targetSibling) { + insertIndex = targetParentChildren.length; + return; + } + + const targetIndex = targetParentChildren + .toArray() + .findIndex(id => id === targetSibling); + if (targetIndex === -1) { + throw new BlockSuiteError( + ErrorCode.ModelCRUDError, + 'Target sibling not found' + ); + } + insertIndex = shouldInsertBeforeSibling + ? targetIndex + : targetIndex + 1; + }; + + updateInsertIndex(); + + targetParentChildren.insert(insertIndex, blocksToMove); + } + ); + } + + updateBlockChildren(id: string, children: string[]) { + const yBlock = this._yBlocks.get(id); + if (!yBlock) return; + + const yChildrenArray = yBlock.get('sys:children') as Y.Array<string>; + yChildrenArray.delete(0, yChildrenArray.length); + yChildrenArray.push(children); + } +} diff --git a/blocksuite/framework/store/src/store/doc/doc.ts b/blocksuite/framework/store/src/store/doc/doc.ts new file mode 100644 index 0000000000..8502b319d6 --- /dev/null +++ b/blocksuite/framework/store/src/store/doc/doc.ts @@ -0,0 +1,670 @@ +import { BlockSuiteError, ErrorCode } from '@blocksuite/global/exceptions'; +import { type Disposable, Slot } from '@blocksuite/global/utils'; +import { signal } from '@preact/signals-core'; + +import type { BlockModel, Schema } from '../../schema/index.js'; +import type { DraftModel } from '../../transformer/index.js'; +import { syncBlockProps } from '../../utils/utils.js'; +import type { BlockOptions } from './block/index.js'; +import { Block } from './block/index.js'; +import type { BlockCollection, BlockProps } from './block-collection.js'; +import type { DocCRUD } from './crud.js'; +import { type Query, runQuery } from './query.js'; + +type DocOptions = { + schema: Schema; + blockCollection: BlockCollection; + crud: DocCRUD; + readonly?: boolean; + query?: Query; +}; + +export class Doc { + private _runQuery = (block: Block) => { + runQuery(this._query, block); + }; + + protected readonly _blockCollection: BlockCollection; + + protected readonly _blocks = signal<Record<string, Block>>({}); + + protected readonly _crud: DocCRUD; + + protected readonly _disposeBlockUpdated: Disposable; + + protected readonly _query: Query = { + match: [], + mode: 'loose', + }; + + protected readonly _readonly?: boolean; + + protected readonly _schema: Schema; + + readonly slots: BlockCollection['slots'] & { + /** This is always triggered after `doc.load` is called. */ + ready: Slot; + /** + * This fires when the root block is added via API call or has just been initialized from existing ydoc. + * useful for internal block UI components to start subscribing following up events. + * Note that at this moment, the whole block tree may not be fully initialized yet. + */ + rootAdded: Slot<string>; + rootDeleted: Slot<string>; + blockUpdated: Slot< + | { + type: 'add'; + id: string; + init: boolean; + flavour: string; + model: BlockModel; + } + | { + type: 'delete'; + id: string; + flavour: string; + parent: string; + model: BlockModel; + } + | { + type: 'update'; + id: string; + flavour: string; + props: { key: string }; + } + >; + }; + + updateBlock: { + <T extends Partial<BlockProps>>(model: BlockModel, props: T): void; + (model: BlockModel, callback: () => void): void; + } = ( + model: BlockModel, + callBackOrProps: (() => void) | Partial<BlockProps> + ) => { + if (this.readonly) { + console.error('cannot modify data in readonly mode'); + return; + } + + const isCallback = typeof callBackOrProps === 'function'; + + if (!isCallback) { + const parent = this.getParent(model); + this.schema.validate( + model.flavour, + parent?.flavour, + callBackOrProps.children?.map(child => child.flavour) + ); + } + + const yBlock = this._yBlocks.get(model.id); + if (!yBlock) { + throw new BlockSuiteError( + ErrorCode.ModelCRUDError, + `updating block: ${model.id} not found` + ); + } + + const block = this.getBlock(model.id); + if (!block) return; + + this.transact(() => { + if (isCallback) { + callBackOrProps(); + this._runQuery(block); + return; + } + + if (callBackOrProps.children) { + this._crud.updateBlockChildren( + model.id, + callBackOrProps.children.map(child => child.id) + ); + } + + const schema = this.schema.flavourSchemaMap.get(model.flavour); + if (!schema) { + throw new BlockSuiteError( + ErrorCode.ModelCRUDError, + `schema for flavour: ${model.flavour} not found` + ); + } + syncBlockProps(schema, model, yBlock, callBackOrProps); + this._runQuery(block); + return; + }); + }; + + private get _yBlocks() { + return this._blockCollection.yBlocks; + } + + get awarenessStore() { + return this._blockCollection.awarenessStore; + } + + get awarenessSync() { + return this.collection.awarenessSync; + } + + get blobSync() { + return this.collection.blobSync; + } + + get blockCollection() { + return this._blockCollection; + } + + get blocks() { + return this._blocks; + } + + get blockSize() { + return Object.values(this._blocks.peek()).length; + } + + get canRedo() { + return this._blockCollection.canRedo; + } + + get canUndo() { + return this._blockCollection.canUndo; + } + + get captureSync() { + return this._blockCollection.captureSync.bind(this._blockCollection); + } + + get clear() { + return this._blockCollection.clear.bind(this._blockCollection); + } + + get collection() { + return this._blockCollection.collection; + } + + get docSync() { + return this.collection.docSync; + } + + get generateBlockId() { + return this._blockCollection.generateBlockId.bind(this._blockCollection); + } + + get history() { + return this._blockCollection.history; + } + + get id() { + return this._blockCollection.id; + } + + get isEmpty() { + return Object.values(this._blocks.peek()).length === 0; + } + + get loaded() { + return this._blockCollection.loaded; + } + + get meta() { + return this._blockCollection.meta; + } + + get readonly() { + if (this._blockCollection.readonly) { + return true; + } + return this._readonly === true; + } + + get ready() { + return this._blockCollection.ready; + } + + get redo() { + return this._blockCollection.redo.bind(this._blockCollection); + } + + get resetHistory() { + return this._blockCollection.resetHistory.bind(this._blockCollection); + } + + get root() { + const rootId = this._crud.root; + if (!rootId) return null; + return this.getBlock(rootId)?.model ?? null; + } + + get rootDoc() { + return this._blockCollection.rootDoc; + } + + get schema() { + return this._schema; + } + + get spaceDoc() { + return this._blockCollection.spaceDoc; + } + + get Text() { + return this._blockCollection.Text; + } + + get transact() { + return this._blockCollection.transact.bind(this._blockCollection); + } + + get undo() { + return this._blockCollection.undo.bind(this._blockCollection); + } + + get withoutTransact() { + return this._blockCollection.withoutTransact.bind(this._blockCollection); + } + + constructor({ schema, blockCollection, crud, readonly, query }: DocOptions) { + this._blockCollection = blockCollection; + + this.slots = { + ready: new Slot(), + rootAdded: new Slot(), + rootDeleted: new Slot(), + blockUpdated: new Slot(), + historyUpdated: this._blockCollection.slots.historyUpdated, + yBlockUpdated: this._blockCollection.slots.yBlockUpdated, + }; + + this._crud = crud; + this._schema = schema; + this._readonly = readonly; + if (query) { + this._query = query; + } + + this._yBlocks.forEach((_, id) => { + if (id in this._blocks.peek()) { + return; + } + this._onBlockAdded(id, true); + }); + + this._disposeBlockUpdated = this._blockCollection.slots.yBlockUpdated.on( + ({ type, id }) => { + switch (type) { + case 'add': { + this._onBlockAdded(id); + return; + } + case 'delete': { + this._onBlockRemoved(id); + return; + } + } + } + ); + } + + private _getSiblings<T>( + block: BlockModel | string, + fn: (parent: BlockModel, index: number) => T + ) { + const parent = this.getParent(block); + if (!parent) return null; + + const blockModel = + typeof block === 'string' ? this.getBlock(block)?.model : block; + if (!blockModel) return null; + + const index = parent.children.indexOf(blockModel); + if (index === -1) return null; + + return fn(parent, index); + } + + private _onBlockAdded(id: string, init = false) { + try { + if (id in this._blocks.peek()) { + return; + } + const yBlock = this._yBlocks.get(id); + if (!yBlock) { + console.warn(`Could not find block with id ${id}`); + return; + } + + const options: BlockOptions = { + onChange: (block, key) => { + if (key) { + block.model.propsUpdated.emit({ key }); + } + + this.slots.blockUpdated.emit({ + type: 'update', + id, + flavour: block.flavour, + props: { key }, + }); + }, + }; + + const block = new Block(this._schema, yBlock, this, options); + this._runQuery(block); + + this._blocks.value = { + ...this._blocks.value, + [id]: block, + }; + block.model.created.emit(); + + if (block.model.role === 'root') { + this.slots.rootAdded.emit(id); + } + + this.slots.blockUpdated.emit({ + type: 'add', + id, + init, + flavour: block.model.flavour, + model: block.model, + }); + } catch (e) { + console.error('An error occurred while adding block:'); + console.error(e); + } + } + + private _onBlockRemoved(id: string) { + try { + const block = this.getBlock(id); + if (!block) return; + + if (block.model.role === 'root') { + this.slots.rootDeleted.emit(id); + } + + this.slots.blockUpdated.emit({ + type: 'delete', + id, + flavour: block.model.flavour, + parent: this.getParent(block.model)?.id ?? '', + model: block.model, + }); + + const { [id]: _, ...blocks } = this._blocks.peek(); + this._blocks.value = blocks; + + block.model.deleted.emit(); + block.model.dispose(); + } catch (e) { + console.error('An error occurred while removing block:'); + console.error(e); + } + } + + addBlock<Key extends BlockSuite.Flavour>( + flavour: Key, + blockProps?: BlockSuite.ModelProps<BlockSuite.BlockModels[Key]>, + parent?: BlockModel | string | null, + parentIndex?: number + ): string; + + addBlock( + flavour: never, + blockProps?: Partial<BlockProps & Omit<BlockProps, 'flavour'>>, + parent?: BlockModel | string | null, + parentIndex?: number + ): string; + + addBlock( + flavour: string, + blockProps: Partial<BlockProps & Omit<BlockProps, 'flavour'>> = {}, + parent?: BlockModel | string | null, + parentIndex?: number + ): string { + if (this.readonly) { + throw new BlockSuiteError( + ErrorCode.ModelCRUDError, + 'cannot modify data in readonly mode' + ); + } + + const id = blockProps.id ?? this._blockCollection.generateBlockId(); + + this.transact(() => { + this._crud.addBlock( + id, + flavour, + { ...blockProps }, + typeof parent === 'string' ? parent : parent?.id, + parentIndex + ); + }); + + return id; + } + + addBlocks( + blocks: Array<{ + flavour: string; + blockProps?: Partial<BlockProps & Omit<BlockProps, 'flavour' | 'id'>>; + }>, + parent?: BlockModel | string | null, + parentIndex?: number + ): string[] { + const ids: string[] = []; + blocks.forEach(block => { + const id = this.addBlock( + block.flavour as never, + block.blockProps ?? {}, + parent, + parentIndex + ); + ids.push(id); + typeof parentIndex === 'number' && parentIndex++; + }); + + return ids; + } + + addSiblingBlocks( + targetModel: BlockModel, + props: Array<Partial<BlockProps>>, + place: 'after' | 'before' = 'after' + ): string[] { + if (!props.length) return []; + const parent = this.getParent(targetModel); + if (!parent) return []; + + const targetIndex = + parent.children.findIndex(({ id }) => id === targetModel.id) ?? 0; + const insertIndex = place === 'before' ? targetIndex : targetIndex + 1; + + if (props.length <= 1) { + if (!props[0]?.flavour) return []; + const { flavour, ...blockProps } = props[0]; + const id = this.addBlock( + flavour as never, + blockProps, + parent.id, + insertIndex + ); + return [id]; + } + + const blocks: Array<{ + flavour: string; + blockProps: Partial<BlockProps>; + }> = []; + props.forEach(prop => { + const { flavour, ...blockProps } = prop; + if (!flavour) return; + blocks.push({ flavour, blockProps }); + }); + return this.addBlocks(blocks, parent.id, insertIndex); + } + + deleteBlock( + model: DraftModel, + options: { + bringChildrenTo?: BlockModel; + deleteChildren?: boolean; + } = { + deleteChildren: true, + } + ) { + if (this.readonly) { + console.error('cannot modify data in readonly mode'); + return; + } + + const opts = ( + options && options.bringChildrenTo + ? { + ...options, + bringChildrenTo: options.bringChildrenTo.id, + } + : options + ) as { + bringChildrenTo?: string; + deleteChildren?: boolean; + }; + + this.transact(() => { + this._crud.deleteBlock(model.id, opts); + }); + } + + dispose() { + this._disposeBlockUpdated.dispose(); + this.slots.ready.dispose(); + this.slots.blockUpdated.dispose(); + this.slots.rootAdded.dispose(); + this.slots.rootDeleted.dispose(); + } + + getBlock(id: string): Block | undefined { + return this._blocks.peek()[id]; + } + + getBlock$(id: string): Block | undefined { + return this._blocks.value[id]; + } + + /** + * @deprecated + * Use `getBlocksByFlavour` instead. + */ + getBlockByFlavour(blockFlavour: string | string[]) { + return this.getBlocksByFlavour(blockFlavour).map(x => x.model); + } + + /** + * @deprecated + * Use `getBlock` instead. + */ + getBlockById<Model extends BlockModel = BlockModel>( + id: string + ): Model | null { + return (this.getBlock(id)?.model ?? null) as Model | null; + } + + getBlocks() { + return Object.values(this._blocks.peek()).map(block => block.model); + } + + getBlocksByFlavour(blockFlavour: string | string[]) { + const flavours = + typeof blockFlavour === 'string' ? [blockFlavour] : blockFlavour; + + return Object.values(this._blocks.peek()).filter(({ flavour }) => + flavours.includes(flavour) + ); + } + + getNext(block: BlockModel | string) { + return this._getSiblings( + block, + (parent, index) => parent.children[index + 1] ?? null + ); + } + + getNexts(block: BlockModel | string) { + return ( + this._getSiblings(block, (parent, index) => + parent.children.slice(index + 1) + ) ?? [] + ); + } + + getParent(target: BlockModel | string): BlockModel | null { + const targetId = typeof target === 'string' ? target : target.id; + const parentId = this._crud.getParent(targetId); + if (!parentId) return null; + + const parent = this._blocks.peek()[parentId]; + if (!parent) return null; + + return parent.model; + } + + getPrev(block: BlockModel | string) { + return this._getSiblings( + block, + (parent, index) => parent.children[index - 1] ?? null + ); + } + + getPrevs(block: BlockModel | string) { + return ( + this._getSiblings(block, (parent, index) => + parent.children.slice(0, index) + ) ?? [] + ); + } + + getSchemaByFlavour(flavour: BlockSuite.Flavour) { + return this._schema.flavourSchemaMap.get(flavour); + } + + hasBlock(id: string) { + return id in this._blocks.peek(); + } + + /** + * @deprecated + * Use `hasBlock` instead. + */ + hasBlockById(id: string) { + return this.hasBlock(id); + } + + load(initFn?: () => void) { + this._blockCollection.load(initFn); + this.slots.ready.emit(); + return this; + } + + moveBlocks( + blocksToMove: BlockModel[], + newParent: BlockModel, + targetSibling: BlockModel | null = null, + shouldInsertBeforeSibling = true + ) { + if (this.readonly) { + console.error('Cannot modify data in read-only mode'); + return; + } + + this.transact(() => { + this._crud.moveBlocks( + blocksToMove.map(model => model.id), + newParent.id, + targetSibling?.id ?? null, + shouldInsertBeforeSibling + ); + }); + } +} diff --git a/blocksuite/framework/store/src/store/doc/index.ts b/blocksuite/framework/store/src/store/doc/index.ts new file mode 100644 index 0000000000..23a3433563 --- /dev/null +++ b/blocksuite/framework/store/src/store/doc/index.ts @@ -0,0 +1,5 @@ +export * from './block/index.js'; +export * from './block-collection.js'; +export * from './consts.js'; +export * from './doc.js'; +export * from './query.js'; diff --git a/blocksuite/framework/store/src/store/doc/query.ts b/blocksuite/framework/store/src/store/doc/query.ts new file mode 100644 index 0000000000..f661ab2701 --- /dev/null +++ b/blocksuite/framework/store/src/store/doc/query.ts @@ -0,0 +1,85 @@ +import isMatch from 'lodash.ismatch'; + +import type { BlockModel } from '../../schema/index.js'; +import type { Block } from './block/index.js'; +import { BlockViewType } from './consts.js'; + +export type QueryMatch = { + id?: string; + flavour?: string; + props?: Record<string, unknown>; + viewType: BlockViewType; +}; + +/** + * - `strict` means that only blocks that match the query will be included. + * - `loose` means that all blocks will be included first, and then the blocks will be run through the query. + * - `include` means that only blocks and their ancestors that match the query will be included. + */ +type QueryMode = 'strict' | 'loose' | 'include'; + +export type Query = { + match: QueryMatch[]; + mode: QueryMode; +}; + +export function runQuery(query: Query, block: Block) { + const blockViewType = getBlockViewType(query, block); + block.blockViewType = blockViewType; + + if (blockViewType !== BlockViewType.Hidden) { + const queryMode = query.mode; + setAncestorsToDisplayIfHidden(queryMode, block); + } +} + +function getBlockViewType(query: Query, block: Block): BlockViewType { + const flavour = block.model.flavour; + const id = block.model.id; + const queryMode = query.mode; + const props = block.model.keys.reduce( + (acc, key) => { + return { + ...acc, + [key]: block.model[key as keyof BlockModel], + }; + }, + {} as Record<string, unknown> + ); + let blockViewType = + queryMode === 'loose' ? BlockViewType.Display : BlockViewType.Hidden; + + query.match.some(queryObject => { + const { + id: queryId, + flavour: queryFlavour, + props: queryProps, + viewType, + } = queryObject; + const matchQueryId = queryId == null ? true : queryId === id; + const matchQueryFlavour = + queryFlavour == null ? true : queryFlavour === flavour; + const matchQueryProps = + queryProps == null ? true : isMatch(props, queryProps); + if (matchQueryId && matchQueryFlavour && matchQueryProps) { + blockViewType = viewType; + return true; + } + return false; + }); + + return blockViewType; +} + +function setAncestorsToDisplayIfHidden(mode: QueryMode, block: Block) { + const doc = block.model.doc; + let parent = doc.getParent(block.model); + while (parent) { + const parentBlock = doc.getBlock(parent.id); + if (parentBlock && parentBlock.blockViewType === BlockViewType.Hidden) { + parentBlock.blockViewType = + mode === 'include' ? BlockViewType.Display : BlockViewType.Bypass; + } + parent = doc.getParent(parent); + } +} diff --git a/blocksuite/framework/store/src/store/id.ts b/blocksuite/framework/store/src/store/id.ts new file mode 100644 index 0000000000..1000e4e458 --- /dev/null +++ b/blocksuite/framework/store/src/store/id.ts @@ -0,0 +1,54 @@ +import { + createAutoIncrementIdGenerator, + createAutoIncrementIdGeneratorByClientId, + type IdGenerator, + nanoid, + uuidv4, +} from '../utils/id-generator.js'; + +export enum IdGeneratorType { + /** + * **Warning**: This generator mode will crash the collaborative feature + * if multiple clients are adding new blocks. + * Use this mode only if you know what you're doing. + */ + AutoIncrement = 'autoIncrement', + + /** + * This generator is trying to fix the real-time collaboration on debug mode. + * This will make generator predictable and won't make conflict + * @link https://docs.yjs.dev/api/faq#i-get-a-new-clientid-for-every-session-is-there-a-way-to-make-it-static-for-a-peer-accessing-the-doc + */ + AutoIncrementByClientId = 'autoIncrementByClientId', + + /** + * Default mode, generator for the unpredictable id + */ + NanoID = 'nanoID', + UUIDv4 = 'uuidV4', +} + +export function pickIdGenerator( + idGenerator: IdGeneratorType | IdGenerator | undefined, + clientId: number +) { + if (typeof idGenerator === 'function') { + return idGenerator; + } + + switch (idGenerator) { + case IdGeneratorType.AutoIncrement: { + return createAutoIncrementIdGenerator(); + } + case IdGeneratorType.AutoIncrementByClientId: { + return createAutoIncrementIdGeneratorByClientId(clientId); + } + case IdGeneratorType.UUIDv4: { + return uuidv4; + } + case IdGeneratorType.NanoID: + default: { + return nanoid; + } + } +} diff --git a/blocksuite/framework/store/src/store/index.ts b/blocksuite/framework/store/src/store/index.ts new file mode 100644 index 0000000000..ebdadefa26 --- /dev/null +++ b/blocksuite/framework/store/src/store/index.ts @@ -0,0 +1,6 @@ +export type * from './collection.js'; +export { DocCollection } from './collection.js'; +export type * from './doc/block-collection.js'; +export * from './doc/index.js'; +export * from './id.js'; +export type * from './meta.js'; diff --git a/blocksuite/framework/store/src/store/meta.ts b/blocksuite/framework/store/src/store/meta.ts new file mode 100644 index 0000000000..930ceea8f0 --- /dev/null +++ b/blocksuite/framework/store/src/store/meta.ts @@ -0,0 +1,344 @@ +import { BlockSuiteError, ErrorCode } from '@blocksuite/global/exceptions'; +import { Slot } from '@blocksuite/global/utils'; +import type * as Y from 'yjs'; + +import { COLLECTION_VERSION, PAGE_VERSION } from '../consts.js'; +import type { BlockSuiteDoc } from '../yjs/index.js'; +import type { DocCollection } from './collection.js'; + +// please use `declare module '@blocksuite/store'` to extend this interface +export interface DocMeta { + id: string; + title: string; + tags: string[]; + createDate: number; + updatedDate?: number; +} + +export type Tag = { + id: string; + value: string; + color: string; +}; +export type DocsPropertiesMeta = { + tags?: { + options: Tag[]; + }; +}; +export type DocCollectionMetaState = { + pages?: unknown[]; + properties?: DocsPropertiesMeta; + workspaceVersion?: number; + pageVersion?: number; + blockVersions?: Record<string, number>; + name?: string; + avatar?: string; +}; + +export class DocCollectionMeta { + private _handleDocCollectionMetaEvents = ( + events: Y.YEvent<Y.Array<unknown> | Y.Text | Y.Map<unknown>>[] + ) => { + events.forEach(e => { + const hasKey = (k: string) => + e.target === this._yMap && e.changes.keys.has(k); + + if ( + e.target === this.yDocs || + e.target.parent === this.yDocs || + hasKey('pages') + ) { + this._handleDocMetaEvent(); + } + + if (hasKey('name') || hasKey('avatar')) { + this._handleCommonFieldsEvent(); + } + }); + }; + + private _prevDocs = new Set<string>(); + + protected readonly _proxy: DocCollectionMetaState; + + protected readonly _yMap: Y.Map< + DocCollectionMetaState[keyof DocCollectionMetaState] + >; + + commonFieldsUpdated = new Slot(); + + readonly doc: BlockSuiteDoc; + + docMetaAdded = new Slot<string>(); + + docMetaRemoved = new Slot<string>(); + + docMetaUpdated = new Slot(); + + readonly id: string = 'meta'; + + get avatar() { + return this._proxy.avatar; + } + + get blockVersions() { + return this._proxy.blockVersions; + } + + get docMetas() { + if (!this._proxy.pages) { + return [] as DocMeta[]; + } + return this._proxy.pages as DocMeta[]; + } + + get docs() { + return this._proxy.pages; + } + + get hasVersion() { + if (!this.blockVersions || !this.pageVersion || !this.workspaceVersion) { + return false; + } + return Object.keys(this.blockVersions).length > 0; + } + + get name() { + return this._proxy.name; + } + + get pageVersion() { + return this._proxy.pageVersion; + } + + get properties(): DocsPropertiesMeta { + const meta = this._proxy.properties; + if (!meta) { + return { + tags: { + options: [], + }, + }; + } + return meta; + } + + get workspaceVersion() { + return this._proxy.workspaceVersion; + } + + get yDocs() { + return this._yMap.get('pages') as unknown as Y.Array<unknown>; + } + + constructor(doc: BlockSuiteDoc) { + this.doc = doc; + this._yMap = doc.getMap(this.id); + this._proxy = doc.getMapProxy<string, DocCollectionMetaState>(this.id); + this._yMap.observeDeep(this._handleDocCollectionMetaEvents); + } + + private _handleCommonFieldsEvent() { + this.commonFieldsUpdated.emit(); + } + + private _handleDocMetaEvent() { + const { docMetas, _prevDocs } = this; + + const newDocs = new Set<string>(); + + docMetas.forEach(docMeta => { + if (!_prevDocs.has(docMeta.id)) { + this.docMetaAdded.emit(docMeta.id); + } + newDocs.add(docMeta.id); + }); + + _prevDocs.forEach(prevDocId => { + const isRemoved = newDocs.has(prevDocId) === false; + if (isRemoved) { + this.docMetaRemoved.emit(prevDocId); + } + }); + + this._prevDocs = newDocs; + + this.docMetaUpdated.emit(); + } + + addDocMeta(doc: DocMeta, index?: number) { + this.doc.transact(() => { + if (!this.docs) { + return; + } + const docs = this.docs as unknown[]; + if (index === undefined) { + docs.push(doc); + } else { + docs.splice(index, 0, doc); + } + }, this.doc.clientID); + } + + getDocMeta(id: string) { + return this.docMetas.find(doc => doc.id === id); + } + + initialize() { + if (!this._proxy.pages) { + this._proxy.pages = []; + } + } + + removeDocMeta(id: string) { + // you cannot delete a doc if there's no doc + if (!this.docs) { + return; + } + + const docMeta = this.docMetas; + const index = docMeta.findIndex((doc: DocMeta) => id === doc.id); + if (index === -1) { + return; + } + this.doc.transact(() => { + if (!this.docs) { + return; + } + this.docs.splice(index, 1); + }, this.doc.clientID); + } + + setAvatar(avatar: string) { + this.doc.transact(() => { + this._proxy.avatar = avatar; + }, this.doc.clientID); + } + + setDocMeta(id: string, props: Partial<DocMeta>) { + const docs = (this.docs as DocMeta[]) ?? []; + const index = docs.findIndex((doc: DocMeta) => id === doc.id); + + this.doc.transact(() => { + if (!this.docs) { + return; + } + if (index === -1) return; + + const doc = this.docs[index] as Record<string, unknown>; + Object.entries(props).forEach(([key, value]) => { + doc[key] = value; + }); + }, this.doc.clientID); + } + + setName(name: string) { + this.doc.transact(() => { + this._proxy.name = name; + }, this.doc.clientID); + } + + setProperties(meta: DocsPropertiesMeta) { + this._proxy.properties = meta; + this.docMetaUpdated.emit(); + } + + /** + * @deprecated Only used for legacy doc version validation + */ + validateVersion(collection: DocCollection) { + const workspaceVersion = this._proxy.workspaceVersion; + if (!workspaceVersion) { + throw new BlockSuiteError( + ErrorCode.DocCollectionError, + 'Invalid workspace data, workspace version is missing. Please make sure the data is valid.' + ); + } + if (workspaceVersion < COLLECTION_VERSION) { + throw new BlockSuiteError( + ErrorCode.DocCollectionError, + `Workspace version ${workspaceVersion} is outdated. Please upgrade the editor.` + ); + } + + const pageVersion = this._proxy.pageVersion; + if (!pageVersion) { + throw new BlockSuiteError( + ErrorCode.DocCollectionError, + 'Invalid workspace data, page version is missing. Please make sure the data is valid.' + ); + } + if (pageVersion < PAGE_VERSION) { + throw new BlockSuiteError( + ErrorCode.DocCollectionError, + `Doc version ${pageVersion} is outdated. Please upgrade the editor.` + ); + } + + const blockVersions = { ...this._proxy.blockVersions }; + if (!blockVersions) { + throw new BlockSuiteError( + ErrorCode.DocCollectionError, + 'Invalid workspace data, versions data is missing. Please make sure the data is valid' + ); + } + const dataFlavours = Object.keys(blockVersions); + if (dataFlavours.length === 0) { + throw new BlockSuiteError( + ErrorCode.DocCollectionError, + 'Invalid workspace data, missing versions field. Please make sure the data is valid.' + ); + } + + dataFlavours.forEach(dataFlavour => { + const dataVersion = blockVersions[dataFlavour] as number; + const editorVersion = + collection.schema.flavourSchemaMap.get(dataFlavour)?.version; + if (!editorVersion) { + throw new BlockSuiteError( + ErrorCode.DocCollectionError, + `Editor missing ${dataFlavour} flavour. Please make sure this block flavour is registered.` + ); + } else if (dataVersion > editorVersion) { + throw new BlockSuiteError( + ErrorCode.DocCollectionError, + `Editor doesn't support ${dataFlavour}@${dataVersion}. Please upgrade the editor.` + ); + } else if (dataVersion < editorVersion) { + throw new BlockSuiteError( + ErrorCode.DocCollectionError, + `In workspace data, the block flavour ${dataFlavour}@${dataVersion} is outdated. Please downgrade the editor or try data migration.` + ); + } + }); + } + + /** + * @internal Only for doc initialization + */ + writeVersion(collection: DocCollection) { + const { blockVersions, pageVersion, workspaceVersion } = this._proxy; + + if (!workspaceVersion) { + this._proxy.workspaceVersion = COLLECTION_VERSION; + } else { + console.error('Workspace version is already set'); + } + + if (!pageVersion) { + this._proxy.pageVersion = PAGE_VERSION; + } else { + console.error('Doc version is already set'); + } + + if (!blockVersions) { + const _versions: Record<string, number> = {}; + collection.schema.flavourSchemaMap.forEach((schema, flavour) => { + _versions[flavour] = schema.version; + }); + this._proxy.blockVersions = _versions; + } else { + console.error('Block versions is already set'); + } + } +} diff --git a/blocksuite/framework/store/src/transformer/assets.ts b/blocksuite/framework/store/src/transformer/assets.ts new file mode 100644 index 0000000000..0dcb50284e --- /dev/null +++ b/blocksuite/framework/store/src/transformer/assets.ts @@ -0,0 +1,104 @@ +import { BlockSuiteError, ErrorCode } from '@blocksuite/global/exceptions'; + +interface BlobCRUD { + get: (key: string) => Promise<Blob | null> | Blob | null; + set: (key: string, value: Blob) => Promise<string> | string; + delete: (key: string) => Promise<void> | void; + list: () => Promise<string[]> | string[]; +} + +type AssetsManagerConfig = { + blob: BlobCRUD; +}; + +function makeNewNameWhenConflict(names: Set<string>, name: string) { + let i = 1; + const ext = name.split('.').at(-1) ?? ''; + let newName = name.replace(new RegExp(`.${ext}$`), ` (${i}).${ext}`); + while (names.has(newName)) { + newName = name.replace(new RegExp(`.${ext}$`), ` (${i}).${ext}`); + i++; + } + return newName; +} + +export class AssetsManager { + private readonly _assetsMap = new Map<string, Blob>(); + + private readonly _blob: BlobCRUD; + + private readonly _names = new Set<string>(); + + private readonly _pathBlobIdMap = new Map<string, string>(); + + constructor(options: AssetsManagerConfig) { + this._blob = options.blob; + } + + cleanup() { + this._assetsMap.clear(); + this._names.clear(); + } + + getAssets() { + return this._assetsMap; + } + + getPathBlobIdMap() { + return this._pathBlobIdMap; + } + + isEmpty() { + return this._assetsMap.size === 0; + } + + async readFromBlob(blobId: string) { + if (this._assetsMap.has(blobId)) return; + const blob = await this._blob.get(blobId); + if (!blob) { + console.error(`Blob ${blobId} not found in blob manager`); + return; + } + if (blob instanceof File) { + let file = blob; + if (this._names.has(blob.name)) { + const newName = makeNewNameWhenConflict(this._names, blob.name); + file = new File([blob], newName, { type: blob.type }); + } + this._assetsMap.set(blobId, file); + this._names.add(file.name); + return; + } + if (blob.type && blob.type !== 'application/octet-stream') { + this._assetsMap.set(blobId, blob); + return; + } + // Guess the file type from the buffer + const buffer = await blob.arrayBuffer(); + const FileType = await import('file-type'); + const fileType = await FileType.fileTypeFromBuffer(buffer); + if (fileType) { + const file = new File([blob], '', { type: fileType.mime }); + this._assetsMap.set(blobId, file); + return; + } + this._assetsMap.set(blobId, blob); + } + + async writeToBlob(blobId: string) { + const blob = this._assetsMap.get(blobId); + if (!blob) { + throw new BlockSuiteError( + ErrorCode.TransformerError, + `Blob ${blobId} not found in assets manager` + ); + } + + const exists = (await this._blob.get(blobId)) !== null; + if (exists) { + return; + } + + await this._blob.set(blobId, blob); + } +} diff --git a/blocksuite/framework/store/src/transformer/base.ts b/blocksuite/framework/store/src/transformer/base.ts new file mode 100644 index 0000000000..2a43762745 --- /dev/null +++ b/blocksuite/framework/store/src/transformer/base.ts @@ -0,0 +1,78 @@ +import type { BlockModel, InternalPrimitives } from '../schema/index.js'; +import { internalPrimitives } from '../schema/index.js'; +import type { AssetsManager } from './assets.js'; +import type { DraftModel } from './draft.js'; +import { fromJSON, toJSON } from './json.js'; +import type { BlockSnapshot } from './type.js'; + +type BlockSnapshotLeaf = Pick< + BlockSnapshot, + 'id' | 'flavour' | 'props' | 'version' +>; + +export type FromSnapshotPayload = { + json: BlockSnapshotLeaf; + assets: AssetsManager; + children: BlockSnapshot[]; +}; + +export type ToSnapshotPayload<Props extends object> = { + model: DraftModel<BlockModel<Props>>; + assets: AssetsManager; +}; + +export type SnapshotNode<Props extends object> = { + id: string; + flavour: string; + version: number; + props: Props; +}; + +export class BaseBlockTransformer<Props extends object = object> { + protected _internal: InternalPrimitives = internalPrimitives; + + protected _propsFromSnapshot(propsJson: Record<string, unknown>) { + return Object.fromEntries( + Object.entries(propsJson).map(([key, value]) => { + return [key, fromJSON(value)]; + }) + ) as Props; + } + + protected _propsToSnapshot(model: DraftModel) { + return Object.fromEntries( + model.keys.map(key => { + const value = model[key as keyof typeof model]; + return [key, toJSON(value)]; + }) + ); + } + + fromSnapshot({ + json, + }: FromSnapshotPayload): Promise<SnapshotNode<Props>> | SnapshotNode<Props> { + const { flavour, id, version, props: _props } = json; + + const props = this._propsFromSnapshot(_props); + + return { + id, + flavour, + version: version ?? -1, + props, + }; + } + + toSnapshot({ model }: ToSnapshotPayload<Props>): BlockSnapshotLeaf { + const { id, flavour, version } = model; + + const props = this._propsToSnapshot(model); + + return { + id, + flavour, + version, + props, + }; + } +} diff --git a/blocksuite/framework/store/src/transformer/draft.ts b/blocksuite/framework/store/src/transformer/draft.ts new file mode 100644 index 0000000000..c1a027c370 --- /dev/null +++ b/blocksuite/framework/store/src/transformer/draft.ts @@ -0,0 +1,35 @@ +import type { BlockModel } from '../schema/base.js'; + +type PropsInDraft = 'version' | 'flavour' | 'role' | 'id' | 'keys' | 'text'; + +type ModelProps<Model> = Model extends BlockModel<infer U> ? U : never; + +export type DraftModel<Model extends BlockModel = BlockModel> = Pick< + Model, + PropsInDraft +> & { + children: DraftModel[]; +} & ModelProps<Model>; + +export function toDraftModel<Model extends BlockModel = BlockModel>( + origin: Model +): DraftModel<Model> { + const { id, version, flavour, role, keys, text, children } = origin; + const props = origin.keys.reduce((acc, key) => { + return { + ...acc, + [key]: origin[key as keyof Model], + }; + }, {} as ModelProps<Model>); + + return { + id, + version, + flavour, + role, + keys, + text, + children: children.map(toDraftModel), + ...props, + } as DraftModel<Model>; +} diff --git a/blocksuite/framework/store/src/transformer/index.ts b/blocksuite/framework/store/src/transformer/index.ts new file mode 100644 index 0000000000..c7d95ff7ce --- /dev/null +++ b/blocksuite/framework/store/src/transformer/index.ts @@ -0,0 +1,8 @@ +export * from './assets.js'; +export * from './base.js'; +export * from './draft.js'; +export * from './job.js'; +export * from './json.js'; +export * from './middleware.js'; +export * from './slice.js'; +export * from './type.js'; diff --git a/blocksuite/framework/store/src/transformer/job.ts b/blocksuite/framework/store/src/transformer/job.ts new file mode 100644 index 0000000000..999c2e3e35 --- /dev/null +++ b/blocksuite/framework/store/src/transformer/job.ts @@ -0,0 +1,630 @@ +import { BlockSuiteError, ErrorCode } from '@blocksuite/global/exceptions'; +import { nextTick, Slot } from '@blocksuite/global/utils'; + +import type { BlockModel, BlockSchemaType } from '../schema/index.js'; +import type { Doc, DocCollection, DocMeta } from '../store/index.js'; +import { AssetsManager } from './assets.js'; +import { BaseBlockTransformer } from './base.js'; +import type { DraftModel } from './draft.js'; +import type { + BeforeExportPayload, + BeforeImportPayload, + FinalPayload, + JobMiddleware, + JobSlots, +} from './middleware.js'; +import { Slice } from './slice.js'; +import type { + BlockSnapshot, + CollectionInfoSnapshot, + DocSnapshot, + SliceSnapshot, +} from './type.js'; +import { + BlockSnapshotSchema, + CollectionInfoSnapshotSchema, + DocSnapshotSchema, + SliceSnapshotSchema, +} from './type.js'; + +export type JobConfig = { + collection: DocCollection; + middlewares?: JobMiddleware[]; +}; + +interface FlatSnapshot { + snapshot: BlockSnapshot; + parentId?: string; + index?: number; +} + +interface DraftBlockTreeNode { + draft: DraftModel; + snapshot: BlockSnapshot; + children: Array<DraftBlockTreeNode>; +} + +// The number of blocks to insert in one batch +const BATCH_SIZE = 100; + +export class Job { + private readonly _adapterConfigs = new Map<string, string>(); + + private readonly _assetsManager: AssetsManager; + + private readonly _collection: DocCollection; + + private readonly _slots: JobSlots = { + beforeImport: new Slot<BeforeImportPayload>(), + afterImport: new Slot<FinalPayload>(), + beforeExport: new Slot<BeforeExportPayload>(), + afterExport: new Slot<FinalPayload>(), + }; + + blockToSnapshot = (model: DraftModel): BlockSnapshot | undefined => { + try { + const snapshot = this._blockToSnapshot(model); + BlockSnapshotSchema.parse(snapshot); + + return snapshot; + } catch (error) { + console.error(`Error when transforming block to snapshot:`); + console.error(error); + return; + } + }; + + collectionInfoToSnapshot = (): CollectionInfoSnapshot | undefined => { + try { + this._slots.beforeExport.emit({ + type: 'info', + }); + const collectionMeta = this._getCollectionMeta(); + const snapshot: CollectionInfoSnapshot = { + type: 'info', + id: this._collection.id, + ...collectionMeta, + }; + this._slots.afterExport.emit({ + type: 'info', + snapshot, + }); + CollectionInfoSnapshotSchema.parse(snapshot); + + return snapshot; + } catch (error) { + console.error(`Error when transforming collection info to snapshot:`); + console.error(error); + return; + } + }; + + docToSnapshot = (doc: Doc): DocSnapshot | undefined => { + try { + this._slots.beforeExport.emit({ + type: 'page', + page: doc, + }); + const rootModel = doc.root; + const meta = this._exportDocMeta(doc); + if (!rootModel) { + throw new BlockSuiteError( + ErrorCode.TransformerError, + 'Root block not found in doc' + ); + } + const blocks = this.blockToSnapshot(rootModel); + if (!blocks) { + return; + } + const docSnapshot: DocSnapshot = { + type: 'page', + meta, + blocks, + }; + this._slots.afterExport.emit({ + type: 'page', + page: doc, + snapshot: docSnapshot, + }); + DocSnapshotSchema.parse(docSnapshot); + + return docSnapshot; + } catch (error) { + console.error(`Error when transforming doc to snapshot:`); + console.error(error); + return; + } + }; + + sliceToSnapshot = (slice: Slice): SliceSnapshot | undefined => { + try { + this._slots.beforeExport.emit({ + type: 'slice', + slice, + }); + const { content, pageId, workspaceId } = slice.data; + const contentSnapshot = []; + for (const block of content) { + const blockSnapshot = this.blockToSnapshot(block); + if (!blockSnapshot) { + return; + } + contentSnapshot.push(blockSnapshot); + } + const snapshot: SliceSnapshot = { + type: 'slice', + workspaceId, + pageId, + content: contentSnapshot, + }; + this._slots.afterExport.emit({ + type: 'slice', + slice, + snapshot, + }); + SliceSnapshotSchema.parse(snapshot); + + return snapshot; + } catch (error) { + console.error(`Error when transforming slice to snapshot:`); + console.error(error); + return; + } + }; + + snapshotToBlock = async ( + snapshot: BlockSnapshot, + doc: Doc, + parent?: string, + index?: number + ): Promise<BlockModel | undefined> => { + try { + BlockSnapshotSchema.parse(snapshot); + const model = await this._snapshotToBlock(snapshot, doc, parent, index); + if (!model) return; + return model; + } catch (error) { + console.error(`Error when transforming snapshot to block:`); + console.error(error); + return; + } + }; + + snapshotToDoc = async (snapshot: DocSnapshot): Promise<Doc | undefined> => { + try { + this._slots.beforeImport.emit({ + type: 'page', + snapshot, + }); + DocSnapshotSchema.parse(snapshot); + const { meta, blocks } = snapshot; + const doc = this._collection.createDoc({ id: meta.id }); + doc.load(); + await this.snapshotToBlock(blocks, doc); + this._slots.afterImport.emit({ + type: 'page', + snapshot, + page: doc, + }); + + return doc; + } catch (error) { + console.error(`Error when transforming snapshot to doc:`); + console.error(error); + return; + } + }; + + snapshotToModelData = async (snapshot: BlockSnapshot) => { + try { + const { children, flavour, props, id } = snapshot; + + const schema = this._getSchema(flavour); + const snapshotLeaf = { + id, + flavour, + props, + }; + const transformer = this._getTransformer(schema); + const modelData = await transformer.fromSnapshot({ + json: snapshotLeaf, + assets: this._assetsManager, + children, + }); + + return modelData; + } catch (error) { + console.error(`Error when transforming snapshot to model data:`); + console.error(error); + return; + } + }; + + snapshotToSlice = async ( + snapshot: SliceSnapshot, + doc: Doc, + parent?: string, + index?: number + ): Promise<Slice | undefined> => { + SliceSnapshotSchema.parse(snapshot); + try { + this._slots.beforeImport.emit({ + type: 'slice', + snapshot, + }); + + const { content, workspaceId, pageId } = snapshot; + + // Create a temporary root snapshot to encompass all content blocks + const tmpRootSnapshot: BlockSnapshot = { + id: 'temporary-root', + flavour: 'affine:page', + 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 => doc.getBlockById(tree.draft.id)) + .filter(Boolean) as DraftModel[]; + + const slice = new Slice({ + content: contentBlocks, + 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; + } + }; + + walk = (snapshot: DocSnapshot, callback: (block: BlockSnapshot) => void) => { + const walk = (block: BlockSnapshot) => { + try { + callback(block); + } catch (error) { + console.error(`Error when walking snapshot:`); + console.error(error); + } + + if (block.children) { + block.children.forEach(walk); + } + }; + + walk(snapshot.blocks); + }; + + get adapterConfigs() { + return this._adapterConfigs; + } + + get assets() { + return this._assetsManager.getAssets(); + } + + get assetsManager() { + return this._assetsManager; + } + + get collection() { + return this._collection; + } + + constructor({ collection, middlewares = [] }: JobConfig) { + this._collection = collection; + this._assetsManager = new AssetsManager({ blob: collection.blobSync }); + + middlewares.forEach(middleware => { + middleware({ + slots: this._slots, + assetsManager: this._assetsManager, + collection: this._collection, + adapterConfigs: this._adapterConfigs, + }); + }); + } + + private _blockToSnapshot(model: DraftModel): BlockSnapshot { + this._slots.beforeExport.emit({ + type: 'block', + model, + }); + const schema = this._getSchema(model.flavour); + const transformer = this._getTransformer(schema); + const snapshotLeaf = transformer.toSnapshot({ + model, + assets: this._assetsManager, + }); + const children = model.children.map(child => { + return this._blockToSnapshot(child); + }); + const snapshot: BlockSnapshot = { + type: 'block', + ...snapshotLeaf, + children, + }; + this._slots.afterExport.emit({ + type: 'block', + model, + snapshot, + }); + + return snapshot; + } + + private async _convertFlatSnapshots(flatSnapshots: FlatSnapshot[]) { + // Phase 1: Convert snapshots to draft models in series + // This is not time-consuming, this is faster than Promise.all + const draftModels = []; + for (const flat of flatSnapshots) { + const draft = await this._convertSnapshotToDraftModel(flat); + if (draft) { + draft.id = flat.snapshot.id; + } + draftModels.push({ + draft, + snapshot: flat.snapshot, + parentId: flat.parentId, + index: flat.index, + }); + } + + // Phase 2: Filter out the models that failed to convert + const validDraftModels = draftModels.filter(item => !!item.draft) as { + draft: DraftModel; + snapshot: BlockSnapshot; + parentId?: string; + index?: number; + }[]; + + // Phase 3: Rebuild the block trees + const blockTree = this._rebuildBlockTree(validDraftModels); + return blockTree; + } + + private async _convertSnapshotToDraftModel( + flat: FlatSnapshot + ): Promise<DraftModel | undefined> { + try { + const { children, flavour } = flat.snapshot; + const schema = this._getSchema(flavour); + const transformer = this._getTransformer(schema); + const { props } = await transformer.fromSnapshot({ + json: { + id: flat.snapshot.id, + flavour: flat.snapshot.flavour, + props: flat.snapshot.props, + }, + assets: this._assetsManager, + children, + }); + + return { + id: flat.snapshot.id, + flavour: flat.snapshot.flavour, + children: [], + ...props, + } as DraftModel; + } catch (error) { + console.error(`Error when transforming snapshot to model data:`); + console.error(error); + return; + } + } + + private _exportDocMeta(doc: Doc): DocSnapshot['meta'] { + const docMeta = doc.meta; + + if (!docMeta) { + throw new BlockSuiteError( + ErrorCode.TransformerError, + 'Doc meta not found' + ); + } + return { + id: docMeta.id, + title: docMeta.title, + createDate: docMeta.createDate, + tags: [], // for backward compatibility + }; + } + + private _flattenSnapshot( + snapshot: BlockSnapshot, + flatSnapshots: FlatSnapshot[], + parentId?: string, + index?: number + ) { + flatSnapshots.push({ snapshot, parentId, index }); + if (snapshot.children) { + snapshot.children.forEach((child, idx) => { + this._flattenSnapshot(child, flatSnapshots, snapshot.id, idx); + }); + } + } + + private _getCollectionMeta() { + const { meta } = this._collection; + const { docs } = meta; + if (!docs) { + throw new BlockSuiteError(ErrorCode.TransformerError, 'Docs not found'); + } + return { + properties: {}, // for backward compatibility + pages: JSON.parse(JSON.stringify(docs)) as DocMeta[], + }; + } + + private _getSchema(flavour: string) { + const schema = this._collection.schema.flavourSchemaMap.get(flavour); + if (!schema) { + throw new BlockSuiteError( + ErrorCode.TransformerError, + `Flavour schema not found for ${flavour}` + ); + } + return schema; + } + + private _getTransformer(schema: BlockSchemaType) { + return schema.transformer?.() ?? new BaseBlockTransformer(); + } + + private async _insertBlockTree( + nodes: DraftBlockTreeNode[], + doc: Doc, + parentId?: string, + startIndex?: number, + counter: number = 0 + ) { + for (let index = 0; index < nodes.length; index++) { + const node = nodes[index]; + const { draft } = node; + const { id, flavour } = draft; + + const actualIndex = + startIndex !== undefined ? startIndex + index : undefined; + doc.addBlock( + flavour as BlockSuite.Flavour, + draft as object, + parentId, + actualIndex + ); + + const model = doc.getBlock(id)?.model; + if (!model) { + throw new BlockSuiteError( + ErrorCode.TransformerError, + `Block not found by id ${id}` + ); + } + + this._slots.afterImport.emit({ + type: 'block', + model, + snapshot: node.snapshot, + }); + + counter++; + if (counter % BATCH_SIZE === 0) { + await nextTick(); + } + + if (node.children.length > 0) { + counter = await this._insertBlockTree( + node.children, + doc, + id, + undefined, + counter + ); + } + } + + return counter; + } + + private _rebuildBlockTree( + draftModels: { + draft: DraftModel; + snapshot: BlockSnapshot; + parentId?: string; + index?: number; + }[] + ): DraftBlockTreeNode { + const nodeMap = new Map<string, DraftBlockTreeNode>(); + // First pass: create nodes and add them to the map + draftModels.forEach(({ draft, snapshot }) => { + nodeMap.set(draft.id, { draft, snapshot, children: [] }); + }); + const root = nodeMap.get(draftModels[0].draft.id) as DraftBlockTreeNode; + + // Second pass: build the tree structure + draftModels.forEach(({ draft, parentId, index }) => { + const node = nodeMap.get(draft.id); + if (!node) return; + + if (parentId) { + const parentNode = nodeMap.get(parentId); + if (parentNode && index !== undefined) { + parentNode.children[index] = node; + } + } + }); + + if (!root) { + throw new Error('No root node found in the tree'); + } + + return root; + } + + private async _snapshotToBlock( + snapshot: BlockSnapshot, + doc: Doc, + parent?: string, + index?: number + ): Promise<BlockModel | null> { + this._triggerBeforeImportEvent(snapshot, parent, index); + + const flatSnapshots: FlatSnapshot[] = []; + this._flattenSnapshot(snapshot, flatSnapshots, parent, index); + + const blockTree = await this._convertFlatSnapshots(flatSnapshots); + + await this._insertBlockTree([blockTree], doc, parent, index); + + return doc.getBlock(snapshot.id)?.model ?? null; + } + + private _triggerBeforeImportEvent( + snapshot: BlockSnapshot, + parent?: string, + index?: number + ) { + const traverseAndTrigger = ( + node: BlockSnapshot, + parent?: string, + index?: number + ) => { + this._slots.beforeImport.emit({ + type: 'block', + snapshot: node, + parent: parent, + index: index, + }); + if (node.children) { + node.children.forEach((child, idx) => { + traverseAndTrigger(child, node.id, idx); + }); + } + }; + traverseAndTrigger(snapshot, parent, index); + } + + reset() { + this._assetsManager.cleanup(); + } +} diff --git a/blocksuite/framework/store/src/transformer/json.ts b/blocksuite/framework/store/src/transformer/json.ts new file mode 100644 index 0000000000..e1b3d05f38 --- /dev/null +++ b/blocksuite/framework/store/src/transformer/json.ts @@ -0,0 +1,51 @@ +import { NATIVE_UNIQ_IDENTIFIER, TEXT_UNIQ_IDENTIFIER } from '../consts.js'; +import { Boxed } from '../reactive/boxed.js'; +import { isPureObject } from '../reactive/index.js'; +import { Text } from '../reactive/text.js'; + +export function toJSON(value: unknown): unknown { + if (value instanceof Boxed) { + return { + [NATIVE_UNIQ_IDENTIFIER]: true, + value: value.getValue(), + }; + } + if (value instanceof Text) { + return { + [TEXT_UNIQ_IDENTIFIER]: true, + delta: value.yText.toDelta(), + }; + } + if (Array.isArray(value)) { + return value.map(toJSON); + } + if (isPureObject(value)) { + return Object.fromEntries( + Object.entries(value).map(([key, value]) => { + return [key, toJSON(value)]; + }) + ); + } + return value; +} + +export function fromJSON(value: unknown): unknown { + if (Array.isArray(value)) { + return value.map(fromJSON); + } + if (typeof value === 'object' && value != null) { + if (Reflect.has(value, NATIVE_UNIQ_IDENTIFIER)) { + return new Boxed(Reflect.get(value, 'value')); + } + if (Reflect.has(value, TEXT_UNIQ_IDENTIFIER)) { + return new Text(Reflect.get(value, 'delta')); + } + return Object.fromEntries( + Object.entries(value).map(([key, value]) => { + return [key, fromJSON(value)]; + }) + ); + } + + return value; +} diff --git a/blocksuite/framework/store/src/transformer/middleware.ts b/blocksuite/framework/store/src/transformer/middleware.ts new file mode 100644 index 0000000000..e12d04b443 --- /dev/null +++ b/blocksuite/framework/store/src/transformer/middleware.ts @@ -0,0 +1,88 @@ +import type { Slot } from '@blocksuite/global/utils'; + +import type { Doc, DocCollection } from '../store/index.js'; +import type { AssetsManager } from './assets.js'; +import type { DraftModel } from './draft.js'; +import type { Slice } from './slice.js'; +import type { + BlockSnapshot, + CollectionInfoSnapshot, + DocSnapshot, + SliceSnapshot, +} from './type.js'; + +export type BeforeImportPayload = + | { + snapshot: BlockSnapshot; + type: 'block'; + parent?: string; + index?: number; + } + | { + snapshot: SliceSnapshot; + type: 'slice'; + } + | { + snapshot: DocSnapshot; + type: 'page'; + } + | { + snapshot: CollectionInfoSnapshot; + type: 'info'; + }; + +export type BeforeExportPayload = + | { + model: DraftModel; + type: 'block'; + } + | { + page: Doc; + type: 'page'; + } + | { + slice: Slice; + type: 'slice'; + } + | { + type: 'info'; + }; + +export type FinalPayload = + | { + snapshot: BlockSnapshot; + type: 'block'; + model: DraftModel; + parent?: string; + index?: number; + } + | { + snapshot: DocSnapshot; + type: 'page'; + page: Doc; + } + | { + snapshot: SliceSnapshot; + type: 'slice'; + slice: Slice; + } + | { + snapshot: CollectionInfoSnapshot; + type: 'info'; + }; + +export type JobSlots = { + beforeImport: Slot<BeforeImportPayload>; + afterImport: Slot<FinalPayload>; + beforeExport: Slot<BeforeExportPayload>; + afterExport: Slot<FinalPayload>; +}; + +type JobMiddlewareOptions = { + collection: DocCollection; + assetsManager: AssetsManager; + slots: JobSlots; + adapterConfigs: Map<string, string>; +}; + +export type JobMiddleware = (options: JobMiddlewareOptions) => void; diff --git a/blocksuite/framework/store/src/transformer/slice.ts b/blocksuite/framework/store/src/transformer/slice.ts new file mode 100644 index 0000000000..ea0226d4c1 --- /dev/null +++ b/blocksuite/framework/store/src/transformer/slice.ts @@ -0,0 +1,32 @@ +import type { Doc } from '../store/index.js'; +import type { DraftModel } from './draft.js'; + +type SliceData = { + content: DraftModel[]; + workspaceId: string; + pageId: string; +}; + +export class Slice { + get content() { + return this.data.content; + } + + get docId() { + return this.data.pageId; + } + + get workspaceId() { + return this.data.workspaceId; + } + + constructor(readonly data: SliceData) {} + + static fromModels(doc: Doc, models: DraftModel[]) { + return new Slice({ + content: models, + workspaceId: doc.collection.id, + pageId: doc.id, + }); + } +} diff --git a/blocksuite/framework/store/src/transformer/type.ts b/blocksuite/framework/store/src/transformer/type.ts new file mode 100644 index 0000000000..98dba8c531 --- /dev/null +++ b/blocksuite/framework/store/src/transformer/type.ts @@ -0,0 +1,67 @@ +import { z } from 'zod'; + +import type { DocMeta, DocsPropertiesMeta } from '../store/meta.js'; + +export type BlockSnapshot = { + type: 'block'; + id: string; + flavour: string; + version?: number; + props: Record<string, unknown>; + children: BlockSnapshot[]; +}; + +export const BlockSnapshotSchema: z.ZodType<BlockSnapshot> = z.object({ + type: z.literal('block'), + id: z.string(), + flavour: z.string(), + version: z.number().optional(), + props: z.record(z.unknown()), + children: z.lazy(() => BlockSnapshotSchema.array()), +}); + +export type SliceSnapshot = { + type: 'slice'; + content: BlockSnapshot[]; + workspaceId: string; + pageId: string; +}; + +export const SliceSnapshotSchema: z.ZodType<SliceSnapshot> = z.object({ + type: z.literal('slice'), + content: BlockSnapshotSchema.array(), + workspaceId: z.string(), + pageId: z.string(), +}); + +export type CollectionInfoSnapshot = { + id: string; + type: 'info'; + properties: DocsPropertiesMeta; +}; + +export const CollectionInfoSnapshotSchema: z.ZodType<CollectionInfoSnapshot> = + z.object({ + id: z.string(), + type: z.literal('info'), + properties: z.record(z.any()), + }); + +export type DocSnapshot = { + type: 'page'; + meta: DocMeta; + blocks: BlockSnapshot; +}; + +const DocMetaSchema = z.object({ + id: z.string(), + title: z.string(), + createDate: z.number(), + tags: z.array(z.string()), +}); + +export const DocSnapshotSchema: z.ZodType<DocSnapshot> = z.object({ + type: z.literal('page'), + meta: DocMetaSchema, + blocks: BlockSnapshotSchema, +}); diff --git a/blocksuite/framework/store/src/utils/formatter.ts b/blocksuite/framework/store/src/utils/formatter.ts new file mode 100644 index 0000000000..c96219862c --- /dev/null +++ b/blocksuite/framework/store/src/utils/formatter.ts @@ -0,0 +1,68 @@ +import { BlockModel } from '../schema/base.js'; + +function isBlockModel(a: unknown): a is BlockModel { + return a instanceof BlockModel; +} + +/** + * Ported from https://github.com/vuejs/core/blob/main/packages/runtime-core/src/customFormatter.ts + * + * See [Custom Object Formatters in Chrome DevTools](https://docs.google.com/document/d/1FTascZXT9cxfetuPRT2eXPQKXui4nWFivUnS_335T3U) + */ +function initCustomFormatter() { + if ( + !(process.env.NODE_ENV === 'development') || + typeof window === 'undefined' + ) { + return; + } + + const bannerStyle = { + style: + 'color: #eee; background: #3F6FDB; margin-right: 5px; padding: 2px; border-radius: 4px', + }; + const typeStyle = { + style: + 'color: #eee; background: #DB6D56; margin-right: 5px; padding: 2px; border-radius: 4px', + }; + + // custom formatter for Chrome + // https://www.mattzeunert.com/2016/02/19/custom-chrome-devtools-object-formatters.html + const formatter = { + header(obj: unknown, config = { expand: false }) { + if (!isBlockModel(obj) || config.expand) { + return null; + } + if (obj.text) { + return [ + 'div', + {}, + ['span', bannerStyle, obj.constructor.name], + ['span', typeStyle, obj.flavour], + obj.text.toString(), + ]; + } + + return [ + 'div', + {}, + ['span', bannerStyle, obj.constructor.name], + ['span', typeStyle, obj.flavour], + ]; + }, + hasBody() { + return true; + }, + body(obj: unknown) { + return ['object', { object: obj, config: { expand: true } }]; + }, + }; + + if ((window as any).devtoolsFormatters) { + (window as any).devtoolsFormatters.push(formatter); + } else { + (window as any).devtoolsFormatters = [formatter]; + } +} + +initCustomFormatter(); diff --git a/blocksuite/framework/store/src/utils/id-generator.ts b/blocksuite/framework/store/src/utils/id-generator.ts new file mode 100644 index 0000000000..54a513f7fb --- /dev/null +++ b/blocksuite/framework/store/src/utils/id-generator.ts @@ -0,0 +1,24 @@ +import { uuidv4 as uuidv4IdGenerator } from 'lib0/random.js'; +import { nanoid as nanoidGenerator } from 'nanoid'; + +export type IdGenerator = () => string; + +export function createAutoIncrementIdGenerator(): IdGenerator { + let i = 0; + return () => (i++).toString(); +} + +export function createAutoIncrementIdGeneratorByClientId( + clientId: number +): IdGenerator { + let i = 0; + return () => `${clientId}:${i++}`; +} + +export const uuidv4: IdGenerator = () => { + return uuidv4IdGenerator(); +}; + +export const nanoid: IdGenerator = () => { + return nanoidGenerator(10); +}; diff --git a/blocksuite/framework/store/src/utils/jsx.ts b/blocksuite/framework/store/src/utils/jsx.ts new file mode 100644 index 0000000000..c969e50817 --- /dev/null +++ b/blocksuite/framework/store/src/utils/jsx.ts @@ -0,0 +1,165 @@ +import * as Y from 'yjs'; + +type DocRecord = Record< + string, + { + 'sys:id': string; + 'sys:flavour': string; + 'sys:children': string[]; + [id: string]: unknown; + } +>; + +export interface JSXElement { + // Ad-hoc for `ReactTestComponent` identify. + // Use ReactTestComponent serializer prevent snapshot be be wrapped in a string, which cases " to be escaped. + // See https://github.com/facebook/jest/blob/f1263368cc85c3f8b70eaba534ddf593392c44f3/packages/pretty-format/src/plugins/ReactTestComponent.ts#L78-L79 + $$typeof: symbol | 0xea71357; + type: string; + props: { 'prop:text'?: string | JSXElement } & Record<string, unknown>; + children?: null | (JSXElement | string | number)[]; +} + +// Ad-hoc for `ReactTestComponent` identify. +// See https://github.com/facebook/jest/blob/f1263368cc85c3f8b70eaba534ddf593392c44f3/packages/pretty-format/src/plugins/ReactTestComponent.ts#L26-L29 +const testSymbol = Symbol.for('react.test.json'); + +function isValidRecord(data: unknown): data is DocRecord { + if (typeof data !== 'object' || data === null) { + return false; + } + // TODO enhance this check + return true; +} + +const IGNORED_PROPS = new Set([ + 'sys:id', + 'sys:version', + 'sys:flavour', + 'sys:children', + 'prop:xywh', + 'prop:cells', + 'prop:elements', +]); + +export function yDocToJSXNode( + serializedDoc: Record<string, unknown>, + nodeId: string +): JSXElement { + if (!isValidRecord(serializedDoc)) { + throw new Error('Failed to parse doc record! Invalid data.'); + } + const node = serializedDoc[nodeId]; + if (!node) { + throw new Error( + `Failed to parse doc record! Node not found! id: ${nodeId}.` + ); + } + // TODO maybe need set PascalCase + const flavour = node['sys:flavour']; + // TODO maybe need check children recursively nested + const children = node['sys:children']; + const props = Object.fromEntries( + Object.entries(node).filter(([key]) => !IGNORED_PROPS.has(key)) + ); + + if ('prop:text' in props && props['prop:text'] instanceof Array) { + props['prop:text'] = parseDelta(props['prop:text'] as DeltaText); + } + + if ('prop:title' in props && props['prop:title'] instanceof Array) { + props['prop:title'] = parseDelta(props['prop:title'] as DeltaText); + } + + if ('prop:columns' in props && props['prop:columns'] instanceof Array) { + props['prop:columns'] = `Array [${props['prop:columns'].length}]`; + } + + if ('prop:views' in props && props['prop:views'] instanceof Array) { + props['prop:views'] = `Array [${props['prop:views'].length}]`; + } + + return { + $$typeof: testSymbol, + type: flavour, + props, + children: children?.map(id => yDocToJSXNode(serializedDoc, id)) ?? [], + }; +} + +export function serializeYDoc(doc: Y.Doc) { + const json: Record<string, unknown> = {}; + doc.share.forEach((value, key) => { + if (value instanceof Y.Map) { + json[key] = serializeYMap(value); + } else { + json[key] = value.toJSON(); + } + }); + return json; +} + +function serializeY(value: unknown): unknown { + if (value instanceof Y.Doc) { + return serializeYDoc(value); + } + if (value instanceof Y.Map) { + return serializeYMap(value); + } + if (value instanceof Y.Text) { + return serializeYText(value); + } + if (value instanceof Y.Array) { + return value.toArray().map(x => serializeY(x)); + } + if (value instanceof Y.AbstractType) { + return value.toJSON(); + } + return value; +} + +function serializeYMap(map: Y.Map<unknown>) { + const json: Record<string, unknown> = {}; + map.forEach((value, key) => { + json[key] = serializeY(value); + }); + return json; +} + +type DeltaText = { + insert: string; + attributes?: Record<string, unknown>; +}[]; + +function serializeYText(text: Y.Text): DeltaText { + const delta = text.toDelta(); + return delta; +} + +function parseDelta(text: DeltaText) { + if (!text.length) { + return undefined; + } + if (text.length === 1 && !text[0].attributes) { + // just plain text + return text[0].insert; + } + return { + // The `Symbol.for('react.fragment')` will render as `<React.Fragment>` + // so we use a empty string to render it as `<>`. + // But it will empty children ad `< />` + // so we return `undefined` directly if not delta text. + $$typeof: testSymbol, // Symbol.for('react.element'), + type: '', // Symbol.for('react.fragment'), + props: {}, + children: text?.map(({ insert, attributes }) => ({ + $$typeof: testSymbol, + type: 'text', + props: { + // Not place at `children` to avoid the trailing whitespace be trim by formatter. + insert, + ...attributes, + }, + })), + }; +} diff --git a/blocksuite/framework/store/src/utils/utils.ts b/blocksuite/framework/store/src/utils/utils.ts new file mode 100644 index 0000000000..13d3249bda --- /dev/null +++ b/blocksuite/framework/store/src/utils/utils.ts @@ -0,0 +1,47 @@ +import type { z } from 'zod'; + +import { SYS_KEYS } from '../consts.js'; +import { native2Y } from '../reactive/index.js'; +import type { BlockModel, BlockSchema } from '../schema/base.js'; +import { internalPrimitives } from '../schema/base.js'; +import type { YBlock } from '../store/doc/block/index.js'; +import type { BlockProps } from '../store/doc/block-collection.js'; + +export function syncBlockProps( + schema: z.infer<typeof BlockSchema>, + model: BlockModel, + yBlock: YBlock, + props: Partial<BlockProps> +) { + const defaultProps = schema.model.props?.(internalPrimitives) ?? {}; + + Object.entries(props).forEach(([key, value]) => { + if (SYS_KEYS.has(key)) return; + if (value === undefined) return; + + // @ts-expect-error FIXME: ts error + model[key] = value; + }); + + // set default value + Object.entries(defaultProps).forEach(([key, value]) => { + const notExists = + !yBlock.has(`prop:${key}`) || yBlock.get(`prop:${key}`) === undefined; + if (!notExists) { + return; + } + + // @ts-expect-error FIXME: ts error + model[key] = native2Y(value); + }); +} + +export const hash = (str: string) => { + return str + .split('') + .reduce( + (prevHash, currVal) => + ((prevHash << 5) - prevHash + currVal.charCodeAt(0)) | 0, + 0 + ); +}; diff --git a/blocksuite/framework/store/src/yjs/awareness.ts b/blocksuite/framework/store/src/yjs/awareness.ts new file mode 100644 index 0000000000..ad0268fa15 --- /dev/null +++ b/blocksuite/framework/store/src/yjs/awareness.ts @@ -0,0 +1,147 @@ +import type { BlockSuiteFlags } from '@blocksuite/global/types'; +import { Slot } from '@blocksuite/global/utils'; +import { type Signal, signal } from '@preact/signals-core'; +import clonedeep from 'lodash.clonedeep'; +import merge from 'lodash.merge'; +import type { Awareness as YAwareness } from 'y-protocols/awareness.js'; + +import type { BlockCollection } from '../store/index.js'; + +export interface UserInfo { + name: string; +} + +type UserSelection = Array<Record<string, unknown>>; + +// Raw JSON state in awareness CRDT +export type RawAwarenessState<Flags extends BlockSuiteFlags = BlockSuiteFlags> = + { + user?: UserInfo; + color?: string; + flags: Flags; + // use v2 to avoid crush on old clients + selectionV2: Record<string, UserSelection>; + }; + +export interface AwarenessEvent< + Flags extends BlockSuiteFlags = BlockSuiteFlags, +> { + id: number; + type: 'add' | 'update' | 'remove'; + state?: RawAwarenessState<Flags>; +} + +export class AwarenessStore<Flags extends BlockSuiteFlags = BlockSuiteFlags> { + private _flags: Signal<Flags>; + + private _onAwarenessChange = (diff: { + added: number[]; + removed: number[]; + updated: number[]; + }) => { + this._flags.value = this.awareness.getLocalState()?.flags ?? {}; + + const { added, removed, updated } = diff; + + const states = this.awareness.getStates(); + added.forEach(id => { + this.slots.update.emit({ + id, + type: 'add', + state: states.get(id), + }); + }); + updated.forEach(id => { + this.slots.update.emit({ + id, + type: 'update', + state: states.get(id), + }); + }); + removed.forEach(id => { + this.slots.update.emit({ + id, + type: 'remove', + }); + }); + }; + + readonly awareness: YAwareness<RawAwarenessState<Flags>>; + + readonly slots = { + update: new Slot<AwarenessEvent<Flags>>(), + }; + + constructor( + awareness: YAwareness<RawAwarenessState<Flags>>, + defaultFlags: Flags + ) { + this._flags = signal<Flags>(defaultFlags); + this.awareness = awareness; + this.awareness.on('change', this._onAwarenessChange); + this.awareness.setLocalStateField('selectionV2', {}); + this._initFlags(defaultFlags); + } + + private _initFlags(defaultFlags: Flags) { + const upstreamFlags = this.awareness.getLocalState()?.flags; + const flags = clonedeep(defaultFlags); + if (upstreamFlags) { + merge(flags, upstreamFlags); + } + this.awareness.setLocalStateField('flags', flags); + } + + destroy() { + this.awareness.off('change', this._onAwarenessChange); + this.slots.update.dispose(); + this.awareness.destroy(); + } + + getFlag<Key extends keyof Flags>(field: Key) { + return this._flags.value[field]; + } + + getLocalSelection( + selectionManagerId: string + ): ReadonlyArray<Record<string, unknown>> { + return ( + (this.awareness.getLocalState()?.selectionV2 ?? {})[selectionManagerId] ?? + [] + ); + } + + getStates(): Map<number, RawAwarenessState<Flags>> { + return this.awareness.getStates(); + } + + isReadonly(blockCollection: BlockCollection): boolean { + const rd = this.getFlag('readonly'); + if (rd && typeof rd === 'object') { + return Boolean((rd as Record<string, boolean>)[blockCollection.id]); + } else { + return false; + } + } + + setFlag<Key extends keyof Flags>(field: Key, value: Flags[Key]) { + const oldFlags = this.awareness.getLocalState()?.flags ?? {}; + this.awareness.setLocalStateField('flags', { ...oldFlags, [field]: value }); + } + + setLocalSelection(selectionManagerId: string, selection: UserSelection) { + const oldSelection = this.awareness.getLocalState()?.selectionV2 ?? {}; + this.awareness.setLocalStateField('selectionV2', { + ...oldSelection, + [selectionManagerId]: selection, + }); + } + + setReadonly(blockCollection: BlockCollection, value: boolean): void { + const flags = this.getFlag('readonly') ?? {}; + this.setFlag('readonly', { + ...flags, + [blockCollection.id]: value, + } as Flags['readonly']); + } +} diff --git a/blocksuite/framework/store/src/yjs/doc.ts b/blocksuite/framework/store/src/yjs/doc.ts new file mode 100644 index 0000000000..66598cece2 --- /dev/null +++ b/blocksuite/framework/store/src/yjs/doc.ts @@ -0,0 +1,58 @@ +import type { Transaction } from 'yjs'; +import * as Y from 'yjs'; + +import { createYProxy } from '../reactive/proxy.js'; + +export type BlockSuiteDocAllowedValue = + | Record<string, unknown> + | unknown[] + | Y.Text; +export type BlockSuiteDocData = Record<string, BlockSuiteDocAllowedValue>; + +export class BlockSuiteDoc extends Y.Doc { + private _spaces: Y.Map<Y.Doc> = this.getMap('spaces'); + + get spaces() { + return this._spaces; + } + + getArrayProxy< + Key extends keyof BlockSuiteDocData & string, + Value extends unknown[] = BlockSuiteDocData[Key] extends unknown[] + ? BlockSuiteDocData[Key] + : never, + >(key: Key): Value { + const array = super.getArray(key); + return createYProxy(array) as Value; + } + + getMapProxy< + Key extends keyof BlockSuiteDocData & string, + Value extends Record< + string, + unknown + > = BlockSuiteDocData[Key] extends Record<string, unknown> + ? BlockSuiteDocData[Key] + : never, + >(key: Key): Value { + const map = super.getMap(key); + return createYProxy(map); + } + + override toJSON(): Record<string, any> { + const json = super.toJSON(); + delete json.spaces; + const spaces: Record<string, unknown> = {}; + this.spaces.forEach((doc, key) => { + spaces[key] = doc.toJSON(); + }); + return { + ...json, + spaces, + }; + } + + override transact<T>(f: (arg0: Transaction) => T, origin?: number | string) { + return super.transact(f, origin); + } +} diff --git a/blocksuite/framework/store/src/yjs/index.ts b/blocksuite/framework/store/src/yjs/index.ts new file mode 100644 index 0000000000..f85bac772b --- /dev/null +++ b/blocksuite/framework/store/src/yjs/index.ts @@ -0,0 +1,3 @@ +export * from './awareness.js'; +export * from './doc.js'; +export * from './utils.js'; diff --git a/blocksuite/framework/store/src/yjs/utils.ts b/blocksuite/framework/store/src/yjs/utils.ts new file mode 100644 index 0000000000..ffe764c20f --- /dev/null +++ b/blocksuite/framework/store/src/yjs/utils.ts @@ -0,0 +1,7 @@ +import type { Doc as YDoc } from 'yjs'; + +export type SubdocEvent = { + loaded: Set<YDoc>; + removed: Set<YDoc>; + added: Set<YDoc>; +}; diff --git a/blocksuite/framework/store/tsconfig.json b/blocksuite/framework/store/tsconfig.json new file mode 100644 index 0000000000..cc52afd31c --- /dev/null +++ b/blocksuite/framework/store/tsconfig.json @@ -0,0 +1,20 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "rootDir": "./src/", + "outDir": "./dist/", + "noEmit": false + }, + "include": ["./src"], + "references": [ + { + "path": "../global" + }, + { + "path": "../sync" + }, + { + "path": "../inline" + } + ] +} diff --git a/blocksuite/framework/store/typedoc.json b/blocksuite/framework/store/typedoc.json new file mode 100644 index 0000000000..101e923dba --- /dev/null +++ b/blocksuite/framework/store/typedoc.json @@ -0,0 +1,4 @@ +{ + "extends": ["../../../typedoc.base.json"], + "entryPoints": ["src/index.ts"] +} diff --git a/blocksuite/framework/store/vitest.config.ts b/blocksuite/framework/store/vitest.config.ts new file mode 100644 index 0000000000..a5c10c8fc5 --- /dev/null +++ b/blocksuite/framework/store/vitest.config.ts @@ -0,0 +1,28 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + esbuild: { + target: 'es2018', + }, + test: { + include: ['src/__tests__/**/*.unit.spec.ts'], + testTimeout: 500, + coverage: { + provider: 'istanbul', // or 'c8' + reporter: ['lcov'], + reportsDirectory: '../../../.coverage/store', + }, + /** + * 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); + }, + }, +}); diff --git a/blocksuite/framework/sync/README.md b/blocksuite/framework/sync/README.md new file mode 100644 index 0000000000..4e869506a3 --- /dev/null +++ b/blocksuite/framework/sync/README.md @@ -0,0 +1,7 @@ +# `@blocksuite/sync` + +BlockSuite data synchronization engine. + +## Documentation + +Checkout [blocksuite.io](https://blocksuite.io/) for comprehensive documentation. diff --git a/blocksuite/framework/sync/package.json b/blocksuite/framework/sync/package.json new file mode 100644 index 0000000000..1bd3ec6cae --- /dev/null +++ b/blocksuite/framework/sync/package.json @@ -0,0 +1,32 @@ +{ + "name": "@blocksuite/sync", + "description": "BlockSuite data synchronization engine abstraction and implementation.", + "type": "module", + "scripts": { + "build": "tsc", + "test:unit": "nx vite:test --run", + "test": "yarn test:unit" + }, + "sideEffects": false, + "keywords": [], + "author": "toeverything", + "license": "MIT", + "dependencies": { + "@blocksuite/global": "workspace:*", + "idb": "^8.0.0", + "idb-keyval": "^6.2.1", + "y-protocols": "^1.0.6" + }, + "peerDependencies": { + "yjs": "*" + }, + "exports": { + ".": "./src/index.ts" + }, + "files": [ + "src", + "dist", + "!src/__tests__", + "!dist/__tests__" + ] +} diff --git a/blocksuite/framework/sync/src/__tests__/blob.unit.spec.ts b/blocksuite/framework/sync/src/__tests__/blob.unit.spec.ts new file mode 100644 index 0000000000..69cd7266be --- /dev/null +++ b/blocksuite/framework/sync/src/__tests__/blob.unit.spec.ts @@ -0,0 +1,51 @@ +import { NoopLogger } from '@blocksuite/global/utils'; +import { beforeEach, describe, expect, it } from 'vitest'; + +import { BlobEngine } from '../blob/engine.js'; +import { MemoryBlobSource } from '../blob/impl/index.js'; + +describe('BlobEngine with MemoryBlobSource', () => { + let mainSource: MemoryBlobSource; + let shadowSource: MemoryBlobSource; + let engine: BlobEngine; + + beforeEach(() => { + mainSource = new MemoryBlobSource(); + shadowSource = new MemoryBlobSource(); + engine = new BlobEngine(mainSource, [shadowSource], new NoopLogger()); + }); + + it('should set and get blobs', async () => { + const blob = new Blob(['test'], { type: 'text/plain' }); + const key = await engine.set(blob); + const retrievedBlob = await engine.get(key); + expect(retrievedBlob).not.toBeNull(); + expect(await retrievedBlob?.text()).toBe('test'); + }); + + it('should sync blobs between main and shadow sources', async () => { + const blob = new Blob(['test'], { type: 'text/plain' }); + const key = await engine.set(blob); + await engine.sync(); + const retrievedBlob = await shadowSource.get(key); + expect(retrievedBlob).not.toBeNull(); + expect(await retrievedBlob?.text()).toBe('test'); + }); + + it('should list all blobs', async () => { + const blob1 = new Blob(['test1'], { type: 'text/plain' }); + const blob2 = new Blob(['test2'], { type: 'text/plain' }); + await engine.set(blob1); + await engine.set(blob2); + const blobList = await engine.list(); + expect(blobList.length).toBe(2); + }); + + it('should not delete blobs (unsupported feature)', async () => { + const blob = new Blob(['test'], { type: 'text/plain' }); + const key = await engine.set(blob); + await engine.delete(key); + const retrievedBlob = await engine.get(key); + expect(retrievedBlob).not.toBeNull(); + }); +}); diff --git a/blocksuite/framework/sync/src/awareness/engine.ts b/blocksuite/framework/sync/src/awareness/engine.ts new file mode 100644 index 0000000000..1cc68774b8 --- /dev/null +++ b/blocksuite/framework/sync/src/awareness/engine.ts @@ -0,0 +1,18 @@ +import type { Awareness } from 'y-protocols/awareness'; + +import type { AwarenessSource } from './source.js'; + +export class AwarenessEngine { + constructor( + readonly awareness: Awareness, + readonly sources: AwarenessSource[] + ) {} + + connect() { + this.sources.forEach(source => source.connect(this.awareness)); + } + + disconnect() { + this.sources.forEach(source => source.disconnect()); + } +} diff --git a/blocksuite/framework/sync/src/awareness/impl/broadcast.ts b/blocksuite/framework/sync/src/awareness/impl/broadcast.ts new file mode 100644 index 0000000000..00a10a3861 --- /dev/null +++ b/blocksuite/framework/sync/src/awareness/impl/broadcast.ts @@ -0,0 +1,73 @@ +import type { Awareness } from 'y-protocols/awareness'; +import { + applyAwarenessUpdate, + encodeAwarenessUpdate, +} from 'y-protocols/awareness'; + +import type { AwarenessSource } from '../source.js'; + +type AwarenessChanges = Record<'added' | 'updated' | 'removed', number[]>; + +type ChannelMessage = + | { type: 'connect' } + | { type: 'update'; update: Uint8Array }; + +export class BroadcastChannelAwarenessSource implements AwarenessSource { + awareness: Awareness | null = null; + + channel: BroadcastChannel | null = null; + + handleAwarenessUpdate = (changes: AwarenessChanges, origin: unknown) => { + if (origin === 'remote') { + return; + } + + const changedClients = Object.values(changes).reduce((res, cur) => + res.concat(cur) + ); + + const update = encodeAwarenessUpdate(this.awareness!, changedClients); + this.channel?.postMessage({ + type: 'update', + update: update, + } satisfies ChannelMessage); + }; + + constructor(readonly channelName: string) {} + + connect(awareness: Awareness): void { + this.channel = new BroadcastChannel(this.channelName); + this.channel.postMessage({ + type: 'connect', + } satisfies ChannelMessage); + this.awareness = awareness; + awareness.on('update', this.handleAwarenessUpdate); + this.channel.addEventListener( + 'message', + (event: MessageEvent<ChannelMessage>) => { + this.handleChannelMessage(event); + } + ); + } + + disconnect(): void { + this.awareness?.off('update', this.handleAwarenessUpdate); + this.channel?.close(); + this.channel = null; + } + + handleChannelMessage(event: MessageEvent<ChannelMessage>) { + if (event.data.type === 'update') { + const update = event.data.update; + applyAwarenessUpdate(this.awareness!, update, 'remote'); + } + if (event.data.type === 'connect') { + this.channel?.postMessage({ + type: 'update', + update: encodeAwarenessUpdate(this.awareness!, [ + this.awareness!.clientID, + ]), + } satisfies ChannelMessage); + } + } +} diff --git a/blocksuite/framework/sync/src/awareness/impl/index.ts b/blocksuite/framework/sync/src/awareness/impl/index.ts new file mode 100644 index 0000000000..497af9d4e2 --- /dev/null +++ b/blocksuite/framework/sync/src/awareness/impl/index.ts @@ -0,0 +1 @@ +export * from './broadcast.js'; diff --git a/blocksuite/framework/sync/src/awareness/index.ts b/blocksuite/framework/sync/src/awareness/index.ts new file mode 100644 index 0000000000..63ae7be0f5 --- /dev/null +++ b/blocksuite/framework/sync/src/awareness/index.ts @@ -0,0 +1,3 @@ +export * from './engine.js'; +export * from './impl/index.js'; +export * from './source.js'; diff --git a/blocksuite/framework/sync/src/awareness/source.ts b/blocksuite/framework/sync/src/awareness/source.ts new file mode 100644 index 0000000000..5f0b985a5e --- /dev/null +++ b/blocksuite/framework/sync/src/awareness/source.ts @@ -0,0 +1,6 @@ +import type { Awareness } from 'y-protocols/awareness'; + +export interface AwarenessSource { + connect(awareness: Awareness): void; + disconnect(): void; +} diff --git a/blocksuite/framework/sync/src/blob/engine.ts b/blocksuite/framework/sync/src/blob/engine.ts new file mode 100644 index 0000000000..a95732939d --- /dev/null +++ b/blocksuite/framework/sync/src/blob/engine.ts @@ -0,0 +1,197 @@ +import { type Logger, sha } from '@blocksuite/global/utils'; + +import type { BlobSource } from './source.js'; + +export interface BlobStatus { + isStorageOverCapacity: boolean; +} + +/** + * # BlobEngine + * + * sync blobs between storages in background. + * + * all operations priority use main, then use shadows. + */ +export class BlobEngine { + private _abort: AbortController | null = null; + + get sources() { + return [this.main, ...this.shadows]; + } + + constructor( + readonly main: BlobSource, + readonly shadows: BlobSource[], + readonly logger: Logger + ) {} + + async delete(_key: string) { + this.logger.error( + 'You are trying to delete a blob. We do not support this feature yet. We need to wait until we implement the indexer, which will inform us which doc is using a particular blob so that we can safely delete it.' + ); + } + + async get(key: string) { + this.logger.debug('get blob', key); + for (const source of this.sources) { + const data = await source.get(key); + if (data) { + return data; + } + } + return null; + } + + async list() { + const blobIdSet = new Set<string>(); + + for (const source of this.sources) { + const blobs = await source.list(); + for (const blob of blobs) { + blobIdSet.add(blob); + } + } + + return Array.from(blobIdSet); + } + + async set(value: Blob): Promise<string>; + + async set(key: string, value: Blob): Promise<string>; + + async set(valueOrKey: string | Blob, _value?: Blob) { + if (this.main.readonly) { + throw new Error('main peer is readonly'); + } + + const key = + typeof valueOrKey === 'string' + ? valueOrKey + : await sha(await valueOrKey.arrayBuffer()); + const value = typeof valueOrKey === 'string' ? _value : valueOrKey; + + if (!value) { + throw new Error('value is empty'); + } + + // await upload to the main peer + await this.main.set(key, value); + + // uploads to other peers in the background + Promise.allSettled( + this.shadows + .filter(r => !r.readonly) + .map(peer => + peer.set(key, value).catch(err => { + this.logger.error('Error when uploading to peer', err); + }) + ) + ) + .then(result => { + if (result.some(({ status }) => status === 'rejected')) { + this.logger.error( + `blob ${key} update finish, but some peers failed to update` + ); + } else { + this.logger.debug(`blob ${key} update finish`); + } + }) + .catch(() => { + // Promise.allSettled never reject + }); + + return key; + } + + start() { + if (this._abort) { + return; + } + this._abort = new AbortController(); + const abortSignal = this._abort.signal; + + const sync = () => { + if (abortSignal.aborted) { + return; + } + + this.sync() + .catch(error => { + this.logger.error('sync blob error', error); + }) + .finally(() => { + // sync every 1 minute + setTimeout(sync, 60000); + }); + }; + + sync(); + } + + stop() { + this._abort?.abort(); + this._abort = null; + } + + async sync() { + if (this.main.readonly) { + return; + } + this.logger.debug('start syncing blob...'); + for (const shadow of this.shadows) { + let mainList: string[] = []; + let shadowList: string[] = []; + + if (!shadow.readonly) { + try { + mainList = await this.main.list(); + shadowList = await shadow.list(); + } catch (err) { + this.logger.error(`error when sync`, err); + continue; + } + + const needUpload = mainList.filter(key => !shadowList.includes(key)); + for (const key of needUpload) { + try { + const data = await this.main.get(key); + if (data) { + await shadow.set(key, data); + } else { + this.logger.error( + 'data not found when trying upload from main to shadow' + ); + } + } catch (err) { + this.logger.error( + `error when sync ${key} from [${this.main.name}] to [${shadow.name}]`, + err + ); + } + } + } + + const needDownload = shadowList.filter(key => !mainList.includes(key)); + for (const key of needDownload) { + try { + const data = await shadow.get(key); + if (data) { + await this.main.set(key, data); + } else { + this.logger.error( + 'data not found when trying download from shadow to main' + ); + } + } catch (err) { + this.logger.error( + `error when sync ${key} from [${shadow.name}] to [${this.main.name}]`, + err + ); + } + } + } + + this.logger.debug('finish syncing blob'); + } +} diff --git a/blocksuite/framework/sync/src/blob/impl/index.ts b/blocksuite/framework/sync/src/blob/impl/index.ts new file mode 100644 index 0000000000..cdb0807eda --- /dev/null +++ b/blocksuite/framework/sync/src/blob/impl/index.ts @@ -0,0 +1,2 @@ +export * from './indexeddb.js'; +export * from './memory.js'; diff --git a/blocksuite/framework/sync/src/blob/impl/indexeddb.ts b/blocksuite/framework/sync/src/blob/impl/indexeddb.ts new file mode 100644 index 0000000000..58cf9dd9f9 --- /dev/null +++ b/blocksuite/framework/sync/src/blob/impl/indexeddb.ts @@ -0,0 +1,39 @@ +import { createStore, del, get, keys, set } from 'idb-keyval'; + +import type { BlobSource } from '../source.js'; + +export class IndexedDBBlobSource implements BlobSource { + readonly mimeTypeStore = createStore(`${this.name}_blob_mime`, 'blob_mime'); + + readonly = false; + + readonly store = createStore(`${this.name}_blob`, 'blob'); + + constructor(readonly name: string) {} + + async delete(key: string) { + await del(key, this.store); + await del(key, this.mimeTypeStore); + } + + async get(key: string) { + const res = await get<ArrayBuffer>(key, this.store); + if (res) { + return new Blob([res], { + type: await get(key, this.mimeTypeStore), + }); + } + return null; + } + + async list() { + const list = await keys<string>(this.store); + return list; + } + + async set(key: string, value: Blob) { + await set(key, await value.arrayBuffer(), this.store); + await set(key, value.type, this.mimeTypeStore); + return key; + } +} diff --git a/blocksuite/framework/sync/src/blob/impl/memory.ts b/blocksuite/framework/sync/src/blob/impl/memory.ts new file mode 100644 index 0000000000..2332bf52f7 --- /dev/null +++ b/blocksuite/framework/sync/src/blob/impl/memory.ts @@ -0,0 +1,27 @@ +import type { BlobSource } from '../source.js'; + +export class MemoryBlobSource implements BlobSource { + readonly map = new Map<string, Blob>(); + + name = 'memory'; + + readonly = false; + + delete(key: string) { + this.map.delete(key); + return Promise.resolve(); + } + + get(key: string) { + return Promise.resolve(this.map.get(key) ?? null); + } + + list() { + return Promise.resolve(Array.from(this.map.keys())); + } + + set(key: string, value: Blob) { + this.map.set(key, value); + return Promise.resolve(key); + } +} diff --git a/blocksuite/framework/sync/src/blob/index.ts b/blocksuite/framework/sync/src/blob/index.ts new file mode 100644 index 0000000000..63ae7be0f5 --- /dev/null +++ b/blocksuite/framework/sync/src/blob/index.ts @@ -0,0 +1,3 @@ +export * from './engine.js'; +export * from './impl/index.js'; +export * from './source.js'; diff --git a/blocksuite/framework/sync/src/blob/source.ts b/blocksuite/framework/sync/src/blob/source.ts new file mode 100644 index 0000000000..ebb675db58 --- /dev/null +++ b/blocksuite/framework/sync/src/blob/source.ts @@ -0,0 +1,8 @@ +export interface BlobSource { + name: string; + readonly: boolean; + get: (key: string) => Promise<Blob | null>; + set: (key: string, value: Blob) => Promise<string>; + delete: (key: string) => Promise<void>; + list: () => Promise<string[]>; +} diff --git a/blocksuite/framework/sync/src/doc/consts.ts b/blocksuite/framework/sync/src/doc/consts.ts new file mode 100644 index 0000000000..8535204534 --- /dev/null +++ b/blocksuite/framework/sync/src/doc/consts.ts @@ -0,0 +1,15 @@ +export enum DocEngineStep { + Stopped = 0, + Synced = 2, + Syncing = 1, +} + +export enum DocPeerStep { + Loaded = 4.5, + LoadingRootDoc = 2, + LoadingSubDoc = 3, + Retrying = 1, + Stopped = 0, + Synced = 6, + Syncing = 5, +} diff --git a/blocksuite/framework/sync/src/doc/engine.ts b/blocksuite/framework/sync/src/doc/engine.ts new file mode 100644 index 0000000000..5c3491efb2 --- /dev/null +++ b/blocksuite/framework/sync/src/doc/engine.ts @@ -0,0 +1,286 @@ +import { type Logger, Slot } from '@blocksuite/global/utils'; +import type { Doc } from 'yjs'; + +import { SharedPriorityTarget } from '../utils/async-queue.js'; +import { MANUALLY_STOP, throwIfAborted } from '../utils/throw-if-aborted.js'; +import { DocEngineStep, DocPeerStep } from './consts.js'; +import { type DocPeerStatus, SyncPeer } from './peer.js'; +import type { DocSource } from './source.js'; + +export interface DocEngineStatus { + step: DocEngineStep; + main: DocPeerStatus | null; + shadows: (DocPeerStatus | null)[]; + retrying: boolean; +} + +/** + * # DocEngine + * + * ``` + * ┌────────────┐ + * │ DocEngine │ + * └─────┬──────┘ + * │ + * ▼ + * ┌────────────┐ + * │ DocPeer │ + * ┌─────────┤ main ├─────────┐ + * │ └─────┬──────┘ │ + * │ │ │ + * ▼ ▼ ▼ + * ┌────────────┐ ┌────────────┐ ┌────────────┐ + * │ DocPeer │ │ DocPeer │ │ DocPeer │ + * │ shadow │ │ shadow │ │ shadow │ + * └────────────┘ └────────────┘ └────────────┘ + * ``` + * + * doc engine manage doc peers + * + * Sync steps: + * 1. start main sync + * 2. wait for main sync complete + * 3. start shadow sync + * 4. continuously sync main and shadows + */ +export class DocEngine { + private _abort = new AbortController(); + + private _status: DocEngineStatus; + + readonly onStatusChange = new Slot<DocEngineStatus>(); + + readonly priorityTarget = new SharedPriorityTarget(); + + get rootDocId() { + return this.rootDoc.guid; + } + + get status() { + return this._status; + } + + constructor( + readonly rootDoc: Doc, + readonly main: DocSource, + readonly shadows: DocSource[], + readonly logger: Logger + ) { + this._status = { + step: DocEngineStep.Stopped, + main: null, + shadows: shadows.map(() => null), + retrying: false, + }; + this.logger.debug(`syne-engine:${this.rootDocId} status init`, this.status); + } + + private setStatus(s: DocEngineStatus) { + this.logger.debug(`syne-engine:${this.rootDocId} status change`, s); + this._status = s; + this.onStatusChange.emit(s); + } + + canGracefulStop() { + return !!this.status.main && this.status.main.pendingPushUpdates === 0; + } + + forceStop() { + this._abort.abort(MANUALLY_STOP); + this.setStatus({ + step: DocEngineStep.Stopped, + main: null, + shadows: this.shadows.map(() => null), + retrying: false, + }); + } + + setPriorityRule(target: ((id: string) => boolean) | null) { + this.priorityTarget.priorityRule = target; + } + + start() { + if (this.status.step !== DocEngineStep.Stopped) { + this.forceStop(); + } + this._abort = new AbortController(); + + this.sync(this._abort.signal).catch(err => { + // should never reach here + this.logger.error(`syne-engine:${this.rootDocId}`, err); + }); + } + + // main sync process, should never return until abort + async sync(signal: AbortSignal) { + const state: { + mainPeer: SyncPeer | null; + shadowPeers: (SyncPeer | null)[]; + } = { + mainPeer: null, + shadowPeers: this.shadows.map(() => null), + }; + + const cleanUp: (() => void)[] = []; + try { + // Step 1: start main sync peer + state.mainPeer = new SyncPeer( + this.rootDoc, + this.main, + this.priorityTarget, + this.logger + ); + + cleanUp.push( + state.mainPeer.onStatusChange.on(() => { + if (!signal.aborted) + this.updateSyncingState(state.mainPeer, state.shadowPeers); + }).dispose + ); + + this.updateSyncingState(state.mainPeer, state.shadowPeers); + + // Step 2: wait for main sync complete + await state.mainPeer.waitForLoaded(signal); + + // Step 3: start shadow sync peer + state.shadowPeers = this.shadows.map(shadow => { + const peer = new SyncPeer( + this.rootDoc, + shadow, + this.priorityTarget, + this.logger + ); + cleanUp.push( + peer.onStatusChange.on(() => { + if (!signal.aborted) + this.updateSyncingState(state.mainPeer, state.shadowPeers); + }).dispose + ); + return peer; + }); + + this.updateSyncingState(state.mainPeer, state.shadowPeers); + + // Step 4: continuously sync main and shadow + + // wait for abort + await new Promise((_, reject) => { + if (signal.aborted) { + reject(signal.reason); + } + signal.addEventListener('abort', () => { + reject(signal.reason); + }); + }); + } catch (error) { + if (error === MANUALLY_STOP || signal.aborted) { + return; + } + throw error; + } finally { + // stop peers + state.mainPeer?.stop(); + for (const shadowPeer of state.shadowPeers) { + shadowPeer?.stop(); + } + for (const clean of cleanUp) { + clean(); + } + } + } + + updateSyncingState(local: SyncPeer | null, shadows: (SyncPeer | null)[]) { + let step = DocEngineStep.Synced; + const allPeer = [local, ...shadows]; + for (const peer of allPeer) { + if (!peer || peer.status.step !== DocPeerStep.Synced) { + step = DocEngineStep.Syncing; + break; + } + } + this.setStatus({ + step, + main: local?.status ?? null, + shadows: shadows.map(peer => peer?.status ?? null), + retrying: allPeer.some( + peer => peer?.status.step === DocPeerStep.Retrying + ), + }); + } + + async waitForGracefulStop(abort?: AbortSignal) { + await Promise.race([ + new Promise((_, reject) => { + if (abort?.aborted) { + reject(abort?.reason); + } + abort?.addEventListener('abort', () => { + reject(abort.reason); + }); + }), + new Promise<void>(resolve => { + this.onStatusChange.on(() => { + if (this.canGracefulStop()) { + resolve(); + } + }); + }), + ]); + throwIfAborted(abort); + this.forceStop(); + } + + async waitForLoadedRootDoc(abort?: AbortSignal) { + function isLoadedRootDoc(status: DocEngineStatus) { + return ![status.main, ...status.shadows].some( + peer => !peer || peer.step <= DocPeerStep.LoadingRootDoc + ); + } + if (isLoadedRootDoc(this.status)) { + return; + } else { + return Promise.race([ + new Promise<void>(resolve => { + this.onStatusChange.on(status => { + if (isLoadedRootDoc(status)) { + resolve(); + } + }); + }), + new Promise((_, reject) => { + if (abort?.aborted) { + reject(abort?.reason); + } + abort?.addEventListener('abort', () => { + reject(abort.reason); + }); + }), + ]); + } + } + + async waitForSynced(abort?: AbortSignal) { + if (this.status.step === DocEngineStep.Synced) { + return; + } else { + return Promise.race([ + new Promise<void>(resolve => { + this.onStatusChange.on(status => { + if (status.step === DocEngineStep.Synced) { + resolve(); + } + }); + }), + new Promise((_, reject) => { + if (abort?.aborted) { + reject(abort?.reason); + } + abort?.addEventListener('abort', () => { + reject(abort.reason); + }); + }), + ]); + } + } +} diff --git a/blocksuite/framework/sync/src/doc/impl/broadcast.ts b/blocksuite/framework/sync/src/doc/impl/broadcast.ts new file mode 100644 index 0000000000..7e48814102 --- /dev/null +++ b/blocksuite/framework/sync/src/doc/impl/broadcast.ts @@ -0,0 +1,91 @@ +import { assertExists } from '@blocksuite/global/utils'; +import { diffUpdate, encodeStateVectorFromUpdate, mergeUpdates } from 'yjs'; + +import type { DocSource } from '../source.js'; + +type ChannelMessage = + | { + type: 'init'; + } + | { + type: 'update'; + docId: string; + data: Uint8Array; + }; + +export class BroadcastChannelDocSource implements DocSource { + private _onMessage = (event: MessageEvent<ChannelMessage>) => { + if (event.data.type === 'init') { + for (const [docId, data] of this.docMap) { + this.channel.postMessage({ + type: 'update', + docId, + data, + } satisfies ChannelMessage); + } + return; + } + + const { docId, data } = event.data; + const update = this.docMap.get(docId); + if (update) { + this.docMap.set(docId, mergeUpdates([update, data])); + } else { + this.docMap.set(docId, data); + } + }; + + channel = new BroadcastChannel(this.channelName); + + docMap = new Map<string, Uint8Array>(); + + name = 'broadcast-channel'; + + constructor(readonly channelName: string = 'blocksuite:doc') { + this.channel.addEventListener('message', this._onMessage); + + this.channel.postMessage({ + type: 'init', + }); + } + + pull(docId: string, state: Uint8Array) { + const update = this.docMap.get(docId); + if (!update) return null; + + const diff = state.length ? diffUpdate(update, state) : update; + return { data: diff, state: encodeStateVectorFromUpdate(update) }; + } + + push(docId: string, data: Uint8Array) { + const update = this.docMap.get(docId); + if (update) { + this.docMap.set(docId, mergeUpdates([update, data])); + } else { + this.docMap.set(docId, data); + } + + assertExists(this.docMap.get(docId)); + this.channel.postMessage({ + type: 'update', + docId, + data: this.docMap.get(docId)!, + } satisfies ChannelMessage); + } + + subscribe(cb: (docId: string, data: Uint8Array) => void) { + const abortController = new AbortController(); + this.channel.addEventListener( + 'message', + (event: MessageEvent<ChannelMessage>) => { + if (event.data.type !== 'update') return; + const { docId, data } = event.data; + cb(docId, data); + }, + { signal: abortController.signal } + ); + return () => { + abortController.abort(); + }; + } +} diff --git a/blocksuite/framework/sync/src/doc/impl/index.ts b/blocksuite/framework/sync/src/doc/impl/index.ts new file mode 100644 index 0000000000..10b81ce98c --- /dev/null +++ b/blocksuite/framework/sync/src/doc/impl/index.ts @@ -0,0 +1,3 @@ +export * from './broadcast.js'; +export * from './indexeddb.js'; +export * from './noop.js'; diff --git a/blocksuite/framework/sync/src/doc/impl/indexeddb.ts b/blocksuite/framework/sync/src/doc/impl/indexeddb.ts new file mode 100644 index 0000000000..6b19baba75 --- /dev/null +++ b/blocksuite/framework/sync/src/doc/impl/indexeddb.ts @@ -0,0 +1,116 @@ +import { type DBSchema, type IDBPDatabase, openDB } from 'idb'; +import { diffUpdate, encodeStateVectorFromUpdate, mergeUpdates } from 'yjs'; + +import type { DocSource } from '../source.js'; + +export const dbVersion = 1; +export const DEFAULT_DB_NAME = 'blocksuite-local'; + +type UpdateMessage = { + timestamp: number; + update: Uint8Array; +}; + +type DocCollectionPersist = { + id: string; + updates: UpdateMessage[]; +}; + +interface BlockSuiteBinaryDB extends DBSchema { + collection: { + key: string; + value: DocCollectionPersist; + }; +} + +export function upgradeDB(db: IDBPDatabase<BlockSuiteBinaryDB>) { + db.createObjectStore('collection', { keyPath: 'id' }); +} + +type ChannelMessage = { + type: 'db-updated'; + payload: { docId: string; update: Uint8Array }; +}; + +export class IndexedDBDocSource implements DocSource { + // indexeddb could be shared between tabs, so we use broadcast channel to notify other tabs + channel = new BroadcastChannel('indexeddb:' + this.dbName); + + dbPromise: Promise<IDBPDatabase<BlockSuiteBinaryDB>> | null = null; + + mergeCount = 1; + + name = 'indexeddb'; + + constructor(readonly dbName: string = DEFAULT_DB_NAME) {} + + getDb() { + if (this.dbPromise === null) { + this.dbPromise = openDB<BlockSuiteBinaryDB>(this.dbName, dbVersion, { + upgrade: upgradeDB, + }); + } + return this.dbPromise; + } + + async pull( + docId: string, + state: Uint8Array + ): Promise<{ data: Uint8Array; state?: Uint8Array | undefined } | null> { + const db = await this.getDb(); + const store = db + .transaction('collection', 'readonly') + .objectStore('collection'); + const data = await store.get(docId); + + if (!data) { + return null; + } + + const { updates } = data; + const update = mergeUpdates(updates.map(({ update }) => update)); + + const diff = state.length ? diffUpdate(update, state) : update; + + return { data: diff, state: encodeStateVectorFromUpdate(update) }; + } + + async push(docId: string, data: Uint8Array): Promise<void> { + const db = await this.getDb(); + const store = db + .transaction('collection', 'readwrite') + .objectStore('collection'); + + const { updates } = (await store.get(docId)) ?? { updates: [] }; + let rows: UpdateMessage[] = [ + ...updates, + { timestamp: Date.now(), update: data }, + ]; + if (this.mergeCount && rows.length >= this.mergeCount) { + const merged = mergeUpdates(rows.map(({ update }) => update)); + rows = [{ timestamp: Date.now(), update: merged }]; + } + await store.put({ + id: docId, + updates: rows, + }); + this.channel.postMessage({ + type: 'db-updated', + payload: { docId, update: data }, + } satisfies ChannelMessage); + } + + subscribe(cb: (docId: string, data: Uint8Array) => void) { + function onMessage(event: MessageEvent<ChannelMessage>) { + const { type, payload } = event.data; + if (type === 'db-updated') { + const { docId, update } = payload; + cb(docId, update); + } + } + this.channel.addEventListener('message', onMessage); + return () => { + this.channel.removeEventListener('message', onMessage); + }; + } +} diff --git a/blocksuite/framework/sync/src/doc/impl/noop.ts b/blocksuite/framework/sync/src/doc/impl/noop.ts new file mode 100644 index 0000000000..e8d33c309f --- /dev/null +++ b/blocksuite/framework/sync/src/doc/impl/noop.ts @@ -0,0 +1,18 @@ +import type { DocSource } from '../source.js'; + +export class NoopDocSource implements DocSource { + name = 'noop'; + + pull(_docId: string, _data: Uint8Array) { + return null; + } + + push(_docId: string, _data: Uint8Array) {} + + subscribe( + _cb: (docId: string, data: Uint8Array) => void, + _disconnect: (reason: string) => void + ) { + return () => {}; + } +} diff --git a/blocksuite/framework/sync/src/doc/index.ts b/blocksuite/framework/sync/src/doc/index.ts new file mode 100644 index 0000000000..941a16fd8d --- /dev/null +++ b/blocksuite/framework/sync/src/doc/index.ts @@ -0,0 +1,21 @@ +/** + * + * **DocEngine** + * + * Manages one main Y.Doc and multiple shadow Y.Doc. + * + * Responsible for creating DocPeers for synchronization, following the main-first strategy. + * + * **DocPeer** + * + * Responsible for synchronizing a single Y.Doc data source with Y.Doc. + * + * Carries the main synchronization logic. + * + */ + +export * from './consts.js'; +export * from './engine.js'; +export * from './impl/index.js'; +export * from './peer.js'; +export * from './source.js'; diff --git a/blocksuite/framework/sync/src/doc/peer.ts b/blocksuite/framework/sync/src/doc/peer.ts new file mode 100644 index 0000000000..005f4ffb3b --- /dev/null +++ b/blocksuite/framework/sync/src/doc/peer.ts @@ -0,0 +1,449 @@ +import { isEqual, type Logger, Slot } from '@blocksuite/global/utils'; +import type { Doc } from 'yjs'; +import { + applyUpdate, + encodeStateAsUpdate, + encodeStateVector, + mergeUpdates, +} from 'yjs'; + +import { + PriorityAsyncQueue, + SharedPriorityTarget, +} from '../utils/async-queue.js'; +import { MANUALLY_STOP, throwIfAborted } from '../utils/throw-if-aborted.js'; +import { DocPeerStep } from './consts.js'; +import type { DocSource } from './source.js'; + +export interface DocPeerStatus { + step: DocPeerStep; + totalDocs: number; + loadedDocs: number; + pendingPullUpdates: number; + pendingPushUpdates: number; +} + +/** + * # DocPeer + * A DocPeer is responsible for syncing one Storage with one Y.Doc and its subdocs. + * + * ``` + * ┌─────┐ + * │Start│ + * └──┬──┘ + * │ + * ┌──────┐ ┌─────▼──────┐ ┌────┐ + * │listen◄─────┤pull rootdoc│ │peer│ + * └──┬───┘ └─────┬──────┘ └──┬─┘ + * │ │ onLoad() │ + * ┌──▼───┐ ┌─────▼──────┐ ┌────▼────┐ + * │listen◄─────┤pull subdocs│ │subscribe│ + * └──┬───┘ └─────┬──────┘ └────┬────┘ + * │ │ onReady() │ + * ┌──▼──┐ ┌─────▼───────┐ ┌──▼──┐ + * │queue├──────►apply updates◄───────┤queue│ + * └─────┘ └─────────────┘ └─────┘ + * ``` + * + * listen: listen for updates from ydoc, typically from user modifications. + * subscribe: listen for updates from storage, typically from other users. + * + */ +export class SyncPeer { + private _status: DocPeerStatus = { + step: DocPeerStep.LoadingRootDoc, + totalDocs: 1, + loadedDocs: 0, + pendingPullUpdates: 0, + pendingPushUpdates: 0, + }; + + readonly abort = new AbortController(); + + // handle updates from storage + handleStorageUpdates = (id: string, data: Uint8Array) => { + this.state.pullUpdatesQueue.push({ + id, + data, + }); + this.updateSyncStatus(); + }; + + // handle subdocs changes, append new subdocs to queue, remove subdocs from queue + handleSubdocsUpdate = ({ + added, + removed, + }: { + added: Set<Doc>; + removed: Set<Doc>; + }) => { + for (const subdoc of added) { + this.state.subdocsLoadQueue.push({ id: subdoc.guid, doc: subdoc }); + } + + for (const subdoc of removed) { + this.disconnectDoc(subdoc); + this.state.subdocsLoadQueue.remove(doc => doc.doc === subdoc); + } + this.updateSyncStatus(); + }; + + // handle updates from ydoc + handleYDocUpdates = (update: Uint8Array, origin: string, doc: Doc) => { + // don't push updates from storage + if (origin === this.name) { + return; + } + + const exist = this.state.pushUpdatesQueue.find(({ id }) => id === doc.guid); + if (exist) { + exist.data.push(update); + } else { + this.state.pushUpdatesQueue.push({ + id: doc.guid, + data: [update], + }); + } + + this.updateSyncStatus(); + }; + + readonly onStatusChange = new Slot<DocPeerStatus>(); + + readonly state: { + connectedDocs: Map<string, Doc>; + pushUpdatesQueue: PriorityAsyncQueue<{ + id: string; + data: Uint8Array[]; + }>; + pushingUpdate: boolean; + pullUpdatesQueue: PriorityAsyncQueue<{ + id: string; + data: Uint8Array; + }>; + subdocLoading: boolean; + subdocsLoadQueue: PriorityAsyncQueue<{ id: string; doc: Doc }>; + } = { + connectedDocs: new Map(), + pushUpdatesQueue: new PriorityAsyncQueue([], this.priorityTarget), + pushingUpdate: false, + pullUpdatesQueue: new PriorityAsyncQueue([], this.priorityTarget), + subdocLoading: false, + subdocsLoadQueue: new PriorityAsyncQueue([], this.priorityTarget), + }; + + get name() { + return this.source.name; + } + + private set status(s: DocPeerStatus) { + if (!isEqual(s, this._status)) { + this.logger.debug(`doc-peer:${this.name} status change`, s); + this._status = s; + this.onStatusChange.emit(s); + } + } + + get status() { + return this._status; + } + + constructor( + readonly rootDoc: Doc, + readonly source: DocSource, + readonly priorityTarget = new SharedPriorityTarget(), + readonly logger: Logger + ) { + this.logger.debug(`doc-peer:${this.name} start`); + + this.syncRetryLoop(this.abort.signal).catch(err => { + // should not reach here + console.error(err); + }); + } + + async connectDoc(doc: Doc, abort: AbortSignal) { + const { data: docData, state: inStorageState } = + (await this.source.pull(doc.guid, encodeStateVector(doc))) ?? {}; + throwIfAborted(abort); + + if (docData && docData.length > 0) { + applyUpdate(doc, docData, 'load'); + } + + // diff root doc and in-storage, save updates to pendingUpdates + this.state.pushUpdatesQueue.push({ + id: doc.guid, + data: [encodeStateAsUpdate(doc, inStorageState)], + }); + + this.state.connectedDocs.set(doc.guid, doc); + + // start listen root doc changes + doc.on('update', this.handleYDocUpdates); + + // mark rootDoc as loaded + doc.emit('sync', [true, doc]); + + this.updateSyncStatus(); + } + + disconnectDoc(doc: Doc) { + doc.off('update', this.handleYDocUpdates); + this.state.connectedDocs.delete(doc.guid); + this.updateSyncStatus(); + } + + initState() { + this.state.connectedDocs.clear(); + this.state.pushUpdatesQueue.clear(); + this.state.pullUpdatesQueue.clear(); + this.state.subdocsLoadQueue.clear(); + this.state.pushingUpdate = false; + this.state.subdocLoading = false; + } + + /** + * stop sync + * + * DocPeer is one-time use, this peer should be discarded after call stop(). + */ + stop() { + this.logger.debug(`doc-peer:${this.name} stop`); + this.abort.abort(MANUALLY_STOP); + } + + /** + * main synchronization logic + */ + async sync(abortOuter: AbortSignal) { + this.initState(); + const abortInner = new AbortController(); + + abortOuter.addEventListener('abort', reason => { + abortInner.abort(reason); + }); + + let dispose: (() => void) | null = null; + try { + this.updateSyncStatus(); + + // start listen storage updates + dispose = await this.source.subscribe( + this.handleStorageUpdates, + reason => { + // abort if storage disconnect, should trigger retry loop + abortInner.abort('subscribe disconnect:' + reason); + } + ); + throwIfAborted(abortInner.signal); + + // Step 1: load root doc + await this.connectDoc(this.rootDoc, abortInner.signal); + + // Step 2: load subdocs + this.state.subdocsLoadQueue.push( + ...Array.from(this.rootDoc.getSubdocs()).map(doc => ({ + id: doc.guid, + doc, + })) + ); + this.updateSyncStatus(); + + this.rootDoc.on('subdocs', this.handleSubdocsUpdate); + + // Finally: start sync + await Promise.all([ + // load subdocs + (async () => { + while (throwIfAborted(abortInner.signal)) { + const subdoc = await this.state.subdocsLoadQueue.next( + abortInner.signal + ); + this.state.subdocLoading = true; + this.updateSyncStatus(); + await this.connectDoc(subdoc.doc, abortInner.signal); + this.state.subdocLoading = false; + this.updateSyncStatus(); + } + })(), + // pull updates + (async () => { + while (throwIfAborted(abortInner.signal)) { + const { id, data } = await this.state.pullUpdatesQueue.next( + abortInner.signal + ); + // don't apply empty data or Uint8Array([0, 0]) + if ( + !( + data.byteLength === 0 || + (data.byteLength === 2 && data[0] === 0 && data[1] === 0) + ) + ) { + const subdoc = this.state.connectedDocs.get(id); + if (subdoc) { + applyUpdate(subdoc, data, this.name); + } + } + this.updateSyncStatus(); + } + })(), + // push updates + (async () => { + while (throwIfAborted(abortInner.signal)) { + const { id, data } = await this.state.pushUpdatesQueue.next( + abortInner.signal + ); + this.state.pushingUpdate = true; + this.updateSyncStatus(); + + const merged = mergeUpdates(data); + + // don't push empty data or Uint8Array([0, 0]) + if ( + !( + merged.byteLength === 0 || + (merged.byteLength === 2 && merged[0] === 0 && merged[1] === 0) + ) + ) { + await this.source.push(id, merged); + } + + this.state.pushingUpdate = false; + this.updateSyncStatus(); + } + })(), + ]); + } finally { + dispose?.(); + for (const docs of this.state.connectedDocs.values()) { + this.disconnectDoc(docs); + } + this.rootDoc.off('subdocs', this.handleSubdocsUpdate); + } + } + + /** + * auto retry after 5 seconds if sync failed + */ + async syncRetryLoop(abort: AbortSignal) { + while (abort.aborted === false) { + try { + await this.sync(abort); + } catch (err) { + if (err === MANUALLY_STOP || abort.aborted) { + return; + } + + this.logger.error(`doc-peer:${this.name} sync error`, err); + } + try { + this.logger.error(`doc-peer:${this.name} retry after 5 seconds`); + this.status = { + step: DocPeerStep.Retrying, + totalDocs: 1, + loadedDocs: 0, + pendingPullUpdates: 0, + pendingPushUpdates: 0, + }; + await Promise.race([ + new Promise<void>(resolve => { + setTimeout(resolve, 5 * 1000); + }), + new Promise((_, reject) => { + // exit if manually stopped + if (abort.aborted) { + reject(abort.reason); + } + abort.addEventListener('abort', () => { + reject(abort.reason); + }); + }), + ]); + } catch (err) { + if (err === MANUALLY_STOP || abort.aborted) { + return; + } + + // should never reach here + throw err; + } + } + } + + updateSyncStatus() { + let step; + if (this.state.connectedDocs.size === 0) { + step = DocPeerStep.LoadingRootDoc; + } else if (this.state.subdocsLoadQueue.length || this.state.subdocLoading) { + step = DocPeerStep.LoadingSubDoc; + } else if ( + this.state.pullUpdatesQueue.length || + this.state.pushUpdatesQueue.length || + this.state.pushingUpdate + ) { + step = DocPeerStep.Syncing; + } else { + step = DocPeerStep.Synced; + } + + this.status = { + step: step, + totalDocs: + this.state.connectedDocs.size + this.state.subdocsLoadQueue.length, + loadedDocs: this.state.connectedDocs.size, + pendingPullUpdates: + this.state.pullUpdatesQueue.length + (this.state.subdocLoading ? 1 : 0), + pendingPushUpdates: + this.state.pushUpdatesQueue.length + (this.state.pushingUpdate ? 1 : 0), + }; + } + + async waitForLoaded(abort?: AbortSignal) { + if (this.status.step > DocPeerStep.Loaded) { + return; + } else { + return Promise.race([ + new Promise<void>(resolve => { + this.onStatusChange.on(status => { + if (status.step > DocPeerStep.Loaded) { + resolve(); + } + }); + }), + new Promise((_, reject) => { + if (abort?.aborted) { + reject(abort?.reason); + } + abort?.addEventListener('abort', () => { + reject(abort.reason); + }); + }), + ]); + } + } + + async waitForSynced(abort?: AbortSignal) { + if (this.status.step >= DocPeerStep.Synced) { + return; + } else { + return Promise.race([ + new Promise<void>(resolve => { + this.onStatusChange.on(status => { + if (status.step >= DocPeerStep.Synced) { + resolve(); + } + }); + }), + new Promise((_, reject) => { + if (abort?.aborted) { + reject(abort?.reason); + } + abort?.addEventListener('abort', () => { + reject(abort.reason); + }); + }), + ]); + } + } +} diff --git a/blocksuite/framework/sync/src/doc/source.ts b/blocksuite/framework/sync/src/doc/source.ts new file mode 100644 index 0000000000..b4d7f52bb9 --- /dev/null +++ b/blocksuite/framework/sync/src/doc/source.ts @@ -0,0 +1,28 @@ +export interface DocSource { + /** + * for debug + */ + name: string; + + pull( + docId: string, + state: Uint8Array + ): + | Promise<{ data: Uint8Array; state?: Uint8Array } | null> + | { data: Uint8Array; state?: Uint8Array } + | null; + push(docId: string, data: Uint8Array): Promise<void> | void; + + /** + * Subscribe to updates from peer + * + * @param cb callback to handle updates + * @param disconnect callback to handle disconnect, reason can be something like 'network-error' + * + * @returns unsubscribe function + */ + subscribe( + cb: (docId: string, data: Uint8Array) => void, + disconnect: (reason: string) => void + ): Promise<() => void> | (() => void); +} diff --git a/blocksuite/framework/sync/src/index.ts b/blocksuite/framework/sync/src/index.ts new file mode 100644 index 0000000000..55d6433915 --- /dev/null +++ b/blocksuite/framework/sync/src/index.ts @@ -0,0 +1,3 @@ +export * from './awareness/index.js'; +export * from './blob/index.js'; +export * from './doc/index.js'; diff --git a/blocksuite/framework/sync/src/utils/__tests__/async-queue.spec.ts b/blocksuite/framework/sync/src/utils/__tests__/async-queue.spec.ts new file mode 100644 index 0000000000..340fd939c5 --- /dev/null +++ b/blocksuite/framework/sync/src/utils/__tests__/async-queue.spec.ts @@ -0,0 +1,45 @@ +import { describe, expect, test, vi } from 'vitest'; + +import { AsyncQueue } from '../async-queue.js'; + +describe('async-queue', () => { + test('push & pop', async () => { + const queue = new AsyncQueue(); + queue.push(1, 2, 3); + expect(queue.length).toBe(3); + expect(await queue.next()).toBe(1); + expect(await queue.next()).toBe(2); + expect(await queue.next()).toBe(3); + expect(queue.length).toBe(0); + }); + + test('await', async () => { + const queue = new AsyncQueue<number>(); + queue.push(1, 2); + expect(await queue.next()).toBe(1); + expect(await queue.next()).toBe(2); + + let v = -1; + + // setup 2 pop tasks + void queue.next().then(next => { + v = next; + }); + void queue.next().then(next => { + v = next; + }); + + // Wait for 100ms + await new Promise(resolve => setTimeout(resolve, 100)); + // v should not be changed + expect(v).toBe(-1); + + // push 3, should trigger the first pop task + queue.push(3); + await vi.waitFor(() => v === 3); + + // push 4, should trigger the second pop task + queue.push(4); + await vi.waitFor(() => v === 4); + }); +}); diff --git a/blocksuite/framework/sync/src/utils/__tests__/throw-if-aborted.spec.ts b/blocksuite/framework/sync/src/utils/__tests__/throw-if-aborted.spec.ts new file mode 100644 index 0000000000..da695f62e2 --- /dev/null +++ b/blocksuite/framework/sync/src/utils/__tests__/throw-if-aborted.spec.ts @@ -0,0 +1,13 @@ +import { describe, expect, test } from 'vitest'; + +import { throwIfAborted } from '../throw-if-aborted.js'; + +describe('throw-if-aborted', () => { + test('basic', () => { + const abortController = new AbortController(); + const abortSignal = abortController.signal; + expect(throwIfAborted(abortSignal)).toBe(true); + abortController.abort('TEST_ABORT'); + expect(() => throwIfAborted(abortSignal)).toThrowError('TEST_ABORT'); + }); +}); diff --git a/blocksuite/framework/sync/src/utils/async-queue.ts b/blocksuite/framework/sync/src/utils/async-queue.ts new file mode 100644 index 0000000000..d93d57d385 --- /dev/null +++ b/blocksuite/framework/sync/src/utils/async-queue.ts @@ -0,0 +1,102 @@ +export class AsyncQueue<T> { + private _queue: T[]; + + private _resolveUpdate: (() => void) | null = null; + + private _waitForUpdate: Promise<void> | null = null; + + get length() { + return this._queue.length; + } + + constructor(init: T[] = []) { + this._queue = init; + } + + clear() { + this._queue = []; + } + + find(predicate: (update: T) => boolean) { + return this._queue.find(predicate); + } + + async next( + abort?: AbortSignal, + dequeue: (arr: T[]) => T | undefined = a => a.shift() + ): Promise<T> { + const update = dequeue(this._queue); + if (update) { + return update; + } else { + if (!this._waitForUpdate) { + this._waitForUpdate = new Promise(resolve => { + this._resolveUpdate = resolve; + }); + } + + await Promise.race([ + this._waitForUpdate, + new Promise((_, reject) => { + if (abort?.aborted) { + reject(abort?.reason); + } + abort?.addEventListener('abort', () => { + reject(abort.reason); + }); + }), + ]); + + return this.next(abort, dequeue); + } + } + + push(...updates: T[]) { + this._queue.push(...updates); + if (this._resolveUpdate) { + const resolve = this._resolveUpdate; + this._resolveUpdate = null; + this._waitForUpdate = null; + resolve(); + } + } + + remove(predicate: (update: T) => boolean) { + const index = this._queue.findIndex(predicate); + if (index !== -1) { + this._queue.splice(index, 1); + } + } +} + +export class PriorityAsyncQueue< + T extends { id: string }, +> extends AsyncQueue<T> { + constructor( + init: T[] = [], + readonly priorityTarget: SharedPriorityTarget = new SharedPriorityTarget() + ) { + super(init); + } + + override next(abort?: AbortSignal | undefined): Promise<T> { + return super.next(abort, arr => { + if (this.priorityTarget.priorityRule !== null) { + const index = arr.findIndex(update => + this.priorityTarget.priorityRule?.(update.id) + ); + if (index !== -1) { + return arr.splice(index, 1)[0]; + } + } + return arr.shift(); + }); + } +} + +/** + * Shared priority target can be shared by multiple queues. + */ +export class SharedPriorityTarget { + priorityRule: ((id: string) => boolean) | null = null; +} diff --git a/blocksuite/framework/sync/src/utils/throw-if-aborted.ts b/blocksuite/framework/sync/src/utils/throw-if-aborted.ts new file mode 100644 index 0000000000..54e2c81ac9 --- /dev/null +++ b/blocksuite/framework/sync/src/utils/throw-if-aborted.ts @@ -0,0 +1,9 @@ +// because AbortSignal.throwIfAborted is not available in abortcontroller-polyfill +export function throwIfAborted(abort?: AbortSignal) { + if (abort?.aborted) { + throw new Error(abort.reason); + } + return true; +} + +export const MANUALLY_STOP = 'manually-stop'; diff --git a/blocksuite/framework/sync/tsconfig.json b/blocksuite/framework/sync/tsconfig.json new file mode 100644 index 0000000000..41db4cf724 --- /dev/null +++ b/blocksuite/framework/sync/tsconfig.json @@ -0,0 +1,14 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "rootDir": "./src/", + "outDir": "./dist/", + "noEmit": false + }, + "include": ["./src", "index.d.ts"], + "references": [ + { + "path": "../global" + } + ] +} diff --git a/blocksuite/framework/sync/vitest.config.ts b/blocksuite/framework/sync/vitest.config.ts new file mode 100644 index 0000000000..f884bd8ac8 --- /dev/null +++ b/blocksuite/framework/sync/vitest.config.ts @@ -0,0 +1,23 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + include: ['src/__tests__/**/*.unit.spec.ts'], + testTimeout: 500, + coverage: { + provider: 'istanbul', // or 'c8' + reporter: ['lcov'], + reportsDirectory: '../../../.coverage/sync', + }, + /** + * Custom handler for console.log in tests. + * + * Return `false` to ignore the log. + */ + onConsoleLog(log, type) { + console.warn(`Unexpected ${type} log`, log); + throw new Error(log); + }, + restoreMocks: true, + }, +}); diff --git a/blocksuite/playground/.env b/blocksuite/playground/.env new file mode 100644 index 0000000000..c8382b574a --- /dev/null +++ b/blocksuite/playground/.env @@ -0,0 +1,2 @@ +PLAYGROUND_SERVER=https://blocksuite-playground.toeverything.workers.dev +PLAYGROUND_WS=wss://blocksuite-playground.toeverything.workers.dev \ No newline at end of file diff --git a/blocksuite/playground/.gitignore b/blocksuite/playground/.gitignore new file mode 100644 index 0000000000..a547bf36d8 --- /dev/null +++ b/blocksuite/playground/.gitignore @@ -0,0 +1,24 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +dist +dist-ssr +*.local + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? diff --git a/blocksuite/playground/apps/README.md b/blocksuite/playground/apps/README.md new file mode 100644 index 0000000000..1c4cecf6da --- /dev/null +++ b/blocksuite/playground/apps/README.md @@ -0,0 +1,3 @@ +# BlockSuite Playground Apps + +This directory contains application entries used for the [BlockSuite playground](https://try-blocksuite.vercel.app) online site. They serve as comprehensive examples utilizing the full capabilities of BlockSuite, and also act as the entry point for executing E2E test cases. diff --git a/blocksuite/playground/apps/_common/components/adapters-panel.ts b/blocksuite/playground/apps/_common/components/adapters-panel.ts new file mode 100644 index 0000000000..0cd29ad0ea --- /dev/null +++ b/blocksuite/playground/apps/_common/components/adapters-panel.ts @@ -0,0 +1,285 @@ +/* eslint-disable @typescript-eslint/no-restricted-imports */ +import '@shoelace-style/shoelace/dist/components/tab-panel/tab-panel.js'; + +import { ShadowlessElement } from '@blocksuite/block-std'; +import { + defaultImageProxyMiddleware, + docLinkBaseURLMiddlewareBuilder, + embedSyncedDocMiddleware, + type HtmlAdapter, + HtmlAdapterFactoryIdentifier, + type MarkdownAdapter, + MarkdownAdapterFactoryIdentifier, + type PlainTextAdapter, + PlainTextAdapterFactoryIdentifier, + titleMiddleware, +} from '@blocksuite/blocks'; +import { WithDisposable } from '@blocksuite/global/utils'; +import type { AffineEditorContainer } from '@blocksuite/presets'; +import { type DocSnapshot, Job } from '@blocksuite/store'; +import { effect } from '@preact/signals-core'; +import type SlTabPanel from '@shoelace-style/shoelace/dist/components/tab-panel/tab-panel.js'; +import { css, html, type PropertyValues } from 'lit'; +import { customElement, property, query, state } from 'lit/decorators.js'; + +@customElement('adapters-panel') +export class AdaptersPanel extends WithDisposable(ShadowlessElement) { + static override styles = css` + adapters-panel { + width: 36vw; + } + .adapters-container { + border: 1px solid var(--affine-border-color, #e3e2e4); + background-color: var(--affine-background-primary-color); + box-sizing: border-box; + position: relative; + } + .adapter-container { + padding: 0px 16px; + width: 100%; + height: calc(100vh - 80px); + white-space: pre-wrap; + color: var(--affine-text-primary-color); + overflow: auto; + } + .update-button { + position: absolute; + top: 8px; + right: 12px; + padding: 8px 12px; + border-radius: 4px; + font-size: 12px; + font-weight: 500; + cursor: pointer; + border: 1px solid var(--affine-border-color); + font-family: var(--affine-font-family); + color: var(--affine-text-primary-color); + background-color: var(--affine-background-primary-color); + } + .update-button:hover { + background-color: var(--affine-hover-color); + } + .html-panel { + display: flex; + gap: 8px; + flex-direction: column; + } + .html-preview-container, + .html-panel-content { + width: 100%; + flex: 1; + border: none; + box-sizing: border-box; + color: var(--affine-text-primary-color); + overflow: auto; + } + .html-panel-footer { + width: 100%; + height: 32px; + display: flex; + justify-content: flex-end; + + span { + cursor: pointer; + padding: 4px 8px; + font-size: 12px; + font-weight: 500; + border: 1px solid var(--affine-border-color); + font-family: var(--affine-font-family); + color: var(--affine-text-primary-color); + background-color: var(--affine-background-primary-color); + line-height: 20px; + } + span[active] { + background-color: var(--affine-hover-color); + } + } + `; + + get doc() { + return this.editor.doc; + } + + private _createJob() { + return new Job({ + collection: this.doc.collection, + middlewares: [ + docLinkBaseURLMiddlewareBuilder('https://example.com').get(), + titleMiddleware, + embedSyncedDocMiddleware('content'), + defaultImageProxyMiddleware, + ], + }); + } + + private _getDocSnapshot() { + const job = this._createJob(); + const result = job.docToSnapshot(this.doc); + return result; + } + + private async _getHtmlContent() { + const job = this._createJob(); + const htmlAdapterFactory = this.editor.std.provider.get( + HtmlAdapterFactoryIdentifier + ); + const htmlAdapter = htmlAdapterFactory.get(job) as HtmlAdapter; + const result = await htmlAdapter.fromDoc(this.doc); + return result?.file; + } + + private async _getMarkdownContent() { + const job = this._createJob(); + const markdownAdapterFactory = this.editor.std.provider.get( + MarkdownAdapterFactoryIdentifier + ); + const markdownAdapter = markdownAdapterFactory.get(job) as MarkdownAdapter; + const result = await markdownAdapter.fromDoc(this.doc); + return result?.file; + } + + private async _getPlainTextContent() { + const job = this._createJob(); + const plainTextAdapterFactory = this.editor.std.provider.get( + PlainTextAdapterFactoryIdentifier + ); + const plainTextAdapter = plainTextAdapterFactory.get( + job + ) as PlainTextAdapter; + const result = await plainTextAdapter.fromDoc(this.doc); + return result?.file; + } + + private async _handleTabShow(name: string) { + switch (name) { + case 'markdown': + this._markdownContent = (await this._getMarkdownContent()) || ''; + break; + case 'html': + this._htmlContent = (await this._getHtmlContent()) || ''; + break; + case 'plaintext': + this._plainTextContent = (await this._getPlainTextContent()) || ''; + break; + case 'snapshot': + this._docSnapshot = this._getDocSnapshot() || null; + break; + } + } + + private _renderHtmlPanel() { + return html` + ${this._isHtmlPreview + ? html`<iframe + class="html-preview-container" + .srcdoc=${this._htmlContent} + ></iframe>` + : html`<div class="html-panel-content">${this._htmlContent}</div>`} + <div class="html-panel-footer"> + <span + class="html-panel-footer-item" + ?active=${!this._isHtmlPreview} + @click=${() => (this._isHtmlPreview = false)} + >Source</span + > + <span + class="html-panel-footer-item" + ?active=${this._isHtmlPreview} + @click=${() => (this._isHtmlPreview = true)} + >Preview</span + > + </div> + `; + } + + private async _updateActiveTabContent() { + if (!this._activeTab) return; + const activeTabName = this._activeTab.name; + await this._handleTabShow(activeTabName); + } + + override firstUpdated() { + this.disposables.add( + effect(() => { + const doc = this.doc; + if (doc) { + this._updateActiveTabContent().catch(console.error); + } + }) + ); + } + + override render() { + const snapshotString = this._docSnapshot + ? JSON.stringify(this._docSnapshot, null, 4) + : ''; + return html` + <div class="adapters-container"> + <sl-tab-group + activation="auto" + @sl-tab-show=${(e: CustomEvent) => this._handleTabShow(e.detail.name)} + > + <sl-tab slot="nav" panel="markdown">Markdown</sl-tab> + <sl-tab slot="nav" panel="plaintext">PlainText</sl-tab> + <sl-tab slot="nav" panel="html">HTML</sl-tab> + <sl-tab slot="nav" panel="snapshot">Snapshot</sl-tab> + + <sl-tab-panel name="markdown"> + <div class="adapter-container">${this._markdownContent}</div> + </sl-tab-panel> + <sl-tab-panel name="html"> + <div class="adapter-container html-panel"> + ${this._renderHtmlPanel()} + </div> + </sl-tab-panel> + <sl-tab-panel name="plaintext"> + <div class="adapter-container">${this._plainTextContent}</div> + </sl-tab-panel> + <sl-tab-panel name="snapshot"> + <div class="adapter-container">${snapshotString}</div> + </sl-tab-panel> + </sl-tab-group> + <sl-tooltip content="Update Adapter Content" placement="left" hoist> + <div class="update-button" @click="${this._updateActiveTabContent}"> + Update + </div> + </sl-tooltip> + </div> + `; + } + + override willUpdate(_changedProperties: PropertyValues) { + if (_changedProperties.has('editor')) { + requestIdleCallback(() => { + this._updateActiveTabContent().catch(console.error); + }); + } + } + + @query('sl-tab-panel[active]') + private accessor _activeTab!: SlTabPanel; + + @state() + private accessor _docSnapshot: DocSnapshot | null = null; + + @state() + private accessor _htmlContent = ''; + + @state() + private accessor _isHtmlPreview = false; + + @state() + private accessor _markdownContent = ''; + + @state() + private accessor _plainTextContent = ''; + + @property({ attribute: false }) + accessor editor!: AffineEditorContainer; +} + +declare global { + interface HTMLElementTagNameMap { + 'adapters-panel': AdaptersPanel; + } +} diff --git a/blocksuite/playground/apps/_common/components/attachment-viewer-panel.ts b/blocksuite/playground/apps/_common/components/attachment-viewer-panel.ts new file mode 100644 index 0000000000..d9ea46ac86 --- /dev/null +++ b/blocksuite/playground/apps/_common/components/attachment-viewer-panel.ts @@ -0,0 +1,323 @@ +/* eslint-disable @typescript-eslint/no-non-null-assertion */ +import type { AttachmentBlockModel } from '@blocksuite/affine-model'; +import { humanFileSize } from '@blocksuite/affine-shared/utils'; +import { getAttachmentFileIcons } from '@blocksuite/blocks'; +import { SignalWatcher, WithDisposable } from '@blocksuite/global/utils'; +import { + ArrowDownBigIcon, + ArrowUpBigIcon, + CloseIcon, +} from '@blocksuite/icons/lit'; +import { signal } from '@preact/signals-core'; +import { css, html, LitElement, type TemplateResult } from 'lit'; +import { customElement, query } from 'lit/decorators.js'; + +import type { DocInfo, MessageData, MessageDataType } from './pdf/types.js'; +import { MessageOp, RenderKind, State } from './pdf/types.js'; + +const DPI = window.devicePixelRatio; + +type FileInfo = { + name: string; + size: string; + isPDF: boolean; + icon: TemplateResult; +}; + +@customElement('attachment-viewer-panel') +export class AttachmentViewerPanel extends SignalWatcher( + WithDisposable(LitElement) +) { + static override styles = css` + :host { + dialog { + padding: 0; + top: 50px; + border: 1px solid var(--affine-border-color); + border-radius: 8px; + background: var(--affine-v2-dialog-background-primary); + box-shadow: var(--affine-overlay-shadow); + outline: none; + } + + .dialog { + position: relative; + display: flex; + flex-direction: column; + width: 700px; + height: 900px; + margin: 0 auto; + overflow: hidden; + + & > .close { + user-select: none; + outline: none; + position: absolute; + right: 10px; + top: 10px; + border: none; + background: transparent; + z-index: 1; + } + + header, + footer { + padding: 10px 20px; + } + + footer { + display: flex; + flex-direction: row; + justify-content: space-between; + align-items: center; + font-size: 12px; + color: var(--affine-text-secondary-color); + } + + h5 { + display: flex; + align-items: center; + gap: 15px; + margin: 0; + + .file-icon svg { + width: 20px; + height: 20px; + } + } + + .body { + display: flex; + flex: 1; + align-items: center; + overflow-y: auto; + + .page { + width: calc(100% - 40px); + height: auto; + margin: 0 auto; + } + + .error { + margin: 0 auto; + } + } + } + + .controls { + position: absolute; + bottom: 50px; + right: 20px; + } + } + `; + + #cursor = signal<number>(0); + + #docInfo = signal<DocInfo | null>(null); + + #fileInfo = signal<FileInfo | null>(null); + + #state = signal<State>(State.Connecting); + + #worker: Worker | null = null; + + clear = () => { + this.#dialog.close(); + + this.#state.value = State.IDLE; + this.#worker?.terminate(); + this.#worker = null; + + this.#fileInfo.value = null; + this.#docInfo.value = null; + this.#cursor.value = 0; + + const canvas = this.#page; + if (!canvas) return; + const ctx = canvas.getContext('2d'); + if (!ctx) return; + ctx.clearRect(0, 0, canvas.width, canvas.height); + }; + + goto(at: number) { + this.#cursor.value = at; + this.post(MessageOp.Render, { + index: at, + scale: 1 * DPI, + kind: RenderKind.Page, + }); + } + + open(model: AttachmentBlockModel) { + this.#dialog.showModal(); + + const { name, size } = model; + + const fileType = name.split('.').pop() ?? ''; + const icon = getAttachmentFileIcons(fileType); + const isPDF = fileType === 'pdf'; + + this.#fileInfo.value = { + name, + icon, + isPDF, + size: humanFileSize(size), + }; + + if (!isPDF) return; + if (!model.sourceId) return; + if (this.#worker) return; + + const process = async ({ data }: MessageEvent<MessageData>) => { + const { type } = data; + + switch (type) { + case MessageOp.Init: { + console.debug('connecting'); + this.#state.value = State.Connecting; + break; + } + + case MessageOp.Inited: { + console.debug('connected'); + this.#state.value = State.Connected; + + const blob = await model.doc.blobSync.get(model.sourceId!); + + if (!blob) return; + const buffer = await blob.arrayBuffer(); + + this.post(MessageOp.Open, buffer, [buffer]); + break; + } + + case MessageOp.Opened: { + const info = data[type]; + this.#cursor.value = 0; + this.#docInfo.value = info; + this.#state.value = State.Opened; + this.post(MessageOp.Render, { + index: 0, + scale: 1 * DPI, + kind: RenderKind.Page, + }); + break; + } + + case MessageOp.Rendered: { + const { index, kind, imageData } = data[type]; + + if (index !== this.#cursor.value) return; + + const canvas = this.#page; + if (!canvas) return; + const ctx = canvas.getContext('2d'); + if (!ctx) return; + + console.debug('render page', index, kind); + canvas.width = imageData.width; + canvas.height = imageData.height; + + ctx.clearRect(0, 0, imageData.width, imageData.height); + ctx.putImageData(imageData, 0, 0); + break; + } + } + }; + + this.#worker = new Worker(new URL('./pdf/worker.ts', import.meta.url), { + type: 'module', + }); + + this.#worker.addEventListener('message', event => { + process(event).catch(console.error); + }); + } + + post<T extends MessageOp>( + type: T, + data?: MessageDataType[T], + transfers?: Transferable[] + ) { + if (!this.#worker) return; + + const message = { type, [type]: data }; + if (transfers?.length) { + this.#worker?.postMessage(message, transfers); + return; + } + + this.#worker?.postMessage(message); + } + + override render() { + const fileInfo = this.#fileInfo.value; + const isPDF = fileInfo?.isPDF ?? false; + const docInfo = this.#docInfo.value; + const cursor = this.#cursor.value; + const total = docInfo ? docInfo.total : 0; + const width = docInfo ? docInfo.width : 0; + const height = docInfo ? docInfo.height : 0; + const isEmpty = total === 0; + const print = (n: number) => (isEmpty ? '-' : n); + + return html` + <dialog> + <div class="dialog"> + <header> + <h5> + <span>${fileInfo?.name}</span> + <span>${fileInfo?.size}</span> + <span class="file-icon">${fileInfo?.icon}</span> + </h5> + </header> + <main class="body"> + ${isPDF + ? html`<canvas class="page"></canvas>` + : html`<p class="error">This file format is not supported.</p>`} + <div class="controls"> + <icon-button + .disabled=${isEmpty || cursor === 0} + @click=${() => this.goto(cursor - 1)} + >${ArrowUpBigIcon()}</icon-button + > + <icon-button + .disabled=${isEmpty || cursor + 1 === total} + @click=${() => this.goto(cursor + 1)} + >${ArrowDownBigIcon()}</icon-button + > + </div> + </main> + <footer> + <div> + <span>${print(width)}</span> + x + <span>${print(height)}</span> + </div> + <div> + <span>${print(cursor + 1)}</span> + / + <span>${print(total)}</span> + </div> + </footer> + <icon-button class="close" @click=${this.clear} + >${CloseIcon()}</icon-button + > + </div> + </dialog> + `; + } + + @query('dialog') + accessor #dialog!: HTMLDialogElement; + + @query('.page') + accessor #page: HTMLCanvasElement | null = null; +} + +declare global { + interface HTMLElementTagNameMap { + 'attachment-viewer-panel': AttachmentViewerPanel; + } +} diff --git a/blocksuite/playground/apps/_common/components/collab-debug-menu.ts b/blocksuite/playground/apps/_common/components/collab-debug-menu.ts new file mode 100644 index 0000000000..5dae352157 --- /dev/null +++ b/blocksuite/playground/apps/_common/components/collab-debug-menu.ts @@ -0,0 +1,633 @@ +/* eslint-disable @typescript-eslint/no-restricted-imports */ +import '@shoelace-style/shoelace/dist/components/alert/alert.js'; +import '@shoelace-style/shoelace/dist/components/button/button.js'; +import '@shoelace-style/shoelace/dist/components/button-group/button-group.js'; +import '@shoelace-style/shoelace/dist/components/color-picker/color-picker.js'; +import '@shoelace-style/shoelace/dist/components/divider/divider.js'; +import '@shoelace-style/shoelace/dist/components/dropdown/dropdown.js'; +import '@shoelace-style/shoelace/dist/components/icon/icon.js'; +import '@shoelace-style/shoelace/dist/components/icon-button/icon-button.js'; +import '@shoelace-style/shoelace/dist/components/input/input.js'; +import '@shoelace-style/shoelace/dist/components/menu/menu.js'; +import '@shoelace-style/shoelace/dist/components/menu-item/menu-item.js'; +import '@shoelace-style/shoelace/dist/components/select/select.js'; +import '@shoelace-style/shoelace/dist/components/tab/tab.js'; +import '@shoelace-style/shoelace/dist/components/tab-group/tab-group.js'; +import '@shoelace-style/shoelace/dist/components/tooltip/tooltip.js'; +import '@shoelace-style/shoelace/dist/themes/light.css'; +import '@shoelace-style/shoelace/dist/themes/dark.css'; + +import type { AffineTextAttributes } from '@blocksuite/affine-shared/types'; +import { ShadowlessElement } from '@blocksuite/block-std'; +import { + ColorScheme, + type DocMode, + DocModeProvider, + EdgelessRootService, + ExportManager, + printToPdf, +} from '@blocksuite/blocks'; +import { type SerializedXYWH, SignalWatcher } from '@blocksuite/global/utils'; +import type { DeltaInsert } from '@blocksuite/inline'; +import type { AffineEditorContainer } from '@blocksuite/presets'; +import { type DocCollection, Text } from '@blocksuite/store'; +import { setBasePath } from '@shoelace-style/shoelace/dist/utilities/base-path.js'; +import { css, html, nothing } from 'lit'; +import { customElement, property, state } from 'lit/decorators.js'; + +import { notify } from '../../default/utils/notify.js'; +import { mockEdgelessTheme } from '../mock-services.js'; +import { generateRoomId } from '../sync/websocket/utils.js'; +import type { DocsPanel } from './docs-panel.js'; +import type { LeftSidePanel } from './left-side-panel.js'; + +const basePath = + 'https://cdn.jsdelivr.net/npm/@shoelace-style/shoelace@2.11.2/dist/'; +setBasePath(basePath); + +@customElement('collab-debug-menu') +export class CollabDebugMenu extends SignalWatcher(ShadowlessElement) { + static override styles = css` + :root { + --sl-font-size-medium: var(--affine-font-xs); + --sl-input-font-size-small: var(--affine-font-xs); + } + + .dg.ac { + z-index: 1001 !important; + } + + .top-container { + display: flex; + align-items: center; + gap: 12px; + font-size: 16px; + } + `; + + private _darkModeChange = (e: MediaQueryListEvent) => { + this._setThemeMode(!!e.matches); + }; + + private _handleDocsPanelClose = () => { + this.leftSidePanel.toggle(this.docsPanel); + }; + + private _keydown = (e: KeyboardEvent) => { + if (e.key === 'F1') { + this._switchEditorMode(); + } + }; + + private _startCollaboration = async () => { + if (window.wsProvider) { + notify('There is already a websocket provider exists', 'neutral').catch( + console.error + ); + return; + } + + const params = new URLSearchParams(location.search); + const id = params.get('room') || (await generateRoomId()); + + params.set('room', id); + const url = new URL(location.href); + url.search = params.toString(); + location.href = url.href; + }; + + get doc() { + return this.editor.doc; + } + + get editorMode() { + return this.editor.mode; + } + + set editorMode(value: DocMode) { + this.editor.mode = value; + } + + get rootService() { + try { + return this.editor.std.getService('affine:page'); + } catch { + return null; + } + } + + private _addNote() { + const rootModel = this.doc.root; + if (!rootModel) return; + const rootId = rootModel.id; + + this.doc.captureSync(); + + const count = rootModel.children.length; + const xywh: SerializedXYWH = `[0,${count * 60},800,95]`; + + const noteId = this.doc.addBlock('affine:note', { xywh }, rootId); + this.doc.addBlock('affine:paragraph', {}, noteId); + } + + private async _clearSiteData() { + await fetch('/Clear-Site-Data'); + window.location.reload(); + } + + private _exportHtml() { + const htmlTransformer = this.rootService?.transformers.html; + htmlTransformer?.exportDoc(this.doc).catch(console.error); + } + + private _exportMarkDown() { + const markdownTransformer = this.rootService?.transformers.markdown; + markdownTransformer?.exportDoc(this.doc).catch(console.error); + } + + private _exportPdf() { + this.editor.std.get(ExportManager).exportPdf().catch(console.error); + } + + private _exportPng() { + this.editor.std.get(ExportManager).exportPng().catch(console.error); + } + + private async _exportSnapshot() { + if (!this.rootService) return; + const zipTransformer = this.rootService.transformers.zip; + await zipTransformer.exportDocs( + this.collection, + [...this.collection.docs.values()].map(collection => collection.getDoc()) + ); + } + + private _importSnapshot() { + const input = document.createElement('input'); + input.setAttribute('type', 'file'); + input.setAttribute('accept', '.zip'); + input.multiple = false; + input.onchange = async () => { + const file = input.files?.item(0); + if (!file) return; + if (!this.rootService) return; + try { + const zipTransformer = this.rootService.transformers.zip; + const docs = await zipTransformer.importDocs(this.collection, file); + for (const doc of docs) { + let noteBlockId; + const noteBlocks = window.doc.getBlocksByFlavour('affine:note'); + if (noteBlocks.length) { + noteBlockId = noteBlocks[0].id; + } else { + noteBlockId = this.doc.addBlock( + 'affine:note', + { + xywh: '[-200,-48,400,96]', + }, + this.doc.root?.id + ); + } + + if (!doc) { + break; + } + + window.doc.addBlock( + 'affine:paragraph', + { + type: 'text', + text: new Text([ + { + insert: ' ', + attributes: { + reference: { + type: 'LinkedPage', + pageId: doc.id, + }, + }, + } as DeltaInsert<AffineTextAttributes>, + ]), + }, + noteBlockId + ); + } + this.requestUpdate(); + } catch (e) { + console.error('Invalid snapshot.'); + console.error(e); + } finally { + input.remove(); + } + }; + input.click(); + } + + private _insertTransitionStyle(classKey: string, duration: number) { + const $html = document.documentElement; + const $style = document.createElement('style'); + const slCSSKeys = ['sl-transition-x-fast']; + $style.innerHTML = `html.${classKey} * { transition: all ${duration}ms 0ms linear !important; } :root { ${slCSSKeys.map( + key => `--${key}: ${duration}ms` + )} }`; + + $html.append($style); + $html.classList.add(classKey); + + setTimeout(() => { + $style.remove(); + $html.classList.remove(classKey); + }, duration); + } + + private _print() { + printToPdf().catch(console.error); + } + + private _setThemeMode(dark: boolean) { + const html = document.querySelector('html'); + + this._dark = dark; + localStorage.setItem('blocksuite:dark', dark ? 'true' : 'false'); + if (!html) return; + html.dataset.theme = dark ? 'dark' : 'light'; + + this._insertTransitionStyle('color-transition', 0); + + if (dark) { + html.classList.add('dark'); + html.classList.add('sl-theme-dark'); + } else { + html.classList.remove('dark'); + html.classList.remove('sl-theme-dark'); + } + + const theme = dark ? ColorScheme.Dark : ColorScheme.Light; + mockEdgelessTheme.setTheme(theme); + } + + private _switchEditorMode() { + if (!this.editor.host) return; + const newMode = this._docMode === 'page' ? 'edgeless' : 'page'; + const docModeService = this.editor.host.std.get(DocModeProvider); + if (docModeService) { + docModeService.setPrimaryMode(newMode, this.editor.doc.id); + } + this._docMode = newMode; + this.editor.mode = newMode; + } + + private _toggleDarkMode() { + this._setThemeMode(!this._dark); + } + + private _toggleDocsPanel() { + this.docsPanel.onClose = this._handleDocsPanelClose; + this.leftSidePanel.toggle(this.docsPanel); + } + + override connectedCallback() { + super.connectedCallback(); + + this._docMode = this.editor.mode; + this.editor.slots.docUpdated.on(({ newDocId }) => { + const newDocMode = this.editor.std + .get(DocModeProvider) + .getPrimaryMode(newDocId); + this._docMode = newDocMode; + }); + + document.body.addEventListener('keydown', this._keydown); + } + + override createRenderRoot() { + const matchMedia = window.matchMedia('(prefers-color-scheme: dark)'); + this._setThemeMode(this._dark && matchMedia.matches); + matchMedia.addEventListener('change', this._darkModeChange); + + return this; + } + + override disconnectedCallback() { + super.disconnectedCallback(); + + const matchMedia = window.matchMedia('(prefers-color-scheme: dark)'); + matchMedia.removeEventListener('change', this._darkModeChange); + document.body.removeEventListener('keydown', this._keydown); + } + + override firstUpdated() { + this.doc.slots.historyUpdated.on(() => { + this._canUndo = this.doc.canUndo; + this._canRedo = this.doc.canRedo; + }); + } + + override render() { + return html` + <style> + .collab-debug-menu { + display: flex; + flex-wrap: nowrap; + position: fixed; + top: 0; + left: 0; + width: 100%; + overflow: auto; + z-index: 1000; /* for debug visibility */ + pointer-events: none; + } + + @media print { + .collab-debug-menu { + display: none; + } + } + + .default-toolbar { + display: flex; + gap: 5px; + padding: 8px 8px 8px 16px; + width: 100%; + min-width: 390px; + align-items: center; + justify-content: space-between; + } + + .default-toolbar sl-button.dots-menu::part(base) { + color: var(--sl-color-neutral-700); + } + + .default-toolbar sl-button.dots-menu::part(label) { + padding-left: 0; + } + + .default-toolbar > * { + pointer-events: auto; + } + + .edgeless-toolbar { + align-items: center; + margin-right: 17px; + pointer-events: auto; + } + + .edgeless-toolbar sl-select, + .edgeless-toolbar sl-color-picker, + .edgeless-toolbar sl-button { + margin-right: 4px; + } + </style> + <div class="collab-debug-menu default"> + <div class="default-toolbar"> + <div class="top-container"> + <sl-dropdown placement="bottom" hoist> + <sl-button + class="dots-menu" + variant="text" + size="small" + slot="trigger" + > + <sl-icon + style="font-size: 14px" + name="three-dots-vertical" + label="Menu" + ></sl-icon> + </sl-button> + <sl-menu> + <sl-menu-item> + <sl-icon + slot="prefix" + name="terminal" + label="Test operations" + ></sl-icon> + <span>Test operations</span> + <sl-menu slot="submenu"> + <sl-menu-item @click="${this._print}"> Print </sl-menu-item> + <sl-menu-item @click=${this._addNote}> + Add Note</sl-menu-item + > + <sl-menu-item @click=${this._exportMarkDown}> + Export Markdown + </sl-menu-item> + <sl-menu-item @click=${this._exportHtml}> + Export HTML + </sl-menu-item> + <sl-menu-item @click=${this._exportPdf}> + Export PDF + </sl-menu-item> + <sl-menu-item @click=${this._exportPng}> + Export PNG + </sl-menu-item> + <sl-menu-item @click=${this._exportSnapshot}> + Export Snapshot + </sl-menu-item> + <sl-menu-item @click=${this._importSnapshot}> + Import Snapshot + </sl-menu-item> + </sl-menu> + </sl-menu-item> + <sl-menu-item @click=${this._clearSiteData}> + Clear Site Data + <sl-icon slot="prefix" name="trash"></sl-icon> + </sl-menu-item> + <sl-menu-item @click=${this._toggleDarkMode}> + Toggle ${this._dark ? 'Light' : 'Dark'} Mode + <sl-icon + slot="prefix" + name=${this._dark ? 'moon' : 'brightness-high'} + ></sl-icon> + </sl-menu-item> + <sl-divider></sl-divider> + <a + target="_blank" + href="https://github.com/toeverything/blocksuite" + > + <sl-menu-item> + <sl-icon slot="prefix" name="github"></sl-icon> + GitHub + </sl-menu-item> + </a> + </sl-menu> + </sl-dropdown> + + <!-- undo/redo group --> + <sl-button-group label="History"> + <!-- undo --> + <sl-tooltip content="Undo" placement="bottom" hoist> + <sl-button + pill + size="small" + content="Undo" + .disabled=${!this._canUndo} + @click=${() => { + this.doc.undo(); + }} + > + <sl-icon name="arrow-counterclockwise" label="Undo"></sl-icon> + </sl-button> + </sl-tooltip> + <!-- redo --> + <sl-tooltip content="Redo" placement="bottom" hoist> + <sl-button + pill + size="small" + content="Redo" + .disabled=${!this._canRedo} + @click=${() => { + this.doc.redo(); + }} + > + <sl-icon name="arrow-clockwise" label="Redo"></sl-icon> + </sl-button> + </sl-tooltip> + </sl-button-group> + + <sl-tooltip content="Start Collaboration" placement="bottom" hoist> + <sl-button @click=${this._startCollaboration} size="small" circle> + <sl-icon name="people" label="Collaboration"></sl-icon> + </sl-button> + </sl-tooltip> + <sl-tooltip content="Docs" placement="bottom" hoist> + <sl-button + @click=${this._toggleDocsPanel} + size="small" + circle + data-docs-panel-toggle + > + <sl-icon name="filetype-doc" label="Doc"></sl-icon> + </sl-button> + </sl-tooltip> + + ${new URLSearchParams(location.search).get('room') + ? html`<sl-input + placeholder="Your name in room" + clearable + size="small" + @blur=${(e: Event) => { + if ((e.target as HTMLInputElement).value.length > 0) { + this.collection.awarenessStore.awareness.setLocalStateField( + 'user', + { + name: (e.target as HTMLInputElement).value ?? '', + } + ); + } else { + this.collection.awarenessStore.awareness.setLocalStateField( + 'user', + { + name: 'Unknown', + } + ); + } + }} + ></sl-input + ></sl-tooltip>` + : nothing} + </div> + + <div style="display: flex; gap: 12px"> + <!-- Edgeless Theme button --> + ${this._docMode === 'edgeless' + ? html`<sl-tooltip + content="Edgeless Theme" + placement="bottom" + hoist + > + <sl-button + size="small" + circle + @click=${() => mockEdgelessTheme.toggleTheme()} + > + <sl-icon + name="${mockEdgelessTheme.theme$.value === 'dark' + ? 'moon' + : 'brightness-high'}" + label="Edgeless Theme" + ></sl-icon> + </sl-button> + </sl-tooltip>` + : nothing} + <!-- Present button --> + ${this._docMode === 'edgeless' + ? html`<sl-tooltip content="Present" placement="bottom" hoist> + <sl-button + size="small" + circle + @click=${() => { + if (this.rootService instanceof EdgelessRootService) { + this.rootService.gfx.tool.setTool('frameNavigator', { + mode: 'fit', + }); + } + }} + > + <sl-icon name="easel" label="Present"></sl-icon> + </sl-button> + </sl-tooltip>` + : nothing} + <sl-button-group label="Mode" style="margin-right: 12px"> + <!-- switch to page --> + <sl-tooltip content="Page" placement="bottom" hoist> + <sl-button + pill + size="small" + content="Page" + .disabled=${this._docMode !== 'edgeless'} + @click=${this._switchEditorMode} + > + <sl-icon name="filetype-doc" label="Page"></sl-icon> + </sl-button> + </sl-tooltip> + <!-- switch to edgeless --> + <sl-tooltip content="Edgeless" placement="bottom" hoist> + <sl-button + pill + size="small" + content="Edgeless" + .disabled=${this._docMode !== 'page'} + @click=${this._switchEditorMode} + > + <sl-icon name="palette" label="Edgeless"></sl-icon> + </sl-button> + </sl-tooltip> + </sl-button-group> + </div> + </div> + </div> + `; + } + + @state() + private accessor _canRedo = false; + + @state() + private accessor _canUndo = false; + + @state() + private accessor _dark = localStorage.getItem('blocksuite:dark') === 'true'; + + @state() + private accessor _docMode: DocMode = 'page'; + + @property({ attribute: false }) + accessor collection!: DocCollection; + + @property({ attribute: false }) + accessor docsPanel!: DocsPanel; + + @property({ attribute: false }) + accessor editor!: AffineEditorContainer; + + @property({ attribute: false }) + accessor leftSidePanel!: LeftSidePanel; + + @property({ attribute: false }) + accessor readonly = false; +} + +declare global { + interface HTMLElementTagNameMap { + 'collab-debug-menu': CollabDebugMenu; + } +} diff --git a/blocksuite/playground/apps/_common/components/custom-frame-panel.ts b/blocksuite/playground/apps/_common/components/custom-frame-panel.ts new file mode 100644 index 0000000000..a8235ef8b0 --- /dev/null +++ b/blocksuite/playground/apps/_common/components/custom-frame-panel.ts @@ -0,0 +1,69 @@ +import { ShadowlessElement } from '@blocksuite/block-std'; +import { WithDisposable } from '@blocksuite/global/utils'; +import type { AffineEditorContainer } from '@blocksuite/presets'; +import { effect } from '@preact/signals-core'; +import { css, html, nothing } from 'lit'; +import { customElement, property, state } from 'lit/decorators.js'; + +@customElement('custom-frame-panel') +export class CustomFramePanel extends WithDisposable(ShadowlessElement) { + static override styles = css` + .custom-frame-container { + position: absolute; + top: 0; + right: 0; + border: 1px solid var(--affine-border-color, #e3e2e4); + background-color: var(--affine-background-primary-color); + height: 100vh; + width: 320px; + box-sizing: border-box; + padding-top: 16px; + z-index: 1; + } + `; + + private _renderPanel() { + return html`<affine-frame-panel + .host=${this.editor.std.host} + ></affine-frame-panel>`; + } + + override connectedCallback(): void { + super.connectedCallback(); + + this.disposables.add( + effect(() => { + const std = this.editor.std; + if (std) { + this.editor.updateComplete + .then(() => this.requestUpdate()) + .catch(console.error); + } + }) + ); + } + + override render() { + return html` + ${this._show + ? html`<div class="custom-frame-container">${this._renderPanel()}</div>` + : nothing} + `; + } + + toggleDisplay() { + this._show = !this._show; + } + + @state() + private accessor _show = false; + + @property({ attribute: false }) + accessor editor!: AffineEditorContainer; +} + +declare global { + interface HTMLElementTagNameMap { + 'custom-frame-panel': CustomFramePanel; + } +} diff --git a/blocksuite/playground/apps/_common/components/custom-outline-panel.ts b/blocksuite/playground/apps/_common/components/custom-outline-panel.ts new file mode 100644 index 0000000000..bdc021c6de --- /dev/null +++ b/blocksuite/playground/apps/_common/components/custom-outline-panel.ts @@ -0,0 +1,54 @@ +import { WithDisposable } from '@blocksuite/global/utils'; +import type { AffineEditorContainer } from '@blocksuite/presets'; +import { css, html, LitElement, nothing } from 'lit'; +import { customElement, property, state } from 'lit/decorators.js'; + +@customElement('custom-outline-panel') +export class CustomOutlinePanel extends WithDisposable(LitElement) { + static override styles = css` + .custom-outline-container { + position: absolute; + top: 0; + right: 16px; + border: 1px solid var(--affine-border-color, #e3e2e4); + background: var(--affine-background-overlay-panel-color); + height: 100vh; + width: 320px; + box-sizing: border-box; + z-index: 1; + } + `; + + private _renderPanel() { + return html`<affine-outline-panel + .editor=${this.editor} + .fitPadding=${[50, 360, 50, 50]} + ></affine-outline-panel>`; + } + + override render() { + return html` + ${this._show + ? html` + <div class="custom-outline-container">${this._renderPanel()}</div> + ` + : nothing} + `; + } + + toggleDisplay() { + this._show = !this._show; + } + + @state() + private accessor _show = false; + + @property({ attribute: false }) + accessor editor!: AffineEditorContainer; +} + +declare global { + interface HTMLElementTagNameMap { + 'custom-outline-panel': CustomOutlinePanel; + } +} diff --git a/blocksuite/playground/apps/_common/components/custom-outline-viewer.ts b/blocksuite/playground/apps/_common/components/custom-outline-viewer.ts new file mode 100644 index 0000000000..5ced1b25df --- /dev/null +++ b/blocksuite/playground/apps/_common/components/custom-outline-viewer.ts @@ -0,0 +1,51 @@ +import { WithDisposable } from '@blocksuite/global/utils'; +import type { AffineEditorContainer } from '@blocksuite/presets'; +import { css, html, LitElement, nothing } from 'lit'; +import { customElement, property, state } from 'lit/decorators.js'; + +@customElement('custom-outline-viewer') +export class CustomOutlineViewer extends WithDisposable(LitElement) { + static override styles = css` + .outline-viewer-container { + position: fixed; + display: flex; + top: 256px; + right: 22px; + max-height: calc(100vh - 256px - 76px); // top(256px) and bottom(76px) + } + `; + + private _renderViewer() { + return html`<affine-outline-viewer + .editor=${this.editor} + .toggleOutlinePanel=${this.toggleOutlinePanel} + ></affine-outline-viewer>`; + } + + override render() { + if (!this._show || this.editor.mode === 'edgeless') return nothing; + + return html`<div class="outline-viewer-container"> + ${this._renderViewer()} + </div>`; + } + + toggleDisplay() { + this._show = !this._show; + } + + @state() + private accessor _show = false; + + @property({ attribute: false }) + accessor editor!: AffineEditorContainer; + + @property({ attribute: false }) + accessor toggleOutlinePanel: (() => void) | null = null; +} + +declare global { + interface HTMLElementTagNameMap { + 'custom-outline-viewer': CustomOutlineViewer; + } +} diff --git a/blocksuite/playground/apps/_common/components/demo-script.ts b/blocksuite/playground/apps/_common/components/demo-script.ts new file mode 100644 index 0000000000..140bb3c339 --- /dev/null +++ b/blocksuite/playground/apps/_common/components/demo-script.ts @@ -0,0 +1,137 @@ +export const demoScript = `import * as THREE from "three"; +import {OrbitControls} from "three/addons/controls/OrbitControls.js"; + + +let scene = new THREE.Scene(); +let camera = new THREE.PerspectiveCamera(30, innerWidth / innerHeight, 1, 1000); +camera.position.set(0, 10, 10).setLength(17); +let renderer = new THREE.WebGLRenderer({antialias: true}); +renderer.setSize(innerWidth, innerHeight); +document.body.appendChild(renderer.domElement); + +window.addEventListener("resize", event => { + camera.aspect = innerWidth / innerHeight; + camera.updateProjectionMatrix(); + renderer.setSize(innerWidth, innerHeight); +}) + +let controls = new OrbitControls(camera, renderer.domElement); +controls.enableDamping = true; + +let gu = { + time: {value: 0} +} + +let params = { + instanceCount: {value: 10}, + instanceLength: {value: 1.75}, + instanceGap: {value: 0.5}, + profileFactor: {value: 1.5} +} + +let ig = new THREE.InstancedBufferGeometry().copy(new THREE.BoxGeometry(1, 1, 1, 100, 1, 1).translate(0.5, 0, 0)); +ig.instanceCount = params.instanceCount.value; + +let m = new THREE.MeshBasicMaterial({ + vertexColors: true, + onBeforeCompile: shader => { + shader.uniforms.time = gu.time; + shader.uniforms.instanceCount = params.instanceCount; + shader.uniforms.instanceLength = params.instanceLength; + shader.uniforms.instanceGap = params.instanceGap; + shader.uniforms.profileFactor = params.profileFactor; + shader.vertexShader = \` + uniform float time; + + uniform float instanceCount; + uniform float instanceLength; + uniform float instanceGap; + + uniform float profileFactor; + + varying float noGrid; + + mat2 rot(float a){return mat2(cos(a), sin(a), -sin(a), cos(a));} + + \${shader.vertexShader} + \`.replace( + \`#include <begin_vertex>\`, + \`#include <begin_vertex> + + float t = time * 0.1; + + float iID = float(gl_InstanceID); + + float instanceTotalLength = instanceLength + instanceGap; + float instanceFactor = instanceLength / instanceTotalLength; + + float circleLength = instanceTotalLength * instanceCount; + float circleRadius = circleLength / PI2; + + float partAngle = PI2 / instanceCount; + float boxAngle = partAngle * instanceFactor; + + float partTurn = PI / instanceCount; + float boxTurn = partTurn * instanceFactor; + + float startAngle = t + partAngle * iID; + float startTurn = t * 0.5 + partTurn * iID; + + float angleFactor = position.x; + + float angle = startAngle + boxAngle * angleFactor; + float turn = startTurn + boxTurn * angleFactor; + + vec3 pos = vec3(0, position.y, position.z); + pos.yz *= rot(turn); + pos.yz *= profileFactor; + pos.z += circleRadius; + pos.xz *= rot(angle); + + transformed = pos; + float nZ = floor(abs(normal.z) + 0.1); + float nX = floor(abs(normal.x) + 0.1); + noGrid = 1. - nX; + vColor = vec3(nZ == 1. ? 0.1 : nX == 1. ? 0. : 0.01); + \` + ); + //console.log(shader.vertexShader); + shader.fragmentShader = \` + varying float noGrid; + + float lines(vec2 coord, float thickness){ + vec2 grid = abs(fract(coord - 0.5) - 0.5) / fwidth(coord) / thickness; + float line = min(grid.x, grid.y); + return 1.0 - min(line, 1.0); + } + \${shader.fragmentShader} + \`.replace( + \`#include <color_fragment>\`, + \`#include <color_fragment> + + float multiply = vColor.r > 0.05 ? 3. : 2.; + float edges = lines(vUv, 3.); + float grid = min(noGrid, lines(vUv * multiply, 1.)); + diffuseColor.rgb = mix(diffuseColor.rgb, vec3(1), max(edges, grid)); + \` + ) + //console.log(shader.fragmentShader) + } +}); +m.defines = {"USE_UV": ""}; + +let o = new THREE.Mesh(ig, m); +scene.add(o) +o.rotation.z = -Math.PI * 0.25; + +let clock = new THREE.Clock(); +let t = 0; + +renderer.setAnimationLoop(()=>{ + let dt = clock.getDelta(); + t += dt; + gu.time.value = t; + controls.update(); + renderer.render(scene, camera); +}) +`; diff --git a/blocksuite/playground/apps/_common/components/docs-panel.ts b/blocksuite/playground/apps/_common/components/docs-panel.ts new file mode 100644 index 0000000000..d342b1f026 --- /dev/null +++ b/blocksuite/playground/apps/_common/components/docs-panel.ts @@ -0,0 +1,181 @@ +import { ShadowlessElement } from '@blocksuite/block-std'; +import { + CloseIcon, + createDefaultDoc, + GenerateDocUrlProvider, +} from '@blocksuite/blocks'; +import { WithDisposable } from '@blocksuite/global/utils'; +import type { AffineEditorContainer } from '@blocksuite/presets'; +import type { BlockCollection, DocCollection } from '@blocksuite/store'; +import { css, html, nothing } from 'lit'; +import { customElement, property } from 'lit/decorators.js'; +import { repeat } from 'lit/directives/repeat.js'; +import { styleMap } from 'lit/directives/style-map.js'; + +import { removeModeFromStorage } from '../mock-services.js'; + +@customElement('docs-panel') +export class DocsPanel extends WithDisposable(ShadowlessElement) { + static override styles = css` + docs-panel { + display: flex; + flex-direction: column; + width: 100%; + background-color: var(--affine-background-secondary-color); + font-family: var(--affine-font-family); + height: 100%; + padding: 12px; + gap: 4px; + } + .doc-item:hover .delete-doc-icon { + display: flex; + } + .doc-item { + color: var(--affine-text-primary-color); + } + .delete-doc-icon { + display: none; + padding: 2px; + border-radius: 4px; + } + .delete-doc-icon:hover { + background-color: var(--affine-hover-color); + } + .delete-doc-icon svg { + width: 14px; + height: 14px; + color: var(--affine-secondary-color); + fill: var(--affine-secondary-color); + } + .new-doc-button { + margin-bottom: 16px; + border: 1px solid var(--affine-border-color); + border-radius: 4px; + height: 32px; + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + color: var(--affine-text-primary-color); + } + .new-doc-button:hover { + background-color: var(--affine-hover-color); + } + `; + + createDoc = () => { + createDocBlock(this.editor.doc.collection); + }; + + gotoDoc = (doc: BlockCollection) => { + const url = this.editor.std + .getOptional(GenerateDocUrlProvider) + ?.generateDocUrl(doc.id); + if (url) history.pushState({}, '', url); + + this.editor.doc = doc.getDoc(); + this.editor.doc.load(); + this.editor.doc.resetHistory(); + this.requestUpdate(); + }; + + private get collection() { + return this.editor.doc.collection; + } + + private get docs() { + return [...this.collection.docs.values()]; + } + + override connectedCallback() { + super.connectedCallback(); + + requestAnimationFrame(() => { + const handleClickOutside = (event: MouseEvent) => { + if (!(event.target instanceof Node)) return; + + const toggleButton = document.querySelector( + 'sl-button[data-docs-panel-toggle]' + ); + if (toggleButton?.contains(event.target as Node)) return; + + if (!this.contains(event.target)) { + this.onClose?.(); + } + }; + document.addEventListener('click', handleClickOutside); + this.disposables.add(() => { + document.removeEventListener('click', handleClickOutside); + }); + }); + + this.disposables.add( + this.editor.doc.collection.slots.docUpdated.on(() => { + this.requestUpdate(); + }) + ); + } + + protected override render(): unknown { + const { docs, collection } = this; + return html` + <div @click="${this.createDoc}" class="new-doc-button">New Doc</div> + ${repeat( + docs, + v => v.id, + doc => { + const style = styleMap({ + backgroundColor: + this.editor.doc.id === doc.id + ? 'var(--affine-hover-color)' + : undefined, + padding: '4px 4px 4px 8px', + borderRadius: '4px', + cursor: 'pointer', + display: 'flex', + justifyContent: 'space-between', + }); + const click = () => { + this.gotoDoc(doc); + }; + const deleteDoc = (e: MouseEvent) => { + e.stopPropagation(); + const isDeleteCurrent = doc.id === this.editor.doc.id; + + collection.removeDoc(doc.id); + removeModeFromStorage(doc.id); + // When delete the current doc, we need to set the editor doc to the first remaining doc + if (isDeleteCurrent) { + this.editor.doc = this.docs[0].getDoc(); + } + }; + return html`<div class="doc-item" @click="${click}" style="${style}"> + ${doc.meta?.title || 'Untitled'} + ${docs.length > 1 + ? html`<div @click="${deleteDoc}" class="delete-doc-icon"> + ${CloseIcon} + </div>` + : nothing} + </div>`; + } + )} + `; + } + + @property({ attribute: false }) + accessor editor!: AffineEditorContainer; + + @property({ attribute: false }) + accessor onClose!: () => void; +} + +function createDocBlock(collection: DocCollection) { + const id = collection.idGenerator(); + createDefaultDoc(collection, { id }); +} + +declare global { + interface HTMLElementTagNameMap { + 'docs-panel': DocsPanel; + } +} diff --git a/blocksuite/playground/apps/_common/components/left-side-panel.ts b/blocksuite/playground/apps/_common/components/left-side-panel.ts new file mode 100644 index 0000000000..570048922b --- /dev/null +++ b/blocksuite/playground/apps/_common/components/left-side-panel.ts @@ -0,0 +1,55 @@ +import { ShadowlessElement } from '@blocksuite/block-std'; +import { css, html } from 'lit'; +import { customElement } from 'lit/decorators.js'; + +@customElement('left-side-panel') +export class LeftSidePanel extends ShadowlessElement { + static override styles = css` + left-side-panel { + padding-top: 50px; + width: 300px; + position: absolute; + top: 0; + left: 0; + height: 100%; + display: none; + } + `; + + currentContent: HTMLElement | null = null; + + hideContent() { + if (this.currentContent) { + this.style.display = 'none'; + this.currentContent.remove(); + this.currentContent = null; + } + } + + protected override render(): unknown { + return html``; + } + + showContent(ele: HTMLElement) { + if (this.currentContent) { + this.currentContent.remove(); + } + this.style.display = 'block'; + this.currentContent = ele; + this.append(ele); + } + + toggle(ele: HTMLElement) { + if (this.currentContent !== ele) { + this.showContent(ele); + } else { + this.hideContent(); + } + } +} + +declare global { + interface HTMLElementTagNameMap { + 'left-side-panel': LeftSidePanel; + } +} diff --git a/blocksuite/playground/apps/_common/components/pdf/types.ts b/blocksuite/playground/apps/_common/components/pdf/types.ts new file mode 100644 index 0000000000..24bac77935 --- /dev/null +++ b/blocksuite/playground/apps/_common/components/pdf/types.ts @@ -0,0 +1,64 @@ +export enum State { + IDLE = 0, + Connecting, + Connected, + Opening, + Opened, + Failed, +} + +export type DocInfo = { + total: number; + width: number; + height: number; +}; + +export type ViewportInfo = { + dpi: number; + width: number; + height: number; +}; + +export enum MessageState { + Poll, + Ready, +} + +export enum MessageOp { + Init, + Inited, + Open, + Opened, + Render, + Rendered, +} + +export enum RenderKind { + Page, + Thumbnail, +} + +export interface MessageDataMap { + [MessageOp.Init]: undefined; + [MessageOp.Inited]: undefined; + [MessageOp.Open]: ArrayBuffer; + [MessageOp.Opened]: DocInfo; + [MessageOp.Render]: { + index: number; + kind: RenderKind; + scale: number; + }; + [MessageOp.Rendered]: { + index: number; + kind: RenderKind; + imageData: ImageData; + }; +} + +export type MessageDataType<T = MessageDataMap> = { + [P in keyof T]: T[P]; +}; + +export type MessageData<T = MessageOp, P = MessageDataType> = { + type: T; +} & P; diff --git a/blocksuite/playground/apps/_common/components/pdf/worker.ts b/blocksuite/playground/apps/_common/components/pdf/worker.ts new file mode 100644 index 0000000000..7aecaf7154 --- /dev/null +++ b/blocksuite/playground/apps/_common/components/pdf/worker.ts @@ -0,0 +1,124 @@ +import type { Document } from '@toeverything/pdf-viewer'; +import { + createPDFium, + PageRenderingflags, + Runtime, + Viewer, +} from '@toeverything/pdf-viewer'; +import wasmUrl from '@toeverything/pdfium/wasm?url'; + +import { type MessageData, type MessageDataType, MessageOp } from './types'; + +let inited = false; +let viewer: Viewer | null = null; +let doc: Document | undefined = undefined; + +const docInfo = { total: 0, width: 1, height: 1 }; +const flags = PageRenderingflags.REVERSE_BYTE_ORDER | PageRenderingflags.ANNOT; + +function post<T extends MessageOp>(type: T, data?: MessageDataType[T]) { + const message = { type, [type]: data }; + self.postMessage(message); +} + +function renderToImageData(index: number, scale: number) { + if (!viewer || !doc) return; + + const page = doc.page(index); + + if (!page) return; + + const width = Math.ceil(docInfo.width * scale); + const height = Math.ceil(docInfo.height * scale); + + const bitmap = viewer.createBitmap(width, height, 0); + bitmap.fill(0, 0, width, height); + page.render(bitmap, 0, 0, width, height, 0, flags); + + // @ts-expect-error FIXME: ts error + const data = new Uint8ClampedArray(bitmap.toUint8Array()); + + bitmap.close(); + page.close(); + + return new ImageData(data, width, height); +} + +async function start() { + inited = true; + + console.debug('pdf worker pending'); + self.postMessage({ type: MessageOp.Init }); + + const pdfium = await createPDFium({ + // @ts-expect-error allow + locateFile: () => wasmUrl, + }); + viewer = new Viewer(new Runtime(pdfium)); + + self.postMessage({ type: MessageOp.Inited }); + console.debug('pdf worker ready'); +} + +async function process({ data }: MessageEvent<MessageData>) { + if (!inited) { + await start(); + } + + if (!viewer) return; + + const { type } = data; + + switch (type) { + case MessageOp.Open: { + const buffer = data[type]; + if (!buffer) return; + + doc = viewer.open(new Uint8Array(buffer)); + + if (!doc) return; + + const page = doc.page(0); + + if (!page) return; + + Object.assign(docInfo, { + total: doc.pageCount(), + height: Math.ceil(page.height()), + width: Math.ceil(page.width()), + }); + page.close(); + post(MessageOp.Opened, docInfo); + + break; + } + + case MessageOp.Render: { + if (!doc) return; + + const { index, kind, scale } = data[type]; + + const { total } = docInfo; + + if (index < 0 || index >= total) return; + + queueMicrotask(() => { + const imageData = renderToImageData(index, scale); + if (!imageData) return; + + post(MessageOp.Rendered, { index, kind, imageData }); + }); + + break; + } + } +} + +self.addEventListener('message', (event: MessageEvent<MessageData>) => { + process(event).catch(console.error); +}); + +start().catch(error => { + inited = false; + console.log(error); +}); diff --git a/blocksuite/playground/apps/_common/components/side-panel.ts b/blocksuite/playground/apps/_common/components/side-panel.ts new file mode 100644 index 0000000000..4314d0749a --- /dev/null +++ b/blocksuite/playground/apps/_common/components/side-panel.ts @@ -0,0 +1,49 @@ +import { ShadowlessElement } from '@blocksuite/block-std'; +import { css, html } from 'lit'; +import { customElement } from 'lit/decorators.js'; + +@customElement('side-panel') +export class SidePanel extends ShadowlessElement { + static override styles = css` + side-panel { + width: 395px; + background-color: var(--affine-background-secondary-color); + position: absolute; + top: 0; + right: 0; + height: 100%; + display: none; + } + `; + + currentContent: HTMLElement | null = null; + + hideContent() { + if (this.currentContent) { + this.style.display = 'none'; + this.currentContent.remove(); + this.currentContent = null; + } + } + + protected override render(): unknown { + return html``; + } + + showContent(ele: HTMLElement) { + if (this.currentContent) { + this.currentContent.remove(); + } + this.style.display = 'block'; + this.currentContent = ele; + this.append(ele); + } + + toggle(ele: HTMLElement) { + if (this.currentContent !== ele) { + this.showContent(ele); + } else { + this.hideContent(); + } + } +} diff --git a/blocksuite/playground/apps/_common/components/starter-debug-menu.ts b/blocksuite/playground/apps/_common/components/starter-debug-menu.ts new file mode 100644 index 0000000000..78c220fdf0 --- /dev/null +++ b/blocksuite/playground/apps/_common/components/starter-debug-menu.ts @@ -0,0 +1,1041 @@ +/* eslint-disable @typescript-eslint/no-restricted-imports */ +import '@shoelace-style/shoelace/dist/components/button/button.js'; +import '@shoelace-style/shoelace/dist/components/button-group/button-group.js'; +import '@shoelace-style/shoelace/dist/components/color-picker/color-picker.js'; +import '@shoelace-style/shoelace/dist/components/divider/divider.js'; +import '@shoelace-style/shoelace/dist/components/dropdown/dropdown.js'; +import '@shoelace-style/shoelace/dist/components/icon/icon.js'; +import '@shoelace-style/shoelace/dist/components/icon-button/icon-button.js'; +import '@shoelace-style/shoelace/dist/components/menu/menu.js'; +import '@shoelace-style/shoelace/dist/components/menu-item/menu-item.js'; +import '@shoelace-style/shoelace/dist/components/select/select.js'; +import '@shoelace-style/shoelace/dist/components/tab/tab.js'; +import '@shoelace-style/shoelace/dist/components/tab-group/tab-group.js'; +import '@shoelace-style/shoelace/dist/components/tooltip/tooltip.js'; +import '@shoelace-style/shoelace/dist/themes/light.css'; +import '@shoelace-style/shoelace/dist/themes/dark.css'; +import './left-side-panel.js'; +import './side-panel.js'; + +import type { AffineTextAttributes } from '@blocksuite/affine-shared/types'; +import { ShadowlessElement } from '@blocksuite/block-std'; +import { + ColorScheme, + ColorVariables, + createAssetsArchive, + defaultImageProxyMiddleware, + docLinkBaseURLMiddleware, + type DocMode, + DocModeProvider, + download, + EdgelessRootService, + ExportManager, + FontFamilyVariables, + HtmlAdapterFactoryIdentifier, + HtmlTransformer, + MarkdownAdapterFactoryIdentifier, + MarkdownTransformer, + NotionHtmlAdapter, + NotionHtmlTransformer, + openFileOrFiles, + PlainTextAdapterFactoryIdentifier, + printToPdf, + SizeVariables, + StyleVariables, + titleMiddleware, + toast, + ZipTransformer, +} from '@blocksuite/blocks'; +import { BlockSuiteError, ErrorCode } from '@blocksuite/global/exceptions'; +import type { SerializedXYWH } from '@blocksuite/global/utils'; +import type { DeltaInsert } from '@blocksuite/inline/types'; +import { AffineEditorContainer, type CommentPanel } from '@blocksuite/presets'; +import { type DocCollection, Job, Text } from '@blocksuite/store'; +import type { SlDropdown } from '@shoelace-style/shoelace'; +import { setBasePath } from '@shoelace-style/shoelace/dist/utilities/base-path.js'; +import { css, html } from 'lit'; +import { customElement, property, query, state } from 'lit/decorators.js'; +import * as lz from 'lz-string'; +import type { Pane } from 'tweakpane'; + +import { mockEdgelessTheme } from '../mock-services.js'; +import { AdaptersPanel } from './adapters-panel.js'; +import type { CustomFramePanel } from './custom-frame-panel.js'; +import type { CustomOutlinePanel } from './custom-outline-panel.js'; +import type { CustomOutlineViewer } from './custom-outline-viewer.js'; +import type { DocsPanel } from './docs-panel.js'; +import type { LeftSidePanel } from './left-side-panel.js'; +import type { SidePanel } from './side-panel.js'; + +const basePath = + 'https://cdn.jsdelivr.net/npm/@shoelace-style/shoelace@2.11.2/dist'; +setBasePath(basePath); + +const OTHER_CSS_VARIABLES = StyleVariables.filter( + variable => + !SizeVariables.includes(variable) && + !ColorVariables.includes(variable) && + !FontFamilyVariables.includes(variable) +); +let styleDebugMenuLoaded = false; + +function initStyleDebugMenu( + styleMenu: Pane, + { writer, reader }: Record<'writer' | 'reader', CSSStyleDeclaration> +) { + const sizeFolder = styleMenu.addFolder({ title: 'Size', expanded: false }); + const fontFamilyFolder = styleMenu.addFolder({ + title: 'Font Family', + expanded: false, + }); + const colorFolder = styleMenu.addFolder({ title: 'Color', expanded: false }); + const othersFolder = styleMenu.addFolder({ + title: 'Others', + expanded: false, + }); + SizeVariables.forEach(name => { + const value = reader.getPropertyValue(name); + sizeFolder + .addBinding( + { + [name]: isNaN(parseFloat(value)) ? 0 : parseFloat(value), + }, + name, + { + min: 0, + max: 100, + } + ) + .on('change', e => { + writer.setProperty(name, `${Math.round(e.value)}px`); + }); + }); + FontFamilyVariables.forEach(name => { + const value = reader.getPropertyValue(name); + fontFamilyFolder + .addBinding( + { + [name]: value, + }, + name + ) + .on('change', e => { + writer.setProperty(name, e.value); + }); + }); + OTHER_CSS_VARIABLES.forEach(name => { + const value = reader.getPropertyValue(name); + othersFolder.addBinding({ [name]: value }, name).on('change', e => { + writer.setProperty(name, e.value); + }); + }); + fontFamilyFolder + .addBinding( + { + '--affine-font-family': + 'Roboto Mono, apple-system, BlinkMacSystemFont,Helvetica Neue, Tahoma, PingFang SC, Microsoft Yahei, Arial,Hiragino Sans GB, sans-serif, Apple Color Emoji, Segoe UI Emoji,Segoe UI Symbol, Noto Color Emoji', + }, + '--affine-font-family' + ) + .on('change', e => { + writer.setProperty('--affine-font-family', e.value); + }); + ColorVariables.forEach(name => { + const value = reader.getPropertyValue(name); + colorFolder.addBinding({ [name]: value }, name).on('change', e => { + writer.setProperty(name, e.value); + }); + }); +} + +function getDarkModeConfig(): boolean { + const updatedDarkModeConfig = localStorage.getItem('blocksuite:dark'); + if (updatedDarkModeConfig !== null) { + return updatedDarkModeConfig === 'true'; + } + + const matchMedia = window.matchMedia('(prefers-color-scheme: dark)'); + return matchMedia.matches; +} + +interface AdapterResult { + file: string; + assetsIds: string[]; +} + +type AdapterFactoryIdentifier = + | typeof HtmlAdapterFactoryIdentifier + | typeof MarkdownAdapterFactoryIdentifier + | typeof PlainTextAdapterFactoryIdentifier; + +interface AdapterConfig { + identifier: AdapterFactoryIdentifier; + fileExtension: string; // file extension need to be lower case with dot prefix, e.g. '.md', '.txt', '.html' + contentType: string; + indexFileName: string; +} + +@customElement('starter-debug-menu') +export class StarterDebugMenu extends ShadowlessElement { + static override styles = css` + :root { + --sl-font-size-medium: var(--affine-font-xs); + --sl-input-font-size-small: var(--affine-font-xs); + } + + .dg.ac { + z-index: 1001 !important; + } + `; + + private _darkModeChange = (e: MediaQueryListEvent) => { + this._setThemeMode(!!e.matches); + }; + + private _handleDocsPanelClose = () => { + this.leftSidePanel.toggle(this.docsPanel); + }; + + private _showStyleDebugMenu = false; + + private _styleMenu!: Pane; + + get doc() { + return this.editor.doc; + } + + get mode() { + return this.editor.mode; + } + + set mode(value: DocMode) { + this.editor.mode = value; + } + + get rootService() { + return this.editor.std?.getService('affine:page'); + } + + private _addNote() { + const rootModel = this.doc.root; + if (!rootModel) return; + const rootId = rootModel.id; + + this.doc.captureSync(); + + const count = rootModel.children.length; + const xywh: SerializedXYWH = `[0,${count * 60},800,95]`; + + const noteId = this.doc.addBlock('affine:note', { xywh }, rootId); + this.doc.addBlock('affine:paragraph', {}, noteId); + } + + private async _clearSiteData() { + await fetch('/Clear-Site-Data'); + window.location.reload(); + } + + private _enableOutlineViewer() { + this.outlineViewer.toggleDisplay(); + } + + private async _exportFile(config: AdapterConfig) { + const doc = this.editor.doc; + const job = new Job({ + collection: this.editor.doc.collection, + middlewares: [docLinkBaseURLMiddleware, titleMiddleware], + }); + + const adapterFactory = this.editor.std.provider.get(config.identifier); + const adapter = adapterFactory.get(job); + const result = (await adapter.fromDoc(doc)) as AdapterResult; + + if (!result || (!result.file && !result.assetsIds.length)) { + return; + } + + const docTitle = doc.meta?.title || 'Untitled'; + const contentBlob = new Blob([result.file], { type: config.contentType }); + + let downloadBlob: Blob; + let name: string; + + if (result.assetsIds.length > 0) { + if (!job.assets) { + throw new BlockSuiteError(ErrorCode.ValueNotExists, 'No assets found'); + } + const zip = await createAssetsArchive(job.assets, result.assetsIds); + await zip.file(config.indexFileName, contentBlob); + downloadBlob = await zip.generate(); + name = `${docTitle}.zip`; + } else { + downloadBlob = contentBlob; + name = `${docTitle}${config.fileExtension}`; + } + + download(downloadBlob, name); + } + + private async _exportHtml() { + await this._exportFile({ + identifier: HtmlAdapterFactoryIdentifier, + fileExtension: '.html', + contentType: 'text/html', + indexFileName: 'index.html', + }); + } + + /** + * Export markdown file using markdown adapter factory extension + */ + private async _exportMarkDown() { + await this._exportFile({ + identifier: MarkdownAdapterFactoryIdentifier, + fileExtension: '.md', + contentType: 'text/plain', + indexFileName: 'index.md', + }); + } + + private _exportPdf() { + this.editor.std.get(ExportManager).exportPdf().catch(console.error); + } + + /** + * Export plain text file using plain text adapter factory extension + */ + private async _exportPlainText() { + await this._exportFile({ + identifier: PlainTextAdapterFactoryIdentifier, + fileExtension: '.txt', + contentType: 'text/plain', + indexFileName: 'index.txt', + }); + } + + private _exportPng() { + this.editor.std.get(ExportManager).exportPng().catch(console.error); + } + + private async _exportSnapshot() { + await ZipTransformer.exportDocs( + this.collection, + [...this.collection.docs.values()].map(collection => collection.getDoc()) + ); + } + + private async _importHTML() { + try { + const files = await openFileOrFiles({ + acceptType: 'Html', + multiple: true, + }); + + if (!files) return; + + const pageIds: string[] = []; + for (const file of files) { + const text = await file.text(); + const fileName = file.name.split('.').slice(0, -1).join('.'); + const pageId = await HtmlTransformer.importHTMLToDoc({ + collection: this.collection, + html: text, + fileName, + }); + if (pageId) { + pageIds.push(pageId); + } + } + if (!this.editor.host) return; + toast( + this.editor.host, + `Successfully imported ${pageIds.length} HTML files.` + ); + } catch (error) { + console.error(' Import HTML files failed:', error); + } + } + + private async _importHTMLZip() { + try { + const file = await openFileOrFiles({ acceptType: 'Zip' }); + if (!file) return; + const result = await HtmlTransformer.importHTMLZip({ + collection: this.collection, + imported: file, + }); + if (!this.editor.host) return; + toast( + this.editor.host, + `Successfully imported ${result.length} HTML files.` + ); + } catch (error) { + console.error('Import HTML zip files failed:', error); + } + } + + private async _importMarkdown() { + try { + const files = await openFileOrFiles({ + acceptType: 'Markdown', + multiple: true, + }); + + if (!files) return; + + const pageIds: string[] = []; + for (const file of files) { + const text = await file.text(); + const fileName = file.name.split('.').slice(0, -1).join('.'); + const pageId = await MarkdownTransformer.importMarkdownToDoc({ + collection: this.collection, + markdown: text, + fileName, + }); + if (pageId) { + pageIds.push(pageId); + } + } + if (!this.editor.host) return; + toast( + this.editor.host, + `Successfully imported ${pageIds.length} markdown files.` + ); + } catch (error) { + console.error(' Import markdown files failed:', error); + } + } + + private async _importMarkdownZip() { + try { + const file = await openFileOrFiles({ acceptType: 'Zip' }); + if (!file) return; + const result = await MarkdownTransformer.importMarkdownZip({ + collection: this.collection, + imported: file, + }); + if (!this.editor.host) return; + toast( + this.editor.host, + `Successfully imported ${result.length} markdown files.` + ); + } catch (error) { + console.error('Import markdown zip files failed:', error); + } + } + + private async _importNotionHTML() { + try { + const file = await openFileOrFiles({ + acceptType: 'Html', + multiple: false, + }); + if (!file) return; + const job = new Job({ + collection: this.collection, + middlewares: [defaultImageProxyMiddleware], + }); + const htmlAdapter = new NotionHtmlAdapter(job); + await htmlAdapter.toDoc({ + file: await file.text(), + pageId: this.collection.idGenerator(), + assets: job.assetsManager, + }); + } catch (error) { + console.error('Failed to import Notion HTML:', error); + } + } + + private async _importNotionHTMLZip() { + try { + const file = await openFileOrFiles({ acceptType: 'Zip' }); + if (!file) return; + const result = await NotionHtmlTransformer.importNotionZip({ + collection: this.collection, + imported: file, + }); + if (!this.editor.host) return; + toast( + this.editor.host, + `Successfully imported ${result.pageIds.length} Notion HTML pages.` + ); + } catch (error) { + console.error('Failed to import Notion HTML Zip:', error); + } + } + + private _importSnapshot() { + const input = document.createElement('input'); + input.setAttribute('type', 'file'); + input.setAttribute('accept', '.zip'); + input.multiple = false; + input.onchange = async () => { + const file = input.files?.item(0); + if (!file) { + return; + } + try { + const docs = await ZipTransformer.importDocs(this.collection, file); + for (const doc of docs) { + if (doc) { + const noteBlock = window.doc.getBlockByFlavour('affine:note'); + window.doc.addBlock( + 'affine:paragraph', + { + type: 'text', + text: new Text([ + { + insert: ' ', + attributes: { + reference: { + type: 'LinkedPage', + pageId: doc.id, + }, + }, + } as DeltaInsert<AffineTextAttributes>, + ]), + }, + noteBlock[0].id + ); + } + } + this.requestUpdate(); + } catch (e) { + console.error('Invalid snapshot.'); + console.error(e); + } finally { + input.remove(); + } + }; + input.click(); + } + + private _insertTransitionStyle(classKey: string, duration: number) { + const $html = document.documentElement; + const $style = document.createElement('style'); + const slCSSKeys = ['sl-transition-x-fast']; + $style.innerHTML = `html.${classKey} * { transition: all ${duration}ms 0ms linear !important; } :root { ${slCSSKeys.map( + key => `--${key}: ${duration}ms` + )} }`; + + $html.append($style); + $html.classList.add(classKey); + + setTimeout(() => { + $style.remove(); + $html.classList.remove(classKey); + }, duration); + } + + private _present() { + if (!this.editor.std || !this.editor.host) return; + const rootService = this.editor.std.getService('affine:page'); + if (!(rootService instanceof EdgelessRootService)) { + toast( + this.editor.host, + 'The presentation mode is only available on edgeless mode.', + 3000 + ); + return; + } + + const edgelessRootService = rootService as EdgelessRootService; + edgelessRootService?.gfx.tool.setTool('frameNavigator', { + mode: 'fit', + }); + } + + private _print() { + printToPdf().catch(console.error); + } + + private _setThemeMode(dark: boolean) { + const html = document.querySelector('html'); + + this._dark = dark; + localStorage.setItem('blocksuite:dark', dark ? 'true' : 'false'); + if (!html) return; + html.dataset.theme = dark ? 'dark' : 'light'; + + this._insertTransitionStyle('color-transition', 0); + + if (dark) { + html.classList.add('dark'); + html.classList.add('sl-theme-dark'); + } else { + html.classList.remove('dark'); + html.classList.remove('sl-theme-dark'); + } + + const theme = dark ? ColorScheme.Dark : ColorScheme.Light; + mockEdgelessTheme.setTheme(theme); + } + + private _shareSelection() { + const selection = this.editor.host?.selection.value; + if (!selection || selection.length === 0) { + return; + } + const json = selection.map(sel => sel.toJSON()); + const hash = lz.compressToEncodedURIComponent(JSON.stringify(json)); + const url = new URL(window.location.toString()); + url.searchParams.set('sel', hash); + window.history.pushState({}, '', url); + } + + private _switchEditorMode() { + if (!this.editor.host) return; + const newMode = this.mode === 'page' ? 'edgeless' : 'page'; + const docModeService = this.editor.host.std.get(DocModeProvider); + if (docModeService) { + docModeService.setPrimaryMode(newMode, this.editor.doc.id); + } + this.mode = newMode; + } + + private _switchOffsetMode() { + this._hasOffset = !this._hasOffset; + } + + private _toggleAdaptersPanel() { + const app = document.querySelector('#app'); + if (!app) return; + + const currentAdaptersPanel = app.querySelector('adapters-panel'); + if (currentAdaptersPanel) { + currentAdaptersPanel.remove(); + (app as HTMLElement).style.display = 'block'; + this.editor.style.width = '100%'; + this.editor.style.flex = ''; + return; + } + + const adaptersPanel = new AdaptersPanel(); + adaptersPanel.editor = this.editor; + app.append(adaptersPanel); + this.editor.style.flex = '1'; + (app as HTMLElement).style.display = 'flex'; + } + + private _toggleCommentPanel() { + document.body.append(this.commentPanel); + } + + private _toggleDarkMode() { + this._setThemeMode(!this._dark); + } + + private _toggleDocsPanel() { + this.docsPanel.onClose = this._handleDocsPanelClose; + this.leftSidePanel.toggle(this.docsPanel); + } + + private _toggleFramePanel() { + this.framePanel.toggleDisplay(); + } + + private _toggleMultipleEditors() { + const app = document.querySelector('#app'); + if (app) { + const currentEditorCount = app.querySelectorAll( + 'affine-editor-container' + ).length; + if (currentEditorCount === 1) { + // Add a second editor + const newEditor = document.createElement('affine-editor-container'); + newEditor.doc = this.doc; + app.append(newEditor); + app.childNodes.forEach(child => { + if (child instanceof AffineEditorContainer) { + child.style.flex = '1'; + } + }); + (app as HTMLElement).style.display = 'flex'; + } else { + // Remove the second editor + const secondEditor = app.querySelectorAll('affine-editor-container')[1]; + if (secondEditor) { + secondEditor.remove(); + } + (app as HTMLElement).style.display = 'block'; + } + } + } + + private _toggleOutlinePanel() { + this.outlinePanel.toggleDisplay(); + } + + private _toggleReadonly() { + const doc = this.doc; + doc.awarenessStore.setReadonly(doc.blockCollection, !doc.readonly); + } + + private async _toggleStyleDebugMenu() { + if (!styleDebugMenuLoaded) { + styleDebugMenuLoaded = true; + const { Pane } = await import('tweakpane'); + this._styleMenu = new Pane({ title: 'Waiting' }); + this._styleMenu.hidden = true; + this._styleMenu.element.style.width = '650'; + initStyleDebugMenu(this._styleMenu, { + writer: document.documentElement.style, + reader: getComputedStyle(document.documentElement), + }); + } + + this._showStyleDebugMenu = !this._showStyleDebugMenu; + this._showStyleDebugMenu + ? (this._styleMenu.hidden = false) + : (this._styleMenu.hidden = true); + } + + override connectedCallback() { + super.connectedCallback(); + + const readSelectionFromURL = async () => { + const editorHost = this.editor.host; + if (!editorHost) { + await new Promise(resolve => { + setTimeout(resolve, 500); + }); + readSelectionFromURL().catch(console.error); + return; + } + const url = new URL(window.location.toString()); + const sel = url.searchParams.get('sel'); + if (!sel) return; + try { + const json = JSON.parse(lz.decompressFromEncodedURIComponent(sel)); + editorHost.std.selection.fromJSON(json); + } catch { + return; + } + }; + readSelectionFromURL().catch(console.error); + } + + override createRenderRoot() { + this._setThemeMode(this._dark); + + const matchMedia = window.matchMedia('(prefers-color-scheme: dark)'); + matchMedia.addEventListener('change', this._darkModeChange); + + return this; + } + + override disconnectedCallback() { + super.disconnectedCallback(); + + const matchMedia = window.matchMedia('(prefers-color-scheme: dark)'); + matchMedia.removeEventListener('change', this._darkModeChange); + } + + override firstUpdated() { + this.doc.slots.historyUpdated.on(() => { + this._canUndo = this.doc.canUndo; + this._canRedo = this.doc.canRedo; + }); + + this.editor.std.get(DocModeProvider).onPrimaryModeChange(() => { + this.requestUpdate(); + }, this.editor.doc.id); + } + + override render() { + return html` + <style> + .debug-menu { + display: flex; + flex-wrap: nowrap; + position: fixed; + top: 0; + left: 0; + width: 100%; + overflow: auto; + z-index: 1000; /* for debug visibility */ + pointer-events: none; + } + + @media print { + .debug-menu { + display: none; + } + } + + .default-toolbar { + display: flex; + gap: 5px; + padding: 8px; + width: 100%; + min-width: 390px; + } + + .default-toolbar > * { + pointer-events: auto; + } + + .edgeless-toolbar { + align-items: center; + margin-right: 17px; + pointer-events: auto; + } + + .edgeless-toolbar sl-select, + .edgeless-toolbar sl-color-picker, + .edgeless-toolbar sl-button { + margin-right: 4px; + } + </style> + <div class="debug-menu default"> + <div class="default-toolbar"> + <!-- undo/redo group --> + <sl-button-group label="History"> + <!-- undo --> + <sl-tooltip content="Undo" placement="bottom" hoist> + <sl-button + size="small" + .disabled="${!this._canUndo}" + @click="${() => this.doc.undo()}" + > + <sl-icon name="arrow-counterclockwise" label="Undo"></sl-icon> + </sl-button> + </sl-tooltip> + <!-- redo --> + <sl-tooltip content="Redo" placement="bottom" hoist> + <sl-button + size="small" + .disabled="${!this._canRedo}" + @click="${() => this.doc.redo()}" + > + <sl-icon name="arrow-clockwise" label="Redo"></sl-icon> + </sl-button> + </sl-tooltip> + </sl-button-group> + + <!-- test operations --> + <sl-dropdown id="test-operations-dropdown" placement="bottom" hoist> + <sl-button size="small" slot="trigger" caret> + Test Operations + </sl-button> + <sl-menu> + <sl-menu-item @click="${this._print}">Print</sl-menu-item> + <sl-menu-item> + Export + <sl-menu slot="submenu"> + <sl-menu-item @click="${this._exportMarkDown}"> + Export Markdown + </sl-menu-item> + <sl-menu-item @click="${this._exportHtml}"> + Export HTML + </sl-menu-item> + <sl-menu-item @click="${this._exportPlainText}"> + Export Plain Text + </sl-menu-item> + <sl-menu-item @click="${this._exportPdf}"> + Export PDF + </sl-menu-item> + <sl-menu-item @click="${this._exportPng}"> + Export PNG + </sl-menu-item> + <sl-menu-item @click="${this._exportSnapshot}"> + Export Snapshot + </sl-menu-item> + </sl-menu> + </sl-menu-item> + <sl-menu-item> + Import + <sl-menu slot="submenu"> + <sl-menu-item @click="${this._importSnapshot}"> + Import Snapshot + </sl-menu-item> + <sl-menu-item> + Import Notion HTML + <sl-menu slot="submenu"> + <sl-menu-item @click="${this._importNotionHTML}"> + Single Notion HTML Page + </sl-menu-item> + <sl-menu-item @click="${this._importNotionHTMLZip}"> + Notion HTML Zip + </sl-menu-item> + </sl-menu> + </sl-menu-item> + <sl-menu-item> + Import Markdown + <sl-menu slot="submenu"> + <sl-menu-item @click="${this._importMarkdown}"> + Markdown Files + </sl-menu-item> + <sl-menu-item @click="${this._importMarkdownZip}"> + Markdown Zip + </sl-menu-item> + </sl-menu> + </sl-menu-item> + <sl-menu-item> + Import HTML + <sl-menu slot="submenu"> + <sl-menu-item @click="${this._importHTML}"> + HTML Files + </sl-menu-item> + <sl-menu-item @click="${this._importHTMLZip}"> + HTML Zip + </sl-menu-item> + </sl-menu> + </sl-menu-item> + </sl-menu> + </sl-menu-item> + <sl-menu-item @click="${this._toggleStyleDebugMenu}"> + Toggle CSS Debug Menu + </sl-menu-item> + <sl-menu-item @click="${this._toggleReadonly}"> + Toggle Readonly + </sl-menu-item> + <sl-menu-item @click="${this._shareSelection}"> + Share Selection + </sl-menu-item> + <sl-menu-item @click="${this._switchOffsetMode}"> + Switch Offset Mode + </sl-menu-item> + <sl-menu-item @click="${this._toggleOutlinePanel}"> + Toggle Outline Panel + </sl-menu-item> + <sl-menu-item @click="${this._enableOutlineViewer}"> + Enable Outline Viewer + </sl-menu-item> + <sl-menu-item @click="${this._toggleFramePanel}"> + Toggle Frame Panel + </sl-menu-item> + <sl-menu-item @click="${this._toggleCommentPanel}"> + Toggle Comment Panel + </sl-menu-item> + <sl-menu-item @click="${this._addNote}"> Add Note </sl-menu-item> + <sl-menu-item @click="${this._toggleMultipleEditors}"> + Toggle Multiple Editors + </sl-menu-item> + <sl-menu-item @click="${this._toggleAdaptersPanel}"> + Toggle Adapters Panel + </sl-menu-item> + </sl-menu> + </sl-dropdown> + + <sl-tooltip content="Switch Editor" placement="bottom" hoist> + <sl-button size="small" @click="${this._switchEditorMode}"> + <sl-icon name="repeat"></sl-icon> + </sl-button> + </sl-tooltip> + + <sl-tooltip content="Clear Site Data" placement="bottom" hoist> + <sl-button size="small" @click="${this._clearSiteData}"> + <sl-icon name="trash"></sl-icon> + </sl-button> + </sl-tooltip> + + <sl-tooltip + content="Toggle ${this._dark ? 'Light' : 'Dark'} Mode" + placement="bottom" + hoist + > + <sl-button size="small" @click="${this._toggleDarkMode}"> + <sl-icon + name="${this._dark ? 'moon' : 'brightness-high'}" + ></sl-icon> + </sl-button> + </sl-tooltip> + + <sl-tooltip + content="Enter presentation mode" + placement="bottom" + hoist + > + <sl-button size="small" @click="${this._present}"> + <sl-icon name="easel"></sl-icon> + </sl-button> + </sl-tooltip> + + <sl-button + data-testid="docs-button" + size="small" + @click="${this._toggleDocsPanel}" + data-docs-panel-toggle + > + Docs + </sl-button> + </div> + </div> + `; + } + + override update(changedProperties: Map<string, unknown>) { + if (changedProperties.has('_hasOffset')) { + const appRoot = document.getElementById('app'); + if (!appRoot) return; + const style: Partial<CSSStyleDeclaration> = this._hasOffset + ? { + margin: '60px 40px 240px 40px', + overflow: 'auto', + height: '400px', + boxShadow: '0 0 10px 0 rgba(0, 0, 0, 0.2)', + } + : { + margin: '0', + overflow: 'initial', + // edgeless needs the container height + height: '100%', + boxShadow: 'initial', + }; + Object.assign(appRoot.style, style); + } + super.update(changedProperties); + } + + @state() + private accessor _canRedo = false; + + @state() + private accessor _canUndo = false; + + @state() + private accessor _dark = getDarkModeConfig(); + + @state() + private accessor _hasOffset = false; + + @query('#block-type-dropdown') + accessor blockTypeDropdown!: SlDropdown; + + @property({ attribute: false }) + accessor collection!: DocCollection; + + @property({ attribute: false }) + accessor commentPanel!: CommentPanel; + + @property({ attribute: false }) + accessor docsPanel!: DocsPanel; + + @property({ attribute: false }) + accessor editor!: AffineEditorContainer; + + @property({ attribute: false }) + accessor framePanel!: CustomFramePanel; + + @property({ attribute: false }) + accessor leftSidePanel!: LeftSidePanel; + + @property({ attribute: false }) + accessor outlinePanel!: CustomOutlinePanel; + + @property({ attribute: false }) + accessor outlineViewer!: CustomOutlineViewer; + + @property({ attribute: false }) + accessor readonly = false; + + @property({ attribute: false }) + accessor sidePanel!: SidePanel; +} + +declare global { + interface HTMLElementTagNameMap { + 'starter-debug-menu': StarterDebugMenu; + } +} diff --git a/blocksuite/playground/apps/_common/history.ts b/blocksuite/playground/apps/_common/history.ts new file mode 100644 index 0000000000..eb763896a6 --- /dev/null +++ b/blocksuite/playground/apps/_common/history.ts @@ -0,0 +1,62 @@ +import type { DocModeProvider } from '@blocksuite/blocks'; +import { assertExists } from '@blocksuite/global/utils'; +import type { AffineEditorContainer } from '@blocksuite/presets'; +import type { BlockCollection, Doc, DocCollection } from '@blocksuite/store'; +import type { LitElement } from 'lit'; + +export function getDocFromUrlParams(collection: DocCollection, url: URL) { + let doc: Doc | null = null; + + const docId = decodeURIComponent(url.hash.slice(1)); + + if (docId) { + doc = collection.getDoc(docId); + } + if (!doc) { + const blockCollection = collection.docs.values().next() + .value as BlockCollection; + assertExists(blockCollection, 'Need to create a doc first'); + doc = blockCollection.getDoc(); + } + + doc.load(); + doc.resetHistory(); + + assertExists(doc.ready, 'Doc is not ready'); + assertExists(doc.root, 'Doc root is not ready'); + + return doc; +} + +export function setDocModeFromUrlParams( + service: DocModeProvider, + search: URLSearchParams, + docId: string +) { + const paramMode = search.get('mode'); + if (paramMode) { + const docMode = paramMode === 'page' ? 'page' : 'edgeless'; + service.setPrimaryMode(docMode, docId); + service.setEditorMode(docMode); + } +} + +export function listenHashChange( + collection: DocCollection, + editor: AffineEditorContainer, + panel?: LitElement +) { + window.addEventListener('hashchange', () => { + const url = new URL(location.toString()); + const doc = getDocFromUrlParams(collection, url); + if (!doc) return; + + if (panel?.checkVisibility()) { + panel.requestUpdate(); + } + + editor.doc = doc; + editor.doc.load(); + editor.doc.resetHistory(); + }); +} diff --git a/blocksuite/playground/apps/_common/mock-services.ts b/blocksuite/playground/apps/_common/mock-services.ts new file mode 100644 index 0000000000..709c8c9dca --- /dev/null +++ b/blocksuite/playground/apps/_common/mock-services.ts @@ -0,0 +1,206 @@ +/* eslint-disable @typescript-eslint/no-non-null-assertion */ +import type { + PeekOptions, + PeekViewService, +} from '@blocksuite/affine-components/peek'; +import { PeekViewExtension } from '@blocksuite/affine-components/peek'; +import { BlockComponent } from '@blocksuite/block-std'; +import { + ColorScheme, + type DocMode, + type DocModeProvider, + type GenerateDocUrlService, + matchFlavours, + type NotificationService, + type ParseDocUrlService, + type ReferenceParams, + type ThemeExtension, + toast, +} from '@blocksuite/blocks'; +import type { AffineEditorContainer } from '@blocksuite/presets'; +import { type DocCollection, Slot } from '@blocksuite/store'; +import { signal } from '@preact/signals-core'; +import type { TemplateResult } from 'lit'; + +import type { AttachmentViewerPanel } from './components/attachment-viewer-panel.js'; + +function getModeFromStorage() { + const mapJson = localStorage.getItem('playground:docMode'); + const mapArray = mapJson ? JSON.parse(mapJson) : []; + return new Map<string, DocMode>(mapArray); +} + +function saveModeToStorage(map: Map<string, DocMode>) { + const mapArray = Array.from(map); + const mapJson = JSON.stringify(mapArray); + localStorage.setItem('playground:docMode', mapJson); +} + +export function removeModeFromStorage(docId: string) { + const modeMap = getModeFromStorage(); + modeMap.delete(docId); + saveModeToStorage(modeMap); +} + +const DEFAULT_MODE: DocMode = 'page'; +const slotMap = new Map<string, Slot<DocMode>>(); + +export function mockDocModeService( + getEditorModeCallback: () => DocMode, + setEditorModeCallback: (mode: DocMode) => void +) { + const docModeService: DocModeProvider = { + getPrimaryMode: (docId: string) => { + try { + const modeMap = getModeFromStorage(); + return modeMap.get(docId) ?? DEFAULT_MODE; + } catch { + return DEFAULT_MODE; + } + }, + onPrimaryModeChange: (handler: (mode: DocMode) => void, docId: string) => { + if (!slotMap.get(docId)) { + slotMap.set(docId, new Slot()); + } + return slotMap.get(docId)!.on(handler); + }, + getEditorMode: () => { + return getEditorModeCallback(); + }, + setEditorMode: (mode: DocMode) => { + setEditorModeCallback(mode); + }, + setPrimaryMode: (mode: DocMode, docId: string) => { + const modeMap = getModeFromStorage(); + modeMap.set(docId, mode); + saveModeToStorage(modeMap); + slotMap.get(docId)?.emit(mode); + }, + togglePrimaryMode: (docId: string) => { + const mode = + docModeService.getPrimaryMode(docId) === 'page' ? 'edgeless' : 'page'; + docModeService.setPrimaryMode(mode, docId); + return mode; + }, + }; + return docModeService; +} + +export function mockNotificationService(editor: AffineEditorContainer) { + const notificationService: NotificationService = { + toast: (message, options) => { + toast(editor.host!, message, options?.duration); + }, + confirm: notification => { + return Promise.resolve(confirm(notification.title.toString())); + }, + prompt: notification => { + return Promise.resolve( + prompt(notification.title.toString(), notification.autofill?.toString()) + ); + }, + notify: notification => { + // todo: implement in playground + console.log(notification); + }, + }; + return notificationService; +} + +export function mockParseDocUrlService(collection: DocCollection) { + const parseDocUrlService: ParseDocUrlService = { + parseDocUrl: (url: string) => { + if (url && URL.canParse(url)) { + const path = decodeURIComponent(new URL(url).hash.slice(1)); + const item = + path.length > 0 + ? [...collection.docs.values()].find(doc => doc.id === path) + : null; + if (item) { + return { + docId: item.id, + }; + } + } + return; + }, + }; + return parseDocUrlService; +} + +export class MockEdgelessTheme { + theme$ = signal(ColorScheme.Light); + + setTheme(theme: ColorScheme) { + this.theme$.value = theme; + } + + toggleTheme() { + const theme = + this.theme$.value === ColorScheme.Dark + ? ColorScheme.Light + : ColorScheme.Dark; + this.theme$.value = theme; + } +} + +export const mockEdgelessTheme = new MockEdgelessTheme(); + +export const themeExtension: ThemeExtension = { + getEdgelessTheme() { + return mockEdgelessTheme.theme$; + }, +}; + +export function mockPeekViewExtension( + attachmentViewerPanel: AttachmentViewerPanel +) { + return PeekViewExtension({ + peek( + element: { + target: HTMLElement; + docId: string; + blockIds?: string[]; + template?: TemplateResult; + }, + options?: PeekOptions + ) { + const { target } = element; + + if ( + target instanceof BlockComponent && + matchFlavours(target.model, ['affine:attachment']) + ) { + attachmentViewerPanel.open(target.model); + return Promise.resolve(); + } + + alert('Peek view not implemented in playground'); + console.log('peek', element, options); + + return Promise.resolve(); + }, + } satisfies PeekViewService); +} + +export function mockGenerateDocUrlService(collection: DocCollection) { + const generateDocUrlService: GenerateDocUrlService = { + generateDocUrl: (docId: string, params?: ReferenceParams) => { + const doc = collection.getDoc(docId); + if (!doc) return; + + const url = new URL(location.pathname, location.origin); + url.search = location.search; + if (params) { + const search = url.searchParams; + for (const [key, value] of Object.entries(params)) { + search.set(key, Array.isArray(value) ? value.join(',') : value); + } + } + url.hash = encodeURIComponent(docId); + + return url.toString(); + }, + }; + return generateDocUrlService; +} diff --git a/blocksuite/playground/apps/_common/setup.ts b/blocksuite/playground/apps/_common/setup.ts new file mode 100644 index 0000000000..5c2823a10e --- /dev/null +++ b/blocksuite/playground/apps/_common/setup.ts @@ -0,0 +1,74 @@ +import type { + Template, + TemplateCategory, + TemplateManager, +} from '@blocksuite/blocks'; +import { EdgelessTemplatePanel } from '@blocksuite/blocks'; + +export function setupEdgelessTemplate() { + const playgroundTemplates = [ + { + name: 'Paws and pals', + templates: () => + import('./templates/stickers.js').then(module => module.default), + }, + ] as TemplateCategory[]; + + function lcs(text1: string, text2: string): number { + const dp: number[][] = Array.from( + { + length: text1.length + 1, + }, + () => Array.from({ length: text2.length + 1 }, () => 0) + ); + + for (let i = 1; i <= text1.length; i++) { + for (let j = 1; j <= text2.length; j++) { + if (text1[i - 1] === text2[j - 1]) { + dp[i][j] = dp[i - 1][j - 1] + 1; + } else { + dp[i][j] = Math.max(dp[i - 1][j], dp[i][j - 1]); + } + } + } + + return dp[text1.length][text2.length]; + } + + EdgelessTemplatePanel.templates.extend({ + search: async (keyword: string) => { + const candidates: Template[] = []; + + await Promise.all( + playgroundTemplates.map(async cate => { + const templates = + cate.templates instanceof Function + ? await cate.templates() + : cate.templates; + + templates.forEach(template => { + if ( + template.name && + lcs(template.name, keyword) === keyword.length + ) { + candidates.push(template); + } + }); + }) + ); + + return candidates; + }, + list: async (cate: string) => { + const category = playgroundTemplates.find(c => c.name === cate); + + if (category?.templates instanceof Function) { + return category.templates(); + } + + return category?.templates ?? []; + }, + + categories: () => playgroundTemplates.map(cate => cate.name), + } satisfies TemplateManager); +} diff --git a/blocksuite/playground/apps/_common/sync/blob/mock-server.ts b/blocksuite/playground/apps/_common/sync/blob/mock-server.ts new file mode 100644 index 0000000000..65b5580587 --- /dev/null +++ b/blocksuite/playground/apps/_common/sync/blob/mock-server.ts @@ -0,0 +1,54 @@ +import type { BlobSource } from '@blocksuite/sync'; + +/** + * @internal just for test + * + * API: /api/collection/:id/blob/:key + * GET: get blob + * PUT: set blob + * DELETE: delete blob + */ +export class MockServerBlobSource implements BlobSource { + private readonly _cache = new Map<string, Blob>(); + + readonly = false; + + constructor(readonly name: string) {} + + async delete(key: string) { + this._cache.delete(key); + await fetch(`/api/collection/${this.name}/blob/${key}`, { + method: 'DELETE', + }); + } + + async get(key: string) { + if (this._cache.has(key)) { + return this._cache.get(key) as Blob; + } else { + const blob = await fetch(`/api/collection/${this.name}/blob/${key}`, { + method: 'GET', + }).then(response => { + if (!response.ok) { + throw new Error(`Failed to fetch blob ${key}`); + } + return response.blob(); + }); + this._cache.set(key, blob); + return blob; + } + } + + async list() { + return Array.from(this._cache.keys()); + } + + async set(key: string, value: Blob) { + this._cache.set(key, value); + await fetch(`/api/collection/${this.name}/blob/${key}`, { + method: 'PUT', + body: await value.arrayBuffer(), + }); + return key; + } +} diff --git a/blocksuite/playground/apps/_common/sync/websocket/awareness.ts b/blocksuite/playground/apps/_common/sync/websocket/awareness.ts new file mode 100644 index 0000000000..1229da661c --- /dev/null +++ b/blocksuite/playground/apps/_common/sync/websocket/awareness.ts @@ -0,0 +1,85 @@ +import { assertExists } from '@blocksuite/global/utils'; +import type { AwarenessSource } from '@blocksuite/sync'; +import type { Awareness } from 'y-protocols/awareness'; +import { + applyAwarenessUpdate, + encodeAwarenessUpdate, +} from 'y-protocols/awareness'; + +import type { WebSocketMessage } from './types'; + +type AwarenessChanges = Record<'added' | 'updated' | 'removed', number[]>; + +export class WebSocketAwarenessSource implements AwarenessSource { + private _onAwareness = (changes: AwarenessChanges, origin: unknown) => { + if (origin === 'remote') return; + + const changedClients = Object.values(changes).reduce((res, cur) => + res.concat(cur) + ); + + assertExists(this.awareness); + const update = encodeAwarenessUpdate(this.awareness, changedClients); + this.ws.send( + JSON.stringify({ + channel: 'awareness', + payload: { + type: 'update', + update: Array.from(update), + }, + } satisfies WebSocketMessage) + ); + }; + + private _onWebSocket = (event: MessageEvent<string>) => { + const data = JSON.parse(event.data) as WebSocketMessage; + + if (data.channel !== 'awareness') return; + const { type } = data.payload; + + if (type === 'update') { + const update = data.payload.update; + assertExists(this.awareness); + applyAwarenessUpdate(this.awareness, new Uint8Array(update), 'remote'); + } + + if (type === 'connect') { + assertExists(this.awareness); + this.ws.send( + JSON.stringify({ + channel: 'awareness', + payload: { + type: 'update', + update: Array.from( + encodeAwarenessUpdate(this.awareness, [this.awareness.clientID]) + ), + }, + } satisfies WebSocketMessage) + ); + } + }; + + awareness: Awareness | null = null; + + constructor(readonly ws: WebSocket) {} + + connect(awareness: Awareness): void { + this.awareness = awareness; + awareness.on('update', this._onAwareness); + + this.ws.addEventListener('message', this._onWebSocket); + this.ws.send( + JSON.stringify({ + channel: 'awareness', + payload: { + type: 'connect', + }, + } satisfies WebSocketMessage) + ); + } + + disconnect(): void { + this.awareness?.off('update', this._onAwareness); + this.ws.close(); + } +} diff --git a/blocksuite/playground/apps/_common/sync/websocket/doc.ts b/blocksuite/playground/apps/_common/sync/websocket/doc.ts new file mode 100644 index 0000000000..a7fdd73f77 --- /dev/null +++ b/blocksuite/playground/apps/_common/sync/websocket/doc.ts @@ -0,0 +1,103 @@ +import { assertExists } from '@blocksuite/global/utils'; +import type { DocSource } from '@blocksuite/sync'; +import { diffUpdate, encodeStateVectorFromUpdate, mergeUpdates } from 'yjs'; + +import type { WebSocketMessage } from './types'; + +export class WebSocketDocSource implements DocSource { + private _onMessage = (event: MessageEvent<string>) => { + const data = JSON.parse(event.data) as WebSocketMessage; + + if (data.channel !== 'doc') return; + + if (data.payload.type === 'init') { + for (const [docId, data] of this.docMap) { + this.ws.send( + JSON.stringify({ + channel: 'doc', + payload: { + type: 'update', + docId, + updates: Array.from(data), + }, + } satisfies WebSocketMessage) + ); + } + return; + } + + const { docId, updates } = data.payload; + const update = this.docMap.get(docId); + if (update) { + this.docMap.set(docId, mergeUpdates([update, new Uint8Array(updates)])); + } else { + this.docMap.set(docId, new Uint8Array(updates)); + } + }; + + docMap = new Map<string, Uint8Array>(); + + name = 'websocket'; + + constructor(readonly ws: WebSocket) { + this.ws.addEventListener('message', this._onMessage); + + this.ws.send( + JSON.stringify({ + channel: 'doc', + payload: { + type: 'init', + }, + } satisfies WebSocketMessage) + ); + } + + pull(docId: string, state: Uint8Array) { + const update = this.docMap.get(docId); + if (!update) return null; + + const diff = state.length ? diffUpdate(update, state) : update; + return { data: diff, state: encodeStateVectorFromUpdate(update) }; + } + + push(docId: string, data: Uint8Array) { + const update = this.docMap.get(docId); + if (update) { + this.docMap.set(docId, mergeUpdates([update, data])); + } else { + this.docMap.set(docId, data); + } + + const latest = this.docMap.get(docId); + assertExists(latest); + this.ws.send( + JSON.stringify({ + channel: 'doc', + payload: { + type: 'update', + docId, + updates: Array.from(latest), + }, + } satisfies WebSocketMessage) + ); + } + + subscribe(cb: (docId: string, data: Uint8Array) => void) { + const abortController = new AbortController(); + this.ws.addEventListener( + 'message', + (event: MessageEvent<string>) => { + const data = JSON.parse(event.data) as WebSocketMessage; + + if (data.channel !== 'doc' || data.payload.type !== 'update') return; + + const { docId, updates } = data.payload; + cb(docId, new Uint8Array(updates)); + }, + { signal: abortController.signal } + ); + return () => { + abortController.abort(); + }; + } +} diff --git a/blocksuite/playground/apps/_common/sync/websocket/types.ts b/blocksuite/playground/apps/_common/sync/websocket/types.ts new file mode 100644 index 0000000000..416061ec72 --- /dev/null +++ b/blocksuite/playground/apps/_common/sync/websocket/types.ts @@ -0,0 +1,19 @@ +export type AwarenessMessage = { + channel: 'awareness'; + payload: { type: 'connect' } | { type: 'update'; update: number[] }; +}; + +export type DocMessage = { + channel: 'doc'; + payload: + | { + type: 'init'; + } + | { + type: 'update'; + docId: string; + updates: number[]; + }; +}; + +export type WebSocketMessage = AwarenessMessage | DocMessage; diff --git a/blocksuite/playground/apps/_common/sync/websocket/utils.ts b/blocksuite/playground/apps/_common/sync/websocket/utils.ts new file mode 100644 index 0000000000..47f16db6b8 --- /dev/null +++ b/blocksuite/playground/apps/_common/sync/websocket/utils.ts @@ -0,0 +1,8 @@ +const BASE_URL = new URL(import.meta.env.PLAYGROUND_SERVER); +export async function generateRoomId(): Promise<string> { + return fetch(new URL('/room/', BASE_URL), { + method: 'post', + }) + .then(res => res.json()) + .then(({ id }) => id); +} diff --git a/blocksuite/playground/apps/_common/templates/stickers.ts b/blocksuite/playground/apps/_common/templates/stickers.ts new file mode 100644 index 0000000000..707d75d17b --- /dev/null +++ b/blocksuite/playground/apps/_common/templates/stickers.ts @@ -0,0 +1,1082 @@ +export default [ + { + preview: + 'https://cdn.affine.pro/cdn-cgi/image/width=80,quality=80/templates/stickers/1.png', + type: 'sticker', + assets: { + 'jnpiF-qH-9NYu1WEx_asdmPfIBVf_Y5AX64yq-Jvbic=': + 'https://cdn.affine.pro/templates/stickers/1.png', + }, + content: { + type: 'page', + meta: { + id: 'doc:home', + title: 'Sticker', + createDate: 1701765881935, + tags: [], + }, + blocks: { + type: 'block', + id: 'block:1VxnfD_8xb', + flavour: 'affine:page', + props: { + title: { + '$blocksuite:internal:text$': true, + delta: [ + { + insert: 'Sticker', + }, + ], + }, + }, + children: [ + { + type: 'block', + id: 'block:pcmYJQ63hX', + flavour: 'affine:surface', + props: { + elements: {}, + }, + children: [ + { + type: 'block', + id: 'block:N24al1Qgl7', + flavour: 'affine:image', + props: { + caption: '', + sourceId: 'jnpiF-qH-9NYu1WEx_asdmPfIBVf_Y5AX64yq-Jvbic=', + width: 0, + height: 0, + index: 'b0D', + xywh: '[0,0,460,430]', + rotate: 0, + }, + children: [], + }, + ], + }, + ], + }, + }, + }, + { + preview: + 'https://cdn.affine.pro/cdn-cgi/image/width=80,quality=80/templates/stickers/2.png', + type: 'sticker', + assets: { + 'JwGgaM_IRE2A9RLtMk8KKbyHAz1rQ3SxnCmNt5CI0Q4=': + 'https://cdn.affine.pro/templates/stickers/2.png', + }, + content: { + type: 'page', + meta: { + id: 'doc:home', + title: 'Sticker', + createDate: 1701765881935, + tags: [], + }, + blocks: { + type: 'block', + id: 'block:1VxnfD_8xb', + flavour: 'affine:page', + props: { + title: { + '$blocksuite:internal:text$': true, + delta: [ + { + insert: 'Sticker', + }, + ], + }, + }, + children: [ + { + type: 'block', + id: 'block:pcmYJQ63hX', + flavour: 'affine:surface', + props: { + elements: {}, + }, + children: [ + { + type: 'block', + id: 'block:N24al1Qgl7', + flavour: 'affine:image', + props: { + caption: '', + sourceId: 'JwGgaM_IRE2A9RLtMk8KKbyHAz1rQ3SxnCmNt5CI0Q4=', + width: 0, + height: 0, + index: 'b0D', + xywh: '[0,0,460,430]', + rotate: 0, + }, + children: [], + }, + ], + }, + ], + }, + }, + }, + { + preview: + 'https://cdn.affine.pro/cdn-cgi/image/width=80,quality=80/templates/stickers/3.png', + type: 'sticker', + assets: { + '9UO31nxq6_RN2F__dx__Rp4UbhmmLDz1Nfz_WdnKOXI=': + 'https://cdn.affine.pro/templates/stickers/3.png', + }, + content: { + type: 'page', + meta: { + id: 'doc:home', + title: 'Sticker', + createDate: 1701765881935, + tags: [], + }, + blocks: { + type: 'block', + id: 'block:1VxnfD_8xb', + flavour: 'affine:page', + props: { + title: { + '$blocksuite:internal:text$': true, + delta: [ + { + insert: 'Sticker', + }, + ], + }, + }, + children: [ + { + type: 'block', + id: 'block:pcmYJQ63hX', + flavour: 'affine:surface', + props: { + elements: {}, + }, + children: [ + { + type: 'block', + id: 'block:N24al1Qgl7', + flavour: 'affine:image', + props: { + caption: '', + sourceId: '9UO31nxq6_RN2F__dx__Rp4UbhmmLDz1Nfz_WdnKOXI=', + width: 0, + height: 0, + index: 'b0D', + xywh: '[0,0,460,430]', + rotate: 0, + }, + children: [], + }, + ], + }, + ], + }, + }, + }, + { + preview: + 'https://cdn.affine.pro/cdn-cgi/image/width=80,quality=80/templates/stickers/4.png', + type: 'sticker', + assets: { + 'lbL16jkIuhQw9gHkalgBT2Bih2AyKOq75NJpE5lZQ_g=': + 'https://cdn.affine.pro/templates/stickers/4.png', + }, + content: { + type: 'page', + meta: { + id: 'doc:home', + title: 'Sticker', + createDate: 1701765881935, + tags: [], + }, + blocks: { + type: 'block', + id: 'block:1VxnfD_8xb', + flavour: 'affine:page', + props: { + title: { + '$blocksuite:internal:text$': true, + delta: [ + { + insert: 'Sticker', + }, + ], + }, + }, + children: [ + { + type: 'block', + id: 'block:pcmYJQ63hX', + flavour: 'affine:surface', + props: { + elements: {}, + }, + children: [ + { + type: 'block', + id: 'block:N24al1Qgl7', + flavour: 'affine:image', + props: { + caption: '', + sourceId: 'lbL16jkIuhQw9gHkalgBT2Bih2AyKOq75NJpE5lZQ_g=', + width: 0, + height: 0, + index: 'b0D', + xywh: '[0,0,460,430]', + rotate: 0, + }, + children: [], + }, + ], + }, + ], + }, + }, + }, + { + preview: + 'https://cdn.affine.pro/cdn-cgi/image/width=80,quality=80/templates/stickers/5.png', + type: 'sticker', + assets: { + 'kwZRDukIBDUV5ixvz5iT3_WExlc4H5hCrDLZxxgQEu8=': + 'https://cdn.affine.pro/templates/stickers/5.png', + }, + content: { + type: 'page', + meta: { + id: 'doc:home', + title: 'Sticker', + createDate: 1701765881935, + tags: [], + }, + blocks: { + type: 'block', + id: 'block:1VxnfD_8xb', + flavour: 'affine:page', + props: { + title: { + '$blocksuite:internal:text$': true, + delta: [ + { + insert: 'Sticker', + }, + ], + }, + }, + children: [ + { + type: 'block', + id: 'block:pcmYJQ63hX', + flavour: 'affine:surface', + props: { + elements: {}, + }, + children: [ + { + type: 'block', + id: 'block:N24al1Qgl7', + flavour: 'affine:image', + props: { + caption: '', + sourceId: 'kwZRDukIBDUV5ixvz5iT3_WExlc4H5hCrDLZxxgQEu8=', + width: 0, + height: 0, + index: 'b0D', + xywh: '[0,0,460,430]', + rotate: 0, + }, + children: [], + }, + ], + }, + ], + }, + }, + }, + { + preview: + 'https://cdn.affine.pro/cdn-cgi/image/width=80,quality=80/templates/stickers/6.png', + type: 'sticker', + assets: { + 'Kc9ZAVaolBXfSTZu94gJH5OAbwdwtP7TaIKmWBhEeYo=': + 'https://cdn.affine.pro/templates/stickers/6.png', + }, + content: { + type: 'page', + meta: { + id: 'doc:home', + title: 'Sticker', + createDate: 1701765881935, + tags: [], + }, + blocks: { + type: 'block', + id: 'block:1VxnfD_8xb', + flavour: 'affine:page', + props: { + title: { + '$blocksuite:internal:text$': true, + delta: [ + { + insert: 'Sticker', + }, + ], + }, + }, + children: [ + { + type: 'block', + id: 'block:pcmYJQ63hX', + flavour: 'affine:surface', + props: { + elements: {}, + }, + children: [ + { + type: 'block', + id: 'block:N24al1Qgl7', + flavour: 'affine:image', + props: { + caption: '', + sourceId: 'Kc9ZAVaolBXfSTZu94gJH5OAbwdwtP7TaIKmWBhEeYo=', + width: 0, + height: 0, + index: 'b0D', + xywh: '[0,0,460,430]', + rotate: 0, + }, + children: [], + }, + ], + }, + ], + }, + }, + }, + { + preview: + 'https://cdn.affine.pro/cdn-cgi/image/width=80,quality=80/templates/stickers/7.png', + type: 'sticker', + assets: { + 'vY51WmaGESNrWFGsZ8NQ5lembXapx9epsP-ZuOugGvk=': + 'https://cdn.affine.pro/templates/stickers/7.png', + }, + content: { + type: 'page', + meta: { + id: 'doc:home', + title: 'Sticker', + createDate: 1701765881935, + tags: [], + }, + blocks: { + type: 'block', + id: 'block:1VxnfD_8xb', + flavour: 'affine:page', + props: { + title: { + '$blocksuite:internal:text$': true, + delta: [ + { + insert: 'Sticker', + }, + ], + }, + }, + children: [ + { + type: 'block', + id: 'block:pcmYJQ63hX', + flavour: 'affine:surface', + props: { + elements: {}, + }, + children: [ + { + type: 'block', + id: 'block:N24al1Qgl7', + flavour: 'affine:image', + props: { + caption: '', + sourceId: 'vY51WmaGESNrWFGsZ8NQ5lembXapx9epsP-ZuOugGvk=', + width: 0, + height: 0, + index: 'b0D', + xywh: '[0,0,460,430]', + rotate: 0, + }, + children: [], + }, + ], + }, + ], + }, + }, + }, + { + preview: + 'https://cdn.affine.pro/cdn-cgi/image/width=80,quality=80/templates/stickers/8.png', + type: 'sticker', + assets: { + 'YuPd_NI22xNMhfm3GHSonetlA9pebkl-z1fsocwM638=': + 'https://cdn.affine.pro/templates/stickers/8.png', + }, + content: { + type: 'page', + meta: { + id: 'doc:home', + title: 'Sticker', + createDate: 1701765881935, + tags: [], + }, + blocks: { + type: 'block', + id: 'block:1VxnfD_8xb', + flavour: 'affine:page', + props: { + title: { + '$blocksuite:internal:text$': true, + delta: [ + { + insert: 'Sticker', + }, + ], + }, + }, + children: [ + { + type: 'block', + id: 'block:pcmYJQ63hX', + flavour: 'affine:surface', + props: { + elements: {}, + }, + children: [ + { + type: 'block', + id: 'block:N24al1Qgl7', + flavour: 'affine:image', + props: { + caption: '', + sourceId: 'YuPd_NI22xNMhfm3GHSonetlA9pebkl-z1fsocwM638=', + width: 0, + height: 0, + index: 'b0D', + xywh: '[0,0,460,430]', + rotate: 0, + }, + children: [], + }, + ], + }, + ], + }, + }, + }, + { + preview: + 'https://cdn.affine.pro/cdn-cgi/image/width=80,quality=80/templates/stickers/9.png', + type: 'sticker', + assets: { + '6upuFIKSmrBV8pYRoioRM2jJr6W9-dh-zdMTECor4hk=': + 'https://cdn.affine.pro/templates/stickers/9.png', + }, + content: { + type: 'page', + meta: { + id: 'doc:home', + title: 'Sticker', + createDate: 1701765881935, + tags: [], + }, + blocks: { + type: 'block', + id: 'block:1VxnfD_8xb', + flavour: 'affine:page', + props: { + title: { + '$blocksuite:internal:text$': true, + delta: [ + { + insert: 'Sticker', + }, + ], + }, + }, + children: [ + { + type: 'block', + id: 'block:pcmYJQ63hX', + flavour: 'affine:surface', + props: { + elements: {}, + }, + children: [ + { + type: 'block', + id: 'block:N24al1Qgl7', + flavour: 'affine:image', + props: { + caption: '', + sourceId: '6upuFIKSmrBV8pYRoioRM2jJr6W9-dh-zdMTECor4hk=', + width: 0, + height: 0, + index: 'b0D', + xywh: '[0,0,460,430]', + rotate: 0, + }, + children: [], + }, + ], + }, + ], + }, + }, + }, + { + preview: + 'https://cdn.affine.pro/cdn-cgi/image/width=80,quality=80/templates/stickers/10.png', + type: 'sticker', + assets: { + 'WTCWRS4fVWExlws9Sgfw6mNkM5tUcsUkDta6cITV5as=': + 'https://cdn.affine.pro/templates/stickers/10.png', + }, + content: { + type: 'page', + meta: { + id: 'doc:home', + title: 'Sticker', + createDate: 1701765881935, + tags: [], + }, + blocks: { + type: 'block', + id: 'block:1VxnfD_8xb', + flavour: 'affine:page', + props: { + title: { + '$blocksuite:internal:text$': true, + delta: [ + { + insert: 'Sticker', + }, + ], + }, + }, + children: [ + { + type: 'block', + id: 'block:pcmYJQ63hX', + flavour: 'affine:surface', + props: { + elements: {}, + }, + children: [ + { + type: 'block', + id: 'block:N24al1Qgl7', + flavour: 'affine:image', + props: { + caption: '', + sourceId: 'WTCWRS4fVWExlws9Sgfw6mNkM5tUcsUkDta6cITV5as=', + width: 0, + height: 0, + index: 'b0D', + xywh: '[0,0,460,430]', + rotate: 0, + }, + children: [], + }, + ], + }, + ], + }, + }, + }, + { + preview: + 'https://cdn.affine.pro/cdn-cgi/image/width=80,quality=80/templates/stickers/11.png', + type: 'sticker', + assets: { + 'hrclQO6EpB6VTJER_N_LkR9P_cK-Ckw0S10x6jf2Btk=': + 'https://cdn.affine.pro/templates/stickers/11.png', + }, + content: { + type: 'page', + meta: { + id: 'doc:home', + title: 'Sticker', + createDate: 1701765881935, + tags: [], + }, + blocks: { + type: 'block', + id: 'block:1VxnfD_8xb', + flavour: 'affine:page', + props: { + title: { + '$blocksuite:internal:text$': true, + delta: [ + { + insert: 'Sticker', + }, + ], + }, + }, + children: [ + { + type: 'block', + id: 'block:pcmYJQ63hX', + flavour: 'affine:surface', + props: { + elements: {}, + }, + children: [ + { + type: 'block', + id: 'block:N24al1Qgl7', + flavour: 'affine:image', + props: { + caption: '', + sourceId: 'hrclQO6EpB6VTJER_N_LkR9P_cK-Ckw0S10x6jf2Btk=', + width: 0, + height: 0, + index: 'b0D', + xywh: '[0,0,460,430]', + rotate: 0, + }, + children: [], + }, + ], + }, + ], + }, + }, + }, + { + preview: + 'https://cdn.affine.pro/cdn-cgi/image/width=80,quality=80/templates/stickers/12.png', + type: 'sticker', + assets: { + 'WWiNfo_E9g7nllIi9BfvNbJHInhwl5vG26LU56hvtJE=': + 'https://cdn.affine.pro/templates/stickers/12.png', + }, + content: { + type: 'page', + meta: { + id: 'doc:home', + title: 'Sticker', + createDate: 1701765881935, + tags: [], + }, + blocks: { + type: 'block', + id: 'block:1VxnfD_8xb', + flavour: 'affine:page', + props: { + title: { + '$blocksuite:internal:text$': true, + delta: [ + { + insert: 'Sticker', + }, + ], + }, + }, + children: [ + { + type: 'block', + id: 'block:pcmYJQ63hX', + flavour: 'affine:surface', + props: { + elements: {}, + }, + children: [ + { + type: 'block', + id: 'block:N24al1Qgl7', + flavour: 'affine:image', + props: { + caption: '', + sourceId: 'WWiNfo_E9g7nllIi9BfvNbJHInhwl5vG26LU56hvtJE=', + width: 0, + height: 0, + index: 'b0D', + xywh: '[0,0,460,430]', + rotate: 0, + }, + children: [], + }, + ], + }, + ], + }, + }, + }, + { + preview: + 'https://cdn.affine.pro/cdn-cgi/image/width=80,quality=80/templates/stickers/13.png', + type: 'sticker', + assets: { + '4KbnQ1P38nUux0Tf2fdGqc909fVzGz8PJVuvuSVvnzc=': + 'https://cdn.affine.pro/templates/stickers/13.png', + }, + content: { + type: 'page', + meta: { + id: 'doc:home', + title: 'Sticker', + createDate: 1701765881935, + tags: [], + }, + blocks: { + type: 'block', + id: 'block:1VxnfD_8xb', + flavour: 'affine:page', + props: { + title: { + '$blocksuite:internal:text$': true, + delta: [ + { + insert: 'Sticker', + }, + ], + }, + }, + children: [ + { + type: 'block', + id: 'block:pcmYJQ63hX', + flavour: 'affine:surface', + props: { + elements: {}, + }, + children: [ + { + type: 'block', + id: 'block:N24al1Qgl7', + flavour: 'affine:image', + props: { + caption: '', + sourceId: '4KbnQ1P38nUux0Tf2fdGqc909fVzGz8PJVuvuSVvnzc=', + width: 0, + height: 0, + index: 'b0D', + xywh: '[0,0,460,430]', + rotate: 0, + }, + children: [], + }, + ], + }, + ], + }, + }, + }, + { + preview: + 'https://cdn.affine.pro/cdn-cgi/image/width=80,quality=80/templates/stickers/14.png', + type: 'sticker', + assets: { + '9-e8kW6m8ps6c5UAL4gp6na1xtld40LB9D9y-UxDMHM=': + 'https://cdn.affine.pro/templates/stickers/14.png', + }, + content: { + type: 'page', + meta: { + id: 'doc:home', + title: 'Sticker', + createDate: 1701765881935, + tags: [], + }, + blocks: { + type: 'block', + id: 'block:1VxnfD_8xb', + flavour: 'affine:page', + props: { + title: { + '$blocksuite:internal:text$': true, + delta: [ + { + insert: 'Sticker', + }, + ], + }, + }, + children: [ + { + type: 'block', + id: 'block:pcmYJQ63hX', + flavour: 'affine:surface', + props: { + elements: {}, + }, + children: [ + { + type: 'block', + id: 'block:N24al1Qgl7', + flavour: 'affine:image', + props: { + caption: '', + sourceId: '9-e8kW6m8ps6c5UAL4gp6na1xtld40LB9D9y-UxDMHM=', + width: 0, + height: 0, + index: 'b0D', + xywh: '[0,0,460,430]', + rotate: 0, + }, + children: [], + }, + ], + }, + ], + }, + }, + }, + { + preview: + 'https://cdn.affine.pro/cdn-cgi/image/width=80,quality=80/templates/stickers/15.png', + type: 'sticker', + assets: { + 'wda7t68nS-GZ5nFvtizlZGznECDe__F86Kcz5a5FAWk=': + 'https://cdn.affine.pro/templates/stickers/15.png', + }, + content: { + type: 'page', + meta: { + id: 'doc:home', + title: 'Sticker', + createDate: 1701765881935, + tags: [], + }, + blocks: { + type: 'block', + id: 'block:1VxnfD_8xb', + flavour: 'affine:page', + props: { + title: { + '$blocksuite:internal:text$': true, + delta: [ + { + insert: 'Sticker', + }, + ], + }, + }, + children: [ + { + type: 'block', + id: 'block:pcmYJQ63hX', + flavour: 'affine:surface', + props: { + elements: {}, + }, + children: [ + { + type: 'block', + id: 'block:N24al1Qgl7', + flavour: 'affine:image', + props: { + caption: '', + sourceId: 'wda7t68nS-GZ5nFvtizlZGznECDe__F86Kcz5a5FAWk=', + width: 0, + height: 0, + index: 'b0D', + xywh: '[0,0,460,430]', + rotate: 0, + }, + children: [], + }, + ], + }, + ], + }, + }, + }, + { + preview: + 'https://cdn.affine.pro/cdn-cgi/image/width=80,quality=80/templates/stickers/16.png', + type: 'sticker', + assets: { + 'cQF0iqkB21MBBJtwCUqb48hbsPpiZ3vZK7YtNXKmos0=': + 'https://cdn.affine.pro/templates/stickers/16.png', + }, + content: { + type: 'page', + meta: { + id: 'doc:home', + title: 'Sticker', + createDate: 1701765881935, + tags: [], + }, + blocks: { + type: 'block', + id: 'block:1VxnfD_8xb', + flavour: 'affine:page', + props: { + title: { + '$blocksuite:internal:text$': true, + delta: [ + { + insert: 'Sticker', + }, + ], + }, + }, + children: [ + { + type: 'block', + id: 'block:pcmYJQ63hX', + flavour: 'affine:surface', + props: { + elements: {}, + }, + children: [ + { + type: 'block', + id: 'block:N24al1Qgl7', + flavour: 'affine:image', + props: { + caption: '', + sourceId: 'cQF0iqkB21MBBJtwCUqb48hbsPpiZ3vZK7YtNXKmos0=', + width: 0, + height: 0, + index: 'b0D', + xywh: '[0,0,460,430]', + rotate: 0, + }, + children: [], + }, + ], + }, + ], + }, + }, + }, + { + preview: + 'https://cdn.affine.pro/cdn-cgi/image/width=80,quality=80/templates/stickers/17.png', + type: 'sticker', + assets: { + 'pmPMoebd5Z2h2cblWB_TxEw4C5VKdVc0r4BDzMu5Jrw=': + 'https://cdn.affine.pro/templates/stickers/17.png', + }, + content: { + type: 'page', + meta: { + id: 'doc:home', + title: 'Sticker', + createDate: 1701765881935, + tags: [], + }, + blocks: { + type: 'block', + id: 'block:1VxnfD_8xb', + flavour: 'affine:page', + props: { + title: { + '$blocksuite:internal:text$': true, + delta: [ + { + insert: 'Sticker', + }, + ], + }, + }, + children: [ + { + type: 'block', + id: 'block:pcmYJQ63hX', + flavour: 'affine:surface', + props: { + elements: {}, + }, + children: [ + { + type: 'block', + id: 'block:N24al1Qgl7', + flavour: 'affine:image', + props: { + caption: '', + sourceId: 'pmPMoebd5Z2h2cblWB_TxEw4C5VKdVc0r4BDzMu5Jrw=', + width: 0, + height: 0, + index: 'b0D', + xywh: '[0,0,460,430]', + rotate: 0, + }, + children: [], + }, + ], + }, + ], + }, + }, + }, + { + preview: + 'https://cdn.affine.pro/cdn-cgi/image/width=80,quality=80/templates/stickers/18.png', + type: 'sticker', + assets: { + 'KN7FRbU7RrtqrgjWE3YX_vTnb3NfsSWWbosjoc2GvJI=': + 'https://cdn.affine.pro/templates/stickers/18.png', + }, + content: { + type: 'page', + meta: { + id: 'doc:home', + title: 'Sticker', + createDate: 1701765881935, + tags: [], + }, + blocks: { + type: 'block', + id: 'block:1VxnfD_8xb', + flavour: 'affine:page', + props: { + title: { + '$blocksuite:internal:text$': true, + delta: [ + { + insert: 'Sticker', + }, + ], + }, + }, + children: [ + { + type: 'block', + id: 'block:pcmYJQ63hX', + flavour: 'affine:surface', + props: { + elements: {}, + }, + children: [ + { + type: 'block', + id: 'block:N24al1Qgl7', + flavour: 'affine:image', + props: { + caption: '', + sourceId: 'KN7FRbU7RrtqrgjWE3YX_vTnb3NfsSWWbosjoc2GvJI=', + width: 0, + height: 0, + index: 'b0D', + xywh: '[0,0,460,430]', + rotate: 0, + }, + children: [], + }, + ], + }, + ], + }, + }, + }, +]; diff --git a/blocksuite/playground/apps/default/main.ts b/blocksuite/playground/apps/default/main.ts new file mode 100644 index 0000000000..a23da626d0 --- /dev/null +++ b/blocksuite/playground/apps/default/main.ts @@ -0,0 +1,27 @@ +import '../../style.css'; +import '../dev-format.js'; + +import { effects as blocksEffects } from '@blocksuite/blocks/effects'; +import { effects as presetsEffects } from '@blocksuite/presets/effects'; + +import { setupEdgelessTemplate } from '../_common/setup.js'; +import { + createDefaultDocCollection, + initDefaultDocCollection, +} from './utils/collection.js'; +import { mountDefaultDocEditor } from './utils/editor.js'; + +blocksEffects(); +presetsEffects(); + +async function main() { + if (window.collection) return; + + setupEdgelessTemplate(); + + const collection = await createDefaultDocCollection(); + await initDefaultDocCollection(collection); + await mountDefaultDocEditor(collection); +} + +main().catch(console.error); diff --git a/blocksuite/playground/apps/default/specs-examples/custom-attachment/custom-attachment.ts b/blocksuite/playground/apps/default/specs-examples/custom-attachment/custom-attachment.ts new file mode 100644 index 0000000000..4e96051aff --- /dev/null +++ b/blocksuite/playground/apps/default/specs-examples/custom-attachment/custom-attachment.ts @@ -0,0 +1,50 @@ +import { + BlockFlavourIdentifier, + BlockServiceIdentifier, + type ExtensionType, + StdIdentifier, +} from '@blocksuite/block-std'; +import { + AttachmentBlockService, + EdgelessEditorBlockSpecs, + PageEditorBlockSpecs, +} from '@blocksuite/blocks'; + +class CustomAttachmentBlockService extends AttachmentBlockService { + override mounted(): void { + super.mounted(); + this.maxFileSize = 100 * 1000 * 1000; // 100MB + } +} + +export function getCustomAttachmentSpecs() { + const pageModeSpecs: ExtensionType[] = [ + ...PageEditorBlockSpecs, + { + setup: di => { + di.override( + BlockServiceIdentifier('affine:attachment'), + CustomAttachmentBlockService, + [StdIdentifier, BlockFlavourIdentifier('affine:attachment')] + ); + }, + }, + ]; + const edgelessModeSpecs: ExtensionType[] = [ + ...EdgelessEditorBlockSpecs, + { + setup: di => { + di.override( + BlockServiceIdentifier('affine:attachment'), + CustomAttachmentBlockService, + [StdIdentifier, BlockFlavourIdentifier('affine:attachment')] + ); + }, + }, + ]; + + return { + pageModeSpecs, + edgelessModeSpecs, + }; +} diff --git a/blocksuite/playground/apps/default/specs-examples/index.ts b/blocksuite/playground/apps/default/specs-examples/index.ts new file mode 100644 index 0000000000..d68a3a07f8 --- /dev/null +++ b/blocksuite/playground/apps/default/specs-examples/index.ts @@ -0,0 +1,26 @@ +import { + EdgelessEditorBlockSpecs, + PageEditorBlockSpecs, +} from '@blocksuite/blocks'; + +import { getCustomAttachmentSpecs } from './custom-attachment/custom-attachment.js'; + +const params = new URLSearchParams(location.search); + +export function getExampleSpecs() { + const type = params.get('exampleSpec'); + + let pageModeSpecs = PageEditorBlockSpecs; + let edgelessModeSpecs = EdgelessEditorBlockSpecs; + + if (type === 'attachment') { + const specs = getCustomAttachmentSpecs(); + pageModeSpecs = specs.pageModeSpecs; + edgelessModeSpecs = specs.edgelessModeSpecs; + } + + return { + pageModeSpecs, + edgelessModeSpecs, + }; +} diff --git a/blocksuite/playground/apps/default/utils/collection.ts b/blocksuite/playground/apps/default/utils/collection.ts new file mode 100644 index 0000000000..f1347cf424 --- /dev/null +++ b/blocksuite/playground/apps/default/utils/collection.ts @@ -0,0 +1,109 @@ +import { AffineSchemas } from '@blocksuite/blocks'; +import type { BlockSuiteFlags } from '@blocksuite/global/types'; +import { + DocCollection, + type DocCollectionOptions, + IdGeneratorType, + Job, + Schema, + Text, +} from '@blocksuite/store'; +import { + BroadcastChannelAwarenessSource, + BroadcastChannelDocSource, + IndexedDBBlobSource, + IndexedDBDocSource, +} from '@blocksuite/sync'; + +import { WebSocketAwarenessSource } from '../../_common/sync/websocket/awareness'; +import { WebSocketDocSource } from '../../_common/sync/websocket/doc'; + +const BASE_WEBSOCKET_URL = new URL(import.meta.env.PLAYGROUND_WS); + +export async function createDefaultDocCollection() { + const idGenerator: IdGeneratorType = IdGeneratorType.NanoID; + const schema = new Schema(); + schema.register(AffineSchemas); + + const params = new URLSearchParams(location.search); + let docSources: DocCollectionOptions['docSources'] = { + main: new IndexedDBDocSource(), + }; + let awarenessSources: DocCollectionOptions['awarenessSources']; + const room = params.get('room'); + if (room) { + const ws = new WebSocket(new URL(`/room/${room}`, BASE_WEBSOCKET_URL)); + await new Promise((resolve, reject) => { + ws.addEventListener('open', resolve); + ws.addEventListener('error', reject); + }) + .then(() => { + docSources = { + main: new IndexedDBDocSource(), + shadows: [new WebSocketDocSource(ws)], + }; + awarenessSources = [new WebSocketAwarenessSource(ws)]; + }) + .catch(() => { + docSources = { + main: new IndexedDBDocSource(), + shadows: [new BroadcastChannelDocSource()], + }; + awarenessSources = [ + new BroadcastChannelAwarenessSource('collabPlayground'), + ]; + }); + } + + const flags: Partial<BlockSuiteFlags> = Object.fromEntries( + [...params.entries()] + .filter(([key]) => key.startsWith('enable_')) + .map(([k, v]) => [k, v === 'true']) + ); + + const options: DocCollectionOptions = { + id: 'collabPlayground', + schema, + idGenerator, + blobSources: { + main: new IndexedDBBlobSource('collabPlayground'), + }, + docSources, + awarenessSources, + defaultFlags: { + enable_synced_doc_block: true, + enable_pie_menu: true, + enable_lasso_tool: true, + enable_color_picker: true, + ...flags, + }, + }; + const collection = new DocCollection(options); + collection.start(); + + // debug info + window.collection = collection; + window.blockSchemas = AffineSchemas; + window.job = new Job({ collection }); + window.Y = DocCollection.Y; + + return collection; +} + +export async function initDefaultDocCollection(collection: DocCollection) { + const params = new URLSearchParams(location.search); + + await collection.waitForSynced(); + + const shouldInit = collection.docs.size === 0 && !params.get('room'); + if (shouldInit) { + collection.meta.initialize(); + const doc = collection.createDoc({ id: 'doc:home' }); + doc.load(); + const rootId = doc.addBlock('affine:page', { + title: new Text(), + }); + doc.addBlock('affine:surface', {}, rootId); + doc.resetHistory(); + } +} diff --git a/blocksuite/playground/apps/default/utils/editor.ts b/blocksuite/playground/apps/default/utils/editor.ts new file mode 100644 index 0000000000..a734ddab86 --- /dev/null +++ b/blocksuite/playground/apps/default/utils/editor.ts @@ -0,0 +1,141 @@ +/* eslint-disable @typescript-eslint/no-non-null-assertion */ +import type { EditorHost, ExtensionType } from '@blocksuite/block-std'; +import { + CommunityCanvasTextFonts, + DocModeExtension, + DocModeProvider, + FontConfigExtension, + GenerateDocUrlExtension, + GenerateDocUrlProvider, + NotificationExtension, + OverrideThemeExtension, + ParseDocUrlExtension, + RefNodeSlotsExtension, + RefNodeSlotsProvider, + SpecProvider, +} from '@blocksuite/blocks'; +import { AffineEditorContainer } from '@blocksuite/presets'; +import type { DocCollection } from '@blocksuite/store'; + +import { AttachmentViewerPanel } from '../../_common/components/attachment-viewer-panel.js'; +import { CollabDebugMenu } from '../../_common/components/collab-debug-menu.js'; +import { DocsPanel } from '../../_common/components/docs-panel.js'; +import { LeftSidePanel } from '../../_common/components/left-side-panel.js'; +import { + getDocFromUrlParams, + listenHashChange, + setDocModeFromUrlParams, +} from '../../_common/history.js'; +import { + mockDocModeService, + mockGenerateDocUrlService, + mockNotificationService, + mockParseDocUrlService, + mockPeekViewExtension, + themeExtension, +} from '../../_common/mock-services.js'; +import { getExampleSpecs } from '../specs-examples/index.js'; + +export async function mountDefaultDocEditor(collection: DocCollection) { + const app = document.getElementById('app'); + if (!app) return; + + const url = new URL(location.toString()); + const doc = getDocFromUrlParams(collection, url); + + const attachmentViewerPanel = new AttachmentViewerPanel(); + + const editor = new AffineEditorContainer(); + const specs = getExampleSpecs(); + const refNodeSlotsExtension = RefNodeSlotsExtension(); + editor.pageSpecs = patchPageRootSpec([ + refNodeSlotsExtension, + ...specs.pageModeSpecs, + ]); + editor.edgelessSpecs = patchPageRootSpec([ + refNodeSlotsExtension, + ...specs.edgelessModeSpecs, + ]); + + SpecProvider.getInstance().extendSpec('edgeless:preview', [ + OverrideThemeExtension(themeExtension), + ]); + editor.doc = doc; + editor.mode = 'page'; + editor.std + .get(RefNodeSlotsProvider) + .docLinkClicked.on(({ pageId: docId }) => { + const target = collection.getDoc(docId); + if (!target) { + throw new Error(`Failed to jump to doc ${docId}`); + } + + const url = editor.std + .get(GenerateDocUrlProvider) + .generateDocUrl(target.id); + if (url) history.pushState({}, '', url); + + target.load(); + editor.doc = target; + }); + + app.append(editor); + await editor.updateComplete; + const modeService = editor.host!.std.get(DocModeProvider); + editor.mode = modeService.getPrimaryMode(doc.id); + setDocModeFromUrlParams(modeService, url.searchParams, doc.id); + editor.slots.docUpdated.on(({ newDocId }) => { + editor.mode = modeService.getPrimaryMode(newDocId); + }); + + const leftSidePanel = new LeftSidePanel(); + + const docsPanel = new DocsPanel(); + docsPanel.editor = editor; + + const collabDebugMenu = new CollabDebugMenu(); + collabDebugMenu.collection = collection; + collabDebugMenu.editor = editor; + collabDebugMenu.leftSidePanel = leftSidePanel; + collabDebugMenu.docsPanel = docsPanel; + + document.body.append(attachmentViewerPanel); + document.body.append(leftSidePanel); + document.body.append(collabDebugMenu); + + // debug info + window.editor = editor; + window.doc = doc; + Object.defineProperty(globalThis, 'host', { + get() { + return document.querySelector<EditorHost>('editor-host'); + }, + }); + Object.defineProperty(globalThis, 'std', { + get() { + return document.querySelector<EditorHost>('editor-host')?.std; + }, + }); + + listenHashChange(collection, editor, docsPanel); + + return editor; + + function patchPageRootSpec(spec: ExtensionType[]) { + const setEditorModeCallBack = editor.switchEditor.bind(editor); + const getEditorModeCallback = () => editor.mode; + const newSpec: typeof spec = [ + ...spec, + DocModeExtension( + mockDocModeService(getEditorModeCallback, setEditorModeCallBack) + ), + OverrideThemeExtension(themeExtension), + ParseDocUrlExtension(mockParseDocUrlService(collection)), + GenerateDocUrlExtension(mockGenerateDocUrlService(collection)), + NotificationExtension(mockNotificationService(editor)), + FontConfigExtension(CommunityCanvasTextFonts), + mockPeekViewExtension(attachmentViewerPanel), + ]; + return newSpec; + } +} diff --git a/blocksuite/playground/apps/default/utils/notify.ts b/blocksuite/playground/apps/default/utils/notify.ts new file mode 100644 index 0000000000..3614012195 --- /dev/null +++ b/blocksuite/playground/apps/default/utils/notify.ts @@ -0,0 +1,26 @@ +function escapeHtml(html: string) { + const div = document.createElement('div'); + div.textContent = html; + return div.innerHTML; +} + +// Custom function to emit toast notifications +export function notify( + message: string, + variant: 'primary' | 'success' | 'neutral' | 'warning' | 'danger' = 'primary', + icon = 'info-circle', + duration = 2000 +) { + const alert = Object.assign(document.createElement('sl-alert'), { + variant, + closable: true, + duration: duration, + innerHTML: ` + <sl-icon name="${icon}" slot="icon"></sl-icon> + ${escapeHtml(message)} + `, + }); + + document.body.append(alert); + return alert.toast(); +} diff --git a/blocksuite/playground/apps/dev-format.ts b/blocksuite/playground/apps/dev-format.ts new file mode 100644 index 0000000000..250ce5e979 --- /dev/null +++ b/blocksuite/playground/apps/dev-format.ts @@ -0,0 +1,60 @@ +import * as globalUtils from '@blocksuite/global/utils'; +import type { BlockModel } from '@blocksuite/store'; + +function toStyledEntry(key: string, value: unknown) { + return [ + ['span', { style: 'color: #c0c0c0' }, ` ${key}`], + ['span', { style: 'color: #fff' }, `: `], + ['span', { style: 'color: rgb(92, 213, 251)' }, `${JSON.stringify(value)}`], + ]; +} + +export const devtoolsFormatter: typeof window.devtoolsFormatters = [ + { + header: function (obj: unknown) { + if ('flavour' in (obj as BlockModel) && 'yBlock' in (obj as BlockModel)) { + globalUtils.assertType<BlockModel>(obj); + return [ + 'span', + { style: 'font-weight: bolder;' }, + ['span', { style: 'color: #fff' }, `Block {`], + ...toStyledEntry('flavour', obj.flavour), + ['span', { style: 'color: #fff' }, `,`], + ...toStyledEntry('id', obj.id), + ['span', { style: 'color: #fff' }, `}`], + ] as HTMLTemplate; + } + + return null; + }, + hasBody: (obj: unknown) => { + if ('flavour' in (obj as BlockModel) && 'yBlock' in (obj as BlockModel)) { + return true; + } + + return null; + }, + body: (obj: unknown) => { + if ('flavour' in (obj as BlockModel) && 'yBlock' in (obj as BlockModel)) { + globalUtils.assertType<BlockModel>(obj); + + // @ts-expect-error FIXME: ts error + const { props } = obj.page._blockTree.getBlock(obj.id)._parseYBlock(); + + const propsArr = Object.entries(props).flatMap(([key]) => { + return [ + // @ts-expect-error FIXME: ts error + ...toStyledEntry(key, obj[key]), + ['div', {}, ''], + ] as HTMLTemplate[]; + }); + + return ['div', { style: 'padding-left: 1em' }, ...propsArr]; + } + + return null; + }, + }, +]; + +window.devtoolsFormatters = devtoolsFormatter; diff --git a/blocksuite/playground/apps/env.d.ts b/blocksuite/playground/apps/env.d.ts new file mode 100644 index 0000000000..cb9cda53dd --- /dev/null +++ b/blocksuite/playground/apps/env.d.ts @@ -0,0 +1,35 @@ +import type { EditorHost } from '@blocksuite/block-std'; +import type { TestUtils } from '@blocksuite/blocks'; +import type { AffineEditorContainer } from '@blocksuite/presets'; +import type { BlockSchema, Doc, DocCollection, Job } from '@blocksuite/store'; +import type { z } from 'zod'; + +declare global { + type HTMLTemplate = [ + string, + Record<string, unknown>, + ...(HTMLTemplate | string)[], + ]; + + interface Window { + editor: AffineEditorContainer; + doc: Doc; + collection: DocCollection; + blockSchemas: z.infer<typeof BlockSchema>[]; + job: Job; + Y: typeof DocCollection.Y; + std: typeof std; + testUtils: TestUtils; + host: EditorHost; + testWorker: Worker; + + wsProvider: ReturnType<typeof setupBroadcastProvider>; + bcProvider: ReturnType<typeof setupBroadcastProvider>; + + devtoolsFormatters: { + header: (obj: unknown, config: unknown) => null | HTMLTemplate; + hasBody: (obj: unknown, config: unknown) => boolean | null; + body: (obj: unknown, config: unknown) => null | HTMLTemplate; + }[]; + } +} diff --git a/blocksuite/playground/apps/starter/data/affine-snapshot.ts b/blocksuite/playground/apps/starter/data/affine-snapshot.ts new file mode 100644 index 0000000000..1897181e96 --- /dev/null +++ b/blocksuite/playground/apps/starter/data/affine-snapshot.ts @@ -0,0 +1,21 @@ +import { ZipTransformer } from '@blocksuite/blocks'; +import { type DocCollection, Text } from '@blocksuite/store'; + +export async function affineSnapshot(collection: DocCollection, id: string) { + const doc = collection.createDoc({ id }); + doc.load(); + // Add root block and surface block at root level + const rootId = doc.addBlock('affine:page', { + title: new Text('Affine Snapshot Test'), + }); + doc.addBlock('affine:surface', {}, rootId); + + const path = '/apps/starter/data/snapshots/affine-default.zip'; + const response = await fetch(path); + const file = await response.blob(); + await ZipTransformer.importDocs(collection, file); +} + +affineSnapshot.id = 'affine-snapshot'; +affineSnapshot.displayName = 'Affine Snapshot Test'; +affineSnapshot.description = 'Affine Snapshot Test'; diff --git a/blocksuite/playground/apps/starter/data/database.ts b/blocksuite/playground/apps/starter/data/database.ts new file mode 100644 index 0000000000..69b1bfeaa4 --- /dev/null +++ b/blocksuite/playground/apps/starter/data/database.ts @@ -0,0 +1,160 @@ +import { + databaseBlockColumns, + type DatabaseBlockModel, + type ListType, + type ParagraphType, + type ViewBasicDataType, +} from '@blocksuite/blocks'; +import { viewPresets } from '@blocksuite/data-view/view-presets'; +import { assertExists } from '@blocksuite/global/utils'; +import { type DocCollection, Text } from '@blocksuite/store'; + +// eslint-disable-next-line @typescript-eslint/no-restricted-imports +import { propertyPresets } from '../../../../affine/data-view/src/property-presets'; +import type { InitFn } from './utils.js'; + +export const database: InitFn = (collection: DocCollection, id: string) => { + const doc = collection.createDoc({ id }); + doc.awarenessStore.setFlag('enable_database_number_formatting', true); + doc.awarenessStore.setFlag('enable_database_attachment_note', true); + doc.awarenessStore.setFlag('enable_database_full_width', true); + doc.awarenessStore.setFlag('enable_block_query', true); + + doc.load(() => { + // Add root block and surface block at root level + const rootId = doc.addBlock('affine:page', { + title: new Text('BlockSuite Playground'), + }); + doc.addBlock('affine:surface', {}, rootId); + + // Add note block inside root block + const noteId = doc.addBlock('affine:note', {}, rootId); + const pId = doc.addBlock('affine:paragraph', {}, noteId); + const model = doc.getBlockById(pId); + assertExists(model); + const addDatabase = (title: string, group = true) => { + const databaseId = doc.addBlock( + 'affine:database', + { + columns: [], + cells: {}, + }, + noteId + ); + + new Promise(resolve => requestAnimationFrame(resolve)) + .then(() => { + const service = window.host.std.getService('affine:database'); + if (!service) return; + service.initDatabaseBlock( + doc, + model, + databaseId, + viewPresets.tableViewMeta.type, + true + ); + const database = doc.getBlockById(databaseId) as DatabaseBlockModel; + database.title = new Text(title); + const richTextId = service.addColumn( + database, + 'end', + databaseBlockColumns.richTextColumnConfig.create( + databaseBlockColumns.richTextColumnConfig.config.name + ) + ); + Object.values([ + propertyPresets.multiSelectPropertyConfig, + propertyPresets.datePropertyConfig, + propertyPresets.numberPropertyConfig, + databaseBlockColumns.linkColumnConfig, + propertyPresets.checkboxPropertyConfig, + propertyPresets.progressPropertyConfig, + ]).forEach(column => { + service.addColumn( + database, + 'end', + column.create(column.config.name) + ); + }); + service.updateView(database, database.views[0].id, () => { + return { + groupBy: group + ? { + columnId: database.columns[1].id, + type: 'groupBy', + name: 'select', + } + : undefined, + } as Partial<ViewBasicDataType>; + }); + const paragraphTypes: ParagraphType[] = [ + 'text', + 'quote', + 'h1', + 'h2', + 'h3', + 'h4', + 'h5', + 'h6', + ]; + paragraphTypes.forEach(type => { + const id = doc.addBlock( + 'affine:paragraph', + { type: type, text: new Text(`Paragraph type ${type}`) }, + databaseId + ); + service.updateCell(database, id, { + columnId: richTextId, + value: new Text(`Paragraph type ${type}`), + }); + }); + const listTypes: ListType[] = [ + 'numbered', + 'bulleted', + 'todo', + 'toggle', + ]; + + listTypes.forEach(type => { + const id = doc.addBlock( + 'affine:list', + { type: type, text: new Text(`List type ${type}`) }, + databaseId + ); + service.updateCell(database, id, { + columnId: richTextId, + value: new Text(`List type ${type}`), + }); + }); + // Add a paragraph after database + doc.addBlock('affine:paragraph', {}, noteId); + doc.addBlock('affine:paragraph', {}, noteId); + doc.addBlock('affine:paragraph', {}, noteId); + doc.addBlock('affine:paragraph', {}, noteId); + doc.addBlock('affine:paragraph', {}, noteId); + service.databaseViewAddView( + database, + viewPresets.kanbanViewMeta.type + ); + + doc.resetHistory(); + }) + .catch(console.error); + }; + // Add database block inside note block + addDatabase('Database 1', false); + addDatabase('Database 2'); + addDatabase('Database 3'); + addDatabase('Database 4'); + addDatabase('Database 5'); + addDatabase('Database 6'); + addDatabase('Database 7'); + addDatabase('Database 8'); + addDatabase('Database 9'); + addDatabase('Database 10'); + }); +}; + +database.id = 'database'; +database.displayName = 'Database Example'; +database.description = 'Database block basic example'; diff --git a/blocksuite/playground/apps/starter/data/embed.ts b/blocksuite/playground/apps/starter/data/embed.ts new file mode 100644 index 0000000000..3330a63e83 --- /dev/null +++ b/blocksuite/playground/apps/starter/data/embed.ts @@ -0,0 +1,54 @@ +import { type DocCollection, Text } from '@blocksuite/store'; + +import type { InitFn } from './utils.js'; + +export const embed: InitFn = (collection: DocCollection, id: string) => { + const doc = collection.getDoc(id) ?? collection.createDoc({ id }); + doc.clear(); + + doc.load(() => { + // Add root block and surface block at root level + const rootId = doc.addBlock('affine:page', { + title: new Text(), + }); + + const surfaceId = doc.addBlock('affine:surface', {}, rootId); + + // Add note block inside root block + const noteId = doc.addBlock('affine:note', {}, rootId); + // Add paragraph block inside note block + doc.addBlock('affine:paragraph', {}, noteId); + + doc.addBlock( + 'affine:embed-github', + { + url: 'https://github.com/toeverything/AFFiNE/pull/5453', + }, + noteId + ); + doc.addBlock( + 'affine:embed-github', + { + url: 'https://www.github.com/toeverything/blocksuite/pull/5927', + style: 'vertical', + xywh: '[0, 400, 364, 390]', + }, + surfaceId + ); + doc.addBlock( + 'affine:embed-github', + { + url: 'https://github.com/Milkdown/milkdown/pull/1215', + xywh: '[500, 400, 752, 116]', + }, + surfaceId + ); + doc.addBlock('affine:paragraph', {}, noteId); + }); + + doc.resetHistory(); +}; + +embed.id = 'embed'; +embed.displayName = 'Example for embed blocks'; +embed.description = 'Example for embed blocks'; diff --git a/blocksuite/playground/apps/starter/data/empty.ts b/blocksuite/playground/apps/starter/data/empty.ts new file mode 100644 index 0000000000..06ac20e114 --- /dev/null +++ b/blocksuite/playground/apps/starter/data/empty.ts @@ -0,0 +1,28 @@ +import { type DocCollection, Text } from '@blocksuite/store'; + +import type { InitFn } from './utils.js'; + +export const empty: InitFn = (collection: DocCollection, id: string) => { + const doc = collection.getDoc(id) ?? collection.createDoc({ id }); + doc.clear(); + + doc.load(() => { + // Add root block and surface block at root level + const rootId = doc.addBlock('affine:page', { + title: new Text(), + }); + + doc.addBlock('affine:surface', {}, rootId); + + // Add note block inside root block + const noteId = doc.addBlock('affine:note', {}, rootId); + // Add paragraph block inside note block + doc.addBlock('affine:paragraph', {}, noteId); + }); + + doc.resetHistory(); +}; + +empty.id = 'empty'; +empty.displayName = 'Empty Editor'; +empty.description = 'Start from empty editor'; diff --git a/blocksuite/playground/apps/starter/data/heavy-whiteboard.ts b/blocksuite/playground/apps/starter/data/heavy-whiteboard.ts new file mode 100644 index 0000000000..3b9bb4034e --- /dev/null +++ b/blocksuite/playground/apps/starter/data/heavy-whiteboard.ts @@ -0,0 +1,101 @@ +import { DEFAULT_ROUGHNESS } from '@blocksuite/affine-model'; +import type { SerializedXYWH } from '@blocksuite/global/utils'; +import { + Boxed, + type DocCollection, + nanoid, + native2Y, + Text, + type Y, +} from '@blocksuite/store'; + +import type { InitFn } from './utils.js'; + +const SHAPE_TYPES = ['rect', 'triangle', 'ellipse', 'diamond']; +const params = new URLSearchParams(location.search); + +function createShapes(count: number): Record<string, unknown> { + const surfaceBlocks: Record<string, unknown> = {}; + + for (let i = 0; i < count; i++) { + const x = Math.random() * count * 2; + const y = Math.random() * count * 2; + const id = nanoid(); + surfaceBlocks[id] = native2Y( + { + id, + index: 'a0', + type: 'shape', + xywh: `[${x},${y},100,100]`, + seed: Math.floor(Math.random() * 2 ** 31), + shapeType: SHAPE_TYPES[Math.floor(Math.random() * 40) % 4], + radius: 0, + filled: false, + fillColor: '--affine-palette-shape-yellow', + strokeWidth: 4, + strokeColor: '--affine-palette-line-yellow', + strokeStyle: 'solid', + roughness: DEFAULT_ROUGHNESS, + }, + { deep: false } + ); + } + return surfaceBlocks; +} + +const SHAPES_COUNT = 100; +const RANGE = 2000; + +export const heavyWhiteboard: InitFn = ( + collection: DocCollection, + id: string +) => { + const count = Number(params.get('count')) || SHAPES_COUNT; + const enableShapes = !!params.get('shapes'); + + const doc = collection.createDoc({ id }); + doc.load(() => { + // Add root block and surface block at root level + const rootId = doc.addBlock('affine:page', { + title: new Text(), + }); + + const surfaceBlocks = enableShapes ? createShapes(count) : {}; + + doc.addBlock( + 'affine:surface', + { + elements: new Boxed(native2Y(surfaceBlocks, { deep: false })) as Boxed< + Y.Map<Y.Map<unknown>> + >, + }, + rootId + ); + + let i = 0; + // Add note block inside root block + for (i = 0; i < count; i++) { + const x = Math.random() * RANGE - RANGE / 2; + const y = Math.random() * RANGE - RANGE / 2; + const noteId = doc.addBlock( + 'affine:note', + { + xywh: `[${x}, ${y}, 100, 50]` as SerializedXYWH, + }, + rootId + ); + // Add paragraph block inside note block + doc.addBlock( + 'affine:paragraph', + { + text: new Text('Note #' + i), + }, + noteId + ); + } + }); +}; + +heavyWhiteboard.id = 'heavy-whiteboard'; +heavyWhiteboard.displayName = 'Heavy Whiteboard'; +heavyWhiteboard.description = 'Heavy Whiteboard on 200 elements by default'; diff --git a/blocksuite/playground/apps/starter/data/heavy.ts b/blocksuite/playground/apps/starter/data/heavy.ts new file mode 100644 index 0000000000..53003c7445 --- /dev/null +++ b/blocksuite/playground/apps/starter/data/heavy.ts @@ -0,0 +1,35 @@ +import { type DocCollection, Text } from '@blocksuite/store'; + +import type { InitFn } from './utils.js'; + +const params = new URLSearchParams(location.search); + +export const heavy: InitFn = (collection: DocCollection, docId: string) => { + const count = Number(params.get('count')) || 1000; + + const doc = collection.createDoc({ id: docId }); + doc.load(() => { + // Add root block and surface block at root level + const rootId = doc.addBlock('affine:page', { + title: new Text(), + }); + doc.addBlock('affine:surface', {}, rootId); + + // Add note block inside root block + const noteId = doc.addBlock('affine:note', {}, rootId); + for (let i = 0; i < count; i++) { + // Add paragraph block inside note block + doc.addBlock( + 'affine:paragraph', + { + text: new Text('Hello, world! ' + i), + }, + noteId + ); + } + }); +}; + +heavy.id = 'heavy'; +heavy.displayName = 'Heavy Example'; +heavy.description = 'Heavy example on thousands of paragraph blocks'; diff --git a/blocksuite/playground/apps/starter/data/index.ts b/blocksuite/playground/apps/starter/data/index.ts new file mode 100644 index 0000000000..fe7dc54dfe --- /dev/null +++ b/blocksuite/playground/apps/starter/data/index.ts @@ -0,0 +1,19 @@ +/** + * Manually create initial page structure. + * In collaboration mode or on page refresh with local persistence, + * the page structure will be automatically loaded from provider. + * In these cases, these functions should not be called. + */ +export * from './affine-snapshot.js'; +export * from './database.js'; +export * from './embed.js'; +export * from './empty.js'; +export * from './heavy.js'; +export * from './heavy-whiteboard.js'; +export * from './linked.js'; +export * from './multiple-editor.js'; +export * from './pending-structs.js'; +export * from './preset.js'; +export * from './synced.js'; +export type { InitFn } from './utils.js'; +export * from './version-mismatch.js'; diff --git a/blocksuite/playground/apps/starter/data/linked.ts b/blocksuite/playground/apps/starter/data/linked.ts new file mode 100644 index 0000000000..80ff3c5473 --- /dev/null +++ b/blocksuite/playground/apps/starter/data/linked.ts @@ -0,0 +1,80 @@ +import { type DocCollection, Text } from '@blocksuite/store'; + +import type { InitFn } from './utils.js'; + +export const linked: InitFn = (collection: DocCollection, id: string) => { + const docA = collection.getDoc(id) ?? collection.createDoc({ id }); + + const docBId = 'doc:linked-page'; + const docB = collection.createDoc({ id: docBId }); + + const docCId = 'doc:linked-edgeless'; + const docC = collection.createDoc({ id: docCId }); + + docA.clear(); + docB.clear(); + docC.clear(); + + docB.load(() => { + const rootId = docB.addBlock('affine:page', { + title: new Text(''), + }); + + docB.addBlock('affine:surface', {}, rootId); + + // Add note block inside root block + const noteId = docB.addBlock('affine:note', {}, rootId); + // Add paragraph block inside note block + docB.addBlock('affine:paragraph', {}, noteId); + }); + + docC.load(() => { + const rootId = docC.addBlock('affine:page', { + title: new Text(''), + }); + + docC.addBlock('affine:surface', {}, rootId); + + // Add note block inside root block + const noteId = docC.addBlock('affine:note', {}, rootId); + // Add paragraph block inside note block + docC.addBlock('affine:paragraph', {}, noteId); + }); + + docA.load(); + // Add root block and surface block at root level + const rootId = docA.addBlock('affine:page', { + title: new Text('Doc A'), + }); + + docA.addBlock('affine:surface', {}, rootId); + + // Add note block inside root block + const noteId = docA.addBlock('affine:note', {}, rootId); + // Add paragraph block inside note block + docA.addBlock('affine:paragraph', {}, noteId); + + docA.addBlock('affine:embed-linked-doc', { pageId: docBId }, noteId); + + docA.addBlock( + 'affine:embed-linked-doc', + { pageId: 'doc:deleted-example' }, + noteId + ); + + docA.addBlock('affine:embed-linked-doc', { pageId: docCId }, noteId); + + docA.addBlock( + 'affine:embed-linked-doc', + { pageId: 'doc:deleted-example-edgeless' }, + noteId + ); + + docA.resetHistory(); + docB.resetHistory(); + docC.resetHistory(); +}; + +linked.id = 'linked'; +linked.displayName = 'Linked Doc Editor'; +linked.description = 'A demo with linked docs'; diff --git a/blocksuite/playground/apps/starter/data/multiple-editor.ts b/blocksuite/playground/apps/starter/data/multiple-editor.ts new file mode 100644 index 0000000000..6e29e4762e --- /dev/null +++ b/blocksuite/playground/apps/starter/data/multiple-editor.ts @@ -0,0 +1,95 @@ +import { RefNodeSlotsProvider } from '@blocksuite/affine-components/rich-text'; +import { AffineEditorContainer } from '@blocksuite/presets'; +import { type DocCollection, Text } from '@blocksuite/store'; + +import type { InitFn } from './utils.js'; + +export const multiEditor: InitFn = (collection: DocCollection, id: string) => { + const doc = collection.createDoc({ id }); + doc.load(() => { + // Add root block and surface block at root level + const rootId = doc.addBlock('affine:page', { + title: new Text(), + }); + + doc.addBlock('affine:surface', {}, rootId); + + // Add note block inside root block + const noteId = doc.addBlock('affine:note', {}, rootId); + // Add paragraph block inside note block + doc.addBlock('affine:paragraph', {}, noteId); + }); + + doc.resetHistory(); + + const app = document.getElementById('app'); + if (app) { + const editor = new AffineEditorContainer(); + editor.doc = doc; + editor.std + .get(RefNodeSlotsProvider) + .docLinkClicked.on(({ pageId: docId }) => { + const target = collection.getDoc(docId); + if (!target) { + throw new Error(`Failed to jump to doc ${docId}`); + } + target.load(); + editor.doc = target; + }); + editor.style.borderRight = '1px solid var(--affine-border-color)'; + + app.append(editor); + app.style.display = 'flex'; + } +}; + +multiEditor.id = 'multiple-editor'; +multiEditor.displayName = 'Multiple Editor Example'; +multiEditor.description = 'Multiple Editor basic example'; + +export const multiEditorVertical: InitFn = ( + collection: DocCollection, + docId: string +) => { + const doc = collection.createDoc({ id: docId }); + doc.load(() => { + // Add root block and surface block at root level + const rootId = doc.addBlock('affine:page', { + title: new Text(), + }); + + doc.addBlock('affine:surface', {}, rootId); + + // Add note block inside root block + const noteId = doc.addBlock('affine:note', {}, rootId); + // Add paragraph block inside note block + doc.addBlock('affine:paragraph', {}, noteId); + }); + + doc.resetHistory(); + + const app = document.getElementById('app'); + if (app) { + const editor = new AffineEditorContainer(); + editor.doc = doc; + editor.std + .get(RefNodeSlotsProvider) + .docLinkClicked.on(({ pageId: docId }) => { + const target = collection.getDoc(docId); + if (!target) { + throw new Error(`Failed to jump to doc ${docId}`); + } + target.load(); + editor.doc = target; + }); + editor.style.borderBottom = '1px solid var(--affine-border-color)'; + + app.append(editor); + app.style.display = 'flex'; + app.style.flexDirection = 'column'; + } +}; + +multiEditorVertical.id = 'multiple-editor-vertical'; +multiEditorVertical.displayName = 'Vertical Multiple Editor Example'; +multiEditorVertical.description = 'Multiple Editor vertical layout example'; diff --git a/blocksuite/playground/apps/starter/data/pending-structs.ts b/blocksuite/playground/apps/starter/data/pending-structs.ts new file mode 100644 index 0000000000..d5c7bd7dd5 --- /dev/null +++ b/blocksuite/playground/apps/starter/data/pending-structs.ts @@ -0,0 +1,41 @@ +import { DocCollection, Text } from '@blocksuite/store'; + +import type { InitFn } from './utils.js'; + +export const pendingStructs: InitFn = ( + collection: DocCollection, + id: string +) => { + const doc = collection.createDoc({ id }); + const tempDoc = collection.createDoc({ id: 'tempDoc' }); + doc.load(); + tempDoc.load(() => { + const rootId = tempDoc.addBlock('affine:page', { + title: new Text('Pending Structs'), + }); + const vec = DocCollection.Y.encodeStateVector(tempDoc.spaceDoc); + + // To avoid pending structs, uncomment the following line + // const update = DocCollection.Y.encodeStateAsUpdate(tempDoc.spaceDoc); + + tempDoc.addBlock('affine:surface', {}, rootId); + // Add note block inside root block + const noteId = tempDoc.addBlock('affine:note', {}, rootId); + tempDoc.addBlock( + 'affine:paragraph', + { + text: new Text('This is a paragraph block'), + }, + noteId + ); + const diff = DocCollection.Y.encodeStateAsUpdate(tempDoc.spaceDoc, vec); + // To avoid pending structs, uncomment the following line + // DocCollection.Y.applyUpdate(doc.spaceDoc, update); + + DocCollection.Y.applyUpdate(doc.spaceDoc, diff); + }); +}; + +pendingStructs.id = 'pending-structs'; +pendingStructs.displayName = 'Pending Structs'; +pendingStructs.description = 'Doc with pending structs'; diff --git a/blocksuite/playground/apps/starter/data/preset.ts b/blocksuite/playground/apps/starter/data/preset.ts new file mode 100644 index 0000000000..0f8f408483 --- /dev/null +++ b/blocksuite/playground/apps/starter/data/preset.ts @@ -0,0 +1,36 @@ +import { MarkdownTransformer } from '@blocksuite/blocks'; +import { type DocCollection, Text } from '@blocksuite/store'; + +import type { InitFn } from './utils.js'; + +const presetMarkdown = `Click the 🔁 button to switch between editors dynamically - they are fully compatible!`; + +export const preset: InitFn = async (collection: DocCollection, id: string) => { + const doc = collection.createDoc({ id }); + doc.load(); + // Add root block and surface block at root level + const rootId = doc.addBlock('affine:page', { + title: new Text('BlockSuite Playground'), + }); + doc.addBlock('affine:surface', {}, rootId); + + // Add note block inside root block + const noteId = doc.addBlock( + 'affine:note', + { xywh: '[0, 100, 800, 640]' }, + rootId + ); + + // Import preset markdown content inside note block + await MarkdownTransformer.importMarkdownToBlock({ + doc, + blockId: noteId, + markdown: presetMarkdown, + }); + + doc.resetHistory(); +}; + +preset.id = 'preset'; +preset.displayName = 'BlockSuite Starter'; +preset.description = 'Start from friendly introduction'; diff --git a/blocksuite/playground/apps/starter/data/snapshots/affine-default.zip b/blocksuite/playground/apps/starter/data/snapshots/affine-default.zip new file mode 100644 index 0000000000..839cfc9b04 Binary files /dev/null and b/blocksuite/playground/apps/starter/data/snapshots/affine-default.zip differ diff --git a/blocksuite/playground/apps/starter/data/synced.ts b/blocksuite/playground/apps/starter/data/synced.ts new file mode 100644 index 0000000000..720dd8e0e2 --- /dev/null +++ b/blocksuite/playground/apps/starter/data/synced.ts @@ -0,0 +1,167 @@ +import { MarkdownTransformer } from '@blocksuite/blocks'; +import { type DocCollection, Text } from '@blocksuite/store'; + +import type { InitFn } from './utils'; + +const syncedDocMarkdown = `We share some of our findings from developing local-first software prototypes at [Ink & Switch](https://www.inkandswitch.com/) over the course of several years. These experiments test the viability of CRDTs in practice, and explore the user interface challenges for this new data model. Lastly, we suggest some next steps for moving towards local-first software: for researchers, for app developers, and a startup opportunity for entrepreneurs. + +This article has also been published [in PDF format](https://www.inkandswitch.com/local-first/static/local-first.pdf) in the proceedings of the [Onward! 2019 conference](https://2019.splashcon.org/track/splash-2019-Onward-Essays). Please cite it as: + +> Martin Kleppmann, Adam Wiggins, Peter van Hardenberg, and Mark McGranaghan. Local-first software: you own your data, in spite of the cloud. 2019 ACM SIGPLAN International Symposium on New Ideas, New Paradigms, and Reflections on Programming and Software (Onward!), October 2019, pages 154-178. [doi:10.1145/3359591.3359737](https://doi.org/10.1145/3359591.3359737) + +We welcome your feedback: [@inkandswitch](https://twitter.com/inkandswitch) or hello@inkandswitch.com.`; + +export const synced: InitFn = (collection: DocCollection, id: string) => { + const docMain = collection.getDoc(id) ?? collection.createDoc({ id }); + + const docSyncedPageId = 'doc:synced-page'; + const docSyncedPage = collection.createDoc({ id: docSyncedPageId }); + + const docSyncedEdgelessId = 'doc:synced-edgeless'; + const docSyncedEdgeless = collection.createDoc({ id: docSyncedEdgelessId }); + + docMain.clear(); + docSyncedPage.clear(); + docSyncedEdgeless.clear(); + + docSyncedPage.load(() => { + // Add root block and surface block at root level + const rootId = docSyncedPage.addBlock('affine:page', { + title: new Text('Synced - Page View'), + }); + + docSyncedPage.addBlock('affine:surface', {}, rootId); + + // Add note block inside root block + const noteId = docSyncedPage.addBlock('affine:note', {}, rootId); + + // Add markdown to note block + MarkdownTransformer.importMarkdownToBlock({ + doc: docSyncedPage, + blockId: noteId, + markdown: syncedDocMarkdown, + }).catch(console.error); + }); + + docSyncedEdgeless.load(() => { + // Add root block and surface block at root level + const rootId = docSyncedEdgeless.addBlock('affine:page', { + title: new Text('Synced - Edgeless View'), + }); + + docSyncedEdgeless.addBlock('affine:surface', {}, rootId); + + // Add note block inside root block + const noteId = docSyncedEdgeless.addBlock('affine:note', {}, rootId); + + // Add markdown to note block + MarkdownTransformer.importMarkdownToBlock({ + doc: docSyncedEdgeless, + blockId: noteId, + markdown: syncedDocMarkdown, + }).catch(console.error); + }); + + docMain.load(() => { + // Add root block and surface block at root level + const rootId = docMain.addBlock('affine:page', { + title: new Text('Home doc, having synced blocks'), + }); + + const surfaceId = docMain.addBlock('affine:surface', {}, rootId); + const noteId = docMain.addBlock('affine:note', {}, rootId); + + // Add markdown to note block + MarkdownTransformer.importMarkdownToBlock({ + doc: docMain, + blockId: noteId, + markdown: syncedDocMarkdown, + }) + .then(() => { + // Add synced block - self + docMain.addBlock( + 'affine:paragraph', + { + text: new Text('Cyclic / Matryoshka synced block 👇'), + type: 'h4', + }, + noteId + ); + + // Add synced block - self + docMain.addBlock( + 'affine:embed-synced-doc', + { + pageId: id, + }, + noteId + ); + + // Add synced block - page view + docMain.addBlock( + 'affine:embed-synced-doc', + { + pageId: docSyncedPageId, + }, + noteId + ); + + // Add synced block - edgeless view + docMain.addBlock( + 'affine:embed-synced-doc', + { + pageId: docSyncedEdgelessId, + }, + noteId + ); + + // Add synced block - page view + docMain.addBlock( + 'affine:embed-synced-doc', + { + pageId: docSyncedPageId, + xywh: '[-1000, 0, 752, 455]', + }, + surfaceId + ); + + // Add synced block - edgeless view + docMain.addBlock( + 'affine:embed-synced-doc', + { + pageId: docSyncedEdgelessId, + xywh: '[-1000, 500, 752, 455]', + }, + surfaceId + ); + + // Add synced block - self + docMain.addBlock( + 'affine:embed-synced-doc', + { + pageId: id, + xywh: '[-1000, 1000, 752, 455]', + }, + surfaceId + ); + + // Add synced block - self + docMain.addBlock( + 'affine:embed-synced-doc', + { + pageId: 'doc:deleted-page', + }, + noteId + ); + }) + .catch(console.error); + }); + + docSyncedEdgeless.resetHistory(); + docSyncedPage.resetHistory(); + docMain.resetHistory(); +}; + +synced.id = 'synced'; +synced.displayName = 'Synced block demo'; +synced.description = 'A simple demo for synced block'; diff --git a/blocksuite/playground/apps/starter/data/utils.ts b/blocksuite/playground/apps/starter/data/utils.ts new file mode 100644 index 0000000000..3b946a0fb4 --- /dev/null +++ b/blocksuite/playground/apps/starter/data/utils.ts @@ -0,0 +1,8 @@ +import type { DocCollection } from '@blocksuite/store'; + +export interface InitFn { + (collection: DocCollection, docId: string): Promise<void> | void; + id: string; + displayName: string; + description: string; +} diff --git a/blocksuite/playground/apps/starter/data/version-mismatch.ts b/blocksuite/playground/apps/starter/data/version-mismatch.ts new file mode 100644 index 0000000000..9926a1ecb5 --- /dev/null +++ b/blocksuite/playground/apps/starter/data/version-mismatch.ts @@ -0,0 +1,39 @@ +import type { Y } from '@blocksuite/store'; +import { DocCollection } from '@blocksuite/store'; + +import type { InitFn } from './utils.js'; + +export const versionMismatch: InitFn = ( + collection: DocCollection, + id: string +) => { + const doc = collection.createDoc({ id }); + const tempDoc = collection.createDoc({ id: 'tempDoc' }); + doc.load(); + + tempDoc.load(() => { + const rootId = tempDoc.addBlock('affine:page', {}); + tempDoc.addBlock('affine:surface', {}, rootId); + const noteId = tempDoc.addBlock( + 'affine:note', + { xywh: '[0, 100, 800, 640]' }, + rootId + ); + const paragraphId = tempDoc.addBlock('affine:paragraph', {}, noteId); + const blocks = tempDoc.spaceDoc.get('blocks') as Y.Map<unknown>; + const paragraph = blocks.get(paragraphId) as Y.Map<unknown>; + paragraph.set('sys:version', (paragraph.get('sys:version') as number) + 1); + + const update = DocCollection.Y.encodeStateAsUpdate(tempDoc.spaceDoc); + + DocCollection.Y.applyUpdate(doc.spaceDoc, update); + doc.addBlock('affine:paragraph', {}, noteId); + }); + + collection.removeDoc('tempDoc'); + doc.resetHistory(); +}; + +versionMismatch.id = 'version-mismatch'; +versionMismatch.displayName = 'Version Mismatch'; +versionMismatch.description = 'Error boundary when version mismatch in data'; diff --git a/blocksuite/playground/apps/starter/main.ts b/blocksuite/playground/apps/starter/main.ts new file mode 100644 index 0000000000..7a0c321407 --- /dev/null +++ b/blocksuite/playground/apps/starter/main.ts @@ -0,0 +1,91 @@ +import '../../style.css'; +import '../dev-format.js'; + +import { + type ExtensionType, + WidgetViewMapExtension, + WidgetViewMapIdentifier, +} from '@blocksuite/block-std'; +import * as blocks from '@blocksuite/blocks'; +import { + CommunityCanvasTextFonts, + DocModeProvider, + FontConfigExtension, + ParseDocUrlProvider, + QuickSearchProvider, + RefNodeSlotsExtension, + RefNodeSlotsProvider, +} from '@blocksuite/blocks'; +import { effects as blocksEffects } from '@blocksuite/blocks/effects'; +import * as globalUtils from '@blocksuite/global/utils'; +import * as editor from '@blocksuite/presets'; +import { effects as presetsEffects } from '@blocksuite/presets/effects'; +// eslint-disable-next-line @typescript-eslint/no-restricted-imports +import * as store from '@blocksuite/store'; + +import { mockDocModeService } from '../_common/mock-services.js'; +import { setupEdgelessTemplate } from '../_common/setup.js'; +import { + createStarterDocCollection, + initStarterDocCollection, +} from './utils/collection.js'; +import { mountDefaultDocEditor } from './utils/editor.js'; + +blocksEffects(); +presetsEffects(); + +async function main() { + if (window.collection) return; + + setupEdgelessTemplate(); + + const params = new URLSearchParams(location.search); + const room = params.get('room') ?? Math.random().toString(16).slice(2, 8); + const isE2E = room.startsWith('playwright'); + const collection = createStarterDocCollection(); + + if (isE2E) { + Object.defineProperty(window, '$blocksuite', { + value: Object.freeze({ + store, + blocks, + global: { utils: globalUtils }, + editor, + identifiers: { + WidgetViewMapIdentifier, + QuickSearchProvider, + DocModeProvider, + RefNodeSlotsProvider, + ParseDocUrlService: ParseDocUrlProvider, + }, + defaultExtensions: (): ExtensionType[] => [ + FontConfigExtension(CommunityCanvasTextFonts), + RefNodeSlotsExtension(), + ], + extensions: { + FontConfigExtension: FontConfigExtension(CommunityCanvasTextFonts), + WidgetViewMapExtension, + RefNodeSlotsExtension: RefNodeSlotsExtension(), + }, + mockServices: { + mockDocModeService, + }, + }), + }); + + // test if blocksuite can run in a web worker, SEE: tests/worker.spec.ts + // window.testWorker = new Worker( + // new URL('./utils/test-worker.ts', import.meta.url), + // { + // type: 'module', + // } + // ); + + return; + } + + await initStarterDocCollection(collection); + await mountDefaultDocEditor(collection); +} + +main().catch(console.error); diff --git a/blocksuite/playground/apps/starter/utils/collection.ts b/blocksuite/playground/apps/starter/utils/collection.ts new file mode 100644 index 0000000000..cdb1f84a40 --- /dev/null +++ b/blocksuite/playground/apps/starter/utils/collection.ts @@ -0,0 +1,138 @@ +import { AffineSchemas, TestUtils } from '@blocksuite/blocks'; +import type { BlockSuiteFlags } from '@blocksuite/global/types'; +import { assertExists } from '@blocksuite/global/utils'; +import { + type BlockCollection, + DocCollection, + type DocCollectionOptions, + IdGeneratorType, + Job, + Schema, +} from '@blocksuite/store'; +import { + type BlobSource, + BroadcastChannelAwarenessSource, + BroadcastChannelDocSource, + IndexedDBBlobSource, + MemoryBlobSource, +} from '@blocksuite/sync'; + +import { MockServerBlobSource } from '../../_common/sync/blob/mock-server.js'; +import type { InitFn } from '../data/utils.js'; + +const params = new URLSearchParams(location.search); +const room = params.get('room'); +const isE2E = room?.startsWith('playwright'); +const blobSourceArgs = (params.get('blobSource') ?? '').split(','); + +export function createStarterDocCollection() { + const collectionId = room ?? 'starter'; + const schema = new Schema(); + schema.register(AffineSchemas); + const idGenerator = isE2E + ? IdGeneratorType.AutoIncrement + : IdGeneratorType.NanoID; + + let docSources: DocCollectionOptions['docSources']; + if (room) { + docSources = { + main: new BroadcastChannelDocSource(`broadcast-channel-${room}`), + }; + } + const id = room ?? `starter-${Math.random().toString(16).slice(2, 8)}`; + + const blobSources = { + main: new MemoryBlobSource(), + shadows: [] as BlobSource[], + } satisfies DocCollectionOptions['blobSources']; + if (blobSourceArgs.includes('mock')) { + blobSources.shadows.push(new MockServerBlobSource(collectionId)); + } + if (blobSourceArgs.includes('idb')) { + blobSources.shadows.push(new IndexedDBBlobSource(collectionId)); + } + + const flags: Partial<BlockSuiteFlags> = Object.fromEntries( + [...params.entries()] + .filter(([key]) => key.startsWith('enable_')) + .map(([k, v]) => [k, v === 'true']) + ); + + const options: DocCollectionOptions = { + id: collectionId, + schema, + idGenerator, + defaultFlags: { + enable_synced_doc_block: true, + enable_pie_menu: true, + enable_lasso_tool: true, + enable_edgeless_text: true, + enable_color_picker: true, + enable_mind_map_import: true, + enable_advanced_block_visibility: true, + enable_shape_shadow_blur: false, + ...flags, + }, + awarenessSources: [new BroadcastChannelAwarenessSource(id)], + docSources, + blobSources, + }; + const collection = new DocCollection(options); + collection.start(); + + // debug info + window.collection = collection; + window.blockSchemas = AffineSchemas; + window.job = new Job({ collection: collection }); + window.Y = DocCollection.Y; + window.testUtils = new TestUtils(); + + return collection; +} + +export async function initStarterDocCollection(collection: DocCollection) { + // init from other clients + if (room && !params.has('init')) { + const firstCollection = collection.docs.values().next().value as + | BlockCollection + | undefined; + let firstDoc = firstCollection?.getDoc(); + if (!firstDoc) { + await new Promise<string>(resolve => + collection.slots.docAdded.once(resolve) + ); + const firstCollection = collection.docs.values().next().value as + | BlockCollection + | undefined; + firstDoc = firstCollection?.getDoc(); + } + assertExists(firstDoc); + const doc = firstDoc; + + doc.load(); + if (!doc.root) { + await new Promise(resolve => doc.slots.rootAdded.once(resolve)); + } + doc.resetHistory(); + return; + } + + // use built-in init function + const functionMap = new Map< + string, + (collection: DocCollection, id: string) => Promise<void> | void + >(); + Object.values( + (await import('../data/index.js')) as Record<string, InitFn> + ).forEach(fn => functionMap.set(fn.id, fn)); + const init = params.get('init') || 'preset'; + if (functionMap.has(init)) { + collection.meta.initialize(); + await functionMap.get(init)?.(collection, 'doc:home'); + const doc = collection.getDoc('doc:home'); + if (!doc?.loaded) { + doc?.load(); + } + doc?.resetHistory(); + } +} diff --git a/blocksuite/playground/apps/starter/utils/editor.ts b/blocksuite/playground/apps/starter/utils/editor.ts new file mode 100644 index 0000000000..c25d10cd77 --- /dev/null +++ b/blocksuite/playground/apps/starter/utils/editor.ts @@ -0,0 +1,203 @@ +import { + BlockServiceWatcher, + type EditorHost, + type ExtensionType, +} from '@blocksuite/block-std'; +import { + AffineFormatBarWidget, + CommunityCanvasTextFonts, + DocModeProvider, + FontConfigExtension, + GenerateDocUrlExtension, + NotificationExtension, + OverrideThemeExtension, + type PageRootService, + ParseDocUrlExtension, + RefNodeSlotsExtension, + RefNodeSlotsProvider, + SpecProvider, + toolbarDefaultConfig, +} from '@blocksuite/blocks'; +import { AffineEditorContainer, CommentPanel } from '@blocksuite/presets'; +import type { DocCollection } from '@blocksuite/store'; + +import { AttachmentViewerPanel } from '../../_common/components/attachment-viewer-panel.js'; +import { CustomFramePanel } from '../../_common/components/custom-frame-panel.js'; +import { CustomOutlinePanel } from '../../_common/components/custom-outline-panel.js'; +import { CustomOutlineViewer } from '../../_common/components/custom-outline-viewer.js'; +import { DocsPanel } from '../../_common/components/docs-panel.js'; +import { LeftSidePanel } from '../../_common/components/left-side-panel.js'; +import { SidePanel } from '../../_common/components/side-panel.js'; +import { StarterDebugMenu } from '../../_common/components/starter-debug-menu.js'; +import { + getDocFromUrlParams, + listenHashChange, + setDocModeFromUrlParams, +} from '../../_common/history.js'; +import { + mockDocModeService, + mockGenerateDocUrlService, + mockNotificationService, + mockParseDocUrlService, + themeExtension, +} from '../../_common/mock-services'; + +function configureFormatBar(formatBar: AffineFormatBarWidget) { + toolbarDefaultConfig(formatBar); +} + +export async function mountDefaultDocEditor(collection: DocCollection) { + const app = document.getElementById('app'); + if (!app) return; + + const url = new URL(location.toString()); + const doc = getDocFromUrlParams(collection, url); + + const attachmentViewerPanel = new AttachmentViewerPanel(); + + const editor = new AffineEditorContainer(); + + class PatchPageServiceWatcher extends BlockServiceWatcher { + static override readonly flavour = 'affine:page'; + + override mounted() { + const pageRootService = this.blockService as PageRootService; + const onFormatBarConnected = pageRootService.specSlots.widgetConnected.on( + view => { + if (view.component instanceof AffineFormatBarWidget) { + configureFormatBar(view.component); + } + } + ); + pageRootService.disposables.add(onFormatBarConnected); + } + } + + const refNodeSlotsExtension = RefNodeSlotsExtension(); + const extensions: ExtensionType[] = [ + refNodeSlotsExtension, + PatchPageServiceWatcher, + FontConfigExtension(CommunityCanvasTextFonts), + ParseDocUrlExtension(mockParseDocUrlService(collection)), + GenerateDocUrlExtension(mockGenerateDocUrlService(collection)), + NotificationExtension(mockNotificationService(editor)), + OverrideThemeExtension(themeExtension), + { + setup: di => { + di.override(DocModeProvider, () => + mockDocModeService(getEditorModeCallback, setEditorModeCallBack) + ); + }, + }, + // mockPeekViewExtension(attachmentViewerPanel), + ]; + + const pageSpecs = SpecProvider.getInstance().getSpec('page'); + const setEditorModeCallBack = editor.switchEditor.bind(editor); + const getEditorModeCallback = () => editor.mode; + pageSpecs.extend([...extensions]); + editor.pageSpecs = pageSpecs.value; + + const edgelessSpecs = SpecProvider.getInstance().getSpec('edgeless'); + edgelessSpecs.extend([...extensions]); + editor.edgelessSpecs = edgelessSpecs.value; + + SpecProvider.getInstance().extendSpec('edgeless:preview', [ + OverrideThemeExtension(themeExtension), + ]); + + editor.mode = 'page'; + editor.doc = doc; + editor.std + .get(RefNodeSlotsProvider) + .docLinkClicked.on(({ pageId: docId }) => { + const target = collection.getDoc(docId); + if (!target) { + throw new Error(`Failed to jump to doc ${docId}`); + } + target.load(); + editor.doc = target; + }); + + app.append(editor); + await editor.updateComplete; + const modeService = editor.std.provider.get(DocModeProvider); + editor.mode = modeService.getPrimaryMode(doc.id); + setDocModeFromUrlParams(modeService, url.searchParams, doc.id); + editor.slots.docUpdated.on(({ newDocId }) => { + editor.mode = modeService.getPrimaryMode(newDocId); + }); + + const outlinePanel = new CustomOutlinePanel(); + outlinePanel.editor = editor; + + const outlineViewer = new CustomOutlineViewer(); + outlineViewer.editor = editor; + outlineViewer.toggleOutlinePanel = () => { + outlinePanel.toggleDisplay(); + }; + + const framePanel = new CustomFramePanel(); + framePanel.editor = editor; + + const sidePanel = new SidePanel(); + + const leftSidePanel = new LeftSidePanel(); + + const docsPanel = new DocsPanel(); + docsPanel.editor = editor; + + const commentPanel = new CommentPanel(); + commentPanel.editor = editor; + + const debugMenu = new StarterDebugMenu(); + debugMenu.collection = collection; + debugMenu.editor = editor; + debugMenu.outlinePanel = outlinePanel; + debugMenu.outlineViewer = outlineViewer; + debugMenu.framePanel = framePanel; + debugMenu.sidePanel = sidePanel; + debugMenu.leftSidePanel = leftSidePanel; + debugMenu.docsPanel = docsPanel; + debugMenu.commentPanel = commentPanel; + + document.body.append(attachmentViewerPanel); + document.body.append(outlinePanel); + document.body.append(outlineViewer); + document.body.append(framePanel); + document.body.append(sidePanel); + document.body.append(leftSidePanel); + document.body.append(debugMenu); + + // for multiple editor + const params = new URLSearchParams(location.search); + const init = params.get('init'); + if (init && init.startsWith('multiple-editor')) { + app.childNodes.forEach(node => { + if (node instanceof AffineEditorContainer) { + node.style.flex = '1'; + if (init === 'multiple-editor-vertical') { + node.style.overflow = 'auto'; + } + } + }); + } + + // debug info + window.editor = editor; + window.doc = doc; + Object.defineProperty(globalThis, 'host', { + get() { + return document.querySelector<EditorHost>('editor-host'); + }, + }); + Object.defineProperty(globalThis, 'std', { + get() { + return document.querySelector<EditorHost>('editor-host')?.std; + }, + }); + + listenHashChange(collection, editor, docsPanel); + + return editor; +} diff --git a/blocksuite/playground/apps/starter/utils/test-worker.ts b/blocksuite/playground/apps/starter/utils/test-worker.ts new file mode 100644 index 0000000000..827635be3d --- /dev/null +++ b/blocksuite/playground/apps/starter/utils/test-worker.ts @@ -0,0 +1,12 @@ +// This file is used to test blocksuite can run in a web worker. SEE: tests/worker.spec.ts + +import '@blocksuite/store'; +// import '@blocksuite/block-std'; // seems not working +import '@blocksuite/blocks/schemas'; + +globalThis.onmessage = event => { + const { data } = event; + if (data === 'ping') { + postMessage('pong'); + } +}; diff --git a/blocksuite/playground/apps/vite-env.d.ts b/blocksuite/playground/apps/vite-env.d.ts new file mode 100644 index 0000000000..11f02fe2a0 --- /dev/null +++ b/blocksuite/playground/apps/vite-env.d.ts @@ -0,0 +1 @@ +/// <reference types="vite/client" /> diff --git a/blocksuite/playground/examples/README.md b/blocksuite/playground/examples/README.md new file mode 100644 index 0000000000..edd09ebd53 --- /dev/null +++ b/blocksuite/playground/examples/README.md @@ -0,0 +1,3 @@ +# BlockSuite Playground Examples + +This directory hosts a collection of simple examples designed for internal demonstration and debugging of specific functionalities within BlockSuite. They are built and deployed together with the [playground apps](../apps). diff --git a/blocksuite/playground/examples/basic/edgeless/index.html b/blocksuite/playground/examples/basic/edgeless/index.html new file mode 100644 index 0000000000..29a6b70feb --- /dev/null +++ b/blocksuite/playground/examples/basic/edgeless/index.html @@ -0,0 +1,23 @@ +<!doctype html> +<html lang="en"> + <head> + <meta charset="UTF-8" /> + <link rel="icon" type="image/svg+xml" href="/vite.svg" /> + <meta name="viewport" content="width=device-width, initial-scale=1.0" /> + <title>Basic EdgelessEditor Example</title> + <style> + html, + body { + height: 100%; + overflow: hidden; + margin: 0; + padding: 0; + background-color: var(--affine-white-90); + transition: background-color 0.3s; + } + </style> + </head> + <body> + <script type="module" src="./main.ts"></script> + </body> +</html> diff --git a/blocksuite/playground/examples/basic/edgeless/main.ts b/blocksuite/playground/examples/basic/edgeless/main.ts new file mode 100644 index 0000000000..9aa96fad10 --- /dev/null +++ b/blocksuite/playground/examples/basic/edgeless/main.ts @@ -0,0 +1,8 @@ +import '../../../style.css'; + +import { createEmptyDoc, EdgelessEditor } from '@blocksuite/presets'; + +const doc = createEmptyDoc().init(); +const editor = new EdgelessEditor(); +editor.doc = doc; +document.body.append(editor); diff --git a/blocksuite/playground/examples/basic/page/index.html b/blocksuite/playground/examples/basic/page/index.html new file mode 100644 index 0000000000..c3d3d96126 --- /dev/null +++ b/blocksuite/playground/examples/basic/page/index.html @@ -0,0 +1,23 @@ +<!doctype html> +<html lang="en"> + <head> + <meta charset="UTF-8" /> + <link rel="icon" type="image/svg+xml" href="/vite.svg" /> + <meta name="viewport" content="width=device-width, initial-scale=1.0" /> + <title>Basic PageEditor Example</title> + <style> + html, + body { + height: 100%; + overflow: hidden; + margin: 0; + padding: 0; + background-color: var(--affine-white-90); + transition: background-color 0.3s; + } + </style> + </head> + <body> + <script type="module" src="./main.ts"></script> + </body> +</html> diff --git a/blocksuite/playground/examples/basic/page/main.ts b/blocksuite/playground/examples/basic/page/main.ts new file mode 100644 index 0000000000..7178c5f4d3 --- /dev/null +++ b/blocksuite/playground/examples/basic/page/main.ts @@ -0,0 +1,13 @@ +import '../../../style.css'; + +import { createEmptyDoc, PageEditor } from '@blocksuite/presets'; +import { Text } from '@blocksuite/store'; + +const doc = createEmptyDoc().init(); +const editor = new PageEditor(); +editor.doc = doc; +document.body.append(editor); + +const paragraphs = doc.getBlockByFlavour('affine:paragraph'); +const paragraph = paragraphs[0]; +doc.updateBlock(paragraph, { text: new Text('Hello World!') }); diff --git a/blocksuite/playground/examples/inline/index.html b/blocksuite/playground/examples/inline/index.html new file mode 100644 index 0000000000..8f99c86530 --- /dev/null +++ b/blocksuite/playground/examples/inline/index.html @@ -0,0 +1,19 @@ +<!doctype html> +<html class="sl-theme-dark" lang="en"> + <head> + <meta charset="UTF-8" /> + <link rel="icon" type="image/svg+xml" href="/vite.svg" /> + <link + rel="stylesheet" + href="https://cdn.jsdelivr.net/npm/@shoelace-style/shoelace@2.0.0/dist/themes/dark.css" + /> + <meta name="viewport" content="width=device-width, initial-scale=1.0" /> + <title>Simple Inline Editor Example</title> + </head> + <body> + <test-page></test-page> + <script type="module"> + import './test-page'; + </script> + </body> +</html> diff --git a/blocksuite/playground/examples/inline/markdown.ts b/blocksuite/playground/examples/inline/markdown.ts new file mode 100644 index 0000000000..68fd4acb0a --- /dev/null +++ b/blocksuite/playground/examples/inline/markdown.ts @@ -0,0 +1,376 @@ +import { + type InlineEditor, + type InlineRange, + KEYBOARD_ALLOW_DEFAULT, + KEYBOARD_PREVENT_DEFAULT, +} from '@blocksuite/inline'; +import type * as Y from 'yjs'; + +interface MarkdownMatch { + name: string; + pattern: RegExp; + action: (props: { + inlineEditor: InlineEditor; + prefixText: string; + inlineRange: InlineRange; + pattern: RegExp; + undoManager: Y.UndoManager; + }) => boolean; +} + +export const markdownMatches: MarkdownMatch[] = [ + { + name: 'bolditalic', + pattern: /(?:\*){3}([^* \n](.+?[^* \n])?)(?:\*){3}$/g, + action: ({ + inlineEditor, + prefixText, + inlineRange, + pattern, + undoManager, + }) => { + const match = pattern.exec(prefixText); + if (!match) { + return KEYBOARD_ALLOW_DEFAULT; + } + + const annotatedText = match[0]; + const startIndex = inlineRange.index - annotatedText.length; + + inlineEditor.insertText( + { + index: startIndex + annotatedText.length, + length: 0, + }, + ' ' + ); + + undoManager.stopCapturing(); + + inlineEditor.formatText( + { + index: startIndex, + length: annotatedText.length, + }, + { + bold: true, + italic: true, + } + ); + + inlineEditor.deleteText({ + index: startIndex + annotatedText.length, + length: 1, + }); + inlineEditor.deleteText({ + index: startIndex + annotatedText.length - 3, + length: 3, + }); + inlineEditor.deleteText({ + index: startIndex, + length: 3, + }); + + inlineEditor.setInlineRange({ + index: startIndex + annotatedText.length - 6, + length: 0, + }); + + return KEYBOARD_PREVENT_DEFAULT; + }, + }, + { + name: 'bold', + pattern: /(?:\*){2}([^* \n](.+?[^* \n])?)(?:\*){2}$/g, + action: ({ + inlineEditor, + prefixText, + inlineRange, + pattern, + undoManager, + }) => { + const match = pattern.exec(prefixText); + if (!match) { + return KEYBOARD_ALLOW_DEFAULT; + } + const annotatedText = match[0]; + const startIndex = inlineRange.index - annotatedText.length; + + inlineEditor.insertText( + { + index: startIndex + annotatedText.length, + length: 0, + }, + ' ' + ); + + undoManager.stopCapturing(); + + inlineEditor.formatText( + { + index: startIndex, + length: annotatedText.length, + }, + { + bold: true, + } + ); + + inlineEditor.deleteText({ + index: startIndex + annotatedText.length, + length: 1, + }); + inlineEditor.deleteText({ + index: startIndex + annotatedText.length - 2, + length: 2, + }); + inlineEditor.deleteText({ + index: startIndex, + length: 2, + }); + + inlineEditor.setInlineRange({ + index: startIndex + annotatedText.length - 4, + length: 0, + }); + + return KEYBOARD_PREVENT_DEFAULT; + }, + }, + { + name: 'italic', + pattern: /(?:\*){1}([^* \n](.+?[^* \n])?)(?:\*){1}$/g, + action: ({ + inlineEditor, + prefixText, + inlineRange, + pattern, + undoManager, + }) => { + const match = pattern.exec(prefixText); + if (!match) { + return KEYBOARD_ALLOW_DEFAULT; + } + const annotatedText = match[0]; + const startIndex = inlineRange.index - annotatedText.length; + + inlineEditor.insertText( + { + index: startIndex + annotatedText.length, + length: 0, + }, + ' ' + ); + + undoManager.stopCapturing(); + + inlineEditor.formatText( + { + index: startIndex, + length: annotatedText.length, + }, + { + italic: true, + } + ); + + inlineEditor.deleteText({ + index: startIndex + annotatedText.length, + length: 1, + }); + inlineEditor.deleteText({ + index: startIndex + annotatedText.length - 1, + length: 1, + }); + inlineEditor.deleteText({ + index: startIndex, + length: 1, + }); + + inlineEditor.setInlineRange({ + index: startIndex + annotatedText.length - 2, + length: 0, + }); + + return KEYBOARD_PREVENT_DEFAULT; + }, + }, + { + name: 'strikethrough', + pattern: /(?:~~)([^~ \n](.+?[^~ \n])?)(?:~~)$/g, + action: ({ + inlineEditor, + prefixText, + inlineRange, + pattern, + undoManager, + }) => { + const match = pattern.exec(prefixText); + if (!match) { + return KEYBOARD_ALLOW_DEFAULT; + } + const annotatedText = match[0]; + const startIndex = inlineRange.index - annotatedText.length; + + inlineEditor.insertText( + { + index: startIndex + annotatedText.length, + length: 0, + }, + ' ' + ); + + undoManager.stopCapturing(); + + inlineEditor.formatText( + { + index: startIndex, + length: annotatedText.length, + }, + { + strike: true, + } + ); + + inlineEditor.deleteText({ + index: startIndex + annotatedText.length, + length: 1, + }); + inlineEditor.deleteText({ + index: startIndex + annotatedText.length - 2, + length: 2, + }); + inlineEditor.deleteText({ + index: startIndex, + length: 2, + }); + + inlineEditor.setInlineRange({ + index: startIndex + annotatedText.length - 4, + length: 0, + }); + + return KEYBOARD_PREVENT_DEFAULT; + }, + }, + { + name: 'underthrough', + pattern: /(?:~)([^~ \n](.+?[^~ \n])?)(?:~)$/g, + action: ({ + inlineEditor, + prefixText, + inlineRange, + pattern, + undoManager, + }) => { + const match = pattern.exec(prefixText); + if (!match) { + return KEYBOARD_ALLOW_DEFAULT; + } + const annotatedText = match[0]; + const startIndex = inlineRange.index - annotatedText.length; + + inlineEditor.insertText( + { + index: startIndex + annotatedText.length, + length: 0, + }, + ' ' + ); + + undoManager.stopCapturing(); + + inlineEditor.formatText( + { + index: startIndex, + length: annotatedText.length, + }, + { + underline: true, + } + ); + + inlineEditor.deleteText({ + index: startIndex + annotatedText.length, + length: 1, + }); + inlineEditor.deleteText({ + index: inlineRange.index - 1, + length: 1, + }); + inlineEditor.deleteText({ + index: startIndex, + length: 1, + }); + + inlineEditor.setInlineRange({ + index: startIndex + annotatedText.length - 2, + length: 0, + }); + + return KEYBOARD_PREVENT_DEFAULT; + }, + }, + { + name: 'code', + pattern: /(?:`)(`{2,}?|[^`]+)(?:`)$/g, + action: ({ + inlineEditor, + prefixText, + inlineRange, + pattern, + undoManager, + }) => { + const match = pattern.exec(prefixText); + if (!match) { + return KEYBOARD_ALLOW_DEFAULT; + } + const annotatedText = match[0]; + const startIndex = inlineRange.index - annotatedText.length; + + if (prefixText.match(/^([* \n]+)$/g)) { + return KEYBOARD_ALLOW_DEFAULT; + } + + inlineEditor.insertText( + { + index: startIndex + annotatedText.length, + length: 0, + }, + ' ' + ); + + undoManager.stopCapturing(); + + inlineEditor.formatText( + { + index: startIndex, + length: annotatedText.length, + }, + { + code: true, + } + ); + + inlineEditor.deleteText({ + index: startIndex + annotatedText.length, + length: 1, + }); + inlineEditor.deleteText({ + index: startIndex + annotatedText.length - 1, + length: 1, + }); + inlineEditor.deleteText({ + index: startIndex, + length: 1, + }); + + inlineEditor.setInlineRange({ + index: startIndex + annotatedText.length - 2, + length: 0, + }); + + return KEYBOARD_PREVENT_DEFAULT; + }, + }, +]; diff --git a/blocksuite/playground/examples/inline/test-page.ts b/blocksuite/playground/examples/inline/test-page.ts new file mode 100644 index 0000000000..fbcf9e65db --- /dev/null +++ b/blocksuite/playground/examples/inline/test-page.ts @@ -0,0 +1,463 @@ +/* eslint-disable @typescript-eslint/no-non-null-assertion */ +import '@shoelace-style/shoelace'; + +import { ShadowlessElement } from '@blocksuite/block-std'; +import { + type AttributeRenderer, + type BaseTextAttributes, + baseTextAttributes, + createInlineKeyDownHandler, + InlineEditor, + KEYBOARD_ALLOW_DEFAULT, + ZERO_WIDTH_NON_JOINER, +} from '@blocksuite/inline'; +import { effects } from '@blocksuite/inline/effects'; +import { effect } from '@preact/signals-core'; +import { css, html, nothing } from 'lit'; +import { customElement, property, query } from 'lit/decorators.js'; +import { styleMap } from 'lit/directives/style-map.js'; +import * as Y from 'yjs'; +import { z } from 'zod'; + +import { markdownMatches } from './markdown.js'; + +effects(); + +function inlineTextStyles( + props: BaseTextAttributes +): ReturnType<typeof styleMap> { + let textDecorations = ''; + if (props.underline) { + textDecorations += 'underline'; + } + if (props.strike) { + textDecorations += ' line-through'; + } + + let inlineCodeStyle = {}; + if (props.code) { + inlineCodeStyle = { + 'font-family': + '"SFMono-Regular", Menlo, Consolas, "PT Mono", "Liberation Mono", Courier, monospace', + 'line-height': 'normal', + background: 'rgba(135,131,120,0.15)', + color: '#EB5757', + 'border-radius': '3px', + 'font-size': '85%', + padding: '0.2em 0.4em', + }; + } + + return styleMap({ + 'font-weight': props.bold ? 'bold' : 'normal', + 'font-style': props.italic ? 'italic' : 'normal', + 'text-decoration': textDecorations.length > 0 ? textDecorations : 'none', + ...inlineCodeStyle, + }); +} + +const attributeRenderer: AttributeRenderer = ({ delta, selected }) => { + // @ts-expect-error ignore + if (delta.attributes?.embed) { + return html`<span + style=${styleMap({ + padding: '0 0.4em', + border: selected ? '1px solid #eb763a' : '', + background: 'rgba(135,131,120,0.15)', + })} + >@flrande<v-text .str=${ZERO_WIDTH_NON_JOINER}></v-text + ></span>`; + } + + const style = delta.attributes + ? inlineTextStyles(delta.attributes) + : styleMap({}); + + return html`<span style=${style} + ><v-text .str=${delta.insert}></v-text + ></span>`; +}; + +function toggleStyle( + inlineEditor: InlineEditor, + attrs: NonNullable<BaseTextAttributes> +): void { + const inlineRange = inlineEditor.getInlineRange(); + if (!inlineRange) { + return; + } + + const root = inlineEditor.rootElement; + if (!root) { + return; + } + + const deltas = inlineEditor.getDeltasByInlineRange(inlineRange); + let oldAttributes: NonNullable<BaseTextAttributes> = {}; + + for (const [delta] of deltas) { + const attributes = delta.attributes; + + if (!attributes) { + continue; + } + + oldAttributes = { ...attributes }; + } + + const newAttributes = Object.fromEntries( + Object.entries(attrs).map(([k, v]) => { + if ( + typeof v === 'boolean' && + v === (oldAttributes as Record<string, unknown>)[k] + ) { + return [k, null]; + } else { + return [k, v]; + } + }) + ); + + inlineEditor.formatText(inlineRange, newAttributes, { + mode: 'merge', + }); + root.blur(); + + inlineEditor.setInlineRange(inlineRange); +} + +@customElement('test-rich-text') +export class TestRichText extends ShadowlessElement { + override firstUpdated() { + this.contentEditable = 'true'; + this.style.outline = 'none'; + this.inlineEditor.mount(this._container, this); + + const keydownHandler = createInlineKeyDownHandler(this.inlineEditor, { + inputRule: { + key: ' ', + handler: context => { + const { inlineEditor, prefixText, inlineRange } = context; + for (const match of markdownMatches) { + const matchedText = prefixText.match(match.pattern); + if (matchedText) { + return match.action({ + inlineEditor, + prefixText, + inlineRange, + pattern: match.pattern, + undoManager: this.undoManager, + }); + } + } + + return KEYBOARD_ALLOW_DEFAULT; + }, + }, + }); + this.addEventListener('keydown', keydownHandler); + + this.inlineEditor.slots.textChange.on(() => { + const el = this.querySelector('.y-text'); + if (el) { + const text = this.inlineEditor.yText.toDelta(); + const span = document.createElement('span'); + span.innerHTML = JSON.stringify(text); + el.replaceChildren(span); + } + }); + effect(() => { + const inlineRange = this.inlineEditor.inlineRange$.value; + const el = this.querySelector('.v-range'); + if (el && inlineRange) { + const span = document.createElement('span'); + span.innerHTML = JSON.stringify(inlineRange); + el.replaceChildren(span); + } + }); + } + + override render() { + return html`<style> + test-rich-text { + display: grid; + grid-template-rows: minmax(0, 3fr) minmax(0, 1fr) minmax(0, 1fr); + grid-template-columns: minmax(0, 1fr); + width: 100%; + } + + .rich-text-container { + outline: none; + } + + code { + font-family: 'SFMono-Regular', Menlo, Consolas, 'PT Mono', + 'Liberation Mono', Courier, monospace; + line-height: normal; + background: rgba(135, 131, 120, 0.15); + color: #eb5757; + border-radius: 3px; + font-size: 85%; + padding: 0.2em 0.4em; + } + + .v-range, + .y-text { + font-family: 'SFMono-Regular', Menlo, Consolas, 'PT Mono', + 'Liberation Mono', Courier, monospace; + line-height: normal; + background: rgba(135, 131, 120, 0.15); + } + + .v-range, + .y-text > span { + display: block; + word-wrap: break-word; + } + </style> + <div class="rich-text-container"></div> + <div contenteditable="false" class="v-range"></div> + <div contenteditable="false" class="y-text"></div>`; + } + + @query('.rich-text-container') + private accessor _container!: HTMLDivElement; + + @property({ attribute: false }) + accessor inlineEditor!: InlineEditor; + + @property({ attribute: false }) + accessor undoManager!: Y.UndoManager; +} + +const TEXT_ID = 'inline-editor'; +const yDocA = new Y.Doc(); +const yDocB = new Y.Doc(); + +yDocA.on('update', update => { + Y.applyUpdate(yDocB, update); +}); + +yDocB.on('update', update => { + Y.applyUpdate(yDocA, update); +}); + +@customElement('custom-toolbar') +export class CustomToolbar extends ShadowlessElement { + static override styles = css` + .custom-toolbar { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + grid-template-rows: repeat(2, minmax(0, 1fr)); + } + `; + + override firstUpdated() { + const boldButton = this.querySelector('.bold'); + const italicButton = this.querySelector('.italic'); + const underlineButton = this.querySelector('.underline'); + const strikeButton = this.querySelector('.strike'); + const code = this.querySelector('.code'); + const embed = this.querySelector('.embed'); + const resetButton = this.querySelector('.reset'); + const undoButton = this.querySelector('.undo'); + const redoButton = this.querySelector('.redo'); + + if ( + !boldButton || + !italicButton || + !underlineButton || + !strikeButton || + !code || + !embed || + !resetButton || + !undoButton || + !redoButton + ) { + throw new Error('Cannot find button'); + } + + const undoManager = new Y.UndoManager(this.inlineEditor.yText, { + trackedOrigins: new Set([this.inlineEditor.yText.doc?.clientID]), + }); + + addEventListener('keydown', e => { + if ( + e instanceof KeyboardEvent && + (e.ctrlKey || e.metaKey) && + e.key === 'z' + ) { + e.preventDefault(); + if (e.shiftKey) { + undoManager.redo(); + } else { + undoManager.undo(); + } + } + }); + + undoButton.addEventListener('click', () => { + undoManager.undo(); + }); + redoButton.addEventListener('click', () => { + undoManager.redo(); + }); + + boldButton.addEventListener('click', () => { + undoManager.stopCapturing(); + toggleStyle(this.inlineEditor, { bold: true }); + }); + italicButton.addEventListener('click', () => { + undoManager.stopCapturing(); + toggleStyle(this.inlineEditor, { italic: true }); + }); + underlineButton.addEventListener('click', () => { + undoManager.stopCapturing(); + toggleStyle(this.inlineEditor, { underline: true }); + }); + strikeButton.addEventListener('click', () => { + undoManager.stopCapturing(); + toggleStyle(this.inlineEditor, { strike: true }); + }); + code.addEventListener('click', () => { + undoManager.stopCapturing(); + toggleStyle(this.inlineEditor, { code: true }); + }); + embed.addEventListener('click', () => { + undoManager.stopCapturing(); + // @ts-expect-error ignore + toggleStyle(this.inlineEditor, { embed: true }); + }); + resetButton.addEventListener('click', () => { + undoManager.stopCapturing(); + const rangeStatic = this.inlineEditor.getInlineRange(); + if (!rangeStatic) { + return; + } + this.inlineEditor.resetText(rangeStatic); + }); + } + + override render() { + return html` + <div class="custom-toolbar"> + <sl-button class="bold">bold</sl-button> + <sl-button class="italic">italic</sl-button> + <sl-button class="underline">underline</sl-button> + <sl-button class="strike">strike</sl-button> + <sl-button class="code">code</sl-button> + <sl-button class="embed">embed</sl-button> + <sl-button class="reset">reset</sl-button> + <sl-button class="undo">undo</sl-button> + <sl-button class="redo">redo</sl-button> + </div> + `; + } + + @property({ attribute: false }) + accessor inlineEditor!: InlineEditor; + + @property({ attribute: false }) + accessor undoManager!: Y.UndoManager; +} + +@customElement('test-page') +export class TestPage extends ShadowlessElement { + static override styles = css` + .container { + display: grid; + height: 100vh; + width: 100vw; + justify-content: center; + align-items: center; + } + + .editors { + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); + padding: 20px; + background-color: #202124; + border-radius: 10px; + color: #fff; + grid-gap: 20px; + } + + .editors > div { + height: 600px; + max-width: 400px; + display: grid; + grid-template-rows: 150px minmax(0, 1fr); + grid-template-columns: minmax(0, 1fr); + overflow-y: scroll; + } + `; + + private _editorA: InlineEditor | null = null; + + private _editorB: InlineEditor | null = null; + + private _undoManagerA: Y.UndoManager | null = null; + + private _undoManagerB: Y.UndoManager | null = null; + + override firstUpdated() { + const textA = yDocA.getText(TEXT_ID); + this._editorA = new InlineEditor< + BaseTextAttributes & { + embed?: true; + } + >(textA, { + isEmbed: delta => !!delta.attributes?.embed, + }); + this._editorA.setAttributeSchema( + baseTextAttributes.extend({ + embed: z.literal(true).optional().catch(undefined), + }) + ); + this._editorA.setAttributeRenderer(attributeRenderer); + this._undoManagerA = new Y.UndoManager(textA, { + trackedOrigins: new Set([textA.doc?.clientID]), + }); + + const textB = yDocB.getText(TEXT_ID); + this._editorB = new InlineEditor(textB); + this._undoManagerB = new Y.UndoManager(textB, { + trackedOrigins: new Set([textB.doc?.clientID]), + }); + + this.requestUpdate(); + } + + override render() { + if (!this._editorA) { + return nothing; + } + + return html` + <div class="container"> + <div class="editors"> + <div class="doc-a"> + <custom-toolbar + .inlineEditor=${this._editorA} + .undoManager=${this._undoManagerA} + ></custom-toolbar> + <test-rich-text + .inlineEditor=${this._editorA} + .undoManager=${this._undoManagerA!} + ></test-rich-text> + </div> + <div class="doc-b"> + <custom-toolbar + .inlineEditor=${this._editorB} + .undoManager=${this._undoManagerB!} + ></custom-toolbar> + <test-rich-text + .inlineEditor=${this._editorB} + .undoManager=${this._undoManagerB} + ></test-rich-text> + </div> + </div> + </div> + `; + } +} diff --git a/blocksuite/playground/examples/multiple-editors/edgeless-edgeless/index.html b/blocksuite/playground/examples/multiple-editors/edgeless-edgeless/index.html new file mode 100644 index 0000000000..ce9616a52b --- /dev/null +++ b/blocksuite/playground/examples/multiple-editors/edgeless-edgeless/index.html @@ -0,0 +1,23 @@ +<!doctype html> +<html lang="en"> + <head> + <meta charset="UTF-8" /> + <link rel="icon" type="image/svg+xml" href="/vite.svg" /> + <meta name="viewport" content="width=device-width, initial-scale=1.0" /> + <title>Edgeless+Edgeless Multiple-Editors Example</title> + <style> + html, + body { + height: 100%; + overflow: hidden; + margin: 0; + padding: 0; + background-color: var(--affine-white-90); + transition: background-color 0.3s; + } + </style> + </head> + <body> + <script type="module" src="./main.ts"></script> + </body> +</html> diff --git a/blocksuite/playground/examples/multiple-editors/edgeless-edgeless/main.ts b/blocksuite/playground/examples/multiple-editors/edgeless-edgeless/main.ts new file mode 100644 index 0000000000..81d51ce652 --- /dev/null +++ b/blocksuite/playground/examples/multiple-editors/edgeless-edgeless/main.ts @@ -0,0 +1,23 @@ +import '../../../style.css'; + +import { createEmptyDoc, EdgelessEditor } from '@blocksuite/presets'; + +const container = document.createElement('div'); +container.style.display = 'flex'; +container.style.height = '100%'; +container.style.width = '100%'; +document.body.append(container); + +const doc1 = createEmptyDoc().init(); +const editor1 = new EdgelessEditor(); +editor1.doc = doc1; +editor1.style.flex = '1'; +editor1.style.borderRight = '1px solid #ccc'; +container.append(editor1); + +const doc2 = createEmptyDoc().init(); +const editor2 = new EdgelessEditor(); +editor2.doc = doc2; +editor2.style.flex = '1'; +editor2.style.borderLeft = '1px solid #ccc'; +container.append(editor2); diff --git a/blocksuite/playground/examples/multiple-editors/page-edgeless/index.html b/blocksuite/playground/examples/multiple-editors/page-edgeless/index.html new file mode 100644 index 0000000000..e8948eb2f8 --- /dev/null +++ b/blocksuite/playground/examples/multiple-editors/page-edgeless/index.html @@ -0,0 +1,23 @@ +<!doctype html> +<html lang="en"> + <head> + <meta charset="UTF-8" /> + <link rel="icon" type="image/svg+xml" href="/vite.svg" /> + <meta name="viewport" content="width=device-width, initial-scale=1.0" /> + <title>Doc+Edgeless Multiple-Editors Example</title> + <style> + html, + body { + height: 100%; + overflow: hidden; + margin: 0; + padding: 0; + background-color: var(--affine-white-90); + transition: background-color 0.3s; + } + </style> + </head> + <body> + <script type="module" src="./main.ts"></script> + </body> +</html> diff --git a/blocksuite/playground/examples/multiple-editors/page-edgeless/main.ts b/blocksuite/playground/examples/multiple-editors/page-edgeless/main.ts new file mode 100644 index 0000000000..425f854a2a --- /dev/null +++ b/blocksuite/playground/examples/multiple-editors/page-edgeless/main.ts @@ -0,0 +1,27 @@ +import '../../../style.css'; + +import { + createEmptyDoc, + EdgelessEditor, + PageEditor, +} from '@blocksuite/presets'; + +const container = document.createElement('div'); +container.style.display = 'flex'; +container.style.height = '100%'; +container.style.width = '100%'; +document.body.append(container); + +const doc1 = createEmptyDoc().init(); +const editor1 = new PageEditor(); +editor1.doc = doc1; +editor1.style.flex = '2'; +editor1.style.borderRight = '1px solid #ccc'; +container.append(editor1); + +const doc2 = createEmptyDoc().init(); +const editor2 = new EdgelessEditor(); +editor2.doc = doc2; +editor2.style.flex = '3'; +editor2.style.borderLeft = '1px solid #ccc'; +container.append(editor2); diff --git a/blocksuite/playground/examples/multiple-editors/page-page/index.html b/blocksuite/playground/examples/multiple-editors/page-page/index.html new file mode 100644 index 0000000000..d7ae1fdf0f --- /dev/null +++ b/blocksuite/playground/examples/multiple-editors/page-page/index.html @@ -0,0 +1,23 @@ +<!doctype html> +<html lang="en"> + <head> + <meta charset="UTF-8" /> + <link rel="icon" type="image/svg+xml" href="/vite.svg" /> + <meta name="viewport" content="width=device-width, initial-scale=1.0" /> + <title>Doc+Doc Multiple-Editors Example</title> + <style> + html, + body { + height: 100%; + overflow: hidden; + margin: 0; + padding: 0; + background-color: var(--affine-white-90); + transition: background-color 0.3s; + } + </style> + </head> + <body> + <script type="module" src="./main.ts"></script> + </body> +</html> diff --git a/blocksuite/playground/examples/multiple-editors/page-page/main.ts b/blocksuite/playground/examples/multiple-editors/page-page/main.ts new file mode 100644 index 0000000000..900c0e7520 --- /dev/null +++ b/blocksuite/playground/examples/multiple-editors/page-page/main.ts @@ -0,0 +1,23 @@ +import '../../../style.css'; + +import { createEmptyDoc, PageEditor } from '@blocksuite/presets'; + +const container = document.createElement('div'); +container.style.display = 'flex'; +container.style.height = '100%'; +container.style.width = '100%'; +document.body.append(container); + +const doc1 = createEmptyDoc().init(); +const editor1 = new PageEditor(); +editor1.doc = doc1; +editor1.style.flex = '1'; +editor1.style.borderRight = '1px solid #ccc'; +container.append(editor1); + +const doc2 = createEmptyDoc().init(); +const editor2 = new PageEditor(); +editor2.doc = doc2; +editor2.style.flex = '1'; +editor2.style.borderLeft = '1px solid #ccc'; +container.append(editor2); diff --git a/blocksuite/playground/index.html b/blocksuite/playground/index.html new file mode 100644 index 0000000000..bff4200e2c --- /dev/null +++ b/blocksuite/playground/index.html @@ -0,0 +1,54 @@ +<!doctype html> +<html lang="en"> + <head> + <meta charset="UTF-8" /> + <link rel="icon" type="image/svg+xml" href="/logo.svg" /> + <link rel="preconnect" href="https://fonts.googleapis.com" /> + <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin /> + <meta name="viewport" content="width=device-width, initial-scale=1.0" /> + <link + href="https://fonts.googleapis.com/css2?family=Kalam&display=swap" + rel="stylesheet" + /> + <link + href="https://fonts.googleapis.com/css2?family=IBM+Plex+Mono&display=swap" + rel="stylesheet" + /> + <title>BlockSuite Playground</title> + <style> + html, + body { + height: 100%; + overflow: hidden; + margin: 0; + padding: 0; + background-color: var(--affine-white-90); + transition: background-color 0.3s; + } + + * { + box-sizing: border-box; + } + + doc-title { + margin-top: 78px; + } + + @media print { + doc-title { + margin-top: 0px; + } + } + </style> + </head> + + <body> + <script type="module" src="./apps/default/main.ts"></script> + <div + id="app" + style="margin: 0; overflow: initial; height: 100%; box-shadow: initial" + > + <div id="inspector"></div> + </div> + </body> +</html> diff --git a/blocksuite/playground/package.json b/blocksuite/playground/package.json new file mode 100644 index 0000000000..6120359211 --- /dev/null +++ b/blocksuite/playground/package.json @@ -0,0 +1,46 @@ +{ + "name": "@blocksuite/playground", + "private": true, + "type": "module", + "scripts": { + "dev": "vite --host", + "dev:hmr": "WC_HMR=1 vite", + "build": "tsc && nx vite:build", + "preview": "vite preview" + }, + "dependencies": { + "@blocksuite/affine-components": "workspace:*", + "@blocksuite/affine-model": "workspace:*", + "@blocksuite/block-std": "workspace:*", + "@blocksuite/blocks": "workspace:*", + "@blocksuite/data-view": "workspace:*", + "@blocksuite/global": "workspace:*", + "@blocksuite/inline": "workspace:*", + "@blocksuite/presets": "workspace:*", + "@blocksuite/store": "workspace:*", + "@blocksuite/sync": "workspace:*", + "@preact/signals-core": "^1.8.0", + "@shoelace-style/shoelace": "2.19.0", + "@toeverything/pdf-viewer": "^0.1.1", + "@toeverything/y-indexeddb": "0.10.0-canary.9", + "@types/katex": "^0.16.7", + "browser-fs-access": "^0.35.0", + "jszip": "^3.10.1", + "lit": "^3.2.0", + "lz-string": "^1.5.0", + "tweakpane": "^4.0.4", + "y-indexeddb": "^9.0.12", + "y-protocols": "^1.0.6", + "yjs": "patch:yjs@npm%3A13.6.18#~/.yarn/patches/yjs-npm-13.6.18-ad0d5f7c43.patch", + "zod": "^3.23.8" + }, + "license": "MIT", + "devDependencies": { + "@tweakpane/core": "^2.0.4", + "@types/micromatch": "^4.0.9", + "graphql": "^16.9.0", + "magic-string": "^0.30.11", + "vite-plugin-wasm": "^3.3.0", + "vite-plugin-web-components-hmr": "^0.1.3" + } +} diff --git a/blocksuite/playground/public/logo.svg b/blocksuite/playground/public/logo.svg new file mode 100644 index 0000000000..0fda3514a3 --- /dev/null +++ b/blocksuite/playground/public/logo.svg @@ -0,0 +1 @@ +<?xml version="1.0" encoding="UTF-8"?><svg id="logo" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><defs><style>.cls-1{fill:#000000;}</style></defs><g id="_20230202-pull_request"><path class="cls-1" d="m512,133.82L342.5,0,0,67.88v310.26l169.56,133.86,342.44-67.88V133.82Zm-203.39-84.81v89.67l51.84-10.27v-56.25l74.87,59.09-249.93,49.53-108.71-85.8,231.93-45.96ZM51.84,133.32l99.71,78.7v93.92l51.84-10.27v-76.15l256.78-50.89v210.06l-99.71-78.7v-93.92l-51.84,10.27v76.16l-256.78,50.89v-210.06Zm151.55,329.67v-89.67l-51.84,10.27v56.25l-74.87-59.09,249.93-49.53,108.71,85.8-231.93,45.96Z"/></g></svg> diff --git a/blocksuite/playground/public/test-card-1.png b/blocksuite/playground/public/test-card-1.png new file mode 100644 index 0000000000..46a193573f Binary files /dev/null and b/blocksuite/playground/public/test-card-1.png differ diff --git a/blocksuite/playground/public/test-card-2.png b/blocksuite/playground/public/test-card-2.png new file mode 100644 index 0000000000..7047dcab58 Binary files /dev/null and b/blocksuite/playground/public/test-card-2.png differ diff --git a/blocksuite/playground/scripts/hmr-plugin/fine-tune.ts b/blocksuite/playground/scripts/hmr-plugin/fine-tune.ts new file mode 100644 index 0000000000..dfa6bc8599 --- /dev/null +++ b/blocksuite/playground/scripts/hmr-plugin/fine-tune.ts @@ -0,0 +1,65 @@ +/* eslint-disable @typescript-eslint/no-non-null-assertion */ +import path from 'node:path'; + +import { init, parse } from 'es-module-lexer'; +import MagicString from 'magic-string'; +import micromatch from 'micromatch'; +import type { Plugin } from 'vite'; +const isMatch = micromatch.isMatch; + +export function fineTuneHmr({ + include, + exclude, +}: { + include: string[]; + exclude: string[]; +}): Plugin { + let root = ''; + const plugin: Plugin = { + name: 'add-hot-for-pure-exports', + apply: 'serve', + configResolved(config) { + root = config.root; + }, + async configureServer() { + await init; + }, + transform: (code, id) => { + // only handle js/ts files + const includeGlob = include.map(i => path.resolve(root, i)); + const excludeGlob = exclude.map(i => path.resolve(root, i)); + const isInScope = isMatch(id, includeGlob) && !isMatch(id, excludeGlob); + if (!isInScope) return; + if (!(id.endsWith('.js') || id.endsWith('.ts'))) return; + // only handle files which not contains Lit elements + if (code.includes('import.meta.hot')) return; + + const [imports, exports] = parse(code, id); + if (exports.length === 0 && imports.length > 0) { + const modules = imports.map(i => i.n); + const modulesEndsWithTs = modules + .filter(Boolean) + .map(m => m!.replace(/\.js$/, '.ts')); + const preamble = ` + if (import.meta.hot) { + import.meta.hot.accept(${JSON.stringify( + modulesEndsWithTs + )}, data => { + // some update logic + }); + } + `; + + const s = new MagicString(code); + s.prepend(preamble + '\n'); + return { + code: s.toString(), + map: s.generateMap({ hires: true, source: id, includeContent: true }), + }; + } + return; + }, + }; + + return plugin; +} diff --git a/blocksuite/playground/scripts/hmr-plugin/index.ts b/blocksuite/playground/scripts/hmr-plugin/index.ts new file mode 100644 index 0000000000..98bb3721c5 --- /dev/null +++ b/blocksuite/playground/scripts/hmr-plugin/index.ts @@ -0,0 +1,38 @@ +import path from 'node:path'; + +import { + hmrPlugin as wcHmrPlugin, + presets, +} from 'vite-plugin-web-components-hmr'; + +import { fineTuneHmr } from './fine-tune.js'; + +const customLitPath = path.resolve( + __dirname, + '../../../blocks/src/_legacy/index.js' +); + +const include = ['../blocks/src/**/*']; +const exclude = ['**/*/node_modules/**/*']; + +// https://vitejs.dev/config/ +export const hmrPlugin = process.env.WC_HMR + ? [ + wcHmrPlugin({ + include, + exclude, + presets: [presets.lit], + decorators: [{ name: 'customElement', import: 'lit/decorators.js' }], + baseClasses: [ + { + name: 'ShadowlessElement', + import: customLitPath, + }, + ], + }), + fineTuneHmr({ + include, + exclude, + }), + ] + : []; diff --git a/blocksuite/playground/starter/index.html b/blocksuite/playground/starter/index.html new file mode 100644 index 0000000000..d6b9a407e1 --- /dev/null +++ b/blocksuite/playground/starter/index.html @@ -0,0 +1,56 @@ +<!doctype html> +<html lang="en"> + <head> + <meta charset="UTF-8" /> + <link rel="icon" type="image/svg+xml" href="/logo.svg" /> + <link rel="preconnect" href="https://fonts.googleapis.com" /> + <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin /> + <link + href="https://fonts.googleapis.com/css2?family=Kalam&display=swap" + rel="stylesheet" + /> + <meta name="viewport" content="width=device-width, initial-scale=1.0" /> + <title>BlockSuite Playground</title> + <style> + html, + body { + height: 100%; + overflow: hidden; + margin: 0; + padding: 0; + background-color: var(--affine-white-90); + transition: background-color 0.3s; + } + + * { + box-sizing: border-box; + } + + doc-title { + margin-top: 78px; + } + + :root .tp-dfwv { + position: fixed; + overflow: scroll; + + top: 0; + bottom: 0; + right: 0; + } + + @media print { + doc-title { + margin-top: 0px; + } + } + </style> + </head> + + <body> + <script type="module" src="../apps/starter/main.ts"></script> + <div id="app"> + <div id="inspector"></div> + </div> + </body> +</html> diff --git a/blocksuite/playground/style.css b/blocksuite/playground/style.css new file mode 100644 index 0000000000..7928003452 --- /dev/null +++ b/blocksuite/playground/style.css @@ -0,0 +1,12 @@ +@import '@toeverything/theme/style.css'; + +@font-face { + font-family: 'color-emoji'; + src: local('Apple Color Emoji'), local('Segoe UI Emoji'), + local('Segoe UI Symbol'), local('Noto Color Emoji'); + unicode-range: U+1F000-1F644, U+203C-3299; +} + +.dg > ul { + overflow: scroll; +} diff --git a/blocksuite/playground/tsconfig.json b/blocksuite/playground/tsconfig.json new file mode 100644 index 0000000000..4aa50535ad --- /dev/null +++ b/blocksuite/playground/tsconfig.json @@ -0,0 +1,20 @@ +{ + "extends": "../tsconfig.json", + "compilerOptions": { + "target": "ESNext", + "useDefineForClassFields": false, + "lib": ["DOM", "DOM.Iterable", "ESNext"], + "allowJs": false, + "skipLibCheck": true, + "esModuleInterop": false, + "allowSyntheticDefaultImports": true, + "strict": true, + "forceConsistentCasingInFileNames": true, + "module": "ESNext", + "moduleResolution": "Bundler", + "noEmit": true, + "composite": false + }, + "include": ["./apps", "./examples"], + "references": [{ "path": "./tsconfig.node.json" }] +} diff --git a/blocksuite/playground/tsconfig.node.json b/blocksuite/playground/tsconfig.node.json new file mode 100644 index 0000000000..e649a770c5 --- /dev/null +++ b/blocksuite/playground/tsconfig.node.json @@ -0,0 +1,9 @@ +{ + "compilerOptions": { + "composite": true, + "module": "ESNext", + "moduleResolution": "Node", + "allowSyntheticDefaultImports": true + }, + "include": ["vite.config.ts", "./scripts"] +} diff --git a/blocksuite/playground/vite.config.ts b/blocksuite/playground/vite.config.ts new file mode 100644 index 0000000000..f48dd1adb0 --- /dev/null +++ b/blocksuite/playground/vite.config.ts @@ -0,0 +1,261 @@ +import fs from 'node:fs'; +import { createRequire } from 'node:module'; +import { cpus } from 'node:os'; +import path, { resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import type { GetManualChunk } from 'rollup'; +import type { Plugin } from 'vite'; +import { defineConfig, loadEnv } from 'vite'; +import istanbul from 'vite-plugin-istanbul'; +import wasm from 'vite-plugin-wasm'; + +import { hmrPlugin } from './scripts/hmr-plugin'; + +const require = createRequire(import.meta.url); +const enableIstanbul = !!process.env.COVERAGE; +const chunkSizeReport = !!process.env.CHUNK_SIZE_REPORT; + +const cache = new Map(); + +export function sourcemapExclude(): Plugin { + return { + name: 'sourcemap-exclude', + transform(code: string, id: string) { + if (id.includes('node_modules') && !id.includes('@blocksuite')) { + return { + code, + // https://github.com/rollup/rollup/blob/master/docs/plugin-development/index.md#source-code-transformations + map: { mappings: '' }, + }; + } + }, + }; +} + +type GetModuleInfo = Parameters<GetManualChunk>[1]['getModuleInfo']; + +function isDepInclude( + id: string, + depPaths: string[], + importChain: string[], + getModuleInfo: GetModuleInfo +): boolean | undefined { + const key = `${id}-${depPaths.join('|')}`; + if (importChain.includes(id)) { + cache.set(key, false); + return false; + } + if (cache.has(key)) { + return cache.get(key); + } + for (const depPath of depPaths) { + if (id.includes(depPath)) { + importChain.forEach(item => + cache.set(`${item}-${depPaths.join('|')}`, true) + ); + return true; + } + } + const moduleInfo = getModuleInfo(id); + if (!moduleInfo || !moduleInfo.importers) { + cache.set(key, false); + return false; + } + const isInclude = moduleInfo.importers.some(importer => + isDepInclude(importer, depPaths, importChain.concat(id), getModuleInfo) + ); + cache.set(key, isInclude); + return isInclude; +} + +const chunkGroups = { + framework: [ + require.resolve('@blocksuite/block-std'), + require.resolve('@blocksuite/block-std/gfx'), + require.resolve('@blocksuite/global'), + require.resolve('@blocksuite/global/utils'), + require.resolve('@blocksuite/global/env'), + require.resolve('@blocksuite/global/exceptions'), + require.resolve('@blocksuite/global/di'), + require.resolve('@blocksuite/inline'), + require.resolve('@blocksuite/store'), + require.resolve('@blocksuite/sync'), + ], + components: [ + require.resolve('@blocksuite/affine-components/icons'), + require.resolve('@blocksuite/affine-components/peek'), + require.resolve('@blocksuite/affine-components/portal'), + require.resolve('@blocksuite/affine-components/hover'), + require.resolve('@blocksuite/affine-components/toolbar'), + require.resolve('@blocksuite/affine-components/toast'), + require.resolve('@blocksuite/affine-components/rich-text'), + require.resolve('@blocksuite/affine-components/caption'), + require.resolve('@blocksuite/affine-components/context-menu'), + require.resolve('@blocksuite/affine-components/date-picker'), + require.resolve('@blocksuite/affine-components/drag-indicator'), + ], + affine: [ + require.resolve('@blocksuite/affine-shared'), + require.resolve('@blocksuite/affine-model'), + require.resolve('@blocksuite/affine-block-list'), + require.resolve('@blocksuite/affine-block-paragraph'), + require.resolve('@blocksuite/affine-block-surface'), + require.resolve('@blocksuite/data-view'), + ], + datefns: [path.dirname(require.resolve('date-fns'))], + dompurify: [path.dirname(require.resolve('dompurify'))], + shiki: [path.dirname(require.resolve('@shikijs/core'))], + dotLottie: [path.dirname(require.resolve('@lottiefiles/dotlottie-wc'))], + unified: [ + path.dirname(require.resolve('unified')), + path.dirname(require.resolve('rehype-parse')), + path.dirname(require.resolve('rehype-stringify')), + path.dirname(require.resolve('remark-parse')), + path.dirname(require.resolve('remark-stringify')), + path.dirname(require.resolve('mdast-util-gfm-autolink-literal')), + path.dirname(require.resolve('mdast-util-gfm-strikethrough')), + path.dirname(require.resolve('mdast-util-gfm-table')), + path.dirname(require.resolve('mdast-util-gfm-task-list-item')), + path.dirname(require.resolve('micromark-extension-gfm-autolink-literal')), + path.dirname(require.resolve('micromark-extension-gfm-strikethrough')), + path.dirname(require.resolve('micromark-extension-gfm-table')), + path.dirname(require.resolve('micromark-extension-gfm-task-list-item')), + path.dirname(require.resolve('micromark-util-combine-extensions')), + ], + blocks: [ + require.resolve('@blocksuite/blocks'), + require.resolve('@blocksuite/blocks/schemas'), + ], + presets: [require.resolve('@blocksuite/presets')], + common: [ + require.resolve('@blocksuite/icons/lit'), + require.resolve('@toeverything/theme'), + require.resolve('@toeverything/y-indexeddb'), + require.resolve('@preact/signals-core'), + require.resolve('@lit/context'), + require.resolve('lit'), + require.resolve('zod'), + require.resolve('minimatch'), + require.resolve('nanoid'), + require.resolve('yjs'), + ], +}; + +const clearSiteDataPlugin = () => + ({ + name: 'clear-site-data', + configureServer(server) { + server.middlewares.use((req, res, next) => { + if (req.url === '/Clear-Site-Data') { + res.statusCode = 200; + res.setHeader('Clear-Site-Data', '"*"'); + } + next(); + }); + }, + }) as Plugin; + +// https://vitejs.dev/config/ +export default ({ mode }) => { + process.env = { ...process.env, ...loadEnv(mode, __dirname, '') }; + + return defineConfig({ + envDir: __dirname, + define: { + 'import.meta.env.PLAYGROUND_SERVER': JSON.stringify( + process.env.PLAYGROUND_SERVER ?? 'http://localhost:8787' + ), + 'import.meta.env.PLAYGROUND_WS': JSON.stringify( + process.env.PLAYGROUND_WS ?? 'ws://localhost:8787' + ), + }, + plugins: [ + hmrPlugin, + sourcemapExclude(), + enableIstanbul && + istanbul({ + cwd: fileURLToPath(new URL('../..', import.meta.url)), + include: ['packages/**/src/*'], + exclude: [ + 'node_modules', + 'tests', + fileURLToPath(new URL('.', import.meta.url)), + ], + forceBuildInstrument: true, + }), + wasm(), + clearSiteDataPlugin(), + ], + esbuild: { + target: 'es2018', + }, + resolve: { + extensions: ['.ts', '.js'], + }, + build: { + target: 'es2022', + sourcemap: true, + rollupOptions: { + cache: false, + maxParallelFileOps: Math.max(1, cpus().length - 1), + onwarn(warning, defaultHandler) { + if (['EVAL', 'SOURCEMAP_ERROR'].includes(warning.code)) { + return; + } + + defaultHandler(warning); + }, + input: { + main: resolve(__dirname, 'index.html'), + 'starter/': resolve(__dirname, 'starter/index.html'), + 'examples/basic/page': resolve( + __dirname, + 'examples/basic/page/index.html' + ), + 'examples/basic/edgeless': resolve( + __dirname, + 'examples/basic/edgeless/index.html' + ), + 'examples/multiple-editors/page-page': resolve( + __dirname, + 'examples/multiple-editors/page-page/index.html' + ), + 'examples/multiple-editors/page-edgeless': resolve( + __dirname, + 'examples/multiple-editors/page-edgeless/index.html' + ), + 'examples/multiple-editors/edgeless-edgeless': resolve( + __dirname, + 'examples/multiple-editors/edgeless-edgeless/index.html' + ), + 'examples/inline': resolve(__dirname, 'examples/inline/index.html'), + }, + treeshake: true, + output: { + sourcemapIgnoreList: relativeSourcePath => { + const normalizedPath = path.normalize(relativeSourcePath); + return normalizedPath.includes('node_modules'); + }, + manualChunks(id, { getModuleInfo }) { + for (const group of Object.keys(chunkGroups)) { + const deps = chunkGroups[group]; + if (isDepInclude(id, deps, [], getModuleInfo)) { + if (chunkSizeReport && id.includes('node_modules')) { + console.log(group + ':', id); + console.log( + group + ':', + fs.statSync(id.replace('\x00', '').replace(/\?.*/, '')) + .size / 1024, + 'KB' + ); + } + return group; + } + } + }, + }, + }, + }, + }); +}; diff --git a/blocksuite/presets/README.md b/blocksuite/presets/README.md new file mode 100644 index 0000000000..a8bb170682 --- /dev/null +++ b/blocksuite/presets/README.md @@ -0,0 +1,7 @@ +# `@blocksuite/presets` + +Prebuilt editors and opt-in additional UI components built for [AFFiNE](https://affine.pro). + +## Documentation + +Checkout [blocksuite.io](https://blocksuite.io/) for comprehensive documentation. diff --git a/blocksuite/presets/package.json b/blocksuite/presets/package.json new file mode 100644 index 0000000000..e833132b11 --- /dev/null +++ b/blocksuite/presets/package.json @@ -0,0 +1,41 @@ +{ + "name": "@blocksuite/presets", + "description": "Prebuilt BlockSuite editors and opt-in additional UI components.", + "type": "module", + "scripts": { + "build": "tsc", + "test:unit": "nx vite:test --browser.headless --run", + "test:debug": "PWDEBUG=1 npx vitest" + }, + "sideEffects": false, + "keywords": [], + "author": "toeverything", + "license": "MIT", + "dependencies": { + "@blocksuite/affine-block-surface": "workspace:*", + "@blocksuite/affine-model": "workspace:*", + "@blocksuite/affine-shared": "workspace:*", + "@blocksuite/block-std": "workspace:*", + "@blocksuite/blocks": "workspace:*", + "@blocksuite/global": "workspace:*", + "@blocksuite/inline": "workspace:*", + "@blocksuite/store": "workspace:*", + "@floating-ui/dom": "^1.6.10", + "@lottiefiles/dotlottie-wc": "^0.4.0", + "@preact/signals-core": "^1.8.0", + "@toeverything/theme": "^1.1.1", + "lit": "^3.2.0", + "zod": "^3.23.8" + }, + "exports": { + ".": "./src/index.ts", + "./effects": "./src/effects.ts" + }, + "files": [ + "src", + "dist", + "themes", + "!src/__tests__", + "!dist/__tests__" + ] +} diff --git a/blocksuite/presets/src/__tests__/edgeless/__screenshots__/frame.spec.ts/frame-frame-should-always-be-placed-under-the-bottom-of-other-blocks-1.png b/blocksuite/presets/src/__tests__/edgeless/__screenshots__/frame.spec.ts/frame-frame-should-always-be-placed-under-the-bottom-of-other-blocks-1.png new file mode 100644 index 0000000000..753a8012c5 Binary files /dev/null and b/blocksuite/presets/src/__tests__/edgeless/__screenshots__/frame.spec.ts/frame-frame-should-always-be-placed-under-the-bottom-of-other-blocks-1.png differ diff --git a/blocksuite/presets/src/__tests__/edgeless/__screenshots__/frame.spec.ts/frame-frame-should-have-externalXYWH-after-moving-viewport-to-contains-frame-1.png b/blocksuite/presets/src/__tests__/edgeless/__screenshots__/frame.spec.ts/frame-frame-should-have-externalXYWH-after-moving-viewport-to-contains-frame-1.png new file mode 100644 index 0000000000..753a8012c5 Binary files /dev/null and b/blocksuite/presets/src/__tests__/edgeless/__screenshots__/frame.spec.ts/frame-frame-should-have-externalXYWH-after-moving-viewport-to-contains-frame-1.png differ diff --git a/blocksuite/presets/src/__tests__/edgeless/__screenshots__/frame.spec.ts/frame-frame-should-have-title-1.png b/blocksuite/presets/src/__tests__/edgeless/__screenshots__/frame.spec.ts/frame-frame-should-have-title-1.png new file mode 100644 index 0000000000..753a8012c5 Binary files /dev/null and b/blocksuite/presets/src/__tests__/edgeless/__screenshots__/frame.spec.ts/frame-frame-should-have-title-1.png differ diff --git a/blocksuite/presets/src/__tests__/edgeless/__screenshots__/group.spec.ts/group-group-s-xywh-should-update-automatically-when-children-change-1.png b/blocksuite/presets/src/__tests__/edgeless/__screenshots__/group.spec.ts/group-group-s-xywh-should-update-automatically-when-children-change-1.png new file mode 100644 index 0000000000..753a8012c5 Binary files /dev/null and b/blocksuite/presets/src/__tests__/edgeless/__screenshots__/group.spec.ts/group-group-s-xywh-should-update-automatically-when-children-change-1.png differ diff --git a/blocksuite/presets/src/__tests__/edgeless/__screenshots__/group.spec.ts/group-group-with-no-children-will-be-removed-automatically-1.png b/blocksuite/presets/src/__tests__/edgeless/__screenshots__/group.spec.ts/group-group-with-no-children-will-be-removed-automatically-1.png new file mode 100644 index 0000000000..753a8012c5 Binary files /dev/null and b/blocksuite/presets/src/__tests__/edgeless/__screenshots__/group.spec.ts/group-group-with-no-children-will-be-removed-automatically-1.png differ diff --git a/blocksuite/presets/src/__tests__/edgeless/__screenshots__/group.spec.ts/group-remove-group-should-remove-its-children-at-the-same-time-1.png b/blocksuite/presets/src/__tests__/edgeless/__screenshots__/group.spec.ts/group-remove-group-should-remove-its-children-at-the-same-time-1.png new file mode 100644 index 0000000000..753a8012c5 Binary files /dev/null and b/blocksuite/presets/src/__tests__/edgeless/__screenshots__/group.spec.ts/group-remove-group-should-remove-its-children-at-the-same-time-1.png differ diff --git a/blocksuite/presets/src/__tests__/edgeless/__screenshots__/group.spec.ts/mindmap-delete-the-root-node-should-remove-all-children-1.png b/blocksuite/presets/src/__tests__/edgeless/__screenshots__/group.spec.ts/mindmap-delete-the-root-node-should-remove-all-children-1.png new file mode 100644 index 0000000000..753a8012c5 Binary files /dev/null and b/blocksuite/presets/src/__tests__/edgeless/__screenshots__/group.spec.ts/mindmap-delete-the-root-node-should-remove-all-children-1.png differ diff --git a/blocksuite/presets/src/__tests__/edgeless/__screenshots__/group.spec.ts/mindmap-mindmap-should-layout-automatically-when-creating-1.png b/blocksuite/presets/src/__tests__/edgeless/__screenshots__/group.spec.ts/mindmap-mindmap-should-layout-automatically-when-creating-1.png new file mode 100644 index 0000000000..753a8012c5 Binary files /dev/null and b/blocksuite/presets/src/__tests__/edgeless/__screenshots__/group.spec.ts/mindmap-mindmap-should-layout-automatically-when-creating-1.png differ diff --git a/blocksuite/presets/src/__tests__/edgeless/__screenshots__/last-props.spec.ts/apply-last-props-brush-1.png b/blocksuite/presets/src/__tests__/edgeless/__screenshots__/last-props.spec.ts/apply-last-props-brush-1.png new file mode 100644 index 0000000000..753a8012c5 Binary files /dev/null and b/blocksuite/presets/src/__tests__/edgeless/__screenshots__/last-props.spec.ts/apply-last-props-brush-1.png differ diff --git a/blocksuite/presets/src/__tests__/edgeless/__screenshots__/last-props.spec.ts/apply-last-props-connector-1.png b/blocksuite/presets/src/__tests__/edgeless/__screenshots__/last-props.spec.ts/apply-last-props-connector-1.png new file mode 100644 index 0000000000..753a8012c5 Binary files /dev/null and b/blocksuite/presets/src/__tests__/edgeless/__screenshots__/last-props.spec.ts/apply-last-props-connector-1.png differ diff --git a/blocksuite/presets/src/__tests__/edgeless/__screenshots__/last-props.spec.ts/apply-last-props-shapes-1.png b/blocksuite/presets/src/__tests__/edgeless/__screenshots__/last-props.spec.ts/apply-last-props-shapes-1.png new file mode 100644 index 0000000000..753a8012c5 Binary files /dev/null and b/blocksuite/presets/src/__tests__/edgeless/__screenshots__/last-props.spec.ts/apply-last-props-shapes-1.png differ diff --git a/blocksuite/presets/src/__tests__/edgeless/__screenshots__/last-props.spec.ts/apply-last-props-text-1.png b/blocksuite/presets/src/__tests__/edgeless/__screenshots__/last-props.spec.ts/apply-last-props-text-1.png new file mode 100644 index 0000000000..753a8012c5 Binary files /dev/null and b/blocksuite/presets/src/__tests__/edgeless/__screenshots__/last-props.spec.ts/apply-last-props-text-1.png differ diff --git a/blocksuite/presets/src/__tests__/edgeless/__screenshots__/layer.spec.ts/a-new-layer-should-be-created-in-canvasLayers-prop-when-the-topmost-layer-is-not-canvas-layer-1.png b/blocksuite/presets/src/__tests__/edgeless/__screenshots__/layer.spec.ts/a-new-layer-should-be-created-in-canvasLayers-prop-when-the-topmost-layer-is-not-canvas-layer-1.png new file mode 100644 index 0000000000..753a8012c5 Binary files /dev/null and b/blocksuite/presets/src/__tests__/edgeless/__screenshots__/layer.spec.ts/a-new-layer-should-be-created-in-canvasLayers-prop-when-the-topmost-layer-is-not-canvas-layer-1.png differ diff --git a/blocksuite/presets/src/__tests__/edgeless/__screenshots__/layer.spec.ts/add-new-edgeless-blocks-or-canvas-elements-should-update-layer-automatically-1.png b/blocksuite/presets/src/__tests__/edgeless/__screenshots__/layer.spec.ts/add-new-edgeless-blocks-or-canvas-elements-should-update-layer-automatically-1.png new file mode 100644 index 0000000000..753a8012c5 Binary files /dev/null and b/blocksuite/presets/src/__tests__/edgeless/__screenshots__/layer.spec.ts/add-new-edgeless-blocks-or-canvas-elements-should-update-layer-automatically-1.png differ diff --git a/blocksuite/presets/src/__tests__/edgeless/__screenshots__/layer.spec.ts/blocks-should-rerender-when-their-z-index-changed-1.png b/blocksuite/presets/src/__tests__/edgeless/__screenshots__/layer.spec.ts/blocks-should-rerender-when-their-z-index-changed-1.png new file mode 100644 index 0000000000..753a8012c5 Binary files /dev/null and b/blocksuite/presets/src/__tests__/edgeless/__screenshots__/layer.spec.ts/blocks-should-rerender-when-their-z-index-changed-1.png differ diff --git a/blocksuite/presets/src/__tests__/edgeless/__screenshots__/layer.spec.ts/change-element-should-update-layer-automatically-1.png b/blocksuite/presets/src/__tests__/edgeless/__screenshots__/layer.spec.ts/change-element-should-update-layer-automatically-1.png new file mode 100644 index 0000000000..753a8012c5 Binary files /dev/null and b/blocksuite/presets/src/__tests__/edgeless/__screenshots__/layer.spec.ts/change-element-should-update-layer-automatically-1.png differ diff --git a/blocksuite/presets/src/__tests__/edgeless/__screenshots__/layer.spec.ts/compare-function-compare-a-group-and-its-child-1.png b/blocksuite/presets/src/__tests__/edgeless/__screenshots__/layer.spec.ts/compare-function-compare-a-group-and-its-child-1.png new file mode 100644 index 0000000000..6cf3271867 Binary files /dev/null and b/blocksuite/presets/src/__tests__/edgeless/__screenshots__/layer.spec.ts/compare-function-compare-a-group-and-its-child-1.png differ diff --git a/blocksuite/presets/src/__tests__/edgeless/__screenshots__/layer.spec.ts/compare-function-compare-nested-elements-1.png b/blocksuite/presets/src/__tests__/edgeless/__screenshots__/layer.spec.ts/compare-function-compare-nested-elements-1.png new file mode 100644 index 0000000000..6cf3271867 Binary files /dev/null and b/blocksuite/presets/src/__tests__/edgeless/__screenshots__/layer.spec.ts/compare-function-compare-nested-elements-1.png differ diff --git a/blocksuite/presets/src/__tests__/edgeless/__screenshots__/layer.spec.ts/compare-function-compare-same-element-1.png b/blocksuite/presets/src/__tests__/edgeless/__screenshots__/layer.spec.ts/compare-function-compare-same-element-1.png new file mode 100644 index 0000000000..6cf3271867 Binary files /dev/null and b/blocksuite/presets/src/__tests__/edgeless/__screenshots__/layer.spec.ts/compare-function-compare-same-element-1.png differ diff --git a/blocksuite/presets/src/__tests__/edgeless/__screenshots__/layer.spec.ts/compare-function-compare-two-different-elements-1.png b/blocksuite/presets/src/__tests__/edgeless/__screenshots__/layer.spec.ts/compare-function-compare-two-different-elements-1.png new file mode 100644 index 0000000000..6cf3271867 Binary files /dev/null and b/blocksuite/presets/src/__tests__/edgeless/__screenshots__/layer.spec.ts/compare-function-compare-two-different-elements-1.png differ diff --git a/blocksuite/presets/src/__tests__/edgeless/__screenshots__/layer.spec.ts/compare-function-compare-two-nested-elements-1.png b/blocksuite/presets/src/__tests__/edgeless/__screenshots__/layer.spec.ts/compare-function-compare-two-nested-elements-1.png new file mode 100644 index 0000000000..6cf3271867 Binary files /dev/null and b/blocksuite/presets/src/__tests__/edgeless/__screenshots__/layer.spec.ts/compare-function-compare-two-nested-elements-1.png differ diff --git a/blocksuite/presets/src/__tests__/edgeless/__screenshots__/layer.spec.ts/delete-element-should-update-layer-automatically-1.png b/blocksuite/presets/src/__tests__/edgeless/__screenshots__/layer.spec.ts/delete-element-should-update-layer-automatically-1.png new file mode 100644 index 0000000000..753a8012c5 Binary files /dev/null and b/blocksuite/presets/src/__tests__/edgeless/__screenshots__/layer.spec.ts/delete-element-should-update-layer-automatically-1.png differ diff --git a/blocksuite/presets/src/__tests__/edgeless/__screenshots__/layer.spec.ts/group-related-functionality-change-group-index-should-update-its-children-s-layer-1.png b/blocksuite/presets/src/__tests__/edgeless/__screenshots__/layer.spec.ts/group-related-functionality-change-group-index-should-update-its-children-s-layer-1.png new file mode 100644 index 0000000000..6cf3271867 Binary files /dev/null and b/blocksuite/presets/src/__tests__/edgeless/__screenshots__/layer.spec.ts/group-related-functionality-change-group-index-should-update-its-children-s-layer-1.png differ diff --git a/blocksuite/presets/src/__tests__/edgeless/__screenshots__/layer.spec.ts/group-related-functionality-new-added-group-should-effect-it-children-s-layer-1.png b/blocksuite/presets/src/__tests__/edgeless/__screenshots__/layer.spec.ts/group-related-functionality-new-added-group-should-effect-it-children-s-layer-1.png new file mode 100644 index 0000000000..6cf3271867 Binary files /dev/null and b/blocksuite/presets/src/__tests__/edgeless/__screenshots__/layer.spec.ts/group-related-functionality-new-added-group-should-effect-it-children-s-layer-1.png differ diff --git a/blocksuite/presets/src/__tests__/edgeless/__screenshots__/layer.spec.ts/if-the-topmost-layer-is-canvas-layer--the-length-of-canvasLayers-array-should-equal-to-the-counts-of-canvas-layers-1.png b/blocksuite/presets/src/__tests__/edgeless/__screenshots__/layer.spec.ts/if-the-topmost-layer-is-canvas-layer--the-length-of-canvasLayers-array-should-equal-to-the-counts-of-canvas-layers-1.png new file mode 100644 index 0000000000..753a8012c5 Binary files /dev/null and b/blocksuite/presets/src/__tests__/edgeless/__screenshots__/layer.spec.ts/if-the-topmost-layer-is-canvas-layer--the-length-of-canvasLayers-array-should-equal-to-the-counts-of-canvas-layers-1.png differ diff --git a/blocksuite/presets/src/__tests__/edgeless/__screenshots__/layer.spec.ts/index-generator-generator-can-generate-incrementing-indices-regardless-the-element-type-1.png b/blocksuite/presets/src/__tests__/edgeless/__screenshots__/layer.spec.ts/index-generator-generator-can-generate-incrementing-indices-regardless-the-element-type-1.png new file mode 100644 index 0000000000..57037303ed Binary files /dev/null and b/blocksuite/presets/src/__tests__/edgeless/__screenshots__/layer.spec.ts/index-generator-generator-can-generate-incrementing-indices-regardless-the-element-type-1.png differ diff --git a/blocksuite/presets/src/__tests__/edgeless/__screenshots__/layer.spec.ts/index-generator-generator-should-remember-the-index-it-generated-1.png b/blocksuite/presets/src/__tests__/edgeless/__screenshots__/layer.spec.ts/index-generator-generator-should-remember-the-index-it-generated-1.png new file mode 100644 index 0000000000..6cf3271867 Binary files /dev/null and b/blocksuite/presets/src/__tests__/edgeless/__screenshots__/layer.spec.ts/index-generator-generator-should-remember-the-index-it-generated-1.png differ diff --git a/blocksuite/presets/src/__tests__/edgeless/__screenshots__/layer.spec.ts/indexed-canvas-should-be-inserted-into-edgeless-portal-when-switch-to-edgeless-mode-1.png b/blocksuite/presets/src/__tests__/edgeless/__screenshots__/layer.spec.ts/indexed-canvas-should-be-inserted-into-edgeless-portal-when-switch-to-edgeless-mode-1.png new file mode 100644 index 0000000000..a0a9b95fd4 Binary files /dev/null and b/blocksuite/presets/src/__tests__/edgeless/__screenshots__/layer.spec.ts/indexed-canvas-should-be-inserted-into-edgeless-portal-when-switch-to-edgeless-mode-1.png differ diff --git a/blocksuite/presets/src/__tests__/edgeless/__screenshots__/layer.spec.ts/layer-reorder-functionality-back-1.png b/blocksuite/presets/src/__tests__/edgeless/__screenshots__/layer.spec.ts/layer-reorder-functionality-back-1.png new file mode 100644 index 0000000000..1e49a95eb2 Binary files /dev/null and b/blocksuite/presets/src/__tests__/edgeless/__screenshots__/layer.spec.ts/layer-reorder-functionality-back-1.png differ diff --git a/blocksuite/presets/src/__tests__/edgeless/__screenshots__/layer.spec.ts/layer-reorder-functionality-backward-1.png b/blocksuite/presets/src/__tests__/edgeless/__screenshots__/layer.spec.ts/layer-reorder-functionality-backward-1.png new file mode 100644 index 0000000000..be97bd13d4 Binary files /dev/null and b/blocksuite/presets/src/__tests__/edgeless/__screenshots__/layer.spec.ts/layer-reorder-functionality-backward-1.png differ diff --git a/blocksuite/presets/src/__tests__/edgeless/__screenshots__/layer.spec.ts/layer-reorder-functionality-forward-1.png b/blocksuite/presets/src/__tests__/edgeless/__screenshots__/layer.spec.ts/layer-reorder-functionality-forward-1.png new file mode 100644 index 0000000000..753a8012c5 Binary files /dev/null and b/blocksuite/presets/src/__tests__/edgeless/__screenshots__/layer.spec.ts/layer-reorder-functionality-forward-1.png differ diff --git a/blocksuite/presets/src/__tests__/edgeless/__screenshots__/layer.spec.ts/layer-reorder-functionality-front-1.png b/blocksuite/presets/src/__tests__/edgeless/__screenshots__/layer.spec.ts/layer-reorder-functionality-front-1.png new file mode 100644 index 0000000000..027141c575 Binary files /dev/null and b/blocksuite/presets/src/__tests__/edgeless/__screenshots__/layer.spec.ts/layer-reorder-functionality-front-1.png differ diff --git a/blocksuite/presets/src/__tests__/edgeless/__screenshots__/layer.spec.ts/layer-zindex-should-update-correctly-when-elements-changed-1.png b/blocksuite/presets/src/__tests__/edgeless/__screenshots__/layer.spec.ts/layer-zindex-should-update-correctly-when-elements-changed-1.png new file mode 100644 index 0000000000..753a8012c5 Binary files /dev/null and b/blocksuite/presets/src/__tests__/edgeless/__screenshots__/layer.spec.ts/layer-zindex-should-update-correctly-when-elements-changed-1.png differ diff --git a/blocksuite/presets/src/__tests__/edgeless/__screenshots__/layer.spec.ts/new-added-block-should-be-placed-under-the-topmost-canvas-layer-1.png b/blocksuite/presets/src/__tests__/edgeless/__screenshots__/layer.spec.ts/new-added-block-should-be-placed-under-the-topmost-canvas-layer-1.png new file mode 100644 index 0000000000..753a8012c5 Binary files /dev/null and b/blocksuite/presets/src/__tests__/edgeless/__screenshots__/layer.spec.ts/new-added-block-should-be-placed-under-the-topmost-canvas-layer-1.png differ diff --git a/blocksuite/presets/src/__tests__/edgeless/__screenshots__/layer.spec.ts/new-added-canvas-elements-should-be-placed-in-the-topmost-canvas-layer-1.png b/blocksuite/presets/src/__tests__/edgeless/__screenshots__/layer.spec.ts/new-added-canvas-elements-should-be-placed-in-the-topmost-canvas-layer-1.png new file mode 100644 index 0000000000..753a8012c5 Binary files /dev/null and b/blocksuite/presets/src/__tests__/edgeless/__screenshots__/layer.spec.ts/new-added-canvas-elements-should-be-placed-in-the-topmost-canvas-layer-1.png differ diff --git a/blocksuite/presets/src/__tests__/edgeless/__screenshots__/layer.spec.ts/the-actual-rendering-z-index-should-satisfy-the-logic-order-of-their-indexes-1.png b/blocksuite/presets/src/__tests__/edgeless/__screenshots__/layer.spec.ts/the-actual-rendering-z-index-should-satisfy-the-logic-order-of-their-indexes-1.png new file mode 100644 index 0000000000..753a8012c5 Binary files /dev/null and b/blocksuite/presets/src/__tests__/edgeless/__screenshots__/layer.spec.ts/the-actual-rendering-z-index-should-satisfy-the-logic-order-of-their-indexes-1.png differ diff --git a/blocksuite/presets/src/__tests__/edgeless/__screenshots__/surface-ref.spec.ts/basic-content-in-frame-should-be-rendered-in-the-correct-order-1.png b/blocksuite/presets/src/__tests__/edgeless/__screenshots__/surface-ref.spec.ts/basic-content-in-frame-should-be-rendered-in-the-correct-order-1.png new file mode 100644 index 0000000000..bc4fc73c65 Binary files /dev/null and b/blocksuite/presets/src/__tests__/edgeless/__screenshots__/surface-ref.spec.ts/basic-content-in-frame-should-be-rendered-in-the-correct-order-1.png differ diff --git a/blocksuite/presets/src/__tests__/edgeless/__screenshots__/surface-ref.spec.ts/basic-content-in-group-should-be-rendered-in-the-correct-order-1.png b/blocksuite/presets/src/__tests__/edgeless/__screenshots__/surface-ref.spec.ts/basic-content-in-group-should-be-rendered-in-the-correct-order-1.png new file mode 100644 index 0000000000..bc4fc73c65 Binary files /dev/null and b/blocksuite/presets/src/__tests__/edgeless/__screenshots__/surface-ref.spec.ts/basic-content-in-group-should-be-rendered-in-the-correct-order-1.png differ diff --git a/blocksuite/presets/src/__tests__/edgeless/__screenshots__/surface-ref.spec.ts/basic-frame-should-be-rendered-in-surface-ref-viewport-1.png b/blocksuite/presets/src/__tests__/edgeless/__screenshots__/surface-ref.spec.ts/basic-frame-should-be-rendered-in-surface-ref-viewport-1.png new file mode 100644 index 0000000000..bc4fc73c65 Binary files /dev/null and b/blocksuite/presets/src/__tests__/edgeless/__screenshots__/surface-ref.spec.ts/basic-frame-should-be-rendered-in-surface-ref-viewport-1.png differ diff --git a/blocksuite/presets/src/__tests__/edgeless/__screenshots__/surface-ref.spec.ts/basic-group-should-be-rendered-in-surface-ref-viewport-1.png b/blocksuite/presets/src/__tests__/edgeless/__screenshots__/surface-ref.spec.ts/basic-group-should-be-rendered-in-surface-ref-viewport-1.png new file mode 100644 index 0000000000..bc4fc73c65 Binary files /dev/null and b/blocksuite/presets/src/__tests__/edgeless/__screenshots__/surface-ref.spec.ts/basic-group-should-be-rendered-in-surface-ref-viewport-1.png differ diff --git a/blocksuite/presets/src/__tests__/edgeless/__screenshots__/surface-ref.spec.ts/basic-surface-ref-should-be-rendered-as-empty-surface-ref-block-edgeless-component-page-mode-1.png b/blocksuite/presets/src/__tests__/edgeless/__screenshots__/surface-ref.spec.ts/basic-surface-ref-should-be-rendered-as-empty-surface-ref-block-edgeless-component-page-mode-1.png new file mode 100644 index 0000000000..027141c575 Binary files /dev/null and b/blocksuite/presets/src/__tests__/edgeless/__screenshots__/surface-ref.spec.ts/basic-surface-ref-should-be-rendered-as-empty-surface-ref-block-edgeless-component-page-mode-1.png differ diff --git a/blocksuite/presets/src/__tests__/edgeless/__screenshots__/surface-ref.spec.ts/basic-surface-ref-should-be-rendered-in-page-mode-1.png b/blocksuite/presets/src/__tests__/edgeless/__screenshots__/surface-ref.spec.ts/basic-surface-ref-should-be-rendered-in-page-mode-1.png new file mode 100644 index 0000000000..bc4fc73c65 Binary files /dev/null and b/blocksuite/presets/src/__tests__/edgeless/__screenshots__/surface-ref.spec.ts/basic-surface-ref-should-be-rendered-in-page-mode-1.png differ diff --git a/blocksuite/presets/src/__tests__/edgeless/__screenshots__/surface-ref.spec.ts/basic-view-in-edgeless-mode-button-1.png b/blocksuite/presets/src/__tests__/edgeless/__screenshots__/surface-ref.spec.ts/basic-view-in-edgeless-mode-button-1.png new file mode 100644 index 0000000000..bc4fc73c65 Binary files /dev/null and b/blocksuite/presets/src/__tests__/edgeless/__screenshots__/surface-ref.spec.ts/basic-view-in-edgeless-mode-button-1.png differ diff --git a/blocksuite/presets/src/__tests__/edgeless/__screenshots__/surface-ref.spec.ts/basic-viewport-of-surface-ref-should-be-updated-when-the-reference-xywh-updated-1.png b/blocksuite/presets/src/__tests__/edgeless/__screenshots__/surface-ref.spec.ts/basic-viewport-of-surface-ref-should-be-updated-when-the-reference-xywh-updated-1.png new file mode 100644 index 0000000000..bc4fc73c65 Binary files /dev/null and b/blocksuite/presets/src/__tests__/edgeless/__screenshots__/surface-ref.spec.ts/basic-viewport-of-surface-ref-should-be-updated-when-the-reference-xywh-updated-1.png differ diff --git a/blocksuite/presets/src/__tests__/edgeless/__screenshots__/surface-ref.spec.ts/clipboard-import-surface-ref-snapshot-should-render-content-correctly-1.png b/blocksuite/presets/src/__tests__/edgeless/__screenshots__/surface-ref.spec.ts/clipboard-import-surface-ref-snapshot-should-render-content-correctly-1.png new file mode 100644 index 0000000000..753a8012c5 Binary files /dev/null and b/blocksuite/presets/src/__tests__/edgeless/__screenshots__/surface-ref.spec.ts/clipboard-import-surface-ref-snapshot-should-render-content-correctly-1.png differ diff --git a/blocksuite/presets/src/__tests__/edgeless/__screenshots__/tools.spec.ts/default-tool-block-drag-moving-1.png b/blocksuite/presets/src/__tests__/edgeless/__screenshots__/tools.spec.ts/default-tool-block-drag-moving-1.png new file mode 100644 index 0000000000..61f7455192 Binary files /dev/null and b/blocksuite/presets/src/__tests__/edgeless/__screenshots__/tools.spec.ts/default-tool-block-drag-moving-1.png differ diff --git a/blocksuite/presets/src/__tests__/edgeless/__screenshots__/tools.spec.ts/default-tool-element-click-selection-1.png b/blocksuite/presets/src/__tests__/edgeless/__screenshots__/tools.spec.ts/default-tool-element-click-selection-1.png new file mode 100644 index 0000000000..753a8012c5 Binary files /dev/null and b/blocksuite/presets/src/__tests__/edgeless/__screenshots__/tools.spec.ts/default-tool-element-click-selection-1.png differ diff --git a/blocksuite/presets/src/__tests__/edgeless/__screenshots__/tools.spec.ts/default-tool-element-drag-moving-1.png b/blocksuite/presets/src/__tests__/edgeless/__screenshots__/tools.spec.ts/default-tool-element-drag-moving-1.png new file mode 100644 index 0000000000..753a8012c5 Binary files /dev/null and b/blocksuite/presets/src/__tests__/edgeless/__screenshots__/tools.spec.ts/default-tool-element-drag-moving-1.png differ diff --git a/blocksuite/presets/src/__tests__/edgeless/basic.spec.ts b/blocksuite/presets/src/__tests__/edgeless/basic.spec.ts new file mode 100644 index 0000000000..9bf5ae90d6 --- /dev/null +++ b/blocksuite/presets/src/__tests__/edgeless/basic.spec.ts @@ -0,0 +1,18 @@ +import { beforeEach, expect, test } from 'vitest'; + +import { getSurface } from '../utils/edgeless.js'; +import { setupEditor } from '../utils/setup.js'; + +beforeEach(async () => { + const cleanup = await setupEditor('edgeless'); + + return cleanup; +}); + +test('basic assert', () => { + expect(window.doc).toBeDefined(); + expect(window.editor).toBeDefined(); + expect(window.editor.mode).toBe('edgeless'); + + expect(getSurface(window.doc, window.editor)).toBeDefined(); +}); diff --git a/blocksuite/presets/src/__tests__/edgeless/color-picker.spec.ts b/blocksuite/presets/src/__tests__/edgeless/color-picker.spec.ts new file mode 100644 index 0000000000..77ac54b968 --- /dev/null +++ b/blocksuite/presets/src/__tests__/edgeless/color-picker.spec.ts @@ -0,0 +1,114 @@ +import '@toeverything/theme/style.css'; + +import { + ColorScheme, + type EdgelessRootBlockComponent, + ThemeProvider, +} from '@blocksuite/blocks'; +import { beforeEach, describe, expect, test } from 'vitest'; + +import { getDocRootBlock } from '../utils/edgeless.js'; +import { setupEditor } from '../utils/setup.js'; + +describe('theme service', () => { + let edgeless!: EdgelessRootBlockComponent; + + beforeEach(async () => { + const cleanup = await setupEditor('edgeless'); + + edgeless = getDocRootBlock(doc, editor, 'edgeless'); + + edgeless.gfx.tool.setTool('default'); + + const themeService = edgeless.gfx.std.get(ThemeProvider); + themeService.theme$.value = ColorScheme.Light; + + return cleanup; + }); + + test('switches theme', () => { + const themeService = edgeless.gfx.std.get(ThemeProvider); + expect(themeService.theme).toBe(ColorScheme.Light); + + themeService.theme$.value = ColorScheme.Dark; + expect(themeService.theme).toBe(ColorScheme.Dark); + + themeService.theme$.value = ColorScheme.Light; + expect(themeService.theme).toBe(ColorScheme.Light); + }); + + test('generates a color property', () => { + const themeService = edgeless.gfx.std.get(ThemeProvider); + expect(themeService.theme).toBe(ColorScheme.Light); + + expect(themeService.generateColorProperty('--affine-hover-color')).toBe( + 'var(--affine-hover-color)' + ); + + expect(themeService.generateColorProperty('--affine-transparent')).toBe( + 'transparent' + ); + + expect(themeService.generateColorProperty('transparent')).toBe( + 'transparent' + ); + + expect( + themeService.generateColorProperty({ dark: 'white', light: 'black' }) + ).toBe('black'); + + themeService.theme$.value = ColorScheme.Dark; + expect(themeService.theme).toBe(ColorScheme.Dark); + + expect( + themeService.generateColorProperty({ dark: 'white', light: 'black' }) + ).toBe('white'); + + expect(themeService.generateColorProperty({ normal: 'grey' })).toBe('grey'); + + expect(themeService.generateColorProperty('', 'blue')).toBe('blue'); + expect(themeService.generateColorProperty({}, 'red')).toBe('red'); + }); + + test('gets a color value', () => { + const themeService = edgeless.gfx.std.get(ThemeProvider); + expect(themeService.theme).toBe(ColorScheme.Light); + + expect(themeService.getColorValue('--affine-transparent')).toBe( + '--affine-transparent' + ); + expect( + themeService.getColorValue('--affine-transparent', 'transparent', true) + ).toBe('transparent'); + expect( + themeService.getColorValue('--affine-hover-color', 'transparent', true) + ).toBe('rgba(0, 0, 0, 0.04)'); + expect( + themeService.getColorValue('--affine-tooltip', undefined, true) + ).toBe('rgba(0, 0, 0, 1)'); + + expect( + themeService.getColorValue( + { dark: 'white', light: 'black' }, + undefined, + true + ) + ).toBe('black'); + expect( + themeService.getColorValue({ dark: 'white', light: '' }, undefined, true) + ).toBe('transparent'); + expect( + themeService.getColorValue({ normal: 'grey' }, undefined, true) + ).toBe('grey'); + + themeService.theme$.value = ColorScheme.Dark; + expect(themeService.theme).toBe(ColorScheme.Dark); + + expect( + themeService.getColorValue('--affine-hover-color', 'transparent', true) + ).toEqual('rgba(255, 255, 255, 0.1)'); + expect( + themeService.getColorValue('--affine-tooltip', undefined, true) + ).toEqual('rgba(234, 234, 234, 1)'); // #eaeaea + }); +}); diff --git a/blocksuite/presets/src/__tests__/edgeless/frame.spec.ts b/blocksuite/presets/src/__tests__/edgeless/frame.spec.ts new file mode 100644 index 0000000000..a313738c63 --- /dev/null +++ b/blocksuite/presets/src/__tests__/edgeless/frame.spec.ts @@ -0,0 +1,128 @@ +import type { + AffineFrameTitleWidget, + EdgelessRootBlockComponent, + FrameBlockComponent, + FrameBlockModel, +} from '@blocksuite/blocks'; +import { assertType } from '@blocksuite/global/utils'; +import { Text } from '@blocksuite/store'; +import { beforeEach, describe, expect, test } from 'vitest'; + +import { wait } from '../utils/common.js'; +import { getDocRootBlock } from '../utils/edgeless.js'; +import { setupEditor } from '../utils/setup.js'; + +describe('frame', () => { + let service!: EdgelessRootBlockComponent['service']; + + beforeEach(async () => { + const cleanup = await setupEditor('edgeless'); + service = getDocRootBlock(window.doc, window.editor, 'edgeless').service; + + return cleanup; + }); + + test('frame should have title', async () => { + const frame = service.doc.addBlock( + 'affine:frame', + { + xywh: '[0,0,300,300]', + title: new Text('Frame 1'), + }, + service.surface.id + ); + await wait(); + + const frameTitleWidget = service.std.view.getWidget( + 'affine-frame-title-widget', + doc.root!.id + ) as AffineFrameTitleWidget | null; + + const frameTitle = frameTitleWidget?.getFrameTitle(frame); + const rect = frameTitle?.getBoundingClientRect(); + + expect(frameTitle).toBeTruthy(); + expect(rect).toBeTruthy(); + expect(rect!.width).toBeGreaterThan(0); + expect(rect!.height).toBeGreaterThan(0); + + const [titleX, titleY] = service.viewport.toModelCoord(rect!.x, rect!.y); + expect(titleX).toBeCloseTo(0); + expect(titleY).toBeLessThan(0); + + const nestedFrame = service.doc.addBlock( + 'affine:frame', + { + xywh: '[20,20,200,200]', + title: new Text('Frame 2'), + }, + service.surface.id + ); + await wait(); + + const nestedTitle = frameTitleWidget?.getFrameTitle(nestedFrame); + expect(nestedTitle).toBeTruthy(); + if (!nestedTitle) return; + + const nestedTitleRect = nestedTitle.getBoundingClientRect()!; + const [nestedTitleX, nestedTitleY] = service.viewport.toModelCoord( + nestedTitleRect.x, + nestedTitleRect.y + ); + + expect(nestedTitleX).toBeGreaterThan(20); + expect(nestedTitleY).toBeGreaterThan(20); + }); + + test('frame should have externalXYWH after moving viewport to contains frame', async () => { + const frameId = service.doc.addBlock( + 'affine:frame', + { + xywh: '[1800,1800,200,200]', + title: new Text('Frame 1'), + }, + service.surface.id + ); + await wait(); + + const frame = service.doc.getBlock(frameId); + expect(frame).toBeTruthy(); + + assertType<FrameBlockComponent>(frame); + + service.viewport.setCenter(900, 900); + expect(frame?.model.externalXYWH).toBeDefined(); + }); + + test('descendant of frame should not contain itself', async () => { + const frameIds = [1, 2, 3].map(i => { + return service.doc.addBlock( + 'affine:frame', + { + xywh: '[0,0,300,300]', + title: new Text(`Frame ${i}`), + }, + service.surface.id + ); + }); + + await wait(); + + const frames = frameIds.map( + id => service.doc.getBlock(id)?.model as FrameBlockModel + ); + + frames.forEach(frame => { + expect(frame.descendantElements).toHaveLength(0); + }); + + frames[0].addChild(frames[1]); + frames[1].addChild(frames[2]); + frames[2].addChild(frames[0]); + + await wait(); + expect(frames[0].descendantElements).toHaveLength(2); + expect(frames[1].descendantElements).toHaveLength(1); + expect(frames[2].descendantElements).toHaveLength(0); + }); +}); diff --git a/blocksuite/presets/src/__tests__/edgeless/group.spec.ts b/blocksuite/presets/src/__tests__/edgeless/group.spec.ts new file mode 100644 index 0000000000..178850141a --- /dev/null +++ b/blocksuite/presets/src/__tests__/edgeless/group.spec.ts @@ -0,0 +1,367 @@ +import type { MindmapElementModel } from '@blocksuite/affine-model'; +import { + type EdgelessRootBlockComponent, + type GroupElementModel, + LayoutType, + NoteDisplayMode, +} from '@blocksuite/blocks'; +import { DocCollection } from '@blocksuite/store'; +import { beforeEach, describe, expect, test } from 'vitest'; + +import { wait } from '../utils/common.js'; +import { addNote, getDocRootBlock } from '../utils/edgeless.js'; +import { setupEditor } from '../utils/setup.js'; + +describe('group', () => { + let service!: EdgelessRootBlockComponent['service']; + + beforeEach(async () => { + const cleanup = await setupEditor('edgeless'); + service = getDocRootBlock(window.doc, window.editor, 'edgeless').service; + + return cleanup; + }); + + test('group with no children will be removed automatically', () => { + const map = new DocCollection.Y.Map<boolean>(); + const ids = Array.from({ length: 2 }) + .map(() => { + const id = service.addElement('shape', { + shapeType: 'rect', + }); + map.set(id, true); + + return id; + }) + .concat( + Array.from({ length: 2 }).map(() => { + const id = addNote(doc); + map.set(id, true); + return id; + }) + ); + service.addElement('group', { children: map }); + doc.captureSync(); + expect(service.elements.length).toBe(3); + + service.removeElement(ids[0]); + service.removeElement(ids[1]); + doc.captureSync(); + expect(service.elements.length).toBe(1); + + service.removeElement(ids[2]); + service.removeElement(ids[3]); + doc.captureSync(); + expect(service.elements.length).toBe(0); + + doc.undo(); + expect(service.elements.length).toBe(1); + doc.redo(); + expect(service.elements.length).toBe(0); + }); + + test('remove group should remove its children at the same time', () => { + const map = new DocCollection.Y.Map<boolean>(); + const doc = service.doc; + const noteId = addNote(doc); + const shapeId = service.addElement('shape', { + shapeType: 'rect', + }); + + map.set(noteId, true); + map.set(shapeId, true); + const groupId = service.addElement('group', { children: map }); + + expect(service.elements.length).toBe(2); + expect(doc.getBlock(noteId)).toBeDefined(); + doc.captureSync(); + + service.removeElement(groupId); + expect(service.elements.length).toBe(0); + expect(doc.getBlock(noteId)).toBeUndefined(); + + doc.undo(); + expect(doc.getBlock(noteId)).toBeDefined(); + expect(service.elements.length).toBe(2); + }); + + test("group's xywh should update automatically when children change", async () => { + const shape1 = service.addElement('shape', { + shapeType: 'rect', + xywh: '[0,0,100,100]', + }); + const shape2 = service.addElement('shape', { + shapeType: 'rect', + xywh: '[100,100,100,100]', + }); + const note1 = addNote(doc, { + displayMode: NoteDisplayMode.DocAndEdgeless, + xywh: '[200,200,800,100]', + edgeless: { + style: { + borderRadius: 8, + borderSize: 4, + borderStyle: 'solid', + shadowType: '--affine-note-shadow-box', + }, + collapse: true, + collapsedHeight: 100, + }, + }); + const children = new DocCollection.Y.Map<boolean>(); + + children.set(shape1, true); + children.set(shape2, true); + children.set(note1, true); + + const groupId = service.addElement('group', { children }); + const group = service.getElementById(groupId) as GroupElementModel; + const assertInitial = () => { + expect(group.x).toBe(0); + expect(group.y).toBe(0); + expect(group.w).toBe(1000); + expect(group.h).toBe(300); + }; + + doc.captureSync(); + await wait(); + assertInitial(); + + service.removeElement(note1); + await wait(); + expect(group.x).toBe(0); + expect(group.y).toBe(0); + expect(group.w).toBe(200); + expect(group.h).toBe(200); + doc.captureSync(); + + doc.undo(); + await wait(); + assertInitial(); + + service.updateElement(note1, { + xywh: '[300,300,800,100]', + }); + await wait(); + expect(group.x).toBe(0); + expect(group.y).toBe(0); + expect(group.w).toBe(1100); + expect(group.h).toBe(400); + doc.captureSync(); + + doc.undo(); + await wait(); + assertInitial(); + + service.removeElement(shape1); + await wait(); + expect(group.x).toBe(100); + expect(group.y).toBe(100); + expect(group.w).toBe(900); + expect(group.h).toBe(200); + doc.captureSync(); + + doc.undo(); + await wait(); + assertInitial(); + + service.updateElement(shape1, { + xywh: '[100,100,100,100]', + }); + await wait(); + expect(group.x).toBe(100); + expect(group.y).toBe(100); + expect(group.w).toBe(900); + expect(group.h).toBe(200); + doc.captureSync(); + + doc.undo(); + await wait(); + assertInitial(); + }); + + test('empty group should have all zero xywh', () => { + const map = new DocCollection.Y.Map<boolean>(); + const groupId = service.addElement('group', { children: map }); + const group = service.getElementById(groupId) as GroupElementModel; + + expect(group.x).toBe(0); + expect(group.y).toBe(0); + expect(group.w).toBe(0); + expect(group.h).toBe(0); + }); + + test('descendant of group should not contain itself', () => { + const groupIds = [1, 2, 3].map(_ => { + return service.addElement('group', { + children: new DocCollection.Y.Map<boolean>(), + }); + }); + const groups = groupIds.map( + id => service.getElementById(id) as GroupElementModel + ); + + groups.forEach(group => { + expect(group.descendantElements).toHaveLength(0); + }); + + groups[0].addChild(groups[1]); + groups[1].addChild(groups[2]); + groups[2].addChild(groups[0]); + + expect(groups[0].descendantElements).toHaveLength(2); + expect(groups[1].descendantElements).toHaveLength(1); + expect(groups[2].descendantElements).toHaveLength(0); + }); +}); + +describe('mindmap', () => { + let service!: EdgelessRootBlockComponent['service']; + + beforeEach(async () => { + const cleanup = await setupEditor('edgeless'); + service = getDocRootBlock(window.doc, window.editor, 'edgeless').service; + + return cleanup; + }); + + test('delete the root node should remove all children', async () => { + const tree = { + text: 'root', + children: [ + { + text: 'leaf1', + }, + { + text: 'leaf2', + }, + { + text: 'leaf3', + children: [ + { + text: 'leaf4', + }, + ], + }, + ], + }; + const mindmapId = service.addElement('mindmap', { children: tree }); + const mindmap = () => + service.getElementById(mindmapId) as MindmapElementModel; + + expect(service.surface.elementModels.length).toBe(6); + doc.captureSync(); + + service.removeElement(mindmap().tree.element); + await wait(); + expect(service.surface.elementModels.length).toBe(0); + doc.captureSync(); + await wait(); + + doc.undo(); + expect(service.surface.elementModels.length).toBe(6); + await wait(); + + service.removeElement(mindmap().tree.children[2].element); + await wait(); + expect(service.surface.elementModels.length).toBe(4); + await wait(); + + doc.undo(); + await wait(); + expect(service.surface.elementModels.length).toBe(6); + }); + + test('mindmap should layout automatically when creating', async () => { + const tree = { + text: 'root', + children: [ + { + text: 'leaf1', + }, + { + text: 'leaf2', + }, + { + text: 'leaf3', + children: [ + { + text: 'leaf4', + }, + ], + }, + ], + }; + const mindmapId = service.addElement('mindmap', { + type: LayoutType.RIGHT, + children: tree, + }); + const mindmap = () => + service.getElementById(mindmapId) as MindmapElementModel; + + doc.captureSync(); + await wait(); + + const root = mindmap().tree.element; + const children = mindmap().tree.children.map(child => child.element); + const leaf4 = mindmap().tree.children[2].children[0].element; + + expect(children[0].x).greaterThan(root.x + root.w); + expect(children[1].x).greaterThan(root.x + root.w); + expect(children[2].x).greaterThan(root.x + root.w); + + expect(children[1].y).greaterThan(children[0].y + children[0].h); + expect(children[2].y).greaterThan(children[1].y + children[1].h); + + expect(leaf4.x).greaterThan(children[2].x + children[2].w); + }); + + test('deliberately creating a circular reference should be resolved correctly', async () => { + const tree = { + text: 'root', + children: [ + { + text: 'leaf1', + }, + { + text: 'leaf2', + }, + { + text: 'leaf3', + children: [ + { + text: 'leaf4', + }, + ], + }, + ], + }; + const mindmapId = service.addElement('mindmap', { + type: LayoutType.RIGHT, + children: tree, + }); + const mindmap = () => + service.getElementById(mindmapId) as MindmapElementModel; + + doc.captureSync(); + await wait(); + + // create a circular reference + doc.transact(() => { + const root = mindmap().tree; + const leaf3 = root.children[2]; + const leaf4 = root.children[2].children[0]; + + mindmap().children.set(leaf3.id, { + index: leaf3.detail.index, + parent: leaf4.id, + }); + }); + doc.captureSync(); + + await wait(); + + // the circular referenced node should be removed + expect(mindmap().nodeMap.size).toBe(3); + }); +}); diff --git a/blocksuite/presets/src/__tests__/edgeless/last-props.spec.ts b/blocksuite/presets/src/__tests__/edgeless/last-props.spec.ts new file mode 100644 index 0000000000..cabf771adc --- /dev/null +++ b/blocksuite/presets/src/__tests__/edgeless/last-props.spec.ts @@ -0,0 +1,248 @@ +import type { BlockStdScope } from '@blocksuite/block-std'; +import { + type BrushElementModel, + type ConnectorElementModel, + DEFAULT_NOTE_BACKGROUND_COLOR, + DEFAULT_NOTE_SHADOW, + DEFAULT_TEXT_COLOR, + type EdgelessRootBlockComponent, + type EdgelessTextBlockModel, + EditPropsStore, + FontFamily, + FrameBackgroundColor, + type FrameBlockModel, + getSurfaceBlock, + LayoutType, + LineColor, + type MindmapElementModel, + MindmapStyle, + NoteBackgroundColor, + type NoteBlockModel, + NoteShadow, + type ShapeElementModel, + ShapeFillColor, + ShapeType, + type TextElementModel, +} from '@blocksuite/blocks'; +import { beforeEach, describe, expect, test } from 'vitest'; + +import { getDocRootBlock } from '../utils/edgeless.js'; +import { setupEditor } from '../utils/setup.js'; + +describe('apply last props', () => { + let edgelessRoot!: EdgelessRootBlockComponent; + let service!: EdgelessRootBlockComponent['service']; + let std!: BlockStdScope; + + beforeEach(async () => { + sessionStorage.removeItem('blocksuite:prop:record'); + const cleanup = await setupEditor('edgeless'); + edgelessRoot = getDocRootBlock(window.doc, window.editor, 'edgeless'); + service = edgelessRoot.service; + std = edgelessRoot.std; + return cleanup; + }); + + test('shapes', () => { + // rect shape + const rectId = service.addElement('shape', { shapeType: ShapeType.Rect }); + const rectShape = service.getElementById(rectId) as ShapeElementModel; + expect(rectShape.fillColor).toBe(ShapeFillColor.Yellow); + service.updateElement(rectId, { + fillColor: ShapeFillColor.Orange, + }); + expect( + std.get(EditPropsStore).lastProps$.value[`shape:${ShapeType.Rect}`] + .fillColor + ).toBe(ShapeFillColor.Orange); + + // diamond shape + const diamondId = service.addElement('shape', { + shapeType: ShapeType.Diamond, + }); + const diamondShape = service.getElementById(diamondId) as ShapeElementModel; + expect(diamondShape.fillColor).toBe(ShapeFillColor.Yellow); + service.updateElement(diamondId, { + fillColor: ShapeFillColor.Blue, + }); + expect( + std.get(EditPropsStore).lastProps$.value[`shape:${ShapeType.Diamond}`] + .fillColor + ).toBe(ShapeFillColor.Blue); + + // rounded rect shape + const roundedRectId = service.addElement('shape', { + shapeType: ShapeType.Rect, + radius: 0.1, + }); + const roundedRectShape = service.getElementById( + roundedRectId + ) as ShapeElementModel; + expect(roundedRectShape.fillColor).toBe(ShapeFillColor.Yellow); + service.updateElement(roundedRectId, { + fillColor: ShapeFillColor.Green, + }); + expect( + std.get(EditPropsStore).lastProps$.value['shape:roundedRect'].fillColor + ).toBe(ShapeFillColor.Green); + + // apply last props + const rectId2 = service.addElement('shape', { shapeType: ShapeType.Rect }); + const rectShape2 = service.getElementById(rectId2) as ShapeElementModel; + expect(rectShape2.fillColor).toBe(ShapeFillColor.Orange); + + const diamondId2 = service.addElement('shape', { + shapeType: ShapeType.Diamond, + }); + const diamondShape2 = service.getElementById( + diamondId2 + ) as ShapeElementModel; + expect(diamondShape2.fillColor).toBe(ShapeFillColor.Blue); + + const roundedRectId2 = service.addElement('shape', { + shapeType: ShapeType.Rect, + radius: 0.1, + }); + const roundedRectShape2 = service.getElementById( + roundedRectId2 + ) as ShapeElementModel; + expect(roundedRectShape2.fillColor).toBe(ShapeFillColor.Green); + }); + + test('connector', () => { + const id = service.addElement('connector', { mode: 0 }); + const connector = service.getElementById(id) as ConnectorElementModel; + expect(connector.stroke).toBe(LineColor.Grey); + expect(connector.strokeWidth).toBe(2); + expect(connector.strokeStyle).toBe('solid'); + expect(connector.frontEndpointStyle).toBe('None'); + expect(connector.rearEndpointStyle).toBe('Arrow'); + service.updateElement(id, { strokeWidth: 10 }); + + const id2 = service.addElement('connector', { mode: 1 }); + const connector2 = service.getElementById(id2) as ConnectorElementModel; + expect(connector2.strokeWidth).toBe(10); + service.updateElement(id2, { + labelStyle: { + color: LineColor.Magenta, + fontFamily: FontFamily.Kalam, + }, + }); + + const id3 = service.addElement('connector', { mode: 1 }); + const connector3 = service.getElementById(id3) as ConnectorElementModel; + expect(connector3.strokeWidth).toBe(10); + expect(connector3.labelStyle.color).toBe(LineColor.Magenta); + expect(connector3.labelStyle.fontFamily).toBe(FontFamily.Kalam); + }); + + test('brush', () => { + const id = service.addElement('brush', {}); + const brush = service.getElementById(id) as BrushElementModel; + expect(brush.color).toEqual({ + dark: LineColor.White, + light: LineColor.Black, + }); + expect(brush.lineWidth).toBe(4); + service.updateElement(id, { lineWidth: 10 }); + const secondBrush = service.getElementById( + service.addElement('brush', {}) + ) as BrushElementModel; + expect(secondBrush.lineWidth).toBe(10); + }); + + test('text', () => { + const id = service.addElement('text', {}); + const text = service.getElementById(id) as TextElementModel; + expect(text.fontSize).toBe(24); + service.updateElement(id, { fontSize: 36 }); + const secondText = service.getElementById( + service.addElement('text', {}) + ) as TextElementModel; + expect(secondText.fontSize).toBe(36); + }); + + test('mindmap', () => { + const id = service.addElement('mindmap', {}); + const mindmap = service.getElementById(id) as MindmapElementModel; + expect(mindmap.layoutType).toBe(LayoutType.RIGHT); + expect(mindmap.style).toBe(MindmapStyle.ONE); + service.updateElement(id, { + layoutType: LayoutType.BALANCE, + style: MindmapStyle.THREE, + }); + + const id2 = service.addElement('mindmap', {}); + const mindmap2 = service.getElementById(id2) as MindmapElementModel; + expect(mindmap2.layoutType).toBe(LayoutType.BALANCE); + expect(mindmap2.style).toBe(MindmapStyle.THREE); + }); + + test('edgeless-text', () => { + const surface = getSurfaceBlock(doc); + const id = service.addBlock('affine:edgeless-text', {}, surface!.id); + const text = service.getElementById(id) as EdgelessTextBlockModel; + expect(text.color).toBe(DEFAULT_TEXT_COLOR); + expect(text.fontFamily).toBe(FontFamily.Inter); + service.updateElement(id, { + color: LineColor.Green, + fontFamily: FontFamily.OrelegaOne, + }); + + const id2 = service.addBlock('affine:edgeless-text', {}, surface!.id); + const text2 = service.getElementById(id2) as EdgelessTextBlockModel; + expect(text2.color).toBe(LineColor.Green); + expect(text2.fontFamily).toBe(FontFamily.OrelegaOne); + }); + + test('note', () => { + const id = service.addBlock('affine:note', {}, doc.root!.id); + const note = service.getElementById(id) as NoteBlockModel; + expect(note.background).toBe(DEFAULT_NOTE_BACKGROUND_COLOR); + expect(note.edgeless.style.shadowType).toBe(DEFAULT_NOTE_SHADOW); + service.updateElement(id, { + background: NoteBackgroundColor.Purple, + edgeless: { + style: { + shadowType: NoteShadow.Film, + }, + }, + }); + + const id2 = service.addBlock('affine:note', {}, doc.root!.id); + const note2 = service.getElementById(id2) as NoteBlockModel; + expect(note2.background).toBe(NoteBackgroundColor.Purple); + expect(note2.edgeless.style.shadowType).toBe(NoteShadow.Film); + }); + + test('frame', () => { + const surface = getSurfaceBlock(doc); + const id = service.addBlock('affine:frame', {}, surface!.id); + const note = service.getElementById(id) as FrameBlockModel; + expect(note.background).toBe('--affine-palette-transparent'); + service.updateElement(id, { + background: FrameBackgroundColor.Purple, + }); + + const id2 = service.addBlock('affine:frame', {}, surface!.id); + const frame2 = service.getElementById(id2) as FrameBlockModel; + expect(frame2.background).toBe(FrameBackgroundColor.Purple); + service.updateElement(id, { + background: { normal: '#def4e740' }, + }); + + const id3 = service.addBlock('affine:frame', {}, surface!.id); + const frame3 = service.getElementById(id3) as FrameBlockModel; + expect(frame3.background).toEqual({ normal: '#def4e740' }); + service.updateElement(id, { + background: { light: '#a381aa23', dark: '#6e907452' }, + }); + + const id4 = service.addBlock('affine:frame', {}, surface!.id); + const frame4 = service.getElementById(id4) as FrameBlockModel; + expect(frame4.background).toEqual({ + light: '#a381aa23', + dark: '#6e907452', + }); + }); +}); diff --git a/blocksuite/presets/src/__tests__/edgeless/layer.spec.ts b/blocksuite/presets/src/__tests__/edgeless/layer.spec.ts new file mode 100644 index 0000000000..c6a12a8fc2 --- /dev/null +++ b/blocksuite/presets/src/__tests__/edgeless/layer.spec.ts @@ -0,0 +1,883 @@ +import { CommonUtils } from '@blocksuite/affine-block-surface'; +import type { BlockComponent } from '@blocksuite/block-std'; +import type { + EdgelessRootBlockComponent, + GroupElementModel, + NoteBlockModel, +} from '@blocksuite/blocks'; +import { type BlockModel, type Doc, DocCollection } from '@blocksuite/store'; +import { beforeEach, describe, expect, test } from 'vitest'; + +import { wait } from '../utils/common.js'; +import { + addNote as _addNote, + getDocRootBlock, + getSurface, +} from '../utils/edgeless.js'; +import { setupEditor } from '../utils/setup.js'; + +let service!: EdgelessRootBlockComponent['service']; + +const addNote = (doc: Doc, props: Record<string, unknown> = {}) => { + return _addNote(doc, { + index: service.layer.generateIndex(), + ...props, + }); +}; + +beforeEach(async () => { + const cleanup = await setupEditor('edgeless'); + service = getDocRootBlock(window.doc, window.editor, 'edgeless').service; + + return async () => { + await wait(100); + cleanup(); + }; +}); + +test('layer manager initial state', () => { + expect(service.layer).toBeDefined(); + expect(service.layer.layers.length).toBe(0); + expect(service.layer.canvasLayers.length).toBe(1); +}); + +describe('add new edgeless blocks or canvas elements should update layer automatically', () => { + test('add note, note, shape sequentially', async () => { + addNote(doc); + addNote(doc); + service.addElement('shape', { + shapeType: 'rect', + }); + + await wait(); + + expect(service.layer.layers.length).toBe(2); + }); + + test('add note, shape, note sequentially', async () => { + addNote(doc); + service.addElement('shape', { + shapeType: 'rect', + }); + addNote(doc); + await wait(); + + expect(service.layer.layers.length).toBe(3); + }); +}); + +test('delete element should update layer automatically', () => { + const id = addNote(doc); + const canvasElId = service.addElement('shape', { + shapeType: 'rect', + }); + + service.removeElement(id); + + expect(service.layer.layers.length).toBe(1); + + service.removeElement(canvasElId); + + expect(service.layer.layers.length).toBe(0); +}); + +test('change element should update layer automatically', async () => { + const id = addNote(doc); + const canvasElId = service.addElement('shape', { + shapeType: 'rect', + }); + + await wait(); + + service.updateElement(id, { + index: service.layer.getReorderedIndex( + service.getElementById(id)!, + 'forward' + ), + }); + expect(service.layer.layers[service.layer.layers.length - 1].type).toBe( + 'block' + ); + + service.updateElement(canvasElId, { + index: service.layer.getReorderedIndex( + service.getElementById(canvasElId)!, + 'forward' + ), + }); + expect(service.layer.layers[service.layer.layers.length - 1].type).toBe( + 'canvas' + ); +}); + +test('new added canvas elements should be placed in the topmost canvas layer', async () => { + addNote(doc); + service.addElement('shape', { + shapeType: 'rect', + }); + + await wait(); + + expect(service.layer.layers.length).toBe(2); + expect(service.layer.layers[1].type).toBe('canvas'); +}); + +test("there should be at lease one layer in canvasLayers property even there's no canvas element", () => { + addNote(doc); + + expect(service.layer.canvasLayers.length).toBe(1); +}); + +test('if the topmost layer is canvas layer, the length of canvasLayers array should equal to the counts of canvas layers', () => { + addNote(doc); + service.addElement('shape', { + shapeType: 'rect', + }); + addNote(doc); + service.addElement('shape', { + shapeType: 'rect', + }); + + expect(service.layer.layers.length).toBe(4); + expect(service.layer.canvasLayers.length).toBe( + service.layer.layers.filter(layer => layer.type === 'canvas').length + ); +}); + +test('a new layer should be created in canvasLayers prop when the topmost layer is not canvas layer', () => { + service.addElement('shape', { + shapeType: 'rect', + }); + addNote(doc); + service.addElement('shape', { + shapeType: 'rect', + }); + addNote(doc); + + expect(service.layer.canvasLayers.length).toBe(3); +}); + +test('layer zindex should update correctly when elements changed', async () => { + addNote(doc); + const noteId = addNote(doc); + const note = service.getElementById(noteId); + addNote(doc); + service.addElement('shape', { + shapeType: 'rect', + }); + const topShapeId = service.addElement('shape', { + shapeType: 'rect', + }); + const topShape = service.getElementById(topShapeId); + + await wait(); + + const assertInitialState = () => { + expect(service.layer.layers[0].type).toBe('block'); + expect(service.layer.layers[0].zIndex).toBe(1); + + expect(service.layer.layers[1].type).toBe('canvas'); + expect(service.layer.layers[1].zIndex).toBe(4); + }; + assertInitialState(); + + service.doc.captureSync(); + + service.updateElement(noteId, { + index: service.layer.getReorderedIndex(note!, 'front'), + }); + await wait(); + service.doc.captureSync(); + + const assert2StepState = () => { + expect(service.layer.layers[1].type).toBe('canvas'); + expect(service.layer.layers[1].zIndex).toBe(3); + + expect(service.layer.layers[2].type).toBe('block'); + expect(service.layer.layers[2].zIndex).toBe(4); + }; + assert2StepState(); + + service.updateElement(topShapeId, { + index: service.layer.getReorderedIndex(topShape!, 'front'), + }); + await wait(); + service.doc.captureSync(); + + expect(service.layer.layers[3].type).toBe('canvas'); + expect(service.layer.layers[3].zIndex).toBe(5); + + service.doc.undo(); + await wait(); + assert2StepState(); + + service.doc.undo(); + await wait(); + assertInitialState(); +}); + +test('blocks should rerender when their z-index changed', async () => { + const blocks = [addNote(doc), addNote(doc), addNote(doc), addNote(doc)]; + const assertBlocksContent = () => { + const blocks = Array.from( + document.querySelectorAll( + 'affine-edgeless-root gfx-viewport > [data-block-id]' + ) + ); + + expect(blocks.length).toBe(5); + + blocks.forEach(element => { + expect(element.children.length).toBeGreaterThan(0); + }); + }; + + await wait(); + assertBlocksContent(); + + service.addElement('shape', { + shapeType: 'rect', + index: CommonUtils.generateKeyBetween( + service.getElementById(blocks[1])!.index, + service.getElementById(blocks[2])!.index + ), + }); + + await wait(); + assertBlocksContent(); +}); + +describe('layer reorder functionality', () => { + let ids: string[] = []; + + beforeEach(() => { + ids = [ + service.addElement('shape', { + shapeType: 'rect', + }), + addNote(doc), + service.addElement('shape', { + shapeType: 'rect', + }), + addNote(doc), + ]; + }); + + test('forward', async () => { + service.updateElement(ids[0], { + index: service.layer.getReorderedIndex( + service.getElementById(ids[0])!, + 'forward' + ), + }); + + expect( + service.layer.layers.findIndex(layer => + layer.set.has(service.getElementById(ids[0]) as any) + ) + ).toBe(1); + expect( + service.layer.layers.findIndex(layer => + layer.set.has(service.getElementById(ids[1]) as any) + ) + ).toBe(0); + + await wait(); + + service.updateElement(ids[1], { + index: service.layer.getReorderedIndex( + service.getElementById(ids[1])!, + 'forward' + ), + }); + + expect( + service.layer.layers.findIndex(layer => + layer.set.has(service.getElementById(ids[0]) as any) + ) + ).toBe(0); + expect( + service.layer.layers.findIndex(layer => + layer.set.has(service.getElementById(ids[1]) as any) + ) + ).toBe(1); + }); + + test('front', async () => { + service.updateElement(ids[0], { + index: service.layer.getReorderedIndex( + service.getElementById(ids[0])!, + 'front' + ), + }); + + await wait(); + + expect( + service.layer.layers.findIndex(layer => + layer.set.has(service.getElementById(ids[0]) as any) + ) + ).toBe(3); + + service.updateElement(ids[1], { + index: service.layer.getReorderedIndex( + service.getElementById(ids[1])!, + 'front' + ), + }); + + expect( + service.layer.layers.findIndex(layer => + layer.set.has(service.getElementById(ids[1]) as any) + ) + ).toBe(3); + }); + + test('backward', async () => { + service.updateElement(ids[3], { + index: service.layer.getReorderedIndex( + service.getElementById(ids[3])!, + 'backward' + ), + }); + + expect( + service.layer.layers.findIndex(layer => + layer.set.has(service.getElementById(ids[3]) as any) + ) + ).toBe(1); + expect( + service.layer.layers.findIndex(layer => + layer.set.has(service.getElementById(ids[2]) as any) + ) + ).toBe(2); + + await wait(); + + service.updateElement(ids[2], { + index: service.layer.getReorderedIndex( + service.getElementById(ids[2])!, + 'backward' + ), + }); + + expect( + service.layer.layers.findIndex(layer => + layer.set.has(service.getElementById(ids[3]) as any) + ) + ).toBe(3); + expect( + service.layer.layers.findIndex(layer => + layer.set.has(service.getElementById(ids[2]) as any) + ) + ).toBe(2); + }); + + test('back', async () => { + service.updateElement(ids[3], { + index: service.layer.getReorderedIndex( + service.getElementById(ids[3])!, + 'back' + ), + }); + + expect( + service.layer.layers.findIndex(layer => + layer.set.has(service.getElementById(ids[3]) as any) + ) + ).toBe(0); + + await wait(); + + service.updateElement(ids[2], { + index: service.layer.getReorderedIndex( + service.getElementById(ids[2])!, + 'back' + ), + }); + + expect( + service.layer.layers.findIndex(layer => + layer.set.has(service.getElementById(ids[2]) as any) + ) + ).toBe(0); + }); +}); + +describe('group related functionality', () => { + const createGroup = ( + service: EdgelessRootBlockComponent['service'], + childIds: string[] + ) => { + const children = new DocCollection.Y.Map<boolean>(); + childIds.forEach(id => children.set(id, true)); + + return service.addElement('group', { + children, + }); + }; + + test("new added group should effect it children's layer", async () => { + const edgeless = getDocRootBlock(doc, editor, 'edgeless'); + const elements = [ + service.addElement('shape', { + shapeType: 'rect', + }), + addNote(doc), + service.addElement('shape', { + shapeType: 'rect', + }), + addNote(doc), + service.addElement('shape', { + shapeType: 'rect', + }), + ]; + + await wait(0); + expect( + edgeless.querySelectorAll<HTMLCanvasElement>('.indexable-canvas').length + ).toBe(2); + + Array.from( + edgeless.querySelectorAll<HTMLCanvasElement>('.indexable-canvas') + ).forEach(canvas => { + const rect = canvas.getBoundingClientRect(); + + expect(rect.width).toBeGreaterThan(0); + expect(rect.height).toBeGreaterThan(0); + }); + + createGroup( + service, + elements.filter((_, idx) => idx !== 1 && idx !== 3) + ); + + expect(service.layer.layers.length).toBe(2); + + expect(service.layer.layers[0].type).toBe('block'); + expect(service.layer.layers[0].set.size).toBe(2); + + expect(service.layer.layers[1].type).toBe('canvas'); + expect(service.layer.layers[1].set.size).toBe(4); + + expect( + edgeless.querySelectorAll<HTMLCanvasElement>('.indexable-canvas').length + ).toBe(0); + + const topCanvas = edgeless.querySelector( + 'affine-surface canvas' + ) as HTMLCanvasElement; + + expect( + Number( + ( + edgeless.querySelector( + `[data-block-id="${elements[1]}"]` + ) as HTMLElement + ).style.zIndex + ) + ).toBeLessThan(Number(topCanvas.style.zIndex)); + expect( + Number( + ( + edgeless.querySelector( + `[data-block-id="${elements[3]}"]` + ) as HTMLElement + ).style.zIndex + ) + ).toBeLessThan(Number(topCanvas.style.zIndex)); + }); + + test("change group index should update its children's layer", () => { + const elements = [ + service.addElement('shape', { + shapeType: 'rect', + }), + addNote(doc), + service.addElement('shape', { + shapeType: 'rect', + }), + addNote(doc), + service.addElement('shape', { + shapeType: 'rect', + }), + ]; + + const groupId = createGroup( + service, + elements.filter((_, idx) => idx !== 1 && idx !== 3) + ); + const group = service.getElementById(groupId)!; + + expect(service.layer.layers.length).toBe(2); + + group.index = service.layer.getReorderedIndex(group, 'back'); + expect(service.layer.layers[0].type).toBe('canvas'); + expect(service.layer.layers[0].set.size).toBe(4); + expect(service.layer.layers[0].elements[0]).toBe(group); + + group.index = service.layer.getReorderedIndex(group, 'front'); + expect(service.layer.layers[1].type).toBe('canvas'); + expect(service.layer.layers[1].set.size).toBe(4); + expect(service.layer.layers[1].elements[0]).toBe(group); + }); + + test('should keep relative index order of elements after group, ungroup, undo, redo', () => { + const edgeless = getDocRootBlock(doc, editor, 'edgeless'); + const elementIds = [ + service.addElement('shape', { + shapeType: 'rect', + }), + addNote(doc), + service.addElement('shape', { + shapeType: 'rect', + }), + addNote(doc), + service.addElement('shape', { + shapeType: 'rect', + }), + ]; + service.doc.captureSync(); + const elements = elementIds.map(id => service.getElementById(id)!); + + const isKeptRelativeOrder = () => { + return elements.every((element, idx) => { + if (idx === 0) return true; + return elements[idx - 1].index < element.index; + }); + }; + + expect(isKeptRelativeOrder()).toBeTruthy(); + + const groupId = createGroup(edgeless.service, elementIds); + expect(isKeptRelativeOrder()).toBeTruthy(); + + service.ungroup(service.getElementById(groupId) as GroupElementModel); + expect(isKeptRelativeOrder()).toBeTruthy(); + + service.doc.undo(); + expect(isKeptRelativeOrder()).toBeTruthy(); + + service.doc.redo(); + expect(isKeptRelativeOrder()).toBeTruthy(); + }); +}); + +describe('compare function', () => { + const SORT_ORDER = { + AFTER: 1, + BEFORE: -1, + SAME: 0, + }; + const createGroup = ( + service: EdgelessRootBlockComponent['service'], + childIds: string[] + // eslint-disable-next-line sonarjs/no-identical-functions + ) => { + const children = new DocCollection.Y.Map<boolean>(); + childIds.forEach(id => children.set(id, true)); + + return service.addElement('group', { + children, + }); + }; + + test('compare same element', () => { + const shapeId = service.addElement('shape', { + shapeType: 'rect', + }); + const shapeEl = service.getElementById(shapeId)!; + expect(service.layer.compare(shapeEl, shapeEl)).toBe(SORT_ORDER.SAME); + + const groupId = createGroup(service, [shapeId]); + const groupEl = service.getElementById(groupId)!; + expect(service.layer.compare(groupEl, groupEl)).toBe(SORT_ORDER.SAME); + + const noteId = addNote(doc); + const note = service.getElementById(noteId)! as NoteBlockModel; + expect(service.layer.compare(note, note)).toBe(SORT_ORDER.SAME); + }); + + test('compare a group and its child', () => { + const shapeId = service.addElement('shape', { + shapeType: 'rect', + }); + const shapeEl = service.getElementById(shapeId)!; + const noteId = addNote(doc); + const note = service.getElementById(noteId)! as NoteBlockModel; + const groupId = createGroup(service, [shapeId, noteId]); + const groupEl = service.getElementById(groupId)!; + + expect(service.layer.compare(groupEl, shapeEl)).toBe(SORT_ORDER.BEFORE); + expect(service.layer.compare(shapeEl, groupEl)).toBe(SORT_ORDER.AFTER); + expect(service.layer.compare(groupEl, note)).toBe(SORT_ORDER.BEFORE); + expect(service.layer.compare(note, groupEl)).toBe(SORT_ORDER.AFTER); + }); + + test('compare two different elements', () => { + const shape1Id = service.addElement('shape', { + shapeType: 'rect', + }); + const shape1 = service.getElementById(shape1Id)!; + const shape2Id = service.addElement('shape', { + shapeType: 'rect', + }); + const shape2 = service.getElementById(shape2Id)!; + const note1Id = addNote(doc); + + const note1 = service.getElementById(note1Id)! as NoteBlockModel; + const note2Id = addNote(doc); + const note2 = service.getElementById(note2Id)! as NoteBlockModel; + + expect(service.layer.compare(shape1, shape2)).toBe(SORT_ORDER.BEFORE); + expect(service.layer.compare(shape2, shape1)).toBe(SORT_ORDER.AFTER); + + expect(service.layer.compare(note1, note2)).toBe(SORT_ORDER.BEFORE); + expect(service.layer.compare(note2, note1)).toBe(SORT_ORDER.AFTER); + + expect(service.layer.compare(shape1, note1)).toBe(SORT_ORDER.BEFORE); + expect(service.layer.compare(note1, shape1)).toBe(SORT_ORDER.AFTER); + + expect(service.layer.compare(shape2, note2)).toBe(SORT_ORDER.BEFORE); + expect(service.layer.compare(note2, shape2)).toBe(SORT_ORDER.AFTER); + }); + + test('compare nested elements', () => { + const shape1Id = service.addElement('shape', { + shapeType: 'rect', + }); + const shape2Id = service.addElement('shape', { + shapeType: 'rect', + }); + const note1Id = addNote(doc); + const note2Id = addNote(doc); + const group1Id = createGroup(service, [ + shape1Id, + shape2Id, + note1Id, + note2Id, + ]); + const group2Id = createGroup(service, [group1Id]); + + const shape1 = service.getElementById(shape1Id)!; + const shape2 = service.getElementById(shape2Id)!; + const note1 = service.getElementById(note1Id)! as NoteBlockModel; + const note2 = service.getElementById(note2Id)! as NoteBlockModel; + const group1 = service.getElementById(group1Id)!; + const group2 = service.getElementById(group2Id)!; + + // assert nested group to group + expect(service.layer.compare(group2, group1)).toBe(SORT_ORDER.BEFORE); + expect(service.layer.compare(group1, group2)).toBe(SORT_ORDER.AFTER); + + // assert element in the same group + expect(service.layer.compare(shape1, shape2)).toBe(SORT_ORDER.BEFORE); + expect(service.layer.compare(shape2, shape1)).toBe(SORT_ORDER.AFTER); + expect(service.layer.compare(note1, note2)).toBe(SORT_ORDER.BEFORE); + expect(service.layer.compare(note2, note1)).toBe(SORT_ORDER.AFTER); + + // assert group and its nested element + expect(service.layer.compare(group2, shape1)).toBe(SORT_ORDER.BEFORE); + expect(service.layer.compare(shape1, group2)).toBe(SORT_ORDER.AFTER); + expect(service.layer.compare(group1, shape2)).toBe(SORT_ORDER.BEFORE); + expect(service.layer.compare(shape2, group1)).toBe(SORT_ORDER.AFTER); + expect(service.layer.compare(group2, note1)).toBe(SORT_ORDER.BEFORE); + expect(service.layer.compare(note1, group2)).toBe(SORT_ORDER.AFTER); + expect(service.layer.compare(group1, note2)).toBe(SORT_ORDER.BEFORE); + expect(service.layer.compare(note2, group1)).toBe(SORT_ORDER.AFTER); + }); + + test('compare two nested elements', () => { + const groupAShapeId = service.addElement('shape', { + shapeType: 'rect', + }); + const groupANoteId = addNote(doc); + const groupAId = createGroup(service, [ + createGroup(service, [groupAShapeId, groupANoteId]), + ]); + const groupAShape = service.getElementById(groupAShapeId)!; + const groupANote = service.getElementById(groupANoteId)!; + const groupA = service.getElementById(groupAId)!; + + const groupBShapeId = service.addElement('shape', { + shapeType: 'rect', + }); + const groupBNoteId = addNote(doc); + const groupBId = createGroup(service, [ + createGroup(service, [groupBShapeId, groupBNoteId]), + ]); + const groupBShape = service.getElementById(groupBShapeId)!; + const groupBNote = service.getElementById(groupBNoteId)!; + const groupB = service.getElementById(groupBId)!; + + expect(service.layer.compare(groupAShape, groupBShape)).toBe( + SORT_ORDER.BEFORE + ); + expect(service.layer.compare(groupBShape, groupAShape)).toBe( + SORT_ORDER.AFTER + ); + expect(service.layer.compare(groupANote, groupBNote)).toBe( + SORT_ORDER.BEFORE + ); + expect(service.layer.compare(groupBNote, groupANote)).toBe( + SORT_ORDER.AFTER + ); + expect(service.layer.compare(groupB, groupA)).toBe(SORT_ORDER.AFTER); + + groupB.index = service.layer.getReorderedIndex(groupB, 'back'); + expect(service.layer.compare(groupB, groupA)).toBe(SORT_ORDER.BEFORE); + expect(service.layer.compare(groupAShape, groupBShape)).toBe( + SORT_ORDER.AFTER + ); + expect(service.layer.compare(groupBShape, groupAShape)).toBe( + SORT_ORDER.BEFORE + ); + expect(service.layer.compare(groupANote, groupBNote)).toBe( + SORT_ORDER.AFTER + ); + expect(service.layer.compare(groupBNote, groupANote)).toBe( + SORT_ORDER.BEFORE + ); + + groupA.index = service.layer.getReorderedIndex(groupA, 'back'); + expect(service.layer.compare(groupA, groupB)).toBe(SORT_ORDER.BEFORE); + expect(service.layer.compare(groupAShape, groupBShape)).toBe( + SORT_ORDER.BEFORE + ); + expect(service.layer.compare(groupBShape, groupAShape)).toBe( + SORT_ORDER.AFTER + ); + expect(service.layer.compare(groupANote, groupBNote)).toBe( + SORT_ORDER.BEFORE + ); + expect(service.layer.compare(groupBNote, groupANote)).toBe( + SORT_ORDER.AFTER + ); + }); +}); + +test('indexed canvas should be inserted into edgeless portal when switch to edgeless mode', async () => { + let surface = getSurface(doc, editor); + + service.addElement('shape', { + shapeType: 'rect', + }); + + addNote(doc); + + await wait(); + + service.addElement('shape', { + shapeType: 'rect', + }); + + editor.mode = 'page'; + await wait(); + editor.mode = 'edgeless'; + await wait(); + + surface = getSurface(doc, editor); + const edgeless = getDocRootBlock(doc, editor, 'edgeless'); + expect(edgeless.querySelectorAll('.indexable-canvas').length).toBe(1); + + const indexedCanvas = edgeless.querySelectorAll( + '.indexable-canvas' + )[0] as HTMLCanvasElement; + + expect(indexedCanvas.width).toBe(surface.renderer.canvas.width); + expect(indexedCanvas.height).toBe(surface.renderer.canvas.height); + expect(indexedCanvas.width).not.toBe(0); + expect(indexedCanvas.height).not.toBe(0); +}); + +test('the actual rendering z-index should satisfy the logic order of their indexes', async () => { + editor.mode = 'page'; + + await wait(); + + const indexes = [ + 'ao', + 'b0D', + 'ar', + 'as', + 'at', + 'au', + 'av', + 'b0Y', + 'b0V', + 'b0H', + 'b0M', + 'b0T', + 'b0f', + 'b0fV', + 'b0g', + 'b0i', + 'b0fl', + ]; + + indexes.forEach(index => { + addNote(doc, { + index, + }); + }); + + await wait(); + + editor.mode = 'edgeless'; + await wait(500); + + const edgeless = getDocRootBlock(doc, editor, 'edgeless'); + const blocks = Array.from( + edgeless.querySelectorAll('gfx-viewport > [data-block-id]') + ) as BlockComponent[]; + + expect(blocks.length).toBe(indexes.length + 1); + + blocks + .filter(block => block.flavour !== 'affine:surface') + .forEach((block, index) => { + if (index === blocks.length - 1) return; + + const model = block.model as BlockModel<{ index: string }>; + const nextModel = blocks[index + 1].model as BlockModel<{ + index: string; + }>; + + const zIndex = Number(block.style.zIndex); + const nextZIndex = Number(blocks[index + 1].style.zIndex); + + expect(model.index <= nextModel.index).equals(zIndex <= nextZIndex); + }); +}); + +describe('index generator', () => { + let preinsertedShape: BlockSuite.SurfaceElementModel; + let preinsertedNote: NoteBlockModel; + + beforeEach(() => { + const shapeId = service.addElement('shape', { + shapeType: 'rect', + }); + const noteId = addNote(doc); + + preinsertedShape = service.getElementById( + shapeId + )! as BlockSuite.SurfaceElementModel; + preinsertedNote = service.getElementById(noteId)! as NoteBlockModel; + }); + + test('generator should remember the index it generated', () => { + const generator = service.layer.createIndexGenerator(); + + const shape1 = generator(); + const block1 = generator(); + const shape2 = generator(); + const block2 = generator(); + + expect(block2 > shape2).toBeTruthy(); + expect(shape2 > block1).toBeTruthy(); + expect(block1 > shape1).toBeTruthy(); + expect(shape1 > preinsertedNote.index).toBeTruthy(); + expect(shape1 > preinsertedShape.index).toBeTruthy(); + }); +}); diff --git a/blocksuite/presets/src/__tests__/edgeless/mini-mindmap.spec.ts b/blocksuite/presets/src/__tests__/edgeless/mini-mindmap.spec.ts new file mode 100644 index 0000000000..5afcb10182 --- /dev/null +++ b/blocksuite/presets/src/__tests__/edgeless/mini-mindmap.spec.ts @@ -0,0 +1,109 @@ +import type { EditorHost } from '@blocksuite/block-std'; +import { GfxControllerIdentifier } from '@blocksuite/block-std/gfx'; +import { + type EdgelessRootBlockComponent, + type MindmapElementModel, + type MindmapSurfaceBlock, + MiniMindmapPreview, +} from '@blocksuite/blocks'; +import { beforeEach, describe, expect, test } from 'vitest'; + +import { wait } from '../utils/common.js'; +import { getDocRootBlock } from '../utils/edgeless.js'; +import { setupEditor } from '../utils/setup.js'; + +describe('mind map', () => { + let edgeless!: EdgelessRootBlockComponent; + + beforeEach(async () => { + const cleanup = await setupEditor('edgeless'); + + edgeless = getDocRootBlock(doc, editor, 'edgeless'); + + edgeless.gfx.tool.setTool('default'); + + return cleanup; + }); + + test('mind map preview', async () => { + const mindmapAnswer = ` + - Mindmap + - Node 1 + - Node 1.1 + - Node 1.2 + - Node 2 + - Node 2.1 + - Node 2.2 + `; + + const createPreview = ( + host: EditorHost, + answer: string = mindmapAnswer + ) => { + const mindmapPreview = new MiniMindmapPreview(); + + mindmapPreview.answer = answer; + mindmapPreview.host = host; + mindmapPreview.ctx = { + get() { + return {}; + }, + set() {}, + }; + + document.body.append(mindmapPreview); + + return mindmapPreview; + }; + + const miniMindMapPreview = createPreview(window.editor.host!); + await wait(50); + const miniMindMapSurface = miniMindMapPreview.renderRoot.querySelector( + 'mini-mindmap-surface-block' + ) as MindmapSurfaceBlock; + + expect(miniMindMapSurface).not.toBeNull(); + expect(miniMindMapSurface.renderer).toBeDefined(); + expect(miniMindMapSurface.renderer?.canvas.isConnected).toBe(true); + expect(miniMindMapSurface.renderer?.canvas.width).toBeGreaterThan(0); + expect(miniMindMapSurface.renderer?.canvas.height).toBeGreaterThan(0); + + expect(miniMindMapSurface.model.elementModels.length).toBe(8); + + return () => { + miniMindMapPreview.remove(); + }; + }); + + test('new mind map should layout automatically', async () => { + const gfx = editor.std.get(GfxControllerIdentifier); + const mindmapId = gfx.surface!.addElement({ + type: 'mindmap', + children: { + text: 'Main node', + children: [ + { + text: 'Child node', + }, + { + text: 'Second child node', + }, + { + text: 'Third child node', + }, + ], + }, + }); + const mindmap = gfx.getElementById(mindmapId) as MindmapElementModel; + await wait(0); + const [child1, child2, child3] = mindmap.tree.children; + + expect(mindmapId).not.toBeUndefined(); + expect(mindmap.tree.children.length).toBe(3); + expect(mindmap.tree.element.x).toBeLessThan(child1.element.x); + expect(child1.element.x).toBe(child2.element.x); + expect(child2.element.x).toBe(child3.element.x); + expect(child1.element.y).toBeLessThan(child2.element.y); + expect(child2.element.y).toBeLessThan(child3.element.y); + }); +}); diff --git a/blocksuite/presets/src/__tests__/edgeless/surface-model.spec.ts b/blocksuite/presets/src/__tests__/edgeless/surface-model.spec.ts new file mode 100644 index 0000000000..6a9a94a967 --- /dev/null +++ b/blocksuite/presets/src/__tests__/edgeless/surface-model.spec.ts @@ -0,0 +1,520 @@ +import type { + BrushElementModel, + GroupElementModel, + ShapeElementModel, + SurfaceBlockModel, +} from '@blocksuite/blocks'; +import { beforeEach, describe, expect, test, vi } from 'vitest'; + +import { wait } from '../utils/common.js'; +import { setupEditor } from '../utils/setup.js'; + +let model: SurfaceBlockModel; + +beforeEach(async () => { + const cleanup = await setupEditor('edgeless'); + const models = doc.getBlockByFlavour('affine:surface') as SurfaceBlockModel[]; + + model = models[0]; + + return cleanup; +}); + +describe('elements management', () => { + test('addElement should work correctly', () => { + const id = model.addElement({ + type: 'shape', + }); + + expect(model.elementModels[0].id).toBe(id); + }); + + test('removeElement should work correctly', () => { + const id = model.addElement({ + type: 'shape', + }); + + model.deleteElement(id); + + expect(model.elementModels.length).toBe(0); + }); + + test('updateElement should work correctly', () => { + const id = model.addElement({ + type: 'shape', + }); + + model.updateElement(id, { xywh: '[10,10,200,200]' }); + + expect(model.elementModels[0].xywh).toBe('[10,10,200,200]'); + }); + + test('getElementById should return element', () => { + const id = model.addElement({ + type: 'shape', + }); + + expect(model.getElementById(id)).not.toBeNull(); + }); + + test('getElementById should return null if not found', () => { + expect(model.getElementById('not-found')).toBeNull(); + }); +}); + +describe('element model', () => { + test('default value should work correctly', () => { + const id = model.addElement({ + type: 'shape', + }); + + const element = model.getElementById(id)! as ShapeElementModel; + + expect(element.index).toBe('a0'); + expect(element.strokeColor).toBe('--affine-palette-line-yellow'); + expect(element.strokeWidth).toBe(4); + }); + + test('defined prop should not be overwritten by default value', () => { + const id = model.addElement({ + type: 'shape', + strokeColor: '--affine-palette-line-black', + }); + + const element = model.getElementById(id)! as ShapeElementModel; + + expect(element.strokeColor).toBe('--affine-palette-line-black'); + }); + + test('assign value to model property should update ymap directly', () => { + const id = model.addElement({ + type: 'shape', + }); + + const element = model.getElementById(id)! as ShapeElementModel; + + expect(element.yMap.get('strokeColor')).toBe( + '--affine-palette-line-yellow' + ); + + element.strokeColor = '--affine-palette-line-black'; + expect(element.yMap.get('strokeColor')).toBe('--affine-palette-line-black'); + expect(element.strokeColor).toBe('--affine-palette-line-black'); + }); +}); + +describe('group', () => { + test('should get group', () => { + const id = model.addElement({ + type: 'shape', + }); + const id2 = model.addElement({ + type: 'shape', + }); + + const groupId = model.addElement({ + type: 'group', + children: { + [id]: true, + [id2]: true, + }, + }); + const group = model.getElementById(groupId); + const shape = model.getElementById(id)!; + const shape2 = model.getElementById(id2)!; + + expect(group).not.toBe(null); + expect(model.getGroup(id)).toBe(group); + expect(model.getGroup(id2)).toBe(group); + expect(shape.group).toBe(group); + expect(shape2.group).toBe(group); + }); + + test('should return null if children property is updated', () => { + const id = model.addElement({ + type: 'shape', + }); + const id2 = model.addElement({ + type: 'shape', + }); + const id3 = model.addElement({ + type: 'shape', + }); + + const groupId = model.addElement({ + type: 'group', + children: { + [id]: true, + [id2]: true, + [id3]: true, + }, + }); + const group = model.getElementById(groupId) as GroupElementModel; + + model.doc.transact(() => { + group.children.delete(id); + group.children.delete(id2); + }); + + expect(model.getElementById(groupId)).toBe(group); + expect(model.getGroup(id)).toBeNull(); + expect(model.getGroup(id2)).toBeNull(); + }); + + test('should return null if group are deleted', () => { + const id = model.addElement({ + type: 'shape', + }); + const id2 = model.addElement({ + type: 'shape', + }); + + const groupId = model.addElement({ + type: 'group', + children: { + [id]: true, + [id2]: true, + }, + }); + + model.deleteElement(groupId); + expect(model.getGroup(id)).toBeNull(); + expect(model.getGroup(id2)).toBeNull(); + }); + + test('children can be updated with a plain object', () => { + const id = model.addElement({ + type: 'shape', + }); + const id2 = model.addElement({ + type: 'shape', + }); + + const groupId = model.addElement({ + type: 'group', + children: { + [id]: true, + [id2]: true, + }, + }); + const group = model.getElementById(groupId) as GroupElementModel; + + model.updateElement(groupId, { + children: { + [id]: false, + }, + }); + + expect(group.childIds).toEqual([id]); + }); +}); + +describe('connector', () => { + test('should get connector', () => { + const id = model.addElement({ + type: 'shape', + }); + const id2 = model.addElement({ + type: 'shape', + }); + const connectorId = model.addElement({ + type: 'connector', + source: { + id, + }, + target: { + id: id2, + }, + }); + const connector = model.getElementById(connectorId)!; + + expect(model.getConnectors(id).map(el => el.id)).toEqual([connector.id]); + expect(model.getConnectors(id2).map(el => el.id)).toEqual([connector.id]); + }); + + test('multiple connectors are supported', () => { + const id = model.addElement({ + type: 'shape', + }); + const id2 = model.addElement({ + type: 'shape', + }); + const connectorId = model.addElement({ + type: 'connector', + source: { + id, + }, + target: { + id: id2, + }, + }); + const connectorId2 = model.addElement({ + type: 'connector', + source: { + id, + }, + target: { + id: id2, + }, + }); + + const connector = model.getElementById(connectorId)!; + const connector2 = model.getElementById(connectorId2)!; + const connectors = [connector.id, connector2.id]; + + expect(model.getConnectors(id).map(c => c.id)).toEqual(connectors); + expect(model.getConnectors(id2).map(c => c.id)).toEqual(connectors); + }); + + test('should return null if connector are updated', () => { + const id = model.addElement({ + type: 'shape', + }); + const id2 = model.addElement({ + type: 'shape', + }); + const connectorId = model.addElement({ + type: 'connector', + source: { + id, + }, + target: { + id: id2, + }, + }); + + model.updateElement(connectorId, { + source: { + position: [0, 0], + }, + target: { + position: [0, 0], + }, + }); + + expect(model.getConnectors(id)).toEqual([]); + expect(model.getConnectors(id2)).toEqual([]); + }); + + test('should return null if connector are deleted', async () => { + const id = model.addElement({ + type: 'shape', + }); + const id2 = model.addElement({ + type: 'shape', + }); + const connectorId = model.addElement({ + type: 'connector', + source: { + id, + }, + target: { + id: id2, + }, + }); + + model.deleteElement(connectorId); + + await wait(); + + expect(model.getConnectors(id)).toEqual([]); + expect(model.getConnectors(id2)).toEqual([]); + }); +}); + +describe('stash/pop', () => { + test('stash and pop should work correctly', () => { + const id = model.addElement({ + type: 'shape', + strokeWidth: 4, + }); + const elementModel = model.getElementById(id)! as ShapeElementModel; + + expect(elementModel.strokeWidth).toBe(4); + + elementModel.stash('strokeWidth'); + elementModel.strokeWidth = 10; + expect(elementModel.strokeWidth).toBe(10); + expect(elementModel.yMap.get('strokeWidth')).toBe(4); + + elementModel.pop('strokeWidth'); + expect(elementModel.strokeWidth).toBe(10); + expect(elementModel.yMap.get('strokeWidth')).toBe(10); + + elementModel.strokeWidth = 6; + expect(elementModel.strokeWidth).toBe(6); + expect(elementModel.yMap.get('strokeWidth')).toBe(6); + }); + + test('assign stashed property should emit event', () => { + const id = model.addElement({ + type: 'shape', + strokeWidth: 4, + }); + const elementModel = model.getElementById(id)! as ShapeElementModel; + + elementModel.stash('strokeWidth'); + + const onchange = vi.fn(); + model.elementUpdated.once(({ id }) => onchange(id)); + + elementModel.strokeWidth = 10; + expect(onchange).toHaveBeenCalledWith(id); + }); + + test('stashed property should also trigger derive decorator', () => { + const id = model.addElement({ + type: 'brush', + points: [ + [0, 0], + [100, 100], + [120, 150], + ], + }); + const elementModel = model.getElementById(id)! as BrushElementModel; + + elementModel.stash('points'); + elementModel.points = [ + [0, 0], + [50, 50], + [135, 145], + [150, 170], + [200, 180], + ]; + const points = elementModel.points; + + expect(elementModel.w).toBe(200 + elementModel.lineWidth); + expect(elementModel.h).toBe(180 + elementModel.lineWidth); + + expect(elementModel.yMap.get('points')).not.toEqual(points); + expect(elementModel.w).toBe(200 + elementModel.lineWidth); + expect(elementModel.h).toBe(180 + elementModel.lineWidth); + }); + + test('non-field property should not allow stash/pop, and should failed silently ', () => { + const id = model.addElement({ + type: 'group', + }); + const elementModel = model.getElementById(id)! as GroupElementModel; + + elementModel.stash('xywh'); + elementModel.xywh = '[10,10,200,200]'; + + expect(elementModel['_stashed'].has('xywh')).toBe(false); + + elementModel.pop('xywh'); + + expect(elementModel['_stashed'].has('xywh')).toBe(false); + expect(elementModel.yMap.has('xywh')).toBe(false); + }); +}); + +describe('derive decorator', () => { + test('derived decorator should work correctly', () => { + const id = model.addElement({ + type: 'brush', + points: [ + [0, 0], + [100, 100], + [120, 150], + ], + }); + const elementModel = model.getElementById(id)! as BrushElementModel; + + expect(elementModel.w).toBe(120 + elementModel.lineWidth); + expect(elementModel.h).toBe(150 + elementModel.lineWidth); + }); +}); + +describe('local decorator', () => { + test('local decorator should work correctly', () => { + const id = model.addElement({ + type: 'shape', + }); + const elementModel = model.getElementById(id)! as BrushElementModel; + + expect(elementModel.display).toBe(true); + + elementModel.display = false; + expect(elementModel.display).toBe(false); + + elementModel.opacity = 0.5; + expect(elementModel.opacity).toBe(0.5); + }); + + test('assign local property should emit event', () => { + const id = model.addElement({ + type: 'shape', + }); + const elementModel = model.getElementById(id)! as BrushElementModel; + + const onchange = vi.fn(); + + model.elementUpdated.once(({ id }) => onchange(id)); + elementModel.display = false; + + expect(elementModel.display).toBe(false); + expect(onchange).toHaveBeenCalledWith(id); + }); +}); + +describe('convert decorator', () => { + test('convert decorator', () => { + const id = model.addElement({ + type: 'brush', + points: [ + [50, 25], + [200, 200], + [300, 300], + ], + }); + const elementModel = model.getElementById(id)! as BrushElementModel; + const halfLineWidth = elementModel.lineWidth / 2; + const xOffset = 50 - halfLineWidth; + const yOffset = 25 - halfLineWidth; + + expect(elementModel.points).toEqual([ + [50 - xOffset, 25 - yOffset], + [200 - xOffset, 200 - yOffset], + [300 - xOffset, 300 - yOffset], + ]); + }); +}); + +describe('basic property', () => { + test('empty group should have all zero xywh', () => { + const id = model.addElement({ + type: 'group', + }); + const group = model.getElementById(id)! as GroupElementModel; + + expect(group.x).toBe(0); + expect(group.y).toBe(0); + expect(group.w).toBe(0); + expect(group.h).toBe(0); + }); +}); + +describe('brush', () => { + test('same lineWidth should have same xywh', () => { + const id = model.addElement({ + type: 'brush', + lineWidth: 2, + points: [ + [0, 0], + [100, 100], + [120, 150], + ], + }); + const brush = model.getElementById(id) as BrushElementModel; + const oldBrushXYWH = brush.xywh; + + brush.lineWidth = 4; + + expect(brush.xywh).not.toBe(oldBrushXYWH); + + brush.lineWidth = 2; + + expect(brush.xywh).toBe(oldBrushXYWH); + }); +}); diff --git a/blocksuite/presets/src/__tests__/edgeless/surface-ref.spec.ts b/blocksuite/presets/src/__tests__/edgeless/surface-ref.spec.ts new file mode 100644 index 0000000000..a4af373a8b --- /dev/null +++ b/blocksuite/presets/src/__tests__/edgeless/surface-ref.spec.ts @@ -0,0 +1,307 @@ +import { + type EdgelessRootBlockComponent, + EdgelessRootService, + type FrameBlockComponent, + type SurfaceRefBlockComponent, +} from '@blocksuite/blocks'; +import type { DocSnapshot } from '@blocksuite/store'; +import { beforeEach, describe, expect, test } from 'vitest'; + +import { wait } from '../utils/common.js'; +import { addNote, getDocRootBlock } from '../utils/edgeless.js'; +import { importFromSnapshot } from '../utils/misc.js'; +import { setupEditor } from '../utils/setup.js'; + +describe('basic', () => { + let service: EdgelessRootBlockComponent['service']; + let edgelessRoot: EdgelessRootBlockComponent; + let noteAId = ''; + let noteBId = ''; + let shapeAId = ''; + let shapeBId = ''; + let frameId = ''; + + beforeEach(async () => { + const cleanup = await setupEditor('edgeless'); + edgelessRoot = getDocRootBlock(doc, editor, 'edgeless'); + service = edgelessRoot.service; + + noteAId = addNote(doc, { + index: service.generateIndex(), + }); + shapeAId = service.addElement('shape', { + type: 'rect', + xywh: '[0, 0, 100, 100]', + index: service.generateIndex(), + }); + noteBId = addNote(doc, { + index: service.generateIndex(), + }); + shapeBId = service.addElement('shape', { + type: 'rect', + xywh: '[100, 0, 100, 100]', + index: service.generateIndex(), + }); + frameId = service.addBlock( + 'affine:frame', + { + xywh: '[0, 0, 800, 200]', + index: service.generateIndex(), + }, + service.surface.id + ); + + return cleanup; + }); + + test('surface-ref should be rendered in page mode', async () => { + const surfaceRefId = doc.addBlock( + 'affine:surface-ref', + { + reference: frameId, + }, + noteAId + ); + + editor.mode = 'page'; + await wait(); + + expect( + document.querySelector( + `affine-surface-ref[data-block-id="${surfaceRefId}"]` + ) + ).instanceOf(Element); + }); + + test('surface-ref should be rendered as empty surface-ref-block-edgeless component page mode', async () => { + const surfaceRefId = doc.addBlock( + 'affine:surface-ref', + { + reference: frameId, + }, + noteAId + ); + + await wait(); + + const refBlock = document.querySelector( + `affine-edgeless-surface-ref[data-block-id="${surfaceRefId}"]` + )! as HTMLElement; + + expect(refBlock).instanceOf(Element); + expect(refBlock.innerText).toBe(''); + }); + + test('content in frame should be rendered in the correct order', async () => { + const surfaceRefId = doc.addBlock( + 'affine:surface-ref', + { + reference: frameId, + }, + noteAId + ); + + editor.mode = 'page'; + await wait(); + + const surfaceRef = document.querySelector( + `affine-surface-ref[data-block-id="${surfaceRefId}"]` + ) as HTMLElement; + const refBlocks = Array.from( + surfaceRef.querySelectorAll('affine-edgeless-note') + ) as HTMLElement[]; + const stackingCanvas = Array.from( + surfaceRef.querySelectorAll('.indexable-canvas')! + ) as HTMLCanvasElement[]; + + expect(refBlocks.length).toBe(2); + expect(stackingCanvas.length).toBe(2); + expect(stackingCanvas[0].style.zIndex > refBlocks[0].style.zIndex).toBe( + true + ); + }); + + test('content in group should be rendered in the correct order', async () => { + const groupId = service.addElement('group', { + children: { + [shapeAId]: true, + [shapeBId]: true, + [noteAId]: true, + [noteBId]: true, + }, + }); + const surfaceRefId = doc.addBlock( + 'affine:surface-ref', + { + reference: groupId, + }, + noteAId + ); + + editor.mode = 'page'; + await wait(); + + const surfaceRef = document.querySelector( + `affine-surface-ref[data-block-id="${surfaceRefId}"]` + ) as HTMLElement; + const refBlocks = Array.from( + surfaceRef.querySelectorAll('affine-edgeless-note') + ) as HTMLElement[]; + const stackingCanvas = Array.from( + surfaceRef.querySelectorAll('.indexable-canvas') + ) as HTMLCanvasElement[]; + + expect(refBlocks.length).toBe(2); + expect(stackingCanvas.length).toBe(2); + expect(stackingCanvas[1].style.zIndex > refBlocks[0].style.zIndex).toBe( + true + ); + }); + + test('frame should be rendered in surface-ref viewport', async () => { + const surfaceRefId = doc.addBlock( + 'affine:surface-ref', + { + reference: frameId, + }, + noteAId + ); + + editor.mode = 'page'; + await wait(); + + const surfaceRef = document.querySelector( + `affine-surface-ref[data-block-id="${surfaceRefId}"]` + ) as SurfaceRefBlockComponent; + + const edgeless = surfaceRef.previewEditor!.std.get(EdgelessRootService); + + const frame = surfaceRef.querySelector( + 'affine-frame' + ) as FrameBlockComponent; + + expect( + edgeless.viewport.isInViewport(frame.model.elementBound) + ).toBeTruthy(); + }); + + test('group should be rendered in surface-ref viewport', async () => { + const groupId = service.addElement('group', { + children: { + [shapeAId]: true, + [shapeBId]: true, + [noteAId]: true, + [noteBId]: true, + }, + }); + const surfaceRefId = doc.addBlock( + 'affine:surface-ref', + { + reference: groupId, + }, + noteAId + ); + + editor.mode = 'page'; + await wait(); + + const surfaceRef = document.querySelector( + `affine-surface-ref[data-block-id="${surfaceRefId}"]` + ) as SurfaceRefBlockComponent; + + const edgeless = surfaceRef.previewEditor!.std.get(EdgelessRootService); + + const group = edgeless.getElementById(groupId)!; + + expect(edgeless.viewport.isInViewport(group.elementBound)).toBeTruthy(); + }); + + test('viewport of surface-ref should be updated when the reference xywh updated', async () => { + const surfaceRefId = doc.addBlock( + 'affine:surface-ref', + { + reference: frameId, + }, + noteAId + ); + + editor.mode = 'page'; + await wait(); + + const surfaceRef = document.querySelector( + `affine-surface-ref[data-block-id="${surfaceRefId}"]` + ) as SurfaceRefBlockComponent; + + const edgeless = surfaceRef.previewEditor!.std.get(EdgelessRootService); + + const frame = surfaceRef.querySelector( + 'affine-frame' + ) as FrameBlockComponent; + + const oldViewport = edgeless.viewport.viewportBounds; + + frame.model.xywh = '[100, 100, 800, 200]'; + await wait(); + + expect(edgeless.viewport.viewportBounds).not.toEqual(oldViewport); + }); + + test('view in edgeless mode button', async () => { + const groupId = service.addElement('group', { + children: { + [shapeAId]: true, + [shapeBId]: true, + [noteAId]: true, + [noteBId]: true, + }, + }); + const surfaceRefId = doc.addBlock( + 'affine:surface-ref', + { + reference: groupId, + }, + noteAId + ); + + editor.mode = 'page'; + await wait(); + + const surfaceRef = document.querySelector( + `affine-surface-ref[data-block-id="${surfaceRefId}"]` + ) as HTMLElement; + + expect(surfaceRef).instanceOf(Element); + (surfaceRef as SurfaceRefBlockComponent).viewInEdgeless(); + await wait(); + }); +}); + +import snapshot from '../snapshots/edgeless/surface-ref.spec.ts/surface-ref.json' assert { type: 'json' }; + +describe('clipboard', () => { + test('import surface-ref snapshot should render content correctly', async () => { + await setupEditor('page'); + + const pageRoot = getDocRootBlock(doc, editor, 'page'); + const pageRootService = pageRoot.service; + + const newDoc = await importFromSnapshot( + pageRootService.collection, + snapshot as DocSnapshot + ); + expect(newDoc).toBeTruthy(); + + editor.doc = newDoc!; + await wait(); + + const surfaceRefs = newDoc!.getBlocksByFlavour('affine:surface-ref'); + expect(surfaceRefs).toHaveLength(2); + + const surfaceRefBlocks = surfaceRefs.map(({ id }) => + editor.std.view.getBlock(id) + ) as SurfaceRefBlockComponent[]; + + expect(surfaceRefBlocks[0].querySelector('.ref-placeholder')).toBeFalsy(); + expect(surfaceRefBlocks[1].querySelector('.ref-placeholder')).toBeFalsy(); + }); +}); diff --git a/blocksuite/presets/src/__tests__/edgeless/template.spec.ts b/blocksuite/presets/src/__tests__/edgeless/template.spec.ts new file mode 100644 index 0000000000..f18fa65648 --- /dev/null +++ b/blocksuite/presets/src/__tests__/edgeless/template.spec.ts @@ -0,0 +1,43 @@ +import { + EdgelessTemplatePanel, + type Template, + type TemplateManager, +} from '@blocksuite/blocks'; +import { beforeEach, expect, test } from 'vitest'; + +import { setupEditor } from '../utils/setup.js'; + +beforeEach(async () => { + const cleanup = await setupEditor('edgeless'); + + return cleanup; +}); + +test('extension api', async () => { + const mockTemplate = { + name: 'Test', + type: 'template', + } as Template; + const customTemplate = { + list: () => { + return [mockTemplate]; + }, + categories: () => { + return ['custom']; + }, + search: (_, __) => { + return [mockTemplate]; + }, + } satisfies TemplateManager; + + EdgelessTemplatePanel.templates.extend(customTemplate); + + const categories = await EdgelessTemplatePanel.templates.categories(); + expect(categories).toContain('custom'); + + const templates = await EdgelessTemplatePanel.templates.list('any'); + expect(templates).toContain(mockTemplate); + + const searchTemplates = await EdgelessTemplatePanel.templates.search('any'); + expect(searchTemplates).toContain(mockTemplate); +}); diff --git a/blocksuite/presets/src/__tests__/edgeless/tools.spec.ts b/blocksuite/presets/src/__tests__/edgeless/tools.spec.ts new file mode 100644 index 0000000000..856332bb78 --- /dev/null +++ b/blocksuite/presets/src/__tests__/edgeless/tools.spec.ts @@ -0,0 +1,95 @@ +import type { + EdgelessRootBlockComponent, + SurfaceBlockComponent, +} from '@blocksuite/blocks'; +import { beforeEach, describe, expect, test } from 'vitest'; + +import { click, drag, wait } from '../utils/common.js'; +import { addNote, getDocRootBlock, getSurface } from '../utils/edgeless.js'; +import { setupEditor } from '../utils/setup.js'; + +describe('default tool', () => { + let surface!: SurfaceBlockComponent; + let edgeless!: EdgelessRootBlockComponent; + let service!: EdgelessRootBlockComponent['service']; + + beforeEach(async () => { + const cleanup = await setupEditor('edgeless'); + + edgeless = getDocRootBlock(doc, editor, 'edgeless'); + surface = getSurface(window.doc, window.editor); + service = edgeless.service; + + edgeless.gfx.tool.setTool('default'); + + return cleanup; + }); + + test('element click selection', async () => { + const id = service.addElement('shape', { + shapeType: 'rect', + xywh: '[0,0,100,100]', + fillColor: 'red', + }); + + await wait(); + + service.viewport.setViewport(1, [ + service.viewport.width / 2, + service.viewport.height / 2, + ]); + + click(edgeless.host, { x: 0, y: 50 }); + + expect(edgeless.service.selection.surfaceSelections[0].elements).toEqual([ + id, + ]); + }); + + test('element drag moving', async () => { + const id = edgeless.service.addElement('shape', { + shapeType: 'rect', + xywh: '[0,0,100,100]', + fillColor: 'red', + }); + await wait(); + + edgeless.service.viewport.setViewport(1, [ + edgeless.service.viewport.width / 2, + edgeless.service.viewport.height / 2, + ]); + await wait(); + + click(edgeless.host, { x: 0, y: 50 }); + drag(edgeless.host, { x: 0, y: 50 }, { x: 0, y: 150 }); + await wait(); + + const element = service.getElementById(id)!; + expect(element.xywh).toEqual(`[0,100,100,100]`); + }); + + test('block drag moving', async () => { + const noteId = addNote(doc); + + await wait(); + + edgeless.service.viewport.setViewport(1, [ + surface.renderer.viewport.width / 2, + surface.renderer.viewport.height / 2, + ]); + await wait(); + + click(edgeless.host, { x: 50, y: 50 }); + expect(edgeless.service.selection.surfaceSelections[0].elements).toEqual([ + noteId, + ]); + drag(edgeless.host, { x: 50, y: 50 }, { x: 150, y: 150 }); + await wait(); + + const element = service.getElementById(noteId)!; + const [x, y] = JSON.parse(element.xywh); + + expect(x).toEqual(100); + expect(y).toEqual(100); + }); +}); diff --git a/blocksuite/presets/src/__tests__/env.d.ts b/blocksuite/presets/src/__tests__/env.d.ts new file mode 100644 index 0000000000..e24f18b5bd --- /dev/null +++ b/blocksuite/presets/src/__tests__/env.d.ts @@ -0,0 +1,16 @@ +import type { Doc, DocCollection, Job } from '@blocksuite/store'; + +import type { AffineEditorContainer } from '../index.js'; + +declare global { + const editor: AffineEditorContainer; + const doc: Doc; + const collection: DocCollection; + const job: Job; + interface Window { + editor: AffineEditorContainer; + doc: Doc; + job: Job; + collection: DocCollection; + } +} diff --git a/blocksuite/presets/src/__tests__/main/__screenshots__/snapshot.spec.ts/snapshot-1-importing-1.png b/blocksuite/presets/src/__tests__/main/__screenshots__/snapshot.spec.ts/snapshot-1-importing-1.png new file mode 100644 index 0000000000..753a8012c5 Binary files /dev/null and b/blocksuite/presets/src/__tests__/main/__screenshots__/snapshot.spec.ts/snapshot-1-importing-1.png differ diff --git a/blocksuite/presets/src/__tests__/main/__screenshots__/snapshot.spec.ts/snapshot-2-importing-1.png b/blocksuite/presets/src/__tests__/main/__screenshots__/snapshot.spec.ts/snapshot-2-importing-1.png new file mode 100644 index 0000000000..027141c575 Binary files /dev/null and b/blocksuite/presets/src/__tests__/main/__screenshots__/snapshot.spec.ts/snapshot-2-importing-1.png differ diff --git a/blocksuite/presets/src/__tests__/main/snapshot.spec.ts b/blocksuite/presets/src/__tests__/main/snapshot.spec.ts new file mode 100644 index 0000000000..c5d802f23b --- /dev/null +++ b/blocksuite/presets/src/__tests__/main/snapshot.spec.ts @@ -0,0 +1,101 @@ +import type { SurfaceBlockModel } from '@blocksuite/blocks'; +import type { PointLocation } from '@blocksuite/global/utils'; +import { beforeEach, expect, test } from 'vitest'; + +import { wait } from '../utils/common.js'; +import { setupEditor } from '../utils/setup.js'; + +const excludes = new Set([ + 'shape-textBound', + 'externalXYWH', + 'connector-text', + 'connector-labelXYWH', +]); + +const fieldChecker: Record<string, (value: any) => boolean> = { + 'connector-path': (value: PointLocation[]) => { + return value.length > 0; + }, + xywh: (value: string) => { + return value.match(xywhPattern) !== null; + }, +}; + +const snapshotTest = async (snapshotUrl: string, elementsCount: number) => { + const pageService = window.editor.host!.std.getService('affine:page'); + if (!pageService) { + throw new Error('page service not found'); + } + const transformer = pageService.transformers.zip; + + const snapshotFile = await fetch(snapshotUrl) + .then(res => res.blob()) + .catch(e => { + console.error(e); + throw e; + }); + const [newDoc] = await transformer.importDocs( + window.editor.doc.collection, + snapshotFile + ); + + if (!newDoc) { + throw new Error('Failed to import snapshot'); + } + + editor.doc = newDoc; + await wait(); + + const surface = newDoc.getBlockByFlavour( + 'affine:surface' + )[0] as SurfaceBlockModel; + const surfaceElements = [...surface['_elementModels']].map( + ([_, { model }]) => model + ); + + expect(surfaceElements.length).toBe(elementsCount); + + surfaceElements.forEach(element => { + const type = element.type; + + for (const field in element) { + const value = element[field as keyof typeof element]; + const typeField = `${type}-${field}`; + + if (excludes.has(`${type}-${field}`) || excludes.has(field)) { + return; + } + + if (fieldChecker[typeField] || fieldChecker[field]) { + const checker = fieldChecker[typeField] || fieldChecker[field]; + expect(checker(value)).toBe(true); + } else { + expect( + value, + `type: ${element.type} field: "${field}"` + ).not.toBeUndefined(); + expect(value, `type: ${element.type} field: "${field}"`).not.toBeNull(); + expect(value, `type: ${element.type} field: "${field}"`).not.toBeNaN(); + } + } + }); +}; + +beforeEach(async () => { + const cleanup = await setupEditor('page'); + + return cleanup; +}); + +const xywhPattern = /\[(\s*-?\d+(\.\d+)?\s*,){3}(\s*-?\d+(\.\d+)?\s*)\]/; + +test('snapshot 1 importing', async () => { + await snapshotTest('https://test.affineassets.com/test-snapshot-1.zip', 25); +}); + +test('snapshot 2 importing', async () => { + await snapshotTest( + 'https://test.affineassets.com/test-snapshot-2%20(onboarding).zip', + 174 + ); +}); diff --git a/blocksuite/presets/src/__tests__/snapshots/edgeless/surface-ref.spec.ts/surface-ref.json b/blocksuite/presets/src/__tests__/snapshots/edgeless/surface-ref.spec.ts/surface-ref.json new file mode 100644 index 0000000000..5f7f429af3 --- /dev/null +++ b/blocksuite/presets/src/__tests__/snapshots/edgeless/surface-ref.spec.ts/surface-ref.json @@ -0,0 +1,222 @@ +{ + "type": "page", + "meta": { + "id": "doc:home", + "title": "", + "createDate": 1725961648308, + "tags": [] + }, + "blocks": { + "type": "block", + "id": "xguBwzpkGD", + "flavour": "affine:page", + "version": 2, + "props": { + "title": { + "$blocksuite:internal:text$": true, + "delta": [] + } + }, + "children": [ + { + "type": "block", + "id": "m9-hJeL979", + "flavour": "affine:surface", + "version": 5, + "props": { + "elements": { + "UrT0kJjtKN": { + "index": "a2", + "seed": 1855830969, + "color": "--affine-palette-line-black", + "fillColor": "--affine-palette-shape-yellow", + "filled": true, + "fontFamily": "blocksuite:surface:Inter", + "fontSize": 20, + "fontStyle": "normal", + "fontWeight": "400", + "maxWidth": false, + "padding": [10, 20], + "radius": 0, + "rotate": 0, + "roughness": 1.4, + "shadow": null, + "shapeStyle": "General", + "shapeType": "triangle", + "strokeColor": "--affine-palette-line-yellow", + "strokeStyle": "solid", + "strokeWidth": 2, + "textAlign": "center", + "textResizing": 1, + "xywh": "[220.12515258789062,240.90625,99.82000732421875,80.5]", + "type": "shape", + "id": "UrT0kJjtKN" + }, + "0AlG3AohCi": { + "index": "a3", + "seed": 1305411005, + "color": "--affine-palette-line-black", + "fillColor": "--affine-palette-shape-yellow", + "filled": true, + "fontFamily": "blocksuite:surface:Inter", + "fontSize": 20, + "fontStyle": "normal", + "fontWeight": "400", + "maxWidth": false, + "padding": [10, 20], + "radius": 0, + "rotate": 0, + "roughness": 1.4, + "shadow": null, + "shapeStyle": "General", + "shapeType": "ellipse", + "strokeColor": "--affine-palette-line-yellow", + "strokeStyle": "solid", + "strokeWidth": 2, + "textAlign": "center", + "textResizing": 1, + "xywh": "[485.93280029296875,231.14300537109375,99.84002685546875,99.84002685546875]", + "type": "shape", + "id": "0AlG3AohCi" + }, + "8fUiHCCqfG": { + "index": "a4", + "seed": 513823325, + "color": "--affine-palette-line-black", + "fillColor": "--affine-palette-shape-yellow", + "filled": true, + "fontFamily": "blocksuite:surface:Inter", + "fontSize": 20, + "fontStyle": "normal", + "fontWeight": "400", + "maxWidth": false, + "padding": [10, 20], + "radius": 0.1, + "rotate": 0, + "roughness": 1.4, + "shadow": null, + "shapeStyle": "General", + "shapeType": "rect", + "strokeColor": "--affine-palette-line-yellow", + "strokeStyle": "solid", + "strokeWidth": 2, + "textAlign": "center", + "textResizing": 1, + "xywh": "[620.0800170898438,234.9613037109375,99.8399658203125,99.8399658203125]", + "type": "shape", + "id": "8fUiHCCqfG" + }, + "KfzvfxYw5C": { + "index": "a5", + "seed": 1817567358, + "children": { + "affine:surface:ymap": true, + "json": { + "8fUiHCCqfG": true, + "0AlG3AohCi": true + } + }, + "title": { + "affine:surface:text": true, + "delta": [ + { + "insert": "Group 1" + } + ] + }, + "type": "group", + "id": "KfzvfxYw5C" + } + } + }, + "children": [ + { + "type": "block", + "id": "yBzeQ6raOv", + "flavour": "affine:frame", + "version": 1, + "props": { + "title": { + "$blocksuite:internal:text$": true, + "delta": [ + { + "insert": "Frame 1" + } + ] + }, + "background": "--affine-palette-transparent", + "xywh": "[140.703125,199.1015625,258.6640625,164.109375]", + "index": "a1", + "childElementIds": { + "UrT0kJjtKN": true + } + }, + "children": [] + } + ] + }, + { + "type": "block", + "id": "eWPFqL6VST", + "flavour": "affine:note", + "version": 1, + "props": { + "xywh": "[0,0,498,92]", + "background": "--affine-note-background-white", + "index": "a0", + "hidden": false, + "displayMode": "both", + "edgeless": { + "style": { + "borderRadius": 8, + "borderSize": 4, + "borderStyle": "none", + "shadowType": "--affine-note-shadow-box" + } + } + }, + "children": [ + { + "type": "block", + "id": "09MoQfa5ex", + "flavour": "affine:paragraph", + "version": 1, + "props": { + "type": "text", + "text": { + "$blocksuite:internal:text$": true, + "delta": [] + }, + "collapsed": false + }, + "children": [] + }, + { + "type": "block", + "id": "NhoAqBBMZg", + "flavour": "affine:surface-ref", + "version": 1, + "props": { + "reference": "yBzeQ6raOv", + "caption": "", + "refFlavour": "affine:frame" + }, + "children": [] + }, + { + "type": "block", + "id": "jJDE1rYIYJ", + "flavour": "affine:surface-ref", + "version": 1, + "props": { + "reference": "KfzvfxYw5C", + "caption": "", + "refFlavour": "group" + }, + "children": [] + } + ] + } + ] + } +} diff --git a/blocksuite/presets/src/__tests__/utils/common.ts b/blocksuite/presets/src/__tests__/utils/common.ts new file mode 100644 index 0000000000..748fea9740 --- /dev/null +++ b/blocksuite/presets/src/__tests__/utils/common.ts @@ -0,0 +1,202 @@ +import type { Point } from '@blocksuite/global/utils'; + +export function wait(time: number = 0) { + return new Promise(resolve => { + requestAnimationFrame(() => { + setTimeout(resolve, time); + }); + }); +} + +/** + * simulate click event + * @param target + * @param position position relative to the target + */ +export function click(target: HTMLElement, position: { x: number; y: number }) { + const element = target.getBoundingClientRect(); + const clientX = element.x + position.x; + const clientY = element.y + position.y; + + target.dispatchEvent( + new PointerEvent('pointerdown', { + clientX, + clientY, + bubbles: true, + pointerId: 1, + isPrimary: true, + }) + ); + target.dispatchEvent( + new PointerEvent('pointerup', { + clientX, + clientY, + bubbles: true, + pointerId: 1, + isPrimary: true, + }) + ); + target.dispatchEvent( + new MouseEvent('click', { + clientX, + clientY, + bubbles: true, + }) + ); +} + +type PointerOptions = { + isPrimary?: boolean; + pointerId?: number; + pointerType?: string; +}; + +const defaultPointerOptions: PointerOptions = { + isPrimary: true, + pointerId: 1, + pointerType: 'mouse', +}; + +/** + * simulate pointerdown event + * @param target + * @param position position relative to the target + */ +export function pointerdown( + target: HTMLElement, + position: { x: number; y: number }, + options: PointerOptions = defaultPointerOptions +) { + const element = target.getBoundingClientRect(); + const clientX = element.x + position.x; + const clientY = element.y + position.y; + + target.dispatchEvent( + new PointerEvent('pointerdown', { + clientX, + clientY, + bubbles: true, + ...options, + }) + ); +} + +/** + * simulate pointerup event + * @param target + * @param position position relative to the target + */ +export function pointerup( + target: HTMLElement, + position: { x: number; y: number }, + options: PointerOptions = defaultPointerOptions +) { + const element = target.getBoundingClientRect(); + const clientX = element.x + position.x; + const clientY = element.y + position.y; + + target.dispatchEvent( + new PointerEvent('pointerup', { + clientX, + clientY, + bubbles: true, + ...options, + }) + ); +} + +/** + * simulate pointermove event + * @param target + * @param position position relative to the target + */ +export function pointermove( + target: HTMLElement, + position: { x: number; y: number }, + options: PointerOptions = defaultPointerOptions +) { + const element = target.getBoundingClientRect(); + const clientX = element.x + position.x; + const clientY = element.y + position.y; + + target.dispatchEvent( + new PointerEvent('pointermove', { + clientX, + clientY, + bubbles: true, + ...options, + }) + ); +} + +export function drag( + target: HTMLElement, + start: { x: number; y: number }, + end: { x: number; y: number }, + step: number = 5 +) { + pointerdown(target, start); + pointermove(target, start); + + if (step !== 0) { + const xStep = (end.x - start.x) / step; + const yStep = (end.y - start.y) / step; + + for (const [i] of Array.from({ length: step }).entries()) { + pointermove(target, { + x: start.x + xStep * (i + 1), + y: start.y + yStep * (i + 1), + }); + } + } + + pointermove(target, end); + pointerup(target, end); +} + +export function multiTouchDown(target: Element, points: Point[]) { + points.forEach((point, index) => { + pointerdown(target as HTMLElement, point, { + isPrimary: index === 0, + pointerId: index, + pointerType: 'touch', + }); + }); +} + +export function multiTouchMove( + target: Element, + from: Point[], + to: Point[], + step = 5 +) { + if (from.length !== to.length) { + throw new Error('from and to should have the same length'); + } + + if (step !== 0) { + for (const [i] of Array.from({ length: step }).entries()) { + const stepPoints = from.map((point, index) => ({ + x: point.x + ((to[index].x - point.x) / step) * (i + 1), + y: point.y + ((to[index].y - point.y) / step) * (i + 1), + })); + from.forEach((_, index) => { + pointermove(target as HTMLElement, stepPoints[index], { + isPrimary: index === 0, + pointerId: index, + pointerType: 'touch', + }); + }); + } + } +} + +export function multiTouchUp(target: Element, points: Point[]) { + points.forEach((point, index) => { + pointerup(target as HTMLElement, point, { + isPrimary: index === 0, + pointerId: index, + pointerType: 'touch', + }); + }); +} diff --git a/blocksuite/presets/src/__tests__/utils/edgeless.ts b/blocksuite/presets/src/__tests__/utils/edgeless.ts new file mode 100644 index 0000000000..ee6c528b8e --- /dev/null +++ b/blocksuite/presets/src/__tests__/utils/edgeless.ts @@ -0,0 +1,52 @@ +/* eslint-disable @typescript-eslint/no-non-null-assertion */ +import type { + EdgelessRootBlockComponent, + PageRootBlockComponent, + SurfaceBlockComponent, +} from '@blocksuite/blocks'; +import type { Doc } from '@blocksuite/store'; + +import type { AffineEditorContainer } from '../../index.js'; + +export function getSurface(doc: Doc, editor: AffineEditorContainer) { + const surfaceModel = doc.getBlockByFlavour('affine:surface'); + + return editor.host!.view.getBlock( + surfaceModel[0]!.id + ) as SurfaceBlockComponent; +} + +export function getDocRootBlock( + doc: Doc, + editor: AffineEditorContainer, + mode: 'page' +): PageRootBlockComponent; +export function getDocRootBlock( + doc: Doc, + editor: AffineEditorContainer, + mode: 'edgeless' +): EdgelessRootBlockComponent; +export function getDocRootBlock( + doc: Doc, + editor: AffineEditorContainer, + _?: 'edgeless' | 'page' +) { + return editor.host!.view.getBlock(doc.root!.id) as + | EdgelessRootBlockComponent + | PageRootBlockComponent; +} + +export function addNote(doc: Doc, props: Record<string, any> = {}) { + const noteId = doc.addBlock( + 'affine:note', + { + xywh: '[0, 0, 800, 100]', + ...props, + }, + doc.root + ); + + doc.addBlock('affine:paragraph', {}, noteId); + + return noteId; +} diff --git a/blocksuite/presets/src/__tests__/utils/misc.ts b/blocksuite/presets/src/__tests__/utils/misc.ts new file mode 100644 index 0000000000..1afba19ec4 --- /dev/null +++ b/blocksuite/presets/src/__tests__/utils/misc.ts @@ -0,0 +1,14 @@ +import { replaceIdMiddleware } from '@blocksuite/blocks'; +import { type DocCollection, type DocSnapshot, Job } from '@blocksuite/store'; + +export async function importFromSnapshot( + collection: DocCollection, + snapshot: DocSnapshot +) { + const job = new Job({ + collection, + middlewares: [replaceIdMiddleware], + }); + + return job.snapshotToDoc(snapshot); +} diff --git a/blocksuite/presets/src/__tests__/utils/setup.ts b/blocksuite/presets/src/__tests__/utils/setup.ts new file mode 100644 index 0000000000..0c8e25fc8a --- /dev/null +++ b/blocksuite/presets/src/__tests__/utils/setup.ts @@ -0,0 +1,111 @@ +import { effects as blocksEffects } from '@blocksuite/blocks/effects'; +import type { BlockCollection } from '@blocksuite/store'; + +import { effects } from '../../effects.js'; + +blocksEffects(); +effects(); + +import { + CommunityCanvasTextFonts, + type DocMode, + FontConfigExtension, +} from '@blocksuite/blocks'; +import { AffineSchemas } from '@blocksuite/blocks/schemas'; +import { assertExists } from '@blocksuite/global/utils'; +import { + DocCollection, + IdGeneratorType, + Schema, + Text, +} from '@blocksuite/store'; + +import { AffineEditorContainer } from '../../index.js'; + +function createCollectionOptions() { + const schema = new Schema(); + const room = Math.random().toString(16).slice(2, 8); + + schema.register(AffineSchemas); + + const idGenerator: IdGeneratorType = IdGeneratorType.AutoIncrement; // works only in single user mode + + return { + id: room, + schema, + idGenerator, + defaultFlags: { + enable_synced_doc_block: true, + enable_pie_menu: true, + readonly: { + 'doc:home': false, + }, + }, + }; +} + +function initCollection(collection: DocCollection) { + const doc = collection.createDoc({ id: 'doc:home' }); + + doc.load(() => { + const rootId = doc.addBlock('affine:page', { + title: new Text(), + }); + doc.addBlock('affine:surface', {}, rootId); + }); + doc.resetHistory(); +} + +async function createEditor(collection: DocCollection, mode: DocMode = 'page') { + const app = document.createElement('div'); + const blockCollection = collection.docs.values().next().value as + | BlockCollection + | undefined; + assertExists(blockCollection, 'Need to create a doc first'); + const doc = blockCollection.getDoc(); + const editor = new AffineEditorContainer(); + editor.doc = doc; + editor.mode = mode; + editor.pageSpecs = editor.pageSpecs.concat([ + FontConfigExtension(CommunityCanvasTextFonts), + ]); + editor.edgelessSpecs = editor.edgelessSpecs.concat([ + FontConfigExtension(CommunityCanvasTextFonts), + ]); + app.append(editor); + + window.editor = editor; + window.doc = doc; + + app.style.width = '100%'; + app.style.height = '1280px'; + + document.body.append(app); + await editor.updateComplete; + return app; +} + +export async function setupEditor(mode: DocMode = 'page') { + const collection = new DocCollection(createCollectionOptions()); + collection.meta.initialize(); + + window.collection = collection; + + initCollection(collection); + const appElement = await createEditor(collection, mode); + + return () => { + appElement.remove(); + cleanup(); + }; +} + +export function cleanup() { + window.editor.remove(); + + delete (window as any).collection; + + delete (window as any).editor; + + delete (window as any).doc; +} diff --git a/blocksuite/presets/src/editors/edgeless-editor.ts b/blocksuite/presets/src/editors/edgeless-editor.ts new file mode 100644 index 0000000000..1cda0a0500 --- /dev/null +++ b/blocksuite/presets/src/editors/edgeless-editor.ts @@ -0,0 +1,104 @@ +import { BlockStdScope, ShadowlessElement } from '@blocksuite/block-std'; +import { EdgelessEditorBlockSpecs, ThemeProvider } from '@blocksuite/blocks'; +import { SignalWatcher, WithDisposable } from '@blocksuite/global/utils'; +import type { Doc } from '@blocksuite/store'; +import { css, html, nothing, type TemplateResult } from 'lit'; +import { property, state } from 'lit/decorators.js'; +import { guard } from 'lit/directives/guard.js'; + +export class EdgelessEditor extends SignalWatcher( + WithDisposable(ShadowlessElement) +) { + static override styles = css` + edgeless-editor { + font-family: var(--affine-font-family); + background: var(--affine-background-primary-color); + } + + edgeless-editor * { + box-sizing: border-box; + } + + @media print { + edgeless-editor { + height: auto; + } + } + + .affine-edgeless-viewport { + display: block; + height: 100%; + position: relative; + overflow: clip; + container-name: viewport; + container-type: inline-size; + } + `; + + get host() { + try { + return this.std.host; + } catch { + return null; + } + } + + override connectedCallback() { + super.connectedCallback(); + this._disposables.add( + this.doc.slots.rootAdded.on(() => this.requestUpdate()) + ); + this.std = new BlockStdScope({ + doc: this.doc, + extensions: this.specs, + }); + } + + override async getUpdateComplete(): Promise<boolean> { + const result = await super.getUpdateComplete(); + await this.host?.updateComplete; + return result; + } + + override render() { + if (!this.doc.root) return nothing; + + const std = this.std; + const theme = std.get(ThemeProvider).edgeless$.value; + return html` + <div class="affine-edgeless-viewport" data-theme=${theme}> + ${guard([std], () => std.render())} + </div> + `; + } + + override willUpdate( + changedProperties: Map<string | number | symbol, unknown> + ) { + super.willUpdate(changedProperties); + if (changedProperties.has('doc')) { + this.std = new BlockStdScope({ + doc: this.doc, + extensions: this.specs, + }); + } + } + + @property({ attribute: false }) + accessor doc!: Doc; + + @property({ attribute: false }) + accessor editor!: TemplateResult; + + @property({ attribute: false }) + accessor specs = EdgelessEditorBlockSpecs; + + @state() + accessor std!: BlockStdScope; +} + +declare global { + interface HTMLElementTagNameMap { + 'edgeless-editor': EdgelessEditor; + } +} diff --git a/blocksuite/presets/src/editors/editor-container.ts b/blocksuite/presets/src/editors/editor-container.ts new file mode 100644 index 0000000000..68cc89a415 --- /dev/null +++ b/blocksuite/presets/src/editors/editor-container.ts @@ -0,0 +1,250 @@ +import { + BlockStdScope, + type ExtensionType, + ShadowlessElement, +} from '@blocksuite/block-std'; +import { + type AbstractEditor, + type DocMode, + EdgelessEditorBlockSpecs, + PageEditorBlockSpecs, + ThemeProvider, +} from '@blocksuite/blocks'; +import { SignalWatcher, Slot, WithDisposable } from '@blocksuite/global/utils'; +import type { BlockModel, Doc } from '@blocksuite/store'; +import { computed, signal } from '@preact/signals-core'; +import { css, html } from 'lit'; +import { property } from 'lit/decorators.js'; +import { keyed } from 'lit/directives/keyed.js'; +import { when } from 'lit/directives/when.js'; + +export class AffineEditorContainer + extends SignalWatcher(WithDisposable(ShadowlessElement)) + implements AbstractEditor +{ + static override styles = css` + .affine-page-viewport { + position: relative; + display: flex; + flex-direction: column; + overflow-x: hidden; + overflow-y: auto; + container-name: viewport; + container-type: inline-size; + font-family: var(--affine-font-family); + } + .affine-page-viewport * { + box-sizing: border-box; + } + + @media print { + .affine-page-viewport { + height: auto; + } + } + + .playground-page-editor-container { + flex-grow: 1; + font-family: var(--affine-font-family); + display: block; + } + + .playground-page-editor-container * { + box-sizing: border-box; + } + + @media print { + .playground-page-editor-container { + height: auto; + } + } + + .edgeless-editor-container { + font-family: var(--affine-font-family); + background: var(--affine-background-primary-color); + display: block; + height: 100%; + position: relative; + overflow: clip; + } + + .edgeless-editor-container * { + box-sizing: border-box; + } + + @media print { + .edgeless-editor-container { + height: auto; + } + } + + .affine-edgeless-viewport { + display: block; + height: 100%; + position: relative; + overflow: clip; + container-name: viewport; + container-type: inline-size; + } + `; + + private _doc = signal<Doc>(); + + private _edgelessSpecs = signal<ExtensionType[]>(EdgelessEditorBlockSpecs); + + private _mode = signal<DocMode>('page'); + + private _pageSpecs = signal<ExtensionType[]>(PageEditorBlockSpecs); + + private _specs = computed(() => + this._mode.value === 'page' + ? this._pageSpecs.value + : this._edgelessSpecs.value + ); + + private _std = computed(() => { + return new BlockStdScope({ + doc: this.doc, + extensions: this._specs.value, + }); + }); + + private _editorTemplate = computed(() => { + return this._std.value.render(); + }); + + /** + * @deprecated need to refactor + */ + slots: AbstractEditor['slots'] = { + docUpdated: new Slot(), + }; + + get doc() { + return this._doc.value as Doc; + } + + set doc(doc: Doc) { + this._doc.value = doc; + } + + set edgelessSpecs(specs: ExtensionType[]) { + this._edgelessSpecs.value = specs; + } + + get edgelessSpecs() { + return this._edgelessSpecs.value; + } + + get host() { + try { + return this.std.host; + } catch { + return null; + } + } + + get mode() { + return this._mode.value; + } + + set mode(mode: DocMode) { + this._mode.value = mode; + } + + set pageSpecs(specs: ExtensionType[]) { + this._pageSpecs.value = specs; + } + + get pageSpecs() { + return this._pageSpecs.value; + } + + get rootModel() { + return this.doc.root as BlockModel; + } + + get std() { + return this._std.value; + } + + /** + * @deprecated need to refactor + */ + override connectedCallback() { + super.connectedCallback(); + + this._disposables.add( + this.doc.slots.rootAdded.on(() => this.requestUpdate()) + ); + } + + override firstUpdated() { + if (this.mode === 'page') { + setTimeout(() => { + if (this.autofocus) { + const richText = this.querySelector('rich-text'); + const inlineEditor = richText?.inlineEditor; + inlineEditor?.focusEnd(); + } + }); + } + } + + override render() { + const mode = this._mode.value; + const themeService = this.std.get(ThemeProvider); + const appTheme = themeService.app$.value; + const edgelessTheme = themeService.edgeless$.value; + + return html`${keyed( + this.rootModel.id + mode, + html` + <div + data-theme=${mode === 'page' ? appTheme : edgelessTheme} + class=${mode === 'page' + ? 'affine-page-viewport' + : 'affine-edgeless-viewport'} + > + ${when( + mode === 'page', + () => html` <doc-title .doc=${this.doc}></doc-title> ` + )} + <div + class=${mode === 'page' + ? 'page-editor playground-page-editor-container' + : 'edgeless-editor-container'} + > + ${this._editorTemplate.value} + </div> + </div> + ` + )}`; + } + + switchEditor(mode: DocMode) { + this._mode.value = mode; + } + + /** + * @deprecated need to refactor + */ + override updated(changedProperties: Map<string, unknown>) { + if (changedProperties.has('doc')) { + this.slots.docUpdated.emit({ newDocId: this.doc.id }); + } + + if (!changedProperties.has('doc') && !changedProperties.has('mode')) { + return; + } + } + + @property({ attribute: false }) + override accessor autofocus = false; +} + +declare global { + interface HTMLElementTagNameMap { + 'affine-editor-container': AffineEditorContainer; + } +} diff --git a/blocksuite/presets/src/editors/index.ts b/blocksuite/presets/src/editors/index.ts new file mode 100644 index 0000000000..0ca8466928 --- /dev/null +++ b/blocksuite/presets/src/editors/index.ts @@ -0,0 +1,3 @@ +export * from './edgeless-editor.js'; +export * from './editor-container.js'; +export * from './page-editor.js'; diff --git a/blocksuite/presets/src/editors/page-editor.ts b/blocksuite/presets/src/editors/page-editor.ts new file mode 100644 index 0000000000..ab4bf3c476 --- /dev/null +++ b/blocksuite/presets/src/editors/page-editor.ts @@ -0,0 +1,120 @@ +import { + BlockStdScope, + EditorHost, + ShadowlessElement, +} from '@blocksuite/block-std'; +import { PageEditorBlockSpecs, ThemeProvider } from '@blocksuite/blocks'; +import { noop, SignalWatcher, WithDisposable } from '@blocksuite/global/utils'; +import type { Doc } from '@blocksuite/store'; +import { css, html, nothing } from 'lit'; +import { property, state } from 'lit/decorators.js'; +import { guard } from 'lit/directives/guard.js'; + +noop(EditorHost); + +export class PageEditor extends SignalWatcher( + WithDisposable(ShadowlessElement) +) { + static override styles = css` + page-editor { + font-family: var(--affine-font-family); + background: var(--affine-background-primary-color); + } + + page-editor * { + box-sizing: border-box; + } + + @media print { + page-editor { + height: auto; + } + } + + .affine-page-viewport { + position: relative; + height: 100%; + overflow-x: hidden; + overflow-y: auto; + container-name: viewport; + container-type: inline-size; + } + + .page-editor-container { + display: block; + height: 100%; + } + `; + + get host() { + try { + return this.std.host; + } catch { + return null; + } + } + + override connectedCallback() { + super.connectedCallback(); + this._disposables.add( + this.doc.slots.rootAdded.on(() => this.requestUpdate()) + ); + this.std = new BlockStdScope({ + doc: this.doc, + extensions: this.specs, + }); + } + + override async getUpdateComplete(): Promise<boolean> { + const result = await super.getUpdateComplete(); + await this.host?.updateComplete; + return result; + } + + override render() { + if (!this.doc.root) return nothing; + + const std = this.std; + const theme = std.get(ThemeProvider).app$.value; + return html` + <div + data-theme=${theme} + class=${this.hasViewport + ? 'affine-page-viewport' + : 'page-editor-container'} + > + ${guard([std], () => std.render())} + </div> + `; + } + + override willUpdate( + changedProperties: Map<string | number | symbol, unknown> + ) { + super.willUpdate(changedProperties); + if (changedProperties.has('doc')) { + this.std = new BlockStdScope({ + doc: this.doc, + extensions: this.specs, + }); + } + } + + @property({ attribute: false }) + accessor doc!: Doc; + + @property({ type: Boolean }) + accessor hasViewport = true; + + @property({ attribute: false }) + accessor specs = PageEditorBlockSpecs; + + @state() + accessor std!: BlockStdScope; +} + +declare global { + interface HTMLElementTagNameMap { + 'page-editor': PageEditor; + } +} diff --git a/blocksuite/presets/src/effects.ts b/blocksuite/presets/src/effects.ts new file mode 100644 index 0000000000..758a5ffe3d --- /dev/null +++ b/blocksuite/presets/src/effects.ts @@ -0,0 +1,96 @@ +import '@blocksuite/affine-shared/commands'; +import '@blocksuite/blocks/effects'; + +import { + AffineEditorContainer, + EdgelessEditor, + PageEditor, +} from './editors/index.js'; +import { CommentInput } from './fragments/comment/comment-input.js'; +import { BacklinkButton } from './fragments/doc-meta-tags/backlink-popover.js'; +import { + AFFINE_FRAME_PANEL_BODY, + FramePanelBody, +} from './fragments/frame-panel/body/frame-panel-body.js'; +import { + AFFINE_FRAME_CARD, + FrameCard, +} from './fragments/frame-panel/card/frame-card.js'; +import { + AFFINE_FRAME_CARD_TITLE, + FrameCardTitle, +} from './fragments/frame-panel/card/frame-card-title.js'; +import { + AFFINE_FRAME_TITLE_EDITOR, + FrameCardTitleEditor, +} from './fragments/frame-panel/card/frame-card-title-editor.js'; +import { + AFFINE_FRAME_PANEL_HEADER, + FramePanelHeader, +} from './fragments/frame-panel/header/frame-panel-header.js'; +import { + AFFINE_FRAMES_SETTING_MENU, + FramesSettingMenu, +} from './fragments/frame-panel/header/frames-setting-menu.js'; +import { + AFFINE_FRAME_PANEL, + AFFINE_OUTLINE_PANEL, + AFFINE_OUTLINE_VIEWER, + CommentPanel, + DocTitle, + FramePanel, + OutlinePanel, + OutlineViewer, +} from './fragments/index.js'; +import { + AFFINE_OUTLINE_NOTICE, + OutlineNotice, +} from './fragments/outline/body/outline-notice.js'; +import { + AFFINE_OUTLINE_PANEL_BODY, + OutlinePanelBody, +} from './fragments/outline/body/outline-panel-body.js'; +import { + AFFINE_OUTLINE_NOTE_CARD, + OutlineNoteCard, +} from './fragments/outline/card/outline-card.js'; +import { + AFFINE_OUTLINE_BLOCK_PREVIEW, + OutlineBlockPreview, +} from './fragments/outline/card/outline-preview.js'; +import { + AFFINE_OUTLINE_PANEL_HEADER, + OutlinePanelHeader, +} from './fragments/outline/header/outline-panel-header.js'; +import { + AFFINE_OUTLINE_NOTE_PREVIEW_SETTING_MENU, + OutlineNotePreviewSettingMenu, +} from './fragments/outline/header/outline-setting-menu.js'; + +export function effects() { + customElements.define('page-editor', PageEditor); + customElements.define('comment-input', CommentInput); + customElements.define('doc-title', DocTitle); + customElements.define( + AFFINE_OUTLINE_NOTE_PREVIEW_SETTING_MENU, + OutlineNotePreviewSettingMenu + ); + customElements.define(AFFINE_FRAME_PANEL, FramePanel); + customElements.define(AFFINE_OUTLINE_NOTICE, OutlineNotice); + customElements.define('comment-panel', CommentPanel); + customElements.define(AFFINE_OUTLINE_PANEL, OutlinePanel); + customElements.define('backlink-button', BacklinkButton); + customElements.define(AFFINE_OUTLINE_PANEL_HEADER, OutlinePanelHeader); + customElements.define('affine-editor-container', AffineEditorContainer); + customElements.define(AFFINE_OUTLINE_NOTE_CARD, OutlineNoteCard); + customElements.define(AFFINE_FRAME_TITLE_EDITOR, FrameCardTitleEditor); + customElements.define('edgeless-editor', EdgelessEditor); + customElements.define(AFFINE_FRAME_CARD, FrameCard); + customElements.define(AFFINE_OUTLINE_VIEWER, OutlineViewer); + customElements.define(AFFINE_FRAME_CARD_TITLE, FrameCardTitle); + customElements.define(AFFINE_OUTLINE_BLOCK_PREVIEW, OutlineBlockPreview); + customElements.define(AFFINE_FRAME_PANEL_BODY, FramePanelBody); + customElements.define(AFFINE_FRAME_PANEL_HEADER, FramePanelHeader); + customElements.define(AFFINE_OUTLINE_PANEL_BODY, OutlinePanelBody); + customElements.define(AFFINE_FRAMES_SETTING_MENU, FramesSettingMenu); +} diff --git a/blocksuite/presets/src/fragments/_common/icons.ts b/blocksuite/presets/src/fragments/_common/icons.ts new file mode 100644 index 0000000000..224f8eb4d6 --- /dev/null +++ b/blocksuite/presets/src/fragments/_common/icons.ts @@ -0,0 +1,427 @@ +import { html } from 'lit'; + +export const SmallFrameNavigatorIcon = html`<svg + width="16" + height="16" + viewBox="0 0 16 16" + fill="none" + xmlns="http://www.w3.org/2000/svg" +> + <path + fill-rule="evenodd" + clip-rule="evenodd" + d="M2.11621 2.63482C2.11621 2.35056 2.34665 2.12012 2.63092 2.12012H3.39072H12.5084H13.2682C13.5524 2.12012 13.7829 2.35056 13.7829 2.63482C13.7829 2.91909 13.5524 3.14953 13.2682 3.14953H13.0231V9.71839H13.2682C13.5524 9.71839 13.7829 9.94884 13.7829 10.2331C13.7829 10.5174 13.5524 10.7478 13.2682 10.7478H8.46425V11.8554L11.0922 13.3571C11.339 13.4981 11.4247 13.8125 11.2837 14.0594C11.1427 14.3062 10.8282 14.3919 10.5814 14.2509L8.46425 13.0411V14.0321C8.46425 14.3164 8.23381 14.5468 7.94954 14.5468C7.66528 14.5468 7.43484 14.3164 7.43484 14.0321V13.0845L5.39364 14.2509C5.14683 14.3919 4.83241 14.3062 4.69138 14.0594C4.55035 13.8125 4.63609 13.4981 4.8829 13.3571L7.43484 11.8989V10.7478H2.63092C2.34665 10.7478 2.11621 10.5174 2.11621 10.2331C2.11621 9.94884 2.34665 9.71839 2.63092 9.71839H2.87601V3.14953H2.63092C2.34665 3.14953 2.11621 2.91909 2.11621 2.63482ZM3.90543 3.14953H11.9937V9.71815H3.90543V3.14953ZM6.18484 6.05396C6.18484 5.7697 5.9544 5.53926 5.67013 5.53926C5.38587 5.53926 5.15543 5.7697 5.15543 6.05396V7.57357C5.15543 7.85783 5.38587 8.08828 5.67013 8.08828C5.9544 8.08828 6.18484 7.85783 6.18484 7.57357V6.05396ZM7.94954 4.3996C8.23381 4.3996 8.46425 4.63004 8.46425 4.91431V7.57362C8.46425 7.85788 8.23381 8.08832 7.94954 8.08832C7.66528 8.08832 7.43484 7.85788 7.43484 7.57362V4.91431C7.43484 4.63004 7.66528 4.3996 7.94954 4.3996ZM10.7437 6.81396C10.7437 6.5297 10.5132 6.29925 10.229 6.29925C9.94469 6.29925 9.71425 6.5297 9.71425 6.81396V7.57376C9.71425 7.85803 9.94469 8.08847 10.229 8.08847C10.5132 8.08847 10.7437 7.85803 10.7437 7.57376V6.81396Z" + /> +</svg>`; + +export const SettingsIcon = html` + <svg + width="20" + height="20" + viewBox="0 0 20 20" + xmlns="http://www.w3.org/2000/svg" + > + <path + fill-rule="evenodd" + clip-rule="evenodd" + d="M7.9965 3.45031C8.50641 1.3499 11.4936 1.3499 12.0035 3.45031C12.1332 3.9845 12.7452 4.23801 13.2146 3.95198C15.0604 2.82733 17.1727 4.93958 16.048 6.78536C15.762 7.25479 16.0155 7.86681 16.5497 7.9965C18.6501 8.50641 18.6501 11.4936 16.5497 12.0035C16.0155 12.1332 15.762 12.7452 16.048 13.2146C17.1727 15.0604 15.0604 17.1727 13.2146 16.048C12.7452 15.762 12.1332 16.0155 12.0035 16.5497C11.4936 18.6501 8.50641 18.6501 7.9965 16.5497C7.86681 16.0155 7.25479 15.762 6.78536 16.048C4.93958 17.1727 2.82733 15.0604 3.95198 13.2146C4.23801 12.7452 3.9845 12.1332 3.45031 12.0035C1.3499 11.4936 1.3499 8.50641 3.45031 7.9965C3.9845 7.86681 4.23801 7.25479 3.95198 6.78536C2.82733 4.93958 4.93958 2.82733 6.78536 3.95198C7.25479 4.23801 7.86681 3.9845 7.9965 3.45031ZM10.7888 3.74521C10.588 2.91826 9.41197 2.91827 9.21121 3.7452C8.88182 5.10205 7.3273 5.74595 6.13495 5.01944C5.40826 4.57666 4.57666 5.40826 5.01944 6.13495C5.74595 7.3273 5.10205 8.88182 3.7452 9.21121C2.91827 9.41197 2.91826 10.588 3.74521 10.7888C5.10205 11.1182 5.74595 12.6727 5.01944 13.8651C4.57666 14.5917 5.40826 15.4233 6.13495 14.9806C7.3273 14.2541 8.88182 14.898 9.21121 16.2548C9.41197 17.0817 10.588 17.0817 10.7888 16.2548C11.1182 14.898 12.6727 14.2541 13.8651 14.9806C14.5917 15.4233 15.4233 14.5917 14.9806 13.8651C14.2541 12.6727 14.898 11.1182 16.2548 10.7888C17.0817 10.588 17.0817 9.41197 16.2548 9.21121C14.898 8.88182 14.2541 7.3273 14.9806 6.13495C15.4233 5.40826 14.5917 4.57666 13.8651 5.01944C12.6727 5.74595 11.1182 5.10205 10.7888 3.74521ZM10 8.125C8.96447 8.125 8.125 8.96447 8.125 10C8.125 11.0355 8.96447 11.875 10 11.875C11.0355 11.875 11.875 11.0355 11.875 10C11.875 8.96447 11.0355 8.125 10 8.125ZM6.875 10C6.875 8.27411 8.27411 6.875 10 6.875C11.7259 6.875 13.125 8.27411 13.125 10C13.125 11.7259 11.7259 13.125 10 13.125C8.27411 13.125 6.875 11.7259 6.875 10Z" + fill="currentColor" + /> + </svg> +`; + +export const BlockPreviewIcon = html`<svg + width="16" + height="16" + viewBox="0 0 16 16" + xmlns="http://www.w3.org/2000/svg" +> + <path + fill-rule="evenodd" + clip-rule="evenodd" + fill="currentColor" + d="M7.77623 1.55279C7.91699 1.4824 8.08268 1.4824 8.22344 1.55279L13.5568 4.21945C13.7262 4.30415 13.8332 4.47728 13.8332 4.66667V11.3333C13.8332 11.5227 13.7262 11.6959 13.5568 11.7805L8.22344 14.4472C8.08268 14.5176 7.91699 14.5176 7.77623 14.4472L2.4429 11.7805C2.27351 11.6959 2.1665 11.5227 2.1665 11.3333V4.66667C2.1665 4.47728 2.27351 4.30415 2.4429 4.21945L7.77623 1.55279ZM3.1665 5.47568L7.49984 7.64235V13.191L3.1665 11.0243V5.47568ZM8.49984 13.191L12.8332 11.0243V5.47568L8.49984 7.64235V13.191ZM7.99984 6.77432L12.2151 4.66667L7.99984 2.55902L3.78454 4.66667L7.99984 6.77432Z" + /> +</svg>`; + +export const SmallTextIcon = html`<svg + width="16" + height="16" + viewBox="0 0 16 16" + xmlns="http://www.w3.org/2000/svg" +> + <path + fill-rule="evenodd" + clip-rule="evenodd" + fill="currentColor" + d="M2.1665 2.66666C2.1665 2.39051 2.39036 2.16666 2.6665 2.16666H13.3332C13.6093 2.16666 13.8332 2.39051 13.8332 2.66666V4.44443C13.8332 4.72058 13.6093 4.94443 13.3332 4.94443C13.057 4.94443 12.8332 4.72058 12.8332 4.44443V3.16666H8.49984V12.8333H10.6665C10.9426 12.8333 11.1665 13.0572 11.1665 13.3333C11.1665 13.6095 10.9426 13.8333 10.6665 13.8333H5.33317C5.05703 13.8333 4.83317 13.6095 4.83317 13.3333C4.83317 13.0572 5.05703 12.8333 5.33317 12.8333H7.49984V3.16666H3.1665V4.44443C3.1665 4.72058 2.94265 4.94443 2.6665 4.94443C2.39036 4.94443 2.1665 4.72058 2.1665 4.44443V2.66666Z" + /> +</svg>`; + +export const SmallHeading1Icon = html`<svg + width="16" + height="16" + viewBox="0 0 16 16" + xmlns="http://www.w3.org/2000/svg" +> + <path + fill-rule="evenodd" + clip-rule="evenodd" + fill="currentColor" + d="M11.9501 4.38557V11.6509C11.9501 12.052 12.1707 12.2926 12.525 12.2926C12.8859 12.2926 13.0998 12.0587 13.0998 11.6509V3.81744C13.0998 3.36294 12.799 3.0488 12.3712 3.0488C12.1106 3.0488 11.8766 3.14906 11.4555 3.4632L10.1054 4.46578C9.83804 4.65961 9.71105 4.84007 9.71105 5.03391C9.71105 5.28789 9.91156 5.49509 10.1589 5.49509C10.3059 5.49509 10.4463 5.44162 10.6401 5.30126L11.8967 4.38557H11.9501ZM2.94643 3.26836C3.22993 3.26836 3.45975 3.49818 3.45975 3.78168V7.34927H7.57203V3.78168C7.57203 3.49818 7.80185 3.26836 8.08535 3.26836C8.36885 3.26836 8.59867 3.49818 8.59867 3.78168V7.86259V11.9435C8.59867 12.227 8.36885 12.4568 8.08535 12.4568C7.80185 12.4568 7.57203 12.227 7.57203 11.9435V8.37591H3.45975V11.9435C3.45975 12.227 3.22993 12.4568 2.94643 12.4568C2.66293 12.4568 2.43311 12.227 2.43311 11.9435V3.78168C2.43311 3.49818 2.66293 3.26836 2.94643 3.26836Z" + /> +</svg>`; + +export const SmallHeading2Icon = html`<svg + width="16" + height="16" + viewBox="0 0 16 16" + xmlns="http://www.w3.org/2000/svg" +> + <path + fill-rule="evenodd" + clip-rule="evenodd" + fill="currentColor" + d="M9.43899 10.6664C9.06418 11.092 8.95618 11.2699 8.95618 11.4986C8.95618 11.8671 9.24841 12.0894 9.71216 12.0894H13.905C14.2544 12.0894 14.4577 11.9052 14.4577 11.6066C14.4577 11.3017 14.2417 11.1174 13.905 11.1174H10.3792V11.0412L12.7297 8.35396C13.8732 7.05164 14.1972 6.44812 14.1972 5.62226C14.1972 4.22465 13.0982 3.23361 11.5354 3.23361C9.85828 3.23361 8.81007 4.36441 8.81007 5.44438C8.81007 5.78743 9.01336 6.02249 9.32464 6.02249C9.58511 6.02249 9.76298 5.85096 9.85192 5.51426C10.0425 4.6884 10.646 4.19924 11.4655 4.19924C12.4311 4.19924 13.0728 4.7964 13.0728 5.69214C13.0728 6.2893 12.7996 6.82294 12.0817 7.64245L9.43899 10.6664ZM2.27891 3.57571C2.54836 3.57571 2.7668 3.79415 2.7668 4.0636V7.45447H6.67536V4.0636C6.67536 3.79415 6.8938 3.57571 7.16326 3.57571C7.43271 3.57571 7.65115 3.79415 7.65115 4.0636V7.94236V11.8211C7.65115 12.0906 7.43271 12.309 7.16326 12.309C6.8938 12.309 6.67536 12.0906 6.67536 11.8211V8.43026H2.7668V11.8211C2.7668 12.0906 2.54836 12.309 2.27891 12.309C2.00945 12.309 1.79102 12.0906 1.79102 11.8211V4.0636C1.79102 3.79415 2.00945 3.57571 2.27891 3.57571Z" + /> +</svg>`; + +export const SmallHeading3Icon = html`<svg + width="16" + height="16" + viewBox="0 0 16 16" + xmlns="http://www.w3.org/2000/svg" +> + <path + fill-rule="evenodd" + clip-rule="evenodd" + fill="currentColor" + d="M2.69991 3.90388C2.69991 3.62782 2.47612 3.40402 2.20005 3.40402C1.92399 3.40402 1.7002 3.62782 1.7002 3.90388V11.8516C1.7002 12.1277 1.92399 12.3515 2.20005 12.3515C2.47612 12.3515 2.69991 12.1277 2.69991 11.8516V8.37761H6.70432V11.8516C6.70432 12.1277 6.92812 12.3515 7.20418 12.3515C7.48025 12.3515 7.70404 12.1277 7.70404 11.8516V7.87775V3.90388C7.70404 3.62782 7.48025 3.40402 7.20418 3.40402C6.92812 3.40402 6.70432 3.62782 6.70432 3.90388V7.37789H2.69991V3.90388ZM9.01949 10.06C8.74072 10.06 8.54431 10.2628 8.54431 10.5479C8.54431 11.5172 9.74177 12.5056 11.3764 12.5056C13.1377 12.5056 14.3669 11.4665 14.3669 9.98398C14.3669 8.89423 13.5496 7.95654 12.4788 7.83616V7.7728C13.3595 7.62074 14.0754 6.7084 14.0754 5.7517C14.0754 4.43386 12.9477 3.48984 11.3637 3.48984C9.80512 3.48984 8.74705 4.4402 8.74705 5.42224C8.74705 5.73903 8.93713 5.94811 9.22857 5.94811C9.47567 5.94811 9.63406 5.81506 9.76077 5.46659C10.0142 4.81401 10.5844 4.4402 11.3384 4.4402C12.3077 4.4402 12.9603 5.02309 12.9603 5.89108C12.9603 6.75908 12.2887 7.38632 11.3637 7.38632H10.6161C10.312 7.38632 10.1092 7.58273 10.1092 7.8615C10.1092 8.13394 10.3247 8.34302 10.6161 8.34302H11.4081C12.5105 8.34302 13.2518 8.9956 13.2518 9.96497C13.2518 10.9343 12.5295 11.5552 11.3954 11.5552C10.5337 11.5552 9.87482 11.1751 9.54536 10.4972C9.38063 10.174 9.24124 10.06 9.01949 10.06Z" + /> +</svg>`; + +export const SmallHeading4Icon = html`<svg + width="16" + height="16" + viewBox="0 0 16 16" + xmlns="http://www.w3.org/2000/svg" +> + <path + fill-rule="evenodd" + clip-rule="evenodd" + fill="currentColor" + d="M12.5287 10.5032V11.791C12.5287 12.1589 12.719 12.3682 13.0552 12.3682C13.3977 12.3682 13.588 12.1589 13.588 11.791V10.5032H14.2478C14.6093 10.5032 14.8123 10.3256 14.8123 10.0211C14.8123 9.71028 14.603 9.53267 14.2478 9.53267H13.588V4.46418C13.588 3.87424 13.2645 3.54437 12.6873 3.54437C12.2496 3.54437 11.9578 3.73468 11.5835 4.28657C10.1435 6.44337 9.57259 7.35683 8.66546 8.96175C8.44344 9.36773 8.36731 9.58976 8.36731 9.82447C8.36731 10.2431 8.69718 10.5032 9.19197 10.5032H12.5287ZM12.5287 9.53267H9.43303V9.46923C10.264 8.02925 11.3932 6.215 12.4716 4.62277H12.5287V9.53267ZM1.96619 3.80359C2.23525 3.80359 2.45337 4.02171 2.45337 4.29077V7.67669H6.35625V4.29077C6.35625 4.02171 6.57436 3.80359 6.84343 3.80359C7.11249 3.80359 7.33061 4.02171 7.33061 4.29077V8.16387V12.037C7.33061 12.306 7.11249 12.5242 6.84343 12.5242C6.57436 12.5242 6.35625 12.306 6.35625 12.037V8.65106H2.45337V12.037C2.45337 12.306 2.23525 12.5242 1.96619 12.5242C1.69712 12.5242 1.479 12.306 1.479 12.037V4.29077C1.479 4.02171 1.69712 3.80359 1.96619 3.80359Z" + /> +</svg>`; + +export const SmallHeading5Icon = html`<svg + width="16" + height="16" + viewBox="0 0 16 16" + xmlns="http://www.w3.org/2000/svg" +> + <path + fill-rule="evenodd" + clip-rule="evenodd" + fill="currentColor" + d="M9.07668 10.0351C8.804 10.0351 8.62427 10.2458 8.62427 10.5556C8.62427 11.4233 9.64684 12.4211 11.2829 12.4211C13.012 12.4211 14.2453 11.2064 14.2453 9.49589C14.2453 7.89076 13.136 6.76284 11.5618 6.76284C10.8491 6.76284 10.124 7.06031 9.84515 7.47554H9.78318L10.0187 4.7177H13.3591C13.7247 4.7177 13.9292 4.55037 13.9292 4.2529C13.9292 3.94922 13.7185 3.7695 13.3591 3.7695H9.88234C9.34936 3.7695 9.08288 3.98641 9.04569 4.44502L8.77301 7.91555C8.74202 8.39275 8.92175 8.67163 9.275 8.67163C9.47331 8.67163 9.59726 8.59727 10.0497 8.17584C10.4091 7.85358 10.8367 7.69245 11.3201 7.69245C12.3923 7.69245 13.1545 8.46092 13.1545 9.55166C13.1545 10.692 12.3675 11.4852 11.2396 11.4852C10.4959 11.4852 9.92572 11.1258 9.58486 10.4627C9.42373 10.1466 9.28739 10.0351 9.07668 10.0351ZM2.05457 3.9359C2.31743 3.9359 2.53053 4.14899 2.53053 4.41185V7.71977H6.34347V4.41185C6.34347 4.14899 6.55657 3.9359 6.81943 3.9359C7.0823 3.9359 7.29539 4.14899 7.29539 4.41185V8.19573V11.9796C7.29539 12.2425 7.0823 12.4556 6.81943 12.4556C6.55657 12.4556 6.34347 12.2425 6.34347 11.9796V8.67169H2.53053V11.9796C2.53053 12.2425 2.31743 12.4556 2.05457 12.4556C1.79171 12.4556 1.57861 12.2425 1.57861 11.9796V4.41185C1.57861 4.14899 1.79171 3.9359 2.05457 3.9359Z" + /> +</svg>`; + +export const SmallHeading6Icon = html`<svg + width="16" + height="16" + viewBox="0 0 16 16" + xmlns="http://www.w3.org/2000/svg" +> + <path + fill-rule="evenodd" + clip-rule="evenodd" + fill="currentColor" + d="M11.7668 3.6778C9.70114 3.6778 8.55013 5.339 8.55013 8.33165C8.55013 9.61955 8.76166 10.5839 9.19096 11.2683C9.70114 12.0709 10.5411 12.5064 11.5925 12.5064C13.3222 12.5064 14.4919 11.343 14.4919 9.632C14.4919 8.00812 13.3844 6.86955 11.8041 6.86955C10.8335 6.86955 9.94379 7.40461 9.65759 8.16367H9.60782C9.62648 5.83674 10.3606 4.61728 11.7481 4.61728C12.3018 4.61728 12.7684 4.81637 13.2164 5.25812C13.4404 5.47588 13.571 5.54432 13.7453 5.54432C14.0128 5.54432 14.2057 5.34522 14.2057 5.07769C14.2057 4.79149 13.9568 4.44929 13.5462 4.18176C13.0733 3.85823 12.4325 3.6778 11.7668 3.6778ZM11.6361 11.5732C10.5349 11.5732 9.76336 10.783 9.76336 9.66933C9.76336 8.56808 10.5162 7.79658 11.5925 7.79658C12.7 7.79658 13.4217 8.54319 13.4217 9.67555C13.4217 10.8203 12.7062 11.5732 11.6361 11.5732ZM2.30302 3.99417C2.56692 3.99417 2.78085 4.2081 2.78085 4.472V7.79291H6.60878V4.472C6.60878 4.2081 6.82271 3.99417 7.08661 3.99417C7.35051 3.99417 7.56444 4.2081 7.56444 4.472V8.27074V12.0695C7.56444 12.3334 7.35051 12.5473 7.08661 12.5473C6.82271 12.5473 6.60878 12.3334 6.60878 12.0695V8.74857H2.78085V12.0695C2.78085 12.3334 2.56692 12.5473 2.30302 12.5473C2.03913 12.5473 1.8252 12.3334 1.8252 12.0695V4.472C1.8252 4.2081 2.03913 3.99417 2.30302 3.99417Z" + /> +</svg>`; + +export const SmallCodeBlockIcon = html`<svg + width="16" + height="16" + viewBox="0 0 16 16" + xmlns="http://www.w3.org/2000/svg" +> + <path + fill-rule="evenodd" + clip-rule="evenodd" + fill="currentColor" + d="M1.5 3.99999C1.5 2.98747 2.32081 2.16666 3.33333 2.16666H12.6667C13.6792 2.16666 14.5 2.98747 14.5 3.99999V12C14.5 13.0125 13.6792 13.8333 12.6667 13.8333H3.33333C2.32081 13.8333 1.5 13.0125 1.5 12V3.99999ZM3.33333 3.16666C2.8731 3.16666 2.5 3.53975 2.5 3.99999V12C2.5 12.4602 2.8731 12.8333 3.33333 12.8333H12.6667C13.1269 12.8333 13.5 12.4602 13.5 12V3.99999C13.5 3.53975 13.1269 3.16666 12.6667 3.16666H3.33333ZM7.02022 6.24637C7.21548 6.44163 7.21548 6.75822 7.02022 6.95348L5.95956 8.01414L7.02022 9.0748C7.21548 9.27006 7.21548 9.58664 7.02022 9.78191C6.82496 9.97717 6.50838 9.97717 6.31311 9.78191L4.8989 8.36769C4.70364 8.17243 4.70364 7.85585 4.8989 7.66058L6.31311 6.24637C6.50838 6.05111 6.82496 6.05111 7.02022 6.24637ZM8.97978 6.95348C8.78452 6.75822 8.78452 6.44163 8.97978 6.24637C9.17504 6.05111 9.49162 6.05111 9.68689 6.24637L11.1011 7.66058C11.2964 7.85585 11.2964 8.17243 11.1011 8.36769L9.68689 9.78191C9.49162 9.97717 9.17504 9.97717 8.97978 9.78191C8.78452 9.58664 8.78452 9.27006 8.97978 9.0748L10.0404 8.01414L8.97978 6.95348Z" + /> +</svg>`; + +export const SmallQuoteBlockIcon = html`<svg + width="16" + height="16" + viewBox="0 0 16 16" + xmlns="http://www.w3.org/2000/svg" +> + <path + fill-rule="evenodd" + clip-rule="evenodd" + fill="currentColor" + d="M3.3335 7.31033V7.39684V10.0405C3.3335 10.4175 3.63908 10.7231 4.01604 10.7231H6.74619C7.12315 10.7231 7.42873 10.4175 7.42873 10.0405V7.31033C7.42873 6.93337 7.12315 6.62779 6.74619 6.62779H4.46965C4.78293 5.77502 5.60225 5.16666 6.56365 5.16666V4.16666C4.83323 4.16666 3.42062 5.52736 3.33738 7.23713C3.33481 7.26118 3.3335 7.2856 3.3335 7.31033ZM8.66683 7.31033V7.39684V10.0405C8.66683 10.4175 8.97242 10.7231 9.34937 10.7231H12.0795C12.4565 10.7231 12.7621 10.4175 12.7621 10.0405V7.31033C12.7621 6.93337 12.4565 6.62779 12.0795 6.62779H9.80298C10.1163 5.77502 10.9356 5.16666 11.897 5.16666V4.16666C10.1666 4.16666 8.75396 5.52736 8.67071 7.23713C8.66815 7.26118 8.66683 7.2856 8.66683 7.31033Z" + /> +</svg>`; + +export const SmallNumberListIcon = html`<svg + width="16" + height="16" + viewBox="0 0 16 16" + xmlns="http://www.w3.org/2000/svg" +> + <path + fill-rule="evenodd" + clip-rule="evenodd" + fill="currentColor" + d="M2.75263 3.84138C2.7875 3.82966 2.81892 3.81149 2.85247 3.7882L3.02274 3.67024V5.09333C3.02274 5.21841 3.06494 5.32694 3.14381 5.40422C3.22253 5.48134 3.33201 5.52173 3.45631 5.52173C3.58061 5.52173 3.69008 5.48134 3.7688 5.40422C3.84767 5.32694 3.88988 5.21841 3.88988 5.09333V3.28924C3.88988 3.15004 3.84638 3.02903 3.75909 2.94279C3.67187 2.85661 3.54844 2.81259 3.40289 2.81259C3.25521 2.81259 3.11383 2.82725 2.95315 2.93437L2.50128 3.23619C2.38666 3.31397 2.3335 3.41753 2.3335 3.54254C2.3335 3.72466 2.46885 3.85721 2.643 3.85721C2.68225 3.85721 2.71723 3.85327 2.75263 3.84138ZM6.27061 3.73435C5.98781 3.73433 5.75855 3.96357 5.75853 4.24636C5.75851 4.52916 5.98775 4.75843 6.27055 4.75844L13.1548 4.75886C13.4375 4.75887 13.6668 4.52964 13.6668 4.24684C13.6668 3.96404 13.4376 3.73478 13.1548 3.73476L6.27061 3.73435ZM6.27061 7.48937C5.98781 7.48935 5.75855 7.71859 5.75853 8.00139C5.75851 8.28419 5.98775 8.51345 6.27055 8.51347L13.1548 8.51388C13.4375 8.5139 13.6668 8.28466 13.6668 8.00186C13.6668 7.71907 13.4376 7.4898 13.1548 7.48978L6.27061 7.48937ZM6.27061 11.2444C5.98781 11.2444 5.75855 11.4736 5.75853 11.7564C5.75851 12.0392 5.98775 12.2685 6.27055 12.2685L13.1548 12.2689C13.4375 12.2689 13.6668 12.0397 13.6668 11.7569C13.6668 11.4741 13.4376 11.2448 13.1548 11.2448L6.27061 11.2444ZM4.41634 7.40936C4.42422 7.36536 4.4281 7.31884 4.4281 7.26872C4.4281 6.81727 4.04212 6.51228 3.43732 6.51228C3.00309 6.51228 2.65331 6.69665 2.52235 6.99302C2.51378 7.0128 2.50697 7.03283 2.50184 7.05321C2.50698 7.03281 2.51379 7.01276 2.52237 6.99296C2.65332 6.69658 3.00311 6.51221 3.43733 6.51221C4.04214 6.51221 4.42811 6.8172 4.42811 7.26865C4.42811 7.3188 4.42423 7.36534 4.41634 7.40936ZM3.41666 8.50244L3.41664 8.50246V8.51624H4.20582C4.38847 8.51624 4.49185 8.6248 4.49185 8.79194C4.49185 8.81314 4.49012 8.83346 4.4867 8.8528C4.49013 8.83344 4.49187 8.81309 4.49187 8.79187C4.49187 8.62473 4.38848 8.51618 4.20583 8.51618H3.41666V8.50244ZM2.49348 8.6609C2.51659 8.5679 2.5776 8.48696 2.68261 8.39901L3.36669 7.81315C3.59238 7.6195 3.68741 7.51146 3.71552 7.39886C3.68743 7.51148 3.5924 7.61953 3.36667 7.81322L2.6826 8.39907C2.57761 8.487 2.5166 8.56793 2.49348 8.6609ZM3.00245 7.45443C3.06576 7.41681 3.11852 7.35977 3.16964 7.28495C3.21335 7.22183 3.25281 7.18128 3.29247 7.1562C3.33084 7.13193 3.37305 7.11981 3.42699 7.11981C3.50193 7.11981 3.55935 7.14261 3.59712 7.17634C3.63429 7.20954 3.65682 7.25704 3.65682 7.3169C3.65682 7.42985 3.60418 7.51941 3.32223 7.76134L2.63848 8.34692C2.49238 8.46935 2.41446 8.59008 2.41446 8.75224C2.41446 8.85608 2.44402 8.95426 2.51569 9.02649C2.58758 9.09893 2.69247 9.13584 2.82391 9.13584H4.20583C4.3113 9.13584 4.40151 9.10321 4.46551 9.03949C4.52944 8.97584 4.56014 8.88822 4.56014 8.79187C4.56014 8.69411 4.52962 8.60621 4.4653 8.54275C4.40102 8.47933 4.31071 8.4479 4.20583 8.4479H3.58427L3.91453 8.16286C4.10644 7.99664 4.25236 7.86448 4.34915 7.72979C4.44874 7.59122 4.49639 7.45023 4.49639 7.26865C4.49639 7.02144 4.38982 6.81248 4.20121 6.66707C4.01414 6.52284 3.75049 6.44394 3.43733 6.44394C2.98691 6.44394 2.6054 6.63612 2.45972 6.96581C2.43335 7.02666 2.42135 7.08939 2.42135 7.15493C2.42135 7.25795 2.45508 7.34823 2.52273 7.41249C2.58996 7.47636 2.68328 7.50751 2.78945 7.50751C2.86827 7.50751 2.93794 7.49275 3.00245 7.45443ZM2.44775 10.719C2.54915 10.4427 2.89358 10.1498 3.48551 10.1498C3.79028 10.1498 4.06201 10.2143 4.2587 10.3429C4.45674 10.4724 4.57779 10.6666 4.57779 10.9199C4.57779 11.2418 4.37235 11.4559 4.11613 11.5475C4.27228 11.5803 4.40301 11.6428 4.5004 11.7332C4.62307 11.8472 4.69016 12.003 4.69016 12.1916C4.69016 12.461 4.57389 12.6824 4.36382 12.8353C4.15486 12.9874 3.85534 13.0703 3.48925 13.0703C2.84939 13.0703 2.49302 12.7649 2.39522 12.4915C2.3769 12.4406 2.36887 12.3842 2.36887 12.3395C2.36887 12.2333 2.40451 12.144 2.47207 12.0815C2.53931 12.0193 2.63451 11.987 2.74761 11.987C2.82533 11.987 2.89225 11.9998 2.95092 12.0293C3.00974 12.0588 3.05785 12.1039 3.09973 12.1647C3.14935 12.2373 3.19619 12.2916 3.2569 12.3283C3.31713 12.3648 3.39437 12.3859 3.50798 12.3859C3.69342 12.3859 3.8147 12.276 3.8147 12.1335C3.8147 12.0471 3.78226 11.9841 3.7209 11.9412C3.65761 11.897 3.55985 11.8717 3.42558 11.8717H3.40685C3.30795 11.8717 3.22859 11.8442 3.1739 11.7906C3.11916 11.7369 3.09366 11.6617 3.09366 11.5754C3.09366 11.4925 3.11937 11.418 3.17378 11.3641C3.22823 11.3102 3.30745 11.281 3.40685 11.281H3.42558C3.54389 11.281 3.63283 11.2551 3.69126 11.2124C3.74841 11.1706 3.77911 11.1108 3.77911 11.0342C3.77911 10.9599 3.75194 10.902 3.70443 10.862C3.65624 10.8215 3.58381 10.7968 3.48925 10.7968C3.41343 10.7968 3.35069 10.8121 3.29851 10.8419C3.24639 10.8716 3.20271 10.9169 3.16682 10.9799C3.12025 11.0624 3.06988 11.122 3.00589 11.1606C2.94171 11.1992 2.86754 11.2144 2.77758 11.2144C2.66225 11.2144 2.57171 11.1804 2.50997 11.1187C2.44832 11.0571 2.41944 10.9717 2.41944 10.8769C2.41944 10.8202 2.42781 10.7749 2.44775 10.719Z" + /> +</svg>`; + +export const SmallBulletListIcon = html`<svg + width="16" + height="16" + viewBox="0 0 16 16" + xmlns="http://www.w3.org/2000/svg" +> + <path + fill-rule="evenodd" + clip-rule="evenodd" + fill="currentColor" + d="M2.86686 4.99999C3.23505 4.99999 3.53353 4.70151 3.53353 4.33332C3.53353 3.96513 3.23505 3.66666 2.86686 3.66666C2.49867 3.66666 2.2002 3.96513 2.2002 4.33332C2.2002 4.70151 2.49867 4.99999 2.86686 4.99999ZM5.55577 3.83381C5.27963 3.8338 5.05576 4.05764 5.05575 4.33378C5.05573 4.60993 5.27958 4.8338 5.55572 4.83381L13.5001 4.83425C13.7763 4.83427 14.0002 4.61042 14.0002 4.33428C14.0002 4.05814 13.7763 3.83427 13.5002 3.83425L5.55577 3.83381ZM5.55578 7.50051C5.27963 7.5005 5.05576 7.72434 5.05575 8.00048C5.05573 8.27662 5.27957 8.5005 5.55572 8.50051L13.5002 8.50099C13.7763 8.50101 14.0002 8.27716 14.0002 8.00102C14.0002 7.72488 13.7764 7.50101 13.5002 7.50099L5.55578 7.50051ZM5.55578 11.1672C5.27963 11.1672 5.05576 11.391 5.05575 11.6671C5.05573 11.9433 5.27957 12.1672 5.55571 12.1672L13.5002 12.1677C13.7763 12.1677 14.0002 11.9438 14.0002 11.6677C14.0002 11.3915 13.7764 11.1677 13.5002 11.1677L5.55578 11.1672ZM3.53353 7.99999C3.53353 8.36818 3.23505 8.66666 2.86686 8.66666C2.49867 8.66666 2.2002 8.36818 2.2002 7.99999C2.2002 7.6318 2.49867 7.33332 2.86686 7.33332C3.23505 7.33332 3.53353 7.6318 3.53353 7.99999ZM2.86686 12.3333C3.23505 12.3333 3.53353 12.0348 3.53353 11.6667C3.53353 11.2985 3.23505 11 2.86686 11C2.49867 11 2.2002 11.2985 2.2002 11.6667C2.2002 12.0348 2.49867 12.3333 2.86686 12.3333Z" + /> +</svg>`; + +export const SmallTodoIcon = html`<svg + width="16" + height="16" + viewBox="0 0 16 16" + xmlns="http://www.w3.org/2000/svg" +> + <path + fill-rule="evenodd" + clip-rule="evenodd" + fill="currentColor" + d="M3.99984 2.16666C2.98732 2.16666 2.1665 2.98747 2.1665 3.99999V12C2.1665 13.0125 2.98732 13.8333 3.99984 13.8333H11.9998C13.0124 13.8333 13.8332 13.0125 13.8332 12V3.99999C13.8332 2.98747 13.0124 2.16666 11.9998 2.16666H3.99984ZM3.1665 3.99999C3.1665 3.53975 3.5396 3.16666 3.99984 3.16666H11.9998C12.4601 3.16666 12.8332 3.53975 12.8332 3.99999V12C12.8332 12.4602 12.4601 12.8333 11.9998 12.8333H3.99984C3.5396 12.8333 3.1665 12.4602 3.1665 12V3.99999ZM11.0201 6.35354C11.2153 6.15828 11.2153 5.8417 11.0201 5.64644C10.8248 5.45117 10.5082 5.45117 10.3129 5.64644L6.99984 8.95955L6.02006 7.97977C5.82479 7.78451 5.50821 7.78451 5.31295 7.97977C5.11769 8.17503 5.11769 8.49161 5.31295 8.68688L6.64628 10.0202C6.84155 10.2155 7.15813 10.2155 7.35339 10.0202L11.0201 6.35354Z" + /> +</svg>`; + +export const SmallBookmarkIcon = html`<svg + width="16" + height="16" + viewBox="0 0 16 16" + xmlns="http://www.w3.org/2000/svg" +> + <path + fill-rule="evenodd" + clip-rule="evenodd" + fill="currentColor" + d="M3.33333 2.16669C2.32081 2.16669 1.5 2.9875 1.5 4.00002V8.66669C1.5 8.94283 1.72386 9.16669 2 9.16669C2.27614 9.16669 2.5 8.94283 2.5 8.66669V4.00002C2.5 3.53978 2.8731 3.16669 3.33333 3.16669H12.6667C13.1269 3.16669 13.5 3.53978 13.5 4.00002V12C13.5 12.4603 13.1269 12.8334 12.6667 12.8334H8C7.72386 12.8334 7.5 13.0572 7.5 13.3334C7.5 13.6095 7.72386 13.8334 8 13.8334H12.6667C13.6792 13.8334 14.5 13.0125 14.5 12V4.00002C14.5 2.9875 13.6792 2.16669 12.6667 2.16669H3.33333ZM6.07741 8.7441C6.40285 8.41866 6.93048 8.41866 7.25592 8.7441C7.58136 9.06953 7.58136 9.59717 7.25592 9.92261L5.92259 11.2559C5.59715 11.5814 5.06951 11.5814 4.74408 11.2559C4.54882 11.0607 4.23223 11.0607 4.03697 11.2559C3.84171 11.4512 3.84171 11.7678 4.03697 11.963C4.75293 12.679 5.91373 12.679 6.6297 11.963L7.96303 10.6297C8.67899 9.91375 8.67899 8.75295 7.96303 8.03699C7.24707 7.32103 6.08627 7.32103 5.3703 8.03699L5.00377 8.40353C4.80851 8.59879 4.80851 8.91537 5.00377 9.11063C5.19903 9.3059 5.51561 9.3059 5.71087 9.11063L6.07741 8.7441ZM4.07741 10.7441C4.40285 10.4187 4.93049 10.4187 5.25592 10.7441C5.45118 10.9394 5.76777 10.9394 5.96303 10.7441C6.15829 10.5488 6.15829 10.2323 5.96303 10.037C5.24707 9.32103 4.08627 9.32103 3.3703 10.037L2.03697 11.3703C1.32101 12.0863 1.32101 13.2471 2.03697 13.963C2.75293 14.679 3.91373 14.679 4.6297 13.963L4.99688 13.5959C5.19215 13.4006 5.19215 13.084 4.99688 12.8888C4.80162 12.6935 4.48504 12.6935 4.28978 12.8888L3.92259 13.2559C3.59715 13.5814 3.06951 13.5814 2.74408 13.2559C2.41864 12.9305 2.41864 12.4029 2.74408 12.0774L4.07741 10.7441Z" + /> +</svg>`; + +export const SmallImageIcon = html`<svg + width="16" + height="16" + viewBox="0 0 16 16" + xmlns="http://www.w3.org/2000/svg" +> + <path + fill-rule="evenodd" + clip-rule="evenodd" + fill="currentColor" + d="M3.99984 2.16669C2.98732 2.16669 2.1665 2.9875 2.1665 4.00002V10.6667V12C2.1665 13.0125 2.98732 13.8334 3.99984 13.8334H11.9998C13.0124 13.8334 13.8332 13.0125 13.8332 12V9.33335V4.00002C13.8332 2.9875 13.0124 2.16669 11.9998 2.16669H3.99984ZM3.1665 12V10.8738L6.07725 7.96305C6.40268 7.63761 6.93032 7.63761 7.25576 7.96305L8.97962 9.68691L10.313 11.0202C10.5082 11.2155 10.8248 11.2155 11.0201 11.0202C11.2153 10.825 11.2153 10.5084 11.0201 10.3131L10.0403 9.33335L10.7439 8.62972C11.0694 8.30428 11.597 8.30428 11.9224 8.62972L12.8332 9.54046V12C12.8332 12.4603 12.4601 12.8334 11.9998 12.8334H3.99984C3.5396 12.8334 3.1665 12.4603 3.1665 12ZM7.96287 7.25594L9.33317 8.62625L10.0368 7.92261C10.7528 7.20665 11.9136 7.20665 12.6295 7.92261L12.8332 8.12625V4.00002C12.8332 3.53978 12.4601 3.16669 11.9998 3.16669H3.99984C3.5396 3.16669 3.1665 3.53978 3.1665 4.00002V9.45958L5.37014 7.25594C6.0861 6.53998 7.2469 6.53998 7.96287 7.25594ZM9.33317 6.00002C9.70136 6.00002 9.99984 5.70154 9.99984 5.33335C9.99984 4.96516 9.70136 4.66669 9.33317 4.66669C8.96498 4.66669 8.6665 4.96516 8.6665 5.33335C8.6665 5.70154 8.96498 6.00002 9.33317 6.00002Z" + /> +</svg>`; + +export const SmallDatabaseTableIcon = html`<svg + width="16" + height="16" + viewBox="0 0 16 16" + xmlns="http://www.w3.org/2000/svg" +> + <path + fill-rule="evenodd" + clip-rule="evenodd" + fill="currentColor" + d="M2.1665 4.00002C2.1665 2.9875 2.98732 2.16669 3.99984 2.16669H11.9998C13.0124 2.16669 13.8332 2.9875 13.8332 4.00002V12C13.8332 13.0125 13.0124 13.8334 11.9998 13.8334H3.99984C2.98732 13.8334 2.1665 13.0125 2.1665 12V4.00002ZM3.99984 3.16669C3.5396 3.16669 3.1665 3.53978 3.1665 4.00002V5.50002H12.8332V4.00002C12.8332 3.53978 12.4601 3.16669 11.9998 3.16669H3.99984ZM3.1665 12V6.50002H5.1665V12.8334H3.99984C3.5396 12.8334 3.1665 12.4603 3.1665 12ZM6.1665 12.8334H11.9998C12.4601 12.8334 12.8332 12.4603 12.8332 12V6.50002H6.1665V12.8334Z" + /> +</svg>`; + +export const SmallDatabaseKanbanIcon = html`<svg + width="16" + height="16" + viewBox="0 0 16 16" + xmlns="http://www.w3.org/2000/svg" +> + <path + fill-rule="evenodd" + clip-rule="evenodd" + fill="currentColor" + d="M3.66667 2.5C3.02233 2.5 2.5 3.02233 2.5 3.66667V12C2.5 12.6443 3.02233 13.1667 3.66667 13.1667H6.33333C6.97767 13.1667 7.5 12.6443 7.5 12V3.66667C7.5 3.02233 6.97767 2.5 6.33333 2.5H3.66667ZM3.5 3.66667C3.5 3.57462 3.57462 3.5 3.66667 3.5H6.33333C6.42538 3.5 6.5 3.57462 6.5 3.66667V12C6.5 12.092 6.42538 12.1667 6.33333 12.1667H3.66667C3.57462 12.1667 3.5 12.092 3.5 12V3.66667ZM9.66667 2.5C9.02233 2.5 8.5 3.02233 8.5 3.66667V8.33333C8.5 8.97767 9.02233 9.5 9.66667 9.5H12.3333C12.9777 9.5 13.5 8.97767 13.5 8.33333V3.66667C13.5 3.02233 12.9777 2.5 12.3333 2.5H9.66667ZM9.5 3.66667C9.5 3.57462 9.57462 3.5 9.66667 3.5H12.3333C12.4254 3.5 12.5 3.57462 12.5 3.66667V8.33333C12.5 8.42538 12.4254 8.5 12.3333 8.5H9.66667C9.57462 8.5 9.5 8.42538 9.5 8.33333V3.66667Z" + /> +</svg>`; + +export const SmallAttachmentIcon = html`<svg + width="16" + height="16" + viewBox="0 0 16 16" + xmlns="http://www.w3.org/2000/svg" +> + <path + fill-rule="evenodd" + clip-rule="evenodd" + fill="currentColor" + d="M8.14486 3.73868L4.11805 7.76553C2.84936 9.00118 2.84933 10.9989 4.11802 12.2346C5.39409 13.4774 7.46833 13.4774 8.7444 12.2346L12.671 8.41922C12.8691 8.22678 13.1856 8.23133 13.378 8.42938C13.5705 8.62742 13.5659 8.94397 13.3679 9.13641L9.44212 12.9509C9.44204 12.951 9.44219 12.9509 9.44212 12.9509C7.77775 14.5717 5.08458 14.5719 3.4203 12.9509C1.74938 11.3235 1.74857 8.67989 3.41786 7.0515L7.44488 3.02445C8.61916 1.88075 10.5177 1.88078 11.692 3.02448C12.8729 4.17456 12.8737 6.04431 11.6945 7.1954L7.66745 11.2225C6.98324 11.8889 5.87921 11.8889 5.195 11.2225C4.50341 10.5489 4.50341 9.45119 5.195 8.77761L9.32726 4.75296C9.52508 4.56029 9.84163 4.56447 10.0343 4.76229C10.227 4.96011 10.2228 5.27666 10.025 5.46933L5.89272 9.49398C5.60417 9.77501 5.60417 10.2251 5.89272 10.5061C6.18796 10.7936 6.67153 10.7943 6.96765 10.5081L10.9943 6.48141C11.7729 5.72307 11.7729 4.49919 10.9943 3.74085C10.2091 2.97604 8.93105 2.97532 8.14486 3.73868Z" + /> +</svg>`; + +export const SmallLinkedDocIcon = html`<svg + width="16" + height="16" + viewBox="0 0 16 16" + xmlns="http://www.w3.org/2000/svg" +> + <path + fill-rule="evenodd" + clip-rule="evenodd" + fill="currentColor" + d="M2.8335 4.00002C2.8335 2.9875 3.65431 2.16669 4.66683 2.16669H11.3335C12.346 2.16669 13.1668 2.9875 13.1668 4.00002V8.00002C13.1668 8.27616 12.943 8.50002 12.6668 8.50002C12.3907 8.50002 12.1668 8.27616 12.1668 8.00002V4.00002C12.1668 3.53978 11.7937 3.16669 11.3335 3.16669H4.66683C4.20659 3.16669 3.8335 3.53978 3.8335 4.00002V12C3.8335 12.4603 4.20659 12.8334 4.66683 12.8334H8.00016C8.27631 12.8334 8.50016 13.0572 8.50016 13.3334C8.50016 13.6095 8.27631 13.8334 8.00016 13.8334H4.66683C3.65431 13.8334 2.8335 13.0125 2.8335 12V4.00002ZM5.50016 5.33335C5.50016 5.05721 5.72402 4.83335 6.00016 4.83335H8.00016C8.27631 4.83335 8.50016 5.05721 8.50016 5.33335C8.50016 5.6095 8.27631 5.83335 8.00016 5.83335H6.00016C5.72402 5.83335 5.50016 5.6095 5.50016 5.33335ZM6.00016 7.16669C5.72402 7.16669 5.50016 7.39054 5.50016 7.66669C5.50016 7.94283 5.72402 8.16669 6.00016 8.16669H10.0002C10.2763 8.16669 10.5002 7.94283 10.5002 7.66669C10.5002 7.39054 10.2763 7.16669 10.0002 7.16669H6.00016ZM5.50016 10C5.50016 9.72388 5.72402 9.50002 6.00016 9.50002H7.66683C7.94297 9.50002 8.16683 9.72388 8.16683 10C8.16683 10.2762 7.94297 10.5 7.66683 10.5H6.00016C5.72402 10.5 5.50016 10.2762 5.50016 10ZM10.3335 9.50002C10.0574 9.50002 9.8335 9.72388 9.8335 10C9.8335 10.2762 10.0574 10.5 10.3335 10.5H12.1264L9.64661 12.9798C9.45135 13.1751 9.45135 13.4916 9.64661 13.6869C9.84187 13.8822 10.1585 13.8822 10.3537 13.6869L12.8335 11.2071V13C12.8335 13.2762 13.0574 13.5 13.3335 13.5C13.6096 13.5 13.8335 13.2762 13.8335 13V10C13.8335 9.86741 13.7808 9.74023 13.687 9.64647C13.5933 9.5527 13.4661 9.50002 13.3335 9.50002H10.3335Z" + /> +</svg>`; + +export const SmallDeleteIcon = html`<svg + width="16" + height="16" + viewBox="0 0 16 16" + fill="none" + xmlns="http://www.w3.org/2000/svg" +> + <path + fill-rule="evenodd" + clip-rule="evenodd" + d="M7.33334 1.5C6.32082 1.5 5.50001 2.32081 5.50001 3.33333V4.09994H2.66667C2.35371 4.09994 2.10001 4.35364 2.10001 4.6666C2.10001 4.97956 2.35371 5.23327 2.66667 5.23327H2.87254L3.41282 12.7973C3.48135 13.7567 4.27966 14.5 5.2415 14.5H10.7585C11.7204 14.5 12.5187 13.7567 12.5872 12.7973L13.1275 5.23327H13.3333C13.6463 5.23327 13.9 4.97956 13.9 4.6666C13.9 4.35364 13.6463 4.09994 13.3333 4.09994H10.5V3.33333C10.5 2.32081 9.67919 1.5 8.66667 1.5H7.33334ZM9.50001 4.09994V3.33333C9.50001 2.8731 9.12691 2.5 8.66667 2.5H7.33334C6.8731 2.5 6.50001 2.8731 6.50001 3.33333V4.09994H9.50001ZM12.1249 5.23327H3.87508L4.41028 12.726C4.44143 13.1621 4.8043 13.5 5.2415 13.5H10.7585C11.1957 13.5 11.5586 13.1621 11.5897 12.726L12.1249 5.23327ZM7.16667 7.33333C7.16667 7.05719 6.94281 6.83333 6.66667 6.83333C6.39053 6.83333 6.16667 7.05719 6.16667 7.33333V11.3333C6.16667 11.6095 6.39053 11.8333 6.66667 11.8333C6.94281 11.8333 7.16667 11.6095 7.16667 11.3333V7.33333ZM9.33334 6.83333C9.60948 6.83333 9.83334 7.05719 9.83334 7.33333V11.3333C9.83334 11.6095 9.60948 11.8333 9.33334 11.8333C9.0572 11.8333 8.83334 11.6095 8.83334 11.3333V7.33333C8.83334 7.05719 9.0572 6.83333 9.33334 6.83333Z" + fill="currentColor" + /> +</svg>`; + +export const SortingIcon = html`<svg + width="20" + height="20" + viewBox="0 0 20 20" + xmlns="http://www.w3.org/2000/svg" +> + <path + fill-rule="evenodd" + clip-rule="evenodd" + fill="currentColor" + d="M14.7916 13.9062C14.7916 14.2514 14.5118 14.5312 14.1666 14.5312C13.8214 14.5312 13.5416 14.2514 13.5416 13.9062V5.10263L10.8585 7.78569C10.6144 8.02977 10.2187 8.02977 9.97464 7.78569C9.73057 7.54161 9.73057 7.14589 9.97464 6.90181L13.7246 3.15181C13.9687 2.90773 14.3644 2.90773 14.6085 3.15181L18.3585 6.90181C18.6026 7.14589 18.6026 7.54161 18.3585 7.78569C18.1144 8.02977 17.7187 8.02977 17.4746 7.78569L14.7916 5.10263V13.9062ZM6.45825 6.09375C6.45825 5.74857 6.17843 5.46875 5.83325 5.46875C5.48807 5.46875 5.20825 5.74857 5.20825 6.09375V14.8974L2.52519 12.2143C2.28112 11.9702 1.88539 11.9702 1.64131 12.2143C1.39723 12.4584 1.39723 12.8541 1.64131 13.0982L5.39131 16.8482C5.63539 17.0923 6.03112 17.0923 6.27519 16.8482L10.0252 13.0982C10.2693 12.8541 10.2693 12.4584 10.0252 12.2143C9.78112 11.9702 9.38539 11.9702 9.14131 12.2143L6.45825 14.8974V6.09375Z" + /> +</svg>`; + +export const ArrowLeftIcon = html`<svg + width="20" + height="20" + viewBox="0 0 20 20" + fill="none" + xmlns="http://www.w3.org/2000/svg" +> + <path + d="M13.0265 9.4584C13.4358 9.69904 13.4358 10.3006 13.0265 10.5413L8.42122 13.2485C8.01186 13.4891 7.50016 13.1883 7.50016 12.707L7.50016 7.29264C7.50016 6.81136 8.01186 6.51056 8.42122 6.7512L13.0265 9.4584Z" + fill="#77757D" + /> +</svg> `; + +export const ArrowJumpIcon = html`<svg + width="20" + height="20" + viewBox="0 0 20 20" + fill="none" + xmlns="http://www.w3.org/2000/svg" +> + <g clip-path="url(#clip0_3398_22894)"> + <path + fill-rule="evenodd" + clip-rule="evenodd" + d="M10.8359 5.4105C10.6024 5.66474 10.6192 6.06011 10.8735 6.29359L14.2287 9.37492L4.16658 9.37492C3.82141 9.37492 3.54158 9.65474 3.54158 9.99992C3.54158 10.3451 3.82141 10.6249 4.16658 10.6249L14.2287 10.6249L10.8735 13.7063C10.6192 13.9397 10.6024 14.3351 10.8359 14.5893C11.0694 14.8436 11.4647 14.8604 11.719 14.6269L16.256 10.4603C16.3849 10.3419 16.4583 10.1749 16.4583 9.99992C16.4583 9.82493 16.3849 9.65796 16.256 9.53959L11.719 5.37292C11.4647 5.13944 11.0694 5.15627 10.8359 5.4105Z" + fill="#77757D" + fill-opacity="0.6" + /> + </g> + <defs> + <clipPath id="clip0_3398_22894"> + <rect width="20" height="20" fill="white" /> + </clipPath> + </defs> +</svg> `; + +export const HiddenIcon = html`<svg + width="20" + height="20" + viewBox="0 0 20 20" + xmlns="http://www.w3.org/2000/svg" +> + <path + fill-rule="evenodd" + clip-rule="evenodd" + fill="currentColor" + d="M3.83277 6.29118C3.62547 6.01519 3.23368 5.9595 2.95769 6.16681C2.68169 6.37411 2.62601 6.76591 2.83331 7.0419C3.50188 7.93198 4.33282 8.69373 5.28223 9.28306L3.64659 11.7365C3.45512 12.0237 3.53273 12.4118 3.81994 12.6032C4.10714 12.7947 4.49519 12.7171 4.68666 12.4299L6.35332 9.92989C6.36688 9.90956 6.37909 9.88872 6.38997 9.86748C7.31361 10.2747 8.31912 10.5306 9.37496 10.6034V14.5832C9.37496 14.9284 9.65478 15.2082 9.99996 15.2082C10.3451 15.2082 10.625 14.9284 10.625 14.5832V10.6035C11.6808 10.5307 12.6864 10.2749 13.6101 9.86776C13.6209 9.8889 13.6331 9.90965 13.6466 9.92989L15.3133 12.4299C15.5047 12.7171 15.8928 12.7947 16.18 12.6032C16.4672 12.4118 16.5448 12.0237 16.3533 11.7365L14.7179 9.2834C15.6675 8.69402 16.4987 7.93215 17.1674 7.0419C17.3747 6.76591 17.319 6.37411 17.043 6.16681C16.767 5.9595 16.3752 6.01519 16.1679 6.29118C14.7605 8.16488 12.5218 9.3749 10.0003 9.3749C7.47885 9.3749 5.24015 8.16488 3.83277 6.29118Z" + /> +</svg>`; + +export const SmallArrowDownIcon = html`<svg + width="16" + height="16" + viewBox="0 0 16 16" + fill="none" + xmlns="http://www.w3.org/2000/svg" +> + <path + fill-rule="evenodd" + clip-rule="evenodd" + d="M4.31344 6.31295C4.5087 6.11769 4.82528 6.11769 5.02055 6.31295L7.76462 9.05703C7.8948 9.1872 8.10585 9.1872 8.23603 9.05703L10.9801 6.31295C11.1754 6.11769 11.492 6.11769 11.6872 6.31295C11.8825 6.50821 11.8825 6.8248 11.6872 7.02006L8.94313 9.76414C8.42244 10.2848 7.57822 10.2848 7.05752 9.76414L4.31344 7.02006C4.11818 6.8248 4.11818 6.50821 4.31344 6.31295Z" + fill="currentColor" + /> +</svg>`; + +export const SmallCloseIcon = html`<svg + width="16" + height="16" + viewBox="0 0 16 16" + xmlns="http://www.w3.org/2000/svg" +> + <path + fill-rule="evenodd" + clip-rule="evenodd" + d="M3.64645 3.64645C3.84171 3.45118 4.15829 3.45118 4.35355 3.64645L8 7.29289L11.6464 3.64645C11.8417 3.45118 12.1583 3.45118 12.3536 3.64645C12.5488 3.84171 12.5488 4.15829 12.3536 4.35355L8.70711 8L12.3536 11.6464C12.5488 11.8417 12.5488 12.1583 12.3536 12.3536C12.1583 12.5488 11.8417 12.5488 11.6464 12.3536L8 8.70711L4.35355 12.3536C4.15829 12.5488 3.84171 12.5488 3.64645 12.3536C3.45118 12.1583 3.45118 11.8417 3.64645 11.6464L7.29289 8L3.64645 4.35355C3.45118 4.15829 3.45118 3.84171 3.64645 3.64645Z" + fill="currentColor" + /> +</svg>`; + +export const TocIcon = html` + <svg + xmlns="http://www.w3.org/2000/svg" + viewBox="0 0 24 24" + width="1em" + height="1em" + fill="none" + style="user-select:none;flex-shrink:0;" + > + <path + fill="#7A7A7A" + fill-rule="evenodd" + d="M3.25 6A.75.75 0 0 1 4 5.25h13a.75.75 0 0 1 0 1.5H4A.75.75 0 0 1 3.25 6ZM3.25 12a.75.75 0 0 1 .75-.75h13a.75.75 0 0 1 0 1.5H4a.75.75 0 0 1-.75-.75ZM3.25 18a.75.75 0 0 1 .75-.75h13a.75.75 0 0 1 0 1.5H4a.75.75 0 0 1-.75-.75Z" + clip-rule="evenodd" + /> + <path + fill="#7A7A7A" + d="M20.8 12a1 1 0 1 1-2 0 1 1 0 0 1 2 0ZM20.8 18a1 1 0 1 1-2 0 1 1 0 0 1 2 0ZM20.8 6a1 1 0 1 1-2 0 1 1 0 0 1 2 0Z" + /> + </svg> +`; diff --git a/blocksuite/presets/src/fragments/comment/comment-input.ts b/blocksuite/presets/src/fragments/comment/comment-input.ts new file mode 100644 index 0000000000..e6975aaecc --- /dev/null +++ b/blocksuite/presets/src/fragments/comment/comment-input.ts @@ -0,0 +1,124 @@ +import type { TextSelection } from '@blocksuite/block-std'; +import { ShadowlessElement } from '@blocksuite/block-std'; +import type { RichText } from '@blocksuite/blocks'; +import { WithDisposable } from '@blocksuite/global/utils'; +import { DocCollection } from '@blocksuite/store'; +import { css, html, nothing } from 'lit'; +import { property, query } from 'lit/decorators.js'; + +import type { Comment, CommentManager } from './comment-manager.js'; + +export class CommentInput extends WithDisposable(ShadowlessElement) { + static override styles = css` + .comment-input-container { + padding: 16px; + } + + .comment-quote { + font-size: 10px; + color: var(--affine-text-secondary-color); + padding-left: 8px; + border-left: 2px solid var(--affine-text-secondary-color); + margin-bottom: 8px; + } + + .comment-author { + font-size: 12px; + } + + .comment-editor { + white-space: pre-wrap; + overflow-wrap: break-word; + min-height: 24px; + margin-top: 16px; + margin-bottom: 16px; + } + + .comment-control { + display: flex; + gap: 8px; + margin-top: 8px; + } + `; + + private _cancel = () => { + this.remove(); + }; + + private _submit = (textSelection: TextSelection) => { + const deltas = this._editor.inlineEditor?.yTextDeltas; + if (!deltas) { + this.remove(); + return; + } + + const yText = new DocCollection.Y.Text(); + yText.applyDelta(deltas); + const comment = this.manager.addComment(textSelection, { + author: 'Anonymous', + text: yText, + }); + + this.onSubmit?.(comment); + + this.remove(); + }; + + get host() { + return this.manager.host; + } + + override render() { + const textSelection = this.host.selection.find('text'); + if (!textSelection) { + this.remove(); + return nothing; + } + const parseResult = this.manager.parseTextSelection(textSelection); + if (!parseResult) { + this.remove(); + return nothing; + } + + const { quote } = parseResult; + + const tmpYDoc = new DocCollection.Y.Doc(); + const tmpYText = tmpYDoc.getText('comment'); + + return html`<div class="comment-input-container"> + <div class="comment-state"> + <div class="comment-quote">${quote}</div> + <div class="comment-author">Anonymous</div> + </div> + <rich-text + @blur=${() => this._submit(textSelection)} + .yText=${tmpYText} + class="comment-editor" + ></rich-text> + <div class="comment-control"> + <button + @click=${() => this._submit(textSelection)} + class="comment-submit" + > + Submit + </button> + <button @click=${this._cancel} class="comment-cancel">Cancel</button> + </div> + </div>`; + } + + @query('rich-text') + private accessor _editor!: RichText; + + @property({ attribute: false }) + accessor manager!: CommentManager; + + @property({ attribute: false }) + accessor onSubmit: undefined | ((comment: Comment) => void) = undefined; +} + +declare global { + interface HTMLElementTagNameMap { + 'comment-input': CommentInput; + } +} diff --git a/blocksuite/presets/src/fragments/comment/comment-manager.ts b/blocksuite/presets/src/fragments/comment/comment-manager.ts new file mode 100644 index 0000000000..0a3e3aec29 --- /dev/null +++ b/blocksuite/presets/src/fragments/comment/comment-manager.ts @@ -0,0 +1,164 @@ +import type { EditorHost, TextSelection } from '@blocksuite/block-std'; +import { DocCollection, type Y } from '@blocksuite/store'; + +export interface CommentMeta { + id: string; + date: number; +} +export interface CommentRange { + start: { + id: string; + index: Y.RelativePosition; + }; + end: { + id: string; + index: Y.RelativePosition; + }; +} +export interface CommentContent { + quote: string; + author: string; + text: Y.Text; +} + +export type Comment = CommentMeta & CommentRange & CommentContent; + +export class CommentManager { + private get _command() { + return this.host.command; + } + + get commentsMap() { + return this.host.doc.spaceDoc.getMap<Y.Map<unknown>>('comments'); + } + + constructor(readonly host: EditorHost) {} + + addComment( + selection: TextSelection, + payload: Pick<CommentContent, 'author' | 'text'> + ): Comment { + const parseResult = this.parseTextSelection(selection); + if (!parseResult) { + throw new Error('Invalid selection'); + } + + const { quote, range } = parseResult; + const id = this.host.doc.collection.idGenerator(); + const comment: Comment = { + id, + date: Date.now(), + start: range.start, + end: range.end, + quote, + ...payload, + }; + this.commentsMap.set( + id, + new DocCollection.Y.Map<unknown>(Object.entries(comment)) + ); + return comment; + } + + getComments(): Comment[] { + const comments: Comment[] = []; + this.commentsMap.forEach((comment, key) => { + const start = comment.get('start') as Comment['start']; + const end = comment.get('end') as Comment['end']; + + const startIndex = + DocCollection.Y.createAbsolutePositionFromRelativePosition( + start.index, + this.host.doc.spaceDoc + ); + const startBlock = this.host.view.getBlock(start.id); + const endIndex = + DocCollection.Y.createAbsolutePositionFromRelativePosition( + end.index, + this.host.doc.spaceDoc + ); + const endBlock = this.host.view.getBlock(end.id); + + if (!startIndex || !startBlock || !endIndex || !endBlock) { + // remove outdated comment + this.commentsMap.delete(key); + return; + } + + const result: Comment = { + id: comment.get('id') as Comment['id'], + date: comment.get('date') as Comment['date'], + start, + end, + quote: comment.get('quote') as Comment['quote'], + author: comment.get('author') as Comment['author'], + text: comment.get('text') as Comment['text'], + }; + comments.push(result); + }); + return comments; + } + + parseTextSelection(selection: TextSelection): { + quote: CommentContent['quote']; + range: CommentRange; + } | null { + const [_, ctx] = this._command + .chain() + .getSelectedBlocks({ + currentTextSelection: selection, + types: ['text'], + }) + .run(); + const blocks = ctx.selectedBlocks; + if (!blocks || blocks.length === 0) return null; + + const { from, to } = selection; + const fromBlock = blocks[0]; + const fromBlockText = fromBlock.model.text; + const fromBlockId = fromBlock.model.id; + const toBlock = blocks[blocks.length - 1]; + const toBlockText = toBlock.model.text; + const toBlockId = toBlock.model.id; + if (!fromBlockText || !toBlockText) return null; + + const startIndex = DocCollection.Y.createRelativePositionFromTypeIndex( + fromBlockText.yText, + from.index + ); + const endIndex = DocCollection.Y.createRelativePositionFromTypeIndex( + toBlockText.yText, + to ? to.index + to.length : from.index + from.length + ); + const quote = blocks.reduce((acc, block, index) => { + const text = block.model.text; + if (!text) return acc; + + if (index === 0) { + return ( + acc + + text.yText.toString().slice(from.index, from.index + from.length) + ); + } + if (index === blocks.length - 1 && to) { + return acc + ' ' + text.yText.toString().slice(0, to.index + to.length); + } + + return acc + ' ' + text.yText.toString(); + }, ''); + + return { + quote, + range: { + start: { + id: fromBlockId, + index: startIndex, + }, + end: { + id: toBlockId, + index: endIndex, + }, + }, + }; + } +} diff --git a/blocksuite/presets/src/fragments/comment/comment-panel.ts b/blocksuite/presets/src/fragments/comment/comment-panel.ts new file mode 100644 index 0000000000..095a07801c --- /dev/null +++ b/blocksuite/presets/src/fragments/comment/comment-panel.ts @@ -0,0 +1,116 @@ +import { ShadowlessElement } from '@blocksuite/block-std'; +import { WithDisposable } from '@blocksuite/global/utils'; +import { css, html } from 'lit'; +import { property, query } from 'lit/decorators.js'; + +import type { AffineEditorContainer } from '../../editors/editor-container.js'; +import { CommentInput } from './comment-input.js'; +import { CommentManager } from './comment-manager.js'; + +export class CommentPanel extends WithDisposable(ShadowlessElement) { + static override styles = css` + comment-panel { + position: absolute; + top: 0; + right: 0; + border: 1px solid var(--affine-border-color, #e3e2e4); + background-color: var(--affine-background-primary-color); + height: 100vh; + width: 320px; + box-sizing: border-box; + padding-top: 16px; + } + + .comment-panel-container { + width: 100%; + height: 100%; + padding: 16px; + } + + .comment-panel-head { + display: flex; + gap: 8px; + } + + .comment-panel-comments { + margin-top: 16px; + } + + .comment-panel-comment { + margin-bottom: 16px; + } + + .comment-panel-comment-quote { + font-size: 10px; + color: var(--affine-text-secondary-color); + padding-left: 8px; + border-left: 2px solid var(--affine-text-secondary-color); + margin-bottom: 8px; + } + + .comment-panel-comment-author { + font-size: 12px; + } + + .comment-panel-comment-text { + margin-top: 8px; + } + `; + + commentManager: CommentManager | null = null; + + private _addComment() { + const textSelection = this.editor.host?.selection.find('text'); + if (!textSelection) return; + + const commentInput = new CommentInput(); + if (!this.commentManager) return; + + commentInput.manager = this.commentManager; + commentInput.onSubmit = () => { + this.requestUpdate(); + }; + this._container.append(commentInput); + } + + override connectedCallback() { + super.connectedCallback(); + + if (!this.editor.host) return; + this.commentManager = new CommentManager(this.editor.host); + } + + override render() { + if (!this.commentManager) return; + const comments = this.commentManager.getComments(); + + return html`<div class="comment-panel-container"> + <div class="comment-panel-head"> + <button @click=${this._addComment}>Add Comment</button> + </div> + <div class="comment-panel-comments"> + ${comments.map(comment => { + return html`<div class="comment-panel-comment"> + <div class="comment-panel-comment-quote">${comment.quote}</div> + <div class="comment-panel-comment-author">${comment.author}</div> + <div class="comment-panel-comment-text"> + <rich-text .yText=${comment.text} .readonly=${true}></rich-text> + </div> + </div>`; + })} + </div> + </div>`; + } + + @query('.comment-panel-container') + private accessor _container!: HTMLDivElement; + + @property({ attribute: false }) + accessor editor!: AffineEditorContainer; +} + +declare global { + interface HTMLElementTagNameMap { + 'comment-panel': CommentPanel; + } +} diff --git a/blocksuite/presets/src/fragments/comment/index.ts b/blocksuite/presets/src/fragments/comment/index.ts new file mode 100644 index 0000000000..aab0fa4372 --- /dev/null +++ b/blocksuite/presets/src/fragments/comment/index.ts @@ -0,0 +1,2 @@ +export * from './comment-manager.js'; +export * from './comment-panel.js'; diff --git a/blocksuite/presets/src/fragments/doc-meta-tags/backlink-popover.ts b/blocksuite/presets/src/fragments/doc-meta-tags/backlink-popover.ts new file mode 100644 index 0000000000..b79388c7b4 --- /dev/null +++ b/blocksuite/presets/src/fragments/doc-meta-tags/backlink-popover.ts @@ -0,0 +1,148 @@ +import { DualLinkIcon16, scrollbarStyle } from '@blocksuite/blocks'; +import { WithDisposable } from '@blocksuite/global/utils'; +import { baseTheme } from '@toeverything/theme'; +import { css, html, LitElement, unsafeCSS } from 'lit'; +import { state } from 'lit/decorators.js'; + +import { type BacklinkData, DEFAULT_DOC_NAME } from './utils.js'; + +export class BacklinkButton extends WithDisposable(LitElement) { + static override styles = css` + :host { + position: relative; + display: flex; + } + + .btn { + padding: 0 12px; + box-sizing: border-box; + display: inline-flex; + align-items: center; + border: none; + height: 30px; + border-radius: 8px; + gap: 4px; + background: transparent; + cursor: pointer; + + user-select: none; + font-size: var(--affine-font-sm); + font-family: ${unsafeCSS(baseTheme.fontSansFamily)}; + fill: var(--affine-text-secondary-color); + color: var(--affine-text-secondary-color); + pointer-events: auto; + } + + .btn > span { + white-space: nowrap; + text-overflow: ellipsis; + overflow: hidden; + } + + .btn:hover { + background: var(--affine-hover-color); + } + + .btn:active { + background: var(--affine-hover-color); + } + + .backlink-popover { + position: absolute; + left: 0; + bottom: 0; + transform: translateY(100%); + z-index: 1; + padding-top: 8px; + } + + .menu { + display: flex; + flex-direction: column; + padding: 8px 4px; + background: var(--affine-white); + box-shadow: var(--affine-menu-shadow); + border-radius: 12px; + } + + .backlink-popover .group-title { + color: var(--affine-text-secondary-color); + margin: 8px 12px; + } + + .backlink-popover icon-button { + padding: 8px; + justify-content: flex-start; + gap: 8px; + } + + ${scrollbarStyle('.backlink-popover .group')} + `; + + private _backlinks: BacklinkData[]; + + // Handle click outside + private _onClickAway = (e: Event) => { + if (e.target === this) return; + if (!this._showPopover) return; + this._showPopover = false; + document.removeEventListener('mousedown', this._onClickAway); + }; + + constructor(backlinks: BacklinkData[]) { + super(); + + this._backlinks = backlinks; + } + + override connectedCallback() { + super.connectedCallback(); + + this.tabIndex = 0; + } + + onClick() { + this._showPopover = !this._showPopover; + document.addEventListener('mousedown', this._onClickAway); + } + + override render() { + // Only show linked doc backlinks + const backlinks = this._backlinks; + if (!backlinks.length) return null; + + return html` + <div class="btn" @click="${this.onClick}"> + ${DualLinkIcon16}<span>Backlinks (${backlinks.length})</span> + ${this._showPopover ? backlinkPopover(backlinks) : null} + </div> + `; + } + + @state() + private accessor _showPopover = false; +} + +function backlinkPopover(backlinks: BacklinkData[]) { + return html`<div + class="backlink-popover" + @click=${(e: MouseEvent) => e.stopPropagation()} + > + <div class="menu"> + <div class="group-title">Linked to this doc</div> + <div class="group" style="overflow-y: scroll; max-height: 372px;"> + ${backlinks.map(link => { + const title = link.title || DEFAULT_DOC_NAME; + return html`<icon-button + width="248px" + height="32px" + text="${title}" + @mousedown="${link.jump}" + > + ${link.icon} + </icon-button>`; + })} + </div> + </div> + </div>`; +} diff --git a/blocksuite/presets/src/fragments/doc-meta-tags/utils.ts b/blocksuite/presets/src/fragments/doc-meta-tags/utils.ts new file mode 100644 index 0000000000..f5697ee8e7 --- /dev/null +++ b/blocksuite/presets/src/fragments/doc-meta-tags/utils.ts @@ -0,0 +1,19 @@ +import type { AffineTextAttributes } from '@blocksuite/affine-shared/types'; +import type { DocMeta } from '@blocksuite/store'; +import type { TemplateResult } from 'lit'; + +export const DOC_BLOCK_CHILD_PADDING = 24; + +export const DEFAULT_DOC_NAME = 'Untitled'; + +export type BackLink = { + pageId: string; + blockId: string; + type: NonNullable<AffineTextAttributes['reference']>['type']; +}; + +export type BacklinkData = BackLink & + DocMeta & { + jump: () => void; + icon: TemplateResult; + }; diff --git a/blocksuite/presets/src/fragments/doc-title/doc-title.ts b/blocksuite/presets/src/fragments/doc-title/doc-title.ts new file mode 100644 index 0000000000..a404403a88 --- /dev/null +++ b/blocksuite/presets/src/fragments/doc-title/doc-title.ts @@ -0,0 +1,196 @@ +import type { EditorHost } from '@blocksuite/block-std'; +import { ShadowlessElement } from '@blocksuite/block-std'; +import type { RichText, RootBlockModel } from '@blocksuite/blocks'; +import { assertExists, WithDisposable } from '@blocksuite/global/utils'; +import type { Doc } from '@blocksuite/store'; +import { css, html } from 'lit'; +import { property, query, state } from 'lit/decorators.js'; + +const DOC_BLOCK_CHILD_PADDING = 24; + +export class DocTitle extends WithDisposable(ShadowlessElement) { + static override styles = css` + .doc-title-container { + box-sizing: border-box; + font-family: var(--affine-font-family); + font-size: var(--affine-font-base); + line-height: var(--affine-line-height); + color: var(--affine-text-primary-color); + font-size: 40px; + line-height: 50px; + font-weight: 700; + outline: none; + resize: none; + border: 0; + width: 100%; + max-width: var(--affine-editor-width); + margin-left: auto; + margin-right: auto; + padding: 38px 0; + + padding-left: var( + --affine-editor-side-padding, + ${DOC_BLOCK_CHILD_PADDING}px + ); + padding-right: var( + --affine-editor-side-padding, + ${DOC_BLOCK_CHILD_PADDING}px + ); + } + + /* Extra small devices (phones, 640px and down) */ + @container viewport (width <= 640px) { + .doc-title-container { + padding-left: ${DOC_BLOCK_CHILD_PADDING}px; + padding-right: ${DOC_BLOCK_CHILD_PADDING}px; + } + } + + .doc-title-container-empty::before { + content: 'Title'; + color: var(--affine-placeholder-color); + position: absolute; + opacity: 0.5; + pointer-events: none; + } + + .doc-title-container:disabled { + background-color: transparent; + } + `; + + private _onTitleKeyDown = (event: KeyboardEvent) => { + if (event.isComposing || this.doc.readonly) return; + const hasContent = !this.doc.isEmpty; + + if (event.key === 'Enter' && hasContent && !event.isComposing) { + event.preventDefault(); + event.stopPropagation(); + + const inlineEditor = this._inlineEditor; + const inlineRange = inlineEditor?.getInlineRange(); + if (inlineRange) { + const rightText = this._rootModel.title.split(inlineRange.index); + this._pageRoot.prependParagraphWithText(rightText); + } + } else if (event.key === 'ArrowDown' && hasContent) { + event.preventDefault(); + event.stopPropagation(); + this._pageRoot.focusFirstParagraph(); + } else if (event.key === 'Tab') { + event.preventDefault(); + event.stopPropagation(); + } + }; + + private _updateTitleInMeta = () => { + this.doc.collection.setDocMeta(this.doc.id, { + title: this._rootModel.title.toString(), + }); + }; + + private get _inlineEditor() { + return this._richTextElement.inlineEditor; + } + + private get _pageRoot() { + const pageRoot = this._viewport.querySelector('affine-page-root'); + assertExists(pageRoot); + return pageRoot; + } + + private get _rootModel() { + return this.doc.root as RootBlockModel; + } + + private get _viewport() { + const el = this.closest<HTMLElement>('.affine-page-viewport'); + assertExists(el); + return el; + } + + override connectedCallback() { + super.connectedCallback(); + + this._isReadonly = this.doc.readonly; + this._disposables.add( + this.doc.awarenessStore.slots.update.on(() => { + if (this._isReadonly !== this.doc.readonly) { + this._isReadonly = this.doc.readonly; + this.requestUpdate(); + } + }) + ); + + this._disposables.addFromEvent(this, 'keydown', this._onTitleKeyDown); + + // Workaround for inline editor skips composition event + this._disposables.addFromEvent( + this, + 'compositionstart', + () => (this._isComposing = true) + ); + + this._disposables.addFromEvent( + this, + 'compositionend', + () => (this._isComposing = false) + ); + + const updateMetaTitle = () => { + this._updateTitleInMeta(); + this.requestUpdate(); + }; + this._rootModel.title.yText.observe(updateMetaTitle); + this._disposables.add(() => { + this._rootModel.title.yText.unobserve(updateMetaTitle); + }); + } + + override render() { + const isEmpty = !this._rootModel.title.length && !this._isComposing; + + return html` + <div + class="doc-title-container ${isEmpty + ? 'doc-title-container-empty' + : ''}" + data-block-is-title="true" + > + <rich-text + .yText=${this._rootModel.title.yText} + .undoManager=${this.doc.history} + .verticalScrollContainerGetter=${() => this._viewport} + .readonly=${this.doc.readonly} + .enableFormat=${false} + ></rich-text> + </div> + `; + } + + @state() + private accessor _isComposing = false; + + @state() + private accessor _isReadonly = false; + + @query('rich-text') + private accessor _richTextElement!: RichText; + + @property({ attribute: false }) + accessor doc!: Doc; +} + +export function getDocTitleByEditorHost( + editorHost: EditorHost +): DocTitle | null { + const docViewport = editorHost.closest('.affine-page-viewport'); + if (!docViewport) return null; + return docViewport.querySelector('doc-title'); +} + +declare global { + interface HTMLElementTagNameMap { + 'doc-title': DocTitle; + } +} diff --git a/blocksuite/presets/src/fragments/frame-panel/body/frame-panel-body.ts b/blocksuite/presets/src/fragments/frame-panel/body/frame-panel-body.ts new file mode 100644 index 0000000000..cd90e8ec43 --- /dev/null +++ b/blocksuite/presets/src/fragments/frame-panel/body/frame-panel-body.ts @@ -0,0 +1,439 @@ +import { type EditorHost, ShadowlessElement } from '@blocksuite/block-std'; +import { generateKeyBetweenV2 } from '@blocksuite/block-std/gfx'; +import { + DocModeProvider, + EdgelessFrameManager, + EdgelessRootService, + EditPropsStore, + type FrameBlockModel, +} from '@blocksuite/blocks'; +import { + Bound, + DisposableGroup, + SignalWatcher, + WithDisposable, +} from '@blocksuite/global/utils'; +import type { Doc } from '@blocksuite/store'; +import { css, html, nothing, type PropertyValues } from 'lit'; +import { property, query, state } from 'lit/decorators.js'; +import { keyed } from 'lit/directives/keyed.js'; +import { repeat } from 'lit/directives/repeat.js'; + +import type { + DragEvent, + FitViewEvent, + FrameCard, + SelectEvent, +} from '../card/frame-card.js'; +import { startDragging } from '../utils/drag.js'; + +const compare = EdgelessFrameManager.framePresentationComparator; + +type FrameListItem = { + frame: FrameBlockModel; + + // frame index + frameIndex: string; + + // card index + cardIndex: number; +}; + +const styles = css` + .frame-list-container { + display: flex; + align-items: start; + box-sizing: border-box; + flex-direction: column; + width: 100%; + gap: 16px; + position: relative; + margin: 0 8px; + } + + .no-frame-container { + display: flex; + flex-direction: column; + width: 100%; + min-width: 300px; + } + + .no-frame-placeholder { + margin-top: 240px; + align-self: center; + width: 230px; + height: 48px; + color: var(--affine-text-secondary-color, #8e8d91); + text-align: center; + + /* light/base */ + font-size: 15px; + font-style: normal; + font-weight: 400; + line-height: 24px; + } + + .insert-indicator { + height: 2px; + border-radius: 1px; + background-color: var(--affine-blue-600); + position: absolute; + contain: layout size; + width: 284px; + left: 0; + } +`; + +export const AFFINE_FRAME_PANEL_BODY = 'affine-frame-panel-body'; + +export class FramePanelBody extends SignalWatcher( + WithDisposable(ShadowlessElement) +) { + static override styles = styles; + + private _clearDocDisposables = () => { + this._docDisposables?.dispose(); + this._docDisposables = null; + }; + + /** + * click at blank area to clear selection + */ + private _clickBlank = (e: MouseEvent) => { + e.stopPropagation(); + // check if click at frame-card, if not, set this._selected to empty + if ( + (e.target as HTMLElement).closest('frame-card') || + this._selected.length === 0 + ) { + return; + } + + this._selected = []; + this._edgelessRootService?.selection.set({ + elements: this._selected, + editing: false, + }); + }; + + private _docDisposables: DisposableGroup | null = null; + + private _frameElementHeight = 0; + + private _frameItems: FrameListItem[] = []; + + private _indicatorTranslateY = 0; + + private _lastEdgelessRootId = ''; + + private _selectFrame = (e: SelectEvent) => { + const { selected, id, multiselect } = e.detail; + + if (!selected) { + // de-select frame + this._selected = this._selected.filter(frameId => frameId !== id); + } else if (multiselect) { + this._selected = [...this._selected, id]; + } else { + this._selected = [id]; + } + + this._edgelessRootService?.selection.set({ + elements: this._selected, + editing: false, + }); + }; + + private _updateFrameItems = () => { + this._frameItems = this.frames.map((frame, idx) => ({ + frame, + frameIndex: frame.presentationIndex ?? frame.index, + cardIndex: idx, + })); + }; + + get _edgelessRootService() { + return this.editorHost.std.getOptional(EdgelessRootService); + } + + get frames() { + const frames = this.editorHost.doc + .getBlocksByFlavour('affine:frame') + .map(block => block.model as FrameBlockModel); + return frames.sort(compare); + } + + get viewportPadding(): [number, number, number, number] { + return this.fitPadding + ? ([0, 0, 0, 0].map((val, idx) => + Number.isFinite(this.fitPadding[idx]) ? this.fitPadding[idx] : val + ) as [number, number, number, number]) + : [0, 0, 0, 0]; + } + + private _drag(e: DragEvent) { + if (!this._selected.length) return; + + this._dragging = true; + + const framesMap = this._frameItems.reduce((map, frame) => { + map.set(frame.frame.id, { + ...frame, + }); + return map; + }, new Map<string, FrameListItem>()); + const selected = this._selected.slice(); + + const draggedFramesInfo = selected.map(id => { + const frame = framesMap.get(id) as FrameListItem; + + return { + frame: frame.frame, + element: this.renderRoot.querySelector( + `[data-frame-id="${frame.frame.id}"]` + ) as FrameCard, + cardIndex: frame.cardIndex, + frameIndex: frame.frameIndex, + }; + }); + const width = draggedFramesInfo[0].element.clientWidth; + + this._frameElementHeight = draggedFramesInfo[0].element.offsetHeight; + + startDragging(draggedFramesInfo, { + width, + container: this, + document: this.ownerDocument, + domHost: this.domHost ?? this.ownerDocument, + start: { + x: e.detail.clientX, + y: e.detail.clientY, + }, + framePanelBody: this, + frameListContainer: this.frameListContainer, + frameElementHeight: this._frameElementHeight, + onDragEnd: insertIdx => { + this._dragging = false; + this.insertIndex = undefined; + + if (insertIdx === undefined || this._frameItems.length <= 1) return; + this._reorderFrames(selected, framesMap, insertIdx); + }, + onDragMove: (idx, indicatorTranslateY) => { + this.insertIndex = idx; + this._indicatorTranslateY = indicatorTranslateY ?? 0; + }, + }); + } + + private _fitToElement(e: FitViewEvent) { + const { block } = e.detail; + const bound = Bound.deserialize(block.xywh); + + if (!this._edgelessRootService) { + // When click frame card in page mode + // Should switch to edgeless mode and set viewport to the frame + const viewport = { + xywh: block.xywh, + referenceId: block.id, + padding: this.viewportPadding as [number, number, number, number], + }; + + this.editorHost.std.get(EditPropsStore).setStorage('viewport', viewport); + this.editorHost.std.get(DocModeProvider).setEditorMode('edgeless'); + } else { + this._edgelessRootService.viewport.setViewportByBound( + bound, + this.viewportPadding, + true + ); + } + } + + private _renderEmptyContent() { + const emptyContent = html` <div class="no-frame-container"> + <div class="no-frame-placeholder"> + Add frames to organize and present your Edgeless + </div> + </div>`; + + return emptyContent; + } + + private _renderFrameList() { + const selectedFrames = new Set(this._selected); + const frameCards = html`${repeat(this._frameItems, frameItem => { + const { frame, frameIndex, cardIndex } = frameItem; + return keyed( + frame, + html`<affine-frame-card + data-frame-id=${frame.id} + .frame=${frame} + .cardIndex=${cardIndex} + .frameIndex=${frameIndex} + .status=${selectedFrames.has(frame.id) + ? this._dragging + ? 'placeholder' + : 'selected' + : 'none'} + @select=${this._selectFrame} + @fitview=${this._fitToElement} + @drag=${this._drag} + ></affine-frame-card>` + ); + })}`; + + const frameList = html` <div class="frame-list-container"> + ${this.insertIndex !== undefined + ? html`<div + class="insert-indicator" + style=${`transform: translateY(${this._indicatorTranslateY}px)`} + ></div>` + : nothing} + ${frameCards} + </div>`; + return frameList; + } + + private _reorderFrames( + selected: string[], + framesMap: Map<string, FrameListItem>, + insertIndex: number + ) { + if (insertIndex >= 0 && insertIndex <= this._frameItems.length) { + const frames = Array.from(framesMap.values()).map( + frameItem => frameItem.frame + ); + const selectedFrames = selected + .map(id => framesMap.get(id) as FrameListItem) + .map(frameItem => frameItem.frame) + .sort(compare); + + // update selected frames index + // make the indexes larger than the frame before and smaller than the frame after + let before = frames[insertIndex - 1]?.presentationIndex || null; + const after = frames[insertIndex]?.presentationIndex || null; + selectedFrames.forEach(frame => { + const newIndex = generateKeyBetweenV2(before, after); + frame.doc.updateBlock(frame, { + presentationIndex: newIndex, + }); + before = newIndex; + }); + + this.editorHost.doc.captureSync(); + this._updateFrames(); + } + } + + private _setDocDisposables(doc: Doc) { + this._clearDocDisposables(); + this._docDisposables = new DisposableGroup(); + this._docDisposables.add( + doc.slots.blockUpdated.on(({ type, flavour }) => { + if (flavour === 'affine:frame' && type !== 'update') { + requestAnimationFrame(() => { + this._updateFrames(); + }); + } + }) + ); + } + + private _updateFrames() { + if (this._dragging) return; + + if (!this.frames.length) { + this._selected = []; + this._frameItems = []; + return; + } + + const frameItems: FramePanelBody['_frameItems'] = []; + const oldSelectedSet = new Set(this._selected); + const newSelected: string[] = []; + const frames = this.frames.sort(compare); + frames.forEach((frame, idx) => { + const frameItem = { + frame, + frameIndex: frame.presentationIndex ?? frame.index, + cardIndex: idx, + }; + + frameItems.push(frameItem); + if (oldSelectedSet.has(frame.id)) { + newSelected.push(frame.id); + } + }); + + this._frameItems = frameItems; + this._selected = newSelected; + this.requestUpdate(); + } + + override connectedCallback() { + super.connectedCallback(); + this._updateFrameItems(); + } + + override disconnectedCallback() { + super.disconnectedCallback(); + this._clearDocDisposables(); + } + + override firstUpdated() { + const disposables = this.disposables; + disposables.addFromEvent(this, 'click', this._clickBlank); + } + + override render() { + this._updateFrameItems(); + return html` ${this._frameItems.length + ? this._renderFrameList() + : this._renderEmptyContent()}`; + } + + override updated(_changedProperties: PropertyValues) { + if (_changedProperties.has('editorHost') && this.editorHost) { + this._setDocDisposables(this.editorHost.doc); + // after switch to edgeless mode, should update the selection + if (this.editorHost.doc.id === this._lastEdgelessRootId) { + this._edgelessRootService?.selection.set({ + elements: this._selected, + editing: false, + }); + } else { + this._selected = this._selected.length ? [] : this._selected; + } + this._lastEdgelessRootId = this.editorHost.doc.id; + } + } + + @state() + private accessor _dragging = false; + + // Store the ids of the selected frames + @state() + private accessor _selected: string[] = []; + + @property({ attribute: false }) + accessor domHost!: Document | HTMLElement; + + @property({ attribute: false }) + accessor editorHost!: EditorHost; + + @property({ attribute: false }) + accessor fitPadding!: number[]; + + @query('.frame-list-container') + accessor frameListContainer!: HTMLElement; + + @property({ attribute: false }) + accessor insertIndex: number | undefined = undefined; +} + +declare global { + interface HTMLElementTagNameMap { + [AFFINE_FRAME_PANEL_BODY]: FramePanelBody; + } +} diff --git a/blocksuite/presets/src/fragments/frame-panel/card/frame-card-title-editor.ts b/blocksuite/presets/src/fragments/frame-panel/card/frame-card-title-editor.ts new file mode 100644 index 0000000000..7974857cb6 --- /dev/null +++ b/blocksuite/presets/src/fragments/frame-panel/card/frame-card-title-editor.ts @@ -0,0 +1,124 @@ +import { ShadowlessElement } from '@blocksuite/block-std'; +import type { FrameBlockModel, RichText } from '@blocksuite/blocks'; +import { WithDisposable } from '@blocksuite/global/utils'; +import { css, html } from 'lit'; +import { property, query } from 'lit/decorators.js'; +import { styleMap } from 'lit/directives/style-map.js'; + +const styles = css` + frame-card-title-editor rich-text .nowrap-lines::-webkit-scrollbar { + display: none; + } +`; + +export const AFFINE_FRAME_TITLE_EDITOR = 'affine-frame-card-title-editor'; + +export class FrameCardTitleEditor extends WithDisposable(ShadowlessElement) { + static override styles = styles; + + private _isComposing = false; + + get inlineEditor() { + return this.richText.inlineEditor; + } + + private _unmount() { + // dispose in advance to avoid execute `this.remove()` twice + this.disposables.dispose(); + this.remove(); + this.titleContentElement.style.display = 'block'; + } + + override firstUpdated(): void { + this.updateComplete + .then(() => { + if (this.inlineEditor === null) return; + + this.titleContentElement.style.display = 'none'; + + this.inlineEditor.selectAll(); + + this.inlineEditor.slots.renderComplete.on(() => { + this.requestUpdate(); + }); + + const inlineEditorContainer = this.inlineEditor.rootElement; + + this.disposables.addFromEvent(inlineEditorContainer, 'blur', () => { + this._unmount(); + }); + this.disposables.addFromEvent(inlineEditorContainer, 'click', e => { + e.stopPropagation(); + }); + this.disposables.addFromEvent(inlineEditorContainer, 'dblclick', e => { + e.stopPropagation(); + }); + + this.disposables.addFromEvent(inlineEditorContainer, 'keydown', e => { + e.stopPropagation(); + if (e.key === 'Enter' && !this._isComposing) { + this._unmount(); + } + }); + }) + .catch(console.error); + } + + override async getUpdateComplete(): Promise<boolean> { + const result = await super.getUpdateComplete(); + await this.richText?.updateComplete; + return result; + } + + override render() { + const inlineEditorStyle = styleMap({ + transformOrigin: 'top left', + borderRadius: '4px', + maxWidth: `${this.maxWidth}px`, + maxHeight: '20px', + width: 'fit-content', + height: '20px', + fontSize: 'var(--affine-font-sm)', + lineHeight: '20px', + position: 'absolute', + left: `${this.left}px`, + top: '0px', + minWidth: '8px', + background: 'var(--affine-background-primary-color)', + border: '1px solid var(--affine-primary-color)', + color: 'var(--affine-text-primary-color)', + boxShadow: '0px 0px 0px 2px rgba(30, 150, 235, 0.30)', + zIndex: '1', + display: 'block', + }); + return html`<rich-text + .yText=${this.frameModel.title.yText} + .enableFormat=${false} + .enableAutoScrollHorizontally=${true} + .enableUndoRedo=${false} + .wrapText=${false} + style=${inlineEditorStyle} + ></rich-text>`; + } + + @property({ attribute: false }) + accessor frameModel!: FrameBlockModel; + + @property({ attribute: false }) + accessor left!: number; + + @property({ attribute: false }) + accessor maxWidth!: number; + + @query('rich-text') + accessor richText!: RichText; + + @property({ attribute: false }) + accessor titleContentElement!: HTMLElement; +} + +declare global { + interface HTMLElementTagNameMap { + [AFFINE_FRAME_TITLE_EDITOR]: FrameCardTitleEditor; + } +} diff --git a/blocksuite/presets/src/fragments/frame-panel/card/frame-card-title.ts b/blocksuite/presets/src/fragments/frame-panel/card/frame-card-title.ts new file mode 100644 index 0000000000..de93d03f83 --- /dev/null +++ b/blocksuite/presets/src/fragments/frame-panel/card/frame-card-title.ts @@ -0,0 +1,150 @@ +import { ShadowlessElement } from '@blocksuite/block-std'; +import type { FrameBlockModel } from '@blocksuite/blocks'; +import { DisposableGroup, WithDisposable } from '@blocksuite/global/utils'; +import type { Y } from '@blocksuite/store'; +import { css, html, type PropertyValues } from 'lit'; +import { property, query } from 'lit/decorators.js'; + +import { FrameCardTitleEditor } from './frame-card-title-editor.js'; + +const styles = css` + .frame-card-title-container { + display: flex; + white-space: nowrap; + display: flex; + justify-content: start; + align-items: center; + width: 100%; + height: 20px; + box-sizing: border-box; + gap: 6px; + font-size: var(--affine-font-sm); + cursor: default; + position: relative; + } + + .frame-card-title-container .card-index { + display: flex; + align-self: center; + align-items: center; + justify-content: center; + min-width: 18px; + height: 18px; + box-sizing: border-box; + border-radius: 2px; + background: var(--affine-black); + margin-left: 2px; + + color: var(--affine-white); + text-align: center; + font-weight: 500; + line-height: 18px; + } + + .frame-card-title-container .card-title { + height: 20px; + color: var(--affine-text-primary-color); + font-weight: 400; + line-height: 20px; + + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + flex: 1; + } +`; + +export const AFFINE_FRAME_CARD_TITLE = 'affine-frame-card-title'; + +export class FrameCardTitle extends WithDisposable(ShadowlessElement) { + static override styles = styles; + + private _clearTitleDisposables = () => { + this._titleDisposables?.dispose(); + this._titleDisposables = null; + }; + + private _mountTitleEditor = (e: MouseEvent) => { + e.stopPropagation(); + + const titleEditor = new FrameCardTitleEditor(); + titleEditor.frameModel = this.frame; + titleEditor.titleContentElement = this.titleContentElement; + const left = this.titleIndexElement.offsetWidth + 6; + titleEditor.left = left; + titleEditor.maxWidth = this.titleContainer.offsetWidth - left - 6; + this.titleContainer.append(titleEditor); + }; + + private _titleDisposables: DisposableGroup | null = null; + + private _updateElement = () => { + this.requestUpdate(); + }; + + private _setFrameDisposables(title: Y.Text) { + this._clearTitleDisposables(); + title.observe(this._updateElement); + this._titleDisposables = new DisposableGroup(); + this._titleDisposables.add({ + dispose: () => { + title.unobserve(this._updateElement); + }, + }); + } + + override connectedCallback() { + super.connectedCallback(); + } + + override disconnectedCallback() { + super.disconnectedCallback(); + this._clearTitleDisposables(); + } + + override render() { + return html`<div class="frame-card-title-container"> + <div + class="card-index" + @click=${(e: MouseEvent) => e.stopPropagation()} + @dblclick=${(e: MouseEvent) => e.stopPropagation()} + > + ${this.cardIndex + 1} + </div> + <div class="card-title"> + <span + @click=${(e: MouseEvent) => e.stopPropagation()} + @dblclick=${this._mountTitleEditor} + >${this.frame.title}</span + > + </div> + </div>`; + } + + override updated(_changedProperties: PropertyValues) { + if (_changedProperties.has('frame')) { + this._setFrameDisposables(this.frame.title.yText); + } + } + + @property({ attribute: false }) + accessor cardIndex!: number; + + @property({ attribute: false }) + accessor frame!: FrameBlockModel; + + @query('.frame-card-title-container') + accessor titleContainer!: HTMLElement; + + @query('.frame-card-title-container .card-title') + accessor titleContentElement!: HTMLElement; + + @query('.frame-card-title-container .card-index') + accessor titleIndexElement!: HTMLElement; +} + +declare global { + interface HTMLElementTagNameMap { + [AFFINE_FRAME_CARD_TITLE]: FrameCardTitle; + } +} diff --git a/blocksuite/presets/src/fragments/frame-panel/card/frame-card.ts b/blocksuite/presets/src/fragments/frame-panel/card/frame-card.ts new file mode 100644 index 0000000000..636086ffbf --- /dev/null +++ b/blocksuite/presets/src/fragments/frame-panel/card/frame-card.ts @@ -0,0 +1,247 @@ +import { ShadowlessElement } from '@blocksuite/block-std'; +import { type FrameBlockModel, on, once } from '@blocksuite/blocks'; +import { WithDisposable } from '@blocksuite/global/utils'; +import { css, html, nothing } from 'lit'; +import { property, query } from 'lit/decorators.js'; +import { styleMap } from 'lit/directives/style-map.js'; + +export type ReorderEvent = CustomEvent<{ + currentNumber: number; + targetNumber: number; + realIndex: number; +}>; + +export type SelectEvent = CustomEvent<{ + id: string; + selected: boolean; + index: number; + multiselect: boolean; +}>; + +export type DragEvent = CustomEvent<{ + clientX: number; + clientY: number; + pageX: number; + pageY: number; +}>; + +export type FitViewEvent = CustomEvent<{ + block: FrameBlockModel; +}>; + +const styles = css` + :host { + display: block; + } + + .frame-card-container { + display: flex; + flex-direction: column; + width: 284px; + height: 198px; + gap: 8px; + + position: relative; + } + + .frame-card-body { + display: flex; + width: 100%; + height: 170px; + box-sizing: border-box; + justify-content: center; + align-items: center; + border-radius: 8px; + border: 1px solid var(--affine-border-color); + background: var(--affine-background-primary-color); + box-shadow: 0px 0px 12px 0px rgba(66, 65, 73, 0.18); + cursor: pointer; + position: relative; + } + + .frame-card-container.selected .frame-card-body { + border: 2px solid var(--light-brand-color, #1e96eb); + } + + .frame-card-container.dragging { + pointer-events: none; + transform-origin: 16px 8px; + position: fixed; + top: 0; + left: 0; + z-index: calc(var(--affine-z-index-popover, 0) + 3); + } + + .frame-card-container.dragging frame-card-title { + display: none; + } + + .frame-card-container.dragging .dragging-card-number { + position: absolute; + bottom: 0; + left: 0; + transform: translate(-30%, 30%); + + display: flex; + align-items: center; + justify-content: center; + min-width: 24px; + height: 24px; + border-radius: 50%; + background: var(--affine-black); + color: var(--affine-white); + font-size: 15px; + line-height: 24px; + text-align: center; + font-weight: 400; + } + + .frame-card-container.placeholder { + opacity: 0.5; + } +`; + +export const AFFINE_FRAME_CARD = 'affine-frame-card'; + +export class FrameCard extends WithDisposable(ShadowlessElement) { + static override styles = styles; + + private _dispatchDragEvent(e: MouseEvent) { + e.preventDefault(); + if (e.button !== 0) return; + + const { clientX: startX, clientY: startY } = e; + const disposeDragStart = on(this.ownerDocument, 'mousemove', e => { + if ( + Math.abs(startX - e.clientX) < 5 && + Math.abs(startY - e.clientY) < 5 + ) { + return; + } + if (this.status !== 'selected') { + this._dispatchSelectEvent(e); + } + + const event = new CustomEvent('drag', { + detail: { + clientX: e.clientX, + clientY: e.clientY, + pageX: e.pageX, + pageY: e.pageY, + }, + }); + + this.dispatchEvent(event); + disposeDragStart(); + }); + + once(this.ownerDocument, 'mouseup', () => { + disposeDragStart(); + }); + } + + private _dispatchFitViewEvent(e: MouseEvent) { + e.stopPropagation(); + + const event = new CustomEvent('fitview', { + detail: { + block: this.frame, + }, + }); + + this.dispatchEvent(event); + } + + private _dispatchSelectEvent(e: MouseEvent) { + e.stopPropagation(); + const event = new CustomEvent('select', { + detail: { + id: this.frame.id, + selected: this.status !== 'selected', + index: this.cardIndex, + multiselect: e.shiftKey, + }, + }) as SelectEvent; + + this.dispatchEvent(event); + } + + private _DraggingCardNumber() { + if (this.draggingCardNumber === undefined) return nothing; + + return html`<div class="dragging-card-number"> + ${this.draggingCardNumber} + </div>`; + } + + override render() { + const { pos, stackOrder, width } = this; + const containerStyle = + this.status === 'dragging' + ? styleMap({ + transform: `${ + stackOrder === 0 + ? `translate(${pos.x - 16}px, ${pos.y - 8}px)` + : `translate(${pos.x - 10}px, ${pos.y - 16}px) scale(0.96)` + }`, + width: width ? `${width}px` : undefined, + }) + : {}; + + return html`<div + class="frame-card-container ${this.status ?? ''}" + style=${containerStyle} + > + ${this.status === 'dragging' + ? nothing + : html`<affine-frame-card-title + .cardIndex=${this.cardIndex} + .frame=${this.frame} + ></affine-frame-card-title>`} + <div + class="frame-card-body" + @click=${this._dispatchSelectEvent} + @dblclick=${this._dispatchFitViewEvent} + @mousedown=${this._dispatchDragEvent} + > + ${this.status === 'dragging' && stackOrder !== 0 + ? nothing + : html`<frame-preview .frame=${this.frame}></frame-preview>`} + ${this._DraggingCardNumber()} + </div> + </div>`; + } + + @property({ attribute: false }) + accessor cardIndex!: number; + + @query('.frame-card-container') + accessor containerElement!: HTMLElement; + + @property({ attribute: false }) + accessor draggingCardNumber: number | undefined = undefined; + + @property({ attribute: false }) + accessor frame!: FrameBlockModel; + + @property({ attribute: false }) + accessor frameIndex!: string; + + @property({ attribute: false }) + accessor pos!: { x: number; y: number }; + + @property({ attribute: false }) + accessor stackOrder!: number; + + @property({ attribute: false }) + accessor status: 'selected' | 'dragging' | 'placeholder' | 'none' = 'none'; + + @property({ attribute: false }) + accessor width: number | undefined = undefined; +} + +declare global { + interface HTMLElementTagNameMap { + [AFFINE_FRAME_CARD]: FrameCard; + } +} diff --git a/blocksuite/presets/src/fragments/frame-panel/frame-panel.ts b/blocksuite/presets/src/fragments/frame-panel/frame-panel.ts new file mode 100644 index 0000000000..a090c8c8af --- /dev/null +++ b/blocksuite/presets/src/fragments/frame-panel/frame-panel.ts @@ -0,0 +1,88 @@ +import { type EditorHost, ShadowlessElement } from '@blocksuite/block-std'; +import { WithDisposable } from '@blocksuite/global/utils'; +import { baseTheme } from '@toeverything/theme'; +import { css, html, unsafeCSS } from 'lit'; +import { property } from 'lit/decorators.js'; + +const styles = css` + frame-panel { + display: block; + width: 100%; + height: 100%; + } + + .frame-panel-container { + background-color: var(--affine-background-primary-color); + box-sizing: border-box; + + display: flex; + flex-direction: column; + align-items: stretch; + + height: 100%; + font-family: ${unsafeCSS(baseTheme.fontSansFamily)}; + padding: 8px; + } + + .frame-panel-body { + padding-top: 12px; + flex-grow: 1; + width: 100%; + + overflow: auto; + overflow-x: hidden; + scrollbar-width: thin; /* For Firefox */ + scrollbar-color: transparent transparent; /* For Firefox */ + } + + .frame-panel-body::-webkit-scrollbar { + width: 4px; + } + + .frame-panel-body::-webkit-scrollbar-thumb { + border-radius: 2px; + } + + .frame-panel-body:hover::-webkit-scrollbar-thumb { + background-color: var(--affine-black-30); + } + + .frame-panel-body::-webkit-scrollbar-track { + background-color: transparent; + } + + .frame-panel-body::-webkit-scrollbar-corner { + display: none; + } +`; + +export const AFFINE_FRAME_PANEL = 'affine-frame-panel'; + +export class FramePanel extends WithDisposable(ShadowlessElement) { + static override styles = styles; + + override render() { + return html`<div class="frame-panel-container"> + <affine-frame-panel-header + .editorHost=${this.host} + ></affine-frame-panel-header> + <affine-frame-panel-body + class="frame-panel-body" + .editorHost=${this.host} + .fitPadding=${this.fitPadding} + ></affine-frame-panel-body> + </div>`; + } + + @property({ attribute: false }) + accessor fitPadding: number[] = [50, 380, 50, 50]; + + @property({ attribute: false }) + accessor host!: EditorHost; +} + +declare global { + interface HTMLElementTagNameMap { + [AFFINE_FRAME_PANEL]: FramePanel; + } +} diff --git a/blocksuite/presets/src/fragments/frame-panel/header/frame-panel-header.ts b/blocksuite/presets/src/fragments/frame-panel/header/frame-panel-header.ts new file mode 100644 index 0000000000..a74881b293 --- /dev/null +++ b/blocksuite/presets/src/fragments/frame-panel/header/frame-panel-header.ts @@ -0,0 +1,246 @@ +import type { EditorHost } from '@blocksuite/block-std'; +import { + createButtonPopper, + DocModeProvider, + EdgelessRootService, + EditPropsStore, + type NavigatorMode, +} from '@blocksuite/blocks'; +import { DisposableGroup, WithDisposable } from '@blocksuite/global/utils'; +import { css, html, LitElement, type PropertyValues } from 'lit'; +import { property, query, state } from 'lit/decorators.js'; + +import { SettingsIcon, SmallFrameNavigatorIcon } from '../../_common/icons.js'; + +const styles = css` + :host { + display: flex; + width: 100%; + justify-content: start; + } + + .frame-panel-header { + display: flex; + width: 100%; + height: 36px; + align-items: center; + justify-content: space-between; + box-sizing: border-box; + padding: 0 8px; + } + + .all-frames-setting { + display: flex; + align-items: center; + gap: 8px; + width: 100px; + height: 24px; + margin: 8px 4px; + } + + .all-frames-setting-button svg { + color: var(--affine-icon-secondary); + } + + .all-frames-setting-button:hover svg, + .all-frames-setting-button.active svg { + color: var(--affine-icon-color); + } + + .all-frames-setting-label { + width: 68px; + height: 22px; + font-size: var(--affine-font-sm); + font-weight: 500; + line-height: 22px; + color: var(--light-text-color-text-secondary-color, #8e8d91); + } + + .frames-setting-container { + display: none; + justify-content: center; + align-items: center; + background: var(--affine-background-overlay-panel-color); + box-shadow: var(--affine-shadow-2); + border-radius: 8px; + } + + .frames-setting-container[data-show] { + display: flex; + } + + .presentation-button { + display: flex; + align-items: center; + gap: 4px; + box-sizing: border-box; + width: 117px; + height: 28px; + padding: 4px 8px; + border-radius: 8px; + margin: 4px 0; + border: 1px solid var(--affine-border-color); + background: var(--affine-white); + } + + .presentation-button:hover { + background: var(--affine-hover-color); + cursor: pointer; + } + + .presentation-button svg { + fill: var(--affine-icon-color); + margin-right: 4px; + } + + .presentation-button-label { + font-size: 12px; + font-weight: 500; + line-height: 20px; + } +`; + +export const AFFINE_FRAME_PANEL_HEADER = 'affine-frame-panel-header'; + +export class FramePanelHeader extends WithDisposable(LitElement) { + static override styles = styles; + + private _clearEdgelessDisposables = () => { + this._edgelessDisposables?.dispose(); + this._edgelessDisposables = null; + }; + + private _edgelessDisposables: DisposableGroup | null = null; + + private _enterPresentationMode = () => { + if (!this._edgelessRootService) { + this.editorHost.std.get(DocModeProvider).setEditorMode('edgeless'); + } + + setTimeout(() => { + this._edgelessRootService?.gfx.tool.setTool({ + type: 'frameNavigator', + mode: this._navigatorMode, + }); + }, 100); + }; + + private _framesSettingMenuPopper: ReturnType< + typeof createButtonPopper + > | null = null; + + private _navigatorMode: NavigatorMode = 'fit'; + + private _setEdgelessDisposables = () => { + if (!this._edgelessRootService) return; + + this._clearEdgelessDisposables(); + this._edgelessDisposables = new DisposableGroup(); + this._edgelessDisposables.add( + this._edgelessRootService.slots.navigatorSettingUpdated.on( + ({ fillScreen }) => { + this._navigatorMode = fillScreen ? 'fill' : 'fit'; + } + ) + ); + }; + + private get _edgelessRootService() { + return this.editorHost.std.getOptional(EdgelessRootService); + } + + private _tryLoadNavigatorStateLocalRecord() { + this._navigatorMode = this.editorHost.std + .get(EditPropsStore) + .getStorage('presentFillScreen') + ? 'fill' + : 'fit'; + } + + override connectedCallback() { + super.connectedCallback(); + this._tryLoadNavigatorStateLocalRecord(); + } + + override disconnectedCallback() { + super.disconnectedCallback(); + if (this._edgelessDisposables) { + this._clearEdgelessDisposables(); + } + } + + override firstUpdated() { + const disposables = this.disposables; + + this._framesSettingMenuPopper = createButtonPopper( + this._frameSettingButton, + this._frameSettingMenu, + ({ display }) => { + this._settingPopperShow = display === 'show'; + }, + { + mainAxis: 14, + crossAxis: -100, + } + ); + disposables.add(this._framesSettingMenuPopper); + } + + override render() { + return html`<div class="frame-panel-header"> + <div class="all-frames-setting"> + <span class="all-frames-setting-label">All frames</span> + <edgeless-tool-icon-button + class="all-frames-setting-button ${this._settingPopperShow + ? 'active' + : ''}" + .tooltip=${this._settingPopperShow ? '' : 'All Frames Settings'} + .tipPosition=${'top'} + .active=${this._settingPopperShow} + .activeMode=${'background'} + @click=${() => this._framesSettingMenuPopper?.toggle()} + > + ${SettingsIcon} + </edgeless-tool-icon-button> + </div> + <div class="frames-setting-container"> + <affine-frames-setting-menu + .editorHost=${this.editorHost} + ></affine-frames-setting-menu> + </div> + <div class="presentation-button" @click=${this._enterPresentationMode}> + ${SmallFrameNavigatorIcon}<span class="presentation-button-label" + >Presentation</span + > + </div> + </div>`; + } + + override updated(_changedProperties: PropertyValues) { + if (_changedProperties.has('editorHost')) { + if (this._edgelessRootService) { + this._setEdgelessDisposables(); + } else { + this._clearEdgelessDisposables(); + } + } + } + + @query('.all-frames-setting-button') + private accessor _frameSettingButton!: HTMLDivElement; + + @query('.frames-setting-container') + private accessor _frameSettingMenu!: HTMLDivElement; + + @state() + private accessor _settingPopperShow = false; + + @property({ attribute: false }) + accessor editorHost!: EditorHost; +} + +declare global { + interface HTMLElementTagNameMap { + [AFFINE_FRAME_PANEL_HEADER]: FramePanelHeader; + } +} diff --git a/blocksuite/presets/src/fragments/frame-panel/header/frames-setting-menu.ts b/blocksuite/presets/src/fragments/frame-panel/header/frames-setting-menu.ts new file mode 100644 index 0000000000..ba6d0ff734 --- /dev/null +++ b/blocksuite/presets/src/fragments/frame-panel/header/frames-setting-menu.ts @@ -0,0 +1,214 @@ +import type { EditorHost } from '@blocksuite/block-std'; +import { EdgelessRootService, EditPropsStore } from '@blocksuite/blocks'; +import { WithDisposable } from '@blocksuite/global/utils'; +import { css, html, LitElement, type PropertyValues } from 'lit'; +import { property, state } from 'lit/decorators.js'; + +const styles = css` + :host { + display: block; + box-sizing: border-box; + padding: 8px; + width: 220px; + } + + .frames-setting-menu-container { + display: flex; + flex-direction: column; + box-sizing: border-box; + width: 100%; + } + + .frames-setting-menu-item { + display: flex; + box-sizing: border-box; + width: 100%; + height: 28px; + padding: 4px 12px; + align-items: center; + } + + .frames-setting-menu-item .setting-label { + font-size: 12px; + font-weight: 500; + line-height: 20px; + color: var(--affine-text-secondary-color); + padding: 0 4px; + } + + .frames-setting-menu-divider { + width: 100%; + height: 1px; + box-sizing: border-box; + background: var(--affine-border-color); + margin: 8px 0; + } + + .frames-setting-menu-item.action { + gap: 4px; + } + + .frames-setting-menu-item .action-label { + width: 138px; + height: 20px; + padding: 0 4px; + font-size: 12px; + font-weight: 500; + line-height: 20px; + color: var(--affine-text-primary-color); + } + + .frames-setting-menu-item .toggle-button { + display: flex; + } + + menu-divider { + height: 16px; + } +`; + +export const AFFINE_FRAMES_SETTING_MENU = 'affine-frames-setting-menu'; + +export class FramesSettingMenu extends WithDisposable(LitElement) { + static override styles = styles; + + private _onBlackBackgroundChange = (checked: boolean) => { + this.blackBackground = checked; + this._edgelessRootService?.slots.navigatorSettingUpdated.emit({ + blackBackground: this.blackBackground, + }); + }; + + private _onFillScreenChange = (checked: boolean) => { + this.fillScreen = checked; + this._edgelessRootService?.slots.navigatorSettingUpdated.emit({ + fillScreen: this.fillScreen, + }); + this._editPropsStore.setStorage('presentFillScreen', this.fillScreen); + }; + + private _onHideToolBarChange = (checked: boolean) => { + this.hideToolbar = checked; + this._edgelessRootService?.slots.navigatorSettingUpdated.emit({ + hideToolbar: this.hideToolbar, + }); + this._editPropsStore.setStorage('presentHideToolbar', this.hideToolbar); + }; + + private get _edgelessRootService() { + return this.editorHost.std.getOptional(EdgelessRootService); + } + + private get _editPropsStore() { + return this.editorHost.std.get(EditPropsStore); + } + + private _tryRestoreSettings() { + const blackBackground = this._editPropsStore.getStorage( + 'presentBlackBackground' + ); + + this.blackBackground = blackBackground ?? true; + this.fillScreen = + this._editPropsStore.getStorage('presentFillScreen') ?? false; + this.hideToolbar = + this._editPropsStore.getStorage('presentHideToolbar') ?? false; + } + + override connectedCallback() { + super.connectedCallback(); + this._tryRestoreSettings(); + } + + override render() { + return html`<div + class="frames-setting-menu-container" + @click=${(e: MouseEvent) => { + e.stopPropagation(); + }} + > + <div class="frames-setting-menu-item"> + <div class="setting-label">Preview Settings</div> + </div> + <div class="frames-setting-menu-item action"> + <div class="action-label">Fill Screen</div> + <div class="toggle-button"> + <toggle-switch + .on=${this.fillScreen} + .onChange=${this._onFillScreenChange} + ></toggle-switch> + </div> + </div> + + <menu-divider></menu-divider> + + <div class="frames-setting-menu-item"> + <div class="setting-label">Playback Settings</div> + </div> + <div class="frames-setting-menu-item action"> + <div class="action-label">Dark background</div> + <div class="toggle-button"> + <toggle-switch + .on=${this.blackBackground} + .onChange=${this._onBlackBackgroundChange} + ></toggle-switch> + </div> + </div> + <div class="frames-setting-menu-item action"> + <div class="action-label">Hide toolbar</div> + <div class="toggle-button"> + <toggle-switch + .on=${this.hideToolbar} + .onChange=${this._onHideToolBarChange} + ></toggle-switch> + </div> + </div> + </div>`; + } + + override updated(_changedProperties: PropertyValues) { + if (_changedProperties.has('editorHost')) { + if (this._edgelessRootService) { + this.disposables.add( + this._edgelessRootService.slots.navigatorSettingUpdated.on( + ({ blackBackground, hideToolbar }) => { + if ( + blackBackground !== undefined && + blackBackground !== this.blackBackground + ) { + this.blackBackground = blackBackground; + } + + if ( + hideToolbar !== undefined && + hideToolbar !== this.hideToolbar + ) { + this.hideToolbar = hideToolbar; + } + } + ) + ); + } else { + this.disposables.dispose(); + } + } + } + + @state() + accessor blackBackground = false; + + @property({ attribute: false }) + accessor editorHost!: EditorHost; + + @state() + accessor fillScreen = false; + + @state() + accessor hideToolbar = false; +} + +declare global { + interface HTMLElementTagNameMap { + [AFFINE_FRAMES_SETTING_MENU]: FramesSettingMenu; + } +} diff --git a/blocksuite/presets/src/fragments/frame-panel/index.ts b/blocksuite/presets/src/fragments/frame-panel/index.ts new file mode 100644 index 0000000000..c0ddb5ee41 --- /dev/null +++ b/blocksuite/presets/src/fragments/frame-panel/index.ts @@ -0,0 +1 @@ +export * from './frame-panel.js'; diff --git a/blocksuite/presets/src/fragments/frame-panel/utils/drag.ts b/blocksuite/presets/src/fragments/frame-panel/utils/drag.ts new file mode 100644 index 0000000000..672c317982 --- /dev/null +++ b/blocksuite/presets/src/fragments/frame-panel/utils/drag.ts @@ -0,0 +1,154 @@ +import { type FrameBlockModel, on, once } from '@blocksuite/blocks'; + +import type { FramePanelBody } from '../body/frame-panel-body.js'; +import { FrameCard } from '../card/frame-card.js'; + +/** + * start drag frame cards + * @param frames frames to drag + */ +export function startDragging( + frames: { + frame: FrameBlockModel; + element: FrameCard; + cardIndex: number; + frameIndex: string; + }[], + options: { + width: number; + onDragEnd?: (insertIndex?: number) => void; + onDragMove?: (insertIdx?: number, indicatorTranslateY?: number) => void; + framePanelBody: HTMLElement; + frameListContainer: HTMLElement; + frameElementHeight: number; + document: Document; + domHost: Document | HTMLElement; + container: FramePanelBody; + start: { + x: number; + y: number; + }; + } +) { + const { + document, + domHost, + container, + onDragMove, + onDragEnd, + frameElementHeight, + framePanelBody, + frameListContainer, + start, + } = options; + const cardElements = frames + .slice(frames.length - 2, frames.length) + .map((frame, idx, arr) => { + const el = new FrameCard(); + + el.frame = frame.frame; + + el.cardIndex = frame.cardIndex; + el.frameIndex = frame.frameIndex; + el.status = 'dragging'; + el.stackOrder = arr.length - 1 - idx; + el.pos = start; + el.width = options.width; + if (frames.length > 1 && el.stackOrder === 0) + el.draggingCardNumber = frames.length; + + return el; + }); + const maskElement = createMaskElement(document); + const listContainerRect = framePanelBody.getBoundingClientRect(); + const children = Array.from(frameListContainer.children) as FrameCard[]; + const computedStyle = getComputedStyle(frameListContainer); + const frameListContainerGap = + parseInt(computedStyle.getPropertyValue('gap')) ?? 16; + let idx: undefined | number; + let indicatorTranslateY: undefined | number; + + container.renderRoot.append(maskElement); + container.renderRoot.append(...cardElements); + + const insideListContainer = (e: MouseEvent) => { + return ( + e.clientX >= listContainerRect.left && + e.clientX <= listContainerRect.right && + e.clientY >= listContainerRect.top && + e.clientY <= listContainerRect.bottom + ); + }; + + const disposeMove = on(container, 'mousemove', e => { + cardElements.forEach(el => { + el.pos = { + x: e.clientX, + y: e.clientY, + }; + }); + + if (!insideListContainer(e)) { + idx = undefined; + onDragMove?.(idx, 0); + return; + } + + idx = 0; + for (const card of children) { + if (!card.frame) break; + + const topBoundary = + listContainerRect.top + + card.offsetTop - + framePanelBody.scrollTop - + frameListContainerGap / 2; + const midBoundary = topBoundary + card.offsetHeight / 2; + const bottomBoundary = + topBoundary + card.offsetHeight + frameListContainerGap; + + if (e.clientY >= topBoundary && e.clientY <= bottomBoundary) { + idx = e.clientY > midBoundary ? idx + 1 : idx; + + indicatorTranslateY = + idx * (frameElementHeight + frameListContainerGap) - + frameListContainerGap / 2; + + onDragMove?.(idx, indicatorTranslateY); + return; + } + + ++idx; + } + + onDragMove?.(idx); + }); + + let ended = false; + const dragEnd = () => { + if (ended) return; + + ended = true; + cardElements.forEach(child => child.remove()); + maskElement.remove(); + + disposeMove(); + onDragEnd?.(idx); + }; + + once(domHost as Document, 'mouseup', dragEnd); +} + +function createMaskElement(doc: Document) { + const mask = doc.createElement('div'); + + mask.style.height = '100vh'; + mask.style.width = '100vw'; + mask.style.position = 'fixed'; + mask.style.left = '0'; + mask.style.top = '0'; + mask.style.zIndex = 'calc(var(--affine-z-index-popover, 0) + 3)'; + mask.style.cursor = 'grabbing'; + + return mask; +} diff --git a/blocksuite/presets/src/fragments/index.ts b/blocksuite/presets/src/fragments/index.ts new file mode 100644 index 0000000000..d5f99aa859 --- /dev/null +++ b/blocksuite/presets/src/fragments/index.ts @@ -0,0 +1,4 @@ +export * from './comment/index.js'; +export * from './doc-title/doc-title.js'; +export * from './frame-panel/index.js'; +export * from './outline/index.js'; diff --git a/blocksuite/presets/src/fragments/outline/body/outline-notice.ts b/blocksuite/presets/src/fragments/outline/body/outline-notice.ts new file mode 100644 index 0000000000..041a805d89 --- /dev/null +++ b/blocksuite/presets/src/fragments/outline/body/outline-notice.ts @@ -0,0 +1,136 @@ +import { WithDisposable } from '@blocksuite/global/utils'; +import { css, html, LitElement, nothing } from 'lit'; +import { property } from 'lit/decorators.js'; + +import { SmallCloseIcon, SortingIcon } from '../../_common/icons.js'; + +const styles = css` + :host { + width: 100%; + box-sizing: border-box; + position: absolute; + left: 0; + bottom: 8px; + padding: 0 8px; + } + .outline-notice-container { + display: flex; + width: 100%; + box-sizing: border-box; + gap: 14px; + padding: 10px; + font-style: normal; + font-size: 12px; + flex-direction: column; + border-radius: 8px; + background-color: var(--affine-background-overlay-panel-color); + } + .outline-notice-header { + display: flex; + width: 100%; + height: 20px; + align-items: center; + justify-content: space-between; + } + .outline-notice-label { + font-weight: 600; + line-height: 20px; + color: var(--affine-text-secondary-color); + } + .outline-notice-close-button { + display: flex; + align-items: center; + justify-content: center; + width: 20px; + height: 20px; + cursor: pointer; + color: var(--affine-icon-color); + } + .outline-notice-body { + display: flex; + width: 100%; + gap: 2px; + flex-direction: column; + } + .outline-notice-item { + display: flex; + height: 20px; + align-items: center; + line-height: 20px; + color: var(--affine-text-primary-color); + } + .outline-notice-item.notice { + font-weight: 400; + } + .outline-notice-item.button { + display: flex; + gap: 2px; + font-weight: 500; + text-decoration: underline; + cursor: pointer; + } + .outline-notice-item.button span { + display: flex; + align-items: center; + line-height: 20px; + } + .outline-notice-item.button svg { + scale: 0.8; + } +`; + +export const AFFINE_OUTLINE_NOTICE = 'affine-outline-notice'; + +export class OutlineNotice extends WithDisposable(LitElement) { + static override styles = styles; + + private _handleNoticeButtonClick() { + this.toggleNotesSorting(); + this.setNoticeVisibility(false); + } + + override render() { + if (!this.noticeVisible) { + return nothing; + } + + return html`<div class="outline-notice-container"> + <div class="outline-notice-header"> + <span class="outline-notice-label">SOME CONTENTS HIDDEN</span> + <span + class="outline-notice-close-button" + @click=${() => this.setNoticeVisibility(false)} + >${SmallCloseIcon}</span + > + </div> + <div class="outline-notice-body"> + <div class="outline-notice-item notice"> + Some contents are not visible on edgeless. + </div> + <div + class="outline-notice-item button" + @click=${this._handleNoticeButtonClick} + > + <span>Click here or</span> + <span>${SortingIcon}</span> + <span>to organize content.</span> + </div> + </div> + </div>`; + } + + @property({ attribute: false }) + accessor noticeVisible!: boolean; + + @property({ attribute: false }) + accessor setNoticeVisibility!: (visibility: boolean) => void; + + @property({ attribute: false }) + accessor toggleNotesSorting!: () => void; +} + +declare global { + interface HTMLElementTagNameMap { + [AFFINE_OUTLINE_NOTICE]: OutlineNotice; + } +} diff --git a/blocksuite/presets/src/fragments/outline/body/outline-panel-body.ts b/blocksuite/presets/src/fragments/outline/body/outline-panel-body.ts new file mode 100644 index 0000000000..d77ba98892 --- /dev/null +++ b/blocksuite/presets/src/fragments/outline/body/outline-panel-body.ts @@ -0,0 +1,747 @@ +import type { + EdgelessRootBlockComponent, + NoteBlockModel, +} from '@blocksuite/blocks'; +import { + BlocksUtils, + NoteDisplayMode, + ThemeProvider, +} from '@blocksuite/blocks'; +import { + Bound, + DisposableGroup, + SignalWatcher, + WithDisposable, +} from '@blocksuite/global/utils'; +import type { Doc } from '@blocksuite/store'; +import { effect, signal } from '@preact/signals-core'; +import { css, html, LitElement, nothing, type PropertyValues } from 'lit'; +import { property, query, state } from 'lit/decorators.js'; +import { classMap } from 'lit/directives/class-map.js'; +import { repeat } from 'lit/directives/repeat.js'; + +import type { AffineEditorContainer } from '../../../editors/editor-container.js'; +import type { + ClickBlockEvent, + DisplayModeChangeEvent, + FitViewEvent, + SelectEvent, +} from '../utils/custom-events.js'; +import { startDragging } from '../utils/drag.js'; +import { + getHeadingBlocksFromDoc, + getNotesFromDoc, + isHeadingBlock, +} from '../utils/query.js'; +import { + observeActiveHeadingDuringScroll, + scrollToBlockWithHighlight, +} from '../utils/scroll.js'; + +type OutlineNoteItem = { + note: NoteBlockModel; + + /** + * the index of the note inside its parent's children property + */ + index: number; + + /** + * the number displayed on the outline panel + */ + number: number; +}; + +const styles = css` + .outline-panel-body-container { + position: relative; + display: flex; + align-items: start; + box-sizing: border-box; + flex-direction: column; + width: 100%; + height: 100%; + padding: 0 8px; + } + + .panel-list { + position: relative; + width: 100%; + } + + .panel-list .hidden-title { + width: 100%; + font-size: 14px; + line-height: 24px; + font-weight: 500; + color: var(--affine-text-secondary-color); + padding-left: 8px; + height: 40px; + box-sizing: border-box; + padding: 6px 8px; + margin-top: 8px; + } + + .insert-indicator { + height: 2px; + border-radius: 1px; + background-color: var(--affine-brand-color); + border-radius: 1px; + position: absolute; + contain: layout size; + width: 100%; + } + + .no-note-container { + display: flex; + flex-direction: column; + width: 100%; + } + + .note-placeholder { + margin-top: 240px; + align-self: center; + width: 190px; + height: 48px; + color: var(--affine-text-secondary-color, #8e8d91); + text-align: center; + /* light/base */ + font-size: 15px; + font-style: normal; + font-weight: 400; + line-height: 24px; + } +`; + +export const AFFINE_OUTLINE_PANEL_BODY = 'affine-outline-panel-body'; + +export class OutlinePanelBody extends SignalWatcher( + WithDisposable(LitElement) +) { + static override styles = styles; + + private _activeHeadingId$ = signal<string | null>(null); + + private _changedFlag = false; + + private _clearHighlightMask = () => {}; + + private _docDisposables: DisposableGroup | null = null; + + private _indicatorTranslateY = 0; + + private _lockActiveHeadingId = false; + + private _oldViewport?: { + zoom: number; + center: { + x: number; + y: number; + }; + }; + + get viewportPadding(): [number, number, number, number] { + return this.fitPadding + ? ([0, 0, 0, 0].map((val, idx) => + Number.isFinite(this.fitPadding[idx]) ? this.fitPadding[idx] : val + ) as [number, number, number, number]) + : [0, 0, 0, 0]; + } + + private _clearDocDisposables() { + this._docDisposables?.dispose(); + this._docDisposables = null; + } + + /* + * Click at blank area to clear selection + */ + private _clickHandler(e: MouseEvent) { + e.stopPropagation(); + // check if click at outline-card, if so, do nothing + if ( + (e.target as HTMLElement).closest('outline-note-card') || + this._selected.length === 0 + ) { + return; + } + + this._selected = []; + this.edgeless?.service.selection.set({ + elements: this._selected, + editing: false, + }); + } + + private _deSelectNoteInEdgelessMode(note: NoteBlockModel) { + if (!this._isEdgelessMode() || !this.edgeless) return; + + const { selection } = this.edgeless.service; + if (!selection.has(note.id)) return; + const selectedIds = selection.selectedIds.filter(id => id !== note.id); + selection.set({ + elements: selectedIds, + editing: false, + }); + } + + /* + * Double click at blank area to disable notes sorting option + */ + private _doubleClickHandler(e: MouseEvent) { + e.stopPropagation(); + // check if click at outline-card, if so, do nothing + if ( + (e.target as HTMLElement).closest('outline-note-card') || + !this.enableNotesSorting + ) { + return; + } + + this.toggleNotesSorting(); + } + + private _drag() { + if ( + !this._selected.length || + !this._pageVisibleNotes.length || + !this.doc.root + ) + return; + + this._dragging = true; + + // cache the notes in case it is changed by other peers + const children = this.doc.root.children.slice() as NoteBlockModel[]; + const notes = this._pageVisibleNotes; + const notesMap = this._pageVisibleNotes.reduce((map, note, index) => { + map.set(note.note.id, { + ...note, + number: index + 1, + }); + return map; + }, new Map<string, OutlineNoteItem>()); + const selected = this._selected.slice(); + + startDragging({ + container: this, + document: this.ownerDocument, + host: this.domHost ?? this.ownerDocument, + doc: this.doc, + outlineListContainer: this.panelListElement, + onDragEnd: insertIdx => { + this._dragging = false; + this.insertIndex = undefined; + + if (insertIdx === undefined) return; + + this._moveNotes(insertIdx, selected, notesMap, notes, children); + }, + onDragMove: (idx, indicatorTranslateY) => { + this.insertIndex = idx; + this._indicatorTranslateY = indicatorTranslateY ?? 0; + }, + }); + } + + private _EmptyPanel() { + return html`<div class="no-note-container"> + <div class="note-placeholder"> + Use headings to create a table of contents. + </div> + </div>`; + } + + private _fitToElement(e: FitViewEvent) { + const edgeless = this.edgeless; + + if (!edgeless) return; + + const { block } = e.detail; + const bound = Bound.deserialize(block.xywh); + + edgeless.service.viewport.setViewportByBound( + bound, + this.viewportPadding, + true + ); + } + + // when display mode change to page only, we should de-select the note if it is selected in edgeless mode + private _handleDisplayModeChange(e: DisplayModeChangeEvent) { + const { note, newMode } = e.detail; + const { displayMode: currentMode } = note; + if (newMode === currentMode) { + return; + } + + this.doc.updateBlock(note, { displayMode: newMode }); + + const noteParent = this.doc.getParent(note); + if (noteParent === null) { + console.error(`Failed to get parent of note(id:${note.id})`); + return; + } + + const noteParentChildNotes = noteParent.children.filter(block => + BlocksUtils.matchFlavours(block, ['affine:note']) + ) as NoteBlockModel[]; + const noteParentLastNote = + noteParentChildNotes[noteParentChildNotes.length - 1]; + + // When the display mode of a note change from edgeless to page visible + // We should move the note to the end of the note list + if ( + currentMode === NoteDisplayMode.EdgelessOnly && + note !== noteParentLastNote + ) { + this.doc.moveBlocks([note], noteParent, noteParentLastNote, false); + } + + // When the display mode of a note changed to page only + // We should check if the note is selected in edgeless mode + // If so, we should de-select it + if (newMode === NoteDisplayMode.DocOnly) { + this._deSelectNoteInEdgelessMode(note); + } + } + + private _isEdgelessMode() { + return this.editor.mode === 'edgeless'; + } + + private _moveNotes( + index: number, + selected: string[], + notesMap: Map<string, OutlineNoteItem>, + notes: OutlineNoteItem[], + children: NoteBlockModel[] + ) { + if (!this._isEdgelessMode() || !children.length || !this.doc.root) return; + + const blocks = selected.map( + id => (notesMap.get(id) as OutlineNoteItem).note + ); + const draggingBlocks = new Set(blocks); + const targetIndex = + index === notes.length ? notes[index - 1].index + 1 : notes[index].index; + + const leftPart = children + .slice(0, targetIndex) + .filter(block => !draggingBlocks.has(block)); + const rightPart = children + .slice(targetIndex) + .filter(block => !draggingBlocks.has(block)); + const newChildren = [...leftPart, ...blocks, ...rightPart]; + + this._changedFlag = true; + this.doc.updateBlock(this.doc.root, { + children: newChildren, + }); + } + + private _PanelList(withEdgelessOnlyNotes: boolean) { + const selectedNotesSet = new Set(this._selected); + const theme = this.editor.std.get(ThemeProvider).theme; + + return html`<div class="panel-list"> + ${this.insertIndex !== undefined + ? html`<div + class="insert-indicator" + style=${`transform: translateY(${this._indicatorTranslateY}px)`} + ></div>` + : nothing} + ${this._renderDocTitle()} + ${this._pageVisibleNotes.length + ? repeat( + this._pageVisibleNotes, + note => note.note.id, + (note, idx) => html` + <affine-outline-note-card + data-note-id=${note.note.id} + .note=${note.note} + .theme=${theme} + .number=${idx + 1} + .index=${note.index} + .doc=${this.doc} + .editorMode=${this.editor.mode} + .activeHeadingId=${this._activeHeadingId$.value} + .status=${selectedNotesSet.has(note.note.id) + ? this._dragging + ? 'placeholder' + : 'selected' + : undefined} + .showPreviewIcon=${this.showPreviewIcon} + .enableNotesSorting=${this.enableNotesSorting} + @select=${this._selectNote} + @drag=${this._drag} + @fitview=${this._fitToElement} + @clickblock=${(e: ClickBlockEvent) => { + this._scrollToBlock(e.detail.blockId).catch(console.error); + }} + @displaymodechange=${this._handleDisplayModeChange} + ></affine-outline-note-card> + ` + ) + : html`${nothing}`} + ${withEdgelessOnlyNotes + ? html`<div class="hidden-title">Hidden Contents</div> + ${repeat( + this._edgelessOnlyNotes, + note => note.note.id, + (note, idx) => + html`<affine-outline-note-card + data-note-id=${note.note.id} + .note=${note.note} + .theme=${theme} + .number=${idx + 1} + .index=${note.index} + .doc=${this.doc} + .activeHeadingId=${this._activeHeadingId$.value} + .invisible=${true} + .showPreviewIcon=${this.showPreviewIcon} + .enableNotesSorting=${this.enableNotesSorting} + @fitview=${this._fitToElement} + @displaymodechange=${this._handleDisplayModeChange} + ></affine-outline-note-card>` + )} ` + : nothing} + </div>`; + } + + private _renderDocTitle() { + if (!this.doc.root) return nothing; + + const hasNotEmptyHeadings = + getHeadingBlocksFromDoc( + this.doc, + [NoteDisplayMode.DocOnly, NoteDisplayMode.DocAndEdgeless], + true + ).length > 0; + + if (!hasNotEmptyHeadings) return nothing; + + return html`<affine-outline-block-preview + class=${classMap({ + active: this.doc.root.id === this._activeHeadingId$.value, + })} + .block=${this.doc.root} + .className=${this.doc.root?.id === this._activeHeadingId$.value + ? 'active' + : ''} + .cardNumber=${1} + .enableNotesSorting=${false} + .showPreviewIcon=${this.showPreviewIcon} + @click=${() => { + if (!this.doc.root) return; + this._scrollToBlock(this.doc.root.id).catch(console.error); + }} + ></affine-outline-block-preview>`; + } + + private async _scrollToBlock(blockId: string) { + this._lockActiveHeadingId = true; + this._activeHeadingId$.value = blockId; + this._clearHighlightMask = await scrollToBlockWithHighlight( + this.editor, + blockId + ); + this._lockActiveHeadingId = false; + } + + private _selectNote(e: SelectEvent) { + if (!this._isEdgelessMode()) return; + + const { selected, id, multiselect } = e.detail; + + if (!selected) { + this._selected = this._selected.filter(noteId => noteId !== id); + } else if (multiselect) { + this._selected = [...this._selected, id]; + } else { + this._selected = [id]; + } + + // When edgeless mode, should select notes which display in both mode + const selectedIds = this._pageVisibleNotes.reduce((ids, item) => { + const note = item.note; + if ( + this._selected.includes(note.id) && + (!note.displayMode || + note.displayMode === NoteDisplayMode.DocAndEdgeless) + ) { + ids.push(note.id); + } + return ids; + }, [] as string[]); + this.edgeless?.service.selection.set({ + elements: selectedIds, + editing: false, + }); + } + + private _setDocDisposables() { + this._clearDocDisposables(); + this._docDisposables = new DisposableGroup(); + this._docDisposables.add( + effect(() => { + this._updateNotes(); + this._updateNoticeVisibility(); + }) + ); + this._docDisposables.add( + this.doc.slots.blockUpdated.on(payload => { + if ( + payload.type === 'update' && + payload.flavour === 'affine:note' && + payload.props.key === 'displayMode' + ) { + this._updateNotes(); + } + }) + ); + } + + /** + * There are two cases that we should render note list: + * 1. There are headings in the notes + * 2. No headings, but there are blocks in the notes and note sorting option is enabled + */ + private _shouldRenderNoteList(noteItems: OutlineNoteItem[]) { + if (!noteItems.length) return false; + + let hasHeadings = false; + let hasChildrenBlocks = false; + + for (const noteItem of noteItems) { + for (const block of noteItem.note.children) { + hasChildrenBlocks = true; + + if (isHeadingBlock(block)) { + hasHeadings = true; + break; + } + } + + if (hasHeadings) { + break; + } + } + + return hasHeadings || (this.enableNotesSorting && hasChildrenBlocks); + } + + private _updateNotes() { + const rootModel = this.doc.root; + + if (this._dragging) return; + + if (!rootModel) { + this._pageVisibleNotes = []; + return; + } + + const oldSelectedSet = this._selected.reduce((pre, id) => { + pre.add(id); + return pre; + }, new Set<string>()); + const newSelected: string[] = []; + + rootModel.children.forEach(block => { + if (!BlocksUtils.matchFlavours(block, ['affine:note'])) return; + + const blockModel = block as NoteBlockModel; + + if ( + blockModel.displayMode !== NoteDisplayMode.EdgelessOnly && + oldSelectedSet.has(block.id) + ) { + newSelected.push(block.id); + } + }); + + this._pageVisibleNotes = getNotesFromDoc(this.doc, [ + NoteDisplayMode.DocAndEdgeless, + NoteDisplayMode.DocOnly, + ]); + this._edgelessOnlyNotes = getNotesFromDoc(this.doc, [ + NoteDisplayMode.EdgelessOnly, + ]); + this._selected = newSelected; + } + + private _updateNoticeVisibility() { + if (this.enableNotesSorting) { + if (this.noticeVisible) { + this.setNoticeVisibility(false); + } + return; + } + + const shouldShowNotice = this._pageVisibleNotes.some( + note => note.note.displayMode === NoteDisplayMode.DocOnly + ); + + if (shouldShowNotice && !this.noticeVisible) { + this.setNoticeVisibility(true); + } + } + + private _zoomToFit() { + const edgeless = this.edgeless; + + if (!edgeless) return; + + const bound = edgeless.gfx.elementsBound; + + this._oldViewport = { + zoom: edgeless.service.viewport.zoom, + center: { + x: edgeless.service.viewport.center.x, + y: edgeless.service.viewport.center.y, + }, + }; + edgeless.service.viewport.setViewportByBound( + new Bound(bound.x, bound.y, bound.w, bound.h), + this.viewportPadding, + true + ); + } + + override connectedCallback(): void { + super.connectedCallback(); + this.disposables.add( + observeActiveHeadingDuringScroll( + () => this.editor, + newHeadingId => { + if (this._lockActiveHeadingId) return; + this._activeHeadingId$.value = newHeadingId; + } + ) + ); + } + + override disconnectedCallback(): void { + super.disconnectedCallback(); + + if (!this._changedFlag && this._oldViewport) { + const edgeless = this.edgeless; + + if (!edgeless) return; + + edgeless.service.viewport.setViewport( + this._oldViewport.zoom, + [this._oldViewport.center.x, this._oldViewport.center.y], + true + ); + } + + this._clearDocDisposables(); + this._clearHighlightMask(); + } + + override firstUpdated(): void { + this.disposables.addFromEvent(this, 'click', this._clickHandler); + this.disposables.addFromEvent(this, 'dblclick', this._doubleClickHandler); + } + + override render() { + const shouldRenderPageVisibleNotes = this._shouldRenderNoteList( + this._pageVisibleNotes + ); + const shouldRenderEdgelessOnlyNotes = + this.renderEdgelessOnlyNotes && + this._shouldRenderNoteList(this._edgelessOnlyNotes); + + const shouldRenderEmptyPanel = + !shouldRenderPageVisibleNotes && !shouldRenderEdgelessOnlyNotes; + + return html` + <div class="outline-panel-body-container"> + ${shouldRenderEmptyPanel + ? this._EmptyPanel() + : this._PanelList(shouldRenderEdgelessOnlyNotes)} + </div> + `; + } + + override willUpdate(_changedProperties: PropertyValues) { + if (_changedProperties.has('doc') || _changedProperties.has('edgeless')) { + this._setDocDisposables(); + } + + if ( + _changedProperties.has('mode') && + this.edgeless && + this._isEdgelessMode() + ) { + this._clearHighlightMask(); + if (_changedProperties.get('mode') === undefined) return; + + requestAnimationFrame(() => this._zoomToFit()); + } + } + + @state() + private accessor _dragging = false; + + @state() + private accessor _edgelessOnlyNotes: OutlineNoteItem[] = []; + + @state() + private accessor _pageVisibleNotes: OutlineNoteItem[] = []; + + /** + * store the id of selected notes + */ + @state() + private accessor _selected: string[] = []; + + @property({ attribute: false }) + accessor doc!: Doc; + + @property({ attribute: false }) + accessor domHost!: Document | HTMLElement; + + @property({ attribute: false }) + accessor edgeless!: EdgelessRootBlockComponent | null; + + @property({ attribute: false }) + accessor editor!: AffineEditorContainer; + + @property({ attribute: false }) + accessor enableNotesSorting!: boolean; + + @property({ attribute: false }) + accessor fitPadding!: number[]; + + @property({ attribute: false }) + accessor insertIndex: number | undefined = undefined; + + @property({ attribute: false }) + accessor noticeVisible!: boolean; + + @query('.outline-panel-body-container') + accessor OutlinePanelContainer!: HTMLElement; + + @query('.panel-list') + accessor panelListElement!: HTMLElement; + + @property({ attribute: false }) + accessor renderEdgelessOnlyNotes: boolean = true; + + @property({ attribute: false }) + accessor setNoticeVisibility!: (visibility: boolean) => void; + + @property({ attribute: false }) + accessor showPreviewIcon!: boolean; + + @property({ attribute: false }) + accessor toggleNotesSorting!: () => void; +} + +declare global { + interface HTMLElementTagNameMap { + [AFFINE_OUTLINE_PANEL_BODY]: OutlinePanelBody; + } +} diff --git a/blocksuite/presets/src/fragments/outline/card/outline-card.ts b/blocksuite/presets/src/fragments/outline/card/outline-card.ts new file mode 100644 index 0000000000..88961eb378 --- /dev/null +++ b/blocksuite/presets/src/fragments/outline/card/outline-card.ts @@ -0,0 +1,432 @@ +import { + type ColorScheme, + createButtonPopper, + type NoteBlockModel, + NoteDisplayMode, + on, + once, +} from '@blocksuite/blocks'; +import { SignalWatcher, WithDisposable } from '@blocksuite/global/utils'; +import type { BlockModel, Doc } from '@blocksuite/store'; +import { baseTheme } from '@toeverything/theme'; +import { css, html, LitElement, nothing, unsafeCSS } from 'lit'; +import { property, query, state } from 'lit/decorators.js'; +import { classMap } from 'lit/directives/class-map.js'; + +import { HiddenIcon, SmallArrowDownIcon } from '../../_common/icons.js'; +import type { SelectEvent } from '../utils/custom-events.js'; + +const styles = css` + :host { + display: block; + position: relative; + } + + .card-container { + position: relative; + + display: flex; + align-items: center; + justify-content: center; + box-sizing: border-box; + } + + .card-preview { + position: relative; + + width: 100%; + + border-radius: 4px; + + cursor: default; + user-select: none; + } + + .card-preview.edgeless:hover { + background: var(--affine-hover-color); + } + + .card-header-container { + padding: 0 8px; + width: 100%; + min-height: 28px; + display: none; + align-items: center; + gap: 8px; + box-sizing: border-box; + } + + .card-header-container.enable-sorting { + display: flex; + } + + .card-header-container .card-number { + text-align: center; + font-size: var(--affine-font-sm); + font-family: ${unsafeCSS(baseTheme.fontSansFamily)}; + color: var(--affine-brand-color, #1e96eb); + font-weight: 500; + line-height: 14px; + line-height: 20px; + } + + .card-header-container .card-header-icon { + display: flex; + align-items: center; + justify-content: center; + } + + .card-header-container .card-divider { + height: 1px; + flex: 1; + border-top: 1px dashed var(--affine-border-color); + transform: translateY(50%); + } + + .display-mode-button-group { + display: none; + position: absolute; + right: 8px; + top: -6px; + padding-top: 8px; + padding-bottom: 8px; + align-items: center; + gap: 4px; + font-size: 12px; + font-weight: 500; + line-height: 20px; + } + + .card-preview:hover .display-mode-button-group { + display: flex; + } + + .display-mode-button-label { + color: var(--affine-text-primary-color); + } + + .display-mode-button { + display: flex; + border-radius: 4px; + background-color: var(--affine-hover-color); + align-items: center; + } + + .current-mode-label { + display: flex; + padding: 2px 0px 2px 4px; + align-items: center; + } + + note-display-mode-panel { + position: absolute; + display: none; + background: var(--affine-background-overlay-panel-color); + border-radius: 8px; + box-shadow: var(--affine-shadow-2); + box-sizing: border-box; + padding: 8px; + font-size: var(--affine-font-sm); + color: var(--affine-text-primary-color); + line-height: 22px; + font-weight: 400; + font-family: ${unsafeCSS(baseTheme.fontSansFamily)}; + } + + note-display-mode-panel[data-show] { + display: flex; + } + + .card-content { + font-family: ${unsafeCSS(baseTheme.fontSansFamily)}; + user-select: none; + color: var(--affine-text-primary-color); + } + + .card-preview.edgeless .card-content:hover { + cursor: pointer; + } + + .card-preview.edgeless .card-header-container:hover { + cursor: grab; + } + + .card-container.placeholder { + pointer-events: none; + opacity: 0.5; + } + + .card-container.selected .card-preview.edgeless { + background: var(--affine-hover-color); + } + + .card-container.placeholder .card-preview.edgeless { + background: var(--affine-hover-color); + opacity: 0.9; + } + + .card-container[data-sortable='true'] { + padding: 2px 0; + } + + .card-container[data-invisible='true'] .card-header-container .card-number, + .card-container[data-invisible='true'] + .card-header-container + .card-header-icon, + .card-container[data-invisible='true'] .card-preview .card-content { + color: var(--affine-text-disable-color); + pointer-events: none; + } + + .card-preview.page outline-block-preview:hover { + color: var(--affine-brand-color); + } +`; + +export const AFFINE_OUTLINE_NOTE_CARD = 'affine-outline-note-card'; + +export class OutlineNoteCard extends SignalWatcher(WithDisposable(LitElement)) { + static override styles = styles; + + private _displayModePopper: ReturnType<typeof createButtonPopper> | null = + null; + + private _dispatchClickBlockEvent(block: BlockModel) { + const event = new CustomEvent('clickblock', { + detail: { + blockId: block.id, + }, + }); + + this.dispatchEvent(event); + } + + private _dispatchDisplayModeChangeEvent( + note: NoteBlockModel, + newMode: NoteDisplayMode + ) { + const event = new CustomEvent('displaymodechange', { + detail: { + note, + newMode, + }, + }); + + this.dispatchEvent(event); + } + + private _dispatchDragEvent(e: MouseEvent) { + e.preventDefault(); + if ( + e.button !== 0 || + this.editorMode === 'page' || + !this.enableNotesSorting + ) + return; + + const { clientX: startX, clientY: startY } = e; + const disposeDragStart = on(this.ownerDocument, 'mousemove', e => { + if ( + Math.abs(startX - e.clientX) < 5 && + Math.abs(startY - e.clientY) < 5 + ) { + return; + } + if (this.status !== 'selected') { + this._dispatchSelectEvent(e); + } + + const event = new CustomEvent('drag'); + + this.dispatchEvent(event); + disposeDragStart(); + }); + + once(this.ownerDocument, 'mouseup', () => { + disposeDragStart(); + }); + } + + private _dispatchFitViewEvent(e: MouseEvent) { + e.stopPropagation(); + + const event = new CustomEvent('fitview', { + detail: { + block: this.note, + }, + }); + + this.dispatchEvent(event); + } + + private _dispatchSelectEvent(e: MouseEvent) { + e.stopPropagation(); + const event = new CustomEvent('select', { + detail: { + id: this.note.id, + selected: this.status !== 'selected', + number: this.number, + multiselect: e.shiftKey, + }, + }) as SelectEvent; + + this.dispatchEvent(event); + } + + private _getCurrentModeLabel(mode: NoteDisplayMode) { + switch (mode) { + case NoteDisplayMode.DocAndEdgeless: + return 'Both'; + case NoteDisplayMode.EdgelessOnly: + return 'Edgeless'; + case NoteDisplayMode.DocOnly: + return 'Page'; + default: + return 'Both'; + } + } + + override firstUpdated() { + this._displayModePopper = createButtonPopper( + this._displayModeButtonGroup, + this._displayModePanel, + ({ display }) => { + this._showPopper = display === 'show'; + }, + { + mainAxis: 0, + crossAxis: -60, + } + ); + + this.disposables.add(this._displayModePopper); + } + + override render() { + if (this.note.isEmpty.peek()) return nothing; + + const { children, displayMode } = this.note; + const currentMode = this._getCurrentModeLabel(displayMode); + const cardHeaderClasses = classMap({ + 'card-header-container': true, + 'enable-sorting': this.enableNotesSorting, + }); + + return html` + <div + data-invisible="${this.invisible ? 'true' : 'false'}" + data-sortable="${this.enableNotesSorting ? 'true' : 'false'}" + class="card-container ${this.status ?? ''} ${this.theme}" + > + <div + class="card-preview ${this.editorMode}" + @mousedown=${this._dispatchDragEvent} + @click=${this._dispatchSelectEvent} + @dblclick=${this._dispatchFitViewEvent} + > + ${html`<div class=${cardHeaderClasses}> + ${ + this.invisible + ? html`<span class="card-header-icon">${HiddenIcon}</span>` + : html`<span class="card-number">${this.number}</span>` + } + <span class="card-divider"></span> + <div class="display-mode-button-group"> + <span class="display-mode-button-label">Show in</span> + <edgeless-tool-icon-button + .tooltip=${this._showPopper ? '' : 'Display Mode'} + .tipPosition=${'left-start'} + .iconContainerPadding=${0} + @click=${(e: MouseEvent) => { + e.stopPropagation(); + this._displayModePopper?.toggle(); + }} + @dblclick=${(e: MouseEvent) => e.stopPropagation()} + > + <div class="display-mode-button"> + <span class="current-mode-label">${currentMode}</span> + ${SmallArrowDownIcon} + </div> + </edgeless-tool-icon-button> + </div> + </div> + <note-display-mode-panel + .displayMode=${displayMode} + .panelWidth=${220} + .onSelect=${(newMode: NoteDisplayMode) => { + this._dispatchDisplayModeChangeEvent(this.note, newMode); + this._displayModePopper?.hide(); + }} + > + </note-display-mode-panel> + </div>`} + <div class="card-content"> + ${children.map(block => { + return html`<affine-outline-block-preview + .block=${block} + .className=${this.activeHeadingId === block.id ? 'active' : ''} + .showPreviewIcon=${this.showPreviewIcon} + .disabledIcon=${this.invisible} + .cardNumber=${this.number} + .enableNotesSorting=${this.enableNotesSorting} + @click=${() => { + if (this.editorMode === 'edgeless' || this.invisible) return; + this._dispatchClickBlockEvent(block); + }} + ></affine-outline-block-preview>`; + })} + </div> + </div> + </div> + </div> + `; + } + + @query('.display-mode-button-group') + private accessor _displayModeButtonGroup!: HTMLDivElement; + + @query('note-display-mode-panel') + private accessor _displayModePanel!: HTMLDivElement; + + @state() + private accessor _showPopper = false; + + @property({ attribute: false }) + accessor activeHeadingId: string | null = null; + + @property({ attribute: false }) + accessor doc!: Doc; + + @property({ attribute: false }) + accessor editorMode: 'page' | 'edgeless' = 'page'; + + @property({ attribute: false }) + accessor enableNotesSorting!: boolean; + + @property({ attribute: false }) + accessor index!: number; + + @property({ attribute: false }) + accessor invisible = false; + + @property({ attribute: false }) + accessor note!: NoteBlockModel; + + @property({ attribute: false }) + accessor number!: number; + + @property({ attribute: false }) + accessor showPreviewIcon!: boolean; + + @property({ attribute: false }) + accessor status: 'selected' | 'placeholder' | undefined = undefined; + + @property({ attribute: false }) + accessor theme!: ColorScheme; +} + +declare global { + interface HTMLElementTagNameMap { + [AFFINE_OUTLINE_NOTE_CARD]: OutlineNoteCard; + } +} diff --git a/blocksuite/presets/src/fragments/outline/card/outline-preview.ts b/blocksuite/presets/src/fragments/outline/card/outline-preview.ts new file mode 100644 index 0000000000..6a0a506e7a --- /dev/null +++ b/blocksuite/presets/src/fragments/outline/card/outline-preview.ts @@ -0,0 +1,315 @@ +import type { AffineTextAttributes } from '@blocksuite/affine-shared/types'; +import type { + AttachmentBlockModel, + BookmarkBlockModel, + CodeBlockModel, + DatabaseBlockModel, + ImageBlockModel, + ListBlockModel, + ParagraphBlockModel, + RootBlockModel, +} from '@blocksuite/blocks'; +import { noop, SignalWatcher, WithDisposable } from '@blocksuite/global/utils'; +import type { DeltaInsert } from '@blocksuite/inline'; +import { css, html, LitElement, nothing } from 'lit'; +import { property } from 'lit/decorators.js'; + +import { SmallLinkedDocIcon } from '../../_common/icons.js'; +import { placeholderMap, previewIconMap } from '../config.js'; +import { isHeadingBlock, isRootBlock } from '../utils/query.js'; + +type ValuesOf<T, K extends keyof T = keyof T> = T[K]; + +function assertType<T>(value: unknown): asserts value is T { + noop(value); +} + +const styles = css` + :host { + display: block; + width: 100%; + font-family: var(--affine-font-family); + } + + :host(:hover) { + cursor: pointer; + background: var(--affine-hover-color); + } + + :host(.active) { + color: var(--affine-text-emphasis-color); + } + + .outline-block-preview { + width: 100%; + box-sizing: border-box; + padding: 6px 8px; + white-space: nowrap; + display: flex; + justify-content: start; + align-items: center; + gap: 8px; + } + + .icon { + display: flex; + align-items: center; + justify-content: center; + width: 22px; + height: 22px; + box-sizing: border-box; + padding: 4px; + background: var(--affine-background-secondary-color); + border-radius: 4px; + color: var(--affine-icon-color); + } + + .icon.disabled { + color: var(--affine-disabled-icon-color); + } + + .text { + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + flex: 1; + + font-size: var(--affine-font-sm); + line-height: 22px; + height: 22px; + } + + .text.general, + .subtype.text, + .subtype.quote { + font-weight: 400; + padding-left: 28px; + } + + .subtype.title, + .subtype.h1, + .subtype.h2, + .subtype.h3, + .subtype.h4, + .subtype.h5, + .subtype.h6 { + font-weight: 600; + } + + .subtype.title { + padding-left: 0; + } + .subtype.h1 { + padding-left: 0; + } + .subtype.h2 { + padding-left: 4px; + } + .subtype.h3 { + padding-left: 12px; + } + .subtype.h4 { + padding-left: 16px; + } + .subtype.h5 { + padding-left: 20px; + } + .subtype.h6 { + padding-left: 24px; + } + + .outline-block-preview:not(:has(span)) { + display: none; + } + + .text span { + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + + .linked-doc-preview svg { + width: 1.1em; + height: 1.1em; + vertical-align: middle; + font-size: inherit; + margin-bottom: 0.1em; + } + + .linked-doc-text { + font-size: inherit; + border-bottom: 0.5px solid var(--affine-divider-color); + white-space: break-spaces; + margin-right: 2px; + } + + .linked-doc-preview.unavailable svg { + color: var(--affine-text-disable-color); + } + + .linked-doc-preview.unavailable .linked-doc-text { + color: var(--affine-text-disable-color); + text-decoration: line-through; + } +`; + +export const AFFINE_OUTLINE_BLOCK_PREVIEW = 'affine-outline-block-preview'; + +export class OutlineBlockPreview extends SignalWatcher( + WithDisposable(LitElement) +) { + static override styles = styles; + + private _TextBlockPreview(block: ParagraphBlockModel | ListBlockModel) { + const deltas: DeltaInsert<AffineTextAttributes>[] = + block.text.yText.toDelta(); + if (!block.text.length) return nothing; + const iconClass = this.disabledIcon ? 'icon disabled' : 'icon'; + + const previewText = deltas.map(delta => { + if (delta.attributes?.reference) { + // If linked doc, render linked doc icon and the doc title. + const refAttribute = delta.attributes.reference; + const refMeta = block.doc.collection.meta.docMetas.find( + doc => doc.id === refAttribute.pageId + ); + const unavailable = !refMeta; + const title = unavailable ? 'Deleted doc' : refMeta.title; + return html`<span + class="linked-doc-preview ${unavailable ? 'unavailable' : ''}" + >${SmallLinkedDocIcon} + <span class="linked-doc-text" + >${title.length ? title : 'Untitled'}</span + ></span + >`; + } else { + // If not linked doc, render the text. + return delta.insert.toString().trim().length > 0 + ? html`<span>${delta.insert.toString()}</span>` + : nothing; + } + }); + + return html`<span class="text subtype ${block.type}">${previewText}</span> + ${this.showPreviewIcon + ? html`<span class=${iconClass}>${previewIconMap[block.type]}</span>` + : nothing}`; + } + + override render() { + return html`<div class="outline-block-preview"> + ${this.renderBlockByFlavour()} + </div>`; + } + + renderBlockByFlavour() { + const { block } = this; + const iconClass = this.disabledIcon ? 'icon disabled' : 'icon'; + + if ( + !this.enableNotesSorting && + !isHeadingBlock(block) && + !isRootBlock(block) + ) + return nothing; + + switch (block.flavour as keyof BlockSuite.BlockModels) { + case 'affine:page': + assertType<RootBlockModel>(block); + return block.title.length > 0 + ? html`<span class="text subtype title"> + ${block.title$.value} + </span>` + : nothing; + case 'affine:paragraph': + assertType<ParagraphBlockModel>(block); + return this._TextBlockPreview(block); + case 'affine:list': + assertType<ListBlockModel>(block); + return this._TextBlockPreview(block); + case 'affine:bookmark': + assertType<BookmarkBlockModel>(block); + return html` + <span class="text general" + >${block.title || block.url || placeholderMap['bookmark']}</span + > + ${this.showPreviewIcon + ? html`<span class=${iconClass} + >${previewIconMap['bookmark']}</span + >` + : nothing} + `; + case 'affine:code': + assertType<CodeBlockModel>(block); + return html` + <span class="text general" + >${block.language ?? placeholderMap['code']}</span + > + ${this.showPreviewIcon + ? html`<span class=${iconClass}>${previewIconMap['code']}</span>` + : nothing} + `; + case 'affine:database': + assertType<DatabaseBlockModel>(block); + return html` + <span class="text general" + >${block.title.toString().length + ? block.title.toString() + : placeholderMap['database']}</span + > + ${this.showPreviewIcon + ? html`<span class=${iconClass}>${previewIconMap['table']}</span>` + : nothing} + `; + case 'affine:image': + assertType<ImageBlockModel>(block); + return html` + <span class="text general" + >${block.caption?.length + ? block.caption + : placeholderMap['image']}</span + > + ${this.showPreviewIcon + ? html`<span class=${iconClass}>${previewIconMap['image']}</span>` + : nothing} + `; + case 'affine:attachment': + assertType<AttachmentBlockModel>(block); + return html` + <span class="text general" + >${block.name?.length + ? block.name + : placeholderMap['attachment']}</span + > + ${this.showPreviewIcon + ? html`<span class=${iconClass} + >${previewIconMap['attachment']}</span + >` + : nothing} + `; + default: + return nothing; + } + } + + @property({ attribute: false }) + accessor block!: ValuesOf<BlockSuite.BlockModels>; + + @property({ attribute: false }) + accessor cardNumber!: number; + + @property({ attribute: false }) + accessor disabledIcon = false; + + @property({ attribute: false }) + accessor enableNotesSorting!: boolean; + + @property({ attribute: false }) + accessor showPreviewIcon!: boolean; +} + +declare global { + interface HTMLElementTagNameMap { + [AFFINE_OUTLINE_BLOCK_PREVIEW]: OutlineBlockPreview; + } +} diff --git a/blocksuite/presets/src/fragments/outline/config.ts b/blocksuite/presets/src/fragments/outline/config.ts new file mode 100644 index 0000000000..9e1b3e8319 --- /dev/null +++ b/blocksuite/presets/src/fragments/outline/config.ts @@ -0,0 +1,86 @@ +import type { ParagraphBlockModel } from '@blocksuite/blocks'; +import type { TemplateResult } from 'lit'; + +import { + BlockPreviewIcon, + SmallAttachmentIcon, + SmallBookmarkIcon, + SmallBulletListIcon, + SmallCodeBlockIcon, + SmallDatabaseKanbanIcon, + SmallDatabaseTableIcon, + SmallHeading1Icon, + SmallHeading2Icon, + SmallHeading3Icon, + SmallHeading4Icon, + SmallHeading5Icon, + SmallHeading6Icon, + SmallImageIcon, + SmallNumberListIcon, + SmallQuoteBlockIcon, + SmallTextIcon, + SmallTodoIcon, +} from '../_common/icons.js'; + +const paragraphIconMap: Record< + ParagraphBlockModel['type'], + TemplateResult<1> +> = { + quote: SmallQuoteBlockIcon, + text: SmallTextIcon, + h1: SmallHeading1Icon, + h2: SmallHeading2Icon, + h3: SmallHeading3Icon, + h4: SmallHeading4Icon, + h5: SmallHeading5Icon, + h6: SmallHeading6Icon, +}; + +export const previewIconMap = { + ...paragraphIconMap, + code: SmallCodeBlockIcon, + numbered: SmallNumberListIcon, + bulleted: SmallBulletListIcon, + todo: SmallTodoIcon, + toggle: BlockPreviewIcon, + bookmark: SmallBookmarkIcon, + image: SmallImageIcon, + table: SmallDatabaseTableIcon, + kanban: SmallDatabaseKanbanIcon, + attachment: SmallAttachmentIcon, +}; + +const paragraphPlaceholderMap: Record<ParagraphBlockModel['type'], string> = { + quote: 'Quote', + text: 'Text Block', + h1: 'Heading 1', + h2: 'Heading 2', + h3: 'Heading 3', + h4: 'Heading 4', + h5: 'Heading 5', + h6: 'Heading 6', +}; + +export const placeholderMap = { + code: 'Code Block', + bulleted: 'Bulleted List', + numbered: 'Numbered List', + toggle: 'Toggle List', + todo: 'Todo', + bookmark: 'Bookmark', + image: 'Image', + database: 'Database', + attachment: 'Attachment', + ...paragraphPlaceholderMap, +}; + +export const headingKeys = new Set( + Object.keys(paragraphPlaceholderMap).filter(key => key.startsWith('h')) +); + +export const outlineSettingsKey = 'outlinePanelSettings'; + +export type OutlineSettingsDataType = { + showIcons: boolean; + enableSorting: boolean; +}; diff --git a/blocksuite/presets/src/fragments/outline/header/outline-panel-header.ts b/blocksuite/presets/src/fragments/outline/header/outline-panel-header.ts new file mode 100644 index 0000000000..0066c3f7de --- /dev/null +++ b/blocksuite/presets/src/fragments/outline/header/outline-panel-header.ts @@ -0,0 +1,166 @@ +import { createButtonPopper } from '@blocksuite/blocks'; +import { WithDisposable } from '@blocksuite/global/utils'; +import { css, html, LitElement } from 'lit'; +import { property, query, state } from 'lit/decorators.js'; + +import { SettingsIcon, SortingIcon } from '../../_common/icons.js'; + +const styles = css` + :host { + display: flex; + width: 100%; + height: 40px; + align-items: center; + justify-content: space-between; + box-sizing: border-box; + padding: 8px 16px; + } + + .outline-panel-header-container { + display: flex; + align-items: center; + justify-content: space-between; + width: 100%; + height: 100%; + box-sizing: border-box; + padding-right: 6px; + } + + .note-setting-container { + display: flex; + align-items: center; + gap: 8px; + } + + .outline-panel-header-label { + width: 119px; + height: 22px; + font-size: 14px; + font-weight: 500; + line-height: 22px; + color: var(--affine-text-secondary-color, #8e8d91); + } + + .note-sorting-button { + justify-self: end; + } + + .note-setting-button svg, + .note-sorting-button svg { + color: var(--affine-icon-secondary); + } + + .note-setting-button:hover svg, + .note-setting-button.active svg, + .note-sorting-button:hover svg { + color: var(--affine-icon-color); + } + + .note-sorting-button.active svg { + color: var(--affine-primary-color); + } + + .note-preview-setting-container { + display: none; + justify-content: center; + align-items: center; + background: var(--affine-background-overlay-panel-color); + box-shadow: var(--affine-shadow-2); + border-radius: 8px; + } + + .note-preview-setting-container[data-show] { + display: flex; + } +`; + +export const AFFINE_OUTLINE_PANEL_HEADER = 'affine-outline-panel-header'; + +export class OutlinePanelHeader extends WithDisposable(LitElement) { + static override styles = styles; + + private _notePreviewSettingMenuPopper: ReturnType< + typeof createButtonPopper + > | null = null; + + override firstUpdated() { + const _disposables = this._disposables; + + this._notePreviewSettingMenuPopper = createButtonPopper( + this._noteSettingButton, + this._notePreviewSettingMenu, + ({ display }) => { + this._settingPopperShow = display === 'show'; + }, + { + mainAxis: 14, + crossAxis: -30, + } + ); + _disposables.add(this._notePreviewSettingMenuPopper); + } + + override render() { + return html`<div class="outline-panel-header-container"> + <div class="note-setting-container"> + <span class="outline-panel-header-label">Table of Contents</span> + <edgeless-tool-icon-button + class="note-setting-button ${this._settingPopperShow + ? 'active' + : ''}" + .tooltip=${this._settingPopperShow ? '' : 'Preview Settings'} + .tipPosition=${'bottom'} + .active=${this._settingPopperShow} + .activeMode=${'background'} + @click=${() => this._notePreviewSettingMenuPopper?.toggle()} + > + ${SettingsIcon} + </edgeless-tool-icon-button> + </div> + <edgeless-tool-icon-button + class="note-sorting-button ${this.enableNotesSorting ? 'active' : ''}" + .tooltip=${'Visibility and sort'} + .tipPosition=${'left'} + .iconContainerPadding=${0} + .active=${this.enableNotesSorting} + .activeMode=${'color'} + @click=${() => this.toggleNotesSorting()} + > + ${SortingIcon} + </edgeless-tool-icon-button> + </div> + <div class="note-preview-setting-container"> + <affine-outline-note-preview-setting-menu + .showPreviewIcon=${this.showPreviewIcon} + .toggleShowPreviewIcon=${this.toggleShowPreviewIcon} + ></affine-outline-note-preview-setting-menu> + </div>`; + } + + @query('.note-preview-setting-container') + private accessor _notePreviewSettingMenu!: HTMLDivElement; + + @query('.note-setting-button') + private accessor _noteSettingButton!: HTMLDivElement; + + @state() + private accessor _settingPopperShow = false; + + @property({ attribute: false }) + accessor enableNotesSorting!: boolean; + + @property({ attribute: false }) + accessor showPreviewIcon!: boolean; + + @property({ attribute: false }) + accessor toggleNotesSorting!: () => void; + + @property({ attribute: false }) + accessor toggleShowPreviewIcon!: (on: boolean) => void; +} + +declare global { + interface HTMLElementTagNameMap { + [AFFINE_OUTLINE_PANEL_HEADER]: OutlinePanelHeader; + } +} diff --git a/blocksuite/presets/src/fragments/outline/header/outline-setting-menu.ts b/blocksuite/presets/src/fragments/outline/header/outline-setting-menu.ts new file mode 100644 index 0000000000..6bc29c7a76 --- /dev/null +++ b/blocksuite/presets/src/fragments/outline/header/outline-setting-menu.ts @@ -0,0 +1,94 @@ +import { WithDisposable } from '@blocksuite/global/utils'; +import { css, html, LitElement } from 'lit'; +import { property } from 'lit/decorators.js'; + +const styles = css` + :host { + display: block; + box-sizing: border-box; + padding: 8px; + width: 220px; + } + + .note-preview-setting-menu-container { + display: flex; + flex-direction: column; + box-sizing: border-box; + width: 100%; + } + + .note-preview-setting-menu-item { + display: flex; + box-sizing: border-box; + width: 100%; + height: 28px; + padding: 4px 12px; + align-items: center; + } + + .note-preview-setting-menu-item .setting-label { + font-family: sans-serif; + font-size: 12px; + font-weight: 500; + line-height: 20px; + color: var(--affine-text-secondary-color); + padding: 0 4px; + } + + .note-preview-setting-menu-item.action { + gap: 4px; + } + + .note-preview-setting-menu-item .action-label { + width: 138px; + height: 20px; + padding: 0 4px; + font-size: 12px; + font-weight: 500; + line-height: 20px; + color: var(--affine-text-primary-color); + } + + .note-preview-setting-menu-item .toggle-button { + display: flex; + } +`; + +export const AFFINE_OUTLINE_NOTE_PREVIEW_SETTING_MENU = + 'affine-outline-note-preview-setting-menu'; + +export class OutlineNotePreviewSettingMenu extends WithDisposable(LitElement) { + static override styles = styles; + + override render() { + return html`<div + class="note-preview-setting-menu-container" + @click=${(e: MouseEvent) => e.stopPropagation()} + > + <div class="note-preview-setting-menu-item"> + <div class="setting-label">Settings</div> + </div> + <div class="note-preview-setting-menu-item action"> + <div class="action-label">Show type icon</div> + <div class="toggle-button"> + <toggle-switch + .on=${this.showPreviewIcon} + .onChange=${this.toggleShowPreviewIcon} + ></toggle-switch> + </div> + </div> + </div>`; + } + + @property({ attribute: false }) + accessor showPreviewIcon!: boolean; + + @property({ attribute: false }) + accessor toggleShowPreviewIcon!: (on: boolean) => void; +} + +declare global { + interface HTMLElementTagNameMap { + [AFFINE_OUTLINE_NOTE_PREVIEW_SETTING_MENU]: OutlineNotePreviewSettingMenu; + } +} diff --git a/blocksuite/presets/src/fragments/outline/index.ts b/blocksuite/presets/src/fragments/outline/index.ts new file mode 100644 index 0000000000..01f75720b3 --- /dev/null +++ b/blocksuite/presets/src/fragments/outline/index.ts @@ -0,0 +1,2 @@ +export * from './outline-panel.js'; +export * from './outline-viewer.js'; diff --git a/blocksuite/presets/src/fragments/outline/outline-panel.ts b/blocksuite/presets/src/fragments/outline/outline-panel.ts new file mode 100644 index 0000000000..ce096f2bef --- /dev/null +++ b/blocksuite/presets/src/fragments/outline/outline-panel.ts @@ -0,0 +1,172 @@ +import { SignalWatcher, WithDisposable } from '@blocksuite/global/utils'; +import { baseTheme } from '@toeverything/theme'; +import { css, html, LitElement, unsafeCSS } from 'lit'; +import { property, state } from 'lit/decorators.js'; + +import type { AffineEditorContainer } from '../../editors/editor-container.js'; +import { type OutlineSettingsDataType, outlineSettingsKey } from './config.js'; + +const styles = css` + :host { + display: block; + width: 100%; + height: 100%; + } + + .outline-panel-container { + background-color: var(--affine-background-primary-color); + box-sizing: border-box; + + display: flex; + flex-direction: column; + align-items: stretch; + + height: 100%; + font-family: ${unsafeCSS(baseTheme.fontSansFamily)}; + padding-top: 8px; + position: relative; + } + + .outline-panel-body { + flex-grow: 1; + width: 100%; + + overflow-y: scroll; + } + .outline-panel-body::-webkit-scrollbar { + width: 4px; + } + .outline-panel-body::-webkit-scrollbar-thumb { + border-radius: 2px; + } + .outline-panel-body:hover::-webkit-scrollbar-thumb { + background-color: var(--affine-black-30); + } + .outline-panel-body::-webkit-scrollbar-track { + background-color: transparent; + } + .outline-panel-body::-webkit-scrollbar-corner { + display: none; + } +`; + +export const AFFINE_OUTLINE_PANEL = 'affine-outline-panel'; + +export class OutlinePanel extends SignalWatcher(WithDisposable(LitElement)) { + static override styles = styles; + + private _setNoticeVisibility = (visibility: boolean) => { + this._noticeVisible = visibility; + }; + + private _settings: OutlineSettingsDataType = { + showIcons: false, + enableSorting: false, + }; + + private _toggleNotesSorting = () => { + this._enableNotesSorting = !this._enableNotesSorting; + this._updateAndSaveSettings({ enableSorting: this._enableNotesSorting }); + }; + + private _toggleShowPreviewIcon = (on: boolean) => { + this._showPreviewIcon = on; + this._updateAndSaveSettings({ showIcons: on }); + }; + + get doc() { + return this.editor.doc; + } + + get edgeless() { + return this.editor.querySelector('affine-edgeless-root'); + } + + get host() { + return this.editor.host; + } + + get mode() { + return this.editor.mode; + } + + private _loadSettingsFromLocalStorage() { + const settings = localStorage.getItem(outlineSettingsKey); + if (settings) { + this._settings = JSON.parse(settings); + this._showPreviewIcon = this._settings.showIcons; + this._enableNotesSorting = this._settings.enableSorting; + } + } + + private _saveSettingsToLocalStorage() { + localStorage.setItem(outlineSettingsKey, JSON.stringify(this._settings)); + } + + private _updateAndSaveSettings( + newSettings: Partial<OutlineSettingsDataType> + ) { + this._settings = { ...this._settings, ...newSettings }; + this._saveSettingsToLocalStorage(); + } + + override connectedCallback() { + super.connectedCallback(); + this._loadSettingsFromLocalStorage(); + } + + override render() { + if (!this.host) return; + + return html` + <div class="outline-panel-container"> + <affine-outline-panel-header + .showPreviewIcon=${this._showPreviewIcon} + .enableNotesSorting=${this._enableNotesSorting} + .toggleShowPreviewIcon=${this._toggleShowPreviewIcon} + .toggleNotesSorting=${this._toggleNotesSorting} + ></affine-outline-panel-header> + <affine-outline-panel-body + class="outline-panel-body" + .doc=${this.doc} + .fitPadding=${this.fitPadding} + .edgeless=${this.edgeless} + .editor=${this.editor} + .mode=${this.mode} + .showPreviewIcon=${this._showPreviewIcon} + .enableNotesSorting=${this._enableNotesSorting} + .toggleNotesSorting=${this._toggleNotesSorting} + .noticeVisible=${this._noticeVisible} + .setNoticeVisibility=${this._setNoticeVisibility} + > + </affine-outline-panel-body> + <affine-outline-notice + .noticeVisible=${this._noticeVisible} + .toggleNotesSorting=${this._toggleNotesSorting} + .setNoticeVisibility=${this._setNoticeVisibility} + ></affine-outline-notice> + </div> + `; + } + + @state() + private accessor _enableNotesSorting = false; + + @state() + private accessor _noticeVisible = false; + + @state() + private accessor _showPreviewIcon = false; + + @property({ attribute: false }) + accessor editor!: AffineEditorContainer; + + @property({ attribute: false }) + accessor fitPadding!: number[]; +} + +declare global { + interface HTMLElementTagNameMap { + [AFFINE_OUTLINE_PANEL]: OutlinePanel; + } +} diff --git a/blocksuite/presets/src/fragments/outline/outline-viewer.ts b/blocksuite/presets/src/fragments/outline/outline-viewer.ts new file mode 100644 index 0000000000..93296c2a1a --- /dev/null +++ b/blocksuite/presets/src/fragments/outline/outline-viewer.ts @@ -0,0 +1,289 @@ +import { PropTypes, requiredProperties } from '@blocksuite/block-std'; +import { NoteDisplayMode, scrollbarStyle } from '@blocksuite/blocks'; +import { SignalWatcher, WithDisposable } from '@blocksuite/global/utils'; +import { signal } from '@preact/signals-core'; +import { css, html, LitElement, nothing } from 'lit'; +import { property, query, state } from 'lit/decorators.js'; +import { classMap } from 'lit/directives/class-map.js'; +import { repeat } from 'lit/directives/repeat.js'; + +import type { AffineEditorContainer } from '../../editors/editor-container.js'; +import { TocIcon } from '../_common/icons.js'; +import { getHeadingBlocksFromDoc } from './utils/query.js'; +import { + observeActiveHeadingDuringScroll, + scrollToBlockWithHighlight, +} from './utils/scroll.js'; + +export const AFFINE_OUTLINE_VIEWER = 'affine-outline-viewer'; + +@requiredProperties({ + editor: PropTypes.object, +}) +export class OutlineViewer extends SignalWatcher(WithDisposable(LitElement)) { + static override styles = css` + :host { + display: flex; + } + .outline-viewer-root { + --duration: 120ms; + --timing: cubic-bezier(0.42, 0, 0.58, 1); + + max-height: 100%; + box-sizing: border-box; + display: flex; + } + + .outline-viewer-indicators-container { + position: absolute; + top: 0; + right: 0; + max-height: 100%; + display: flex; + flex-direction: column; + align-items: flex-end; + overflow-y: hidden; + } + + .outline-viewer-indicator-wrapper { + flex: 1 1 16px; + display: flex; + align-items: center; + justify-content: center; + } + + .outline-viewer-indicator { + width: 20px; + height: 2px; + border-radius: 1px; + overflow: hidden; + background: var(--affine-black-10, rgba(0, 0, 0, 0.1)); + } + + .outline-viewer-indicator.active { + width: 24px; + background: var(--affine-text-primary-color); + } + + .outline-viewer-panel { + position: relative; + display: flex; + width: 0px; + max-height: 100%; + box-sizing: border-box; + flex-direction: column; + align-items: flex-start; + + border-radius: 8px; + border-width: 0px; + border-style: solid; + border-color: var(--affine-border-color); + background: var(--affine-background-overlay-panel-color); + box-shadow: 0px 6px 16px 0px rgba(0, 0, 0, 0.14); + + overflow-y: auto; + + opacity: 0; + transform: translateX(0px); + transition: + width 0s var(--duration), + padding 0s var(--duration), + border-width 0s var(--duration), + transform var(--duration) var(--timing), + opacity var(--duration) var(--timing); + } + + ${scrollbarStyle('.outline-viewer-panel')} + + .outline-viewer-header { + display: flex; + padding: 6px; + align-items: center; + gap: 4px; + align-self: stretch; + + span { + flex: 1; + overflow: hidden; + color: var(--affine-text-secondary-color); + text-overflow: ellipsis; + + font-family: var(--affine-font-family); + font-size: 12px; + font-style: normal; + font-weight: 500; + line-height: 20px; + } + } + + .outline-viewer-item { + display: flex; + align-items: center; + align-self: stretch; + } + + .outline-viewer-root:hover { + .outline-viewer-indicators-container { + visibility: hidden; + } + + .outline-viewer-panel { + width: 200px; + border-width: 1px; + padding: 8px 4px 8px 8px; + opacity: 1; + transform: translateX(-10px); + transition: + transform var(--duration) var(--timing), + opacity var(--duration) var(--timing); + } + } + `; + + private _activeHeadingId$ = signal<string | null>(null); + + private _highlightMaskDisposable = () => {}; + + private _lockActiveHeadingId = false; + + private _scrollPanel = () => { + this._activeItem?.scrollIntoView({ + behavior: 'instant', + block: 'center', + }); + }; + + private async _scrollToBlock(blockId: string) { + this._lockActiveHeadingId = true; + this._activeHeadingId$.value = blockId; + this._highlightMaskDisposable = await scrollToBlockWithHighlight( + this.editor, + blockId + ); + this._lockActiveHeadingId = false; + } + + private _toggleOutlinePanel() { + if (this.toggleOutlinePanel) { + this._showViewer = false; + this.toggleOutlinePanel(); + } + } + + override connectedCallback() { + super.connectedCallback(); + + this.disposables.add( + observeActiveHeadingDuringScroll( + () => this.editor, + newHeadingId => { + if (this._lockActiveHeadingId) return; + this._activeHeadingId$.value = newHeadingId; + } + ) + ); + } + + override disconnectedCallback() { + super.disconnectedCallback(); + this._highlightMaskDisposable(); + } + + override render() { + if (this.editor.doc.root === null || this.editor.mode === 'edgeless') + return nothing; + + const headingBlocks = getHeadingBlocksFromDoc( + this.editor.doc, + [NoteDisplayMode.DocAndEdgeless, NoteDisplayMode.DocOnly], + true + ); + + if (headingBlocks.length === 0) return nothing; + + const items = [ + ...(this.editor.doc.meta?.title !== '' ? [this.editor.doc.root] : []), + ...headingBlocks, + ]; + + const toggleOutlinePanelButton = + this.toggleOutlinePanel !== null + ? html`<edgeless-tool-icon-button + .tooltip=${'Open in sidebar'} + .tipPosition=${'top-end'} + .activeMode=${'background'} + @click=${this._toggleOutlinePanel} + data-testid="toggle-outline-panel-button" + > + ${TocIcon} + </edgeless-tool-icon-button>` + : nothing; + + return html` + <div class="outline-viewer-root" @mouseenter=${this._scrollPanel}> + <div class="outline-viewer-indicators-container"> + ${repeat( + items, + block => block.id, + block => + html`<div class="outline-viewer-indicator-wrapper"> + <div + class=${classMap({ + 'outline-viewer-indicator': true, + active: this._activeHeadingId$.value === block.id, + })} + ></div> + </div>` + )} + </div> + <div class="outline-viewer-panel"> + <div class="outline-viewer-item outline-viewer-header"> + <span>Table of Contents</span> + ${toggleOutlinePanelButton} + </div> + ${repeat( + items, + block => block.id, + block => { + return html`<div + class=${classMap({ + 'outline-viewer-item': true, + active: this._activeHeadingId$.value === block.id, + })} + > + <affine-outline-block-preview + class=${classMap({ + active: this._activeHeadingId$.value === block.id, + })} + .block=${block} + @click=${() => { + this._scrollToBlock(block.id).catch(console.error); + }} + > + </affine-outline-block-preview> + </div>`; + } + )} + </div> + </div> + `; + } + + @query('.outline-viewer-item.active') + private accessor _activeItem: HTMLElement | null = null; + + @state() + private accessor _showViewer: boolean = false; + + @property({ attribute: false }) + accessor editor!: AffineEditorContainer; + + @property({ attribute: false }) + accessor toggleOutlinePanel: (() => void) | null = null; +} + +declare global { + interface HTMLElementTagNameMap { + [AFFINE_OUTLINE_VIEWER]: OutlineViewer; + } +} diff --git a/blocksuite/presets/src/fragments/outline/utils/custom-events.ts b/blocksuite/presets/src/fragments/outline/utils/custom-events.ts new file mode 100644 index 0000000000..7140eb0baa --- /dev/null +++ b/blocksuite/presets/src/fragments/outline/utils/custom-events.ts @@ -0,0 +1,27 @@ +import type { NoteBlockModel, NoteDisplayMode } from '@blocksuite/blocks'; + +export type ReorderEvent = CustomEvent<{ + currentNumber: number; + targetNumber: number; + realIndex: number; +}>; + +export type SelectEvent = CustomEvent<{ + id: string; + selected: boolean; + number: number; + multiselect: boolean; +}>; + +export type FitViewEvent = CustomEvent<{ + block: NoteBlockModel; +}>; + +export type ClickBlockEvent = CustomEvent<{ + blockId: string; +}>; + +export type DisplayModeChangeEvent = CustomEvent<{ + note: NoteBlockModel; + newMode: NoteDisplayMode; +}>; diff --git a/blocksuite/presets/src/fragments/outline/utils/drag.ts b/blocksuite/presets/src/fragments/outline/utils/drag.ts new file mode 100644 index 0000000000..14595cd534 --- /dev/null +++ b/blocksuite/presets/src/fragments/outline/utils/drag.ts @@ -0,0 +1,106 @@ +import { on, once } from '@blocksuite/blocks'; +import type { Doc } from '@blocksuite/store'; + +import type { OutlinePanelBody } from '../body/outline-panel-body.js'; +import type { OutlineNoteCard } from '../card/outline-card.js'; + +/** + * start drag notes + * @param notes notes to drag + */ +export function startDragging(options: { + onDragEnd?: (insertIndex?: number) => void; + onDragMove?: (insertIdx?: number, indicatorTranslateY?: number) => void; + outlineListContainer: HTMLElement; + document: Document; + host: Document | HTMLElement; + container: OutlinePanelBody; + doc: Doc; +}) { + const { + document, + host, + container, + onDragMove, + onDragEnd, + outlineListContainer, + } = options; + const maskElement = createMaskElement(document); + const listContainerRect = outlineListContainer.getBoundingClientRect(); + const children = Array.from( + outlineListContainer.children + ) as OutlineNoteCard[]; + let idx: undefined | number; + let indicatorTranslateY: undefined | number; + + container.renderRoot.append(maskElement); + + const insideListContainer = (e: MouseEvent) => { + return ( + e.clientX >= listContainerRect.left && + e.clientX <= listContainerRect.right && + e.clientY >= listContainerRect.top && + e.clientY <= listContainerRect.bottom + ); + }; + + const disposeMove = on(container, 'mousemove', e => { + if (!insideListContainer(e)) { + idx = undefined; + onDragMove?.(idx, 0); + return; + } + + idx = 0; + for (const note of children) { + if (note.invisible || !note.note) break; + + const topBoundary = + listContainerRect.top + note.offsetTop - outlineListContainer.scrollTop; + const midBoundary = topBoundary + note.offsetHeight / 2; + const bottomBoundary = topBoundary + note.offsetHeight; + + if (e.clientY >= topBoundary && e.clientY <= bottomBoundary) { + idx = e.clientY > midBoundary ? idx + 1 : idx; + + indicatorTranslateY = + e.clientY > midBoundary ? bottomBoundary : topBoundary; + indicatorTranslateY -= listContainerRect.top; + + onDragMove?.(idx, indicatorTranslateY); + return; + } + + ++idx; + } + + onDragMove?.(idx); + }); + + let ended = false; + const dragEnd = () => { + if (ended) return; + + ended = true; + maskElement.remove(); + + disposeMove(); + onDragEnd?.(idx); + }; + + once(host as Document, 'mouseup', dragEnd); +} + +function createMaskElement(doc: Document) { + const mask = doc.createElement('div'); + + mask.style.height = '100vh'; + mask.style.width = '100vw'; + mask.style.position = 'fixed'; + mask.style.left = '0'; + mask.style.top = '0'; + mask.style.zIndex = 'calc(var(--affine-z-index-popover, 0) + 3)'; + mask.style.cursor = 'grabbing'; + + return mask; +} diff --git a/blocksuite/presets/src/fragments/outline/utils/query.ts b/blocksuite/presets/src/fragments/outline/utils/query.ts new file mode 100644 index 0000000000..23bec22767 --- /dev/null +++ b/blocksuite/presets/src/fragments/outline/utils/query.ts @@ -0,0 +1,85 @@ +import { + BlocksUtils, + type NoteBlockModel, + type NoteDisplayMode, + type ParagraphBlockModel, + type RootBlockModel, +} from '@blocksuite/blocks'; +import type { BlockModel, Doc } from '@blocksuite/store'; + +import { headingKeys } from '../config.js'; + +type OutlineNoteItem = { + note: NoteBlockModel; + /** + * the index of the note inside its parent's children property + */ + index: number; + /** + * the number displayed on the outline panel + */ + number: number; +}; + +export function getNotesFromDoc( + doc: Doc, + modes: NoteDisplayMode[] +): OutlineNoteItem[] { + const rootModel = doc.root; + if (!rootModel) return []; + + const notes: OutlineNoteItem[] = []; + + rootModel.children.forEach((block, index) => { + if (!['affine:note'].includes(block.flavour)) return; + + const blockModel = block as NoteBlockModel; + const OutlineNoteItem = { + note: block as NoteBlockModel, + index, + number: index + 1, + }; + + if (modes.includes(blockModel.displayMode)) { + notes.push(OutlineNoteItem); + } + }); + + return notes; +} + +export function isRootBlock(block: BlockModel): block is RootBlockModel { + return BlocksUtils.matchFlavours(block, ['affine:page']); +} + +export function isHeadingBlock( + block: BlockModel +): block is ParagraphBlockModel { + return ( + BlocksUtils.matchFlavours(block, ['affine:paragraph']) && + headingKeys.has(block.type$.value) + ); +} + +export function getHeadingBlocksFromNote( + note: NoteBlockModel, + ignoreEmpty = false +) { + const models = note.children.filter(block => { + const empty = block.text && block.text.length > 0; + return isHeadingBlock(block) && (!ignoreEmpty || empty); + }); + + return models; +} + +export function getHeadingBlocksFromDoc( + doc: Doc, + modes: NoteDisplayMode[], + ignoreEmpty = false +) { + const notes = getNotesFromDoc(doc, modes); + return notes + .map(({ note }) => getHeadingBlocksFromNote(note, ignoreEmpty)) + .flat(); +} diff --git a/blocksuite/presets/src/fragments/outline/utils/scroll.ts b/blocksuite/presets/src/fragments/outline/utils/scroll.ts new file mode 100644 index 0000000000..fd1884c227 --- /dev/null +++ b/blocksuite/presets/src/fragments/outline/utils/scroll.ts @@ -0,0 +1,206 @@ +import type { EditorHost } from '@blocksuite/block-std'; +import { NoteDisplayMode } from '@blocksuite/blocks'; +import { clamp, DisposableGroup } from '@blocksuite/global/utils'; + +import type { AffineEditorContainer } from '../../../editors/editor-container.js'; +import { getDocTitleByEditorHost } from '../../doc-title/doc-title.js'; +import { getHeadingBlocksFromDoc } from './query.js'; + +export function scrollToBlock(editor: AffineEditorContainer, blockId: string) { + const { host, mode } = editor; + if (mode === 'edgeless' || !host) return; + + if (editor.doc.root?.id === blockId) { + const docTitle = getDocTitleByEditorHost(host); + if (!docTitle) return; + + docTitle.scrollIntoView({ + behavior: 'smooth', + block: 'start', + }); + } else { + const block = host.view.getBlock(blockId); + if (!block) return; + block.scrollIntoView({ + behavior: 'smooth', + block: 'center', + }); + } +} + +export function isBlockBeforeViewportCenter( + blockId: string, + editorHost: EditorHost +) { + const block = editorHost.view.getBlock(blockId); + if (!block) return false; + + const editorRect = ( + editorHost.parentElement ?? editorHost + ).getBoundingClientRect(); + const blockRect = block.getBoundingClientRect(); + + const editorCenter = + clamp(editorRect.top, 0, document.documentElement.clientHeight) + + Math.min(editorRect.height, document.documentElement.clientHeight) / 2; + + const blockCenter = blockRect.top + blockRect.height / 2; + + return blockCenter < editorCenter + blockRect.height; +} + +export const observeActiveHeadingDuringScroll = ( + getEditor: () => AffineEditorContainer, // workaround for editor changed + update: (activeHeading: string | null) => void +) => { + const editor = getEditor(); + update(editor.doc.root?.id ?? null); + + const disposables = new DisposableGroup(); + disposables.addFromEvent( + window, + 'scroll', + () => { + const { host } = getEditor(); + if (!host) return; + + const headings = getHeadingBlocksFromDoc( + host.doc, + [NoteDisplayMode.DocAndEdgeless, NoteDisplayMode.DocOnly], + true + ); + + let activeHeadingId = host.doc.root?.id ?? null; + headings.forEach(heading => { + if (isBlockBeforeViewportCenter(heading.id, host)) { + activeHeadingId = heading.id; + } + }); + update(activeHeadingId); + }, + true + ); + + return disposables; +}; + +let highlightMask: HTMLDivElement | null = null; +let highlightTimeoutId: ReturnType<typeof setTimeout> | null = null; + +function highlightBlock(editor: AffineEditorContainer, blockId: string) { + const emptyClear = () => {}; + + const { host } = editor; + if (!host) return emptyClear; + + if (editor.doc.root?.id === blockId) return emptyClear; + + const rootComponent = host.querySelector('affine-page-root'); + if (!rootComponent) return emptyClear; + + if (!rootComponent.viewport) { + console.error('viewport should exist'); + return emptyClear; + } + + const { + top: offsetY, + left: offsetX, + scrollTop, + scrollLeft, + } = rootComponent.viewport; + + const block = host.view.getBlock(blockId); + if (!block) return emptyClear; + + const blockRect = block.getBoundingClientRect(); + const { top, left, width, height } = blockRect; + + if (!highlightMask) { + highlightMask = document.createElement('div'); + rootComponent.append(highlightMask); + } + + Object.assign(highlightMask.style, { + position: 'absolute', + top: `${top - offsetY + scrollTop}px`, + left: `${left - offsetX + scrollLeft}px`, + width: `${width}px`, + height: `${height}px`, + background: 'var(--affine-hover-color)', + borderRadius: '4px', + display: 'block', + }); + + // Clear the previous timeout if it exists + if (highlightTimeoutId !== null) { + clearTimeout(highlightTimeoutId); + } + + highlightTimeoutId = setTimeout(() => { + if (highlightMask) { + highlightMask.style.display = 'none'; + } + }, 1000); + + return () => { + if (highlightMask !== null) { + highlightMask.remove(); + highlightMask = null; + } + if (highlightTimeoutId !== null) { + clearTimeout(highlightTimeoutId); + highlightTimeoutId = null; + } + }; +} + +// this function is useful when the scroll need smooth animation +let highlightIntervalId: ReturnType<typeof setInterval> | null = null; +export async function scrollToBlockWithHighlight( + editor: AffineEditorContainer, + blockId: string, + timeout = 3000 +) { + scrollToBlock(editor, blockId); + + let timeCount = 0; + + return new Promise<ReturnType<typeof highlightBlock>>(resolve => { + if (highlightIntervalId !== null) { + clearInterval(highlightIntervalId); + } + + // wait block be scrolled into view + let lastTop = -1; + highlightIntervalId = setInterval(() => { + if (highlightIntervalId === null) { + console.error('unreachable code'); + return; + } + + const { host } = editor; + const block = host?.view.getBlock(blockId); + + if (!host || !block || timeCount > timeout) { + clearInterval(highlightIntervalId); + resolve(() => {}); + return; + } + + const blockRect = block.getBoundingClientRect(); + const { top } = blockRect; + + if (top !== lastTop) { + timeCount += 100; + lastTop = top; + return; + } + + clearInterval(highlightIntervalId); + + // highlight block + resolve(highlightBlock(editor, blockId)); + }, 100); + }); +} diff --git a/blocksuite/presets/src/helpers/index.ts b/blocksuite/presets/src/helpers/index.ts new file mode 100644 index 0000000000..983f3004b1 --- /dev/null +++ b/blocksuite/presets/src/helpers/index.ts @@ -0,0 +1,21 @@ +import { AffineSchemas } from '@blocksuite/blocks/schemas'; +import { DocCollection, Schema } from '@blocksuite/store'; + +export function createEmptyDoc() { + const schema = new Schema().register(AffineSchemas); + const collection = new DocCollection({ schema }); + collection.meta.initialize(); + const doc = collection.createDoc(); + + return { + doc, + init() { + doc.load(); + const rootId = doc.addBlock('affine:page', {}); + doc.addBlock('affine:surface', {}, rootId); + const noteId = doc.addBlock('affine:note', {}, rootId); + doc.addBlock('affine:paragraph', {}, noteId); + return doc; + }, + }; +} diff --git a/blocksuite/presets/src/index.ts b/blocksuite/presets/src/index.ts new file mode 100644 index 0000000000..c1e96203c0 --- /dev/null +++ b/blocksuite/presets/src/index.ts @@ -0,0 +1,26 @@ +import '@blocksuite/affine-block-surface/effects'; + +export * from './editors/index.js'; +export * from './fragments/index.js'; +export * from './helpers/index.js'; + +const env = + typeof globalThis !== 'undefined' + ? globalThis + : typeof window !== 'undefined' + ? window + : typeof global !== 'undefined' + ? global + : {}; +const importIdentifier = '__ $BLOCKSUITE_EDITOR$ __'; + +// @ts-expect-error FIXME: ts error +if (env[importIdentifier] === true) { + // https://github.com/yjs/yjs/issues/438 + console.error( + '@blocksuite/presets was already imported. This breaks constructor checks and will lead to issues!' + ); +} + +// @ts-expect-error FIXME: ts error +env[importIdentifier] = true; diff --git a/blocksuite/presets/tsconfig.json b/blocksuite/presets/tsconfig.json new file mode 100644 index 0000000000..0ddbcd9d24 --- /dev/null +++ b/blocksuite/presets/tsconfig.json @@ -0,0 +1,25 @@ +{ + "extends": "../tsconfig.json", + "compilerOptions": { + "rootDir": "./src/", + "outDir": "./dist/", + "noEmit": false, + "resolveJsonModule": true, + "esModuleInterop": true + }, + "include": ["./src", "./src/**/*.json"], + "references": [ + { + "path": "../framework/global" + }, + { + "path": "../framework/store" + }, + { + "path": "../framework/block-std" + }, + { + "path": "../blocks" + } + ] +} diff --git a/blocksuite/presets/typedoc.json b/blocksuite/presets/typedoc.json new file mode 100644 index 0000000000..f593f276c2 --- /dev/null +++ b/blocksuite/presets/typedoc.json @@ -0,0 +1,4 @@ +{ + "extends": ["../../typedoc.base.json"], + "entryPoints": ["src/index.ts"] +} diff --git a/blocksuite/presets/vitest.config.ts b/blocksuite/presets/vitest.config.ts new file mode 100644 index 0000000000..9c15ac7432 --- /dev/null +++ b/blocksuite/presets/vitest.config.ts @@ -0,0 +1,60 @@ +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { defineConfig } from 'vitest/config'; + +export default defineConfig(_configEnv => + defineConfig({ + esbuild: { target: 'es2018' }, + optimizeDeps: { + force: true, + esbuildOptions: { + // Vitest hardcodes the esbuild target to es2020, + // override it to es2022 for top level await. + target: 'es2022', + }, + }, + test: { + include: ['src/__tests__/**/*.spec.ts'], + browser: { + enabled: true, + headless: process.env.CI === 'true', + name: 'chromium', + provider: 'playwright', + isolate: false, + providerOptions: {}, + }, + coverage: { + provider: 'istanbul', // or 'c8' + reporter: ['lcov'], + reportsDirectory: '../../.coverage/presets', + }, + deps: { + interopDefault: true, + }, + testTransformMode: { + web: ['src/__tests__/**/*.spec.ts'], + }, + alias: { + '@blocksuite/blocks': path.resolve( + fileURLToPath(new URL('../blocks/src', import.meta.url)) + ), + '@blocksuite/blocks/*': path.resolve( + fileURLToPath(new URL('../blocks/src/*', import.meta.url)) + ), + '@blocksuite/global/*': path.resolve( + fileURLToPath(new URL('../framework/global/src/*', import.meta.url)) + ), + '@blocksuite/store': path.resolve( + fileURLToPath(new URL('../framework/store/src', import.meta.url)) + ), + '@blocksuite/inline': path.resolve( + fileURLToPath(new URL('../framework/inline/src', import.meta.url)) + ), + '@blocksuite/inline/*': path.resolve( + fileURLToPath(new URL('../framework/inline/src/*', import.meta.url)) + ), + }, + }, + }) +); diff --git a/blocksuite/tsconfig.json b/blocksuite/tsconfig.json new file mode 100644 index 0000000000..816c407bfc --- /dev/null +++ b/blocksuite/tsconfig.json @@ -0,0 +1,78 @@ +{ + "extends": "../tsconfig.json", + "compilerOptions": { + "useDefineForClassFields": false, + "module": "NodeNext", + "lib": ["ESNext", "DOM", "DOM.Iterable"], + "moduleResolution": "NodeNext", + "esModuleInterop": true, + "noEmitOnError": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noImplicitReturns": true, + "noImplicitOverride": true, + "skipLibCheck": true, + "incremental": true, + "composite": true + }, + "files": [], + "references": [ + { + "path": "./framework/block-std" + }, + { + "path": "./framework/store" + }, + { + "path": "./framework/global" + }, + { + "path": "./framework/inline" + }, + { + "path": "./blocks" + }, + { + "path": "./presets" + }, + { + "path": "./affine/all" + }, + { + "path": "./affine/block-embed" + }, + { + "path": "./affine/block-list" + }, + { + "path": "./affine/block-paragraph" + }, + { + "path": "./affine/block-surface" + }, + { + "path": "./affine/components" + }, + { + "path": "./affine/data-view" + }, + { + "path": "./affine/model" + }, + { + "path": "./affine/shared" + }, + { + "path": "./affine/widget-scroll-anchoring" + } + ], + "ts-node": { + "esm": true, + "experimentalSpecifierResolution": "node", + "compilerOptions": { + "noUnusedLocals": false, + "noUnusedParameters": false, + "noImplicitReturns": false + } + } +} diff --git a/oxlint.json b/oxlint.json index 45aa4926f8..e5fe6d31d5 100644 --- a/oxlint.json +++ b/oxlint.json @@ -108,7 +108,12 @@ ], "typescript/prefer-as-const": "error", "typescript/no-var-requires": "error", - "typescript/no-namespace": "error", + "typescript/no-namespace": [ + "error", + { + "allowDeclarations": true + } + ], "typescript/ban-ts-comment": [ "error", { @@ -181,6 +186,14 @@ } ] } + }, + { + "files": ["blocksuite/**/*.ts"], + "rules": { + "eslint/eqeqeq": "off", + "typescript/no-non-null-assertion": "off", + "unicorn/prefer-array-some": "off" + } } ] } diff --git a/package.json b/package.json index d521103632..69128e0b88 100644 --- a/package.json +++ b/package.json @@ -6,6 +6,7 @@ "license": "MIT", "workspaces": [ ".", + "blocksuite/**/*", "packages/*/*", "packages/frontend/apps/*", "tools/*", diff --git a/packages/common/env/package.json b/packages/common/env/package.json index 27e9b05ceb..71397e2b31 100644 --- a/packages/common/env/package.json +++ b/packages/common/env/package.json @@ -3,7 +3,7 @@ "private": true, "type": "module", "devDependencies": { - "@blocksuite/affine": "0.19.5", + "@blocksuite/affine": "workspace:*", "vitest": "2.1.8" }, "exports": { diff --git a/packages/common/env/src/constant.ts b/packages/common/env/src/constant.ts index 43dfd43062..1c91e6db40 100644 --- a/packages/common/env/src/constant.ts +++ b/packages/common/env/src/constant.ts @@ -1,4 +1,6 @@ // This file should has not side effect +// oxlint-disable-next-line +// @ts-ignore FIXME: typecheck error import type { DocCollection } from '@blocksuite/affine/store'; declare global { diff --git a/packages/common/env/tsconfig.json b/packages/common/env/tsconfig.json index dec9fd8c92..4bbd8d0b79 100644 --- a/packages/common/env/tsconfig.json +++ b/packages/common/env/tsconfig.json @@ -5,6 +5,5 @@ "composite": true, "noEmit": false, "outDir": "lib" - }, - "references": [] + } } diff --git a/packages/common/infra/package.json b/packages/common/infra/package.json index d4bc741c0e..24f33a8df5 100644 --- a/packages/common/infra/package.json +++ b/packages/common/infra/package.json @@ -15,7 +15,7 @@ "@affine/debug": "workspace:*", "@affine/env": "workspace:*", "@affine/templates": "workspace:*", - "@blocksuite/affine": "0.19.5", + "@blocksuite/affine": "workspace:*", "@datastructures-js/binary-search-tree": "^5.3.2", "eventemitter2": "^6.4.9", "foxact": "^0.2.43", diff --git a/packages/common/infra/tsconfig.json b/packages/common/infra/tsconfig.json index 11279f55b9..5a5e7d4e4e 100644 --- a/packages/common/infra/tsconfig.json +++ b/packages/common/infra/tsconfig.json @@ -13,6 +13,9 @@ { "path": "../debug" }, + { + "path": "../../../blocksuite/tsconfig.json" + }, { "path": "./tsconfig.node.json" } diff --git a/packages/frontend/apps/android/package.json b/packages/frontend/apps/android/package.json index 18a7382ff1..fffe61ad84 100644 --- a/packages/frontend/apps/android/package.json +++ b/packages/frontend/apps/android/package.json @@ -13,7 +13,7 @@ "@affine/component": "workspace:*", "@affine/core": "workspace:*", "@affine/i18n": "workspace:*", - "@blocksuite/affine": "0.19.5", + "@blocksuite/affine": "workspace:*", "@blocksuite/icons": "2.1.75", "@capacitor/android": "^6.2.0", "@capacitor/core": "^6.2.0", diff --git a/packages/frontend/apps/electron/package.json b/packages/frontend/apps/electron/package.json index 02f0dc7de1..3596818e5f 100644 --- a/packages/frontend/apps/electron/package.json +++ b/packages/frontend/apps/electron/package.json @@ -30,7 +30,7 @@ "@affine/i18n": "workspace:*", "@affine/native": "workspace:*", "@affine/nbstore": "workspace:*", - "@blocksuite/affine": "0.19.5", + "@blocksuite/affine": "workspace:*", "@electron-forge/cli": "^7.6.0", "@electron-forge/core": "^7.6.0", "@electron-forge/core-utils": "^7.6.0", diff --git a/packages/frontend/apps/ios/package.json b/packages/frontend/apps/ios/package.json index 3faafe0297..192ca564b8 100644 --- a/packages/frontend/apps/ios/package.json +++ b/packages/frontend/apps/ios/package.json @@ -15,7 +15,7 @@ "@affine/component": "workspace:*", "@affine/core": "workspace:*", "@affine/i18n": "workspace:*", - "@blocksuite/affine": "0.19.5", + "@blocksuite/affine": "workspace:*", "@blocksuite/icons": "2.1.75", "@capacitor/app": "^6.0.2", "@capacitor/browser": "^6.0.4", diff --git a/packages/frontend/apps/mobile/package.json b/packages/frontend/apps/mobile/package.json index 61869dd885..75cf6da660 100644 --- a/packages/frontend/apps/mobile/package.json +++ b/packages/frontend/apps/mobile/package.json @@ -13,7 +13,7 @@ "@affine/component": "workspace:*", "@affine/core": "workspace:*", "@affine/i18n": "workspace:*", - "@blocksuite/affine": "0.19.5", + "@blocksuite/affine": "workspace:*", "@blocksuite/icons": "2.1.75", "@sentry/react": "^8.44.0", "react": "^19.0.0", diff --git a/packages/frontend/component/package.json b/packages/frontend/component/package.json index 4185a2eaa3..51680b150d 100644 --- a/packages/frontend/component/package.json +++ b/packages/frontend/component/package.json @@ -66,7 +66,7 @@ "zod": "^3.24.1" }, "devDependencies": { - "@blocksuite/affine": "0.19.5", + "@blocksuite/affine": "workspace:*", "@blocksuite/icons": "2.1.75", "@chromatic-com/storybook": "^3.2.2", "@storybook/addon-essentials": "^8.4.7", diff --git a/packages/frontend/core/package.json b/packages/frontend/core/package.json index 68c111eb98..d017c9fc47 100644 --- a/packages/frontend/core/package.json +++ b/packages/frontend/core/package.json @@ -16,7 +16,7 @@ "@affine/i18n": "workspace:*", "@affine/templates": "workspace:*", "@affine/track": "workspace:*", - "@blocksuite/affine": "0.19.5", + "@blocksuite/affine": "workspace:*", "@blocksuite/icons": "2.1.75", "@capacitor/app": "^6.0.2", "@capacitor/browser": "^6.0.4", diff --git a/packages/frontend/core/src/modules/docs-search/worker/in-worker.ts b/packages/frontend/core/src/modules/docs-search/worker/in-worker.ts index f697e6f3d5..a6bb1af4d4 100644 --- a/packages/frontend/core/src/modules/docs-search/worker/in-worker.ts +++ b/packages/frontend/core/src/modules/docs-search/worker/in-worker.ts @@ -116,7 +116,7 @@ function generateMarkdownPreviewBuilder( blocks: BlockDocumentInfo[] ) { function yblockToDraftModal(yblock: YBlock): DraftModel | null { - const flavour = yblock.get('sys:flavour'); + const flavour = yblock.get('sys:flavour') as string; const blockSchema = blocksuiteSchema.flavourSchemaMap.get(flavour); if (!blockSchema) { return null; @@ -131,7 +131,7 @@ function generateMarkdownPreviewBuilder( return { ...props, - id: yblock.get('sys:id'), + id: yblock.get('sys:id') as string, flavour, children: [], role: blockSchema.model.role, diff --git a/tools/cli/package.json b/tools/cli/package.json index a05f939523..34b905b8a6 100644 --- a/tools/cli/package.json +++ b/tools/cli/package.json @@ -6,7 +6,7 @@ "@affine/env": "workspace:*", "@affine/templates": "workspace:*", "@aws-sdk/client-s3": "^3.709.0", - "@blocksuite/affine": "0.19.5", + "@blocksuite/affine": "workspace:*", "@clack/core": "^0.3.5", "@clack/prompts": "^0.8.2", "@magic-works/i18n-codegen": "^0.6.1", diff --git a/tsconfig.json b/tsconfig.json index 5b53d87062..c5634e98a5 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -157,6 +157,10 @@ { "path": "./tests/affine-desktop" }, + // Blocksuite + { + "path": "./blocksuite" + }, // Others { "path": "./tsconfig.node.json" diff --git a/yarn.lock b/yarn.lock index de622edffe..427e86030f 100644 --- a/yarn.lock +++ b/yarn.lock @@ -161,7 +161,7 @@ __metadata: "@affine/component": "workspace:*" "@affine/core": "workspace:*" "@affine/i18n": "workspace:*" - "@blocksuite/affine": "npm:0.19.5" + "@blocksuite/affine": "workspace:*" "@blocksuite/icons": "npm:2.1.75" "@capacitor/android": "npm:^6.2.0" "@capacitor/cli": "npm:^6.2.0" @@ -208,7 +208,7 @@ __metadata: "@affine/env": "workspace:*" "@affine/templates": "workspace:*" "@aws-sdk/client-s3": "npm:^3.709.0" - "@blocksuite/affine": "npm:0.19.5" + "@blocksuite/affine": "workspace:*" "@clack/core": "npm:^0.3.5" "@clack/prompts": "npm:^0.8.2" "@magic-works/i18n-codegen": "npm:^0.6.1" @@ -265,7 +265,7 @@ __metadata: "@affine/i18n": "workspace:*" "@atlaskit/pragmatic-drag-and-drop": "patch:@atlaskit/pragmatic-drag-and-drop@npm%3A1.4.0#~/.yarn/patches/@atlaskit-pragmatic-drag-and-drop-npm-1.4.0-75c45f52d3.patch" "@atlaskit/pragmatic-drag-and-drop-hitbox": "npm:^1.0.3" - "@blocksuite/affine": "npm:0.19.5" + "@blocksuite/affine": "workspace:*" "@blocksuite/icons": "npm:2.1.75" "@chromatic-com/storybook": "npm:^3.2.2" "@emotion/react": "npm:^11.14.0" @@ -352,7 +352,7 @@ __metadata: "@affine/i18n": "workspace:*" "@affine/templates": "workspace:*" "@affine/track": "workspace:*" - "@blocksuite/affine": "npm:0.19.5" + "@blocksuite/affine": "workspace:*" "@blocksuite/icons": "npm:2.1.75" "@capacitor/app": "npm:^6.0.2" "@capacitor/browser": "npm:^6.0.4" @@ -465,7 +465,7 @@ __metadata: "@affine/i18n": "workspace:*" "@affine/native": "workspace:*" "@affine/nbstore": "workspace:*" - "@blocksuite/affine": "npm:0.19.5" + "@blocksuite/affine": "workspace:*" "@electron-forge/cli": "npm:^7.6.0" "@electron-forge/core": "npm:^7.6.0" "@electron-forge/core-utils": "npm:^7.6.0" @@ -525,7 +525,7 @@ __metadata: version: 0.0.0-use.local resolution: "@affine/env@workspace:packages/common/env" dependencies: - "@blocksuite/affine": "npm:0.19.5" + "@blocksuite/affine": "workspace:*" vitest: "npm:2.1.8" zod: "npm:^3.24.1" peerDependencies: @@ -574,7 +574,7 @@ __metadata: "@affine/component": "workspace:*" "@affine/core": "workspace:*" "@affine/i18n": "workspace:*" - "@blocksuite/affine": "npm:0.19.5" + "@blocksuite/affine": "workspace:*" "@blocksuite/icons": "npm:2.1.75" "@capacitor/app": "npm:^6.0.2" "@capacitor/browser": "npm:^6.0.4" @@ -602,7 +602,7 @@ __metadata: "@affine/component": "workspace:*" "@affine/core": "workspace:*" "@affine/i18n": "workspace:*" - "@blocksuite/affine": "npm:0.19.5" + "@blocksuite/affine": "workspace:*" "@blocksuite/icons": "npm:2.1.75" "@sentry/react": "npm:^8.44.0" "@types/react": "npm:^19.0.1" @@ -1908,7 +1908,7 @@ __metadata: languageName: node linkType: hard -"@babel/core@npm:7.26.0, @babel/core@npm:^7.14.0, @babel/core@npm:^7.18.5, @babel/core@npm:^7.18.9, @babel/core@npm:^7.22.1, @babel/core@npm:^7.22.9, @babel/core@npm:^7.23.9": +"@babel/core@npm:7.26.0, @babel/core@npm:^7.12.3, @babel/core@npm:^7.14.0, @babel/core@npm:^7.18.5, @babel/core@npm:^7.18.9, @babel/core@npm:^7.22.1, @babel/core@npm:^7.22.9, @babel/core@npm:^7.23.9": version: 7.26.0 resolution: "@babel/core@npm:7.26.0" dependencies: @@ -2067,7 +2067,7 @@ __metadata: languageName: node linkType: hard -"@babel/helper-plugin-utils@npm:^7.0.0, @babel/helper-plugin-utils@npm:^7.12.13, @babel/helper-plugin-utils@npm:^7.18.6, @babel/helper-plugin-utils@npm:^7.20.2, @babel/helper-plugin-utils@npm:^7.22.5, @babel/helper-plugin-utils@npm:^7.25.9, @babel/helper-plugin-utils@npm:^7.8.0": +"@babel/helper-plugin-utils@npm:^7.0.0, @babel/helper-plugin-utils@npm:^7.12.13, @babel/helper-plugin-utils@npm:^7.14.5, @babel/helper-plugin-utils@npm:^7.18.6, @babel/helper-plugin-utils@npm:^7.20.2, @babel/helper-plugin-utils@npm:^7.22.5, @babel/helper-plugin-utils@npm:^7.25.9, @babel/helper-plugin-utils@npm:^7.8.0": version: 7.25.9 resolution: "@babel/helper-plugin-utils@npm:7.25.9" checksum: 10/e347d87728b1ab10b6976d46403941c8f9008c045ea6d99997a7ffca7b852dc34b6171380f7b17edf94410e0857ff26f3a53d8618f11d73744db86e8ca9b8c64 @@ -2271,7 +2271,7 @@ __metadata: languageName: node linkType: hard -"@babel/plugin-syntax-class-properties@npm:^7.0.0": +"@babel/plugin-syntax-class-properties@npm:^7.0.0, @babel/plugin-syntax-class-properties@npm:^7.12.13": version: 7.12.13 resolution: "@babel/plugin-syntax-class-properties@npm:7.12.13" dependencies: @@ -2304,7 +2304,7 @@ __metadata: languageName: node linkType: hard -"@babel/plugin-syntax-import-assertions@npm:^7.20.0, @babel/plugin-syntax-import-assertions@npm:^7.26.0": +"@babel/plugin-syntax-import-assertions@npm:^7.12.1, @babel/plugin-syntax-import-assertions@npm:^7.20.0, @babel/plugin-syntax-import-assertions@npm:^7.26.0": version: 7.26.0 resolution: "@babel/plugin-syntax-import-assertions@npm:7.26.0" dependencies: @@ -2348,6 +2348,17 @@ __metadata: languageName: node linkType: hard +"@babel/plugin-syntax-top-level-await@npm:^7.12.1": + version: 7.14.5 + resolution: "@babel/plugin-syntax-top-level-await@npm:7.14.5" + dependencies: + "@babel/helper-plugin-utils": "npm:^7.14.5" + peerDependencies: + "@babel/core": ^7.0.0-0 + checksum: 10/bbd1a56b095be7820029b209677b194db9b1d26691fe999856462e66b25b281f031f3dfd91b1619e9dcf95bebe336211833b854d0fb8780d618e35667c2d0d7e + languageName: node + linkType: hard + "@babel/plugin-syntax-typescript@npm:^7.23.3, @babel/plugin-syntax-typescript@npm:^7.25.9": version: 7.25.9 resolution: "@babel/plugin-syntax-typescript@npm:7.25.9" @@ -3207,19 +3218,19 @@ __metadata: languageName: node linkType: hard -"@blocksuite/affine-block-embed@npm:0.19.5": - version: 0.19.5 - resolution: "@blocksuite/affine-block-embed@npm:0.19.5" +"@blocksuite/affine-block-embed@workspace:*, @blocksuite/affine-block-embed@workspace:blocksuite/affine/block-embed": + version: 0.0.0-use.local + resolution: "@blocksuite/affine-block-embed@workspace:blocksuite/affine/block-embed" dependencies: - "@blocksuite/affine-block-surface": "npm:0.19.5" - "@blocksuite/affine-components": "npm:0.19.5" - "@blocksuite/affine-model": "npm:0.19.5" - "@blocksuite/affine-shared": "npm:0.19.5" - "@blocksuite/block-std": "npm:0.19.5" - "@blocksuite/global": "npm:0.19.5" + "@blocksuite/affine-block-surface": "workspace:*" + "@blocksuite/affine-components": "workspace:*" + "@blocksuite/affine-model": "workspace:*" + "@blocksuite/affine-shared": "workspace:*" + "@blocksuite/block-std": "workspace:*" + "@blocksuite/global": "workspace:*" "@blocksuite/icons": "npm:^2.1.75" - "@blocksuite/inline": "npm:0.19.5" - "@blocksuite/store": "npm:0.19.5" + "@blocksuite/inline": "workspace:*" + "@blocksuite/store": "workspace:*" "@floating-ui/dom": "npm:^1.6.10" "@lit/context": "npm:^1.1.2" "@preact/signals-core": "npm:^1.8.0" @@ -3227,21 +3238,20 @@ __metadata: lit: "npm:^3.2.0" minimatch: "npm:^10.0.1" zod: "npm:^3.23.8" - checksum: 10/d0471b5a39f2a3f144922f88a64d62615fa071094063927c7cfa0ca87f5b7a37e0b30250c630dd5c8a87f816daf06e54497e190d75e74001f27a50aaca13a622 - languageName: node - linkType: hard + languageName: unknown + linkType: soft -"@blocksuite/affine-block-list@npm:0.19.5": - version: 0.19.5 - resolution: "@blocksuite/affine-block-list@npm:0.19.5" +"@blocksuite/affine-block-list@workspace:*, @blocksuite/affine-block-list@workspace:blocksuite/affine/block-list": + version: 0.0.0-use.local + resolution: "@blocksuite/affine-block-list@workspace:blocksuite/affine/block-list" dependencies: - "@blocksuite/affine-components": "npm:0.19.5" - "@blocksuite/affine-model": "npm:0.19.5" - "@blocksuite/affine-shared": "npm:0.19.5" - "@blocksuite/block-std": "npm:0.19.5" - "@blocksuite/global": "npm:0.19.5" - "@blocksuite/inline": "npm:0.19.5" - "@blocksuite/store": "npm:0.19.5" + "@blocksuite/affine-components": "workspace:*" + "@blocksuite/affine-model": "workspace:*" + "@blocksuite/affine-shared": "workspace:*" + "@blocksuite/block-std": "workspace:*" + "@blocksuite/global": "workspace:*" + "@blocksuite/inline": "workspace:*" + "@blocksuite/store": "workspace:*" "@floating-ui/dom": "npm:^1.6.10" "@lit/context": "npm:^1.1.2" "@preact/signals-core": "npm:^1.8.0" @@ -3250,21 +3260,20 @@ __metadata: lit: "npm:^3.2.0" minimatch: "npm:^10.0.1" zod: "npm:^3.23.8" - checksum: 10/ee33af69a09c5deceae039def619029dee286055d39ffbfd8e19b4337fb041f1ab53bd9812d43641fcb5abec42cf95ae6459d65791754eb734ccf70fb796cba1 - languageName: node - linkType: hard + languageName: unknown + linkType: soft -"@blocksuite/affine-block-paragraph@npm:0.19.5": - version: 0.19.5 - resolution: "@blocksuite/affine-block-paragraph@npm:0.19.5" +"@blocksuite/affine-block-paragraph@workspace:*, @blocksuite/affine-block-paragraph@workspace:blocksuite/affine/block-paragraph": + version: 0.0.0-use.local + resolution: "@blocksuite/affine-block-paragraph@workspace:blocksuite/affine/block-paragraph" dependencies: - "@blocksuite/affine-components": "npm:0.19.5" - "@blocksuite/affine-model": "npm:0.19.5" - "@blocksuite/affine-shared": "npm:0.19.5" - "@blocksuite/block-std": "npm:0.19.5" - "@blocksuite/global": "npm:0.19.5" - "@blocksuite/inline": "npm:0.19.5" - "@blocksuite/store": "npm:0.19.5" + "@blocksuite/affine-components": "workspace:*" + "@blocksuite/affine-model": "workspace:*" + "@blocksuite/affine-shared": "workspace:*" + "@blocksuite/block-std": "workspace:*" + "@blocksuite/global": "workspace:*" + "@blocksuite/inline": "workspace:*" + "@blocksuite/store": "workspace:*" "@floating-ui/dom": "npm:^1.6.10" "@lit/context": "npm:^1.1.2" "@preact/signals-core": "npm:^1.8.0" @@ -3273,49 +3282,51 @@ __metadata: lit: "npm:^3.2.0" minimatch: "npm:^10.0.1" zod: "npm:^3.23.8" - checksum: 10/b62c34afa43c99f71bbbf8099b0f81ad47e46feda7ee20d9e3b2747254f9a2c7e3685472fa8416eb266531a88eef747db54963265798992312fa5beac26664f2 - languageName: node - linkType: hard + languageName: unknown + linkType: soft -"@blocksuite/affine-block-surface@npm:0.19.5": - version: 0.19.5 - resolution: "@blocksuite/affine-block-surface@npm:0.19.5" +"@blocksuite/affine-block-surface@workspace:*, @blocksuite/affine-block-surface@workspace:blocksuite/affine/block-surface": + version: 0.0.0-use.local + resolution: "@blocksuite/affine-block-surface@workspace:blocksuite/affine/block-surface" dependencies: - "@blocksuite/affine-components": "npm:0.19.5" - "@blocksuite/affine-model": "npm:0.19.5" - "@blocksuite/affine-shared": "npm:0.19.5" - "@blocksuite/block-std": "npm:0.19.5" - "@blocksuite/global": "npm:0.19.5" - "@blocksuite/inline": "npm:0.19.5" - "@blocksuite/store": "npm:0.19.5" + "@blocksuite/affine-components": "workspace:*" + "@blocksuite/affine-model": "workspace:*" + "@blocksuite/affine-shared": "workspace:*" + "@blocksuite/block-std": "workspace:*" + "@blocksuite/global": "workspace:*" + "@blocksuite/inline": "workspace:*" + "@blocksuite/store": "workspace:*" "@lit/context": "npm:^1.1.2" "@preact/signals-core": "npm:^1.8.0" "@toeverything/theme": "npm:^1.1.1" + "@types/dompurify": "npm:^3.0.5" + "@types/lodash.chunk": "npm:^4.2.9" fractional-indexing: "npm:^3.2.0" lit: "npm:^3.2.0" lodash.chunk: "npm:^4.2.0" nanoid: "npm:^5.0.7" zod: "npm:^3.23.8" - checksum: 10/3885e2543982ea2c88acd3884fc7cf007b835097afb017cc5c7e79667aa3a72f4a87a36ed8d791b7a7791e1a17611da87b8722a0bacb20c03b1201f34474040c - languageName: node - linkType: hard + languageName: unknown + linkType: soft -"@blocksuite/affine-components@npm:0.19.5": - version: 0.19.5 - resolution: "@blocksuite/affine-components@npm:0.19.5" +"@blocksuite/affine-components@workspace:*, @blocksuite/affine-components@workspace:blocksuite/affine/components": + version: 0.0.0-use.local + resolution: "@blocksuite/affine-components@workspace:blocksuite/affine/components" dependencies: - "@blocksuite/affine-model": "npm:0.19.5" - "@blocksuite/affine-shared": "npm:0.19.5" - "@blocksuite/block-std": "npm:0.19.5" - "@blocksuite/global": "npm:0.19.5" + "@blocksuite/affine-model": "workspace:*" + "@blocksuite/affine-shared": "workspace:*" + "@blocksuite/block-std": "workspace:*" + "@blocksuite/global": "workspace:*" "@blocksuite/icons": "npm:^2.1.75" - "@blocksuite/inline": "npm:0.19.5" - "@blocksuite/store": "npm:0.19.5" + "@blocksuite/inline": "workspace:*" + "@blocksuite/store": "workspace:*" "@floating-ui/dom": "npm:^1.6.10" "@lit/context": "npm:^1.1.2" "@lottiefiles/dotlottie-wc": "npm:^0.4.0" "@preact/signals-core": "npm:^1.8.0" "@toeverything/theme": "npm:^1.1.1" + "@types/katex": "npm:^0.16.7" + "@types/lodash.clonedeep": "npm:^4.5.9" date-fns: "npm:^4.0.0" katex: "npm:^0.16.11" lit: "npm:^3.2.0" @@ -3323,85 +3334,82 @@ __metadata: lodash.clonedeep: "npm:^4.5.0" shiki: "npm:^1.12.0" zod: "npm:^3.23.8" - checksum: 10/3d0f74febc0bd0acb34585570ee582f9d6cbbd04f3c84596f7116ec616b25433474a6e242ab782541f03570bea1b86c511b5da221523cfa1272423919c55c61c - languageName: node - linkType: hard + languageName: unknown + linkType: soft -"@blocksuite/affine-model@npm:0.19.5": - version: 0.19.5 - resolution: "@blocksuite/affine-model@npm:0.19.5" +"@blocksuite/affine-model@workspace:*, @blocksuite/affine-model@workspace:blocksuite/affine/model": + version: 0.0.0-use.local + resolution: "@blocksuite/affine-model@workspace:blocksuite/affine/model" dependencies: - "@blocksuite/block-std": "npm:0.19.5" - "@blocksuite/global": "npm:0.19.5" - "@blocksuite/inline": "npm:0.19.5" - "@blocksuite/store": "npm:0.19.5" + "@blocksuite/block-std": "workspace:*" + "@blocksuite/global": "workspace:*" + "@blocksuite/inline": "workspace:*" + "@blocksuite/store": "workspace:*" fractional-indexing: "npm:^3.2.0" zod: "npm:^3.23.8" - checksum: 10/34904a9c36ec64f2f8acdb9f750318072c98238a81778eb2843df75e2789d96cd4f2a2b42162c3a64b499ecd7b1bd565a3a4833794c7570c3e5c7715fa85722e - languageName: node - linkType: hard + languageName: unknown + linkType: soft -"@blocksuite/affine-shared@npm:0.19.5": - version: 0.19.5 - resolution: "@blocksuite/affine-shared@npm:0.19.5" +"@blocksuite/affine-shared@workspace:*, @blocksuite/affine-shared@workspace:blocksuite/affine/shared": + version: 0.0.0-use.local + resolution: "@blocksuite/affine-shared@workspace:blocksuite/affine/shared" dependencies: - "@blocksuite/affine-model": "npm:0.19.5" - "@blocksuite/block-std": "npm:0.19.5" - "@blocksuite/global": "npm:0.19.5" + "@blocksuite/affine-model": "workspace:*" + "@blocksuite/block-std": "workspace:*" + "@blocksuite/global": "workspace:*" "@blocksuite/icons": "npm:^2.1.75" - "@blocksuite/inline": "npm:0.19.5" - "@blocksuite/store": "npm:0.19.5" + "@blocksuite/inline": "workspace:*" + "@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.1.1" "@types/hast": "npm:^3.0.4" + "@types/lodash.clonedeep": "npm:^4.5.9" + "@types/lodash.mergewith": "npm:^4" "@types/mdast": "npm:^4.0.4" lit: "npm:^3.2.0" lodash.clonedeep: "npm:^4.5.0" lodash.mergewith: "npm:^4.6.2" minimatch: "npm:^10.0.1" zod: "npm:^3.23.8" - checksum: 10/e203841a6b4cf62b4fa089549c0b2371837b5fac73cf8e45155d655a1f933e40bfd50e850302ee7a540dfe622a480d919fa75df7c7d4cb5912a3cce20567e44b - languageName: node - linkType: hard + languageName: unknown + linkType: soft -"@blocksuite/affine-widget-scroll-anchoring@npm:0.19.5": - version: 0.19.5 - resolution: "@blocksuite/affine-widget-scroll-anchoring@npm:0.19.5" +"@blocksuite/affine-widget-scroll-anchoring@workspace:*, @blocksuite/affine-widget-scroll-anchoring@workspace:blocksuite/affine/widget-scroll-anchoring": + version: 0.0.0-use.local + resolution: "@blocksuite/affine-widget-scroll-anchoring@workspace:blocksuite/affine/widget-scroll-anchoring" dependencies: - "@blocksuite/affine-model": "npm:0.19.5" - "@blocksuite/affine-shared": "npm:0.19.5" - "@blocksuite/block-std": "npm:0.19.5" - "@blocksuite/global": "npm:0.19.5" + "@blocksuite/affine-model": "workspace:*" + "@blocksuite/affine-shared": "workspace:*" + "@blocksuite/block-std": "workspace:*" + "@blocksuite/global": "workspace:*" "@preact/signals-core": "npm:^1.8.0" "@toeverything/theme": "npm:^1.1.1" lit: "npm:^3.2.0" - checksum: 10/ab86b3fec3579afad84de53b7b321cb0322581071eec6ecfb586191006d454892e716b5980252c274b526780ae451d35c8d509b193f45598178004f67e936c2b - languageName: node - linkType: hard + languageName: unknown + linkType: soft -"@blocksuite/affine@npm:0.19.5": - version: 0.19.5 - resolution: "@blocksuite/affine@npm:0.19.5" +"@blocksuite/affine@workspace:*, @blocksuite/affine@workspace:blocksuite/affine/all": + version: 0.0.0-use.local + resolution: "@blocksuite/affine@workspace:blocksuite/affine/all" dependencies: - "@blocksuite/block-std": "npm:0.19.5" - "@blocksuite/blocks": "npm:0.19.5" - "@blocksuite/global": "npm:0.19.5" - "@blocksuite/inline": "npm:0.19.5" - "@blocksuite/presets": "npm:0.19.5" - "@blocksuite/store": "npm:0.19.5" - checksum: 10/9536042e028f9b1b7aff47c7f3d3415bf7c065da5e8021d0ea21f42adc3e146f395edf29616e719a98474cc471959b2e4d550167ce7e2df8e2cd1518e0ea1feb - languageName: node - linkType: hard + "@blocksuite/block-std": "workspace:*" + "@blocksuite/blocks": "workspace:*" + "@blocksuite/global": "workspace:*" + "@blocksuite/inline": "workspace:*" + "@blocksuite/presets": "workspace:*" + "@blocksuite/store": "workspace:*" + languageName: unknown + linkType: soft -"@blocksuite/block-std@npm:0.19.5": - version: 0.19.5 - resolution: "@blocksuite/block-std@npm:0.19.5" +"@blocksuite/block-std@workspace:*, @blocksuite/block-std@workspace:blocksuite/framework/block-std": + version: 0.0.0-use.local + resolution: "@blocksuite/block-std@workspace:blocksuite/framework/block-std" dependencies: - "@blocksuite/global": "npm:0.19.5" - "@blocksuite/inline": "npm:0.19.5" - "@blocksuite/store": "npm:0.19.5" + "@blocksuite/global": "workspace:*" + "@blocksuite/inline": "workspace:*" + "@blocksuite/store": "workspace:*" "@lit/context": "npm:^1.1.2" "@preact/signals-core": "npm:^1.8.0" "@types/hast": "npm:^3.0.4" @@ -3413,33 +3421,34 @@ __metadata: unified: "npm:^11.0.5" w3c-keyname: "npm:^2.2.8" zod: "npm:^3.23.8" - checksum: 10/cd50184cce2bf799c90cd814229c781c6c7b2ead800f6b433e73917f842f1a0e2b907d1ea06f885101d0d99d9cf9c74bbac0386d877367edde8c4144581dc652 - languageName: node - linkType: hard + languageName: unknown + linkType: soft -"@blocksuite/blocks@npm:0.19.5": - version: 0.19.5 - resolution: "@blocksuite/blocks@npm:0.19.5" +"@blocksuite/blocks@workspace:*, @blocksuite/blocks@workspace:blocksuite/blocks": + version: 0.0.0-use.local + resolution: "@blocksuite/blocks@workspace:blocksuite/blocks" dependencies: - "@blocksuite/affine-block-embed": "npm:0.19.5" - "@blocksuite/affine-block-list": "npm:0.19.5" - "@blocksuite/affine-block-paragraph": "npm:0.19.5" - "@blocksuite/affine-block-surface": "npm:0.19.5" - "@blocksuite/affine-components": "npm:0.19.5" - "@blocksuite/affine-model": "npm:0.19.5" - "@blocksuite/affine-shared": "npm:0.19.5" - "@blocksuite/affine-widget-scroll-anchoring": "npm:0.19.5" - "@blocksuite/block-std": "npm:0.19.5" - "@blocksuite/data-view": "npm:0.19.5" - "@blocksuite/global": "npm:0.19.5" + "@blocksuite/affine-block-embed": "workspace:*" + "@blocksuite/affine-block-list": "workspace:*" + "@blocksuite/affine-block-paragraph": "workspace:*" + "@blocksuite/affine-block-surface": "workspace:*" + "@blocksuite/affine-components": "workspace:*" + "@blocksuite/affine-model": "workspace:*" + "@blocksuite/affine-shared": "workspace:*" + "@blocksuite/affine-widget-scroll-anchoring": "workspace:*" + "@blocksuite/block-std": "workspace:*" + "@blocksuite/data-view": "workspace:*" + "@blocksuite/global": "workspace:*" "@blocksuite/icons": "npm:^2.1.75" - "@blocksuite/inline": "npm:0.19.5" - "@blocksuite/store": "npm:0.19.5" + "@blocksuite/inline": "workspace:*" + "@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.1.1" + "@types/dompurify": "npm:^3.0.5" "@types/hast": "npm:^3.0.4" + "@types/katex": "npm:^0.16.7" "@types/mdast": "npm:^4.0.4" collapse-white-space: "npm:^2.1.0" date-fns: "npm:^4.0.0" @@ -3472,20 +3481,19 @@ __metadata: simple-xml-to-json: "npm:^1.2.2" unified: "npm:^11.0.5" zod: "npm:^3.23.8" - checksum: 10/69d649678cda97a27dcc55806dee2dfbe7adc637afb6e39e7323fe3e31d729e3ed7b067930f12a4f1692fed63f7cb3d788770381dd77b8e2eaeccc7ae2bc0f63 - languageName: node - linkType: hard + languageName: unknown + linkType: soft -"@blocksuite/data-view@npm:0.19.5": - version: 0.19.5 - resolution: "@blocksuite/data-view@npm:0.19.5" +"@blocksuite/data-view@workspace:*, @blocksuite/data-view@workspace:blocksuite/affine/data-view": + version: 0.0.0-use.local + resolution: "@blocksuite/data-view@workspace:blocksuite/affine/data-view" dependencies: - "@blocksuite/affine-components": "npm:0.19.5" - "@blocksuite/affine-shared": "npm:0.19.5" - "@blocksuite/block-std": "npm:0.19.5" - "@blocksuite/global": "npm:0.19.5" + "@blocksuite/affine-components": "workspace:*" + "@blocksuite/affine-shared": "workspace:*" + "@blocksuite/block-std": "workspace:*" + "@blocksuite/global": "workspace:*" "@blocksuite/icons": "npm:^2.1.75" - "@blocksuite/store": "npm:0.19.5" + "@blocksuite/store": "workspace:*" "@emotion/hash": "npm:^0.9.2" "@floating-ui/dom": "npm:^1.6.10" "@lit/context": "npm:^1.1.2" @@ -3494,21 +3502,19 @@ __metadata: date-fns: "npm:^4.0.0" lit: "npm:^3.2.0" zod: "npm:^3.23.8" - checksum: 10/9034727612b084df6e0a7524ec2f83c48f349869eeb43d8575e82d59602dd877cc7c1d909182ab76618f5e1641099c73f8d9ee6fde85e29ee89cfb577b54e2c6 - languageName: node - linkType: hard + languageName: unknown + linkType: soft -"@blocksuite/global@npm:0.19.5": - version: 0.19.5 - resolution: "@blocksuite/global@npm:0.19.5" +"@blocksuite/global@workspace:*, @blocksuite/global@workspace:blocksuite/framework/global": + version: 0.0.0-use.local + resolution: "@blocksuite/global@workspace:blocksuite/framework/global" dependencies: "@preact/signals-core": "npm:^1.8.0" lib0: "npm:^0.2.97" lit: "npm:^3.2.0" zod: "npm:^3.23.8" - checksum: 10/73cf1b9d736189cd1a7a4cd7b7c9053f5ae8de9193bc8b71273aba687975606dab6e7047a79d9a9e243e17cc1c8b880cf7ccd8560cc6f867a0d30114dc1cfaa6 - languageName: node - linkType: hard + languageName: unknown + linkType: soft "@blocksuite/icons@npm:2.1.75, @blocksuite/icons@npm:^2.1.75": version: 2.1.75 @@ -3526,81 +3532,120 @@ __metadata: languageName: node linkType: hard -"@blocksuite/inline@npm:0.19.5": - version: 0.19.5 - resolution: "@blocksuite/inline@npm:0.19.5" +"@blocksuite/inline@workspace:*, @blocksuite/inline@workspace:blocksuite/framework/inline": + version: 0.0.0-use.local + resolution: "@blocksuite/inline@workspace:blocksuite/framework/inline" dependencies: - "@blocksuite/global": "npm:0.19.5" + "@blocksuite/global": "workspace:*" "@preact/signals-core": "npm:^1.8.0" + lit: "npm:^3.2.0" + yjs: "patch:yjs@npm%3A13.6.18#~/.yarn/patches/yjs-npm-13.6.18-ad0d5f7c43.patch" zod: "npm:^3.23.8" peerDependencies: lit: ^3.2.0 - yjs: ^13.6.18 - checksum: 10/a9a4dec65744e3ba500ae33741e71e841b8bb0a5540e0023e4437c270bd00724e97bcefe83488138025686442b3311dc9de6b93ad8e29d32d6566a5c98dcfc26 - languageName: node - linkType: hard + yjs: "*" + languageName: unknown + linkType: soft -"@blocksuite/presets@npm:0.19.5": - version: 0.19.5 - resolution: "@blocksuite/presets@npm:0.19.5" +"@blocksuite/playground@workspace:blocksuite/playground": + version: 0.0.0-use.local + resolution: "@blocksuite/playground@workspace:blocksuite/playground" dependencies: - "@blocksuite/affine-block-surface": "npm:0.19.5" - "@blocksuite/affine-model": "npm:0.19.5" - "@blocksuite/affine-shared": "npm:0.19.5" - "@blocksuite/block-std": "npm:0.19.5" - "@blocksuite/blocks": "npm:0.19.5" - "@blocksuite/global": "npm:0.19.5" - "@blocksuite/inline": "npm:0.19.5" - "@blocksuite/store": "npm:0.19.5" + "@blocksuite/affine-components": "workspace:*" + "@blocksuite/affine-model": "workspace:*" + "@blocksuite/block-std": "workspace:*" + "@blocksuite/blocks": "workspace:*" + "@blocksuite/data-view": "workspace:*" + "@blocksuite/global": "workspace:*" + "@blocksuite/inline": "workspace:*" + "@blocksuite/presets": "workspace:*" + "@blocksuite/store": "workspace:*" + "@blocksuite/sync": "workspace:*" + "@preact/signals-core": "npm:^1.8.0" + "@shoelace-style/shoelace": "npm:2.19.0" + "@toeverything/pdf-viewer": "npm:^0.1.1" + "@toeverything/y-indexeddb": "npm:0.10.0-canary.9" + "@tweakpane/core": "npm:^2.0.4" + "@types/katex": "npm:^0.16.7" + "@types/micromatch": "npm:^4.0.9" + browser-fs-access: "npm:^0.35.0" + graphql: "npm:^16.9.0" + jszip: "npm:^3.10.1" + lit: "npm:^3.2.0" + lz-string: "npm:^1.5.0" + magic-string: "npm:^0.30.11" + tweakpane: "npm:^4.0.4" + vite-plugin-wasm: "npm:^3.3.0" + vite-plugin-web-components-hmr: "npm:^0.1.3" + y-indexeddb: "npm:^9.0.12" + y-protocols: "npm:^1.0.6" + yjs: "patch:yjs@npm%3A13.6.18#~/.yarn/patches/yjs-npm-13.6.18-ad0d5f7c43.patch" + zod: "npm:^3.23.8" + languageName: unknown + linkType: soft + +"@blocksuite/presets@workspace:*, @blocksuite/presets@workspace:blocksuite/presets": + version: 0.0.0-use.local + resolution: "@blocksuite/presets@workspace:blocksuite/presets" + dependencies: + "@blocksuite/affine-block-surface": "workspace:*" + "@blocksuite/affine-model": "workspace:*" + "@blocksuite/affine-shared": "workspace:*" + "@blocksuite/block-std": "workspace:*" + "@blocksuite/blocks": "workspace:*" + "@blocksuite/global": "workspace:*" + "@blocksuite/inline": "workspace:*" + "@blocksuite/store": "workspace:*" "@floating-ui/dom": "npm:^1.6.10" "@lottiefiles/dotlottie-wc": "npm:^0.4.0" "@preact/signals-core": "npm:^1.8.0" "@toeverything/theme": "npm:^1.1.1" lit: "npm:^3.2.0" zod: "npm:^3.23.8" - checksum: 10/baafcfaeb38ca0e922a177dc8877e08e01a960cd65b577cc5cadc4712625db8d1c27aa20dabe3ebbf0d51ea14f2e7cbbf4d47d43a0100553df22745fc7e0068d - languageName: node - linkType: hard + languageName: unknown + linkType: soft -"@blocksuite/store@npm:0.19.5": - version: 0.19.5 - resolution: "@blocksuite/store@npm:0.19.5" +"@blocksuite/store@workspace:*, @blocksuite/store@workspace:blocksuite/framework/store": + version: 0.0.0-use.local + resolution: "@blocksuite/store@workspace:blocksuite/framework/store" dependencies: - "@blocksuite/global": "npm:0.19.5" - "@blocksuite/inline": "npm:0.19.5" - "@blocksuite/sync": "npm:0.19.5" + "@blocksuite/global": "workspace:*" + "@blocksuite/inline": "workspace:*" + "@blocksuite/sync": "workspace:*" "@preact/signals-core": "npm:^1.8.0" "@types/flexsearch": "npm:^0.7.6" + "@types/lodash.clonedeep": "npm:^4.5.9" "@types/lodash.ismatch": "npm:^4.4.9" + "@types/lodash.merge": "npm:^4.6.9" file-type: "npm:^19.5.0" flexsearch: "npm:0.7.43" lib0: "npm:^0.2.97" + lit: "npm:^3.2.0" lodash.clonedeep: "npm:^4.5.0" lodash.ismatch: "npm:^4.4.0" lodash.merge: "npm:^4.6.2" minimatch: "npm:^10.0.1" nanoid: "npm:^5.0.7" y-protocols: "npm:^1.0.6" + yjs: "patch:yjs@npm%3A13.6.18#~/.yarn/patches/yjs-npm-13.6.18-ad0d5f7c43.patch" zod: "npm:^3.23.8" peerDependencies: - yjs: ^13.6.18 - checksum: 10/0655e8a96e817134bfbd6cedb602b91e6f6f4b434b3e6ac2165e4184f59b3022e42388de814f5e714631e3f001df01143e7156c637846826162ea485149fec7e - languageName: node - linkType: hard + yjs: "*" + languageName: unknown + linkType: soft -"@blocksuite/sync@npm:0.19.5": - version: 0.19.5 - resolution: "@blocksuite/sync@npm:0.19.5" +"@blocksuite/sync@workspace:*, @blocksuite/sync@workspace:blocksuite/framework/sync": + version: 0.0.0-use.local + resolution: "@blocksuite/sync@workspace:blocksuite/framework/sync" dependencies: - "@blocksuite/global": "npm:0.19.5" + "@blocksuite/global": "workspace:*" idb: "npm:^8.0.0" idb-keyval: "npm:^6.2.1" y-protocols: "npm:^1.0.6" peerDependencies: - yjs: ^13.6.15 - checksum: 10/bdde7cac32e780d4b1d784240f3a90d41e3a2988764d1347b48d7c8764fff2b660a2adbf267574215a27c311a716f59c9522ffed7c0e536c613d468c5565887d - languageName: node - linkType: hard + yjs: "*" + languageName: unknown + linkType: soft "@bundled-es-modules/cookie@npm:^2.0.1": version: 2.0.1 @@ -4011,6 +4056,13 @@ __metadata: languageName: node linkType: hard +"@ctrl/tinycolor@npm:^4.1.0": + version: 4.1.0 + resolution: "@ctrl/tinycolor@npm:4.1.0" + checksum: 10/e64569399139ef0abd2eb0ec9fb7267dfd7820f7ad7d4567a63e5fc35e5cfdcb8ecdb3bad65cb9244b47ba6c77bc51085826c00e981acf263a3221dc89343adc + languageName: node + linkType: hard + "@datastructures-js/binary-search-tree@npm:^5.3.2": version: 5.3.2 resolution: "@datastructures-js/binary-search-tree@npm:5.3.2" @@ -7048,6 +7100,15 @@ __metadata: languageName: node linkType: hard +"@lit/react@npm:^1.0.6": + version: 1.0.6 + resolution: "@lit/react@npm:1.0.6" + peerDependencies: + "@types/react": 17 || 18 + checksum: 10/343d6574b1b514a547dfd39f55014c4caf06c48facfc19431013b34f22c693939fa9e89a8336f8a64360718c9a34adf955d786e6142d358ccd880a9deab0b3b0 + languageName: node + linkType: hard + "@lit/reactive-element@npm:^1.3.0, @lit/reactive-element@npm:^1.6.0": version: 1.6.3 resolution: "@lit/reactive-element@npm:1.6.3" @@ -12254,6 +12315,36 @@ __metadata: languageName: node linkType: hard +"@shoelace-style/animations@npm:^1.2.0": + version: 1.2.0 + resolution: "@shoelace-style/animations@npm:1.2.0" + checksum: 10/73773147cebc5833f362f01f96245cc156e9619cc04a8ee342bd9d320661d0fce30ba2fee3a515603eb1141da005c163a608b6356fd5b478f50a483bc9806e16 + languageName: node + linkType: hard + +"@shoelace-style/localize@npm:^3.2.1": + version: 3.2.1 + resolution: "@shoelace-style/localize@npm:3.2.1" + checksum: 10/e22e108a27ce7da6b86a7b2f16f8db69e9b3c7d2aaf4e34fc39023c2f060aa7a5004d02ffd1cce2fbef3de7e5cd2a60e79c77fbba5cbd5e9456881fa3a452db1 + languageName: node + linkType: hard + +"@shoelace-style/shoelace@npm:2.19.0": + version: 2.19.0 + resolution: "@shoelace-style/shoelace@npm:2.19.0" + dependencies: + "@ctrl/tinycolor": "npm:^4.1.0" + "@floating-ui/dom": "npm:^1.6.12" + "@lit/react": "npm:^1.0.6" + "@shoelace-style/animations": "npm:^1.2.0" + "@shoelace-style/localize": "npm:^3.2.1" + composed-offset-position: "npm:^0.0.6" + lit: "npm:^3.2.1" + qr-creator: "npm:^1.0.0" + checksum: 10/437b6dc65c97f192bb4f63a9d5eb519c64ac2dac9f688bb2fc71964ae2ecd544e4bf9d2f122183f7741a22257cc542addc447439b1143cf52dc33a2367ade060 + languageName: node + linkType: hard + "@sidvind/better-ajv-errors@npm:3.0.1": version: 3.0.1 resolution: "@sidvind/better-ajv-errors@npm:3.0.1" @@ -13621,7 +13712,7 @@ __metadata: "@affine/debug": "workspace:*" "@affine/env": "workspace:*" "@affine/templates": "workspace:*" - "@blocksuite/affine": "npm:0.19.5" + "@blocksuite/affine": "workspace:*" "@datastructures-js/binary-search-tree": "npm:^5.3.2" "@emotion/react": "npm:^11.14.0" "@swc/core": "npm:^1.10.1" @@ -13681,6 +13772,19 @@ __metadata: languageName: node linkType: hard +"@toeverything/y-indexeddb@npm:0.10.0-canary.9": + version: 0.10.0-canary.9 + resolution: "@toeverything/y-indexeddb@npm:0.10.0-canary.9" + dependencies: + idb: "npm:^7.1.1" + nanoid: "npm:^5.0.1" + y-provider: "npm:0.10.0-canary.9" + peerDependencies: + yjs: ^13 + checksum: 10/126a234f0ef64c24fa8b7a2ab398a14de0e3f34187cd931af07c30551a336e240d58b8236aa78d69544adb429b1457be8d071a04109f86441b39c4a0c98a446e + languageName: node + linkType: hard + "@tokenizer/token@npm:^0.3.0": version: 0.3.0 resolution: "@tokenizer/token@npm:0.3.0" @@ -13742,6 +13846,13 @@ __metadata: languageName: node linkType: hard +"@tweakpane/core@npm:^2.0.4": + version: 2.0.5 + resolution: "@tweakpane/core@npm:2.0.5" + checksum: 10/329dd120b26abd2059dc929d37ade52bf823d631965e4cdd8fc402dd4faebd85be9a3c95069def09a937fd1a8d7364b23f490b8efe2737076fbbba210c657e1e + languageName: node + linkType: hard + "@tybys/wasm-util@npm:^0.9.0": version: 0.9.0 resolution: "@tybys/wasm-util@npm:0.9.0" @@ -13851,6 +13962,13 @@ __metadata: languageName: node linkType: hard +"@types/braces@npm:*": + version: 3.0.4 + resolution: "@types/braces@npm:3.0.4" + checksum: 10/7324497b6cc34c963c44d3f8516c67a83b749ab4f18defd9418b231b071af7ee8f0a0f345a52b204e867de80f684cabb21158512e1eaecbcebbabed1d1e357a3 + languageName: node + linkType: hard + "@types/busboy@npm:^1.5.0": version: 1.5.4 resolution: "@types/busboy@npm:1.5.4" @@ -13997,6 +14115,15 @@ __metadata: languageName: node linkType: hard +"@types/dompurify@npm:^3.0.5": + version: 3.0.5 + resolution: "@types/dompurify@npm:3.0.5" + dependencies: + "@types/trusted-types": "npm:*" + checksum: 10/e544b3ce53c41215cabff3d89256ff707c7ee8e0c9a1b5034b22014725d288b16e6942cdcdeeb4221c578c3421a6a4721aa0676431f55d7abd18c07368855c5e + languageName: node + linkType: hard + "@types/eslint-scope@npm:^3.7.7": version: 3.7.7 resolution: "@types/eslint-scope@npm:3.7.7" @@ -14215,7 +14342,7 @@ __metadata: languageName: node linkType: hard -"@types/katex@npm:^0.16.0": +"@types/katex@npm:^0.16.0, @types/katex@npm:^0.16.7": version: 0.16.7 resolution: "@types/katex@npm:0.16.7" checksum: 10/4fd15d93553be97c02c064e16be18d7ccbabf66ec72a9dc7fd5bfa47f0c7581da2f942f693c7cb59499de4c843c2189796e49c9647d336cbd52b777b6722a95a @@ -14281,6 +14408,24 @@ __metadata: languageName: node linkType: hard +"@types/lodash.chunk@npm:^4.2.9": + version: 4.2.9 + resolution: "@types/lodash.chunk@npm:4.2.9" + dependencies: + "@types/lodash": "npm:*" + checksum: 10/ccffe7273a0941655d5b988baeffa8f7d4d19a8b43ed728ff4e616013506efe85914ba99d4ec299e0106506e1bca3923b065eabb0aa5f1e4b18f68e790ae6b88 + languageName: node + linkType: hard + +"@types/lodash.clonedeep@npm:^4.5.9": + version: 4.5.9 + resolution: "@types/lodash.clonedeep@npm:4.5.9" + dependencies: + "@types/lodash": "npm:*" + checksum: 10/ef85512b7dce7a4f981a818ae44d11982907e1f26b5b26bedf0957c35e8591eb8e1d24fa31ca851d4b40e0a1ee88563853d762412691fe5f357e8335cead2325 + languageName: node + linkType: hard + "@types/lodash.ismatch@npm:^4.4.9": version: 4.4.9 resolution: "@types/lodash.ismatch@npm:4.4.9" @@ -14290,6 +14435,24 @@ __metadata: languageName: node linkType: hard +"@types/lodash.merge@npm:^4.6.9": + version: 4.6.9 + resolution: "@types/lodash.merge@npm:4.6.9" + dependencies: + "@types/lodash": "npm:*" + checksum: 10/d0dd6654547c9d8d905184d14aa5c2a37a1ed1c3204f5ab20b7d591a05f34859ef09d3b72c065e94ca1989abf9109eb8230f67c4d64a5768b1d65b9ed8baf8e7 + languageName: node + linkType: hard + +"@types/lodash.mergewith@npm:^4": + version: 4.6.9 + resolution: "@types/lodash.mergewith@npm:4.6.9" + dependencies: + "@types/lodash": "npm:*" + checksum: 10/c5a67e83040103decfd37090127118f5758773d0ce2a1756d442b371721737c7752f48f62544cc970f44abec8471f260cc4c844e1a4fdef8b76cb96bdec8a595 + languageName: node + linkType: hard + "@types/lodash@npm:*": version: 4.17.13 resolution: "@types/lodash@npm:4.17.13" @@ -14334,6 +14497,15 @@ __metadata: languageName: node linkType: hard +"@types/micromatch@npm:^4.0.9": + version: 4.0.9 + resolution: "@types/micromatch@npm:4.0.9" + dependencies: + "@types/braces": "npm:*" + checksum: 10/324f4bcb4a7caa2048bdd650f442d2c24b5bf6bc95e4d63d4741bd234fdcf3cde140217bd477b2c02c7fb0034c7293037fd7b61429ace84e997dd3b4d3b2b2f7 + languageName: node + linkType: hard + "@types/mime-types@npm:^2.1.4": version: 2.1.4 resolution: "@types/mime-types@npm:2.1.4" @@ -14700,7 +14872,7 @@ __metadata: languageName: node linkType: hard -"@types/trusted-types@npm:^2.0.2, @types/trusted-types@npm:^2.0.7": +"@types/trusted-types@npm:*, @types/trusted-types@npm:^2.0.2, @types/trusted-types@npm:^2.0.7": version: 2.0.7 resolution: "@types/trusted-types@npm:2.0.7" checksum: 10/8e4202766a65877efcf5d5a41b7dd458480b36195e580a3b1085ad21e948bc417d55d6f8af1fd2a7ad008015d4117d5fdfe432731157da3c68678487174e4ba3 @@ -16651,6 +16823,13 @@ __metadata: languageName: node linkType: hard +"browser-fs-access@npm:^0.35.0": + version: 0.35.0 + resolution: "browser-fs-access@npm:0.35.0" + checksum: 10/5fa9876cc10499bfc8e1a3d83261f8966c5687c2d31c1abefed35ecc5a8e1ab8b12a51aed638b48ee4e2109ec48c2cba3bfcd162b9de270a5b5ca08740024eda + languageName: node + linkType: hard + "browser-image-hash@npm:^0.0.5": version: 0.0.5 resolution: "browser-image-hash@npm:0.0.5" @@ -17913,6 +18092,15 @@ __metadata: languageName: node linkType: hard +"composed-offset-position@npm:^0.0.6": + version: 0.0.6 + resolution: "composed-offset-position@npm:0.0.6" + peerDependencies: + "@floating-ui/utils": ^0.2.5 + checksum: 10/f0e403f11a6a677631d39b5e7a742c242067c44c2278c6616362d46ee2b9a376dd9cb2d676640bf1f395cc69da52e5ebac9cd5b4f4dc51d9f4d6f2cb60d4a49b + languageName: node + linkType: hard + "compressible@npm:~2.0.16, compressible@npm:~2.0.18": version: 2.0.18 resolution: "compressible@npm:2.0.18" @@ -22847,6 +23035,13 @@ __metadata: languageName: node linkType: hard +"idb@npm:^7.1.1": + version: 7.1.1 + resolution: "idb@npm:7.1.1" + checksum: 10/8e33eaebf21055129864acb89932e0739b8c96788e559df24c253ce114d8c6deb977a3b30ea47a9bb8a2ae8a55964861c3df65f360d95745e341cee40d5c17f4 + languageName: node + linkType: hard + "idb@npm:^8.0.0": version: 8.0.1 resolution: "idb@npm:8.0.1" @@ -24213,7 +24408,7 @@ __metadata: languageName: node linkType: hard -"lib0@npm:^0.2.85, lib0@npm:^0.2.86, lib0@npm:^0.2.97, lib0@npm:^0.2.99": +"lib0@npm:^0.2.74, lib0@npm:^0.2.85, lib0@npm:^0.2.86, lib0@npm:^0.2.97, lib0@npm:^0.2.99": version: 0.2.99 resolution: "lib0@npm:0.2.99" dependencies: @@ -24908,12 +25103,12 @@ __metadata: languageName: node linkType: hard -"magic-string@npm:^0.30.0, magic-string@npm:^0.30.12": - version: 0.30.15 - resolution: "magic-string@npm:0.30.15" +"magic-string@npm:^0.30.0, magic-string@npm:^0.30.11, magic-string@npm:^0.30.12": + version: 0.30.17 + resolution: "magic-string@npm:0.30.17" dependencies: "@jridgewell/sourcemap-codec": "npm:^1.5.0" - checksum: 10/321f6e3156ac65d938fb7e08b3eaef9f4f5718180b7507f37bb55273f1faf979ab42e3b550a9e5dbbacf1c9a0f416157ab01c08619938734dcbbe02e2ef10872 + checksum: 10/2f71af2b0afd78c2e9012a29b066d2c8ba45a9cd0c8070f7fd72de982fb1c403b4e3afdb1dae00691d56885ede66b772ef6bedf765e02e3a7066208fe2fec4aa languageName: node linkType: hard @@ -26331,7 +26526,7 @@ __metadata: languageName: node linkType: hard -"nanoid@npm:^5.0.7, nanoid@npm:^5.0.9": +"nanoid@npm:^5.0.1, nanoid@npm:^5.0.7, nanoid@npm:^5.0.9": version: 5.0.9 resolution: "nanoid@npm:5.0.9" bin: @@ -27742,7 +27937,7 @@ __metadata: languageName: node linkType: hard -"picomatch@npm:^2.0.4, picomatch@npm:^2.2.1, picomatch@npm:^2.3.1": +"picomatch@npm:^2.0.4, picomatch@npm:^2.2.1, picomatch@npm:^2.2.2, picomatch@npm:^2.3.1": version: 2.3.1 resolution: "picomatch@npm:2.3.1" checksum: 10/60c2595003b05e4535394d1da94850f5372c9427ca4413b71210f437f7b2ca091dbd611c45e8b37d10036fa8eade25c1b8951654f9d3973bfa66a2ff4d3b08bc @@ -28674,6 +28869,13 @@ __metadata: languageName: node linkType: hard +"qr-creator@npm:^1.0.0": + version: 1.0.0 + resolution: "qr-creator@npm:1.0.0" + checksum: 10/77325a895fabfc899a54f0fc4598696dc234dc5056714181bbadb62bb15944366be7cd56ec19e36eb6e92a99f086143435f654855e512726bd8923149d94f709 + languageName: node + linkType: hard + "qs@npm:6.13.0": version: 6.13.0 resolution: "qs@npm:6.13.0" @@ -32363,6 +32565,13 @@ __metadata: languageName: node linkType: hard +"tweakpane@npm:^4.0.4": + version: 4.0.5 + resolution: "tweakpane@npm:4.0.5" + checksum: 10/7719a15ce96dd2b936b277239ccb18ee6a75ed2416a6bdacfc537515d909da6edd50161b12e91441ace5243efad3a14a98fe6e5475cae2617d7647a197117e64 + languageName: node + linkType: hard + "tween-functions@npm:^1.2.0": version: 1.2.0 resolution: "tween-functions@npm:1.2.0" @@ -33269,6 +33478,30 @@ __metadata: languageName: node linkType: hard +"vite-plugin-wasm@npm:^3.3.0": + version: 3.4.1 + resolution: "vite-plugin-wasm@npm:3.4.1" + peerDependencies: + vite: ^2 || ^3 || ^4 || ^5 || ^6 + checksum: 10/4329318a6ece0e4021e89d83738bbe9214e85f93fd8cfe3a9026fcf46cf5fe9d921a37c1ef2f9c726c753e4ace203c575edfb10a14adf817b885a307c0cbb10c + languageName: node + linkType: hard + +"vite-plugin-web-components-hmr@npm:^0.1.3": + version: 0.1.3 + resolution: "vite-plugin-web-components-hmr@npm:0.1.3" + dependencies: + "@babel/core": "npm:^7.12.3" + "@babel/plugin-syntax-class-properties": "npm:^7.12.13" + "@babel/plugin-syntax-import-assertions": "npm:^7.12.1" + "@babel/plugin-syntax-top-level-await": "npm:^7.12.1" + picomatch: "npm:^2.2.2" + peerDependencies: + vite: ">=2" + checksum: 10/eaf5c41d4c3e31ac67921f75caf9852d16f785ce3c6c1833bdef5946a5ef0d381ffefc05087cfa6a73b7917cfd2382c8f05c61f35b63ac75890675717e9c979c + languageName: node + linkType: hard + "vite@npm:6.0.3": version: 6.0.3 resolution: "vite@npm:6.0.3" @@ -33981,6 +34214,17 @@ __metadata: languageName: node linkType: hard +"y-indexeddb@npm:^9.0.12": + version: 9.0.12 + resolution: "y-indexeddb@npm:9.0.12" + dependencies: + lib0: "npm:^0.2.74" + peerDependencies: + yjs: ^13.0.0 + checksum: 10/6468ebdcb2936a5fe10e4fb57cbe2d90260c44b63c6ecf6a26cc3652d21bd3be58bb76dfb56dbe56dd71b320042bfd3663274217b89300f2f0db92611fc9e7c6 + languageName: node + linkType: hard + "y-protocols@npm:^1.0.6": version: 1.0.6 resolution: "y-protocols@npm:1.0.6" @@ -33992,6 +34236,15 @@ __metadata: languageName: node linkType: hard +"y-provider@npm:0.10.0-canary.9": + version: 0.10.0-canary.9 + resolution: "y-provider@npm:0.10.0-canary.9" + peerDependencies: + yjs: ^13 + checksum: 10/ab27477a2a67c6f743d811d95352720b2e3471cff6c566d74782c28c11e457a38d9b522055fa13527c77018b6d2c5735ead81e87c018b44a6c58f2a005f7cb26 + languageName: node + linkType: hard + "y18n@npm:^4.0.0": version: 4.0.3 resolution: "y18n@npm:4.0.3"