diff --git a/blocksuite/affine/blocks/attachment/src/attachment-edgeless-block.ts b/blocksuite/affine/blocks/attachment/src/attachment-edgeless-block.ts index 12c42d8cca..2fd641ca2c 100644 --- a/blocksuite/affine/blocks/attachment/src/attachment-edgeless-block.ts +++ b/blocksuite/affine/blocks/attachment/src/attachment-edgeless-block.ts @@ -1,10 +1,14 @@ import { EdgelessLegacySlotIdentifier } from '@blocksuite/affine-block-surface'; -import { AttachmentBlockStyles } from '@blocksuite/affine-model'; +import { + AttachmentBlockSchema, + AttachmentBlockStyles, +} from '@blocksuite/affine-model'; import { EMBED_CARD_HEIGHT, EMBED_CARD_WIDTH, } from '@blocksuite/affine-shared/consts'; import { toGfxBlockComponent } from '@blocksuite/std'; +import { GfxViewInteractionExtension } from '@blocksuite/std/gfx'; import { styleMap } from 'lit/directives/style-map.js'; import { AttachmentBlockComponent } from './attachment-block.js'; @@ -48,3 +52,21 @@ declare global { 'affine-edgeless-attachment': AttachmentEdgelessBlockComponent; } } + +export const AttachmentBlockInteraction = GfxViewInteractionExtension( + AttachmentBlockSchema.model.flavour, + { + resizeConstraint: { + lockRatio: true, + }, + handleRotate: () => { + return { + beforeRotate: context => { + context.set({ + rotatable: false, + }); + }, + }; + }, + } +); diff --git a/blocksuite/affine/blocks/attachment/src/attachment-spec.ts b/blocksuite/affine/blocks/attachment/src/attachment-spec.ts index 928778625a..03d452a2cd 100644 --- a/blocksuite/affine/blocks/attachment/src/attachment-spec.ts +++ b/blocksuite/affine/blocks/attachment/src/attachment-spec.ts @@ -5,6 +5,7 @@ import type { ExtensionType } from '@blocksuite/store'; import { literal } from 'lit/static-html.js'; import { AttachmentBlockAdapterExtensions } from './adapters/extension.js'; +import { AttachmentBlockInteraction } from './attachment-edgeless-block.js'; import { AttachmentDropOption } from './attachment-service.js'; import { attachmentSlashMenuConfig } from './configs/slash-menu.js'; import { createBuiltinToolbarConfigExtension } from './configs/toolbar'; @@ -26,6 +27,7 @@ export const AttachmentBlockSpec: ExtensionType[] = [ AttachmentEmbedConfigExtension(), AttachmentEmbedService, AttachmentBlockAdapterExtensions, + AttachmentBlockInteraction, createBuiltinToolbarConfigExtension(flavour), SlashMenuConfigExtension(flavour, attachmentSlashMenuConfig), ].flat(); diff --git a/blocksuite/affine/blocks/attachment/src/view.ts b/blocksuite/affine/blocks/attachment/src/view.ts index 5419aaf13a..575fcff31e 100644 --- a/blocksuite/affine/blocks/attachment/src/view.ts +++ b/blocksuite/affine/blocks/attachment/src/view.ts @@ -7,6 +7,7 @@ import { SlashMenuConfigExtension } from '@blocksuite/affine-widget-slash-menu'; import { BlockViewExtension, FlavourExtension } from '@blocksuite/std'; import { literal } from 'lit/static-html.js'; +import { AttachmentBlockInteraction } from './attachment-edgeless-block.js'; import { AttachmentDropOption } from './attachment-service.js'; import { attachmentSlashMenuConfig } from './configs/slash-menu.js'; import { createBuiltinToolbarConfigExtension } from './configs/toolbar'; @@ -44,6 +45,7 @@ export class AttachmentViewExtension extends ViewExtensionProvider { ]); if (this.isEdgeless(context.scope)) { context.register(EdgelessClipboardAttachmentConfig); + context.register(AttachmentBlockInteraction); } } } diff --git a/blocksuite/affine/blocks/bookmark/src/bookmark-edgeless-block.ts b/blocksuite/affine/blocks/bookmark/src/bookmark-edgeless-block.ts index 44df09cabb..7f64fd34b4 100644 --- a/blocksuite/affine/blocks/bookmark/src/bookmark-edgeless-block.ts +++ b/blocksuite/affine/blocks/bookmark/src/bookmark-edgeless-block.ts @@ -1,8 +1,10 @@ +import { BookmarkBlockSchema } from '@blocksuite/affine-model'; import { EMBED_CARD_HEIGHT, EMBED_CARD_WIDTH, } from '@blocksuite/affine-shared/consts'; import { toGfxBlockComponent } from '@blocksuite/std'; +import { GfxViewInteractionExtension } from '@blocksuite/std/gfx'; import { type StyleInfo, styleMap } from 'lit/directives/style-map.js'; import { BookmarkBlockComponent } from './bookmark-block.js'; @@ -50,6 +52,24 @@ export class BookmarkEdgelessBlockComponent extends toGfxBlockComponent( }; } +export const BookmarkBlockInteraction = GfxViewInteractionExtension( + BookmarkBlockSchema.model.flavour, + { + resizeConstraint: { + lockRatio: true, + }, + handleRotate: () => { + return { + beforeRotate(context) { + context.set({ + rotatable: false, + }); + }, + }; + }, + } +); + declare global { interface HTMLElementTagNameMap { 'affine-edgeless-bookmark': BookmarkEdgelessBlockComponent; diff --git a/blocksuite/affine/blocks/bookmark/src/bookmark-spec.ts b/blocksuite/affine/blocks/bookmark/src/bookmark-spec.ts index e709c8e24d..9372405675 100644 --- a/blocksuite/affine/blocks/bookmark/src/bookmark-spec.ts +++ b/blocksuite/affine/blocks/bookmark/src/bookmark-spec.ts @@ -4,6 +4,7 @@ import type { ExtensionType } from '@blocksuite/store'; import { literal } from 'lit/static-html.js'; import { BookmarkBlockAdapterExtensions } from './adapters/extension'; +import { BookmarkBlockInteraction } from './bookmark-edgeless-block'; import { BookmarkSlashMenuConfigExtension } from './configs/slash-menu'; import { createBuiltinToolbarConfigExtension } from './configs/toolbar'; @@ -16,6 +17,7 @@ export const BookmarkBlockSpec: ExtensionType[] = [ ? literal`affine-edgeless-bookmark` : literal`affine-bookmark`; }), + BookmarkBlockInteraction, BookmarkBlockAdapterExtensions, createBuiltinToolbarConfigExtension(flavour), BookmarkSlashMenuConfigExtension, diff --git a/blocksuite/affine/blocks/bookmark/src/view.ts b/blocksuite/affine/blocks/bookmark/src/view.ts index 19bfa48f14..6e3faa17e8 100644 --- a/blocksuite/affine/blocks/bookmark/src/view.ts +++ b/blocksuite/affine/blocks/bookmark/src/view.ts @@ -6,6 +6,7 @@ import { BookmarkBlockSchema } from '@blocksuite/affine-model'; import { BlockViewExtension, FlavourExtension } from '@blocksuite/std'; import { literal } from 'lit/static-html.js'; +import { BookmarkBlockInteraction } from './bookmark-edgeless-block'; import { BookmarkSlashMenuConfigExtension } from './configs/slash-menu'; import { createBuiltinToolbarConfigExtension } from './configs/toolbar'; import { EdgelessClipboardBookmarkConfig } from './edgeless-clipboard-config'; @@ -36,6 +37,7 @@ export class BookmarkViewExtension extends ViewExtensionProvider { const isEdgeless = this.isEdgeless(context.scope); if (isEdgeless) { context.register(EdgelessClipboardBookmarkConfig); + context.register(BookmarkBlockInteraction); } } } diff --git a/blocksuite/affine/blocks/edgeless-text/src/edgeless-text-block.ts b/blocksuite/affine/blocks/edgeless-text/src/edgeless-text-block.ts index 3c961df91f..580a33db67 100644 --- a/blocksuite/affine/blocks/edgeless-text/src/edgeless-text-block.ts +++ b/blocksuite/affine/blocks/edgeless-text/src/edgeless-text-block.ts @@ -5,6 +5,7 @@ import { EDGELESS_TEXT_BLOCK_MIN_HEIGHT, EDGELESS_TEXT_BLOCK_MIN_WIDTH, type EdgelessTextBlockModel, + EdgelessTextBlockSchema, ListBlockModel, ParagraphBlockModel, } from '@blocksuite/affine-model'; @@ -21,7 +22,10 @@ import { GfxBlockComponent, TextSelection, } from '@blocksuite/std'; -import type { SelectedContext } from '@blocksuite/std/gfx'; +import { + GfxViewInteractionExtension, + type SelectedContext, +} from '@blocksuite/std/gfx'; import { css, html } from 'lit'; import { query, state } from 'lit/decorators.js'; import { type StyleInfo, styleMap } from 'lit/directives/style-map.js'; @@ -420,3 +424,69 @@ declare global { 'affine-edgeless-text': EdgelessTextBlockComponent; } } + +export const EdgelessTextInteraction = + GfxViewInteractionExtension( + EdgelessTextBlockSchema.model.flavour, + { + resizeConstraint: { + lockRatio: ['top-left', 'top-right', 'bottom-left', 'bottom-right'], + allowedHandlers: [ + 'top-left', + 'top-right', + 'left', + 'right', + 'bottom-left', + 'bottom-right', + ], + minWidth: EDGELESS_TEXT_BLOCK_MIN_WIDTH, + }, + handleResize: context => { + const { model, view } = context; + const initialScale = model.props.scale; + + return { + onResizeStart(context) { + context.default(context); + model.stash('scale'); + model.stash('hasMaxWidth'); + }, + onResizeMove(context) { + const { originalBound, newBound, constraint, lockRatio } = context; + + if (lockRatio) { + const originalRealWidth = originalBound.w / initialScale; + const newScale = newBound.w / originalRealWidth; + + model.props.scale = newScale; + model.props.xywh = newBound.serialize(); + } else { + if (!view.checkWidthOverflow(newBound.w)) { + return; + } + + const newRealWidth = clamp( + newBound.w / initialScale, + constraint.minWidth, + constraint.maxWidth + ); + + const curBound = Bound.deserialize(model.xywh); + + model.props.xywh = Bound.serialize({ + ...newBound, + w: newRealWidth * initialScale, + h: curBound.h, + }); + model.props.hasMaxWidth = true; + } + }, + onResizeEnd(context) { + context.default(context); + model.pop('scale'); + model.pop('hasMaxWidth'); + }, + }; + }, + } + ); diff --git a/blocksuite/affine/blocks/edgeless-text/src/edgeless-text-spec.ts b/blocksuite/affine/blocks/edgeless-text/src/edgeless-text-spec.ts index 204b9f254f..7d321bc292 100644 --- a/blocksuite/affine/blocks/edgeless-text/src/edgeless-text-spec.ts +++ b/blocksuite/affine/blocks/edgeless-text/src/edgeless-text-spec.ts @@ -2,6 +2,9 @@ import { BlockViewExtension } from '@blocksuite/std'; import type { ExtensionType } from '@blocksuite/store'; import { literal } from 'lit/static-html.js'; +import { EdgelessTextInteraction } from './edgeless-text-block'; + export const EdgelessTextBlockSpec: ExtensionType[] = [ BlockViewExtension('affine:edgeless-text', literal`affine-edgeless-text`), + EdgelessTextInteraction, ]; diff --git a/blocksuite/affine/blocks/edgeless-text/src/view.ts b/blocksuite/affine/blocks/edgeless-text/src/view.ts index 4692d174ae..a20b8cf850 100644 --- a/blocksuite/affine/blocks/edgeless-text/src/view.ts +++ b/blocksuite/affine/blocks/edgeless-text/src/view.ts @@ -6,6 +6,7 @@ import { BlockViewExtension } from '@blocksuite/std'; import { literal } from 'lit/static-html.js'; import { EdgelessClipboardEdgelessTextConfig } from './edgeless-clipboard-config'; +import { EdgelessTextInteraction } from './edgeless-text-block'; import { edgelessTextToolbarExtension } from './edgeless-toolbar'; import { effects } from './effects'; @@ -30,6 +31,7 @@ export class EdgelessTextViewExtension extends ViewExtensionProvider { ]); context.register(edgelessTextToolbarExtension); context.register(EdgelessClipboardEdgelessTextConfig); + context.register(EdgelessTextInteraction); } } } diff --git a/blocksuite/affine/blocks/embed-doc/src/embed-linked-doc-block/embed-edgeless-linked-doc-block.ts b/blocksuite/affine/blocks/embed-doc/src/embed-linked-doc-block/embed-edgeless-linked-doc-block.ts index ca162fd8fe..1fe6bb5380 100644 --- a/blocksuite/affine/blocks/embed-doc/src/embed-linked-doc-block/embed-edgeless-linked-doc-block.ts +++ b/blocksuite/affine/blocks/embed-doc/src/embed-linked-doc-block/embed-edgeless-linked-doc-block.ts @@ -1,8 +1,12 @@ -import { toEdgelessEmbedBlock } from '@blocksuite/affine-block-embed'; +import { + createEmbedEdgelessBlockInteraction, + toEdgelessEmbedBlock, +} from '@blocksuite/affine-block-embed'; import { EdgelessCRUDIdentifier, reassociateConnectorsCommand, } from '@blocksuite/affine-block-surface'; +import { EmbedLinkedDocBlockSchema } from '@blocksuite/affine-model'; import { EMBED_CARD_HEIGHT, EMBED_CARD_WIDTH, @@ -61,3 +65,7 @@ export class EmbedEdgelessLinkedDocBlockComponent extends toEdgelessEmbedBlock( } }; } + +export const EmbedLinkedDocInteraction = createEmbedEdgelessBlockInteraction( + EmbedLinkedDocBlockSchema.model.flavour +); diff --git a/blocksuite/affine/blocks/embed-doc/src/embed-linked-doc-block/embed-linked-doc-spec.ts b/blocksuite/affine/blocks/embed-doc/src/embed-linked-doc-block/embed-linked-doc-spec.ts index 57cb2289a4..70ec909cb0 100644 --- a/blocksuite/affine/blocks/embed-doc/src/embed-linked-doc-block/embed-linked-doc-spec.ts +++ b/blocksuite/affine/blocks/embed-doc/src/embed-linked-doc-block/embed-linked-doc-spec.ts @@ -6,6 +6,7 @@ import { literal } from 'lit/static-html.js'; import { EmbedLinkedDocBlockAdapterExtensions } from './adapters/extension'; import { LinkedDocSlashMenuConfigExtension } from './configs/slash-menu'; import { createBuiltinToolbarConfigExtension } from './configs/toolbar'; +import { EmbedLinkedDocInteraction } from './embed-edgeless-linked-doc-block'; const flavour = EmbedLinkedDocBlockSchema.model.flavour; @@ -27,5 +28,6 @@ export const EmbedLinkedDocViewExtensions: ExtensionType[] = [ : literal`affine-embed-linked-doc-block`; }), createBuiltinToolbarConfigExtension(flavour), + EmbedLinkedDocInteraction, LinkedDocSlashMenuConfigExtension, ].flat(); diff --git a/blocksuite/affine/blocks/embed-doc/src/embed-linked-doc-block/index.ts b/blocksuite/affine/blocks/embed-doc/src/embed-linked-doc-block/index.ts index 9b63e40680..4b02185664 100644 --- a/blocksuite/affine/blocks/embed-doc/src/embed-linked-doc-block/index.ts +++ b/blocksuite/affine/blocks/embed-doc/src/embed-linked-doc-block/index.ts @@ -2,5 +2,6 @@ export * from './adapters'; export * from './commands'; export { LinkedDocSlashMenuConfigIdentifier } from './configs/slash-menu'; export * from './edgeless-clipboard-config'; +export * from './embed-edgeless-linked-doc-block'; export * from './embed-linked-doc-block'; export * from './embed-linked-doc-spec'; diff --git a/blocksuite/affine/blocks/embed-doc/src/embed-synced-doc-block/embed-edgeless-synced-doc-block.ts b/blocksuite/affine/blocks/embed-doc/src/embed-synced-doc-block/embed-edgeless-synced-doc-block.ts index 06110436ff..3122770fa8 100644 --- a/blocksuite/affine/blocks/embed-doc/src/embed-synced-doc-block/embed-edgeless-synced-doc-block.ts +++ b/blocksuite/affine/blocks/embed-doc/src/embed-synced-doc-block/embed-edgeless-synced-doc-block.ts @@ -3,7 +3,12 @@ import { EdgelessCRUDIdentifier, reassociateConnectorsCommand, } from '@blocksuite/affine-block-surface'; -import type { AliasInfo } from '@blocksuite/affine-model'; +import { + type AliasInfo, + EmbedSyncedDocBlockSchema, + SYNCED_MIN_HEIGHT, + SYNCED_MIN_WIDTH, +} from '@blocksuite/affine-model'; import { EMBED_CARD_HEIGHT, EMBED_CARD_WIDTH, @@ -12,8 +17,9 @@ import { ThemeExtensionIdentifier, ThemeProvider, } from '@blocksuite/affine-shared/services'; -import { Bound } from '@blocksuite/global/gfx'; +import { Bound, clamp } from '@blocksuite/global/gfx'; import { type BlockComponent, BlockStdScope } from '@blocksuite/std'; +import { GfxViewInteractionExtension } from '@blocksuite/std/gfx'; import { html, nothing } from 'lit'; import { query, queryAsync } from 'lit/decorators.js'; import { choose } from 'lit/directives/choose.js'; @@ -199,3 +205,60 @@ export class EmbedEdgelessSyncedDocBlockComponent extends toEdgelessEmbedBlock( override accessor useCaptionEditor = true; } + +export const EmbedSyncedDocInteraction = + GfxViewInteractionExtension( + EmbedSyncedDocBlockSchema.model.flavour, + { + resizeConstraint: { + minWidth: SYNCED_MIN_WIDTH, + minHeight: SYNCED_MIN_HEIGHT, + }, + + handleRotate: () => { + return { + beforeRotate(context) { + context.set({ + rotatable: false, + }); + }, + }; + }, + + handleResize: ({ model }) => { + const initialScale = model.props.scale ?? 1; + + return { + onResizeStart: context => { + context.default(context); + model.stash('scale'); + }, + onResizeMove: context => { + const { lockRatio, originalBound, constraint, newBound } = context; + + let scale = initialScale; + const realWidth = originalBound.w / initialScale; + + if (lockRatio) { + scale = newBound.w / realWidth; + } + + const newWidth = newBound.w / scale; + + newBound.w = + clamp(newWidth, constraint.minWidth, constraint.maxWidth) * scale; + newBound.h = + clamp(newBound.h, constraint.minHeight, constraint.maxHeight) * + scale; + + model.props.scale = scale; + model.xywh = newBound.serialize(); + }, + onResizeEnd: context => { + context.default(context); + model.pop('scale'); + }, + }; + }, + } + ); diff --git a/blocksuite/affine/blocks/embed-doc/src/embed-synced-doc-block/embed-synced-doc-spec.ts b/blocksuite/affine/blocks/embed-doc/src/embed-synced-doc-block/embed-synced-doc-spec.ts index 2ccae6cbdb..a4594ec5be 100644 --- a/blocksuite/affine/blocks/embed-doc/src/embed-synced-doc-block/embed-synced-doc-spec.ts +++ b/blocksuite/affine/blocks/embed-doc/src/embed-synced-doc-block/embed-synced-doc-spec.ts @@ -5,6 +5,7 @@ import { literal } from 'lit/static-html.js'; import { EmbedSyncedDocBlockAdapterExtensions } from './adapters/extension'; import { createBuiltinToolbarConfigExtension } from './configs/toolbar'; +import { EmbedSyncedDocInteraction } from './embed-edgeless-synced-doc-block'; import { HeightInitializationExtension } from './init-height-extension'; const flavour = EmbedSyncedDocBlockSchema.model.flavour; @@ -29,4 +30,5 @@ export const EmbedSyncedDocViewExtensions: ExtensionType[] = [ }), createBuiltinToolbarConfigExtension(flavour), HeightInitializationExtension, + EmbedSyncedDocInteraction, ].flat(); diff --git a/blocksuite/affine/blocks/embed-doc/src/embed-synced-doc-block/index.ts b/blocksuite/affine/blocks/embed-doc/src/embed-synced-doc-block/index.ts index 48149b5c9c..04308f7a35 100644 --- a/blocksuite/affine/blocks/embed-doc/src/embed-synced-doc-block/index.ts +++ b/blocksuite/affine/blocks/embed-doc/src/embed-synced-doc-block/index.ts @@ -2,6 +2,7 @@ export * from './adapters'; export * from './commands'; export * from './configs'; export * from './edgeless-clipboard-config'; +export * from './embed-edgeless-synced-doc-block'; export * from './embed-synced-doc-block'; export * from './embed-synced-doc-spec'; export { SYNCED_MIN_HEIGHT, SYNCED_MIN_WIDTH } from '@blocksuite/affine-model'; diff --git a/blocksuite/affine/blocks/embed-doc/src/view.ts b/blocksuite/affine/blocks/embed-doc/src/view.ts index e3ff8adbab..4ae4e2293e 100644 --- a/blocksuite/affine/blocks/embed-doc/src/view.ts +++ b/blocksuite/affine/blocks/embed-doc/src/view.ts @@ -6,10 +6,12 @@ import { import { effects } from './effects'; import { EdgelessClipboardEmbedLinkedDocConfig, + EmbedLinkedDocInteraction, EmbedLinkedDocViewExtensions, } from './embed-linked-doc-block'; import { EdgelessClipboardEmbedSyncedDocConfig, + EmbedSyncedDocInteraction, EmbedSyncedDocViewExtensions, } from './embed-synced-doc-block'; @@ -30,6 +32,8 @@ export class EmbedDocViewExtension extends ViewExtensionProvider { context.register([ EdgelessClipboardEmbedLinkedDocConfig, EdgelessClipboardEmbedSyncedDocConfig, + EmbedLinkedDocInteraction, + EmbedSyncedDocInteraction, ]); } } diff --git a/blocksuite/affine/blocks/embed/src/common/embed-block-element.ts b/blocksuite/affine/blocks/embed/src/common/embed-block-element.ts index c09a541423..47ae21a1d5 100644 --- a/blocksuite/affine/blocks/embed/src/common/embed-block-element.ts +++ b/blocksuite/affine/blocks/embed/src/common/embed-block-element.ts @@ -11,7 +11,11 @@ import { import { DocModeProvider } from '@blocksuite/affine-shared/services'; import { findAncestorModel } from '@blocksuite/affine-shared/utils'; import type { BlockService } from '@blocksuite/std'; -import type { GfxCompatibleProps } from '@blocksuite/std/gfx'; +import { + type GfxCompatibleProps, + GfxViewInteractionExtension, + type ResizeConstraint, +} from '@blocksuite/std/gfx'; import type { BlockModel } from '@blocksuite/store'; import { computed, type ReadonlySignal, signal } from '@preact/signals-core'; import type { TemplateResult } from 'lit'; @@ -163,3 +167,31 @@ export class EmbedBlockComponent< override accessor useZeroWidth = true; } + +export const createEmbedEdgelessBlockInteraction = ( + flavour: string, + config?: { + resizeConstraint?: ResizeConstraint; + } +) => { + const resizeConstraint = Object.assign( + { + lockRatio: true, + }, + config?.resizeConstraint ?? {} + ); + const rotateConstraint = { + rotatable: false, + }; + + return GfxViewInteractionExtension(flavour, { + resizeConstraint, + handleRotate() { + return { + beforeRotate(context) { + context.set(rotateConstraint); + }, + }; + }, + }); +}; diff --git a/blocksuite/affine/blocks/embed/src/embed-figma-block/embed-edgeless-figma-block.ts b/blocksuite/affine/blocks/embed/src/embed-figma-block/embed-edgeless-figma-block.ts index d33c23988c..6df3664112 100644 --- a/blocksuite/affine/blocks/embed/src/embed-figma-block/embed-edgeless-figma-block.ts +++ b/blocksuite/affine/blocks/embed/src/embed-figma-block/embed-edgeless-figma-block.ts @@ -1,6 +1,13 @@ +import { EmbedFigmaBlockSchema } from '@blocksuite/affine-model'; + +import { createEmbedEdgelessBlockInteraction } from '../common/embed-block-element.js'; import { toEdgelessEmbedBlock } from '../common/to-edgeless-embed-block.js'; import { EmbedFigmaBlockComponent } from './embed-figma-block.js'; export class EmbedEdgelessBlockComponent extends toEdgelessEmbedBlock( EmbedFigmaBlockComponent ) {} + +export const EmbedFigmaBlockInteraction = createEmbedEdgelessBlockInteraction( + EmbedFigmaBlockSchema.model.flavour +); diff --git a/blocksuite/affine/blocks/embed/src/embed-figma-block/embed-figma-spec.ts b/blocksuite/affine/blocks/embed/src/embed-figma-block/embed-figma-spec.ts index 4b94fe902b..649c4489b0 100644 --- a/blocksuite/affine/blocks/embed/src/embed-figma-block/embed-figma-spec.ts +++ b/blocksuite/affine/blocks/embed/src/embed-figma-block/embed-figma-spec.ts @@ -7,6 +7,7 @@ import { literal } from 'lit/static-html.js'; import { createBuiltinToolbarConfigExtension } from '../configs/toolbar'; import { EmbedFigmaBlockAdapterExtensions } from './adapters/extension'; import { embedFigmaSlashMenuConfig } from './configs/slash-menu'; +import { EmbedFigmaBlockInteraction } from './embed-edgeless-figma-block'; import { EmbedFigmaBlockComponent } from './embed-figma-block'; import { EmbedFigmaBlockOptionConfig } from './embed-figma-service'; @@ -35,4 +36,5 @@ export const EmbedFigmaViewExtensions: ExtensionType[] = [ EmbedFigmaBlockOptionConfig, createBuiltinToolbarConfigExtension(flavour, EmbedFigmaBlockComponent), SlashMenuConfigExtension(flavour, embedFigmaSlashMenuConfig), + EmbedFigmaBlockInteraction, ].flat(); diff --git a/blocksuite/affine/blocks/embed/src/embed-github-block/embed-edgeless-github-block.ts b/blocksuite/affine/blocks/embed/src/embed-github-block/embed-edgeless-github-block.ts index 3ea5794366..7b53506a76 100644 --- a/blocksuite/affine/blocks/embed/src/embed-github-block/embed-edgeless-github-block.ts +++ b/blocksuite/affine/blocks/embed/src/embed-github-block/embed-edgeless-github-block.ts @@ -1,6 +1,13 @@ +import { EmbedGithubBlockSchema } from '@blocksuite/affine-model'; + +import { createEmbedEdgelessBlockInteraction } from '../common/embed-block-element.js'; import { toEdgelessEmbedBlock } from '../common/to-edgeless-embed-block.js'; import { EmbedGithubBlockComponent } from './embed-github-block.js'; export class EmbedEdgelessGithubBlockComponent extends toEdgelessEmbedBlock( EmbedGithubBlockComponent ) {} + +export const EmbedGithubBlockInteraction = createEmbedEdgelessBlockInteraction( + EmbedGithubBlockSchema.model.flavour +); diff --git a/blocksuite/affine/blocks/embed/src/embed-github-block/embed-github-spec.ts b/blocksuite/affine/blocks/embed/src/embed-github-block/embed-github-spec.ts index 34b1e60a9a..4f3a9449e0 100644 --- a/blocksuite/affine/blocks/embed/src/embed-github-block/embed-github-spec.ts +++ b/blocksuite/affine/blocks/embed/src/embed-github-block/embed-github-spec.ts @@ -7,6 +7,7 @@ import { literal } from 'lit/static-html.js'; import { createBuiltinToolbarConfigExtension } from '../configs/toolbar'; import { EmbedGithubBlockAdapterExtensions } from './adapters/extension'; import { embedGithubSlashMenuConfig } from './configs/slash-menu'; +import { EmbedGithubBlockInteraction } from './embed-edgeless-github-block'; import { EmbedGithubBlockComponent } from './embed-github-block'; import { EmbedGithubBlockOptionConfig, @@ -38,6 +39,7 @@ export const EmbedGithubViewExtensions: ExtensionType[] = [ : literal`affine-embed-github-block`; }), EmbedGithubBlockOptionConfig, + EmbedGithubBlockInteraction, createBuiltinToolbarConfigExtension(flavour, EmbedGithubBlockComponent), SlashMenuConfigExtension(flavour, embedGithubSlashMenuConfig), ].flat(); diff --git a/blocksuite/affine/blocks/embed/src/embed-html-block/embed-edgeless-html-block.ts b/blocksuite/affine/blocks/embed/src/embed-html-block/embed-edgeless-html-block.ts index 86dfee16a9..a980a1927f 100644 --- a/blocksuite/affine/blocks/embed/src/embed-html-block/embed-edgeless-html-block.ts +++ b/blocksuite/affine/blocks/embed/src/embed-html-block/embed-edgeless-html-block.ts @@ -1,6 +1,19 @@ +import { EmbedHtmlBlockSchema } from '@blocksuite/affine-model'; + +import { createEmbedEdgelessBlockInteraction } from '../common/embed-block-element.js'; import { toEdgelessEmbedBlock } from '../common/to-edgeless-embed-block.js'; import { EmbedHtmlBlockComponent } from './embed-html-block.js'; +import { EMBED_HTML_MIN_HEIGHT, EMBED_HTML_MIN_WIDTH } from './styles.js'; export class EmbedEdgelessHtmlBlockComponent extends toEdgelessEmbedBlock( EmbedHtmlBlockComponent ) {} + +export const EmbedEdgelessHtmlBlockInteraction = + createEmbedEdgelessBlockInteraction(EmbedHtmlBlockSchema.model.flavour, { + resizeConstraint: { + minWidth: EMBED_HTML_MIN_WIDTH, + minHeight: EMBED_HTML_MIN_HEIGHT, + lockRatio: false, + }, + }); diff --git a/blocksuite/affine/blocks/embed/src/embed-html-block/embed-html-spec.ts b/blocksuite/affine/blocks/embed/src/embed-html-block/embed-html-spec.ts index 401212cc73..b56f37047a 100644 --- a/blocksuite/affine/blocks/embed/src/embed-html-block/embed-html-spec.ts +++ b/blocksuite/affine/blocks/embed/src/embed-html-block/embed-html-spec.ts @@ -4,6 +4,7 @@ import type { ExtensionType } from '@blocksuite/store'; import { literal } from 'lit/static-html.js'; import { createBuiltinToolbarConfigExtension } from './configs/toolbar'; +import { EmbedEdgelessHtmlBlockInteraction } from './embed-edgeless-html-block'; const flavour = EmbedHtmlBlockSchema.model.flavour; @@ -23,4 +24,5 @@ export const EmbedHtmlViewExtensions: ExtensionType[] = [ : literal`affine-embed-html-block`; }), createBuiltinToolbarConfigExtension(flavour), + EmbedEdgelessHtmlBlockInteraction, ].flat(); diff --git a/blocksuite/affine/blocks/embed/src/embed-iframe-block/embed-edgeless-iframe-block.ts b/blocksuite/affine/blocks/embed/src/embed-iframe-block/embed-edgeless-iframe-block.ts index 0e790756c8..51a6596a89 100644 --- a/blocksuite/affine/blocks/embed/src/embed-iframe-block/embed-edgeless-iframe-block.ts +++ b/blocksuite/affine/blocks/embed/src/embed-iframe-block/embed-edgeless-iframe-block.ts @@ -1,6 +1,8 @@ import { EdgelessLegacySlotIdentifier } from '@blocksuite/affine-block-surface'; -import { Bound } from '@blocksuite/global/gfx'; +import { EmbedIframeBlockSchema } from '@blocksuite/affine-model'; +import { Bound, clamp } from '@blocksuite/global/gfx'; import { toGfxBlockComponent } from '@blocksuite/std'; +import { GfxViewInteractionExtension } from '@blocksuite/std/gfx'; import { styleMap } from 'lit/directives/style-map.js'; import { html } from 'lit/static-html.js'; @@ -53,3 +55,65 @@ export class EmbedEdgelessIframeBlockComponent extends toGfxBlockComponent( `; } } + +export const EmbedIframeInteraction = + GfxViewInteractionExtension( + EmbedIframeBlockSchema.model.flavour, + { + resizeConstraint: { + minWidth: 218, + minHeight: 44, + maxWidth: 3400, + maxHeight: 2200, + }, + + handleResize: context => { + const { model } = context; + const initialScale = model.props.scale$.peek(); + + return { + onResizeStart(context) { + context.default(context); + model.stash('scale'); + }, + onResizeMove(context) { + const { newBound, originalBound, lockRatio, constraint } = context; + const { minWidth, maxWidth, minHeight, maxHeight } = constraint; + + let scale = initialScale; + const originalRealWidth = originalBound.w / scale; + + // update scale if resize is proportional + if (lockRatio) { + scale = newBound.w / originalRealWidth; + } + + let newRealWidth = clamp(newBound.w / scale, minWidth, maxWidth); + let newRealHeight = clamp(newBound.h / scale, minHeight, maxHeight); + + newBound.w = newRealWidth * scale; + newBound.h = newRealHeight * scale; + + model.props.xywh = newBound.serialize(); + if (scale !== initialScale) { + model.props.scale = scale; + } + }, + onResizeEnd(context) { + context.default(context); + model.pop('scale'); + }, + }; + }, + + handleRotate: () => { + return { + beforeRotate(context) { + context.set({ + rotatable: false, + }); + }, + }; + }, + } + ); diff --git a/blocksuite/affine/blocks/embed/src/embed-iframe-block/embed-iframe-spec.ts b/blocksuite/affine/blocks/embed/src/embed-iframe-block/embed-iframe-spec.ts index 7e0228df77..b7907a23aa 100644 --- a/blocksuite/affine/blocks/embed/src/embed-iframe-block/embed-iframe-spec.ts +++ b/blocksuite/affine/blocks/embed/src/embed-iframe-block/embed-iframe-spec.ts @@ -7,6 +7,7 @@ import { literal } from 'lit/static-html.js'; import { EmbedIframeBlockAdapterExtensions } from './adapters'; import { embedIframeSlashMenuConfig } from './configs/slash-menu/slash-menu'; import { createBuiltinToolbarConfigExtension } from './configs/toolbar'; +import { EmbedIframeInteraction } from './embed-edgeless-iframe-block'; const flavour = EmbedIframeBlockSchema.model.flavour; @@ -31,4 +32,5 @@ export const EmbedIframeViewExtensions: ExtensionType[] = [ }), createBuiltinToolbarConfigExtension(flavour), SlashMenuConfigExtension(flavour, embedIframeSlashMenuConfig), + EmbedIframeInteraction, ].flat(); diff --git a/blocksuite/affine/blocks/embed/src/embed-loom-block/embed-edgeless-loom-bock.ts b/blocksuite/affine/blocks/embed/src/embed-loom-block/embed-edgeless-loom-bock.ts index 84b4e9a987..8bfa08ddc2 100644 --- a/blocksuite/affine/blocks/embed/src/embed-loom-block/embed-edgeless-loom-bock.ts +++ b/blocksuite/affine/blocks/embed/src/embed-loom-block/embed-edgeless-loom-bock.ts @@ -1,6 +1,13 @@ +import { EmbedLoomBlockSchema } from '@blocksuite/affine-model'; + +import { createEmbedEdgelessBlockInteraction } from '../common/embed-block-element.js'; import { toEdgelessEmbedBlock } from '../common/to-edgeless-embed-block.js'; import { EmbedLoomBlockComponent } from './embed-loom-block.js'; export class EmbedEdgelessLoomBlockComponent extends toEdgelessEmbedBlock( EmbedLoomBlockComponent ) {} + +export const EmbedLoomBlockInteraction = createEmbedEdgelessBlockInteraction( + EmbedLoomBlockSchema.model.flavour +); diff --git a/blocksuite/affine/blocks/embed/src/embed-loom-block/embed-loom-spec.ts b/blocksuite/affine/blocks/embed/src/embed-loom-block/embed-loom-spec.ts index 551152bb91..cd93ce0faf 100644 --- a/blocksuite/affine/blocks/embed/src/embed-loom-block/embed-loom-spec.ts +++ b/blocksuite/affine/blocks/embed/src/embed-loom-block/embed-loom-spec.ts @@ -7,6 +7,7 @@ import { literal } from 'lit/static-html.js'; import { createBuiltinToolbarConfigExtension } from '../configs/toolbar'; import { EmbedLoomBlockAdapterExtensions } from './adapters/extension'; import { embedLoomSlashMenuConfig } from './configs/slash-menu'; +import { EmbedLoomBlockInteraction } from './embed-edgeless-loom-bock'; import { EmbedLoomBlockComponent } from './embed-loom-block'; import { EmbedLoomBlockOptionConfig, @@ -40,4 +41,5 @@ export const EmbedLoomViewExtensions: ExtensionType[] = [ EmbedLoomBlockOptionConfig, createBuiltinToolbarConfigExtension(flavour, EmbedLoomBlockComponent), SlashMenuConfigExtension(flavour, embedLoomSlashMenuConfig), + EmbedLoomBlockInteraction, ].flat(); diff --git a/blocksuite/affine/blocks/embed/src/embed-youtube-block/embed-edgeless-youtube-block.ts b/blocksuite/affine/blocks/embed/src/embed-youtube-block/embed-edgeless-youtube-block.ts index 3e4b52ab19..bd8634e10f 100644 --- a/blocksuite/affine/blocks/embed/src/embed-youtube-block/embed-edgeless-youtube-block.ts +++ b/blocksuite/affine/blocks/embed/src/embed-youtube-block/embed-edgeless-youtube-block.ts @@ -1,6 +1,13 @@ +import { EmbedYoutubeBlockSchema } from '@blocksuite/affine-model'; + +import { createEmbedEdgelessBlockInteraction } from '../common/embed-block-element.js'; import { toEdgelessEmbedBlock } from '../common/to-edgeless-embed-block.js'; import { EmbedYoutubeBlockComponent } from './embed-youtube-block.js'; export class EmbedEdgelessYoutubeBlockComponent extends toEdgelessEmbedBlock( EmbedYoutubeBlockComponent ) {} + +export const EmbedYoutubeBlockInteraction = createEmbedEdgelessBlockInteraction( + EmbedYoutubeBlockSchema.model.flavour +); diff --git a/blocksuite/affine/blocks/embed/src/embed-youtube-block/embed-youtube-spec.ts b/blocksuite/affine/blocks/embed/src/embed-youtube-block/embed-youtube-spec.ts index bf8d13fe02..6e2b6fe1d3 100644 --- a/blocksuite/affine/blocks/embed/src/embed-youtube-block/embed-youtube-spec.ts +++ b/blocksuite/affine/blocks/embed/src/embed-youtube-block/embed-youtube-spec.ts @@ -7,6 +7,7 @@ import { literal } from 'lit/static-html.js'; import { createBuiltinToolbarConfigExtension } from '../configs/toolbar'; import { EmbedYoutubeBlockAdapterExtensions } from './adapters/extension'; import { embedYoutubeSlashMenuConfig } from './configs/slash-menu'; +import { EmbedYoutubeBlockInteraction } from './embed-edgeless-youtube-block'; import { EmbedYoutubeBlockComponent } from './embed-youtube-block'; import { EmbedYoutubeBlockOptionConfig, @@ -40,4 +41,5 @@ export const EmbedYoutubeViewExtensions: ExtensionType[] = [ EmbedYoutubeBlockOptionConfig, createBuiltinToolbarConfigExtension(flavour, EmbedYoutubeBlockComponent), SlashMenuConfigExtension('affine:embed-youtube', embedYoutubeSlashMenuConfig), + EmbedYoutubeBlockInteraction, ].flat(); diff --git a/blocksuite/affine/blocks/embed/src/index.ts b/blocksuite/affine/blocks/embed/src/index.ts index d7012571ec..94430175ac 100644 --- a/blocksuite/affine/blocks/embed/src/index.ts +++ b/blocksuite/affine/blocks/embed/src/index.ts @@ -20,7 +20,10 @@ export const EmbedExtensions: ExtensionType[] = [ export { createEmbedBlockHtmlAdapterMatcher } from './common/adapters/html'; export { createEmbedBlockMarkdownAdapterMatcher } from './common/adapters/markdown'; export { createEmbedBlockPlainTextAdapterMatcher } from './common/adapters/plain-text'; -export { EmbedBlockComponent } from './common/embed-block-element'; +export { + createEmbedEdgelessBlockInteraction, + EmbedBlockComponent, +} from './common/embed-block-element'; export * from './common/embed-note-content-styles'; export { insertEmbedCard } from './common/insert-embed-card'; export * from './common/render-linked-doc'; diff --git a/blocksuite/affine/blocks/embed/src/view.ts b/blocksuite/affine/blocks/embed/src/view.ts index f66708798f..2e5c8a34fa 100644 --- a/blocksuite/affine/blocks/embed/src/view.ts +++ b/blocksuite/affine/blocks/embed/src/view.ts @@ -8,26 +8,32 @@ import { EdgelessClipboardEmbedFigmaConfig, EmbedFigmaViewExtensions, } from './embed-figma-block'; +import { EmbedFigmaBlockInteraction } from './embed-figma-block/embed-edgeless-figma-block'; import { EdgelessClipboardEmbedGithubConfig, EmbedGithubViewExtensions, } from './embed-github-block'; +import { EmbedGithubBlockInteraction } from './embed-github-block/embed-edgeless-github-block'; import { EdgelessClipboardEmbedHtmlConfig, EmbedHtmlViewExtensions, } from './embed-html-block'; +import { EmbedEdgelessHtmlBlockInteraction } from './embed-html-block/embed-edgeless-html-block'; import { EdgelessClipboardEmbedIframeConfig, EmbedIframeViewExtensions, } from './embed-iframe-block'; +import { EmbedIframeInteraction } from './embed-iframe-block/embed-edgeless-iframe-block'; import { EdgelessClipboardEmbedLoomConfig, EmbedLoomViewExtensions, } from './embed-loom-block'; +import { EmbedLoomBlockInteraction } from './embed-loom-block/embed-edgeless-loom-bock'; import { EdgelessClipboardEmbedYoutubeConfig, EmbedYoutubeViewExtensions, } from './embed-youtube-block'; +import { EmbedYoutubeBlockInteraction } from './embed-youtube-block/embed-edgeless-youtube-block'; export class EmbedViewExtension extends ViewExtensionProvider { override name = 'affine-embed-block'; @@ -54,6 +60,12 @@ export class EmbedViewExtension extends ViewExtensionProvider { EdgelessClipboardEmbedLoomConfig, EdgelessClipboardEmbedYoutubeConfig, EdgelessClipboardEmbedIframeConfig, + EmbedFigmaBlockInteraction, + EmbedGithubBlockInteraction, + EmbedEdgelessHtmlBlockInteraction, + EmbedLoomBlockInteraction, + EmbedYoutubeBlockInteraction, + EmbedIframeInteraction, ]); } } diff --git a/blocksuite/affine/blocks/frame/src/frame-block.ts b/blocksuite/affine/blocks/frame/src/frame-block.ts index a82d1d9b0a..213e392ac1 100644 --- a/blocksuite/affine/blocks/frame/src/frame-block.ts +++ b/blocksuite/affine/blocks/frame/src/frame-block.ts @@ -1,13 +1,28 @@ -import { DefaultTheme, type FrameBlockModel } from '@blocksuite/affine-model'; +import { OverlayIdentifier } from '@blocksuite/affine-block-surface'; +import { + DefaultTheme, + type FrameBlockModel, + FrameBlockSchema, +} from '@blocksuite/affine-model'; import { ThemeProvider } from '@blocksuite/affine-shared/services'; import { Bound } from '@blocksuite/global/gfx'; import { GfxBlockComponent } from '@blocksuite/std'; -import type { BoxSelectionContext, SelectedContext } from '@blocksuite/std/gfx'; +import { + type BoxSelectionContext, + getTopElements, + GfxViewInteractionExtension, + type SelectedContext, +} from '@blocksuite/std/gfx'; 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 { + EdgelessFrameManagerIdentifier, + type FrameOverlay, +} from './frame-manager'; + export class FrameBlockComponent extends GfxBlockComponent { override connectedCallback() { super.connectedCallback(); @@ -115,3 +130,64 @@ declare global { 'affine-frame': FrameBlockComponent; } } + +export const FrameBlockInteraction = + GfxViewInteractionExtension( + FrameBlockSchema.model.flavour, + { + handleResize: context => { + const { model, std } = context; + + return { + onResizeStart(context): void { + context.default(context); + model.stash('childElementIds'); + }, + + onResizeMove(context): void { + const { newBound } = context; + const frameManager = std.getOptional( + EdgelessFrameManagerIdentifier + ); + const overlay = std.getOptional( + OverlayIdentifier('frame') + ) as FrameOverlay; + + model.xywh = newBound.serialize(); + + if (!frameManager) { + return; + } + + const oldChildren = frameManager.getChildElementsInFrame(model); + + const newChildren = getTopElements( + frameManager.getElementsInFrameBound(model) + ).concat( + oldChildren.filter(oldChild => { + return model.intersectsBound(oldChild.elementBound); + }) + ); + + frameManager.removeAllChildrenFromFrame(model); + frameManager.addElementsToFrame(model, newChildren); + + overlay?.highlight(model, true, false); + }, + onResizeEnd(context): void { + context.default(context); + model.pop('childElementIds'); + }, + }; + }, + handleRotate: () => { + return { + beforeRotate(context): void { + context.set({ + rotatable: false, + }); + }, + }; + }, + } + ); diff --git a/blocksuite/affine/blocks/frame/src/frame-spec.ts b/blocksuite/affine/blocks/frame/src/frame-spec.ts index 07e2a0c23c..d555f03f26 100644 --- a/blocksuite/affine/blocks/frame/src/frame-spec.ts +++ b/blocksuite/affine/blocks/frame/src/frame-spec.ts @@ -3,6 +3,7 @@ import { BlockViewExtension } from '@blocksuite/std'; import type { ExtensionType } from '@blocksuite/store'; import { literal } from 'lit/static-html.js'; +import { FrameBlockInteraction } from './frame-block'; import { EdgelessFrameManager, FrameOverlay } from './frame-manager'; const flavour = FrameBlockSchema.model.flavour; @@ -11,4 +12,5 @@ export const FrameBlockSpec: ExtensionType[] = [ BlockViewExtension(flavour, literal`affine-frame`), FrameOverlay, EdgelessFrameManager, + FrameBlockInteraction, ]; diff --git a/blocksuite/affine/blocks/frame/src/view.ts b/blocksuite/affine/blocks/frame/src/view.ts index 28d029493b..9644629e15 100644 --- a/blocksuite/affine/blocks/frame/src/view.ts +++ b/blocksuite/affine/blocks/frame/src/view.ts @@ -6,6 +6,7 @@ import { import { EdgelessClipboardFrameConfig } from './edgeless-clipboard-config'; import { frameQuickTool } from './edgeless-toolbar'; import { effects } from './effects'; +import { FrameBlockInteraction } from './frame-block'; import { FrameHighlightManager } from './frame-highlight-manager'; import { FrameBlockSpec } from './frame-spec'; import { FrameTool } from './frame-tool'; @@ -32,6 +33,7 @@ export class FrameViewExtension extends ViewExtensionProvider { context.register(frameToolbarExtension); context.register(edgelessNavigatorBgWidget); context.register(EdgelessClipboardFrameConfig); + context.register(FrameBlockInteraction); } } } diff --git a/blocksuite/affine/blocks/image/src/image-edgeless-block.ts b/blocksuite/affine/blocks/image/src/image-edgeless-block.ts index 3da51550ba..b7262cf60a 100644 --- a/blocksuite/affine/blocks/image/src/image-edgeless-block.ts +++ b/blocksuite/affine/blocks/image/src/image-edgeless-block.ts @@ -2,12 +2,16 @@ import type { BlockCaptionEditor } from '@blocksuite/affine-components/caption'; import { getLoadingIconWith } from '@blocksuite/affine-components/icons'; import { Peekable } from '@blocksuite/affine-components/peek'; import { ResourceController } from '@blocksuite/affine-components/resource'; -import type { ImageBlockModel } from '@blocksuite/affine-model'; +import { + type ImageBlockModel, + ImageBlockSchema, +} from '@blocksuite/affine-model'; import { ThemeProvider } from '@blocksuite/affine-shared/services'; import { unsafeCSSVarV2 } from '@blocksuite/affine-shared/theme'; import { humanFileSize } from '@blocksuite/affine-shared/utils'; import { BrokenImageIcon, ImageIcon } from '@blocksuite/icons/lit'; import { GfxBlockComponent } from '@blocksuite/std'; +import { GfxViewInteractionExtension } from '@blocksuite/std/gfx'; import { computed } from '@preact/signals-core'; import { css, html } from 'lit'; import { query } from 'lit/decorators.js'; @@ -172,6 +176,15 @@ export class ImageEdgelessBlockComponent extends GfxBlockComponent( + NoteBlockSchema.model.flavour, + { + resizeConstraint: { + minWidth: 170 + 24 * 2, + minHeight: 92, + }, + handleRotate: () => { + return { + beforeRotate(context) { + context.set({ + rotatable: false, + }); + }, + }; + }, + handleResize: ({ model }) => { + const initialScale: number = model.props.edgeless.scale ?? 1; + return { + onResizeStart(context): void { + context.default(context); + model.stash('edgeless'); + }, + + onResizeMove(context): void { + const { originalBound, newBound, lockRatio, constraint } = context; + const { minWidth, minHeight } = constraint; + + let scale = initialScale; + let edgelessProp = { ...model.props.edgeless }; + const originalRealWidth = originalBound.w / scale; + + if (lockRatio) { + scale = newBound.w / originalRealWidth; + edgelessProp.scale = scale; + } + + newBound.w = clamp(newBound.w, minWidth, Number.MAX_SAFE_INTEGER); + newBound.h = clamp(newBound.h, minHeight, Number.MAX_SAFE_INTEGER); + + if (newBound.h > minHeight * scale) { + edgelessProp.collapse = true; + edgelessProp.collapsedHeight = newBound.h / scale; + } + + model.props.edgeless = edgelessProp; + model.props.xywh = newBound.serialize(); + }, + + onResizeEnd(context): void { + context.default(context); + model.pop('edgeless'); + }, + }; + }, + } + ); diff --git a/blocksuite/affine/blocks/note/src/note-spec.ts b/blocksuite/affine/blocks/note/src/note-spec.ts index 6904c6bd76..0d845e62d1 100644 --- a/blocksuite/affine/blocks/note/src/note-spec.ts +++ b/blocksuite/affine/blocks/note/src/note-spec.ts @@ -9,6 +9,7 @@ import { } from './adapters/index'; import { NoteSlashMenuConfigExtension } from './configs/slash-menu'; import { createBuiltinToolbarConfigExtension } from './configs/toolbar'; +import { EdgelessNoteInteraction } from './note-edgeless-block'; import { NoteKeymapExtension } from './note-keymap.js'; const flavour = NoteBlockSchema.model.flavour; @@ -28,4 +29,5 @@ export const EdgelessNoteBlockSpec: ExtensionType[] = [ NoteSlashMenuConfigExtension, createBuiltinToolbarConfigExtension(flavour), NoteKeymapExtension, + EdgelessNoteInteraction, ].flat(); diff --git a/blocksuite/affine/blocks/note/src/view.ts b/blocksuite/affine/blocks/note/src/view.ts index fa18357d79..836d916d4b 100644 --- a/blocksuite/affine/blocks/note/src/view.ts +++ b/blocksuite/affine/blocks/note/src/view.ts @@ -10,6 +10,7 @@ import { NoteSlashMenuConfigExtension } from './configs/slash-menu'; import { createBuiltinToolbarConfigExtension } from './configs/toolbar'; import { EdgelessClipboardNoteConfig } from './edgeless-clipboard-config'; import { effects } from './effects'; +import { EdgelessNoteInteraction } from './note-edgeless-block'; import { NoteKeymapExtension } from './note-keymap'; const flavour = NoteBlockSchema.model.flavour; @@ -38,6 +39,7 @@ export class NoteViewExtension extends ViewExtensionProvider { ); context.register(createBuiltinToolbarConfigExtension(flavour)); context.register(EdgelessClipboardNoteConfig); + context.register(EdgelessNoteInteraction); } else { context.register(BlockViewExtension(flavour, literal`affine-note`)); } diff --git a/blocksuite/affine/blocks/root/src/edgeless/components/auto-complete/edgeless-auto-complete.ts b/blocksuite/affine/blocks/root/src/edgeless/components/auto-complete/edgeless-auto-complete.ts index 9837eae001..ab9856320c 100644 --- a/blocksuite/affine/blocks/root/src/edgeless/components/auto-complete/edgeless-auto-complete.ts +++ b/blocksuite/affine/blocks/root/src/edgeless/components/auto-complete/edgeless-auto-complete.ts @@ -691,6 +691,12 @@ export class EdgelessAutoComplete extends WithDisposable(LitElement) { }) ); + _disposables.add( + gfx.selection.slots.updated.subscribe(() => { + this.requestUpdate(); + }) + ); + _disposables.add(() => this.removeOverlay()); _disposables.add( @@ -716,6 +722,15 @@ export class EdgelessAutoComplete extends WithDisposable(LitElement) { }); } + private _canAutoComplete() { + const selection = this.gfx.selection; + return ( + selection.selectedElements.length === 1 && + (selection.selectedElements[0] instanceof ShapeElementModel || + isNoteBlock(selection.selectedElements[0])) + ); + } + removeOverlay() { this._timer && clearTimeout(this._timer); const surface = getSurfaceComponent(this.std); @@ -727,7 +742,10 @@ export class EdgelessAutoComplete extends WithDisposable(LitElement) { const isShape = this.current instanceof ShapeElementModel; const isMindMap = this.current.group instanceof MindmapElementModel; - if (this._isMoving || (this._isHover && !isShape)) { + if ( + this._isMoving || + (this._isHover && !isShape && this._canAutoComplete()) + ) { this.removeOverlay(); return nothing; } diff --git a/blocksuite/affine/blocks/root/src/edgeless/components/rects/edgeless-selected-rect.ts b/blocksuite/affine/blocks/root/src/edgeless/components/rects/edgeless-selected-rect.ts index 5fc7e62e83..63aea44a25 100644 --- a/blocksuite/affine/blocks/root/src/edgeless/components/rects/edgeless-selected-rect.ts +++ b/blocksuite/affine/blocks/root/src/edgeless/components/rects/edgeless-selected-rect.ts @@ -1,113 +1,34 @@ -import type { EdgelessTextBlockComponent } from '@blocksuite/affine-block-edgeless-text'; +import { type FrameOverlay } from '@blocksuite/affine-block-frame'; +import { OverlayIdentifier } from '@blocksuite/affine-block-surface'; import { - EMBED_HTML_MIN_HEIGHT, - EMBED_HTML_MIN_WIDTH, -} from '@blocksuite/affine-block-embed'; -import { - SYNCED_MIN_HEIGHT, - SYNCED_MIN_WIDTH, -} from '@blocksuite/affine-block-embed-doc'; -import { - EdgelessFrameManagerIdentifier, - type FrameOverlay, - isFrameBlock, -} from '@blocksuite/affine-block-frame'; -import { - CanvasElementType, - isNoteBlock, - OverlayIdentifier, -} from '@blocksuite/affine-block-surface'; -import { isMindmapNode } from '@blocksuite/affine-gfx-mindmap'; -import { normalizeShapeBound } from '@blocksuite/affine-gfx-shape'; -import { normalizeTextBound } from '@blocksuite/affine-gfx-text'; -import { - type BookmarkBlockModel, ConnectorElementModel, - EDGELESS_TEXT_BLOCK_MIN_WIDTH, - type EdgelessTextBlockModel, - type EmbedHtmlModel, - type EmbedSyncedDocModel, - FrameBlockModel, - NOTE_MIN_HEIGHT, - NOTE_MIN_WIDTH, - NoteBlockModel, type RootBlockModel, - ShapeElementModel, - TextElementModel, } from '@blocksuite/affine-model'; -import { EMBED_CARD_HEIGHT } from '@blocksuite/affine-shared/consts'; import { unsafeCSSVarV2 } from '@blocksuite/affine-shared/theme'; import { - getElementsWithoutGroup, getSelectedRect, requestThrottledConnectedFrame, stopPropagation, } from '@blocksuite/affine-shared/utils'; -import type { IPoint, IVec, PointLocation } from '@blocksuite/global/gfx'; -import { - Bound, - deserializeXYWH, - normalizeDegAngle, -} from '@blocksuite/global/gfx'; -import { assertType } from '@blocksuite/global/utils'; +import { deserializeXYWH } from '@blocksuite/global/gfx'; import { WidgetComponent } from '@blocksuite/std'; import { type CursorType, - getTopElements, GfxControllerIdentifier, type GfxModel, - type GfxPrimitiveElementModel, + InteractivityIdentifier, + type ResizeHandle, } from '@blocksuite/std/gfx'; import { css, html, nothing } from 'lit'; import { state } from 'lit/decorators.js'; import { ifDefined } from 'lit/directives/if-defined.js'; +import { repeat } from 'lit/directives/repeat.js'; import { styleMap } from 'lit/directives/style-map.js'; -import clamp from 'lodash-es/clamp'; -import { Subject, type Subscription } from 'rxjs'; +import { type Subscription } from 'rxjs'; import type { EdgelessRootBlockComponent } from '../../edgeless-root-block.js'; -import { - AI_CHAT_BLOCK_MAX_HEIGHT, - AI_CHAT_BLOCK_MAX_WIDTH, - AI_CHAT_BLOCK_MIN_HEIGHT, - AI_CHAT_BLOCK_MIN_WIDTH, - EMBED_IFRAME_BLOCK_MAX_HEIGHT, - EMBED_IFRAME_BLOCK_MAX_WIDTH, - EMBED_IFRAME_BLOCK_MIN_HEIGHT, - EMBED_IFRAME_BLOCK_MIN_WIDTH, -} from '../../utils/consts.js'; -import { - getSelectableBounds, - isAIChatBlock, - isAttachmentBlock, - isBookmarkBlock, - isCanvasElement, - isEdgelessTextBlock, - isEmbeddedBlock, - isEmbedFigmaBlock, - isEmbedGithubBlock, - isEmbedHtmlBlock, - isEmbedIframeBlock, - isEmbedLinkedDocBlock, - isEmbedLoomBlock, - isEmbedSyncedDocBlock, - isEmbedYoutubeBlock, - isImageBlock, -} 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'; +import { RenderResizeHandles } from '../resize/resize-handles.js'; +import { generateCursorUrl, getRotatedResizeCursor } from '../utils.js'; export type SelectedRect = { left: number; @@ -121,13 +42,6 @@ export type SelectedRect = { export const EDGELESS_SELECTED_RECT_WIDGET = 'edgeless-selected-rect'; -interface ResizeConstraints { - minWidth: number; - maxWidth: number; - minHeight: number; - maxHeight: number; -} - export class EdgelessSelectedRectWidget extends WidgetComponent< RootBlockModel, EdgelessRootBlockComponent @@ -455,10 +369,6 @@ export class EdgelessSelectedRectWidget extends WidgetComponent< } `; - private _cursorRotate = 0; - - private _dragEndCallback: (() => void)[] = []; - private readonly _initSelectedSlot = () => { this._propDisposables.forEach(disposable => disposable.unsubscribe()); this._propDisposables = []; @@ -474,345 +384,60 @@ export class EdgelessSelectedRectWidget extends WidgetComponent< }); }; - private readonly _onDragEnd = () => { - this.slots.dragEnd.next(); - - this.store.transact(() => { - this._dragEndCallback.forEach(cb => cb()); - }); - - this._dragEndCallback = []; + private readonly _dragEndCleanup = () => { this._isWidthLimit = false; this._isHeightLimit = false; - this._updateCursor(false); - this._scalePercent = undefined; this._scaleDirection = undefined; - this._updateMode(); - this.block?.slots.elementResizeEnd.next(); + this._updateCursor(); this.frameOverlay.clear(); }; - private readonly _onDragMove = ( - newBounds: Map< - string, - { - bound: Bound; - path?: PointLocation[]; - matrix?: DOMMatrix; - } - >, - direction: HandleDirection - ) => { - this.slots.dragMove.next(); - - 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 (isEmbedIframeBlock(element)) { - this.#adjustEmbedIframe(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 readonly _onDragRotate = (center: IPoint, delta: number) => { - this.slots.dragRotate.next(); - - 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: normalizeDegAngle(rotate + delta), - }); - } - }); - - this._updateCursor(true, { type: 'rotate', angle: delta }); - this._updateMode(); - }; - - private readonly _onDragStart = () => { - this.slots.dragStart.next(); - - const rotation = this._resizeManager.rotation; - - this._dragEndCallback = []; - this.block?.slots.elementResizeStart.next(); - 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: Subscription[] = []; - private readonly _resizeManager: HandleResizeManager; - - private readonly _updateCursor = ( - dragging: boolean, - options?: { - type: 'resize' | 'rotate'; - angle?: number; - target?: HTMLElement; - point?: IVec; + private readonly _updateCursor = (options?: { + type: 'resize' | 'rotate'; + angle: number; + handle: ResizeHandle; + }) => { + if (!options) { + !this._isResizing && (this.gfx.cursor$.value = 'default'); + return; } - ) => { + + const { type, angle, handle } = options; 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); - } - } + if (type === 'rotate') { + cursor = generateCursorUrl(angle, handle); } else { - this._cursorRotate = 0; + cursor = getRotatedResizeCursor({ + handle, + angle, + }); } + this.gfx.cursor$.value = cursor; }; - private readonly _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 readonly _updateOnElementChange = ( - element: string | { id: string }, - fromRemote: boolean = false + element: string | { id: string } ) => { - 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)) { + if (this.selection.has(id)) { this._updateSelectedRect(); - this._updateMode(); } }; private readonly _updateOnSelectionChange = () => { this._initSelectedSlot(); this._updateSelectedRect(); - this._updateResizeManagerState(true); // Reset the cursor - this._updateCursor(false); - this._updateMode(); - }; - - private readonly _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 readonly _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)); + this._updateCursor(); }; @state() @@ -853,17 +478,6 @@ export class EdgelessSelectedRectWidget extends WidgetComponent< }; }, this); - readonly slots = { - dragStart: new Subject(), - dragMove: new Subject(), - dragRotate: new Subject(), - dragEnd: new Subject(), - }; - - get dragDirection() { - return this._resizeManager.dragDirection; - } - get edgelessSlots() { return this.block?.slots; } @@ -876,71 +490,6 @@ export class EdgelessSelectedRectWidget extends WidgetComponent< 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(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; } @@ -955,418 +504,9 @@ export class EdgelessSelectedRectWidget extends WidgetComponent< 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 - * @deprecated - */ - #adjustBlockWithConstraints( - element: GfxModel, - bound: Bound, - direction: HandleDirection, - constraints: ResizeConstraints - ) { - const curBound = Bound.deserialize(element.xywh); - const { minWidth, maxWidth, minHeight, maxHeight } = constraints; - - let scale = 1; - if ('props' in element && 'scale' in element.props) { - scale = element.props.scale as number; - } - let width = curBound.w / scale; - let height = curBound.h / scale; - - // Handle shift key scaling (maintain aspect ratio) - if (this._shiftKey) { - scale = bound.w / width; - this._scalePercent = `${Math.round(scale * 100)}%`; - this._scaleDirection = direction; - } - - // Apply constraints - width = bound.w / scale; - width = clamp(width, minWidth, maxWidth); - bound.w = width * scale; - - height = bound.h / scale; - height = clamp(height, minHeight, maxHeight); - bound.h = height * scale; - - // Update limit flags - this._isWidthLimit = width === minWidth || width === maxWidth; - this._isHeightLimit = height === minHeight || height === maxHeight; - - this.gfx.updateElement(element.id, { - scale, - xywh: bound.serialize(), - }); - } - - /** - * 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: GfxModel, bound: Bound, direction: HandleDirection) { - this.#adjustBlockWithConstraints(element, bound, direction, { - minWidth: AI_CHAT_BLOCK_MIN_WIDTH, - maxWidth: AI_CHAT_BLOCK_MAX_WIDTH, - minHeight: AI_CHAT_BLOCK_MIN_HEIGHT, - maxHeight: AI_CHAT_BLOCK_MAX_HEIGHT, - }); - } - - /** - * 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-2841/ - * @deprecated - */ - #adjustEmbedIframe( - element: GfxModel, - bound: Bound, - direction: HandleDirection - ) { - this.#adjustBlockWithConstraints(element, bound, direction, { - minWidth: EMBED_IFRAME_BLOCK_MIN_WIDTH, - maxWidth: EMBED_IFRAME_BLOCK_MAX_WIDTH, - minHeight: EMBED_IFRAME_BLOCK_MIN_HEIGHT, - maxHeight: EMBED_IFRAME_BLOCK_MAX_HEIGHT, - }); - } - - #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.props.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.props.scale, - EDGELESS_TEXT_BLOCK_MIN_WIDTH, - Infinity - ); - bound.w = newRealWidth * element.props.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 block = this.std.view.getBlock(element.id); - if (!block) return; - const headerHeight = - block - .querySelector('.affine-embed-synced-doc-edgeless-header-wrapper') - ?.getBoundingClientRect().height ?? 0; - const contentHeight = - block.querySelector('affine-preview-root')?.getBoundingClientRect() - .height ?? 0; - - const maxHeight = (headerHeight + contentHeight) / this.zoom; - - const curBound = Bound.deserialize(element.xywh); - - let scale = element.props.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, maxHeight); - bound.h = height * scale; - - this._isWidthLimit = width === SYNCED_MIN_WIDTH; - this._isHeightLimit = height === SYNCED_MIN_HEIGHT || height === maxHeight; - - this.gfx.updateElement(element.id, { - scale, - xywh: bound.serialize(), - }); - } - - #adjustFrame(frame: FrameBlockModel, bound: Bound) { - const frameManager = this.std.get(EdgelessFrameManagerIdentifier); - - 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.props.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.store.updateBlock(element, () => { - element.props.edgeless.collapse = true; - element.props.edgeless.collapsedHeight = bound.h / scale; - }); - } - - this.gfx.updateElement(element.id, { - edgeless: { - ...element.props.edgeless, - scale, - }, - xywh: bound.serialize(), - }); - } - - #adjustProportional( - element: GfxModel, - bound: Bound, - direction: HandleDirection - ) { - const curBound = Bound.deserialize(element.xywh); - - if (isImageBlock(element)) { - const { height } = element.props; - if (height) { - this._scalePercent = `${Math.round((bound.h / height) * 100)}%`; - this._scaleDirection = direction; - } - } else { - const cardStyle = (element as BookmarkBlockModel).props.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 = 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: GfxModel, - 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: GfxModel) { - return ( - isAttachmentBlock(element) || - isImageBlock(element) || - isBookmarkBlock(element) || - isEmbedFigmaBlock(element) || - isEmbedGithubBlock(element) || - isEmbedYoutubeBlock(element) || - isEmbedLoomBlock(element) || - isEmbedLinkedDocBlock(element) - ); - } - private _shouldRenderSelection(elements?: GfxModel[]) { elements = elements ?? this.selection.selectedElements; return elements.length > 0 && !this.selection.editing; @@ -1377,7 +517,7 @@ export class EdgelessSelectedRectWidget extends WidgetComponent< _disposables.add( // viewport zooming / scrolling - gfx.viewport.viewportUpdated.subscribe(this._updateOnViewportChange) + gfx.viewport.viewportUpdated.subscribe(this._updateSelectedRect) ); if (gfx.surface) { @@ -1405,35 +545,29 @@ export class EdgelessSelectedRectWidget extends WidgetComponent< block.slots.readonlyUpdated.subscribe(() => this.requestUpdate()) ); - _disposables.add( - block.slots.elementResizeStart.subscribe( - () => (this._isResizing = true) - ) - ); _disposables.add( block.slots.elementResizeEnd.subscribe(() => (this._isResizing = false)) ); + } - block.handleEvent( - 'keyDown', - ctx => { - const event = ctx.get('defaultState').event; - if (event instanceof KeyboardEvent) { - this._shift(event); - } - }, - { global: true } - ); + if (this._interaction) { + _disposables.add( + this._interaction.activeInteraction$.subscribe(val => { + const pre = this._isResizing; + const newVal = val?.type === 'resize' || val?.type === 'rotate'; - block.handleEvent( - 'keyUp', - ctx => { - const event = ctx.get('defaultState').event; - if (event instanceof KeyboardEvent) { - this._shift(event); + if (pre === newVal) { + return; } - }, - { global: true } + + this._isResizing = newVal; + + if (newVal) { + block?.slots.elementResizeStart.next(); + } else { + block?.slots.elementResizeEnd.next(); + } + }) ); } @@ -1442,111 +576,173 @@ export class EdgelessSelectedRectWidget extends WidgetComponent< }); } - private _shift(event: KeyboardEvent) { - if (event.repeat) return; + private get _interaction() { + return this.std.getOptional(InteractivityIdentifier); + } - const pressed = event.key.toLowerCase() === 'shift' && event.shiftKey; + private _renderHandles() { + const { selection, gfx, block, store } = this; + const elements = selection.selectedElements; - this._shiftKey = pressed; - this._resizeManager.onPressShiftKey(pressed); - this._updateSelectedRect(); - this._updateMode(); + if (selection.inoperable) { + return []; + } + + const handles = []; + + if ( + this._interaction && + !selection.editing && + !store.readonly && + !elements.some(element => element.isLocked()) + ) { + const interaction = this._interaction; + const resizeHandlers = interaction.getResizeHandlers({ + elements, + }); + const { rotatable } = interaction.getRotateConfig({ + elements, + }); + + handles.push( + RenderResizeHandles( + resizeHandlers, + rotatable, + (e: PointerEvent, handle: ResizeHandle) => { + const isRotate = (e.target as HTMLElement).classList.contains( + 'rotate' + ); + + if (isRotate) { + interaction.handleElementRotate({ + elements: this.selection.selectedElements, + event: e, + onRotateStart: () => { + this._mode = 'rotate'; + }, + onRotateUpdate: payload => { + this._updateCursor({ + type: 'rotate', + angle: payload.currentAngle, + handle, + }); + }, + onRotateEnd: () => { + this._mode = 'resize'; + this._dragEndCleanup(); + }, + }); + } else { + interaction.handleElementResize({ + elements: this.selection.selectedElements, + handle, + event: e, + onResizeStart: () => { + this._mode = 'resize'; + }, + onResizeUpdate: ({ lockRatio, scaleX, exceed }) => { + if (lockRatio) { + this._scaleDirection = handle; + this._scalePercent = `${Math.round(scaleX * 100)}%`; + } + + if (exceed) { + this._isWidthLimit = exceed.w; + this._isHeightLimit = exceed.h; + } + }, + onResizeEnd: () => { + this._mode = 'resize'; + this._dragEndCleanup(); + }, + }); + } + }, + option => { + if (option) { + this._updateCursor({ + ...option, + angle: elements.length > 1 ? 0 : (elements[0]?.rotate ?? 0), + }); + } else { + this._updateCursor(); + } + } + ) + ); + } + + if ( + elements.length === 1 && + elements[0] instanceof ConnectorElementModel && + !elements[0].isLocked() + ) { + handles.push(html` + + `); + } + + if ( + elements.length > 1 && + !elements.some(e => e instanceof ConnectorElementModel) + ) { + handles.push( + repeat( + elements, + element => element.id, + 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`
`; + } + ) + ); + } + + return handles; + } + + private _renderAutoComplete() { + const { store, selection, block, _selectedRect } = this; + + return !store.readonly && + !selection.inoperable && + !this.autoCompleteOff && + !this._isResizing + ? html` + ` + : nothing; } override render() { - if (!this.isConnected) return nothing; - - const { selection } = this; - const elements = selection.selectedElements; + const elements = this.selection.selectedElements; if (!this._shouldRenderSelection(elements)) return nothing; - const { - block, - gfx, - store, - resizeMode, - _resizeManager, - _selectedRect, - _updateCursor, - } = this; - - const hasResizeHandles = !selection.editing && !store.readonly; - const inoperable = selection.inoperable; + const { _selectedRect } = this; 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` - - ` - : 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`
`; - }) - : nothing; - - handlers.push(resizeHandles, connectorHandle, elementHandle); - } + const handlers = this._renderHandles(); const isConnector = elements.length === 1 && elements[0] instanceof ConnectorElementModel; @@ -1578,14 +774,7 @@ export class EdgelessSelectedRectWidget extends WidgetComponent< } - ${!store.readonly && !inoperable && this._canAutoComplete() - ? html` - ` - : nothing} + ${this._renderAutoComplete()}
void, - updateCursor?: ( - dragging: boolean, - options?: { - type: 'resize' | 'rotate'; - target?: HTMLElement; - point?: IVec; - } - ) => void, - hideEdgeHandle?: boolean +function ResizeHandleRenderer( + handle: ResizeHandle, + rotatable: boolean, + onPointerDown?: (e: PointerEvent, direction: ResizeHandle) => void, + updateCursor?: (options?: { + type: 'resize' | 'rotate'; + handle: ResizeHandle; + }) => void ) { const handlerPointerDown = (e: PointerEvent) => { e.stopPropagation(); - onPointerDown && onPointerDown(e, handleDirection); + onPointerDown && onPointerDown(e, handle); }; 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 }); + updateCursor({ type, handle }); }; const pointerLeave = (e: PointerEvent) => { e.stopPropagation(); if (e.buttons === 1 || !updateCursor) return; - updateCursor(false); + updateCursor(); }; const rotationTpl = - handleDirection === HandleDirection.Top || - handleDirection === HandleDirection.Bottom || - handleDirection === HandleDirection.Left || - handleDirection === HandleDirection.Right - ? nothing - : html`
6 && rotatable + ? html`
`; + >
` + : nothing; return html`
${rotationTpl}
@@ -85,135 +75,21 @@ function ResizeHandle( */ 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 +export function RenderResizeHandles( + resizeHandles: ResizeHandle[], + rotatable: boolean, + onPointerDown: (e: PointerEvent, direction: ResizeHandle) => void, + updateCursor?: (options?: { + type: 'resize' | 'rotate'; + handle: ResizeHandle; + }) => 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; - } - } + return html` + ${repeat( + resizeHandles, + handle => handle, + handle => + ResizeHandleRenderer(handle, rotatable, onPointerDown, updateCursor) + )} + `; } diff --git a/blocksuite/affine/blocks/root/src/edgeless/components/resize/resize-manager.ts b/blocksuite/affine/blocks/root/src/edgeless/components/resize/resize-manager.ts deleted file mode 100644 index dbeacdf2ef..0000000000 --- a/blocksuite/affine/blocks/root/src/edgeless/components/resize/resize-manager.ts +++ /dev/null @@ -1,705 +0,0 @@ -import { NOTE_MIN_WIDTH } from '@blocksuite/affine-model'; -import { - Bound, - getQuadBoundWithRotation, - type IPoint, - type IVec, - type PointLocation, - rotatePoints, -} from '@blocksuite/global/gfx'; - -import type { SelectableProps } from '../../utils/query.js'; -import { HandleDirection, type ResizeMode } from './resize-handles.js'; - -// 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 readonly _onDragEnd: DragEndHandler; - - private readonly _onDragStart: DragStartHandler; - - private readonly _onResizeMove: ResizeMoveHandler; - - private readonly _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(); - if (!rect) { - return; - } - 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, - _originalRect, - _currentRect, - } = this; - proportion ||= this._proportion; - - 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) { - 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/affine/blocks/root/src/edgeless/components/utils.ts b/blocksuite/affine/blocks/root/src/edgeless/components/utils.ts index d9af1662e1..972b9432f3 100644 --- a/blocksuite/affine/blocks/root/src/edgeless/components/utils.ts +++ b/blocksuite/affine/blocks/root/src/edgeless/components/utils.ts @@ -1,128 +1,65 @@ -import type { IVec } from '@blocksuite/global/gfx'; -import { normalizeDegAngle, Vec } from '@blocksuite/global/gfx'; -import type { CursorType, StandardCursor } from '@blocksuite/std/gfx'; +import type { + CursorType, + ResizeHandle, + StandardCursor, +} from '@blocksuite/std/gfx'; + +const rotateCursorMap: { + [key in ResizeHandle]: number; +} = { + 'top-right': 0, + 'bottom-right': 90, + 'bottom-left': 180, + 'top-left': 270, + + // not used + left: 0, + right: 0, + top: 0, + bottom: 0, +}; export function generateCursorUrl( angle = 0, + handle: ResizeHandle, 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}`; + angle = ((angle % 360) + 360) % 360; + 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(${rotateCursorMap[handle] + 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}`; } -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(); - - if (!rect) { - console.error('rect not found when calc angle'); - return 0; - } - const { left, top, right, bottom } = rect; - const center = Vec.med([left, top], [right, bottom]); - return normalizeDegAngle( - ((Vec.angle(center, point) + offset) * 180) / Math.PI - ); -} - -export function calcAngleWithRotation( - target: HTMLElement, - point: IVec, - rect: DOMRect, - rotate: number -) { - const handle = target.parentElement; - const ariaLabel = handle?.getAttribute('aria-label'); - 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 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; - const ariaLabel = handle?.getAttribute('aria-label'); - 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; - const ariaLabel = handle?.getAttribute('aria-label'); - return ariaLabel; +const handleToRotateMap: { + [key in ResizeHandle]: number; +} = { + 'top-left': 45, + 'top-right': 135, + 'bottom-right': 45, + 'bottom-left': 135, + left: 0, + right: 0, + top: 90, + bottom: 90, +}; + +const rotateToHandleMap: { + [key: number]: StandardCursor; +} = { + 0: 'ew-resize', + 45: 'nwse-resize', + 90: 'ns-resize', + 135: 'nesw-resize', +}; + +export function getRotatedResizeCursor(option: { + handle: ResizeHandle; + angle: number; +}) { + const angle = + (Math.round( + (handleToRotateMap[option.handle] + ((option.angle + 360) % 360)) / 45 + ) % + 4) * + 45; + + return rotateToHandleMap[angle] || 'default'; } diff --git a/blocksuite/affine/blocks/root/src/edgeless/utils/consts.ts b/blocksuite/affine/blocks/root/src/edgeless/utils/consts.ts index eb3497b4d1..641ad0c9be 100644 --- a/blocksuite/affine/blocks/root/src/edgeless/utils/consts.ts +++ b/blocksuite/affine/blocks/root/src/edgeless/utils/consts.ts @@ -9,13 +9,3 @@ export const ATTACHED_DISTANCE = 20; export const SurfaceColor = '#6046FE'; export const NoteColor = '#1E96EB'; export const BlendColor = '#7D91FF'; - -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; - -export const EMBED_IFRAME_BLOCK_MIN_WIDTH = 218; -export const EMBED_IFRAME_BLOCK_MIN_HEIGHT = 44; -export const EMBED_IFRAME_BLOCK_MAX_WIDTH = 3400; -export const EMBED_IFRAME_BLOCK_MAX_HEIGHT = 2200; diff --git a/blocksuite/affine/gfx/connector/src/connector-watcher.ts b/blocksuite/affine/gfx/connector/src/connector-watcher.ts index 776ca9ad82..b58c0275c7 100644 --- a/blocksuite/affine/gfx/connector/src/connector-watcher.ts +++ b/blocksuite/affine/gfx/connector/src/connector-watcher.ts @@ -62,10 +62,7 @@ export const connectorWatcher: SurfaceMiddleware = ( if ( 'type' in element && element.type === 'connector' && - (props['mode'] !== undefined || - props['target'] || - props['source'] || - (props['xywh'] && !(element as ConnectorElementModel).updatingPath)) + (props['mode'] !== undefined || props['target'] || props['source']) ) { addToUpdateList(element as ConnectorElementModel); } diff --git a/blocksuite/affine/gfx/connector/src/view.ts b/blocksuite/affine/gfx/connector/src/view.ts index 4df717af20..2b08376011 100644 --- a/blocksuite/affine/gfx/connector/src/view.ts +++ b/blocksuite/affine/gfx/connector/src/view.ts @@ -10,7 +10,7 @@ import { ConnectorElementRendererExtension } from './element-renderer'; import { ConnectorFilter } from './element-transform'; import { connectorToolbarExtension } from './toolbar/config'; import { connectorQuickTool } from './toolbar/quick-tool'; -import { ConnectorElementView } from './view/view'; +import { ConnectorElementView, ConnectorInteraction } from './view/view'; export class ConnectorViewExtension extends ViewExtensionProvider { override name = 'affine-connector-gfx'; @@ -30,6 +30,7 @@ export class ConnectorViewExtension extends ViewExtensionProvider { context.register(connectorQuickTool); context.register(connectorToolbarExtension); context.register(ConnectionOverlay); + context.register(ConnectorInteraction); } } } diff --git a/blocksuite/affine/gfx/connector/src/view/view.ts b/blocksuite/affine/gfx/connector/src/view/view.ts index 830776629a..4680a8c2a2 100644 --- a/blocksuite/affine/gfx/connector/src/view/view.ts +++ b/blocksuite/affine/gfx/connector/src/view/view.ts @@ -10,6 +10,7 @@ import { type DragStartContext, generateKeyBetween, GfxElementModelView, + GfxViewInteractionExtension, } from '@blocksuite/std/gfx'; import { mountConnectorLabelEditor } from '../text/edgeless-connector-label-editor'; @@ -174,3 +175,72 @@ export class ConnectorElementView extends GfxElementModelView(ConnectorElementView.type, { + handleResize: ({ model, gfx }) => { + const initialPath = model.absolutePath; + + return { + beforeResize(context): void { + const { elements } = context; + // show the handles only when connector is selected along with + // its source and target elements + if ( + elements.length === 1 || + (model.source.id && + !elements.some(el => el.model.id === model.source.id)) || + (model.target.id && + !elements.some(el => el.model.id === model.target.id)) + ) { + context.set({ + allowedHandlers: [], + }); + } + }, + + onResizeStart(): void { + model.stash('labelXYWH'); + model.stash('source'); + model.stash('target'); + }, + + onResizeMove(context): void { + const { matrix } = context; + const props = model.resize(initialPath, matrix); + + gfx.updateElement(model, props); + }, + + onResizeEnd(): void { + model.pop('labelXYWH'); + model.pop('source'); + model.pop('target'); + }, + }; + }, + handleRotate({ model, gfx }) { + const initialPath = model.absolutePath; + + return { + onRotateStart(): void { + model.stash('labelXYWH'); + model.stash('source'); + model.stash('target'); + }, + + onRotateMove(context): void { + const { matrix } = context; + const props = model.resize(initialPath, matrix); + + gfx.updateElement(model, props); + }, + + onRotateEnd(): void { + model.pop('labelXYWH'); + model.pop('source'); + model.pop('target'); + }, + }; + }, + }); diff --git a/blocksuite/affine/gfx/group/src/element-view.ts b/blocksuite/affine/gfx/group/src/element-view.ts index bc7712078a..3953cef413 100644 --- a/blocksuite/affine/gfx/group/src/element-view.ts +++ b/blocksuite/affine/gfx/group/src/element-view.ts @@ -1,5 +1,8 @@ -import type { GroupElementModel } from '@blocksuite/affine-model'; -import { GfxElementModelView } from '@blocksuite/std/gfx'; +import { GroupElementModel } from '@blocksuite/affine-model'; +import { + GfxElementModelView, + GfxViewInteractionExtension, +} from '@blocksuite/std/gfx'; import { mountGroupTitleEditor } from './text/edgeless-group-title-editor'; @@ -29,3 +32,26 @@ export class GroupElementView extends GfxElementModelView { }); } } + +export const GroupInteraction = GfxViewInteractionExtension( + GroupElementView.type, + { + handleResize(context) { + const empty = () => {}; + context.model.descendantElements.forEach(elm => { + if (elm instanceof GroupElementModel) { + return; + } + + context.add(elm); + }); + context.delete(context.model); + + return { + onResizeStart: empty, + onResizeMove: empty, + onResizeEnd: empty, + }; + }, + } +); diff --git a/blocksuite/affine/gfx/group/src/view.ts b/blocksuite/affine/gfx/group/src/view.ts index e75a09f44c..82445acaab 100644 --- a/blocksuite/affine/gfx/group/src/view.ts +++ b/blocksuite/affine/gfx/group/src/view.ts @@ -5,7 +5,7 @@ import { import { effects } from './effects'; import { GroupElementRendererExtension } from './element-renderer'; -import { GroupElementView } from './element-view'; +import { GroupElementView, GroupInteraction } from './element-view'; import { groupToolbarExtension } from './toolbar/config'; export class GroupViewExtension extends ViewExtensionProvider { @@ -22,6 +22,7 @@ export class GroupViewExtension extends ViewExtensionProvider { context.register(GroupElementView); if (this.isEdgeless(context.scope)) { context.register(groupToolbarExtension); + context.register(GroupInteraction); } } } diff --git a/blocksuite/affine/gfx/mindmap/src/view.ts b/blocksuite/affine/gfx/mindmap/src/view.ts index 8e4d1d923e..335fc1f1e1 100644 --- a/blocksuite/affine/gfx/mindmap/src/view.ts +++ b/blocksuite/affine/gfx/mindmap/src/view.ts @@ -12,7 +12,7 @@ import { shapeMindmapToolbarExtension, } from './toolbar/config'; import { mindMapSeniorTool } from './toolbar/senior-tool'; -import { MindMapView } from './view/view'; +import { MindMapInteraction, MindMapView } from './view/view'; export class MindmapViewExtension extends ViewExtensionProvider { override name = 'affine-mindmap-gfx'; @@ -31,5 +31,6 @@ export class MindmapViewExtension extends ViewExtensionProvider { context.register(MindMapView); context.register(MindMapDragExtension); context.register(MindMapIndicatorOverlay); + context.register(MindMapInteraction); } } diff --git a/blocksuite/affine/gfx/mindmap/src/view/view.ts b/blocksuite/affine/gfx/mindmap/src/view/view.ts index 40c0d1042f..1187527dee 100644 --- a/blocksuite/affine/gfx/mindmap/src/view/view.ts +++ b/blocksuite/affine/gfx/mindmap/src/view/view.ts @@ -12,6 +12,7 @@ import type { PointerEventState } from '@blocksuite/std'; import { type BoxSelectionContext, GfxElementModelView, + GfxViewInteractionExtension, type SelectedContext, } from '@blocksuite/std/gfx'; @@ -381,3 +382,12 @@ export class MindMapView extends GfxElementModelView { }); } } + +export const MindMapInteraction = GfxViewInteractionExtension( + MindMapView.type, + { + resizeConstraint: { + allowedHandlers: [], + }, + } +); diff --git a/blocksuite/affine/gfx/shape/src/element-view.ts b/blocksuite/affine/gfx/shape/src/element-view.ts index 03b2ed678c..ade802b961 100644 --- a/blocksuite/affine/gfx/shape/src/element-view.ts +++ b/blocksuite/affine/gfx/shape/src/element-view.ts @@ -1,6 +1,10 @@ import { ShapeElementModel } from '@blocksuite/affine-model'; -import { GfxElementModelView } from '@blocksuite/std/gfx'; +import { + GfxElementModelView, + GfxViewInteractionExtension, +} from '@blocksuite/std/gfx'; +import { normalizeShapeBound } from './element-renderer'; import { mountShapeTextEditor } from './text/edgeless-shape-text-editor'; export class ShapeElementView extends GfxElementModelView { @@ -26,3 +30,16 @@ export class ShapeElementView extends GfxElementModelView { }); } } + +export const ShapeViewInteraction = + GfxViewInteractionExtension(ShapeElementView.type, { + handleResize: () => { + return { + onResizeMove({ newBound, model }) { + const normalizedBound = normalizeShapeBound(model, newBound); + + model.xywh = normalizedBound.serialize(); + }, + }; + }, + }); diff --git a/blocksuite/affine/gfx/shape/src/view.ts b/blocksuite/affine/gfx/shape/src/view.ts index 2f21971a6b..c9c88da942 100644 --- a/blocksuite/affine/gfx/shape/src/view.ts +++ b/blocksuite/affine/gfx/shape/src/view.ts @@ -8,7 +8,7 @@ import { HighlighterElementRendererExtension, ShapeElementRendererExtension, } from './element-renderer'; -import { ShapeElementView } from './element-view'; +import { ShapeElementView, ShapeViewInteraction } from './element-view'; import { ShapeTool } from './shape-tool'; import { shapeSeniorTool, shapeToolbarExtension } from './toolbar'; @@ -29,6 +29,7 @@ export class ShapeViewExtension extends ViewExtensionProvider { context.register(ShapeTool); context.register(shapeSeniorTool); context.register(shapeToolbarExtension); + context.register(ShapeViewInteraction); } } } diff --git a/blocksuite/affine/gfx/text/src/element-view.ts b/blocksuite/affine/gfx/text/src/element-view.ts index 34d8f870d1..fae86b9f94 100644 --- a/blocksuite/affine/gfx/text/src/element-view.ts +++ b/blocksuite/affine/gfx/text/src/element-view.ts @@ -1,7 +1,11 @@ import type { TextElementModel } from '@blocksuite/affine-model'; -import { GfxElementModelView } from '@blocksuite/std/gfx'; +import { + GfxElementModelView, + GfxViewInteractionExtension, +} from '@blocksuite/std/gfx'; import { mountTextElementEditor } from './edgeless-text-editor'; +import { normalizeTextBound } from './element-renderer'; export class TextElementView extends GfxElementModelView { static override type: string = 'text'; @@ -26,3 +30,66 @@ export class TextElementView extends GfxElementModelView { }); } } + +export const TextInteraction = GfxViewInteractionExtension( + TextElementView.type, + { + resizeConstraint: { + lockRatio: ['top-left', 'top-right', 'bottom-left', 'bottom-right'], + }, + handleResize({ model }) { + let initialFontSize = model.fontSize; + return { + onResizeStart(context) { + const { handle } = context; + + context.default(context); + + if (handle === 'left' || handle === 'right') { + model.stash('hasMaxWidth'); + } + model.stash('fontSize'); + }, + onResizeMove(context) { + const { handle, newBound, originalBound } = context; + if (handle === 'left' || handle === 'right') { + const { + text: yText, + fontFamily, + fontSize, + fontStyle, + fontWeight, + hasMaxWidth, + } = model; + // If the width of the text element has been changed by dragging, + // We need to set hasMaxWidth to true for wrapping the text + const normalizedBound = normalizeTextBound( + { + yText, + fontFamily, + fontSize, + fontStyle, + fontWeight, + hasMaxWidth, + }, + newBound, + true + ); + + model.xywh = normalizedBound.serialize(); + model.hasMaxWidth = true; + } else { + model.xywh = newBound.serialize(); + model.fontSize = initialFontSize * (newBound.w / originalBound.w); + } + }, + onResizeEnd(context) { + context.default(context); + + model.pop('fontSize'); + model.pop('hasMaxWidth'); + }, + }; + }, + } +); diff --git a/blocksuite/affine/gfx/text/src/view.ts b/blocksuite/affine/gfx/text/src/view.ts index ef5c202a5e..edec2c471c 100644 --- a/blocksuite/affine/gfx/text/src/view.ts +++ b/blocksuite/affine/gfx/text/src/view.ts @@ -6,7 +6,7 @@ import { import { DblClickAddEdgelessText } from './dblclick-add-edgeless-text'; import { effects } from './effects'; import { TextElementRendererExtension } from './element-renderer'; -import { TextElementView } from './element-view'; +import { TextElementView, TextInteraction } from './element-view'; import { TextTool } from './tool'; import { textToolbarExtension } from './toolbar'; @@ -26,6 +26,7 @@ export class TextViewExtension extends ViewExtensionProvider { context.register(TextTool); context.register(textToolbarExtension); context.register(DblClickAddEdgelessText); + context.register(TextInteraction); } } } diff --git a/blocksuite/affine/model/src/elements/connector/connector.ts b/blocksuite/affine/model/src/elements/connector/connector.ts index 0db5fc0071..c37426f708 100644 --- a/blocksuite/affine/model/src/elements/connector/connector.ts +++ b/blocksuite/affine/model/src/elements/connector/connector.ts @@ -339,28 +339,15 @@ export class ConnectorElementModel extends GfxPrimitiveElementModel 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, diff --git a/blocksuite/framework/std/src/gfx/index.ts b/blocksuite/framework/std/src/gfx/index.ts index ec821e31a7..a0bb030460 100644 --- a/blocksuite/framework/std/src/gfx/index.ts +++ b/blocksuite/framework/std/src/gfx/index.ts @@ -26,10 +26,21 @@ export type { ExtensionDragMoveContext, ExtensionDragStartContext, GfxInteractivityContext, + GfxViewInteractionConfig, + ResizeConstraint, + ResizeEndContext, + ResizeHandle, + ResizeMoveContext, + ResizeStartContext, + RotateConstraint, + RotateEndContext, + RotateMoveContext, + RotateStartContext, SelectedContext, } from './interactivity/index.js'; export { GfxViewEventManager, + GfxViewInteractionExtension, InteractivityExtension, InteractivityIdentifier, InteractivityManager, diff --git a/blocksuite/framework/std/src/gfx/interactivity/extension/view.ts b/blocksuite/framework/std/src/gfx/interactivity/extension/view.ts new file mode 100644 index 0000000000..82e1306260 --- /dev/null +++ b/blocksuite/framework/std/src/gfx/interactivity/extension/view.ts @@ -0,0 +1,118 @@ +import { createIdentifier } from '@blocksuite/global/di'; +import type { ExtensionType } from '@blocksuite/store'; + +import type { BlockStdScope } from '../../../scope'; +import type { GfxBlockComponent } from '../../../view'; +import type { GfxController, GfxModel } from '../..'; +import type { GfxElementModelView } from '../../view/view'; +import type { + BeforeResizeContext, + BeforeRotateContext, + ResizeConstraint, + ResizeEndContext, + ResizeMoveContext, + ResizeStartContext, + RotateEndContext, + RotateMoveContext, + RotateStartContext, +} from '../types/view'; + +type ExtendedViewContext< + T extends GfxBlockComponent | GfxElementModelView, + Context, +> = { + /** + * The default function of the interaction. + * If the interaction is handled by the extension, the default function will not be executed. + * But extension can choose to call the default function by `context.default(context)` if needed. + */ + default: (context: Context) => void; + + model: T['model']; + + view: T; +}; + +type ViewInteractionHandleContext< + T extends GfxBlockComponent | GfxElementModelView, +> = { + std: BlockStdScope; + gfx: GfxController; + view: T; + model: T['model']; + + /** + * Used to add an element to resize list. + * @param model + */ + add(element: GfxModel): void; + + /** + * Used to remove an element from resize list. + * @param element + */ + delete(element: GfxModel): void; +}; + +export type GfxViewInteractionConfig< + T extends GfxBlockComponent | GfxElementModelView = + | GfxBlockComponent + | GfxElementModelView, +> = { + readonly resizeConstraint?: ResizeConstraint; + + /** + * The function that will be called when the view is resized. + * You can add or delete the resize element before resize starts in this function., + * And return handlers to customize the resize behavior. + * @param context + * @returns + */ + handleResize?: (context: ViewInteractionHandleContext) => { + /** + * Called before resize starts. When this method is called, the resize elements are confirmed and will not be changed. + * You can set the resize constraint in this method. + * @param context + * @returns + */ + beforeResize?: (context: BeforeResizeContext) => void; + onResizeStart?( + context: ResizeStartContext & ExtendedViewContext + ): void; + onResizeMove?( + context: ResizeMoveContext & ExtendedViewContext + ): void; + onResizeEnd?( + context: ResizeEndContext & ExtendedViewContext + ): void; + }; + + handleRotate?: (context: ViewInteractionHandleContext) => { + beforeRotate?: (context: BeforeRotateContext) => void; + onRotateStart?( + context: RotateStartContext & ExtendedViewContext + ): void; + onRotateMove?( + context: RotateMoveContext & ExtendedViewContext + ): void; + onRotateEnd?( + context: RotateEndContext & ExtendedViewContext + ): void; + }; +}; + +export const GfxViewInteractionIdentifier = + createIdentifier('GfxViewInteraction'); + +export function GfxViewInteractionExtension< + T extends GfxBlockComponent | GfxElementModelView, +>(viewType: string, config: GfxViewInteractionConfig): ExtensionType { + return { + setup(di) { + di.addImpl( + GfxViewInteractionIdentifier(viewType), + () => config as GfxViewInteractionConfig + ); + }, + }; +} diff --git a/blocksuite/framework/std/src/gfx/interactivity/index.ts b/blocksuite/framework/std/src/gfx/interactivity/index.ts index a1f936c2f2..1fcdfd3c45 100644 --- a/blocksuite/framework/std/src/gfx/interactivity/index.ts +++ b/blocksuite/framework/std/src/gfx/interactivity/index.ts @@ -1,7 +1,13 @@ export type { GfxInteractivityContext } from './event.js'; export { InteractivityExtension } from './extension/base.js'; +export { + type GfxViewInteractionConfig, + GfxViewInteractionExtension, + GfxViewInteractionIdentifier, +} from './extension/view.js'; export { GfxViewEventManager } from './gfx-view-event-handler.js'; export { InteractivityIdentifier, InteractivityManager } from './manager.js'; +export { type ResizeHandle } from './resize/manager.js'; export type { DragExtensionInitializeContext, DragInitializationOption, @@ -15,5 +21,13 @@ export type { DragMoveContext, DragStartContext, GfxViewTransformInterface, + ResizeConstraint, + ResizeEndContext, + ResizeMoveContext, + ResizeStartContext, + RotateConstraint, + RotateEndContext, + RotateMoveContext, + RotateStartContext, SelectedContext, } from './types/view.js'; diff --git a/blocksuite/framework/std/src/gfx/interactivity/manager.ts b/blocksuite/framework/std/src/gfx/interactivity/manager.ts index cf2142cd30..cbb223bca4 100644 --- a/blocksuite/framework/std/src/gfx/interactivity/manager.ts +++ b/blocksuite/framework/std/src/gfx/interactivity/manager.ts @@ -1,18 +1,33 @@ import { type ServiceIdentifier } from '@blocksuite/global/di'; import { DisposableGroup } from '@blocksuite/global/disposable'; -import { Bound, Point } from '@blocksuite/global/gfx'; +import { Bound, clamp, Point } from '@blocksuite/global/gfx'; +import { signal } from '@preact/signals-core'; import type { PointerEventState } from '../../event/state/pointer.js'; import { getTopElements } from '../../utils/tree.js'; +import type { GfxBlockComponent } from '../../view/index.js'; import { GfxExtension, GfxExtensionIdentifier } from '../extension.js'; +import { GfxBlockElementModel } from '../model/gfx-block-model.js'; import type { GfxModel } from '../model/model.js'; +import type { GfxElementModelView } from '../view/view.js'; import { createInteractionContext, type SupportedEvents } from './event.js'; import { type InteractivityActionAPI, type InteractivityEventAPI, InteractivityExtensionIdentifier, } from './extension/base.js'; +import { + type GfxViewInteractionConfig, + GfxViewInteractionIdentifier, +} from './extension/view.js'; import { GfxViewEventManager } from './gfx-view-event-handler.js'; +import { + DEFAULT_HANDLES, + type OptionResize, + ResizeController, + type ResizeHandle, + type RotateOption, +} from './resize/manager.js'; import type { RequestElementsCloneContext } from './types/clone.js'; import type { DragExtensionInitializeContext, @@ -21,7 +36,11 @@ import type { ExtensionDragMoveContext, ExtensionDragStartContext, } from './types/drag.js'; -import type { BoxSelectionContext } from './types/view.js'; +import type { + BoxSelectionContext, + ResizeConstraint, + RotateConstraint, +} from './types/view.js'; type ExtensionPointerHandler = Exclude< SupportedEvents, @@ -46,6 +65,11 @@ export class InteractivityManager extends GfxExtension { }); } + activeInteraction$ = signal(null); + override unmounted(): void { this._disposable.dispose(); this.interactExtensions.forEach(ext => { @@ -76,6 +100,10 @@ export class InteractivityManager extends GfxExtension { * @returns */ dispatchEvent(eventName: ExtensionPointerHandler, evt: PointerEventState) { + if (this.activeInteraction$.peek()) { + return; + } + const { context, preventDefaultState } = createInteractionContext(evt); const extensions = this.interactExtensions; @@ -247,6 +275,8 @@ export class InteractivityManager extends GfxExtension { }); }; const onDragEnd = (event: PointerEvent) => { + this.activeInteraction$.value = null; + host.removeEventListener('pointermove', onDragMove, false); host.removeEventListener('pointerup', onDragEnd, false); viewportWatcher.unsubscribe(); @@ -292,6 +322,11 @@ export class InteractivityManager extends GfxExtension { host.addEventListener('pointerup', onDragEnd, false); }; const dragStart = () => { + this.activeInteraction$.value = { + type: 'move', + elements: context.elements, + }; + internal.elements.forEach(({ view, originalBound }) => { view.onDragStart({ currentBound: originalBound, @@ -316,6 +351,519 @@ export class InteractivityManager extends GfxExtension { dragStart(); } + handleElementRotate( + options: Omit< + RotateOption, + 'onRotateStart' | 'onRotateEnd' | 'onRotateUpdate' + > & { + onRotateUpdate?: (payload: { + currentAngle: number; + delta: number; + }) => void; + onRotateStart?: () => void; + onRotateEnd?: () => void; + } + ) { + const { rotatable, viewConfigMap, initialRotate } = + this._getViewRotateConfig(options.elements); + + if (!rotatable) { + return; + } + + const handler = new ResizeController({ gfx: this.gfx }); + const elements = Array.from(viewConfigMap.values()).map( + config => config.view.model + ) as GfxModel[]; + + handler.startRotate({ + ...options, + elements, + onRotateStart: payload => { + this.activeInteraction$.value = { + type: 'rotate', + elements, + }; + options.onRotateStart?.(); + payload.data.forEach(({ model }) => { + if (!viewConfigMap.has(model.id)) { + return; + } + + const { handlers, defaultHandlers, view, constraint } = + viewConfigMap.get(model.id)!; + + handlers.onRotateStart({ + default: defaultHandlers.onRotateStart as () => void, + constraint, + model, + view, + }); + }); + }, + onRotateUpdate: payload => { + options.onRotateUpdate?.({ + currentAngle: initialRotate + payload.delta, + delta: payload.delta, + }); + payload.data.forEach( + ({ + model, + newBound, + originalBound, + newRotate, + originalRotate, + matrix, + }) => { + if (!viewConfigMap.has(model.id)) { + return; + } + + const { handlers, defaultHandlers, view, constraint } = + viewConfigMap.get(model.id)!; + + handlers.onRotateMove({ + model, + newBound, + originalBound, + newRotate, + originalRotate, + default: defaultHandlers.onRotateMove as () => void, + constraint, + view, + matrix, + }); + } + ); + }, + onRotateEnd: payload => { + this.activeInteraction$.value = null; + options.onRotateEnd?.(); + this.std.store.transact(() => { + payload.data.forEach(({ model }) => { + if (!viewConfigMap.has(model.id)) { + return; + } + + const { handlers, defaultHandlers, view, constraint } = + viewConfigMap.get(model.id)!; + + handlers.onRotateEnd({ + default: defaultHandlers.onRotateEnd as () => void, + view, + model, + constraint, + }); + }); + }); + }, + }); + } + + private _getViewRotateConfig(elements: GfxModel[]) { + const deleted = new Set(); + const added = new Set(); + const del = (model: GfxModel) => { + deleted.add(model); + }; + const add = (model: GfxModel) => { + added.add(model); + }; + + type ViewRotateHandlers = Required< + ReturnType['handleRotate']> + >; + + const viewConfigMap = new Map< + string, + { + model: GfxModel; + view: GfxElementModelView | GfxBlockComponent; + handlers: ViewRotateHandlers; + defaultHandlers: ViewRotateHandlers; + constraint: Required; + } + >(); + + const addToConfigMap = (model: GfxModel) => { + const flavourOrType = 'type' in model ? model.type : model.flavour; + const interactionConfig = this.std.getOptional( + GfxViewInteractionIdentifier(flavourOrType) + ); + const view = this.gfx.view.get(model); + + if (!view) { + return; + } + + const defaultHandlers: ViewRotateHandlers = { + beforeRotate: () => {}, + onRotateStart: context => { + if (!context.constraint.rotatable) { + return; + } + + if (model instanceof GfxBlockElementModel) { + if (Object.hasOwn(model.props, 'rotate')) { + // @ts-expect-error prop existence has been checked + model.stash('rotate'); + model.stash('xywh'); + } + } else { + model.stash('rotate'); + model.stash('xywh'); + } + }, + onRotateEnd: context => { + if (!context.constraint.rotatable) { + return; + } + + if (model instanceof GfxBlockElementModel) { + if (Object.hasOwn(model.props, 'rotate')) { + // @ts-expect-error prop existence has been checked + model.pop('rotate'); + model.pop('xywh'); + } + } else { + model.pop('rotate'); + model.pop('xywh'); + } + }, + onRotateMove: context => { + if (!context.constraint.rotatable) { + return; + } + + const { newBound, newRotate } = context; + model.rotate = newRotate; + model.xywh = newBound.serialize(); + }, + }; + const handlers = interactionConfig?.handleRotate?.({ + std: this.std, + gfx: this.gfx, + view, + model, + delete: del, + add, + }); + + viewConfigMap.set(model.id, { + model, + view, + defaultHandlers, + handlers: Object.assign({}, defaultHandlers, handlers ?? {}), + constraint: { + rotatable: true, + }, + }); + }; + + elements.forEach(addToConfigMap); + + deleted.forEach(model => { + if (viewConfigMap.has(model.id)) { + viewConfigMap.delete(model.id); + } + }); + + added.forEach(model => { + if (viewConfigMap.has(model.id)) { + return; + } + + addToConfigMap(model); + }); + + const views = Array.from(viewConfigMap.values().map(item => item.view)); + + let rotatable = true; + viewConfigMap.forEach(config => { + const handlers = config.handlers; + + handlers.beforeRotate({ + set: (newConstraint: RotateConstraint) => { + Object.assign(config.constraint, newConstraint); + rotatable = rotatable && config.constraint.rotatable; + }, + elements: views, + }); + }); + + return { + initialRotate: views.length > 1 ? 0 : (views[0]?.model.rotate ?? 0), + rotatable, + viewConfigMap, + }; + } + + private _getViewResizeConfig(elements: GfxModel[]) { + const deleted = new Set(); + const added = new Set(); + const del = (model: GfxModel) => { + deleted.add(model); + }; + const add = (model: GfxModel) => { + added.add(model); + }; + + type ViewResizeHandlers = Required< + ReturnType['handleResize']> + >; + + const viewConfigMap = new Map< + string, + { + model: GfxModel; + view: GfxElementModelView | GfxBlockComponent; + constraint: Required; + handlers: ViewResizeHandlers; + defaultHandlers: ViewResizeHandlers; + } + >(); + const addToConfigMap = (model: GfxModel) => { + const flavourOrType = 'type' in model ? model.type : model.flavour; + const interactionConfig = this.std.getOptional( + GfxViewInteractionIdentifier(flavourOrType) + ); + const view = this.gfx.view.get(model); + + if (!view) { + return; + } + + const defaultHandlers: ViewResizeHandlers = { + beforeResize: () => {}, + onResizeStart: () => { + model.stash('xywh'); + }, + onResizeEnd: () => { + model.pop('xywh'); + }, + onResizeMove: context => { + const { newBound, constraint } = context; + const { minWidth, minHeight, maxWidth, maxHeight } = constraint; + + newBound.w = clamp(newBound.w, minWidth, maxWidth); + newBound.h = clamp(newBound.h, minHeight, maxHeight); + + model.xywh = newBound.serialize(); + }, + }; + const handlers = interactionConfig?.handleResize?.({ + std: this.std, + gfx: this.gfx, + view, + model, + delete: del, + add, + }); + + viewConfigMap.set(model.id, { + model, + view, + constraint: { + lockRatio: false, + allowedHandlers: DEFAULT_HANDLES, + minHeight: 2, + minWidth: 2, + maxHeight: 5000000, + maxWidth: 5000000, + ...interactionConfig?.resizeConstraint, + }, + defaultHandlers, + handlers: Object.assign({}, defaultHandlers, handlers ?? {}), + }); + }; + + elements.forEach(addToConfigMap); + + deleted.forEach(model => { + if (viewConfigMap.has(model.id)) { + viewConfigMap.delete(model.id); + } + }); + + added.forEach(model => { + if (viewConfigMap.has(model.id)) { + return; + } + + addToConfigMap(model); + }); + + const views = Array.from(viewConfigMap.values().map(item => item.view)); + let allowedHandlers = new Set(DEFAULT_HANDLES); + + viewConfigMap.forEach(config => { + const currConstraint: Required = config.constraint; + + config.handlers.beforeResize({ + set: (newConstraint: ResizeConstraint) => { + Object.assign(currConstraint, newConstraint); + }, + elements: views, + }); + + config.constraint = currConstraint; + + const currentAllowedHandlers = new Set(currConstraint.allowedHandlers); + allowedHandlers.forEach(h => { + if (!currentAllowedHandlers.has(h)) { + allowedHandlers.delete(h); + } + }); + }); + + return { + allowedHandlers: Array.from(allowedHandlers) as ResizeHandle[], + viewConfigMap, + }; + } + + getRotateConfig(options: { elements: GfxModel[] }) { + return this._getViewRotateConfig(options.elements); + } + + getResizeHandlers(options: { elements: GfxModel[] }) { + return this._getViewResizeConfig(options.elements).allowedHandlers; + } + + handleElementResize( + options: Omit< + OptionResize, + 'lockRatio' | 'onResizeStart' | 'onResizeEnd' | 'onResizeUpdate' + > & { + onResizeStart?: () => void; + onResizeEnd?: () => void; + onResizeUpdate?: (payload: { + lockRatio: boolean; + scaleX: number; + scaleY: number; + exceed: { + w: boolean; + h: boolean; + }; + }) => void; + } + ) { + const { viewConfigMap, allowedHandlers } = this._getViewResizeConfig( + options.elements + ); + + if (!allowedHandlers.includes(options.handle)) { + return; + } + + const { handle } = options; + const controller = new ResizeController({ gfx: this.gfx }); + const elements = Array.from(viewConfigMap.values()).map( + config => config.view.model + ) as GfxModel[]; + let lockRatio = false; + + viewConfigMap.forEach(config => { + const { lockRatio: lockRatioConfig } = config.constraint; + + lockRatio = + lockRatio || + lockRatioConfig === true || + (Array.isArray(lockRatioConfig) && lockRatioConfig.includes(handle)); + }); + + controller.startResize({ + ...options, + lockRatio, + elements, + onResizeStart: ({ data }) => { + this.activeInteraction$.value = { + type: 'resize', + elements, + }; + options.onResizeStart?.(); + data.forEach(({ model }) => { + if (!viewConfigMap.has(model.id)) { + return; + } + + const { handlers, defaultHandlers, view, constraint } = + viewConfigMap.get(model.id)!; + + handlers.onResizeStart({ + handle, + default: defaultHandlers.onResizeStart as () => void, + constraint, + model, + view, + }); + }); + }, + onResizeUpdate: ({ data, scaleX, scaleY, lockRatio }) => { + const exceed = { + w: false, + h: false, + }; + + data.forEach( + ({ model, newBound, originalBound, lockRatio, matrix }) => { + if (!viewConfigMap.has(model.id)) { + return; + } + + const { handlers, defaultHandlers, view, constraint } = + viewConfigMap.get(model.id)!; + + handlers.onResizeMove({ + model, + newBound, + originalBound, + handle, + default: defaultHandlers.onResizeMove as () => void, + constraint, + view, + lockRatio, + matrix, + }); + + exceed.w = + exceed.w || + model.w === constraint.minWidth || + model.w === constraint.maxWidth; + exceed.h = + exceed.h || + model.h === constraint.minHeight || + model.h === constraint.maxHeight; + } + ); + + options.onResizeUpdate?.({ scaleX, scaleY, lockRatio, exceed }); + }, + onResizeEnd: ({ data }) => { + this.activeInteraction$.value = null; + options.onResizeEnd?.(); + this.std.store.transact(() => { + data.forEach(({ model }) => { + if (!viewConfigMap.has(model.id)) { + return; + } + + const { handlers, defaultHandlers, view, constraint } = + viewConfigMap.get(model.id)!; + + handlers.onResizeEnd({ + default: defaultHandlers.onResizeEnd as () => void, + view, + model, + constraint, + handle, + }); + }); + }); + }, + }); + } + requestElementClone(options: RequestElementsCloneContext) { const extensions = this.interactExtensions; diff --git a/blocksuite/framework/std/src/gfx/interactivity/resize/manager.ts b/blocksuite/framework/std/src/gfx/interactivity/resize/manager.ts new file mode 100644 index 0000000000..68a70593fe --- /dev/null +++ b/blocksuite/framework/std/src/gfx/interactivity/resize/manager.ts @@ -0,0 +1,558 @@ +import { + Bound, + getCommonBoundWithRotation, + type IVec, +} from '@blocksuite/global/gfx'; + +import type { GfxController } from '../..'; +import type { GfxModel } from '../../model/model'; + +export type ResizeHandle = + | 'top-left' + | 'top' + | 'top-right' + | 'right' + | 'bottom-right' + | 'bottom' + | 'bottom-left' + | 'left'; + +export const DEFAULT_HANDLES: ResizeHandle[] = [ + 'top-left', + 'top-right', + 'bottom-left', + 'bottom-right', + 'left', + 'right', + 'top', + 'bottom', +]; + +interface ElementInitialSnapshot { + x: number; + y: number; + w: number; + h: number; + rotate: number; +} + +export interface OptionResize { + elements: GfxModel[]; + handle: ResizeHandle; + lockRatio: boolean; + event: PointerEvent; + onResizeUpdate: (payload: { + lockRatio: boolean; + scaleX: number; + scaleY: number; + data: { + model: GfxModel; + originalBound: Bound; + newBound: Bound; + lockRatio: boolean; + matrix: DOMMatrix; + }[]; + }) => void; + onResizeStart?: (payload: { data: { model: GfxModel }[] }) => void; + onResizeEnd?: (payload: { data: { model: GfxModel }[] }) => void; +} + +export type RotateOption = { + elements: GfxModel[]; + event: PointerEvent; + + onRotateUpdate: (payload: { + delta: number; + data: { + model: GfxModel; + newBound: Bound; + originalBound: Bound; + originalRotate: number; + newRotate: number; + matrix: DOMMatrix; + }[]; + }) => void; + + onRotateStart?: (payload: { data: { model: GfxModel }[] }) => void; + + onRotateEnd?: (payload: { data: { model: GfxModel }[] }) => void; +}; + +export class ResizeController { + private readonly gfx: GfxController; + + get host() { + return this.gfx.std.host; + } + + constructor(option: { gfx: GfxController }) { + this.gfx = option.gfx; + } + + startResize(options: OptionResize) { + const { + elements, + handle, + lockRatio, + onResizeStart, + onResizeUpdate, + onResizeEnd, + event, + } = options; + + const originals: ElementInitialSnapshot[] = elements.map(el => ({ + x: el.x, + y: el.y, + w: el.w, + h: el.h, + rotate: el.rotate, + })); + const startPt = this.gfx.viewport.toModelCoordFromClientCoord([ + event.clientX, + event.clientY, + ]); + + const onPointerMove = (e: PointerEvent) => { + const currPt = this.gfx.viewport.toModelCoordFromClientCoord([ + e.clientX, + e.clientY, + ]); + const shouldLockRatio = lockRatio || e.shiftKey; + + if (elements.length === 1) { + this.resizeSingle( + originals[0], + elements[0], + shouldLockRatio, + startPt, + currPt, + handle, + onResizeUpdate + ); + } else { + this.resizeMulti( + originals, + elements, + handle, + currPt, + startPt, + onResizeUpdate + ); + } + }; + + onResizeStart?.({ data: elements.map(model => ({ model })) }); + + const onPointerUp = () => { + this.host.removeEventListener('pointermove', onPointerMove); + this.host.removeEventListener('pointerup', onPointerUp); + + onResizeEnd?.({ data: elements.map(model => ({ model })) }); + }; + + this.host.addEventListener('pointermove', onPointerMove); + this.host.addEventListener('pointerup', onPointerUp); + } + + private resizeSingle( + orig: ElementInitialSnapshot, + model: GfxModel, + lockRatio: boolean, + startPt: IVec, + currPt: IVec, + handle: ResizeHandle, + updateCallback: OptionResize['onResizeUpdate'] + ) { + const { xSign, ySign } = this.getHandleSign(handle); + + const pivot = new DOMPoint( + orig.x + (-xSign === 1 ? orig.w : 0), + orig.y + (-ySign === 1 ? orig.h : 0) + ); + const toLocalRotatedM = new DOMMatrix() + .translate(-pivot.x, -pivot.y) + .translate(orig.w / 2 + orig.x, orig.h / 2 + orig.y) + .rotate(-orig.rotate) + .translate(-(orig.w / 2 + orig.x), -(orig.h / 2 + orig.y)); + const toLocalM = new DOMMatrix().translate(-pivot.x, -pivot.y); + + const toLocal = (p: DOMPoint, withRotation: boolean) => + p.matrixTransform(withRotation ? toLocalRotatedM : toLocalM); + const toModel = (p: DOMPoint) => + p.matrixTransform(toLocalRotatedM.inverse()); + + const currPtLocal = toLocal(new DOMPoint(currPt[0], currPt[1]), true); + const handleLocal = toLocal(new DOMPoint(startPt[0], startPt[1]), true); + + let scaleX = xSign + ? (xSign * (currPtLocal.x - handleLocal.x) + orig.w) / orig.w + : 1; + let scaleY = ySign + ? (ySign * (currPtLocal.y - handleLocal.y) + orig.h) / orig.h + : 1; + + if (lockRatio) { + const min = Math.min(Math.abs(scaleX), Math.abs(scaleY)); + scaleX = Math.sign(scaleX) * min; + scaleY = Math.sign(scaleY) * min; + } + + const scaleM = new DOMMatrix().scale(scaleX, scaleY); + + const [visualTopLeft, visualBottomRight] = [ + new DOMPoint(orig.x, orig.y), + new DOMPoint(orig.x + orig.w, orig.y + orig.h), + ].map(p => { + const localP = toLocal(p, false); + const scaledP = localP.matrixTransform(scaleM); + + return toModel(scaledP); + }); + + const center = { + x: + Math.min(visualTopLeft.x, visualBottomRight.x) + + Math.abs(visualBottomRight.x - visualTopLeft.x) / 2, + y: + Math.min(visualTopLeft.y, visualBottomRight.y) + + Math.abs(visualBottomRight.y - visualTopLeft.y) / 2, + }; + + const restoreM = new DOMMatrix() + .translate(center.x, center.y) + .rotate(-orig.rotate) + .translate(-center.x, -center.y); + + // only used to provide the matrix information + const finalM = restoreM + .multiply(toLocalRotatedM.inverse()) + .multiply(scaleM) + .multiply(toLocalM); + + const [topLeft, bottomRight] = [visualTopLeft, visualBottomRight].map(p => { + return p.matrixTransform(restoreM); + }); + + updateCallback({ + lockRatio, + scaleX, + scaleY, + data: [ + { + model: model, + originalBound: new Bound(orig.x, orig.y, orig.w, orig.h), + newBound: new Bound( + Math.min(topLeft.x, bottomRight.x), + Math.min(bottomRight.y, topLeft.y), + Math.abs(bottomRight.x - topLeft.x), + Math.abs(bottomRight.y - topLeft.y) + ), + lockRatio: lockRatio, + matrix: finalM, + }, + ], + }); + } + + private resizeMulti( + originals: ElementInitialSnapshot[], + elements: GfxModel[], + handle: ResizeHandle, + currPt: IVec, + startPt: IVec, + updateCallback: OptionResize['onResizeUpdate'] + ) { + const commonBound = getCommonBoundWithRotation(originals); + const { xSign, ySign } = this.getHandleSign(handle); + + const pivot = new DOMPoint( + commonBound.x + ((-xSign + 1) / 2) * commonBound.w, + commonBound.y + ((-ySign + 1) / 2) * commonBound.h + ); + const toLocalM = new DOMMatrix().translate(-pivot.x, -pivot.y); + + const toLocal = (p: DOMPoint) => p.matrixTransform(toLocalM); + + const currPtLocal = toLocal(new DOMPoint(currPt[0], currPt[1])); + const handleLocal = toLocal(new DOMPoint(startPt[0], startPt[1])); + + let scaleX = xSign + ? (xSign * (currPtLocal.x - handleLocal.x) + commonBound.w) / + commonBound.w + : 1; + let scaleY = ySign + ? (ySign * (currPtLocal.y - handleLocal.y) + commonBound.h) / + commonBound.h + : 1; + + const min = Math.max(Math.abs(scaleX), Math.abs(scaleY)); + scaleX = Math.sign(scaleX) * min; + scaleY = Math.sign(scaleY) * min; + + const scaleM = new DOMMatrix().scale(scaleX, scaleY); + + const data = elements.map((model, i) => { + const orig = originals[i]; + const finalM = new DOMMatrix() + .multiply(toLocalM.inverse()) + .multiply(scaleM) + .multiply(toLocalM); + const [topLeft, bottomRight] = [ + new DOMPoint(orig.x, orig.y), + new DOMPoint(orig.x + orig.w, orig.y + orig.h), + ].map(p => { + return p.matrixTransform(finalM); + }); + + const newBound = new Bound( + Math.min(topLeft.x, bottomRight.x), + Math.min(bottomRight.y, topLeft.y), + Math.abs(bottomRight.x - topLeft.x), + Math.abs(bottomRight.y - topLeft.y) + ); + + return { + model, + originalBound: new Bound(orig.x, orig.y, orig.w, orig.h), + newBound, + lockRatio: true, + matrix: finalM, + }; + }); + + updateCallback({ lockRatio: true, scaleX, scaleY, data }); + } + + startRotate(option: RotateOption) { + const { event, elements, onRotateUpdate } = option; + + const originals: ElementInitialSnapshot[] = elements.map(el => ({ + x: el.x, + y: el.y, + w: el.w, + h: el.h, + rotate: el.rotate, + })); + + const startPt = this.gfx.viewport.toModelCoordFromClientCoord([ + event.clientX, + event.clientY, + ]); + const onPointerMove = (e: PointerEvent) => { + const currentPt = this.gfx.viewport.toModelCoordFromClientCoord([ + e.clientX, + e.clientY, + ]); + const snap = e.shiftKey; + + if (elements.length > 1) { + this.rotateMulti({ + origs: originals, + models: elements, + startPt, + currentPt, + snap, + onRotateUpdate, + }); + } else { + this.rotateSingle({ + orig: originals[0], + model: elements[0], + startPt, + currentPt, + snap, + onRotateUpdate, + }); + } + }; + const onPointerUp = () => { + this.host.removeEventListener('pointermove', onPointerMove); + this.host.removeEventListener('pointerup', onPointerUp); + this.host.removeEventListener('pointercancel', onPointerUp); + + option.onRotateEnd?.({ data: elements.map(model => ({ model })) }); + }; + + option.onRotateStart?.({ data: elements.map(model => ({ model })) }); + + this.host.addEventListener('pointermove', onPointerMove, false); + this.host.addEventListener('pointerup', onPointerUp, false); + this.host.addEventListener('pointercancel', onPointerUp, false); + } + + private getNormalizedAngle(y: number, x: number) { + let angle = Math.atan2(y, x); + if (angle < 0) { + angle += 2 * Math.PI; + } + + return (angle * 180) / Math.PI; + } + + private toNormalizedAngle(angle: number) { + if (angle < 0) { + angle += 360; + } + + return Math.round(angle) % 360; + } + + private rotateSingle(option: { + orig: ElementInitialSnapshot; + model: GfxModel; + startPt: IVec; + currentPt: IVec; + snap: boolean; + onRotateUpdate?: RotateOption['onRotateUpdate']; + }) { + const { orig, model, startPt, currentPt, snap, onRotateUpdate } = option; + + const center = { + x: orig.x + orig.w / 2, + y: orig.y + orig.h / 2, + }; + const toLocalM = new DOMMatrix().translate(-center.x, -center.y); + const toLocal = (p: DOMPoint) => p.matrixTransform(toLocalM); + + const v0 = toLocal(new DOMPoint(startPt[0], startPt[1])), + v1 = toLocal(new DOMPoint(currentPt[0], currentPt[1])); + + const startAngle = this.getNormalizedAngle(v0.y, v0.x), + endAngle = this.getNormalizedAngle(v1.y, v1.x); + const deltaDeg = endAngle - startAngle; + const rotatedAngle = orig.rotate + deltaDeg; + const targetRotate = this.toNormalizedAngle( + snap + ? Math.round((rotatedAngle % 15) / 15) * 15 + + Math.floor(rotatedAngle / 15) * 15 + : rotatedAngle + ); + + // only used to provide the matrix information + const rotateM = new DOMMatrix() + .translate(center.x, center.y) + .rotate(targetRotate - orig.rotate) + .translate(-center.x, -center.y); + + onRotateUpdate?.({ + delta: deltaDeg, + data: [ + { + model, + originalBound: new Bound(orig.x, orig.y, orig.w, orig.h), + newBound: new Bound(orig.x, orig.y, orig.w, orig.h), + originalRotate: orig.rotate, + newRotate: targetRotate, + matrix: rotateM, + }, + ], + }); + } + + private rotateMulti(option: { + origs: ElementInitialSnapshot[]; + models: GfxModel[]; + startPt: IVec; + currentPt: IVec; + snap: boolean; + onRotateUpdate?: RotateOption['onRotateUpdate']; + }) { + const { models, startPt, currentPt, onRotateUpdate } = option; + const commonBound = getCommonBoundWithRotation(option.origs); + + const center = { + x: commonBound.x + commonBound.w / 2, + y: commonBound.y + commonBound.h / 2, + }; + const toLocalM = new DOMMatrix().translate(-center.x, -center.y); + const toLocal = (p: DOMPoint) => p.matrixTransform(toLocalM); + + const v0 = toLocal(new DOMPoint(startPt[0], startPt[1])), + v1 = toLocal(new DOMPoint(currentPt[0], currentPt[1])); + const a0 = this.getNormalizedAngle(v0.y, v0.x), + a1 = this.getNormalizedAngle(v1.y, v1.x); + const deltaDeg = a1 - a0; + const rotateM = new DOMMatrix() + .translate(center.x, center.y) + .rotate(deltaDeg) + .translate(-center.x, -center.y); + const toRotatedPoint = (p: DOMPoint) => p.matrixTransform(rotateM); + + onRotateUpdate?.({ + delta: deltaDeg, + data: models.map((model, index) => { + const orig = option.origs[index]; + const center = { + x: orig.x + orig.w / 2, + y: orig.y + orig.h / 2, + }; + + const visualM = new DOMMatrix() + .translate(center.x, center.y) + .rotate(orig.rotate) + .translate(-center.x, -center.y); + const toVisual = (p: DOMPoint) => p.matrixTransform(visualM); + + const [rotatedVisualLeftTop, rotatedVisualBottomRight] = [ + new DOMPoint(orig.x, orig.y), + new DOMPoint(orig.x + orig.w, orig.y + orig.h), + ].map(p => toRotatedPoint(toVisual(p))); + + const newCenter = { + x: + Math.min(rotatedVisualLeftTop.x, rotatedVisualBottomRight.x) + + Math.abs(rotatedVisualBottomRight.x - rotatedVisualLeftTop.x) / 2, + y: + Math.min(rotatedVisualLeftTop.y, rotatedVisualBottomRight.y) + + Math.abs(rotatedVisualBottomRight.y - rotatedVisualLeftTop.y) / 2, + }; + const newRotated = this.toNormalizedAngle(orig.rotate + deltaDeg); + const finalM = new DOMMatrix() + .translate(newCenter.x, newCenter.y) + .rotate(-newRotated) + .translate(-newCenter.x, -newCenter.y) + .multiply(rotateM) + .multiply(visualM); + + const topLeft = rotatedVisualLeftTop.matrixTransform( + new DOMMatrix() + .translate(newCenter.x, newCenter.y) + .rotate(-newRotated) + .translate(-newCenter.x, -newCenter.y) + ); + + return { + model, + originalBound: new Bound(orig.x, orig.y, orig.w, orig.h), + newBound: new Bound(topLeft.x, topLeft.y, orig.w, orig.h), + originalRotate: orig.rotate, + newRotate: newRotated, + matrix: finalM, + }; + }), + }); + } + + private getHandleSign(handle: ResizeHandle) { + switch (handle) { + case 'top-left': + return { xSign: -1, ySign: -1 }; + case 'top': + return { xSign: 0, ySign: -1 }; + case 'top-right': + return { xSign: 1, ySign: -1 }; + case 'right': + return { xSign: 1, ySign: 0 }; + case 'bottom-right': + return { xSign: 1, ySign: 1 }; + case 'bottom': + return { xSign: 0, ySign: 1 }; + case 'bottom-left': + return { xSign: -1, ySign: 1 }; + case 'left': + return { xSign: -1, ySign: 0 }; + default: + return { xSign: 0, ySign: 0 }; + } + } +} diff --git a/blocksuite/framework/std/src/gfx/interactivity/types/view.ts b/blocksuite/framework/std/src/gfx/interactivity/types/view.ts index 8f7e52ee5d..87b77baca6 100644 --- a/blocksuite/framework/std/src/gfx/interactivity/types/view.ts +++ b/blocksuite/framework/std/src/gfx/interactivity/types/view.ts @@ -3,6 +3,7 @@ import type { Bound, IBound, IPoint } from '@blocksuite/global/gfx'; import type { GfxBlockComponent } from '../../../view/element/gfx-block-component.js'; import type { GfxModel } from '../../model/model.js'; import type { GfxElementModelView } from '../../view/view.js'; +import type { ResizeHandle } from '../resize/manager.js'; export type DragStartContext = { /** @@ -34,6 +35,97 @@ export type DragMoveContext = DragStartContext & { export type DragEndContext = DragMoveContext; +export type ResizeConstraint = { + minWidth?: number; + minHeight?: number; + + maxWidth?: number; + maxHeight?: number; + allowedHandlers?: ResizeHandle[]; + + /** + * Whether to lock the aspect ratio of the element when resizing. + * If the value is an array, it will only lock the aspect ratio when resizing the specified handles. + */ + lockRatio?: boolean | ResizeHandle[]; +}; + +export type BeforeResizeContext = { + /** + * The elements that will be resized + */ + elements: (GfxBlockComponent | GfxElementModelView)[]; + + /** + * Set the constraint before resize starts. + */ + set: (constraint: ResizeConstraint) => void; +}; + +export type ResizeStartContext = { + /** + * The handle that is used to resize the element + */ + handle: ResizeHandle; + + /** + * The resize constraint. + */ + constraint: Readonly>; +}; + +export type ResizeMoveContext = ResizeStartContext & { + /** + * The element bound when resize starts + */ + originalBound: Bound; + + newBound: Bound; + + /** + * The matrix that used to transform the element. + */ + matrix: DOMMatrix; + + lockRatio: boolean; +}; + +export type ResizeEndContext = ResizeStartContext; + +export type RotateConstraint = { + rotatable?: boolean; +}; + +export type BeforeRotateContext = { + /** + * The elements that will be rotated + */ + elements: (GfxBlockComponent | GfxElementModelView)[]; + + /** + * Set the constraint before rotate starts. + */ + set: (constraint: RotateConstraint) => void; +}; + +export type RotateStartContext = { + constraint: Readonly>; +}; + +export type RotateMoveContext = RotateStartContext & { + newBound: Bound; + + originalBound: Bound; + + newRotate: number; + + originalRotate: number; + + matrix: DOMMatrix; +}; + +export type RotateEndContext = RotateStartContext; + export type SelectedContext = { /** * The selected state of the element @@ -79,8 +171,6 @@ export type GfxViewTransformInterface = { onDragStart: (context: DragStartContext) => void; onDragMove: (context: DragMoveContext) => void; onDragEnd: (context: DragEndContext) => void; - onRotate: (context: {}) => void; - onResize: (context: {}) => void; /** * When the element is selected by the pointer diff --git a/blocksuite/framework/std/src/gfx/model/gfx-block-model.ts b/blocksuite/framework/std/src/gfx/model/gfx-block-model.ts index d60640db05..3e73979717 100644 --- a/blocksuite/framework/std/src/gfx/model/gfx-block-model.ts +++ b/blocksuite/framework/std/src/gfx/model/gfx-block-model.ts @@ -116,7 +116,19 @@ export class GfxBlockElementModel< */ responseExtension: [number, number] = [0, 0]; - rotate = 0; + get rotate() { + if ('rotate' in this.props) { + return this.props.rotate as number; + } + + return 0; + } + + set rotate(rotate: number) { + if ('rotate' in this.props) { + this.props.rotate = rotate; + } + } get deserializedXYWH() { if (this._cacheDeserKey !== this.xywh || !this._cacheDeserXYWH) { diff --git a/blocksuite/framework/std/src/gfx/view/view.ts b/blocksuite/framework/std/src/gfx/view/view.ts index 84f7555270..ae934c923e 100644 --- a/blocksuite/framework/std/src/gfx/view/view.ts +++ b/blocksuite/framework/std/src/gfx/view/view.ts @@ -224,10 +224,6 @@ export class GfxElementModelView< onBoxSelected(_: BoxSelectionContext): boolean | void {} - onResize = () => {}; - - onRotate = () => {}; - /** * Called when the view is destroyed. * Override this method requires calling `super.onDestroyed()`. diff --git a/blocksuite/framework/std/src/view/element/gfx-block-component.ts b/blocksuite/framework/std/src/view/element/gfx-block-component.ts index 383a0a48c3..117fffc90c 100644 --- a/blocksuite/framework/std/src/view/element/gfx-block-component.ts +++ b/blocksuite/framework/std/src/view/element/gfx-block-component.ts @@ -11,7 +11,7 @@ import type { GfxViewTransformInterface, SelectedContext, } from '../../gfx/interactivity/index.js'; -import { type GfxBlockElementModel } from '../../gfx/model/gfx-block-model.js'; +import type { GfxBlockElementModel } from '../../gfx/model/gfx-block-model.js'; import { SurfaceSelection } from '../../selection/index.js'; import { BlockComponent } from './block-component.js'; @@ -116,10 +116,6 @@ export abstract class GfxBlockComponent< onBoxSelected(_: BoxSelectionContext) {} - onRotate() {} - - onResize() {} - getCSSTransform() { const viewport = this.gfx.viewport; const { translateX, translateY, zoom } = viewport; @@ -236,10 +232,6 @@ export function toGfxBlockComponent< onBoxSelected(_: BoxSelectionContext) {} - onRotate() {} - - onResize() {} - get gfx() { return this.std.get(GfxControllerIdentifier); } diff --git a/packages/frontend/core/package.json b/packages/frontend/core/package.json index 58a8944a61..cb5af94137 100644 --- a/packages/frontend/core/package.json +++ b/packages/frontend/core/package.json @@ -20,6 +20,7 @@ "@affine/track": "workspace:*", "@blocksuite/affine": "workspace:*", "@blocksuite/icons": "^2.2.13", + "@blocksuite/std": "workspace:*", "@dotlottie/player-component": "^2.7.12", "@emotion/cache": "^11.14.0", "@emotion/css": "^11.13.5", diff --git a/packages/frontend/core/src/blocksuite/ai/blocks/ai-chat-block/ai-chat-edgeless-block.ts b/packages/frontend/core/src/blocksuite/ai/blocks/ai-chat-block/ai-chat-edgeless-block.ts index e4644b5b21..b42ba2a06a 100644 --- a/packages/frontend/core/src/blocksuite/ai/blocks/ai-chat-block/ai-chat-edgeless-block.ts +++ b/packages/frontend/core/src/blocksuite/ai/blocks/ai-chat-block/ai-chat-edgeless-block.ts @@ -1,9 +1,11 @@ -import { Bound } from '@blocksuite/affine/global/gfx'; +import { Bound, clamp } from '@blocksuite/affine/global/gfx'; import { toGfxBlockComponent } from '@blocksuite/affine/std'; +import { GfxViewInteractionExtension } from '@blocksuite/std/gfx'; import { html } from 'lit'; import { styleMap } from 'lit/directives/style-map.js'; import { AIChatBlockComponent } from './ai-chat-block'; +import { AIChatBlockSchema } from './model'; export class EdgelessAIChatBlockComponent extends toGfxBlockComponent( AIChatBlockComponent @@ -31,6 +33,69 @@ export class EdgelessAIChatBlockComponent extends toGfxBlockComponent( } } +export const EdgelessAIChatBlockInteraction = + GfxViewInteractionExtension( + AIChatBlockSchema.model.flavour, + { + resizeConstraint: { + minWidth: 260, + minHeight: 160, + maxWidth: 320, + maxHeight: 300, + }, + + handleRotate() { + return { + beforeRotate(context) { + context.set({ + rotatable: false, + }); + }, + }; + }, + + handleResize({ model }) { + const initialScale = model.props.scale$.peek(); + + return { + onResizeStart(context) { + context.default(context); + model.stash('scale'); + }, + + onResizeMove(context) { + const { newBound, originalBound, lockRatio, constraint } = context; + const { minWidth, maxWidth, minHeight, maxHeight } = constraint; + + let scale = initialScale; + const originalRealWidth = originalBound.w / scale; + + // update scale if resize is proportional + if (lockRatio) { + scale = newBound.w / originalRealWidth; + } + + let newRealWidth = clamp(newBound.w / scale, minWidth, maxWidth); + let newRealHeight = clamp(newBound.h / scale, minHeight, maxHeight); + + newBound.w = newRealWidth * scale; + newBound.h = newRealHeight * scale; + + model.props.xywh = newBound.serialize(); + if (scale !== initialScale) { + model.props.scale = scale; + } + }, + + onResizeEnd(context) { + context.default(context); + model.pop('scale'); + }, + }; + }, + } + ); + declare global { interface HTMLElementTagNameMap { 'affine-edgeless-ai-chat': EdgelessAIChatBlockComponent; diff --git a/packages/frontend/core/tsconfig.json b/packages/frontend/core/tsconfig.json index f3e56ca57f..0655ffc9d5 100644 --- a/packages/frontend/core/tsconfig.json +++ b/packages/frontend/core/tsconfig.json @@ -17,6 +17,7 @@ { "path": "../../common/nbstore" }, { "path": "../track" }, { "path": "../../../blocksuite/affine/all" }, + { "path": "../../../blocksuite/framework/std" }, { "path": "../../common/infra" } ] } diff --git a/tests/blocksuite/e2e/edgeless/edgeless-text.spec.ts b/tests/blocksuite/e2e/edgeless/edgeless-text.spec.ts index 4cb7e5504f..fb646f54f0 100644 --- a/tests/blocksuite/e2e/edgeless/edgeless-text.spec.ts +++ b/tests/blocksuite/e2e/edgeless/edgeless-text.spec.ts @@ -441,9 +441,11 @@ test.describe('edgeless text block', () => { ); await page.mouse.up(); - expect(await getPageSnapshot(page, true)).toMatchSnapshot( - `${testInfo.title}_drag.json` - ); + const selectedRect2 = await getEdgelessSelectedRect(page); + expect(selectedRect2.width).toBeCloseTo(selectedRect1.width + 45); + expect(selectedRect2.height).toBeCloseTo(selectedRect1.height); + expect(selectedRect2.x).toBeCloseTo(selectedRect1.x); + expect(selectedRect2.y).toBeCloseTo(selectedRect1.y); }); test('cut edgeless text', async ({ page }) => { diff --git a/tests/blocksuite/e2e/edgeless/note/scale.spec.ts b/tests/blocksuite/e2e/edgeless/note/scale.spec.ts index 29439c7936..01de79fb6e 100644 --- a/tests/blocksuite/e2e/edgeless/note/scale.spec.ts +++ b/tests/blocksuite/e2e/edgeless/note/scale.spec.ts @@ -137,7 +137,10 @@ test.describe('note scale', () => { await page.mouse.down(); await page.mouse.move( noteRect.x + noteRect.width * 2, - noteRect.y + noteRect.height * 2 + noteRect.y + noteRect.height * 2, + { + steps: 10, + } ); await page.mouse.up(); diff --git a/tests/blocksuite/e2e/edgeless/note/undo-redo.spec.ts b/tests/blocksuite/e2e/edgeless/note/undo-redo.spec.ts index 48c5e5129a..8513f265b3 100644 --- a/tests/blocksuite/e2e/edgeless/note/undo-redo.spec.ts +++ b/tests/blocksuite/e2e/edgeless/note/undo-redo.spec.ts @@ -56,18 +56,17 @@ test('undo/redo should work correctly after resizing', async ({ page }) => { await switchEditorMode(page); await zoomResetByKeyboard(page); await activeNoteInEdgeless(page, noteId); - await waitNextFrame(page, 400); + await waitNextFrame(page, 600); // current implementation may be a little inefficient await fillLine(page, true); await page.mouse.click(0, 0); - await waitNextFrame(page, 400); + await waitNextFrame(page, 600); await selectNoteInEdgeless(page, noteId); const initRect = await getNoteRect(page, noteId); const rightHandle = page.locator('.handle[aria-label="right"] .resize'); const box = await rightHandle.boundingBox(); if (box === null) throw new Error(); - await dragBetweenCoords( page, { x: box.x + 5, y: box.y + 5 }, diff --git a/tests/blocksuite/e2e/edgeless/rotation.spec.ts b/tests/blocksuite/e2e/edgeless/rotation.spec.ts index 49bab473fc..6d8e6c196f 100644 --- a/tests/blocksuite/e2e/edgeless/rotation.spec.ts +++ b/tests/blocksuite/e2e/edgeless/rotation.spec.ts @@ -120,7 +120,7 @@ test.describe('rotation', () => { { x: 100, y: 100 }, { x: 200, y: 200 } ); - await rotateElementByHandle(page, 90, 'bottom-left'); + await rotateElementByHandle(page, 90, 'bottom-left', 10); await assertEdgelessSelectedRectRotation(page, 90); await resizeElementByHandle(page, { x: 10, y: -10 }, 'bottom-right'); diff --git a/tests/blocksuite/e2e/edgeless/text.spec.ts b/tests/blocksuite/e2e/edgeless/text.spec.ts index 83aa2549e9..b14d2d1193 100644 --- a/tests/blocksuite/e2e/edgeless/text.spec.ts +++ b/tests/blocksuite/e2e/edgeless/text.spec.ts @@ -7,6 +7,7 @@ import { initEmptyEdgelessState, pressArrowLeft, pressEnter, + resizeElementByHandle, setEdgelessTool, SHORT_KEY, switchEditorMode, @@ -208,12 +209,7 @@ test.describe('edgeless canvas text', () => { let lastHeight = selectedRect.height; // move cursor to the right edge and drag it to resize the width of text element - await page.mouse.move(130 + lastWidth, 160); - await page.mouse.down(); - await page.mouse.move(130 + lastWidth / 2, 160, { - steps: 10, - }); - await page.mouse.up(); + await resizeElementByHandle(page, { x: -20, y: 0 }, 'right', 10); // the text should be wrapped, so check the width and height of text element selectedRect = await getEdgelessSelectedRect(page); @@ -236,23 +232,13 @@ test.describe('edgeless canvas text', () => { selectedRect = await getEdgelessSelectedRect(page); lastWidth = selectedRect.width; lastHeight = selectedRect.height; - // move cursor to the left edge and drag it to resize the width of text element - await page.mouse.move(130, 160); - await page.mouse.down(); - await page.mouse.move(60, 160, { - steps: 10, - }); - await page.mouse.up(); + + await resizeElementByHandle(page, { x: 80, y: 0 }, 'right', 10); // the text should be unwrapped, check the width and height of text element selectedRect = await getEdgelessSelectedRect(page); expect(selectedRect.width).toBeGreaterThan(lastWidth); expect(selectedRect.height).toBeLessThan(lastHeight); - - await page.mouse.dblclick(100, 160); - await waitForInlineEditorStateUpdated(page); - await waitNextFrame(page); - await assertEdgelessCanvasText(page, 'hellohello'); }); test('text element should have maxWidth after adjusting width by dragging left or right edge', async ({ diff --git a/tests/blocksuite/e2e/utils/actions/edgeless.ts b/tests/blocksuite/e2e/utils/actions/edgeless.ts index fe15b094b5..f4f7827186 100644 --- a/tests/blocksuite/e2e/utils/actions/edgeless.ts +++ b/tests/blocksuite/e2e/utils/actions/edgeless.ts @@ -594,6 +594,10 @@ export async function resizeElementByHandle( page: Page, delta: Point, corner: + | 'top' + | 'bottom' + | 'left' + | 'right' | 'top-left' | 'top-right' | 'bottom-right' @@ -604,11 +608,13 @@ export async function resizeElementByHandle( const handle = page.locator(`.handle[aria-label="${corner}"] .resize`); const box = await handle.boundingBox(); if (box === null) throw new Error(); - const offset = 5; + const xOffset = box.width / 2; + const yOffset = box.height / 2; + await dragBetweenCoords( page, - { x: box.x + offset, y: box.y + offset }, - { x: box.x + delta.x + offset, y: box.y + delta.y + offset }, + { x: box.x + xOffset, y: box.y + yOffset }, + { x: box.x + delta.x + xOffset, y: box.y + delta.y + yOffset }, { steps, beforeMouseUp, diff --git a/tests/kit/src/utils/editor.ts b/tests/kit/src/utils/editor.ts index 7cee473063..78296855a0 100644 --- a/tests/kit/src/utils/editor.ts +++ b/tests/kit/src/utils/editor.ts @@ -454,6 +454,10 @@ export async function resizeElementByHandle( page: Page, delta: IVec, corner: + | 'right' + | 'left' + | 'top' + | 'bottom' | 'top-left' | 'top-right' | 'bottom-right' diff --git a/tools/utils/src/workspace.gen.ts b/tools/utils/src/workspace.gen.ts index 7b170e99f3..9364d9cfc4 100644 --- a/tools/utils/src/workspace.gen.ts +++ b/tools/utils/src/workspace.gen.ts @@ -1260,6 +1260,7 @@ export const PackageList = [ 'packages/frontend/templates', 'packages/frontend/track', 'blocksuite/affine/all', + 'blocksuite/framework/std', 'packages/common/infra', ], }, diff --git a/yarn.lock b/yarn.lock index aee6dc5354..45ddff42ba 100644 --- a/yarn.lock +++ b/yarn.lock @@ -405,6 +405,7 @@ __metadata: "@affine/track": "workspace:*" "@blocksuite/affine": "workspace:*" "@blocksuite/icons": "npm:^2.2.13" + "@blocksuite/std": "workspace:*" "@dotlottie/player-component": "npm:^2.7.12" "@emotion/cache": "npm:^11.14.0" "@emotion/css": "npm:^11.13.5"