diff --git a/blocksuite/affine/all/package.json b/blocksuite/affine/all/package.json index 7b504d08c9..46dbd62c4f 100644 --- a/blocksuite/affine/all/package.json +++ b/blocksuite/affine/all/package.json @@ -274,7 +274,9 @@ "./model": "./src/model/index.ts", "./sync": "./src/sync/index.ts", "./extensions/store": "./src/extensions/store.ts", - "./extensions/view": "./src/extensions/view.ts" + "./extensions/view": "./src/extensions/view.ts", + "./foundation/store": "./src/foundation/store.ts", + "./foundation/view": "./src/foundation/view.ts" }, "files": [ "src", diff --git a/blocksuite/affine/all/src/__tests__/adapters/notion-text.unit.spec.ts b/blocksuite/affine/all/src/__tests__/adapters/notion-text.unit.spec.ts index f998133522..a4d98aa6eb 100644 --- a/blocksuite/affine/all/src/__tests__/adapters/notion-text.unit.spec.ts +++ b/blocksuite/affine/all/src/__tests__/adapters/notion-text.unit.spec.ts @@ -106,4 +106,65 @@ describe('notion-text to snapshot', () => { }); expect(nanoidReplacement(target!)).toEqual(sliceSnapshot); }); + + test('notion text with empty styles array', () => { + const notionText = + '{"blockType":"text","editing":[["a "],["bold text",[["b"]]],[" hello world"]],"selection":{"startIndex":0,"endIndex":23},"action":"copy"}'; + + const sliceSnapshot: SliceSnapshot = { + type: 'slice', + content: [ + { + type: 'block', + id: 'matchesReplaceMap[0]', + flavour: 'affine:note', + props: { + xywh: '[0,0,800,95]', + background: DefaultTheme.noteBackgrounColor, + index: 'a0', + hidden: false, + displayMode: 'both', + }, + children: [ + { + type: 'block', + id: 'matchesReplaceMap[1]', + flavour: 'affine:paragraph', + props: { + type: 'text', + text: { + '$blocksuite:internal:text$': true, + delta: [ + { + insert: 'a ', + }, + { + insert: 'bold text', + attributes: { + bold: true, + }, + }, + { + insert: ' hello world', + }, + ], + }, + }, + children: [], + }, + ], + }, + ], + workspaceId: '', + pageId: '', + }; + + const ntAdapter = new NotionTextAdapter(createJob(), provider); + const target = ntAdapter.toSliceSnapshot({ + file: notionText, + workspaceId: '', + pageId: '', + }); + expect(nanoidReplacement(target!)).toEqual(sliceSnapshot); + }); }); diff --git a/blocksuite/affine/all/src/foundation/store.ts b/blocksuite/affine/all/src/foundation/store.ts new file mode 100644 index 0000000000..54ebe27f0c --- /dev/null +++ b/blocksuite/affine/all/src/foundation/store.ts @@ -0,0 +1 @@ +export * from '@blocksuite/affine-foundation/store'; diff --git a/blocksuite/affine/all/src/foundation/view.ts b/blocksuite/affine/all/src/foundation/view.ts new file mode 100644 index 0000000000..dbb9444ecd --- /dev/null +++ b/blocksuite/affine/all/src/foundation/view.ts @@ -0,0 +1 @@ +export * from '@blocksuite/affine-foundation/view'; 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-block.ts b/blocksuite/affine/blocks/bookmark/src/bookmark-block.ts index 8ee5f387df..4d63bed35c 100644 --- a/blocksuite/affine/blocks/bookmark/src/bookmark-block.ts +++ b/blocksuite/affine/blocks/bookmark/src/bookmark-block.ts @@ -9,7 +9,7 @@ import type { import { ImageProxyService } from '@blocksuite/affine-shared/adapters'; import { DocModeProvider, - LinkPreviewerService, + LinkPreviewServiceIdentifier, } from '@blocksuite/affine-shared/services'; import { BlockSelection } from '@blocksuite/std'; import { computed, type ReadonlySignal, signal } from '@preact/signals-core'; @@ -72,8 +72,8 @@ export class BookmarkBlockComponent extends CaptionedBlockComponent { this._localLinkPreview$.value = { 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/utils.ts b/blocksuite/affine/blocks/bookmark/src/utils.ts index 457ef51a18..7e9f309a52 100644 --- a/blocksuite/affine/blocks/bookmark/src/utils.ts +++ b/blocksuite/affine/blocks/bookmark/src/utils.ts @@ -1,4 +1,4 @@ -import { LinkPreviewerService } from '@blocksuite/affine-shared/services'; +import { LinkPreviewServiceIdentifier } from '@blocksuite/affine-shared/services'; import { isAbortError } from '@blocksuite/affine-shared/utils'; import type { BookmarkBlockComponent } from './bookmark-block.js'; @@ -15,7 +15,7 @@ export async function refreshBookmarkUrlData( try { bookmarkElement.loading = true; - const linkPreviewer = bookmarkElement.store.get(LinkPreviewerService); + const linkPreviewer = bookmarkElement.std.get(LinkPreviewServiceIdentifier); const bookmarkUrlData = await linkPreviewer.query( bookmarkElement.model.props.url, signal 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/code/src/view.ts b/blocksuite/affine/blocks/code/src/view.ts index d5aa50c5b1..eab094fd3b 100644 --- a/blocksuite/affine/blocks/code/src/view.ts +++ b/blocksuite/affine/blocks/code/src/view.ts @@ -11,6 +11,7 @@ import { import { literal, unsafeStatic } from 'lit/static-html.js'; import { getCodeClipboardExtensions } from './clipboard/index.js'; +import { CodeBlockConfigExtension } from './code-block-config'; import { CodeBlockInlineManagerExtension, CodeBlockUnitSpecExtension, @@ -21,7 +22,7 @@ import { AFFINE_CODE_TOOLBAR_WIDGET } from './code-toolbar/index.js'; import { codeSlashMenuConfig } from './configs/slash-menu.js'; import { effects } from './effects.js'; -export const codeToolbarWidget = WidgetViewExtension( +const codeToolbarWidget = WidgetViewExtension( 'affine:code', AFFINE_CODE_TOOLBAR_WIDGET, literal`${unsafeStatic(AFFINE_CODE_TOOLBAR_WIDGET)}` @@ -51,6 +52,12 @@ export class CodeBlockViewExtension extends ViewExtensionProvider { ]); if (!this.isMobile(context.scope)) { context.register(codeToolbarWidget); + } else { + context.register( + CodeBlockConfigExtension({ + showLineNumbers: false, + }) + ); } } } diff --git a/blocksuite/affine/blocks/database/src/context/host-context.ts b/blocksuite/affine/blocks/database/src/context/host-context.ts index 727a147c3a..8979ede015 100644 --- a/blocksuite/affine/blocks/database/src/context/host-context.ts +++ b/blocksuite/affine/blocks/database/src/context/host-context.ts @@ -1,7 +1,4 @@ -import { createContextKey } from '@blocksuite/data-view'; +import { createIdentifier } from '@blocksuite/global/di'; import type { EditorHost } from '@blocksuite/std'; -export const HostContextKey = createContextKey( - 'editor-host', - undefined -); +export const EditorHostKey = createIdentifier('editor-host'); diff --git a/blocksuite/affine/blocks/database/src/data-source.ts b/blocksuite/affine/blocks/database/src/data-source.ts index 4d8830f81a..e97fbf0cd9 100644 --- a/blocksuite/affine/blocks/database/src/data-source.ts +++ b/blocksuite/affine/blocks/database/src/data-source.ts @@ -57,6 +57,9 @@ type SpacialProperty = { valueGet: (rowId: string, propertyId: string) => unknown; }; export class DatabaseBlockDataSource extends DataSourceBase { + override get parentProvider() { + return this._model.store.provider; + } spacialProperties: Record = { 'created-time': { valueSet: () => {}, @@ -186,9 +189,13 @@ export class DatabaseBlockDataSource extends DataSourceBase { ); }); - constructor(model: DatabaseBlockModel) { + constructor( + model: DatabaseBlockModel, + init?: (dataSource: DatabaseBlockDataSource) => void + ) { super(); - this._model = model; + this._model = model; // ensure invariants first + init?.(this); // then allow external initialisation } private _runCapture() { diff --git a/blocksuite/affine/blocks/database/src/database-block.ts b/blocksuite/affine/blocks/database/src/database-block.ts index 83c565fbb8..92c284a9c9 100644 --- a/blocksuite/affine/blocks/database/src/database-block.ts +++ b/blocksuite/affine/blocks/database/src/database-block.ts @@ -27,6 +27,7 @@ import { type DataViewWidget, type DataViewWidgetProps, defineUniComponent, + ExternalGroupByConfigProvider, renderUniLit, type SingleView, uniMap, @@ -47,7 +48,7 @@ import { css, html, nothing, unsafeCSS } from 'lit'; import { popSideDetail } from './components/layout.js'; import { DatabaseConfigExtension } from './config.js'; -import { HostContextKey } from './context/host-context.js'; +import { EditorHostKey } from './context/host-context.js'; import { DatabaseBlockDataSource } from './data-source.js'; import { BlockRenderer } from './detail-panel/block-renderer.js'; import { NoteRenderer } from './detail-panel/note-renderer.js'; @@ -333,8 +334,17 @@ export class DatabaseBlockComponent extends CaptionedBlockComponent { + dataSource.serviceSet(EditorHostKey, this.host); + this.std.provider + .getAll(ExternalGroupByConfigProvider) + .forEach(config => { + dataSource.serviceSet( + ExternalGroupByConfigProvider(config.name), + config + ); + }); + }); const id = currentViewStorage.getCurrentView(this.model.id); if (id && this.dataSource.viewManager.viewGet(id)) { this.dataSource.viewManager.setCurrentView(id); diff --git a/blocksuite/affine/blocks/database/src/database-spec.ts b/blocksuite/affine/blocks/database/src/database-spec.ts deleted file mode 100644 index 863db62e22..0000000000 --- a/blocksuite/affine/blocks/database/src/database-spec.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { SlashMenuConfigExtension } from '@blocksuite/affine-widget-slash-menu'; -import { BlockViewExtension, FlavourExtension } from '@blocksuite/std'; -import type { ExtensionType } from '@blocksuite/store'; -import { literal } from 'lit/static-html.js'; - -import { DatabaseBlockAdapterExtensions } from './adapters/extension.js'; -import { databaseSlashMenuConfig } from './configs/slash-menu.js'; - -export const DatabaseBlockSpec: ExtensionType[] = [ - FlavourExtension('affine:database'), - BlockViewExtension('affine:database', literal`affine-database`), - DatabaseBlockAdapterExtensions, - SlashMenuConfigExtension('affine:database', databaseSlashMenuConfig), -].flat(); diff --git a/blocksuite/affine/blocks/database/src/index.ts b/blocksuite/affine/blocks/database/src/index.ts index e3f4689f56..90813a8cd4 100644 --- a/blocksuite/affine/blocks/database/src/index.ts +++ b/blocksuite/affine/blocks/database/src/index.ts @@ -4,7 +4,6 @@ export * from './config'; export * from './context'; export * from './data-source'; export * from './database-block'; -export * from './database-spec'; export * from './detail-panel/block-renderer'; export * from './detail-panel/note-renderer'; export * from './properties'; diff --git a/blocksuite/affine/blocks/database/src/properties/link/cell-renderer.ts b/blocksuite/affine/blocks/database/src/properties/link/cell-renderer.ts index 8ff76cdf55..67e4b5fe46 100644 --- a/blocksuite/affine/blocks/database/src/properties/link/cell-renderer.ts +++ b/blocksuite/affine/blocks/database/src/properties/link/cell-renderer.ts @@ -15,7 +15,7 @@ import { computed } from '@preact/signals-core'; import { html, nothing, type PropertyValues } from 'lit'; import { createRef, ref } from 'lit/directives/ref.js'; -import { HostContextKey } from '../../context/host-context.js'; +import { EditorHostKey } from '../../context/host-context.js'; import { inlineLinkNodeStyle, linkCellStyle, @@ -88,7 +88,7 @@ export class LinkCell extends BaseCellRenderer { }; get std() { - const host = this.view.contextGet(HostContextKey); + const host = this.view.serviceGet(EditorHostKey); return host?.std; } diff --git a/blocksuite/affine/blocks/database/src/properties/rich-text/cell-renderer.ts b/blocksuite/affine/blocks/database/src/properties/rich-text/cell-renderer.ts index 7b7e01c519..9ee787e47a 100644 --- a/blocksuite/affine/blocks/database/src/properties/rich-text/cell-renderer.ts +++ b/blocksuite/affine/blocks/database/src/properties/rich-text/cell-renderer.ts @@ -24,7 +24,7 @@ import { computed, effect, signal } from '@preact/signals-core'; import { ref } from 'lit/directives/ref.js'; import { html } from 'lit/static-html.js'; -import { HostContextKey } from '../../context/host-context.js'; +import { EditorHostKey } from '../../context/host-context.js'; import type { DatabaseBlockComponent } from '../../database-block.js'; import { richTextCellStyle, @@ -87,7 +87,7 @@ export class RichTextCell extends BaseCellRenderer { get inlineManager() { return this.view - .contextGet(HostContextKey) + .serviceGet(EditorHostKey) ?.std.get(DefaultInlineManagerExtension.identifier); } @@ -98,7 +98,7 @@ export class RichTextCell extends BaseCellRenderer { } get host() { - return this.view.contextGet(HostContextKey); + return this.view.serviceGet(EditorHostKey); } private readonly richText$ = signal(); @@ -398,7 +398,7 @@ export class RichTextCell extends BaseCellRenderer { } private get std() { - return this.view.contextGet(HostContextKey)?.std; + return this.view.serviceGet(EditorHostKey)?.std; } insertDelta = (delta: DeltaInsert) => { diff --git a/blocksuite/affine/blocks/database/src/properties/rich-text/define.ts b/blocksuite/affine/blocks/database/src/properties/rich-text/define.ts index 96c1297c26..7df11a61d6 100644 --- a/blocksuite/affine/blocks/database/src/properties/rich-text/define.ts +++ b/blocksuite/affine/blocks/database/src/properties/rich-text/define.ts @@ -5,7 +5,7 @@ import { Text } from '@blocksuite/store'; import * as Y from 'yjs'; import zod from 'zod'; -import { HostContextKey } from '../../context/host-context.js'; +import { EditorHostKey } from '../../context/host-context.js'; import { isLinkedDoc } from '../../utils/title-doc.js'; export const richTextColumnType = propertyType('rich-text'); @@ -43,7 +43,7 @@ export const richTextPropertyModelConfig = richTextColumnType.modelConfig({ }, toJson: ({ value, dataSource }) => { if (!value) return null; - const host = dataSource.contextGet(HostContextKey); + const host = dataSource.serviceGet(EditorHostKey); if (host) { const collection = host.std.workspace; const yText = toYText(value); diff --git a/blocksuite/affine/blocks/database/src/properties/title/define.ts b/blocksuite/affine/blocks/database/src/properties/title/define.ts index 6450fae4f3..400b5d4a19 100644 --- a/blocksuite/affine/blocks/database/src/properties/title/define.ts +++ b/blocksuite/affine/blocks/database/src/properties/title/define.ts @@ -3,7 +3,7 @@ import { Text } from '@blocksuite/store'; import { Doc } from 'yjs'; import zod from 'zod'; -import { HostContextKey } from '../../context/host-context.js'; +import { EditorHostKey } from '../../context/host-context.js'; import { isLinkedDoc } from '../../utils/title-doc.js'; export const titleColumnType = propertyType('title'); @@ -28,7 +28,7 @@ export const titlePropertyModelConfig = titleColumnType.modelConfig({ }, toJson: ({ value, dataSource }) => { if (!value) return ''; - const host = dataSource.contextGet(HostContextKey); + const host = dataSource.serviceGet(EditorHostKey); if (host) { const collection = host.std.workspace; const deltas = value.deltas$.value; diff --git a/blocksuite/affine/blocks/database/src/properties/title/text.ts b/blocksuite/affine/blocks/database/src/properties/title/text.ts index fbad7f071f..67978672f2 100644 --- a/blocksuite/affine/blocks/database/src/properties/title/text.ts +++ b/blocksuite/affine/blocks/database/src/properties/title/text.ts @@ -17,7 +17,7 @@ import { property } from 'lit/decorators.js'; import { createRef, ref } from 'lit/directives/ref.js'; import { html } from 'lit/static-html.js'; -import { HostContextKey } from '../../context/host-context.js'; +import { EditorHostKey } from '../../context/host-context.js'; import type { DatabaseBlockComponent } from '../../database-block.js'; import { getSingleDocIdFromText } from '../../utils/title-doc.js'; import { @@ -32,7 +32,7 @@ export class HeaderAreaTextCell extends BaseCellRenderer { docId$ = signal(); get host() { - return this.view.contextGet(HostContextKey); + return this.view.serviceGet(EditorHostKey); } get inlineEditor() { @@ -50,7 +50,7 @@ export class HeaderAreaTextCell extends BaseCellRenderer { } get std() { - return this.view.contextGet(HostContextKey)?.std; + return this.view.serviceGet(EditorHostKey)?.std; } private readonly _onCopy = (e: ClipboardEvent) => { 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-service.ts b/blocksuite/affine/blocks/embed/src/embed-github-block/embed-github-service.ts index 853522d70e..7d532bfddb 100644 --- a/blocksuite/affine/blocks/embed/src/embed-github-block/embed-github-service.ts +++ b/blocksuite/affine/blocks/embed/src/embed-github-block/embed-github-service.ts @@ -5,7 +5,7 @@ import { } from '@blocksuite/affine-model'; import { EmbedOptionConfig, - LinkPreviewerService, + LinkPreviewServiceIdentifier, } from '@blocksuite/affine-shared/services'; import { BlockService } from '@blocksuite/std'; @@ -22,7 +22,7 @@ export class EmbedGithubBlockService extends BlockService { queryUrlData = (embedGithubModel: EmbedGithubModel, signal?: AbortSignal) => { return queryEmbedGithubData( embedGithubModel, - this.doc.get(LinkPreviewerService), + this.std.get(LinkPreviewServiceIdentifier), signal ); }; 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-github-block/utils.ts b/blocksuite/affine/blocks/embed/src/embed-github-block/utils.ts index a03cdde5ab..100edb78b6 100644 --- a/blocksuite/affine/blocks/embed/src/embed-github-block/utils.ts +++ b/blocksuite/affine/blocks/embed/src/embed-github-block/utils.ts @@ -2,7 +2,7 @@ import type { EmbedGithubBlockUrlData, EmbedGithubModel, } from '@blocksuite/affine-model'; -import type { LinkPreviewerService } from '@blocksuite/affine-shared/services'; +import type { LinkPreviewProvider } from '@blocksuite/affine-shared/services'; import { isAbortError } from '@blocksuite/affine-shared/utils'; import { nothing } from 'lit'; @@ -19,7 +19,7 @@ import { export async function queryEmbedGithubData( embedGithubModel: EmbedGithubModel, - linkPreviewer: LinkPreviewerService, + linkPreviewer: LinkPreviewProvider, signal?: AbortSignal ): Promise> { const [githubApiData, openGraphData] = await Promise.all([ 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-block.ts b/blocksuite/affine/blocks/embed/src/embed-iframe-block/embed-iframe-block.ts index 8f33be0ddf..c63d7123d5 100644 --- a/blocksuite/affine/blocks/embed/src/embed-iframe-block/embed-iframe-block.ts +++ b/blocksuite/affine/blocks/embed/src/embed-iframe-block/embed-iframe-block.ts @@ -9,7 +9,7 @@ import { type EmbedIframeData, EmbedIframeService, type IframeOptions, - LinkPreviewerService, + LinkPreviewServiceIdentifier, NotificationProvider, } from '@blocksuite/affine-shared/services'; import { matchModels } from '@blocksuite/affine-shared/utils'; @@ -94,7 +94,7 @@ export class EmbedIframeBlockComponent extends CaptionedBlockComponent { return queryEmbedYoutubeData( embedYoutubeModel, - this.doc.get(LinkPreviewerService), + this.std.get(LinkPreviewServiceIdentifier), signal ); }; 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/embed-youtube-block/utils.ts b/blocksuite/affine/blocks/embed/src/embed-youtube-block/utils.ts index 9f651ba99c..3d78913c20 100644 --- a/blocksuite/affine/blocks/embed/src/embed-youtube-block/utils.ts +++ b/blocksuite/affine/blocks/embed/src/embed-youtube-block/utils.ts @@ -2,14 +2,14 @@ import type { EmbedYoutubeBlockUrlData, EmbedYoutubeModel, } from '@blocksuite/affine-model'; -import type { LinkPreviewerService } from '@blocksuite/affine-shared/services'; +import type { LinkPreviewProvider } from '@blocksuite/affine-shared/services'; import { isAbortError } from '@blocksuite/affine-shared/utils'; import type { EmbedYoutubeBlockComponent } from './embed-youtube-block.js'; export async function queryEmbedYoutubeData( embedYoutubeModel: EmbedYoutubeModel, - linkPreviewer: LinkPreviewerService, + linkPreviewer: LinkPreviewProvider, signal?: AbortSignal ): Promise> { const url = embedYoutubeModel.props.url; 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/paragraph/src/view.ts b/blocksuite/affine/blocks/paragraph/src/view.ts index 87883b4d84..103625b7c5 100644 --- a/blocksuite/affine/blocks/paragraph/src/view.ts +++ b/blocksuite/affine/blocks/paragraph/src/view.ts @@ -28,10 +28,9 @@ import { z } from 'zod'; import { effects } from './effects'; const optionsSchema = z.object({ - getPlaceholder: z - .function() - .args(z.instanceof(ParagraphBlockModel)) - .returns(z.string()), + getPlaceholder: z.optional( + z.function().args(z.instanceof(ParagraphBlockModel)).returns(z.string()) + ), }); export class ParagraphViewExtension extends ViewExtensionProvider< @@ -50,7 +49,7 @@ export class ParagraphViewExtension extends ViewExtensionProvider< context: ViewExtensionContext, options?: z.infer ) { - super.setup(context); + super.setup(context, options); const getPlaceholder = options?.getPlaceholder ?? (model => placeholders[model.props.type]); 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/components/src/peek/service.ts b/blocksuite/affine/components/src/peek/service.ts index 9ef64a6c7e..cb9999a074 100644 --- a/blocksuite/affine/components/src/peek/service.ts +++ b/blocksuite/affine/components/src/peek/service.ts @@ -10,7 +10,7 @@ export const PeekViewProvider = createIdentifier( export function PeekViewExtension(service: PeekViewService): ExtensionType { return { setup: di => { - di.addImpl(PeekViewProvider, () => service); + di.override(PeekViewProvider, () => service); }, }; } diff --git a/blocksuite/affine/data-view/src/core/data-source/base.ts b/blocksuite/affine/data-view/src/core/data-source/base.ts index 78f1911c3b..ac5cd8b083 100644 --- a/blocksuite/affine/data-view/src/core/data-source/base.ts +++ b/blocksuite/affine/data-view/src/core/data-source/base.ts @@ -1,5 +1,11 @@ import type { ColumnDataType } from '@blocksuite/affine-model'; import type { InsertToPosition } from '@blocksuite/affine-shared/utils'; +import { + Container, + createScope, + type GeneralServiceIdentifier, + type ServiceProvider, +} from '@blocksuite/global/di'; import { computed, type ReadonlySignal } from '@preact/signals-core'; import type { TypeInstance } from '../logical/type.js'; @@ -8,7 +14,6 @@ import type { DatabaseFlags } from '../types.js'; import type { ViewConvertConfig } from '../view/convert.js'; import type { DataViewDataType, ViewMeta } from '../view/data-view.js'; import type { ViewManager } from '../view-manager/view-manager.js'; -import type { DataViewContextKey } from './context.js'; export interface DataSource { readonly$: ReadonlySignal; @@ -65,7 +70,9 @@ export interface DataSource { propertyDelete(id: string): void; propertyCanDelete(propertyId: string): boolean; - contextGet(key: DataViewContextKey): T; + provider: ServiceProvider; + serviceGet(key: GeneralServiceIdentifier): T | null; + serviceGetOrCreate(key: GeneralServiceIdentifier, create: () => T): T; viewConverts: ViewConvertConfig[]; viewManager: ViewManager; @@ -91,6 +98,8 @@ export interface DataSource { viewMetaGetById$(viewId: string): ReadonlySignal; } +export const DataSourceScope = createScope('data-source'); + export abstract class DataSourceBase implements DataSource { propertyTypeCanSet(propertyId: string): boolean { return !this.isFixedProperty(propertyId); @@ -101,7 +110,9 @@ export abstract class DataSourceBase implements DataSource { propertyCanDelete(propertyId: string): boolean { return !this.isFixedProperty(propertyId); } - context = new Map(); + protected container = new Container(); + + abstract get parentProvider(): ServiceProvider; abstract featureFlags$: ReadonlySignal; @@ -144,12 +155,26 @@ export abstract class DataSourceBase implements DataSource { return computed(() => this.cellValueGet(rowId, propertyId)); } - contextGet(key: DataViewContextKey): T { - return (this.context.get(key.key) as T) ?? key.defaultValue; + get provider() { + return this.container.provider(DataSourceScope, this.parentProvider); } - contextSet(key: DataViewContextKey, value: T): void { - this.context.set(key.key, value); + serviceGet(key: GeneralServiceIdentifier): T | null { + return this.provider.getOptional(key); + } + + serviceSet(key: GeneralServiceIdentifier, value: T): void { + this.container.addValue(key, value, { scope: DataSourceScope }); + } + + serviceGetOrCreate(key: GeneralServiceIdentifier, create: () => T): T { + const result = this.serviceGet(key); + if (result != null) { + return result; + } + const value = create(); + this.serviceSet(key, value); + return value; } abstract propertyAdd( diff --git a/blocksuite/affine/data-view/src/core/data-source/context.ts b/blocksuite/affine/data-view/src/core/data-source/context.ts deleted file mode 100644 index f81b56973c..0000000000 --- a/blocksuite/affine/data-view/src/core/data-source/context.ts +++ /dev/null @@ -1,12 +0,0 @@ -export interface DataViewContextKey { - key: symbol; - defaultValue: T; -} - -export const createContextKey = ( - name: string, - defaultValue: T -): DataViewContextKey => ({ - key: Symbol(name), - defaultValue, -}); diff --git a/blocksuite/affine/data-view/src/core/data-source/index.ts b/blocksuite/affine/data-view/src/core/data-source/index.ts index ffe27a8284..ab44c4589c 100644 --- a/blocksuite/affine/data-view/src/core/data-source/index.ts +++ b/blocksuite/affine/data-view/src/core/data-source/index.ts @@ -1,2 +1 @@ export * from './base.js'; -export * from './context.js'; diff --git a/blocksuite/affine/data-view/src/core/group-by/default.ts b/blocksuite/affine/data-view/src/core/group-by/default.ts index 97a74f7e02..6e52685313 100644 --- a/blocksuite/affine/data-view/src/core/group-by/default.ts +++ b/blocksuite/affine/data-view/src/core/group-by/default.ts @@ -1,7 +1,7 @@ import type { GroupBy } from '../common/types.js'; import type { DataSource } from '../data-source/index.js'; import type { PropertyMetaConfig } from '../property/property-config.js'; -import { groupByMatcher } from './matcher.js'; +import { getGroupByService } from './matcher.js'; export const defaultGroupBy = ( dataSource: DataSource, @@ -9,7 +9,8 @@ export const defaultGroupBy = ( propertyId: string, data: NonNullable ): GroupBy | undefined => { - const name = groupByMatcher.match( + const groupByService = getGroupByService(dataSource); + const name = groupByService?.matcher.match( propertyMeta.config.jsonValue.type({ data, dataSource }) )?.name; return name != null diff --git a/blocksuite/affine/data-view/src/core/group-by/define.ts b/blocksuite/affine/data-view/src/core/group-by/define.ts index 60a8d01383..a72c54113c 100644 --- a/blocksuite/affine/data-view/src/core/group-by/define.ts +++ b/blocksuite/affine/data-view/src/core/group-by/define.ts @@ -1,6 +1,6 @@ import hash from '@emotion/hash'; -import { MatcherCreator } from '../logical/matcher.js'; +import type { TypeInstance } from '../logical/type.js'; import { t } from '../logical/type-presets.js'; import { createUniComponentFromWebComponent } from '../utils/uni-component/uni-component.js'; import { BooleanGroupView } from './renderer/boolean-group.js'; @@ -8,15 +8,23 @@ import { NumberGroupView } from './renderer/number-group.js'; import { SelectGroupView } from './renderer/select-group.js'; import { StringGroupView } from './renderer/string-group.js'; import type { GroupByConfig } from './types.js'; - -const groupByMatcherCreator = new MatcherCreator(); -const ungroups = { +export const createGroupByConfig = < + Data extends Record, + MatchType extends TypeInstance, + GroupValue = unknown, +>( + config: GroupByConfig +): GroupByConfig => { + return config as never as GroupByConfig; +}; +export const ungroups = { key: 'Ungroups', value: null, }; export const groupByMatchers = [ - groupByMatcherCreator.createMatcher(t.tag.instance(), { + createGroupByConfig({ name: 'select', + matchType: t.tag.instance(), groupName: (type, value) => { if (t.tag.is(type) && type.data) { return type.data.find(v => v.id === value)?.value ?? ''; @@ -48,11 +56,12 @@ export const groupByMatchers = [ }, view: createUniComponentFromWebComponent(SelectGroupView), }), - groupByMatcherCreator.createMatcher(t.array.instance(t.tag.instance()), { + createGroupByConfig({ name: 'multi-select', - groupName: (type, value) => { - if (t.tag.is(type) && type.data) { - return type.data.find(v => v.id === value)?.value ?? ''; + matchType: t.array.instance(t.tag.instance()), + groupName: (type, value: string | null) => { + if (t.array.is(type) && t.tag.is(type.element) && type.element.data) { + return type.element.data.find(v => v.id === value)?.value ?? ''; } return ''; }, @@ -94,8 +103,9 @@ export const groupByMatchers = [ }, view: createUniComponentFromWebComponent(SelectGroupView), }), - groupByMatcherCreator.createMatcher(t.string.instance(), { + createGroupByConfig({ name: 'text', + matchType: t.string.instance(), groupName: (_type, value) => { return `${value ?? ''}`; }, @@ -115,15 +125,16 @@ export const groupByMatchers = [ }, view: createUniComponentFromWebComponent(StringGroupView), }), - groupByMatcherCreator.createMatcher(t.number.instance(), { + createGroupByConfig({ name: 'number', - groupName: (_type, value) => { + matchType: t.number.instance(), + groupName: (_type, value: number | null) => { return `${value ?? ''}`; }, defaultKeys: _type => { return [ungroups]; }, - valuesGroup: (value, _type) => { + valuesGroup: (value: number | null, _type) => { if (typeof value !== 'number') { return [ungroups]; } @@ -137,8 +148,9 @@ export const groupByMatchers = [ addToGroup: value => (typeof value === 'number' ? value * 10 : null), view: createUniComponentFromWebComponent(NumberGroupView), }), - groupByMatcherCreator.createMatcher(t.boolean.instance(), { + createGroupByConfig({ name: 'boolean', + matchType: t.boolean.instance(), groupName: (_type, value) => { return `${value?.toString() ?? ''}`; }, diff --git a/blocksuite/affine/data-view/src/core/group-by/group-title.ts b/blocksuite/affine/data-view/src/core/group-by/group-title.ts index b4da888d24..9e58bed869 100644 --- a/blocksuite/affine/data-view/src/core/group-by/group-title.ts +++ b/blocksuite/affine/data-view/src/core/group-by/group-title.ts @@ -5,10 +5,10 @@ import { nothing } from 'lit'; import { html } from 'lit/static-html.js'; import { renderUniLit } from '../utils/uni-component/uni-component.js'; -import type { GroupData } from './trait.js'; +import type { Group } from './trait.js'; import type { GroupRenderProps } from './types.js'; -function GroupHeaderCount(group: GroupData) { +function GroupHeaderCount(group: Group) { const cards = group.rows; if (!cards.length) { return; @@ -16,32 +16,25 @@ function GroupHeaderCount(group: GroupData) { return html`
${cards.length}
`; } const GroupTitleMobile = ( - groupData: GroupData, + groupData: Group, ops: { readonly: boolean; clickAdd: (evt: MouseEvent) => void; clickOps: (evt: MouseEvent) => void; } ) => { - const data = groupData.manager.config$.value; - if (!data) return nothing; + const type = groupData.tType; + if (!type) return nothing; const icon = groupData.value == null ? '' : html` `; const props: GroupRenderProps = { - value: groupData.value, - data: groupData.property.data$.value, - updateData: groupData.manager.updateData, - updateValue: value => - groupData.manager.updateValue( - groupData.rows.map(row => row.rowId), - value - ), + group: groupData, readonly: ops.readonly, }; @@ -103,7 +96,7 @@ const GroupTitleMobile = (
- ${icon} ${renderUniLit(data.view, props)} ${columnName} + ${icon} ${renderUniLit(groupData.view, props)} ${columnName} ${GroupHeaderCount(groupData)}
${ops.readonly @@ -120,7 +113,7 @@ const GroupTitleMobile = ( }; export const GroupTitle = ( - groupData: GroupData, + groupData: Group, ops: { readonly: boolean; clickAdd: (evt: MouseEvent) => void; @@ -130,25 +123,18 @@ export const GroupTitle = ( if (IS_MOBILE) { return GroupTitleMobile(groupData, ops); } - const data = groupData.manager.config$.value; - if (!data) return nothing; + const type = groupData.tType; + if (!type) return nothing; const icon = groupData.value == null ? '' : html` `; const props: GroupRenderProps = { - value: groupData.value, - data: groupData.property.data$.value, - updateData: groupData.manager.updateData, - updateValue: value => - groupData.manager.updateValue( - groupData.rows.map(row => row.rowId), - value - ), + group: groupData, readonly: ops.readonly, }; @@ -228,7 +214,7 @@ export const GroupTitle = (
- ${icon} ${renderUniLit(data.view, props)} ${columnName} + ${icon} ${renderUniLit(groupData.view, props)} ${columnName} ${GroupHeaderCount(groupData)}
${ops.readonly diff --git a/blocksuite/affine/data-view/src/core/group-by/index.ts b/blocksuite/affine/data-view/src/core/group-by/index.ts index 5c36099e4d..15042294f3 100644 --- a/blocksuite/affine/data-view/src/core/group-by/index.ts +++ b/blocksuite/affine/data-view/src/core/group-by/index.ts @@ -1 +1,3 @@ +export * from './define.js'; +export * from './matcher.js'; export * from './trait.js'; diff --git a/blocksuite/affine/data-view/src/core/group-by/matcher.ts b/blocksuite/affine/data-view/src/core/group-by/matcher.ts index 7b3cd536ec..205e242fb7 100644 --- a/blocksuite/affine/data-view/src/core/group-by/matcher.ts +++ b/blocksuite/affine/data-view/src/core/group-by/matcher.ts @@ -1,5 +1,41 @@ -import { Matcher } from '../logical/matcher.js'; +import { createIdentifier } from '@blocksuite/global/di'; + +import type { DataSource } from '../data-source/base.js'; +import { Matcher_ } from '../logical/matcher.js'; import { groupByMatchers } from './define.js'; import type { GroupByConfig } from './types.js'; -export const groupByMatcher = new Matcher(groupByMatchers); +export const createGroupByMatcher = (list: GroupByConfig[]) => { + return new Matcher_(list, v => v.matchType); +}; + +export class GroupByService { + constructor(private readonly dataSource: DataSource) {} + + allExternalGroupByConfig(): GroupByConfig[] { + return Array.from( + this.dataSource.provider.getAll(ExternalGroupByConfigProvider).values() + ); + } + + get matcher() { + return createGroupByMatcher([ + ...this.allExternalGroupByConfig(), + ...groupByMatchers, + ]); + } +} + +export const GroupByProvider = + createIdentifier('group-by-service'); + +export const getGroupByService = (dataSource: DataSource) => { + return dataSource.serviceGetOrCreate( + GroupByProvider, + () => new GroupByService(dataSource) + ); +}; + +export const ExternalGroupByConfigProvider = createIdentifier( + 'external-group-by-config' +); diff --git a/blocksuite/affine/data-view/src/core/group-by/renderer/base.ts b/blocksuite/affine/data-view/src/core/group-by/renderer/base.ts index 80bd84ebed..d73ad16cb4 100644 --- a/blocksuite/affine/data-view/src/core/group-by/renderer/base.ts +++ b/blocksuite/affine/data-view/src/core/group-by/renderer/base.ts @@ -2,24 +2,39 @@ import { SignalWatcher, WithDisposable } from '@blocksuite/global/lit'; import { ShadowlessElement } from '@blocksuite/std'; import { property } from 'lit/decorators.js'; +import type { Group } from '../trait.js'; import type { GroupRenderProps } from '../types.js'; -export class BaseGroup, Value> +export class BaseGroup> extends SignalWatcher(WithDisposable(ShadowlessElement)) - implements GroupRenderProps + implements GroupRenderProps { @property({ attribute: false }) - accessor data!: Data; + accessor group!: Group; @property({ attribute: false }) accessor readonly!: boolean; - @property({ attribute: false }) - accessor updateData: ((data: Data) => void) | undefined = undefined; + updateData(data: Data) { + this.group.manager.updateData(data); + } - @property({ attribute: false }) - accessor updateValue: ((value: Value) => void) | undefined = undefined; + updateValue(value: JsonValue) { + this.group.manager.updateValue( + this.group.rows.map(row => row.rowId), + value + ); + } - @property({ attribute: false }) - accessor value!: Value; + get value(): JsonValue { + return this.group.value as JsonValue; + } + + get type() { + return this.group.tType; + } + + get data() { + return this.group.property.data$.value; + } } diff --git a/blocksuite/affine/data-view/src/core/group-by/renderer/boolean-group.ts b/blocksuite/affine/data-view/src/core/group-by/renderer/boolean-group.ts index 4090bbb533..201cc7a9bb 100644 --- a/blocksuite/affine/data-view/src/core/group-by/renderer/boolean-group.ts +++ b/blocksuite/affine/data-view/src/core/group-by/renderer/boolean-group.ts @@ -3,7 +3,7 @@ import { css, html } from 'lit'; import { BaseGroup } from './base.js'; -export class BooleanGroupView extends BaseGroup, boolean> { +export class BooleanGroupView extends BaseGroup> { static override styles = css` .data-view-group-title-boolean-view { display: flex; diff --git a/blocksuite/affine/data-view/src/core/group-by/renderer/number-group.ts b/blocksuite/affine/data-view/src/core/group-by/renderer/number-group.ts index be9f840eb7..ae946fa3cd 100644 --- a/blocksuite/affine/data-view/src/core/group-by/renderer/number-group.ts +++ b/blocksuite/affine/data-view/src/core/group-by/renderer/number-group.ts @@ -7,7 +7,7 @@ import { css, html } from 'lit'; import { BaseGroup } from './base.js'; -export class NumberGroupView extends BaseGroup, number> { +export class NumberGroupView extends BaseGroup> { static override styles = css` .data-view-group-title-number-view { border-radius: 8px; diff --git a/blocksuite/affine/data-view/src/core/group-by/renderer/select-group.ts b/blocksuite/affine/data-view/src/core/group-by/renderer/select-group.ts index b4200045d0..d5a7979afb 100644 --- a/blocksuite/affine/data-view/src/core/group-by/renderer/select-group.ts +++ b/blocksuite/affine/data-view/src/core/group-by/renderer/select-group.ts @@ -12,10 +12,10 @@ import type { SelectTag } from '../../logical/index.js'; import { BaseGroup } from './base.js'; export class SelectGroupView extends BaseGroup< + string, { options: SelectTag[]; - }, - string + } > { static override styles = css` data-view-group-title-select-view { diff --git a/blocksuite/affine/data-view/src/core/group-by/renderer/string-group.ts b/blocksuite/affine/data-view/src/core/group-by/renderer/string-group.ts index cb39355c3f..1114d87c9e 100644 --- a/blocksuite/affine/data-view/src/core/group-by/renderer/string-group.ts +++ b/blocksuite/affine/data-view/src/core/group-by/renderer/string-group.ts @@ -7,7 +7,7 @@ import { css, html } from 'lit'; import { BaseGroup } from './base.js'; -export class StringGroupView extends BaseGroup, string> { +export class StringGroupView extends BaseGroup> { static override styles = css` .data-view-group-title-string-view { border-radius: 8px; diff --git a/blocksuite/affine/data-view/src/core/group-by/setting.ts b/blocksuite/affine/data-view/src/core/group-by/setting.ts index 951e313a79..d8a47288e8 100644 --- a/blocksuite/affine/data-view/src/core/group-by/setting.ts +++ b/blocksuite/affine/data-view/src/core/group-by/setting.ts @@ -24,7 +24,7 @@ import { sortable, } from '../utils/wc-dnd/sort/sort-context.js'; import { verticalListSortingStrategy } from '../utils/wc-dnd/sort/strategies/index.js'; -import { groupByMatcher } from './matcher.js'; +import { getGroupByService } from './matcher.js'; import type { GroupTrait } from './trait.js'; import type { GroupRenderProps } from './types.js'; @@ -142,21 +142,22 @@ export class GroupSetting extends SignalWatcher( groups, group => group?.key ?? 'default key', group => { - if (!group) return; + const type = group.property.dataType$.value; + if (!type) return; const props: GroupRenderProps = { - value: group.value, - data: group.property.data$.value, + group, readonly: true, }; - const config = group.manager.config$.value; return html`
-
- ${renderUniLit(config?.view, props)} +
+ ${renderUniLit(group.view, props)}
@@ -198,7 +199,8 @@ export const selectGroupByProperty = ( if (!dataType) { return false; } - return !!groupByMatcher.match(dataType); + const groupByService = getGroupByService(view.manager.dataSource); + return !!groupByService?.matcher.match(dataType); }) .map(property => { return menu.action({ diff --git a/blocksuite/affine/data-view/src/core/group-by/trait.ts b/blocksuite/affine/data-view/src/core/group-by/trait.ts index 43acac0075..2758cd09cc 100644 --- a/blocksuite/affine/data-view/src/core/group-by/trait.ts +++ b/blocksuite/affine/data-view/src/core/group-by/trait.ts @@ -12,89 +12,112 @@ import type { Property } from '../view-manager/property.js'; import type { Row } from '../view-manager/row.js'; import type { SingleView } from '../view-manager/single-view.js'; import { defaultGroupBy } from './default.js'; -import { groupByMatcher } from './matcher.js'; -export type GroupData = { - manager: GroupTrait; - property: Property; - key: string; - name: string; - type: TypeInstance; - value: unknown; - rows: Row[]; +import { getGroupByService } from './matcher.js'; +import type { GroupByConfig } from './types.js'; + +export type GroupInfo< + RawValue = unknown, + JsonValue = unknown, + Data extends Record = Record, +> = { + config: GroupByConfig; + property: Property; + tType: TypeInstance; }; +export class Group< + RawValue = unknown, + JsonValue = unknown, + Data extends Record = Record, +> { + rows: Row[] = []; + constructor( + public readonly key: string, + public readonly value: JsonValue, + private readonly groupInfo: GroupInfo, + public readonly manager: GroupTrait + ) {} + + get property() { + return this.groupInfo.property; + } + + name$ = computed(() => { + const type = this.property.dataType$.value; + if (!type) { + return ''; + } + return this.groupInfo.config.groupName(type, this.value); + }); + + private get config() { + return this.groupInfo.config; + } + + get tType() { + return this.groupInfo.tType; + } + get view() { + return this.config.view; + } +} + export class GroupTrait { - config$ = computed(() => { + groupInfo$ = computed(() => { const groupBy = this.groupBy$.value; if (!groupBy) { return; } - const result = groupByMatcher.find(v => v.data.name === groupBy.name); + const property = this.view.propertyGetOrCreate(groupBy.columnId); + if (!property) { + return; + } + const tType = property.dataType$.value; + if (!tType) { + return; + } + const groupByService = getGroupByService(this.view.manager.dataSource); + const result = groupByService?.matcher.match(tType); if (!result) { return; } - return result.data; + return { + config: result, + property, + tType: tType, + }; }); - property$ = computed(() => { - const groupBy = this.groupBy$.value; - if (!groupBy) { + staticInfo$ = computed(() => { + const groupInfo = this.groupInfo$.value; + if (!groupInfo) { return; } - return this.view.propertyGetOrCreate(groupBy.columnId); - }); - - staticGroupDataMap$ = computed< - Record> | undefined - >(() => { - const config = this.config$.value; - const property = this.property$.value; - const tType = property?.dataType$.value; - if (!config || !tType || !property) { - return; - } - return Object.fromEntries( - config.defaultKeys(tType).map(({ key, value }) => [ - key, - { - key, - property, - name: config.groupName(tType, value), - manager: this, - type: tType, - value, - }, - ]) + const staticMap = Object.fromEntries( + groupInfo.config + .defaultKeys(groupInfo.tType) + .map(({ key, value }) => [key, new Group(key, value, groupInfo, this)]) ); + return { + staticMap, + groupInfo, + }; }); - groupDataMap$ = computed | undefined>(() => { - const staticGroupMap = this.staticGroupDataMap$.value; - const config = this.config$.value; - const groupBy = this.groupBy$.value; - const property = this.property$.value; - const tType = property?.dataType$.value; - if (!staticGroupMap || !config || !groupBy || !tType || !property) { + groupDataMap$ = computed(() => { + const staticInfo = this.staticInfo$.value; + if (!staticInfo) { return; } - const groupMap: Record = Object.fromEntries( - Object.entries(staticGroupMap).map(([k, v]) => [k, { ...v, rows: [] }]) - ); + const { staticMap, groupInfo } = staticInfo; + const groupMap: Record = { ...staticMap }; this.view.rows$.value.forEach(row => { - const value = this.view.cellGetOrCreate(row.rowId, groupBy.columnId) + const value = this.view.cellGetOrCreate(row.rowId, groupInfo.property.id) .jsonValue$.value; - const keys = config.valuesGroup(value, tType); + const keys = groupInfo.config.valuesGroup(value, groupInfo.tType); keys.forEach(({ key, value }) => { if (!groupMap[key]) { - groupMap[key] = { - key, - property: property, - name: config.groupName(tType, value), - manager: this, - value, - rows: [], - type: tType, - }; + groupMap[key] = new Group(key, value, groupInfo, this); } groupMap[key].rows.push(row); }); @@ -115,30 +138,30 @@ export class GroupTrait { }); return sortedGroup .map(key => groupMap[key]) - .filter((v): v is GroupData => v != null); + .filter((v): v is Group => v != null); }), this.view.isLocked$ ); updateData = (data: NonNullable) => { - const propertyId = this.propertyId; - if (!propertyId) { + const property = this.property$.value; + if (!property) { return; } - this.view.propertyGetOrCreate(propertyId).dataUpdate(() => data); + this.view.propertyGetOrCreate(property.id).dataUpdate(() => data); }; get addGroup() { - const type = this.property$.value?.type$.value; - if (!type) { - return; - } - return this.view.manager.dataSource.propertyMetaGet(type)?.config.addGroup; + return this.property$.value?.meta$.value?.config.addGroup; } - get propertyId() { - return this.groupBy$.value?.columnId; - } + property$ = computed(() => { + const groupInfo = this.groupInfo$.value; + if (!groupInfo) { + return; + } + return groupInfo.property; + }); constructor( private readonly groupBy$: ReadonlySignal, @@ -158,18 +181,20 @@ export class GroupTrait { addToGroup(rowId: string, key: string) { const groupMap = this.groupDataMap$.value; - const propertyId = this.propertyId; - if (!groupMap || !propertyId) { + const groupInfo = this.groupInfo$.value; + if (!groupMap || !groupInfo) { return; } - const addTo = this.config$.value?.addToGroup ?? (value => value); + const addTo = groupInfo.config.addToGroup ?? (value => value); const v = groupMap[key]?.value; if (v != null) { const newValue = addTo( v, - this.view.cellGetOrCreate(rowId, propertyId).jsonValue$.value + this.view.cellGetOrCreate(rowId, groupInfo.property.id).jsonValue$.value ); - this.view.cellGetOrCreate(rowId, propertyId).valueSet(newValue); + this.view + .cellGetOrCreate(rowId, groupInfo.property.id) + .valueSet(newValue); } } @@ -229,11 +254,12 @@ export class GroupTrait { return; } if (fromGroupKey !== toGroupKey) { - const propertyId = this.propertyId; + const propertyId = this.property$.value?.id; if (!propertyId) { return; } - const remove = this.config$.value?.removeFromGroup ?? (() => null); + const remove = + this.groupInfo$.value?.config.removeFromGroup ?? (() => null); const group = fromGroupKey != null ? groupMap[fromGroupKey] : undefined; let newValue: unknown = null; if (group) { @@ -242,7 +268,8 @@ export class GroupTrait { this.view.cellGetOrCreate(rowId, propertyId).jsonValue$.value ); } - const addTo = this.config$.value?.addToGroup ?? (value => value); + const addTo = + this.groupInfo$.value?.config.addToGroup ?? (value => value); newValue = addTo(groupMap[toGroupKey]?.value ?? null, newValue); this.view.cellGetOrCreate(rowId, propertyId).jsonValueSet(newValue); } @@ -275,11 +302,12 @@ export class GroupTrait { if (!groupMap) { return; } - const propertyId = this.propertyId; + const propertyId = this.property$.value?.id; if (!propertyId) { return; } - const remove = this.config$.value?.removeFromGroup ?? (() => undefined); + const remove = + this.groupInfo$.value?.config.removeFromGroup ?? (() => undefined); const newValue = remove( groupMap[key]?.value ?? null, this.view.cellGetOrCreate(rowId, propertyId).jsonValue$.value @@ -288,7 +316,7 @@ export class GroupTrait { } updateValue(rows: string[], value: unknown) { - const propertyId = this.propertyId; + const propertyId = this.property$.value?.id; if (!propertyId) { return; } diff --git a/blocksuite/affine/data-view/src/core/group-by/types.ts b/blocksuite/affine/data-view/src/core/group-by/types.ts index 3af11396fe..906736793d 100644 --- a/blocksuite/affine/data-view/src/core/group-by/types.ts +++ b/blocksuite/affine/data-view/src/core/group-by/types.ts @@ -1,35 +1,41 @@ import type { UniComponent } from '@blocksuite/affine-shared/types'; -import type { TypeInstance } from '../logical/type.js'; +import type { TypeInstance, ValueTypeOf } from '../logical/type.js'; +import type { Group } from './trait.js'; export interface GroupRenderProps< - Data extends NonNullable = NonNullable, JsonValue = unknown, + Data extends Record = Record, > { - data: Data; - updateData?: (data: Data) => void; - value: JsonValue; - updateValue?: (value: JsonValue) => void; + group: Group; readonly: boolean; } export type GroupByConfig< - JsonValue = unknown, Data extends NonNullable = NonNullable, + MatchType extends TypeInstance = TypeInstance, + GroupValue = unknown, > = { name: string; - groupName: (type: TypeInstance, value: unknown) => string; - defaultKeys: (type: TypeInstance) => { + matchType: MatchType; + groupName: (type: MatchType, value: GroupValue | null) => string; + defaultKeys: (type: MatchType) => { key: string; - value: JsonValue; + value: GroupValue | null; }[]; valuesGroup: ( - value: unknown, - type: TypeInstance + value: ValueTypeOf | null, + type: MatchType ) => { key: string; - value: JsonValue; + value: GroupValue | null; }[]; - addToGroup?: (value: JsonValue, oldValue: JsonValue) => JsonValue; - removeFromGroup?: (value: JsonValue, oldValue: JsonValue) => JsonValue; - view: UniComponent>; + addToGroup?: ( + value: GroupValue | null, + oldValue: ValueTypeOf | null + ) => ValueTypeOf | null; + removeFromGroup?: ( + value: GroupValue | null, + oldValue: ValueTypeOf | null + ) => ValueTypeOf | null; + view: UniComponent>; }; diff --git a/blocksuite/affine/data-view/src/core/logical/matcher.ts b/blocksuite/affine/data-view/src/core/logical/matcher.ts index 5c1db06ba8..e24c7d3f16 100644 --- a/blocksuite/affine/data-view/src/core/logical/matcher.ts +++ b/blocksuite/affine/data-view/src/core/logical/matcher.ts @@ -68,3 +68,46 @@ export class Matcher { return; } } + +export class Matcher_ { + constructor( + private readonly list: Value[], + private readonly getType: (value: Value) => Type, + private readonly matchFunc: ( + type: Type, + target: TypeInstance + ) => boolean = (type, target) => typeSystem.unify(target, type) + ) {} + all(): Value[] { + return this.list; + } + + allMatched(type: TypeInstance): Value[] { + const result: Value[] = []; + for (const t of this.list) { + const tType = this.getType(t); + if (this.matchFunc(tType, type)) { + result.push(t); + } + } + return result; + } + + find(f: (data: Value) => boolean): Value | undefined { + return this.list.find(f); + } + + isMatched(type: Type, target: TypeInstance) { + return this.matchFunc(type, target); + } + + match(type: TypeInstance) { + for (const t of this.list) { + const tType = this.getType(t); + if (this.matchFunc(tType, type)) { + return t; + } + } + return; + } +} diff --git a/blocksuite/affine/data-view/src/core/logical/type-presets.ts b/blocksuite/affine/data-view/src/core/logical/type-presets.ts index 3e3188f212..6b80408d44 100644 --- a/blocksuite/affine/data-view/src/core/logical/type-presets.ts +++ b/blocksuite/affine/data-view/src/core/logical/type-presets.ts @@ -1,3 +1,7 @@ +import type { + UserListService, + UserService, +} from '@blocksuite/affine-shared/services'; import * as zod from 'zod'; import Zod from 'zod'; @@ -12,6 +16,11 @@ export const SelectTagSchema = Zod.object({ color: Zod.string(), value: Zod.string(), }); +export const UserInfoSchema = Zod.object({ + userService: Zod.custom(() => true), + userListService: Zod.custom(() => true), +}); +export type UserInfo = Zod.TypeOf; export const unknown = defineDataType('Unknown', zod.never(), zod.unknown()); export const dt = { number: defineDataType('Number', zod.number(), zod.number()), @@ -22,6 +31,7 @@ export const dt = { url: defineDataType('URL', zod.string(), zod.string()), image: defineDataType('Image', zod.string(), zod.string()), tag: defineDataType('Tag', zod.array(SelectTagSchema), zod.string()), + user: defineDataType('User', UserInfoSchema, zod.string()), }; export const t = { unknown, @@ -53,4 +63,5 @@ export const converts: TypeConvertConfig[] = [ ), createTypeConvert(t.richText.instance(), t.string.instance(), value => value), createTypeConvert(t.url.instance(), t.string.instance(), value => value), + createTypeConvert(t.user.instance(), t.string.instance(), value => value), ]; diff --git a/blocksuite/affine/data-view/src/core/view-manager/single-view.ts b/blocksuite/affine/data-view/src/core/view-manager/single-view.ts index aa464dd8ae..593f1fd5b3 100644 --- a/blocksuite/affine/data-view/src/core/view-manager/single-view.ts +++ b/blocksuite/affine/data-view/src/core/view-manager/single-view.ts @@ -1,7 +1,7 @@ import type { InsertToPosition } from '@blocksuite/affine-shared/utils'; +import type { GeneralServiceIdentifier } from '@blocksuite/global/di'; import { computed, type ReadonlySignal, signal } from '@preact/signals-core'; -import type { DataViewContextKey } from '../data-source/context.js'; import type { Variable } from '../expression/types.js'; import type { PropertyMetaConfig } from '../property/property-config.js'; import type { TraitKey } from '../traits/key.js'; @@ -61,7 +61,8 @@ export interface SingleView { type?: string ): string | undefined; - contextGet(key: DataViewContextKey): T; + serviceGet(key: GeneralServiceIdentifier): T | null; + serviceGetOrCreate(key: GeneralServiceIdentifier, create: () => T): T; traitGet(key: TraitKey): T | undefined; @@ -201,8 +202,12 @@ export abstract class SingleViewBase< return new CellBase(this, propertyId, rowId); } - contextGet(key: DataViewContextKey): T { - return this.dataSource.contextGet(key); + serviceGet(key: GeneralServiceIdentifier): T | null { + return this.dataSource.serviceGet(key); + } + + serviceGetOrCreate(key: GeneralServiceIdentifier, create: () => T): T { + return this.dataSource.serviceGetOrCreate(key, create); } dataUpdate(updater: (viewData: ViewData) => Partial): void { diff --git a/blocksuite/affine/data-view/src/view-presets/kanban/define.ts b/blocksuite/affine/data-view/src/view-presets/kanban/define.ts index d0cb16d624..7a35e93335 100644 --- a/blocksuite/affine/data-view/src/view-presets/kanban/define.ts +++ b/blocksuite/affine/data-view/src/view-presets/kanban/define.ts @@ -2,7 +2,7 @@ import { BlockSuiteError, ErrorCode } from '@blocksuite/global/exceptions'; import type { GroupBy, GroupProperty } from '../../core/common/types.js'; import type { FilterGroup } from '../../core/filter/types.js'; -import { defaultGroupBy, groupByMatcher, t } from '../../core/index.js'; +import { defaultGroupBy, getGroupByService, t } from '../../core/index.js'; import type { Sort } from '../../core/sort/types.js'; import { type BasicViewDataType, viewType } from '../../core/view/data-view.js'; import { KanbanSingleView } from './kanban-view-manager.js'; @@ -34,10 +34,11 @@ export const kanbanViewModel = kanbanViewType.createModel({ defaultName: 'Kanban View', dataViewManager: KanbanSingleView, defaultData: viewManager => { + const groupByService = getGroupByService(viewManager.dataSource); const columns = viewManager.dataSource.properties$.value; const allowList = columns.filter(columnId => { const dataType = viewManager.dataSource.propertyDataTypeGet(columnId); - return dataType && !!groupByMatcher.match(dataType); + return dataType && !!groupByService?.matcher.match(dataType); }); const getWeight = (columnId: string) => { const dataType = viewManager.dataSource.propertyDataTypeGet(columnId); diff --git a/blocksuite/affine/data-view/src/view-presets/kanban/mobile/group.ts b/blocksuite/affine/data-view/src/view-presets/kanban/mobile/group.ts index b6b4f4f6c5..72ce91be99 100644 --- a/blocksuite/affine/data-view/src/view-presets/kanban/mobile/group.ts +++ b/blocksuite/affine/data-view/src/view-presets/kanban/mobile/group.ts @@ -13,7 +13,7 @@ import { html } from 'lit/static-html.js'; import type { DataViewRenderer } from '../../../core/data-view.js'; import { GroupTitle } from '../../../core/group-by/group-title.js'; -import type { GroupData } from '../../../core/group-by/trait.js'; +import type { Group } from '../../../core/group-by/trait.js'; import { dragHandler } from '../../../core/utils/wc-dnd/dnd-context.js'; import type { KanbanSingleView } from '../kanban-view-manager.js'; @@ -137,7 +137,7 @@ export class MobileKanbanGroup extends SignalWatcher( accessor dataViewEle!: DataViewRenderer; @property({ attribute: false }) - accessor group!: GroupData; + accessor group!: Group; @property({ attribute: false }) accessor view!: KanbanSingleView; diff --git a/blocksuite/affine/data-view/src/view-presets/kanban/mobile/menu.ts b/blocksuite/affine/data-view/src/view-presets/kanban/mobile/menu.ts index 7b84d44e13..8bee340055 100644 --- a/blocksuite/affine/data-view/src/view-presets/kanban/mobile/menu.ts +++ b/blocksuite/affine/data-view/src/view-presets/kanban/mobile/menu.ts @@ -55,7 +55,7 @@ export const popCardMenu = ( }) .map(group => { return menu.action({ - name: group.value != null ? group.name : 'Ungroup', + name: group.value != null ? group.name$.value : 'Ungroup', select: () => { groupTrait.moveCardTo( cardId, diff --git a/blocksuite/affine/data-view/src/view-presets/kanban/pc/group.ts b/blocksuite/affine/data-view/src/view-presets/kanban/pc/group.ts index 597dd4b9b8..6024101ec9 100644 --- a/blocksuite/affine/data-view/src/view-presets/kanban/pc/group.ts +++ b/blocksuite/affine/data-view/src/view-presets/kanban/pc/group.ts @@ -13,7 +13,7 @@ import { html } from 'lit/static-html.js'; import type { DataViewRenderer } from '../../../core/data-view.js'; import { GroupTitle } from '../../../core/group-by/group-title.js'; -import type { GroupData } from '../../../core/group-by/trait.js'; +import type { Group } from '../../../core/group-by/trait.js'; import { dragHandler } from '../../../core/utils/wc-dnd/dnd-context.js'; import type { KanbanSingleView } from '../kanban-view-manager.js'; @@ -201,7 +201,7 @@ export class KanbanGroup extends SignalWatcher( accessor dataViewEle!: DataViewRenderer; @property({ attribute: false }) - accessor group!: GroupData; + accessor group!: Group; @property({ attribute: false }) accessor view!: KanbanSingleView; diff --git a/blocksuite/affine/data-view/src/view-presets/kanban/pc/menu.ts b/blocksuite/affine/data-view/src/view-presets/kanban/pc/menu.ts index 743b8e4cbc..dac06f91f2 100644 --- a/blocksuite/affine/data-view/src/view-presets/kanban/pc/menu.ts +++ b/blocksuite/affine/data-view/src/view-presets/kanban/pc/menu.ts @@ -60,7 +60,7 @@ export const popCardMenu = ( }) .map(group => { return menu.action({ - name: group.value != null ? group.name : 'Ungroup', + name: group.value != null ? group.name$.value : 'Ungroup', select: () => { selection.moveCard(rowId, group.key); }, diff --git a/blocksuite/affine/data-view/src/view-presets/table/mobile/group.ts b/blocksuite/affine/data-view/src/view-presets/table/mobile/group.ts index fab4df0830..4b4f9f3a06 100644 --- a/blocksuite/affine/data-view/src/view-presets/table/mobile/group.ts +++ b/blocksuite/affine/data-view/src/view-presets/table/mobile/group.ts @@ -13,7 +13,7 @@ import { repeat } from 'lit/directives/repeat.js'; import type { DataViewRenderer } from '../../../core/data-view.js'; import { GroupTitle } from '../../../core/group-by/group-title.js'; -import type { GroupData } from '../../../core/group-by/trait.js'; +import type { Group } from '../../../core/group-by/trait.js'; import type { Row } from '../../../core/index.js'; import { LEFT_TOOL_BAR_WIDTH } from '../consts.js'; import type { DataViewTable } from '../pc/table-view.js'; @@ -185,7 +185,7 @@ export class MobileTableGroup extends SignalWatcher( accessor dataViewEle!: DataViewRenderer; @property({ attribute: false }) - accessor group: GroupData | undefined = undefined; + accessor group: Group | undefined = undefined; @query('.affine-database-block-rows') accessor rowsContainer: HTMLElement | null = null; diff --git a/blocksuite/affine/data-view/src/view-presets/table/pc-virtual/group/bottom/stats/column-stats-bar.ts b/blocksuite/affine/data-view/src/view-presets/table/pc-virtual/group/bottom/stats/column-stats-bar.ts index bfe1562183..eb361cb4f3 100644 --- a/blocksuite/affine/data-view/src/view-presets/table/pc-virtual/group/bottom/stats/column-stats-bar.ts +++ b/blocksuite/affine/data-view/src/view-presets/table/pc-virtual/group/bottom/stats/column-stats-bar.ts @@ -4,7 +4,7 @@ import { css, html } from 'lit'; import { property } from 'lit/decorators.js'; import { repeat } from 'lit/directives/repeat.js'; -import type { GroupData } from '../../../../../../core/group-by/trait'; +import type { Group } from '../../../../../../core/group-by/trait'; import { LEFT_TOOL_BAR_WIDTH, STATS_BAR_HEIGHT } from '../../../../consts'; import type { TableSingleView } from '../../../../table-view-manager'; @@ -38,7 +38,7 @@ export class VirtualDataBaseColumnStats extends SignalWatcher( } @property({ attribute: false }) - accessor group: GroupData | undefined = undefined; + accessor group: Group | undefined = undefined; @property({ attribute: false }) accessor view!: TableSingleView; diff --git a/blocksuite/affine/data-view/src/view-presets/table/pc-virtual/group/bottom/stats/column-stats-column.ts b/blocksuite/affine/data-view/src/view-presets/table/pc-virtual/group/bottom/stats/column-stats-column.ts index f74faf9de2..0eb1e74dce 100644 --- a/blocksuite/affine/data-view/src/view-presets/table/pc-virtual/group/bottom/stats/column-stats-column.ts +++ b/blocksuite/affine/data-view/src/view-presets/table/pc-virtual/group/bottom/stats/column-stats-column.ts @@ -15,7 +15,7 @@ import { property } from 'lit/decorators.js'; import { styleMap } from 'lit/directives/style-map.js'; import { typeSystem } from '../../../../../../core'; -import type { GroupData } from '../../../../../../core/group-by/trait'; +import type { Group } from '../../../../../../core/group-by/trait'; import { statsFunctions } from '../../../../../../core/statistics'; import type { StatisticsConfig } from '../../../../../../core/statistics/types'; import type { TableProperty } from '../../../../table-view-manager'; @@ -236,7 +236,7 @@ export class VirtualDatabaseColumnStatsCell extends SignalWatcher( } @property({ attribute: false }) - accessor group: GroupData | undefined = undefined; + accessor group: Group | undefined = undefined; } declare global { diff --git a/blocksuite/affine/data-view/src/view-presets/table/pc-virtual/group/top/group-title.ts b/blocksuite/affine/data-view/src/view-presets/table/pc-virtual/group/top/group-title.ts index 0712e9c4f8..3bf8c5ccde 100644 --- a/blocksuite/affine/data-view/src/view-presets/table/pc-virtual/group/top/group-title.ts +++ b/blocksuite/affine/data-view/src/view-presets/table/pc-virtual/group/top/group-title.ts @@ -4,7 +4,7 @@ import { nothing } from 'lit'; import { html } from 'lit/static-html.js'; import { - type GroupData, + type Group, type GroupRenderProps, renderUniLit, } from '../../../../../core'; @@ -18,7 +18,7 @@ import { show, } from './group-title-css'; -function GroupHeaderCount(group: GroupData) { +function GroupHeaderCount(group: Group) { const cards = group.rows; if (!cards.length) { return; @@ -27,7 +27,7 @@ function GroupHeaderCount(group: GroupData) { } export const GroupTitle = ( - groupData: GroupData, + groupData: Group, ops: { groupHover: boolean; readonly: boolean; @@ -35,24 +35,20 @@ export const GroupTitle = ( clickOps: (evt: MouseEvent) => void; } ) => { - const data = groupData.manager.config$.value; - if (!data) return nothing; + const view = groupData.view; + const type = groupData.property.dataType$.value; + if (!view || !type) { + return nothing; + } const icon = groupData.value == null ? '' : html` `; const props: GroupRenderProps = { - value: groupData.value, - data: groupData.property.data$.value, - updateData: groupData.manager.updateData, - updateValue: value => - groupData.manager.updateValue( - groupData.rows.map(row => row.rowId), - value - ), + group: groupData, readonly: ops.readonly, }; @@ -65,7 +61,7 @@ export const GroupTitle = ( const opsClass = clsx(ops.groupHover && show, groupHeaderOps); return html`
- ${icon} ${renderUniLit(data.view, props)} ${columnName} + ${icon} ${renderUniLit(view, props)} ${columnName} ${GroupHeaderCount(groupData)}
${!ops.readonly diff --git a/blocksuite/affine/data-view/src/view-presets/table/pc-virtual/group/top/header/column-move-preview.ts b/blocksuite/affine/data-view/src/view-presets/table/pc-virtual/group/top/header/column-move-preview.ts index 119f2cd9a0..323f0451a7 100644 --- a/blocksuite/affine/data-view/src/view-presets/table/pc-virtual/group/top/header/column-move-preview.ts +++ b/blocksuite/affine/data-view/src/view-presets/table/pc-virtual/group/top/header/column-move-preview.ts @@ -7,7 +7,7 @@ import { repeat } from 'lit/directives/repeat.js'; import { styleMap } from 'lit/directives/style-map.js'; import { html } from 'lit/static-html.js'; -import type { GroupData } from '../../../../../../core/group-by/trait'; +import type { Group } from '../../../../../../core/group-by/trait'; import type { Row } from '../../../../../../core/view-manager/row'; import type { TableProperty, @@ -84,7 +84,7 @@ export class DataViewColumnPreview extends SignalWatcher( accessor container!: HTMLElement; @property({ attribute: false }) - accessor group: GroupData | undefined = undefined; + accessor group: Group | undefined = undefined; } declare global { diff --git a/blocksuite/affine/data-view/src/view-presets/table/pc-virtual/group/top/header/single-column-header.ts b/blocksuite/affine/data-view/src/view-presets/table/pc-virtual/group/top/header/single-column-header.ts index 3d807a75c9..899393b3d7 100644 --- a/blocksuite/affine/data-view/src/view-presets/table/pc-virtual/group/top/header/single-column-header.ts +++ b/blocksuite/affine/data-view/src/view-presets/table/pc-virtual/group/top/header/single-column-header.ts @@ -40,7 +40,10 @@ import { } from '../../../../../../core/utils/wc-dnd/dnd-context'; import type { Property } from '../../../../../../core/view-manager/property'; import { numberFormats } from '../../../../../../property-presets/number/utils/formats'; -import { ShowQuickSettingBarContextKey } from '../../../../../../widget-presets/quick-setting-bar/context'; +import { + createDefaultShowQuickSettingBar, + ShowQuickSettingBarKey, +} from '../../../../../../widget-presets/quick-setting-bar/context'; import { DEFAULT_COLUMN_TITLE_HEIGHT } from '../../../../consts'; import type { TableProperty, @@ -193,7 +196,10 @@ export class DatabaseHeaderColumn extends SignalWatcher( } private _toggleQuickSettingBar(show = true) { - const map = this.tableViewManager.contextGet(ShowQuickSettingBarContextKey); + const map = this.tableViewManager.serviceGetOrCreate( + ShowQuickSettingBarKey, + createDefaultShowQuickSettingBar + ); map.value = { ...map.value, [this.tableViewManager.id]: show, diff --git a/blocksuite/affine/data-view/src/view-presets/table/pc/group.ts b/blocksuite/affine/data-view/src/view-presets/table/pc/group.ts index 6100a313ec..b3ef78737c 100644 --- a/blocksuite/affine/data-view/src/view-presets/table/pc/group.ts +++ b/blocksuite/affine/data-view/src/view-presets/table/pc/group.ts @@ -14,7 +14,7 @@ import { repeat } from 'lit/directives/repeat.js'; import type { DataViewRenderer } from '../../../core/data-view.js'; import { GroupTitle } from '../../../core/group-by/group-title.js'; -import type { GroupData } from '../../../core/group-by/trait.js'; +import type { Group } from '../../../core/group-by/trait.js'; import type { Row } from '../../../core/index.js'; import { createDndContext } from '../../../core/utils/wc-dnd/dnd-context.js'; import { defaultActivators } from '../../../core/utils/wc-dnd/sensors/index.js'; @@ -150,7 +150,7 @@ export class TableGroup extends SignalWatcher( }; @property({ attribute: false }) - accessor group: GroupData | undefined = undefined; + accessor group: Group | undefined = undefined; @property({ attribute: false }) accessor view!: TableSingleView; diff --git a/blocksuite/affine/data-view/src/view-presets/table/pc/header/column-renderer.ts b/blocksuite/affine/data-view/src/view-presets/table/pc/header/column-renderer.ts index 72c05d9544..e591fc3bae 100644 --- a/blocksuite/affine/data-view/src/view-presets/table/pc/header/column-renderer.ts +++ b/blocksuite/affine/data-view/src/view-presets/table/pc/header/column-renderer.ts @@ -7,7 +7,7 @@ import { repeat } from 'lit/directives/repeat.js'; import { styleMap } from 'lit/directives/style-map.js'; import { html } from 'lit/static-html.js'; -import type { GroupData } from '../../../../core/group-by/trait.js'; +import type { Group } from '../../../../core/group-by/trait.js'; import type { Row } from '../../../../core/index.js'; import type { TableProperty, @@ -84,7 +84,7 @@ export class DataViewColumnPreview extends SignalWatcher( accessor container!: HTMLElement; @property({ attribute: false }) - accessor group: GroupData | undefined = undefined; + accessor group: Group | undefined = undefined; } declare global { diff --git a/blocksuite/affine/data-view/src/view-presets/table/pc/header/database-header-column.ts b/blocksuite/affine/data-view/src/view-presets/table/pc/header/database-header-column.ts index ddcee8822d..4db3170518 100644 --- a/blocksuite/affine/data-view/src/view-presets/table/pc/header/database-header-column.ts +++ b/blocksuite/affine/data-view/src/view-presets/table/pc/header/database-header-column.ts @@ -40,7 +40,10 @@ import { } from '../../../../core/utils/wc-dnd/dnd-context.js'; import type { Property } from '../../../../core/view-manager/property.js'; import { numberFormats } from '../../../../property-presets/number/utils/formats.js'; -import { ShowQuickSettingBarContextKey } from '../../../../widget-presets/quick-setting-bar/context.js'; +import { + createDefaultShowQuickSettingBar, + ShowQuickSettingBarKey, +} from '../../../../widget-presets/quick-setting-bar/context.js'; import { DEFAULT_COLUMN_TITLE_HEIGHT } from '../../consts.js'; import type { TableProperty, @@ -193,7 +196,10 @@ export class DatabaseHeaderColumn extends SignalWatcher( } private _toggleQuickSettingBar(show = true) { - const map = this.tableViewManager.contextGet(ShowQuickSettingBarContextKey); + const map = this.tableViewManager.serviceGetOrCreate( + ShowQuickSettingBarKey, + createDefaultShowQuickSettingBar + ); map.value = { ...map.value, [this.tableViewManager.id]: show, diff --git a/blocksuite/affine/data-view/src/view-presets/table/stats/column-stats-bar.ts b/blocksuite/affine/data-view/src/view-presets/table/stats/column-stats-bar.ts index 8fe1f356f9..39ffe76d89 100644 --- a/blocksuite/affine/data-view/src/view-presets/table/stats/column-stats-bar.ts +++ b/blocksuite/affine/data-view/src/view-presets/table/stats/column-stats-bar.ts @@ -4,7 +4,7 @@ import { css, html } from 'lit'; import { property } from 'lit/decorators.js'; import { repeat } from 'lit/directives/repeat.js'; -import type { GroupData } from '../../../core/group-by/trait.js'; +import type { Group } from '../../../core/group-by/trait.js'; import { LEFT_TOOL_BAR_WIDTH, STATS_BAR_HEIGHT } from '../consts.js'; import type { TableSingleView } from '../table-view-manager.js'; @@ -38,7 +38,7 @@ export class DataBaseColumnStats extends SignalWatcher( } @property({ attribute: false }) - accessor group: GroupData | undefined = undefined; + accessor group: Group | undefined = undefined; @property({ attribute: false }) accessor view!: TableSingleView; diff --git a/blocksuite/affine/data-view/src/view-presets/table/stats/column-stats-column.ts b/blocksuite/affine/data-view/src/view-presets/table/stats/column-stats-column.ts index 56e345225b..f39d308466 100644 --- a/blocksuite/affine/data-view/src/view-presets/table/stats/column-stats-column.ts +++ b/blocksuite/affine/data-view/src/view-presets/table/stats/column-stats-column.ts @@ -14,7 +14,7 @@ import { css, html } from 'lit'; import { property } from 'lit/decorators.js'; import { styleMap } from 'lit/directives/style-map.js'; -import type { GroupData } from '../../../core/group-by/trait.js'; +import type { Group } from '../../../core/group-by/trait.js'; import { typeSystem } from '../../../core/index.js'; import { statsFunctions } from '../../../core/statistics/index.js'; import type { StatisticsConfig } from '../../../core/statistics/types.js'; @@ -236,7 +236,7 @@ export class DatabaseColumnStatsCell extends SignalWatcher( } @property({ attribute: false }) - accessor group: GroupData | undefined = undefined; + accessor group: Group | undefined = undefined; } declare global { diff --git a/blocksuite/affine/data-view/src/widget-presets/quick-setting-bar/context.ts b/blocksuite/affine/data-view/src/widget-presets/quick-setting-bar/context.ts index af25383151..c667dc7db8 100644 --- a/blocksuite/affine/data-view/src/widget-presets/quick-setting-bar/context.ts +++ b/blocksuite/affine/data-view/src/widget-presets/quick-setting-bar/context.ts @@ -1,7 +1,10 @@ +import { createIdentifier } from '@blocksuite/global/di'; import { type Signal, signal } from '@preact/signals-core'; -import { createContextKey } from '../../core/index.js'; - -export const ShowQuickSettingBarContextKey = createContextKey< +export const ShowQuickSettingBarKey = createIdentifier< Signal> ->('show-quick-setting-bar', signal({})); +>('show-quick-setting-bar'); + +export const createDefaultShowQuickSettingBar = () => { + return signal>({}); +}; diff --git a/blocksuite/affine/data-view/src/widget-presets/quick-setting-bar/index.ts b/blocksuite/affine/data-view/src/widget-presets/quick-setting-bar/index.ts index 2cde6a1fef..833d222b91 100644 --- a/blocksuite/affine/data-view/src/widget-presets/quick-setting-bar/index.ts +++ b/blocksuite/affine/data-view/src/widget-presets/quick-setting-bar/index.ts @@ -6,7 +6,10 @@ import { type DataViewWidgetProps, defineUniComponent, } from '../../core/index.js'; -import { ShowQuickSettingBarContextKey } from './context.js'; +import { + createDefaultShowQuickSettingBar, + ShowQuickSettingBarKey, +} from './context.js'; import { renderFilterBar } from './filter/index.js'; import { renderSortBar } from './sort/index.js'; @@ -17,7 +20,12 @@ export const widgetQuickSettingBar = defineUniComponent( Boolean ); if (!IS_MOBILE) { - if (!view.contextGet(ShowQuickSettingBarContextKey).value[view.id]) { + if ( + !view.serviceGetOrCreate( + ShowQuickSettingBarKey, + createDefaultShowQuickSettingBar + ).value[view.id] + ) { return html``; } if (!barList.length) { diff --git a/blocksuite/affine/data-view/src/widget-presets/tools/presets/filter/filter.ts b/blocksuite/affine/data-view/src/widget-presets/tools/presets/filter/filter.ts index d61eb616af..cb37be6053 100644 --- a/blocksuite/affine/data-view/src/widget-presets/tools/presets/filter/filter.ts +++ b/blocksuite/affine/data-view/src/widget-presets/tools/presets/filter/filter.ts @@ -11,7 +11,10 @@ import { filterTraitKey } from '../../../../core/filter/trait.js'; import type { FilterGroup } from '../../../../core/filter/types.js'; import { emptyFilterGroup } from '../../../../core/filter/utils.js'; import { WidgetBase } from '../../../../core/widget/widget-base.js'; -import { ShowQuickSettingBarContextKey } from '../../../quick-setting-bar/context.js'; +import { + createDefaultShowQuickSettingBar, + ShowQuickSettingBarKey, +} from '../../../quick-setting-bar/context.js'; const styles = css` .affine-database-filter-button { @@ -100,7 +103,10 @@ export class DataViewHeaderToolsFilter extends WidgetBase { } toggleShowFilter(show?: boolean) { - const map = this.view.contextGet(ShowQuickSettingBarContextKey); + const map = this.view.serviceGetOrCreate( + ShowQuickSettingBarKey, + createDefaultShowQuickSettingBar + ); map.value = { ...map.value, [this.view.id]: show ?? !map.value[this.view.id], diff --git a/blocksuite/affine/data-view/src/widget-presets/tools/presets/sort/sort.ts b/blocksuite/affine/data-view/src/widget-presets/tools/presets/sort/sort.ts index 7d4ca7e82f..58c17a9314 100644 --- a/blocksuite/affine/data-view/src/widget-presets/tools/presets/sort/sort.ts +++ b/blocksuite/affine/data-view/src/widget-presets/tools/presets/sort/sort.ts @@ -10,7 +10,10 @@ import { popCreateSort } from '../../../../core/sort/add-sort.js'; import { sortTraitKey } from '../../../../core/sort/manager.js'; import { createSortUtils } from '../../../../core/sort/utils.js'; import { WidgetBase } from '../../../../core/widget/widget-base.js'; -import { ShowQuickSettingBarContextKey } from '../../../quick-setting-bar/context.js'; +import { + createDefaultShowQuickSettingBar, + ShowQuickSettingBarKey, +} from '../../../quick-setting-bar/context.js'; import { popSortRoot } from '../../../quick-setting-bar/sort/root-panel.js'; const styles = css` @@ -106,7 +109,10 @@ export class DataViewHeaderToolsSort extends WidgetBase { } toggleShowQuickSettingBar(show?: boolean) { - const map = this.view.contextGet(ShowQuickSettingBarContextKey); + const map = this.view.serviceGetOrCreate( + ShowQuickSettingBarKey, + createDefaultShowQuickSettingBar + ); map.value = { ...map.value, [this.view.id]: show ?? !map.value[this.view.id], diff --git a/blocksuite/affine/ext-loader/src/base-provider.ts b/blocksuite/affine/ext-loader/src/base-provider.ts index 6c54cbd9d4..adcc9bd5b5 100644 --- a/blocksuite/affine/ext-loader/src/base-provider.ts +++ b/blocksuite/affine/ext-loader/src/base-provider.ts @@ -17,7 +17,7 @@ export type Context = { /** The scope this context is associated with */ scope: Scope; /** Function to register one or more extensions */ - register(extensions: ExtensionType[] | ExtensionType): void; + register(extensions: ExtensionType[] | ExtensionType): Context; }; /** diff --git a/blocksuite/affine/ext-loader/src/manager.ts b/blocksuite/affine/ext-loader/src/manager.ts index da7857e039..f6b55ef2d4 100644 --- a/blocksuite/affine/ext-loader/src/manager.ts +++ b/blocksuite/affine/ext-loader/src/manager.ts @@ -75,11 +75,14 @@ export class ExtensionManager { /** @internal */ private readonly _getContextByScope = (scope: Scope): Context => { - return { + const context: Context = { scope, - register: (extensions: ExtensionType[] | ExtensionType) => - this._registerToScope(scope, extensions), + register: (extensions: ExtensionType[] | ExtensionType) => { + this._registerToScope(scope, extensions); + return context; + }, }; + return context; }; /** diff --git a/blocksuite/affine/foundation/src/store.ts b/blocksuite/affine/foundation/src/store.ts index c5499721d3..90589767d0 100644 --- a/blocksuite/affine/foundation/src/store.ts +++ b/blocksuite/affine/foundation/src/store.ts @@ -15,7 +15,6 @@ import { HighlightSelectionExtension } from '@blocksuite/affine-shared/selection import { BlockMetaService, FeatureFlagService, - LinkPreviewerService, } from '@blocksuite/affine-shared/services'; import { BlockSelectionExtension, @@ -49,7 +48,6 @@ export class FoundationStoreExtension extends StoreExtensionProvider { FeatureFlagService, BlockMetaService, // TODO(@mirone): maybe merge these services into a file setting service - LinkPreviewerService, ImageProxyService, ]); } diff --git a/blocksuite/affine/foundation/src/view.ts b/blocksuite/affine/foundation/src/view.ts index 55ff5bf477..78c23f544b 100644 --- a/blocksuite/affine/foundation/src/view.ts +++ b/blocksuite/affine/foundation/src/view.ts @@ -1,4 +1,8 @@ import { FileDropExtension } from '@blocksuite/affine-components/drop-indicator'; +import { + PeekViewExtension, + type PeekViewService, +} from '@blocksuite/affine-components/peek'; import { type ViewExtensionContext, ViewExtensionProvider, @@ -12,26 +16,49 @@ import { EditPropsStore, EmbedOptionService, FileSizeLimitService, + FontConfigExtension, + fontConfigSchema, FontLoaderService, + LinkPreviewCache, + LinkPreviewCacheConfigSchema, + LinkPreviewCacheExtension, + LinkPreviewService, PageViewportServiceExtension, + TelemetryExtension, + type TelemetryService, ThemeService, ToolbarRegistryExtension, } from '@blocksuite/affine-shared/services'; import { InteractivityManager, ToolController } from '@blocksuite/std/gfx'; +import { z } from 'zod'; import { clipboardConfigs } from './clipboard'; import { effects } from './effects'; -export class FoundationViewExtension extends ViewExtensionProvider { +const optionsSchema = z.object({ + linkPreviewCacheConfig: z.optional(LinkPreviewCacheConfigSchema), + fontConfig: z.optional(z.array(fontConfigSchema)), + telemetry: z.optional(z.custom()), + peekView: z.optional(z.custom()), +}); + +export type FoundationViewExtensionOptions = z.infer; + +export class FoundationViewExtension extends ViewExtensionProvider { override name = 'foundation'; + override schema = optionsSchema; + override effect() { super.effect(); effects(); } - override setup(context: ViewExtensionContext) { - super.setup(context); + override setup( + context: ViewExtensionContext, + options?: FoundationViewExtensionOptions + ) { + super.setup(context, options); context.register([ DocDisplayMetaService, EditPropsStore, @@ -47,10 +74,28 @@ export class FoundationViewExtension extends ViewExtensionProvider { ToolbarRegistryExtension, AutoClearSelectionService, FileSizeLimitService, + LinkPreviewCache, + LinkPreviewService, ]); context.register(clipboardConfigs); if (this.isEdgeless(context.scope)) { context.register([InteractivityManager, ToolController]); } + const fontConfig = options?.fontConfig; + if (fontConfig) { + context.register(FontConfigExtension(fontConfig)); + } + const linkPreviewCacheConfig = options?.linkPreviewCacheConfig; + if (linkPreviewCacheConfig) { + context.register(LinkPreviewCacheExtension(linkPreviewCacheConfig)); + } + const telemetry = options?.telemetry; + if (telemetry) { + context.register(TelemetryExtension(telemetry)); + } + const peekView = options?.peekView; + if (peekView) { + context.register(PeekViewExtension(peekView)); + } } } 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/inlines/footnote/src/footnote-node/footnote-node.ts b/blocksuite/affine/inlines/footnote/src/footnote-node/footnote-node.ts index 9fb53b1ff8..2d84951469 100644 --- a/blocksuite/affine/inlines/footnote/src/footnote-node/footnote-node.ts +++ b/blocksuite/affine/inlines/footnote/src/footnote-node/footnote-node.ts @@ -150,6 +150,20 @@ export class AffineFootnoteNode extends WithDisposable(ShadowlessElement) { window.open(url, '_blank'); }; + private readonly _updateFootnoteAttributes = (footnote: FootNote) => { + if (!this.footnote || this.readonly) { + return; + } + + if (!this.inlineEditor || !this.selfInlineRange) { + return; + } + + this.inlineEditor.formatText(this.selfInlineRange, { + footnote: footnote, + }); + }; + private readonly _FootNoteDefaultContent = (footnote: FootNote) => { return html``; }; diff --git a/blocksuite/affine/inlines/footnote/src/footnote-node/footnote-popup.ts b/blocksuite/affine/inlines/footnote/src/footnote-node/footnote-popup.ts index 07b7757801..4efa9c937a 100644 --- a/blocksuite/affine/inlines/footnote/src/footnote-node/footnote-popup.ts +++ b/blocksuite/affine/inlines/footnote/src/footnote-node/footnote-popup.ts @@ -7,7 +7,7 @@ import type { FootNote } from '@blocksuite/affine-model'; import { ImageProxyService } from '@blocksuite/affine-shared/adapters'; import { DocDisplayMetaProvider, - LinkPreviewerService, + LinkPreviewServiceIdentifier, ThemeProvider, } from '@blocksuite/affine-shared/services'; import { unsafeCSSVar, unsafeCSSVarV2 } from '@blocksuite/affine-shared/theme'; @@ -121,7 +121,7 @@ export class FootNotePopup extends SignalWatcher(WithDisposable(LitElement)) { if (referenceType === 'url') { const title = this._linkPreview$.value?.title; const url = this.footnote.reference.url; - return [title, url].filter(Boolean).join(' ') || ''; + return [title, url].filter(Boolean).join('\n') || ''; } return this._popupLabel$.value; }); @@ -160,15 +160,29 @@ export class FootNotePopup extends SignalWatcher(WithDisposable(LitElement)) { isTitleAndDescriptionEmpty ) { this._isLoading$.value = true; - this.std.store - .get(LinkPreviewerService) + this.std + .get(LinkPreviewServiceIdentifier) .query(this.footnote.reference.url) .then(data => { + // update the local link preview data this._linkPreview$.value = { favicon: data.icon ?? undefined, title: data.title ?? undefined, description: data.description ?? undefined, }; + + // update the footnote attributes in the node with the link preview data + // to avoid fetching the same data multiple times + const footnote: FootNote = { + ...this.footnote, + reference: { + ...this.footnote.reference, + ...(data.icon && { favicon: data.icon }), + ...(data.title && { title: data.title }), + ...(data.description && { description: data.description }), + }, + }; + this.updateFootnoteAttributes(footnote); }) .catch(console.error) .finally(() => { @@ -209,4 +223,7 @@ export class FootNotePopup extends SignalWatcher(WithDisposable(LitElement)) { @property({ attribute: false }) accessor onPopupClick: FootNotePopupClickHandler | (() => void) = () => {}; + + @property({ attribute: false }) + accessor updateFootnoteAttributes: (footnote: FootNote) => void = () => {}; } 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/affine/model/src/utils/types.ts b/blocksuite/affine/model/src/utils/types.ts index 720021b4e5..a4586c7a8a 100644 --- a/blocksuite/affine/model/src/utils/types.ts +++ b/blocksuite/affine/model/src/utils/types.ts @@ -1,4 +1,5 @@ import type { GfxModel } from '@blocksuite/std/gfx'; +import { z } from 'zod'; import type { BrushElementModel, @@ -20,12 +21,14 @@ export type EmbedCardStyle = | 'pdf' | 'citation'; -export type LinkPreviewData = { - description: string | null; - icon: string | null; - image: string | null; - title: string | null; -}; +export const LinkPreviewDataSchema = z.object({ + description: z.string().nullable(), + icon: z.string().nullable(), + image: z.string().nullable(), + title: z.string().nullable(), +}); + +export type LinkPreviewData = z.infer; export type Connectable = Exclude< GfxModel, diff --git a/blocksuite/affine/shared/package.json b/blocksuite/affine/shared/package.json index 7cb12e66c7..6a636a90c3 100644 --- a/blocksuite/affine/shared/package.json +++ b/blocksuite/affine/shared/package.json @@ -38,6 +38,7 @@ "micromark-extension-gfm-task-list-item": "^2.1.0", "micromark-util-combine-extensions": "^2.0.0", "minimatch": "^10.0.1", + "quick-lru": "^7.0.1", "rehype-parse": "^9.0.0", "rehype-stringify": "^10.0.0", "remark-math": "^6.0.0", diff --git a/blocksuite/affine/shared/src/adapters/middlewares/replace-id.ts b/blocksuite/affine/shared/src/adapters/middlewares/replace-id.ts index 849528d55c..b2648206de 100644 --- a/blocksuite/affine/shared/src/adapters/middlewares/replace-id.ts +++ b/blocksuite/affine/shared/src/adapters/middlewares/replace-id.ts @@ -1,4 +1,4 @@ -import type { +import { DatabaseBlockModel, EmbedLinkedDocModel, EmbedSyncedDocModel, @@ -7,32 +7,50 @@ import type { SurfaceRefBlockModel, } from '@blocksuite/affine-model'; import { BlockSuiteError } from '@blocksuite/global/exceptions'; -import type { DeltaOperation, TransformerMiddleware } from '@blocksuite/store'; +import type { + AfterImportBlockPayload, + BeforeImportBlockPayload, + DeltaOperation, + TransformerMiddleware, +} from '@blocksuite/store'; +import { filter, map } from 'rxjs'; + +import { matchModels } from '../../utils'; export const replaceIdMiddleware = (idGenerator: () => string): TransformerMiddleware => ({ slots, docCRUD }) => { const idMap = new Map(); - slots.afterImport.subscribe(payload => { - if ( - payload.type === 'block' && - payload.snapshot.flavour === 'affine:database' - ) { - const model = payload.model as DatabaseBlockModel; + + // After Import + + const afterImportBlock$ = slots.afterImport.pipe( + filter( + (payload): payload is AfterImportBlockPayload => + payload.type === 'block' + ), + map(({ model }) => model) + ); + + afterImportBlock$ + .pipe(filter(model => matchModels(model, [DatabaseBlockModel]))) + .subscribe(model => { Object.keys(model.props.cells).forEach(cellId => { if (idMap.has(cellId)) { model.props.cells[idMap.get(cellId)!] = model.props.cells[cellId]; delete model.props.cells[cellId]; } }); - } + }); - // replace LinkedPage pageId with new id in paragraph blocks - if ( - payload.type === 'block' && - ['affine:list', 'affine:paragraph'].includes(payload.snapshot.flavour) - ) { - const model = payload.model as ParagraphBlockModel | ListBlockModel; + // replace LinkedPage pageId with new id in paragraph blocks + afterImportBlock$ + .pipe( + filter(model => + matchModels(model, [ParagraphBlockModel, ListBlockModel]) + ) + ) + .subscribe(model => { let prev = 0; const delta: DeltaOperation[] = []; for (const d of model.props.text.toDelta()) { @@ -64,13 +82,11 @@ export const replaceIdMiddleware = if (delta.length > 0) { model.props.text.applyDelta(delta); } - } + }); - if ( - payload.type === 'block' && - payload.snapshot.flavour === 'affine:surface-ref' - ) { - const model = payload.model as SurfaceRefBlockModel; + afterImportBlock$ + .pipe(filter(model => matchModels(model, [SurfaceRefBlockModel]))) + .subscribe(model => { const original = model.props.reference; // If there exists a replacement, replace the reference with the new id. // Otherwise, @@ -86,18 +102,16 @@ export const replaceIdMiddleware = idMap.set(original, newId); model.props.reference = newId; } - } + }); - // TODO(@fundon): process linked block/element - if ( - payload.type === 'block' && - ['affine:embed-linked-doc', 'affine:embed-synced-doc'].includes( - payload.snapshot.flavour + // TODO(@fundon): process linked block/element + afterImportBlock$ + .pipe( + filter(model => + matchModels(model, [EmbedLinkedDocModel, EmbedSyncedDocModel]) ) - ) { - const model = payload.model as - | EmbedLinkedDocModel - | EmbedSyncedDocModel; + ) + .subscribe(model => { const original = model.props.pageId; // If the pageId is not in the doc, generate a new id. // If we already have a replacement, use it. @@ -110,10 +124,13 @@ export const replaceIdMiddleware = model.props.pageId = newId; } } - } - }); - slots.beforeImport.subscribe(payload => { - if (payload.type === 'page') { + }); + + // Before Import + + slots.beforeImport + .pipe(filter(payload => payload.type === 'page')) + .subscribe(payload => { if (idMap.has(payload.snapshot.meta.id)) { payload.snapshot.meta.id = idMap.get(payload.snapshot.meta.id)!; return; @@ -121,10 +138,16 @@ export const replaceIdMiddleware = const newId = idGenerator(); idMap.set(payload.snapshot.meta.id, newId); payload.snapshot.meta.id = newId; - return; - } + }); - if (payload.type === 'block') { + slots.beforeImport + .pipe( + filter( + (payload): payload is BeforeImportBlockPayload => + payload.type === 'block' + ) + ) + .subscribe(payload => { const { snapshot } = payload; if (snapshot.flavour === 'affine:page') { const index = snapshot.children.findIndex( @@ -210,6 +233,5 @@ export const replaceIdMiddleware = } }); } - } - }); + }); }; diff --git a/blocksuite/affine/shared/src/adapters/notion-text.ts b/blocksuite/affine/shared/src/adapters/notion-text.ts index fd11bdc0ea..b407ea55fd 100644 --- a/blocksuite/affine/shared/src/adapters/notion-text.ts +++ b/blocksuite/affine/shared/src/adapters/notion-text.ts @@ -94,28 +94,49 @@ export class NotionTextAdapter extends BaseAdapter { const notionText = JSON.parse(payload.file) as NotionTextSerialized; const content: SliceSnapshot['content'] = []; const deltas: DeltaInsert[] = []; + + // Check if the notionText.editing is an array + if (!Array.isArray(notionText.editing)) { + return null; + } + for (const editing of notionText.editing) { const delta: DeltaInsert = { insert: editing[0], - attributes: Object.create(null), }; - for (const styleElement of editing[1]) { - switch (styleElement[0]) { - case 'b': - delta.attributes!.bold = true; - break; - case 'i': - delta.attributes!.italic = true; - break; - case '_': - delta.attributes!.underline = true; - break; - case 'c': - delta.attributes!.code = true; - break; - case 's': - delta.attributes!.strike = true; - break; + + // Check if the stylesArray of editing[1] is an array + const stylesArray = editing[1]; + if (Array.isArray(stylesArray)) { + for (const styleElement of stylesArray) { + // Skip invalid style entries + if (!styleElement || typeof styleElement[0] !== 'string') { + continue; + } + + // Check if the delta.attributes exists, if not, create a new object + if (!delta.attributes) { + delta.attributes = Object.create(null); + } + + // Add the style to the delta.attributes + switch (styleElement[0]) { + case 'b': + delta.attributes!.bold = true; + break; + case 'i': + delta.attributes!.italic = true; + break; + case '_': + delta.attributes!.underline = true; + break; + case 'c': + delta.attributes!.code = true; + break; + case 's': + delta.attributes!.strike = true; + break; + } } } deltas.push(delta); diff --git a/blocksuite/affine/shared/src/services/feature-flag-service.ts b/blocksuite/affine/shared/src/services/feature-flag-service.ts index b7b0a8f239..77fad1c5ee 100644 --- a/blocksuite/affine/shared/src/services/feature-flag-service.ts +++ b/blocksuite/affine/shared/src/services/feature-flag-service.ts @@ -22,6 +22,7 @@ export interface BlockSuiteFlags { enable_embed_doc_with_alias: boolean; enable_turbo_renderer: boolean; enable_citation: boolean; + enable_link_preview_cache: boolean; } export class FeatureFlagService extends StoreExtension { @@ -48,6 +49,7 @@ export class FeatureFlagService extends StoreExtension { enable_embed_doc_with_alias: false, enable_turbo_renderer: false, enable_citation: false, + enable_link_preview_cache: false, }); setFlag(key: keyof BlockSuiteFlags, value: boolean) { diff --git a/blocksuite/affine/shared/src/services/font-loader/config.ts b/blocksuite/affine/shared/src/services/font-loader/config.ts index 71c0d678f1..dae68a3ad2 100644 --- a/blocksuite/affine/shared/src/services/font-loader/config.ts +++ b/blocksuite/affine/shared/src/services/font-loader/config.ts @@ -1,11 +1,14 @@ import { FontFamily, FontStyle, FontWeight } from '@blocksuite/affine-model'; +import { z } from 'zod'; -export interface FontConfig { - font: string; - weight: string; - url: string; - style: string; -} +export const fontConfigSchema = z.object({ + font: z.string(), + weight: z.string(), + url: z.string(), + style: z.string(), +}); + +export type FontConfig = z.infer; export const AffineCanvasTextFonts: FontConfig[] = [ // Inter, https://fonts.cdnfonts.com/css/inter?styles=29139,29134,29135,29136,29140,29141 diff --git a/blocksuite/affine/shared/src/services/index.ts b/blocksuite/affine/shared/src/services/index.ts index f15160447a..85aa2dc467 100644 --- a/blocksuite/affine/shared/src/services/index.ts +++ b/blocksuite/affine/shared/src/services/index.ts @@ -11,7 +11,7 @@ export * from './feature-flag-service'; export * from './file-size-limit-service'; export * from './font-loader'; export * from './generate-url-service'; -export * from './link-previewer-service'; +export * from './link-preview-service'; export * from './native-clipboard-service'; export * from './notification-service'; export * from './open-doc-config'; diff --git a/blocksuite/affine/shared/src/services/link-preview-service/index.ts b/blocksuite/affine/shared/src/services/link-preview-service/index.ts new file mode 100644 index 0000000000..ec070d8474 --- /dev/null +++ b/blocksuite/affine/shared/src/services/link-preview-service/index.ts @@ -0,0 +1,3 @@ +export * from './link-preview-cache'; +export * from './link-preview-service'; +export * from './link-preview-storage'; diff --git a/blocksuite/affine/shared/src/services/link-preview-service/link-preview-cache.ts b/blocksuite/affine/shared/src/services/link-preview-service/link-preview-cache.ts new file mode 100644 index 0000000000..e7c388212c --- /dev/null +++ b/blocksuite/affine/shared/src/services/link-preview-service/link-preview-cache.ts @@ -0,0 +1,240 @@ +import type { LinkPreviewData } from '@blocksuite/affine-model'; +import { type Container, createIdentifier } from '@blocksuite/global/di'; +import { Extension, type ExtensionType } from '@blocksuite/store'; +import debounce from 'lodash-es/debounce'; +import QuickLRU from 'quick-lru'; +import { z } from 'zod'; + +import { LinkPreviewStorage } from './link-preview-storage'; + +export const LinkPreviewCacheConfigSchema = z.object({ + /** + * The maximum number of items in the cache + */ + cacheSize: z.number(), + /** + * The time to live for the memory cache + */ + memoryTTL: z.number(), + /** + * The time to live for the local storage cache + */ + localStorageTTL: z.number(), +}); + +export type LinkPreviewCacheConfig = z.infer< + typeof LinkPreviewCacheConfigSchema +>; + +const DEFAULT_LINK_PREVIEW_CACHE_CONFIG: LinkPreviewCacheConfig = { + cacheSize: 50, + memoryTTL: 1000 * 60 * 60, // 60 minutes + localStorageTTL: 1000 * 60 * 60 * 6, // 6 hours +}; + +/** + * It's used to debounce the save to local storage to avoid frequent writes + */ +const DEBOUNCE_TIME = 1000; + +/** + * The interface for the link preview cache provider + */ +export interface LinkPreviewCacheProvider { + /** + * Get the link preview data for a given URL + * @param url The URL to get the link preview data for + * @returns The link preview data for the given URL + */ + get(url: string): Partial | undefined; + /** + * Set the link preview data for a given URL + * @param url The URL to set the link preview data for + * @param data The link preview data to set + */ + set(url: string, data: Partial): void; + /** + * Get the pending request for a given URL + * @param url The URL to get the pending request for + * @returns The pending request for the given URL + */ + getPendingRequest(url: string): Promise> | undefined; + /** + * Set the pending request for a given URL + * @param url The URL to set the pending request for + * @param promise The promise to set for the given URL + */ + setPendingRequest( + url: string, + promise: Promise> + ): void; + /** + * Delete the pending request for a given URL + * @param url The URL to delete the pending request for + */ + deletePendingRequest(url: string): void; + /** + * Clear the cache + */ + clear(): void; +} + +export const LinkPreviewCacheIdentifier = + createIdentifier('AffineLinkPreviewCache'); + +/** + * The link preview cache, it will cache the link preview data in the memory and local storage + */ +export class LinkPreviewCache + extends Extension + implements LinkPreviewCacheProvider +{ + /** + * The singleton instance of the link preview cache + */ + private static instance: LinkPreviewCache | null = null; + + /** + * The memory cache for the link preview + */ + private readonly memoryCache: QuickLRU>; + /** + * The pending requests for the link preview + * The promise will be resolved when the data is fetched + */ + private readonly pendingRequests: Map< + string, + Promise> + >; + /** + * The local storage manager for the link preview + */ + private readonly storage: LinkPreviewStorage; + + constructor( + private readonly config: LinkPreviewCacheConfig = DEFAULT_LINK_PREVIEW_CACHE_CONFIG + ) { + super(); + this.storage = new LinkPreviewStorage(); + this.memoryCache = new QuickLRU({ + maxSize: this.config.cacheSize, + maxAge: this.config.memoryTTL, + onEviction: key => { + this._clearItemFromStorage(key); + }, + }); + this.pendingRequests = new Map(); + this._loadFromStorage(); + } + + static getInstance(config?: LinkPreviewCacheConfig): LinkPreviewCache { + if (!LinkPreviewCache.instance) { + LinkPreviewCache.instance = new LinkPreviewCache(config); + } + return LinkPreviewCache.instance; + } + + get(url: string): Partial | undefined { + return this.memoryCache.get(url); + } + + set(url: string, data: Partial): void { + this.memoryCache.set(url, data); + this._saveToStorage(); + } + + getPendingRequest( + url: string + ): Promise> | undefined { + return this.pendingRequests.get(url); + } + + setPendingRequest( + url: string, + promise: Promise> + ): void { + this.pendingRequests.set(url, promise); + } + + deletePendingRequest(url: string): void { + this.pendingRequests.delete(url); + } + + /** + * Load the cache from local storage + */ + private readonly _loadFromStorage = (): void => { + const data = this.storage.load(); + + // Check if the data is expired + const localDataExpires = data.expires; + // If the data is expired, clear the data + if (localDataExpires && localDataExpires < Date.now()) { + this.storage.clear(); + return; + } + + // load the data to the memory cache + Object.entries(data.data).forEach(([url, item]) => { + this.memoryCache.set(url, item); + }); + }; + + /** + * Save the cache to local storage + * Debounce the save to local storage to avoid frequent writes + */ + private readonly _saveToStorage = debounce(() => { + const entries = Array.from(this.memoryCache.entriesDescending()); + const linkPreviewData = Object.fromEntries( + entries.slice(0, this.memoryCache.size).map(([url, data]) => [url, data]) + ); + const data = { + data: linkPreviewData, + expires: Date.now() + this.config.localStorageTTL, + }; + this.storage.save(data); + }, DEBOUNCE_TIME); + + /** + * Clear a link preview record from local storage with specific URL + * Called when the item is evicted from the memory cache + * @param {string} url The URL key to remove from storage + * @returns {boolean} Whether the item was successfully removed + */ + private readonly _clearItemFromStorage = (url: string): void => { + this.storage.clearItem(url); + }; + + clear(): void { + this.memoryCache.clear(); + this.pendingRequests.clear(); + } + + clearLocalStorage(): void { + this.storage.clear(); + } + + static override setup(di: Container) { + di.addImpl(LinkPreviewCacheIdentifier, () => + LinkPreviewCache.getInstance() + ); + } +} + +/** + * The extension for the link preview cache, it will override the link preview cache instance + * @param config - The configuration for the link preview cache + * @returns The extension for the link preview cache + */ +export const LinkPreviewCacheExtension = ( + config?: LinkPreviewCacheConfig +): ExtensionType => { + return { + setup: (di: Container) => { + di.override(LinkPreviewCacheIdentifier, () => + LinkPreviewCache.getInstance(config) + ); + }, + }; +}; diff --git a/blocksuite/affine/shared/src/services/link-preview-service/link-preview-service.ts b/blocksuite/affine/shared/src/services/link-preview-service/link-preview-service.ts new file mode 100644 index 0000000000..a775f91237 --- /dev/null +++ b/blocksuite/affine/shared/src/services/link-preview-service/link-preview-service.ts @@ -0,0 +1,225 @@ +import { type LinkPreviewData } from '@blocksuite/affine-model'; +import { type Container, createIdentifier } from '@blocksuite/global/di'; +import { BlockSuiteError, ErrorCode } from '@blocksuite/global/exceptions'; +import { type BlockStdScope, StdIdentifier } from '@blocksuite/std'; +import { Extension } from '@blocksuite/store'; + +import { DEFAULT_LINK_PREVIEW_ENDPOINT } from '../../consts'; +import { isAbortError } from '../../utils/is-abort-error'; +import { FeatureFlagService } from '../feature-flag-service'; +import { + LinkPreviewCacheIdentifier, + type LinkPreviewCacheProvider, +} from './link-preview-cache'; + +export type LinkPreviewResponseData = { + url: string; + title?: string; + siteName?: string; + description?: string; + images?: string[]; + mediaType?: string; + contentType?: string; + charset?: string; + videos?: string[]; + favicons?: string[]; +}; + +export interface LinkPreviewProvider { + /** + * Query link preview data for a given URL + */ + query: ( + url: string, + signal?: AbortSignal + ) => Promise>; + /** + * Set the endpoint for link preview + */ + setEndpoint: (endpoint: string) => void; + + /** + * Get the endpoint for link preview + */ + endpoint: string; +} + +export const LinkPreviewServiceIdentifier = + createIdentifier('AffineLinkPreviewService'); + +export class LinkPreviewService + extends Extension + implements LinkPreviewProvider +{ + static override setup(di: Container) { + di.addImpl(LinkPreviewServiceIdentifier, LinkPreviewService, [ + StdIdentifier, + LinkPreviewCacheIdentifier, + ]); + } + + private _endpoint: string = DEFAULT_LINK_PREVIEW_ENDPOINT; + + constructor( + private readonly _std: BlockStdScope, + private readonly _cache: LinkPreviewCacheProvider + ) { + super(); + } + + get endpoint() { + return this._endpoint; + } + + setEndpoint = (endpoint: string) => { + this._endpoint = endpoint; + }; + + private readonly _fetchTwitterPreview = async ( + url: string, + signal?: AbortSignal + ): Promise> => { + try { + const match = /\/status\/(\d+)/.exec(url); + if (!match) { + throw new BlockSuiteError( + ErrorCode.DefaultRuntimeError, + `Invalid tweet URL: ${url}` + ); + } + const apiUrl = `https://api.fxtwitter.com/status/${match[1]}`; + + const response = await fetch(apiUrl, { signal }).then(res => res.json()); + const tweet = response?.tweet; + if (!tweet) { + throw new BlockSuiteError( + ErrorCode.DefaultRuntimeError, + `Invalid tweet response: ${url}` + ); + } + + return { + title: tweet.author?.name ?? null, + icon: tweet.author?.avatar_url ?? null, + description: tweet.text ?? null, + image: + tweet.media?.photos?.[0]?.url || tweet.author?.banner_url || null, + }; + } catch (e) { + console.error(`Failed to fetch tweet: ${url}`); + console.error(e); + return {}; + } + }; + + private readonly _fetchStandardPreview = async ( + url: string, + signal?: AbortSignal + ): Promise> => { + const response = await fetch(this.endpoint, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ url }), + signal, + }) + .then(r => { + if (!r || !r.ok) { + throw new BlockSuiteError( + ErrorCode.DefaultRuntimeError, + `Failed to fetch link preview: ${url}` + ); + } + return r; + }) + .catch(err => { + if (isAbortError(err)) return null; + console.error(`Failed to fetch link preview: ${url}`); + console.error(err); + return null; + }); + + if (!response) return {}; + + const data: LinkPreviewResponseData = await response.json(); + return { + title: data.title ?? null, + description: data.description ?? null, + icon: data.favicons?.[0], + image: data.images?.[0], + }; + }; + + private readonly _isTwitterUrl = (url: string): boolean => { + const twitterDomains = [ + 'https://x.com/', + 'https://www.x.com/', + 'https://www.twitter.com/', + 'https://twitter.com/', + ]; + return ( + twitterDomains.some(domain => url.startsWith(domain)) && + url.includes('/status/') + ); + }; + + private readonly _fetchPreview = async ( + url: string, + signal?: AbortSignal + ): Promise> => { + if (this._isTwitterUrl(url)) { + return this._fetchTwitterPreview(url, signal); + } + return this._fetchStandardPreview(url, signal); + }; + + /** + * Fetch link preview data for a given URL + */ + query = async ( + url: string, + signal?: AbortSignal + ): Promise> => { + const featureFlagService = this._std.store.get(FeatureFlagService); + const cacheEnabled = featureFlagService.getFlag( + 'enable_link_preview_cache' + ); + // If the cache is not enabled, fetch the preview directly + if (!cacheEnabled) { + return this._fetchPreview(url, signal); + } + + // Check memory cache, if hit, return the cached data + const cached = this._cache.get(url); + if (cached) { + return cached; + } + + // Check pending requests, if there is a pending request, return the promise + const pendingRequest = this._cache.getPendingRequest(url); + if (pendingRequest) { + return pendingRequest; + } + + // Fetch new data + const promise = (async () => { + try { + // Fetch new data + const data = await this._fetchPreview(url, signal); + // If the data is not empty, set the data to the cache + if (data && Object.keys(data).length > 0) { + this._cache.set(url, data); + } + return data; + } finally { + // Delete the pending request regardless of success or failure + this._cache.deletePendingRequest(url); + } + })(); + + // Set the promise to the cache + this._cache.setPendingRequest(url, promise); + return promise; + }; +} diff --git a/blocksuite/affine/shared/src/services/link-preview-service/link-preview-storage.ts b/blocksuite/affine/shared/src/services/link-preview-service/link-preview-storage.ts new file mode 100644 index 0000000000..3bd0c560f3 --- /dev/null +++ b/blocksuite/affine/shared/src/services/link-preview-service/link-preview-storage.ts @@ -0,0 +1,81 @@ +import { LinkPreviewDataSchema } from '@blocksuite/affine-model'; +import { z } from 'zod'; + +const _StorageSchema = z.object({ + data: z.record(LinkPreviewDataSchema.partial()), + expires: z.number().optional(), +}); + +type StorageData = z.infer; + +/** + * The local storage manager for the link preview cache data + */ +export class LinkPreviewStorage { + /** + * The storage key for the link preview + */ + storageKey = 'blocksuite:link-preview-cache'; + + /** + * Load the cache from local storage + * @returns StorageData + */ + load(): StorageData { + try { + const stored = localStorage.getItem(this.storageKey); + if (stored) { + const parsed = JSON.parse(stored); + const safe = _StorageSchema.safeParse(parsed); + if (safe.success) { + return safe.data; + } + // if the data is invalid, clear the data + this.clear(); + } + } catch (e) { + console.error('Failed to load cache from storage:', e); + } + return { data: {} }; + } + + /** + * Save the cache to local storage + * @param {StorageData} data + */ + save(data: StorageData): void { + try { + const serialized = JSON.stringify(data); + localStorage.setItem(this.storageKey, serialized); + } catch (e) { + console.error('Failed to save cache to storage:', e); + } + } + + /** + * Clear a link preview record from local storage with specific URL + * @param {string} url The URL key to remove from storage + * @returns {boolean} Whether the item was successfully removed + */ + clearItem(url: string): boolean { + try { + const data = this.load(); + if (!(url in data.data)) { + return false; + } + delete data.data[url]; + this.save(data); + return true; + } catch (e) { + console.error('Failed to clear item from storage:', e); + return false; + } + } + + /** + * Clear all records from local storage + */ + clear(): void { + localStorage.removeItem(this.storageKey); + } +} diff --git a/blocksuite/affine/shared/src/services/link-previewer-service.ts b/blocksuite/affine/shared/src/services/link-previewer-service.ts deleted file mode 100644 index bef4fc1dfd..0000000000 --- a/blocksuite/affine/shared/src/services/link-previewer-service.ts +++ /dev/null @@ -1,99 +0,0 @@ -import type { LinkPreviewData } from '@blocksuite/affine-model'; -import { BlockSuiteError, ErrorCode } from '@blocksuite/global/exceptions'; -import { StoreExtension } from '@blocksuite/store'; - -import { DEFAULT_LINK_PREVIEW_ENDPOINT } from '../consts'; -import { isAbortError } from '../utils/is-abort-error'; - -export type LinkPreviewResponseData = { - url: string; - title?: string; - siteName?: string; - description?: string; - images?: string[]; - mediaType?: string; - contentType?: string; - charset?: string; - videos?: string[]; - favicons?: string[]; -}; - -export class LinkPreviewerService extends StoreExtension { - static override key = 'link-previewer'; - - private _endpoint = DEFAULT_LINK_PREVIEW_ENDPOINT; - - query = async ( - url: string, - signal?: AbortSignal - ): Promise> => { - if ( - (url.startsWith('https://x.com/') || - url.startsWith('https://www.x.com/') || - url.startsWith('https://www.twitter.com/') || - url.startsWith('https://twitter.com/')) && - url.includes('/status/') - ) { - // use api.fxtwitter.com - url = - 'https://api.fxtwitter.com/status/' + /\/status\/(.*)/.exec(url)?.[1]; - try { - const { tweet } = await fetch(url, { signal }).then(res => res.json()); - return { - title: tweet.author.name, - icon: tweet.author.avatar_url, - description: tweet.text, - image: tweet.media?.photos?.[0].url || tweet.author.banner_url, - }; - } catch (e) { - console.error(`Failed to fetch tweet: ${url}`); - console.error(e); - return {}; - } - } else { - const response = await fetch(this._endpoint, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ - url, - }), - signal, - }) - .then(r => { - if (!r || !r.ok) { - throw new BlockSuiteError( - ErrorCode.DefaultRuntimeError, - `Failed to fetch link preview: ${url}` - ); - } - return r; - }) - .catch(err => { - if (isAbortError(err)) return null; - console.error(`Failed to fetch link preview: ${url}`); - console.error(err); - return null; - }); - - if (!response) return {}; - - const data: LinkPreviewResponseData = await response.json(); - return { - title: data.title ?? null, - description: data.description ?? null, - icon: data.favicons?.[0], - image: data.images?.[0], - }; - } - }; - - get endpoint() { - return this._endpoint; - } - - setEndpoint = (endpoint: string) => { - this._endpoint = endpoint; - }; -} diff --git a/blocksuite/affine/shared/src/services/telemetry-service/telemetry-service.ts b/blocksuite/affine/shared/src/services/telemetry-service/telemetry-service.ts index 506e8590df..14735727a5 100644 --- a/blocksuite/affine/shared/src/services/telemetry-service/telemetry-service.ts +++ b/blocksuite/affine/shared/src/services/telemetry-service/telemetry-service.ts @@ -1,4 +1,5 @@ import { createIdentifier } from '@blocksuite/global/di'; +import type { ExtensionType } from '@blocksuite/store'; import type { OutDatabaseAllEvents } from './database.js'; import type { LinkToolbarEvents } from './link.js'; @@ -46,3 +47,13 @@ export interface TelemetryService { export const TelemetryProvider = createIdentifier( 'AffineTelemetryService' ); + +export const TelemetryExtension = ( + service: TelemetryService +): ExtensionType => { + return { + setup: di => { + di.override(TelemetryProvider, () => service); + }, + }; +}; 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/blocksuite/framework/store/src/transformer/middleware.ts b/blocksuite/framework/store/src/transformer/middleware.ts index 7a288ec687..5fbf6006c9 100644 --- a/blocksuite/framework/store/src/transformer/middleware.ts +++ b/blocksuite/framework/store/src/transformer/middleware.ts @@ -11,13 +11,15 @@ import type { SliceSnapshot, } from './type.js'; +export type BeforeImportBlockPayload = { + snapshot: BlockSnapshot; + type: 'block'; + parent?: string; + index?: number; +}; + export type BeforeImportPayload = - | { - snapshot: BlockSnapshot; - type: 'block'; - parent?: string; - index?: number; - } + | BeforeImportBlockPayload | { snapshot: SliceSnapshot; type: 'slice'; @@ -71,14 +73,16 @@ export type AfterExportPayload = type: 'info'; }; +export type AfterImportBlockPayload = { + snapshot: BlockSnapshot; + type: 'block'; + model: BlockModel; + parent?: string; + index?: number; +}; + export type AfterImportPayload = - | { - snapshot: BlockSnapshot; - type: 'block'; - model: BlockModel; - parent?: string; - index?: number; - } + | AfterImportBlockPayload | { snapshot: DocSnapshot; type: 'page'; diff --git a/packages/backend/server/migrations/20250512031140_add_optional_models/migration.sql b/packages/backend/server/migrations/20250512031140_add_optional_models/migration.sql new file mode 100644 index 0000000000..39e96d2208 --- /dev/null +++ b/packages/backend/server/migrations/20250512031140_add_optional_models/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "ai_prompts_metadata" ADD COLUMN "optional_models" VARCHAR[] DEFAULT ARRAY[]::VARCHAR[]; diff --git a/packages/backend/server/schema.prisma b/packages/backend/server/schema.prisma index 650bec0e6a..80560c7bab 100644 --- a/packages/backend/server/schema.prisma +++ b/packages/backend/server/schema.prisma @@ -397,6 +397,7 @@ model AiPrompt { // it is only used in the frontend and does not affect the backend action String? @db.VarChar model String @db.VarChar + optionalModels String[] @default([]) @db.VarChar @map("optional_models") config Json? @db.Json createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3) updatedAt DateTime @default(now()) @map("updated_at") @db.Timestamptz(3) diff --git a/packages/backend/server/src/plugins/copilot/controller.ts b/packages/backend/server/src/plugins/copilot/controller.ts index 8b31427f06..b11762a2d3 100644 --- a/packages/backend/server/src/plugins/copilot/controller.ts +++ b/packages/backend/server/src/plugins/copilot/controller.ts @@ -62,7 +62,7 @@ export interface ChatEvent { } type CheckResult = { - model: string | undefined; + model: string; hasAttachment?: boolean; }; @@ -94,7 +94,8 @@ export class CopilotController implements BeforeApplicationShutdown { private async checkRequest( userId: string, sessionId: string, - messageId?: string + messageId?: string, + modelId?: string ): Promise { await this.chatSession.checkQuota(userId); const session = await this.chatSession.get(sessionId); @@ -102,7 +103,13 @@ export class CopilotController implements BeforeApplicationShutdown { throw new CopilotSessionNotFound(); } - const ret: CheckResult = { model: session.model }; + const ret: CheckResult = { + model: session.model, + }; + + if (modelId && session.optionalModels.includes(modelId)) { + ret.model = modelId; + } if (messageId && typeof messageId === 'string') { const message = await session.getMessageById(messageId); @@ -116,13 +123,16 @@ export class CopilotController implements BeforeApplicationShutdown { private async chooseTextProvider( userId: string, sessionId: string, - messageId?: string - ): Promise { + messageId?: string, + modelId?: string + ): Promise<{ provider: CopilotTextProvider; model: string }> { const { hasAttachment, model } = await this.checkRequest( userId, sessionId, - messageId + messageId, + modelId ); + let provider = await this.provider.getProviderByCapability( CopilotCapability.TextToText, { model } @@ -138,7 +148,7 @@ export class CopilotController implements BeforeApplicationShutdown { throw new NoCopilotProviderAvailable(); } - return provider; + return { provider, model }; } private async appendSessionMessage( @@ -182,13 +192,17 @@ export class CopilotController implements BeforeApplicationShutdown { const webSearch = Array.isArray(params.webSearch) ? Boolean(params.webSearch[0]) : Boolean(params.webSearch); + const modelId = Array.isArray(params.modelId) + ? params.modelId[0] + : params.modelId; delete params.messageId; delete params.retry; delete params.reasoning; delete params.webSearch; + delete params.modelId; - return { messageId, retry, reasoning, webSearch, params }; + return { messageId, retry, reasoning, webSearch, modelId, params }; } private getSignal(req: Request) { @@ -236,13 +250,14 @@ export class CopilotController implements BeforeApplicationShutdown { const info: any = { sessionId, params }; try { - const { messageId, retry, reasoning, webSearch } = + const { messageId, retry, reasoning, webSearch, modelId } = this.prepareParams(params); - const provider = await this.chooseTextProvider( + const { provider, model } = await this.chooseTextProvider( user.id, sessionId, - messageId + messageId, + modelId ); const [latestMessage, session] = await this.appendSessionMessage( @@ -251,8 +266,8 @@ export class CopilotController implements BeforeApplicationShutdown { retry ); - info.model = session.model; - metrics.ai.counter('chat_calls').add(1, { model: session.model }); + info.model = model; + metrics.ai.counter('chat_calls').add(1, { model }); if (latestMessage) { params = Object.assign({}, params, latestMessage.params, { @@ -264,7 +279,7 @@ export class CopilotController implements BeforeApplicationShutdown { const finalMessage = session.finish(params); info.finalMessage = finalMessage.filter(m => m.role !== 'system'); - const content = await provider.generateText(finalMessage, session.model, { + const content = await provider.generateText(finalMessage, model, { ...session.config.promptConfig, signal: this.getSignal(req), user: user.id, @@ -302,13 +317,14 @@ export class CopilotController implements BeforeApplicationShutdown { const info: any = { sessionId, params, throwInStream: false }; try { - const { messageId, retry, reasoning, webSearch } = + const { messageId, retry, reasoning, webSearch, modelId } = this.prepareParams(params); - const provider = await this.chooseTextProvider( + const { provider, model } = await this.chooseTextProvider( user.id, sessionId, - messageId + messageId, + modelId ); const [latestMessage, session] = await this.appendSessionMessage( @@ -317,8 +333,8 @@ export class CopilotController implements BeforeApplicationShutdown { retry ); - info.model = session.model; - metrics.ai.counter('chat_stream_calls').add(1, { model: session.model }); + info.model = model; + metrics.ai.counter('chat_stream_calls').add(1, { model }); if (latestMessage) { params = Object.assign({}, params, latestMessage.params, { @@ -332,7 +348,7 @@ export class CopilotController implements BeforeApplicationShutdown { info.finalMessage = finalMessage.filter(m => m.role !== 'system'); const source$ = from( - provider.generateTextStream(finalMessage, session.model, { + provider.generateTextStream(finalMessage, model, { ...session.config.promptConfig, signal: this.getSignal(req), user: user.id, diff --git a/packages/backend/server/src/plugins/copilot/prompt/chat-prompt.ts b/packages/backend/server/src/plugins/copilot/prompt/chat-prompt.ts index 233791571a..86d9fcae13 100644 --- a/packages/backend/server/src/plugins/copilot/prompt/chat-prompt.ts +++ b/packages/backend/server/src/plugins/copilot/prompt/chat-prompt.ts @@ -41,6 +41,7 @@ export class ChatPrompt { options.name, options.action || undefined, options.model, + options.optionalModels, options.config, options.messages ); @@ -50,6 +51,7 @@ export class ChatPrompt { public readonly name: string, public readonly action: string | undefined, public readonly model: string, + public readonly optionalModels: string[], public readonly config: PromptConfig | undefined, private readonly messages: PromptMessage[] ) { diff --git a/packages/backend/server/src/plugins/copilot/prompt/prompts.ts b/packages/backend/server/src/plugins/copilot/prompt/prompts.ts index 6c29859500..017187e1bd 100644 --- a/packages/backend/server/src/plugins/copilot/prompt/prompts.ts +++ b/packages/backend/server/src/plugins/copilot/prompt/prompts.ts @@ -5,8 +5,15 @@ import { PromptConfig, PromptMessage } from '../providers'; type Prompt = Omit< AiPrompt, - 'id' | 'createdAt' | 'updatedAt' | 'modified' | 'action' | 'config' + | 'id' + | 'createdAt' + | 'updatedAt' + | 'modified' + | 'action' + | 'config' + | 'optionalModels' > & { + optionalModels?: string[]; action?: string; messages: PromptMessage[]; config?: PromptConfig; @@ -1037,7 +1044,13 @@ Finally, please only send us the content of your continuation in Markdown Format const chat: Prompt[] = [ { name: 'Chat With AFFiNE AI', - model: 'o4-mini', + model: 'gpt-4.1', + optionalModels: [ + 'o3', + 'o4-mini', + 'claude-3-7-sonnet-20250219', + 'claude-3-5-sonnet-20241022', + ], messages: [ { role: 'system', @@ -1161,14 +1174,15 @@ export async function refreshPrompts(db: PrismaClient) { create: { name: prompt.name, action: prompt.action, - config: prompt.config || undefined, + config: prompt.config ?? undefined, model: prompt.model, + optionalModels: prompt.optionalModels, messages: { create: prompt.messages.map((message, idx) => ({ idx, role: message.role, content: message.content, - params: message.params || undefined, + params: message.params ?? undefined, })), }, }, @@ -1177,6 +1191,7 @@ export async function refreshPrompts(db: PrismaClient) { action: prompt.action, config: prompt.config ?? undefined, model: prompt.model, + optionalModels: prompt.optionalModels, updatedAt: new Date(), messages: { deleteMany: {}, @@ -1184,7 +1199,7 @@ export async function refreshPrompts(db: PrismaClient) { idx, role: message.role, content: message.content, - params: message.params || undefined, + params: message.params ?? undefined, })), }, }, diff --git a/packages/backend/server/src/plugins/copilot/prompt/service.ts b/packages/backend/server/src/plugins/copilot/prompt/service.ts index 52d21f6f30..c4d19bf5ac 100644 --- a/packages/backend/server/src/plugins/copilot/prompt/service.ts +++ b/packages/backend/server/src/plugins/copilot/prompt/service.ts @@ -64,6 +64,7 @@ export class PromptService implements OnApplicationBootstrap { name: true, action: true, model: true, + optionalModels: true, config: true, messages: { select: { diff --git a/packages/backend/server/src/plugins/copilot/providers/anthropic.ts b/packages/backend/server/src/plugins/copilot/providers/anthropic.ts index e7cbd748d5..2c89de5ae5 100644 --- a/packages/backend/server/src/plugins/copilot/providers/anthropic.ts +++ b/packages/backend/server/src/plugins/copilot/providers/anthropic.ts @@ -34,7 +34,10 @@ export class AnthropicProvider { override readonly type = CopilotProviderType.Anthropic; override readonly capabilities = [CopilotCapability.TextToText]; - override readonly models = ['claude-3-7-sonnet-20250219']; + override readonly models = [ + 'claude-3-7-sonnet-20250219', + 'claude-3-5-sonnet-20241022', + ]; private readonly MAX_STEPS = 20; diff --git a/packages/backend/server/src/plugins/copilot/providers/openai.ts b/packages/backend/server/src/plugins/copilot/providers/openai.ts index f9edfc5f54..9bcf2a377b 100644 --- a/packages/backend/server/src/plugins/copilot/providers/openai.ts +++ b/packages/backend/server/src/plugins/copilot/providers/openai.ts @@ -74,6 +74,7 @@ export class OpenAIProvider 'gpt-4.1-2025-04-14', 'gpt-4.1-mini', 'o1', + 'o3', 'o4-mini', // embeddings 'text-embedding-3-large', diff --git a/packages/backend/server/src/plugins/copilot/providers/types.ts b/packages/backend/server/src/plugins/copilot/providers/types.ts index f83609a448..d975d30739 100644 --- a/packages/backend/server/src/plugins/copilot/providers/types.ts +++ b/packages/backend/server/src/plugins/copilot/providers/types.ts @@ -110,12 +110,12 @@ export type CopilotImageOptions = z.infer; export interface CopilotTextToTextProvider extends CopilotProvider { generateText( messages: PromptMessage[], - model?: string, + model: string, options?: CopilotChatOptions ): Promise; generateTextStream( messages: PromptMessage[], - model?: string, + model: string, options?: CopilotChatOptions ): AsyncIterable; } @@ -136,7 +136,7 @@ export interface CopilotTextToImageProvider extends CopilotProvider { ): Promise>; generateImagesStream( messages: PromptMessage[], - model?: string, + model: string, options?: CopilotImageOptions ): AsyncIterable; } @@ -145,12 +145,12 @@ export interface CopilotImageToTextProvider extends CopilotProvider { generateText( messages: PromptMessage[], model: string, - options?: CopilotChatOptions + options: CopilotChatOptions ): Promise; generateTextStream( messages: PromptMessage[], model: string, - options?: CopilotChatOptions + options: CopilotChatOptions ): AsyncIterable; } @@ -162,7 +162,7 @@ export interface CopilotImageToImageProvider extends CopilotProvider { ): Promise>; generateImagesStream( messages: PromptMessage[], - model?: string, + model: string, options?: CopilotImageOptions ): AsyncIterable; } diff --git a/packages/backend/server/src/plugins/copilot/session.ts b/packages/backend/server/src/plugins/copilot/session.ts index ca71a41b12..5ddd448463 100644 --- a/packages/backend/server/src/plugins/copilot/session.ts +++ b/packages/backend/server/src/plugins/copilot/session.ts @@ -45,6 +45,10 @@ export class ChatSession implements AsyncDisposable { return this.state.prompt.model; } + get optionalModels() { + return this.state.prompt.optionalModels; + } + get config() { const { sessionId, diff --git a/packages/common/env/src/filter.ts b/packages/common/env/src/filter.ts index c92ff6cb9d..a8cca4f1f9 100644 --- a/packages/common/env/src/filter.ts +++ b/packages/common/env/src/filter.ts @@ -55,23 +55,6 @@ export const collectionSchema = z.object({ createDate: z.union([z.date(), z.number()]).optional(), updateDate: z.union([z.date(), z.number()]).optional(), }); -export const deletedCollectionSchema = z.object({ - userId: z.string().optional(), - userName: z.string(), - collection: collectionSchema, -}); -export type DeprecatedCollection = { - id: string; - name: string; - workspaceId: string; - filterList: z.infer[]; - allowList?: string[]; -}; export type Collection = z.input; -export type DeleteCollectionInfo = { - userId: string; - userName: string; -} | null; -export type DeletedCollection = z.input; export type PropertiesMeta = DocsPropertiesMeta; diff --git a/packages/common/graphql/src/graphql/get-is-admin.gql b/packages/common/graphql/src/graphql/get-is-admin.gql deleted file mode 100644 index 8e9199ab9b..0000000000 --- a/packages/common/graphql/src/graphql/get-is-admin.gql +++ /dev/null @@ -1,3 +0,0 @@ -query getIsAdmin($workspaceId: String!) { - isAdmin(workspaceId: $workspaceId) -} diff --git a/packages/common/graphql/src/graphql/get-is-owner.gql b/packages/common/graphql/src/graphql/get-is-owner.gql deleted file mode 100644 index f9de28c683..0000000000 --- a/packages/common/graphql/src/graphql/get-is-owner.gql +++ /dev/null @@ -1,3 +0,0 @@ -query getIsOwner($workspaceId: String!) { - isOwner(workspaceId: $workspaceId) -} diff --git a/packages/common/graphql/src/graphql/get-workspace-info.gql b/packages/common/graphql/src/graphql/get-workspace-info.gql index 3ebd59a253..29bfe9d269 100644 --- a/packages/common/graphql/src/graphql/get-workspace-info.gql +++ b/packages/common/graphql/src/graphql/get-workspace-info.gql @@ -1,7 +1,6 @@ query getWorkspaceInfo($workspaceId: String!) { - isAdmin(workspaceId: $workspaceId) - isOwner(workspaceId: $workspaceId) workspace(id: $workspaceId) { + role team } } diff --git a/packages/common/graphql/src/graphql/index.ts b/packages/common/graphql/src/graphql/index.ts index c23ad056af..ff7c06e7b7 100644 --- a/packages/common/graphql/src/graphql/index.ts +++ b/packages/common/graphql/src/graphql/index.ts @@ -1036,24 +1036,6 @@ export const getInviteInfoQuery = { }`, }; -export const getIsAdminQuery = { - id: 'getIsAdminQuery' as const, - op: 'getIsAdmin', - query: `query getIsAdmin($workspaceId: String!) { - isAdmin(workspaceId: $workspaceId) -}`, - deprecations: ["'isAdmin' is deprecated: use WorkspaceType[role] instead"], -}; - -export const getIsOwnerQuery = { - id: 'getIsOwnerQuery' as const, - op: 'getIsOwner', - query: `query getIsOwner($workspaceId: String!) { - isOwner(workspaceId: $workspaceId) -}`, - deprecations: ["'isOwner' is deprecated: use WorkspaceType[role] instead"], -}; - export const getMemberCountByWorkspaceIdQuery = { id: 'getMemberCountByWorkspaceIdQuery' as const, op: 'getMemberCountByWorkspaceId', @@ -1185,13 +1167,11 @@ export const getWorkspaceInfoQuery = { id: 'getWorkspaceInfoQuery' as const, op: 'getWorkspaceInfo', query: `query getWorkspaceInfo($workspaceId: String!) { - isAdmin(workspaceId: $workspaceId) - isOwner(workspaceId: $workspaceId) workspace(id: $workspaceId) { + role team } }`, - deprecations: ["'isAdmin' is deprecated: use WorkspaceType[role] instead","'isOwner' is deprecated: use WorkspaceType[role] instead"], }; export const getWorkspacePageByIdQuery = { diff --git a/packages/common/graphql/src/schema.ts b/packages/common/graphql/src/schema.ts index 8444ef8a73..97395c613c 100644 --- a/packages/common/graphql/src/schema.ts +++ b/packages/common/graphql/src/schema.ts @@ -3665,18 +3665,6 @@ export type GetInviteInfoQuery = { }; }; -export type GetIsAdminQueryVariables = Exact<{ - workspaceId: Scalars['String']['input']; -}>; - -export type GetIsAdminQuery = { __typename?: 'Query'; isAdmin: boolean }; - -export type GetIsOwnerQueryVariables = Exact<{ - workspaceId: Scalars['String']['input']; -}>; - -export type GetIsOwnerQuery = { __typename?: 'Query'; isOwner: boolean }; - export type GetMemberCountByWorkspaceIdQueryVariables = Exact<{ workspaceId: Scalars['String']['input']; }>; @@ -3829,9 +3817,7 @@ export type GetWorkspaceInfoQueryVariables = Exact<{ export type GetWorkspaceInfoQuery = { __typename?: 'Query'; - isAdmin: boolean; - isOwner: boolean; - workspace: { __typename?: 'WorkspaceType'; team: boolean }; + workspace: { __typename?: 'WorkspaceType'; role: Permission; team: boolean }; }; export type GetWorkspacePageByIdQueryVariables = Exact<{ @@ -4825,16 +4811,6 @@ export type Queries = variables: GetInviteInfoQueryVariables; response: GetInviteInfoQuery; } - | { - name: 'getIsAdminQuery'; - variables: GetIsAdminQueryVariables; - response: GetIsAdminQuery; - } - | { - name: 'getIsOwnerQuery'; - variables: GetIsOwnerQueryVariables; - response: GetIsOwnerQuery; - } | { name: 'getMemberCountByWorkspaceIdQuery'; variables: GetMemberCountByWorkspaceIdQueryVariables; diff --git a/packages/common/infra/src/utils/yjs-observable.ts b/packages/common/infra/src/utils/yjs-observable.ts index 3863eee36e..f446a89e43 100644 --- a/packages/common/infra/src/utils/yjs-observable.ts +++ b/packages/common/infra/src/utils/yjs-observable.ts @@ -182,7 +182,7 @@ export function yjsObservePath(yjs?: any, path?: string) { * observable will automatically update when yjs data changed. * * @example - * yjsObserveDeep(yjs) -> emit when any of children changed + * yjsObserve(yjs) -> emit when yjs type changed */ export function yjsObserve(yjs?: any) { return new Observable(subscriber => { diff --git a/packages/frontend/admin/src/modules/accounts/components/data-table-row-actions.tsx b/packages/frontend/admin/src/modules/accounts/components/data-table-row-actions.tsx index a71c13bd25..e6ae064e86 100644 --- a/packages/frontend/admin/src/modules/accounts/components/data-table-row-actions.tsx +++ b/packages/frontend/admin/src/modules/accounts/components/data-table-row-actions.tsx @@ -170,24 +170,27 @@ export function DataTableRowActions({ user }: DataTableRowActionsProps) { - - Reset Password - - Edit + + Edit + + + + {user.hasPassword ? 'Reset Password' : 'Setup Account'} {user.disabled && ( - Enable Email + + Enable Email )} @@ -196,14 +199,16 @@ export function DataTableRowActions({ user }: DataTableRowActionsProps) { className="px-2 py-[6px] text-sm font-normal gap-2 text-red-500 cursor-pointer focus:text-red-500" onSelect={openDisableDialog} > - Disable & Delete data + + Disable & Delete data )} - Delete + + Delete diff --git a/packages/frontend/admin/src/modules/accounts/components/data-table-toolbar.tsx b/packages/frontend/admin/src/modules/accounts/components/data-table-toolbar.tsx index 82a9b2d0aa..fb49b6e6c2 100644 --- a/packages/frontend/admin/src/modules/accounts/components/data-table-toolbar.tsx +++ b/packages/frontend/admin/src/modules/accounts/components/data-table-toolbar.tsx @@ -17,7 +17,7 @@ import { useRightPanel } from '../../panel/context'; import type { UserType } from '../schema'; import { DiscardChanges } from './discard-changes'; import { ExportUsersDialog } from './export-users-dialog'; -import { ImportUsersDialog } from './import-users-dialog'; +import { ImportUsersDialog } from './import-users'; import { CreateUserForm } from './user-form'; interface DataTableToolbarProps { diff --git a/packages/frontend/admin/src/modules/accounts/components/import-users-dialog.tsx b/packages/frontend/admin/src/modules/accounts/components/import-users-dialog.tsx deleted file mode 100644 index 97e6c818cb..0000000000 --- a/packages/frontend/admin/src/modules/accounts/components/import-users-dialog.tsx +++ /dev/null @@ -1,314 +0,0 @@ -import { Button } from '@affine/admin/components/ui/button'; -import { - Dialog, - DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, - DialogTitle, -} from '@affine/admin/components/ui/dialog'; -import { useAsyncCallback } from '@affine/core/components/hooks/affine-async-hooks'; -import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; -import { toast } from 'sonner'; - -import { - downloadCsvTemplate, - exportImportResults, - getValidUsersToImport, - ImportStatus, - type ParsedUser, - processCSVFile, -} from '../utils/csv-utils'; -import { FileUploadArea, type FileUploadAreaRef } from './file-upload-area'; -import { - useImportUsers, - type UserImportReturnType, -} from './use-user-management'; -import { UserTable } from './user-table'; - -interface ImportUsersDialogProps { - open: boolean; - onOpenChange: (open: boolean) => void; -} - -export function ImportUsersDialog({ - open, - onOpenChange, -}: ImportUsersDialogProps) { - const [isImporting, setIsImporting] = useState(false); - const [parsedUsers, setParsedUsers] = useState([]); - const [isPreviewMode, setIsPreviewMode] = useState(false); - const [isFormatError, setIsFormatError] = useState(false); - const importUsers = useImportUsers(); - const fileUploadRef = useRef(null); - - const handleUpload = useCallback( - () => fileUploadRef.current?.triggerFileUpload(), - [] - ); - - // Reset all states when dialog is closed - useEffect(() => { - if (open) { - setIsPreviewMode(false); - setParsedUsers([]); - setIsImporting(false); - setIsFormatError(false); - } - }, [open]); - - const importUsersCallback = useCallback( - (result: UserImportReturnType) => { - const successfulUsers = result.filter( - (user): user is Extract => - user.__typename === 'UserType' - ); - - const failedUsers = result.filter( - ( - user - ): user is Extract< - typeof user, - { __typename: 'UserImportFailedType' } - > => user.__typename === 'UserImportFailedType' - ); - - const successCount = successfulUsers.length; - const failedCount = parsedUsers.length - successCount; - - if (failedCount > 0) { - toast.info( - `Successfully imported ${successCount} users, ${failedCount} failed` - ); - } else { - toast.success(`Successfully imported ${successCount} users`); - } - - const successfulUserEmails = new Set( - successfulUsers.map(user => user.email) - ); - - const failedUserErrorMap = new Map( - failedUsers.map(user => [user.email, user.error]) - ); - - setParsedUsers(prev => { - return prev.map(user => { - if (successfulUserEmails.has(user.email)) { - return { - ...user, - importStatus: ImportStatus.Success, - }; - } - - const errorMessage = failedUserErrorMap.get(user.email) || user.error; - return { - ...user, - importStatus: ImportStatus.Failed, - importError: errorMessage, - }; - }); - }); - - setIsImporting(false); - }, - [parsedUsers.length, setIsImporting] - ); - - const handleFileSelected = useCallback(async (file: File) => { - setIsImporting(true); - try { - await processCSVFile( - file, - validatedUsers => { - setParsedUsers(validatedUsers); - setIsPreviewMode(true); - setIsImporting(false); - }, - () => { - setIsImporting(false); - setIsFormatError(true); - } - ); - } catch (error) { - console.error('Failed to process file', error); - setIsImporting(false); - setIsFormatError(true); - } - }, []); - - const confirmImport = useAsyncCallback(async () => { - setIsImporting(true); - try { - const validUsersToImport = getValidUsersToImport(parsedUsers); - - setParsedUsers(prev => - prev.map(user => - user.valid ? { ...user, importStatus: ImportStatus.Processing } : user - ) - ); - - await importUsers({ users: validUsersToImport }, importUsersCallback); - // Note: setIsImporting(false) is now handled in importUsersCallback - } catch (error) { - console.error('Failed to import users', error); - toast.error('Failed to import users'); - setIsImporting(false); - } - }, [importUsers, importUsersCallback, parsedUsers]); - - const cancelImport = useCallback(() => { - setIsPreviewMode(false); - setParsedUsers([]); - }, []); - - const resetFormatError = useCallback(() => { - setIsFormatError(false); - }, []); - - // Handle closing the dialog after import is complete - const handleDone = useCallback(() => { - // Reset all states and close the dialog - setIsPreviewMode(false); - setParsedUsers([]); - setIsImporting(false); - setIsFormatError(false); - onOpenChange(false); - }, [onOpenChange]); - - // Export failed imports to CSV - const exportResult = useCallback(() => { - exportImportResults(parsedUsers); - }, [parsedUsers]); - - const isImported = useMemo(() => { - return parsedUsers.some( - user => user.importStatus && user.importStatus !== ImportStatus.Processing - ); - }, [parsedUsers]); - - const handleConfirm = useCallback(() => { - if (isImported) { - exportResult(); - handleDone(); - } else { - confirmImport(); - } - }, [confirmImport, exportResult, handleDone, isImported]); - - return ( - - - - - {isFormatError - ? 'Incorrect import format' - : isPreviewMode - ? isImported - ? 'Import results' - : 'Confirm import' - : 'Import'} - - - - {isFormatError ? ( - 'You need to import the accounts by importing a CSV file in the correct format. Please download the CSV template.' - ) : isPreviewMode ? ( -
- {isImported ? null : ( -

- {parsedUsers.length} users detected from the CSV file. Please - confirm the user list below and import. -

- )} - -
- ) : ( -
-

- You need to import the accounts by importing a CSV file in the - correct format. Please download the CSV template. -

- -
- )} -
- - - {isFormatError ? ( - <> -
- CSV template -
- - - ) : isPreviewMode ? ( - <> - - - - ) : ( - <> -
- CSV template -
- - - )} -
-
-
- ); -} diff --git a/packages/frontend/admin/src/modules/accounts/components/import-users/csv-format-guidance.tsx b/packages/frontend/admin/src/modules/accounts/components/import-users/csv-format-guidance.tsx new file mode 100644 index 0000000000..63e620d905 --- /dev/null +++ b/packages/frontend/admin/src/modules/accounts/components/import-users/csv-format-guidance.tsx @@ -0,0 +1,51 @@ +import { WarningIcon } from '@blocksuite/icons/rc'; +import { cssVar } from '@toeverything/theme'; +import { cssVarV2 } from '@toeverything/theme/v2'; +import type { FC } from 'react'; + +interface CsvFormatGuidanceProps { + passwordLimits: { + minLength: number; + maxLength: number; + }; +} + +/** + * Component that displays CSV format guidelines + */ +export const CsvFormatGuidance: FC = ({ + passwordLimits, +}) => { + return ( +
+
+ +
+
+

CSV file includes username, email, and password.

+
    + {[ + `Username (optional): any text.`, + `Email (required): e.g., user@example.com.`, + `Password (optional): ${passwordLimits.minLength}–${passwordLimits.maxLength} characters.`, + ].map((text, index) => ( +
  • + + {text} +
  • + ))} +
+
+
+ ); +}; diff --git a/packages/frontend/admin/src/modules/accounts/components/file-upload-area.tsx b/packages/frontend/admin/src/modules/accounts/components/import-users/file-upload-area.tsx similarity index 100% rename from packages/frontend/admin/src/modules/accounts/components/file-upload-area.tsx rename to packages/frontend/admin/src/modules/accounts/components/import-users/file-upload-area.tsx diff --git a/packages/frontend/admin/src/modules/accounts/components/import-users/import-content.tsx b/packages/frontend/admin/src/modules/accounts/components/import-users/import-content.tsx new file mode 100644 index 0000000000..455cf38763 --- /dev/null +++ b/packages/frontend/admin/src/modules/accounts/components/import-users/import-content.tsx @@ -0,0 +1,73 @@ +import type { FC, RefObject } from 'react'; + +import type { ParsedUser } from '../../utils/csv-utils'; +import { UserTable } from '../user-table'; +import { CsvFormatGuidance } from './csv-format-guidance'; +import { FileUploadArea, type FileUploadAreaRef } from './file-upload-area'; + +interface ImportPreviewContentProps { + parsedUsers: ParsedUser[]; + isImported: boolean; +} + +/** + * Component for the preview mode content + */ +export const ImportPreviewContent: FC = ({ + parsedUsers, + isImported, +}) => { + return ( +
+ {!isImported && ( +

+ {parsedUsers.length} users detected from the CSV file. Please confirm + the user list below and import. +

+ )} + +
+ ); +}; + +interface ImportInitialContentProps { + passwordLimits: { + minLength: number; + maxLength: number; + }; + fileUploadRef: RefObject; + onFileSelected: (file: File) => Promise; +} + +/** + * Component for the initial import screen + */ +export const ImportInitialContent: FC = ({ + passwordLimits, + fileUploadRef, + onFileSelected, +}) => { + return ( +
+

+ You need to import the accounts by importing a CSV file in the correct + format. Please download the CSV template. +

+ + +
+ ); +}; + +interface ImportErrorContentProps { + message?: string; +} + +/** + * Component for displaying import errors + */ +export const ImportErrorContent: FC = ({ + message = 'You need to import the accounts by importing a CSV file in the correct format. Please download the CSV template.', +}) => { + return message; +}; diff --git a/packages/frontend/admin/src/modules/accounts/components/import-users/import-footer.tsx b/packages/frontend/admin/src/modules/accounts/components/import-users/import-footer.tsx new file mode 100644 index 0000000000..ff8101d1a0 --- /dev/null +++ b/packages/frontend/admin/src/modules/accounts/components/import-users/import-footer.tsx @@ -0,0 +1,168 @@ +import { Button } from '@affine/admin/components/ui/button'; +import { DialogFooter } from '@affine/admin/components/ui/dialog'; +import type { FC } from 'react'; + +import { downloadCsvTemplate, ImportStatus } from '../../utils/csv-utils'; + +interface ImportUsersFooterProps { + isFormatError: boolean; + isPreviewMode: boolean; + isImporting: boolean; + isImported: boolean; + resetFormatError: () => void; + cancelImport: () => void; + handleConfirm: () => void; + handleUpload: () => void; + parsedUsers: { + importStatus?: ImportStatus; + }[]; +} + +/** + * Component for the dialog footer with appropriate buttons based on the import state + */ +export const ImportUsersFooter: FC = ({ + isFormatError, + isPreviewMode, + isImporting, + isImported, + resetFormatError, + cancelImport, + handleConfirm, + handleUpload, + parsedUsers, +}) => { + return ( + + {isFormatError ? ( + + ) : isPreviewMode ? ( + + ) : ( + + )} + + ); +}; + +interface FormatErrorFooterProps { + downloadCsvTemplate: () => void; + resetFormatError: () => void; +} + +/** + * Footer component when there's a format error + */ +const FormatErrorFooter: FC = ({ + downloadCsvTemplate, + resetFormatError, +}) => ( + <> +
+ CSV template +
+ + +); + +interface PreviewModeFooterProps { + isImporting: boolean; + isImported: boolean; + cancelImport: () => void; + handleConfirm: () => void; + parsedUsers: { + importStatus?: ImportStatus; + }[]; +} + +/** + * Footer component for preview mode + */ +const PreviewModeFooter: FC = ({ + isImporting, + isImported, + cancelImport, + handleConfirm, + parsedUsers, +}) => ( + <> + + + +); + +interface InitialFooterProps { + isImporting: boolean; + downloadCsvTemplate: () => void; + handleUpload: () => void; +} + +/** + * Footer component for initial state + */ +const InitialFooter: FC = ({ + isImporting, + downloadCsvTemplate, + handleUpload, +}) => ( + <> +
+ CSV template +
+ + +); diff --git a/packages/frontend/admin/src/modules/accounts/components/import-users/index.tsx b/packages/frontend/admin/src/modules/accounts/components/import-users/index.tsx new file mode 100644 index 0000000000..f63302b347 --- /dev/null +++ b/packages/frontend/admin/src/modules/accounts/components/import-users/index.tsx @@ -0,0 +1,109 @@ +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, +} from '@affine/admin/components/ui/dialog'; +import { useEffect, useRef } from 'react'; + +import { useServerConfig } from '../../../common'; +import type { FileUploadAreaRef } from './file-upload-area'; +import { + ImportErrorContent, + ImportInitialContent, + ImportPreviewContent, +} from './import-content'; +import { ImportUsersFooter } from './import-footer'; +import { useImportUsersState } from './use-import-users-state'; + +export interface ImportUsersDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; +} + +/** + * Dialog for importing users from a CSV file + */ +export function ImportUsersDialog({ + open, + onOpenChange, +}: ImportUsersDialogProps) { + const fileUploadRef = useRef(null); + const serverConfig = useServerConfig(); + const passwordLimits = serverConfig.credentialsRequirement.password; + + const handleUpload = () => fileUploadRef.current?.triggerFileUpload(); + + const { + isImporting, + parsedUsers, + isPreviewMode, + isFormatError, + isImported, + handleFileSelected, + cancelImport, + resetFormatError, + handleConfirm, + resetState, + } = useImportUsersState({ + passwordLimits, + onClose: () => onOpenChange(false), + }); + + // Reset all states when dialog is opened + useEffect(() => { + if (open) { + resetState(); + } + }, [open, resetState]); + + return ( + + + + + {isFormatError + ? 'Incorrect import format' + : isPreviewMode + ? isImported + ? 'Import results' + : 'Confirm import' + : 'Import'} + + +
+ {isFormatError ? ( + + ) : isPreviewMode ? ( + + ) : ( + + )} +
+ + +
+
+ ); +} diff --git a/packages/frontend/admin/src/modules/accounts/components/import-users/use-import-users-state.ts b/packages/frontend/admin/src/modules/accounts/components/import-users/use-import-users-state.ts new file mode 100644 index 0000000000..1a224dbb9c --- /dev/null +++ b/packages/frontend/admin/src/modules/accounts/components/import-users/use-import-users-state.ts @@ -0,0 +1,193 @@ +import { useAsyncCallback } from '@affine/core/components/hooks/affine-async-hooks'; +import { useCallback, useState } from 'react'; +import { toast } from 'sonner'; + +import { + exportImportResults, + getValidUsersToImport, + ImportStatus, + type ParsedUser, + processCSVFile, + validateUsers, +} from '../../utils/csv-utils'; +import { + useImportUsers, + type UserImportReturnType, +} from '../use-user-management'; + +export interface ImportUsersStateProps { + passwordLimits: { + minLength: number; + maxLength: number; + }; + onClose: () => void; +} + +export function useImportUsersState({ + passwordLimits, + onClose, +}: ImportUsersStateProps) { + const [isImporting, setIsImporting] = useState(false); + const [parsedUsers, setParsedUsers] = useState([]); + const [isPreviewMode, setIsPreviewMode] = useState(false); + const [isFormatError, setIsFormatError] = useState(false); + const importUsers = useImportUsers(); + + // Reset all states when dialog is closed + const resetState = useCallback(() => { + setIsPreviewMode(false); + setParsedUsers([]); + setIsImporting(false); + setIsFormatError(false); + }, []); + + const importUsersCallback = useCallback( + (result: UserImportReturnType) => { + const successfulUsers = result.filter( + (user): user is Extract => + user.__typename === 'UserType' + ); + + const failedUsers = result.filter( + ( + user + ): user is Extract< + typeof user, + { __typename: 'UserImportFailedType' } + > => user.__typename === 'UserImportFailedType' + ); + + const successCount = successfulUsers.length; + const failedCount = parsedUsers.length - successCount; + + if (failedCount > 0) { + toast.info( + `Successfully imported ${successCount} users, ${failedCount} failed` + ); + } else { + toast.success(`Successfully imported ${successCount} users`); + } + + const successfulUserEmails = new Set( + successfulUsers.map(user => user.email) + ); + + const failedUserErrorMap = new Map( + failedUsers.map(user => [user.email, user.error]) + ); + + setParsedUsers(prev => { + return prev.map(user => { + if (successfulUserEmails.has(user.email)) { + return { + ...user, + importStatus: ImportStatus.Success, + }; + } + + const errorMessage = failedUserErrorMap.get(user.email) || user.error; + return { + ...user, + importStatus: ImportStatus.Failed, + importError: errorMessage, + }; + }); + }); + + setIsImporting(false); + }, + [parsedUsers.length] + ); + + const handleFileSelected = useCallback( + async (file: File) => { + setIsImporting(true); + try { + await processCSVFile( + file, + usersFromCsv => { + const validatedUsers = validateUsers(usersFromCsv, passwordLimits); + setParsedUsers(validatedUsers); + setIsPreviewMode(true); + setIsImporting(false); + }, + () => { + setIsImporting(false); + setIsFormatError(true); + } + ); + } catch (error) { + console.error('Failed to process file', error); + setIsImporting(false); + setIsFormatError(true); + } + }, + [passwordLimits] + ); + + const confirmImport = useAsyncCallback(async () => { + setIsImporting(true); + try { + const validUsersToImport = getValidUsersToImport(parsedUsers); + + setParsedUsers(prev => + prev.map(user => ({ + ...user, + importStatus: ImportStatus.Processing, + })) + ); + + await importUsers({ users: validUsersToImport }, importUsersCallback); + } catch (error) { + console.error('Failed to import users', error); + toast.error('Failed to import users'); + setIsImporting(false); + } + }, [importUsers, importUsersCallback, parsedUsers]); + + const cancelImport = useCallback(() => { + setIsPreviewMode(false); + setParsedUsers([]); + }, []); + + const resetFormatError = useCallback(() => { + setIsFormatError(false); + }, []); + + // Handle closing the dialog after import is complete + const handleDone = useCallback(() => { + resetState(); + onClose(); + }, [onClose, resetState]); + + // Export failed imports to CSV + const exportResult = useCallback(() => { + exportImportResults(parsedUsers); + }, [parsedUsers]); + + const isImported = !!parsedUsers.some( + user => user.importStatus && user.importStatus !== ImportStatus.Processing + ); + + const handleConfirm = useCallback(() => { + if (isImported) { + exportResult(); + handleDone(); + } else { + confirmImport(); + } + }, [confirmImport, exportResult, handleDone, isImported]); + + return { + isImporting, + parsedUsers, + isPreviewMode, + isFormatError, + isImported, + handleFileSelected, + cancelImport, + resetFormatError, + handleConfirm, + resetState, + }; +} diff --git a/packages/frontend/admin/src/modules/accounts/components/use-user-management.ts b/packages/frontend/admin/src/modules/accounts/components/use-user-management.ts index fddf2e00fc..15726d8528 100644 --- a/packages/frontend/admin/src/modules/accounts/components/use-user-management.ts +++ b/packages/frontend/admin/src/modules/accounts/components/use-user-management.ts @@ -47,12 +47,13 @@ export const useCreateUser = () => { const revalidate = useMutateQueryResource(); const create = useAsyncCallback( - async ({ name, email, features }: UserInput) => { + async ({ name, email, password, features }: UserInput) => { try { const account = await createAccount({ input: { name, email, + password: password === '' ? undefined : password, }, }); diff --git a/packages/frontend/admin/src/modules/accounts/components/user-form.tsx b/packages/frontend/admin/src/modules/accounts/components/user-form.tsx index f6d7f36995..5b0aa079e1 100644 --- a/packages/frontend/admin/src/modules/accounts/components/user-form.tsx +++ b/packages/frontend/admin/src/modules/accounts/components/user-form.tsx @@ -4,13 +4,16 @@ import { Label } from '@affine/admin/components/ui/label'; import { Separator } from '@affine/admin/components/ui/separator'; import { Switch } from '@affine/admin/components/ui/switch'; import type { FeatureType } from '@affine/graphql'; +import { cssVarV2 } from '@toeverything/theme/v2'; import { ChevronRightIcon } from 'lucide-react'; import type { ChangeEvent } from 'react'; import { useCallback, useEffect, useMemo, useState } from 'react'; +import { toast } from 'sonner'; import { useServerConfig } from '../../common'; import { RightPanelHeader } from '../../header'; import type { UserInput, UserType } from '../schema'; +import { validateEmails, validatePassword } from '../utils/csv-utils'; import { useCreateUser, useUpdateUser } from './use-user-management'; type UserFormProps = { @@ -20,6 +23,7 @@ type UserFormProps = { onConfirm: (user: UserInput) => void; onValidate: (user: Partial) => boolean; actions?: React.ReactNode; + showOption?: boolean; }; function UserForm({ @@ -29,6 +33,7 @@ function UserForm({ onConfirm, onValidate, actions, + showOption, }: UserFormProps) { const serverConfig = useServerConfig(); @@ -36,9 +41,10 @@ function UserForm({ () => ({ name: defaultValue?.name ?? '', email: defaultValue?.email ?? '', + password: defaultValue?.password ?? '', features: defaultValue?.features ?? [], }), - [defaultValue?.email, defaultValue?.features, defaultValue?.name] + [defaultValue] ); const [changes, setChanges] = useState>(defaultUser); @@ -68,7 +74,8 @@ function UserForm({ // @ts-expect-error checked onConfirm(changes); - }, [canSave, changes, onConfirm]); + setChanges(defaultUser); + }, [canSave, changes, defaultUser, onConfirm]); const onFeatureChanged = useCallback( (feature: FeatureType, checked: boolean) => { @@ -116,6 +123,19 @@ function UserForm({ onChange={setField} placeholder="Enter email address" /> + {showOption && ( + <> + + + + )}
@@ -167,12 +187,14 @@ function ToggleItem({ function InputItem({ label, field, + optional, value, onChange, placeholder, }: { label: string; field: keyof UserInput; + optional?: boolean; value?: string; onChange: (field: keyof UserInput, value: string) => void; placeholder?: string; @@ -185,9 +207,20 @@ function InputItem({ ); return ( -
-