From ace5d44a61ad6d6a5fe2359103422222922fe57a Mon Sep 17 00:00:00 2001 From: doouding Date: Wed, 26 Mar 2025 07:32:43 +0000 Subject: [PATCH] refactor: rewrite dblclick and selection logic of default-tool (#11036) continue #10824 ### Changed - Moved double-click-to-edit behavior from the default tool to individual model views - Introduced `onSelected` callback interface in gfx view components to allows developers to override default selection logic --- .../src/edgeless-text-block.ts | 72 +++++- .../src/common/insert-embed-card.ts | 9 +- .../blocks/block-frame/src/frame-block.ts | 17 ++ .../block-note/src/note-edgeless-block.ts | 71 +++++- .../src/edgeless/edgeless-builtin-spec.ts | 8 +- .../src/edgeless/edgeless-keyboard.ts | 2 + .../src/edgeless/edgeless-root-spec.ts | 14 +- .../dblclick-add-edgeless-text.ts | 47 ++++ .../src/edgeless/gfx-tool/default-tool.ts | 224 +----------------- .../src/edgeless/gfx-tool/pan-tool.ts | 2 + .../text/edgeless-connector-label-editor.ts | 89 +++++-- .../affine/gfx/connector/src/text/text.ts | 1 - .../affine/gfx/connector/src/view/view.ts | 22 ++ blocksuite/affine/gfx/group/src/index.ts | 1 + .../src/text/edgeless-group-title-editor.ts | 48 +++- blocksuite/affine/gfx/group/src/text/text.ts | 1 - blocksuite/affine/gfx/group/src/view.ts | 24 ++ blocksuite/affine/gfx/shape/src/index.ts | 1 + .../src/text/edgeless-shape-text-editor.ts | 77 ++++-- blocksuite/affine/gfx/shape/src/text/index.ts | 2 +- blocksuite/affine/gfx/shape/src/text/text.ts | 52 ---- .../affine/gfx/shape/src/toolbar/config.ts | 2 +- blocksuite/affine/gfx/shape/src/view.ts | 24 ++ .../gfx/text/src/edgeless-text-editor.ts | 71 +++++- blocksuite/affine/gfx/text/src/index.ts | 2 +- blocksuite/affine/gfx/text/src/view.ts | 28 +++ .../widget-frame-title/src/frame-title.ts | 28 +++ .../gfx/classes/GfxSelectionManager.md | 18 ++ .../extension/canvas-event-handler.ts | 20 +- .../element-transform/transform-manager.ts | 62 ++++- .../gfx/element-transform/view-transform.ts | 36 ++- .../framework/block-std/src/gfx/index.ts | 1 + .../framework/block-std/src/gfx/selection.ts | 17 +- .../block-std/src/gfx/tool/tool-controller.ts | 1 + .../framework/block-std/src/gfx/view/view.ts | 15 ++ .../src/view/element/gfx-block-component.ts | 20 +- .../src/editors/editor-container.ts | 2 +- .../e2e/edgeless/note/drag-handle.spec.ts | 1 + 38 files changed, 779 insertions(+), 353 deletions(-) create mode 100644 blocksuite/affine/blocks/block-root/src/edgeless/element-transform/dblclick-add-edgeless-text.ts create mode 100644 blocksuite/affine/gfx/group/src/view.ts delete mode 100644 blocksuite/affine/gfx/shape/src/text/text.ts create mode 100644 blocksuite/affine/gfx/shape/src/view.ts create mode 100644 blocksuite/affine/gfx/text/src/view.ts diff --git a/blocksuite/affine/blocks/block-edgeless-text/src/edgeless-text-block.ts b/blocksuite/affine/blocks/block-edgeless-text/src/edgeless-text-block.ts index aa7c0f48f..9782606a8 100644 --- a/blocksuite/affine/blocks/block-edgeless-text/src/edgeless-text-block.ts +++ b/blocksuite/affine/blocks/block-edgeless-text/src/edgeless-text-block.ts @@ -5,15 +5,20 @@ import { ListBlockModel, ParagraphBlockModel, } from '@blocksuite/affine-model'; +import { focusTextModel } from '@blocksuite/affine-rich-text'; import { ThemeProvider } from '@blocksuite/affine-shared/services'; -import { matchModels } from '@blocksuite/affine-shared/utils'; +import { + handleNativeRangeAtPoint, + matchModels, +} from '@blocksuite/affine-shared/utils'; import type { BlockComponent } from '@blocksuite/block-std'; import { BlockSelection, GfxBlockComponent, TextSelection, } from '@blocksuite/block-std'; -import { Bound } from '@blocksuite/global/gfx'; +import type { SelectedContext } from '@blocksuite/block-std/gfx'; +import { Bound, clamp } from '@blocksuite/global/gfx'; import { css, html } from 'lit'; import { query, state } from 'lit/decorators.js'; import { type StyleInfo, styleMap } from 'lit/directives/style-map.js'; @@ -255,6 +260,69 @@ export class EdgelessTextBlockComponent extends GfxBlockComponent { + if (!this.isConnected) { + return; + } + + if (this.model.children.length === 0) { + const blockId = this.doc.addBlock( + 'affine:paragraph', + { type: 'text' }, + this.model.id + ); + + if (blockId) { + focusTextModel(this.std, blockId); + } + } else { + const rect = this.querySelector( + '.affine-block-children-container' + )?.getBoundingClientRect(); + + if (rect) { + const offsetY = 8 * this.gfx.viewport.zoom; + const offsetX = 2 * this.gfx.viewport.zoom; + const x = clamp( + e.clientX, + rect.left + offsetX, + rect.right - offsetX + ); + const y = clamp( + e.clientY, + rect.top + offsetY, + rect.bottom - offsetY + ); + handleNativeRangeAtPoint(x, y); + } else { + handleNativeRangeAtPoint(e.clientX, e.clientY); + } + } + }) + .catch(console.error); + } else { + super.onSelected(context); + } + } + override renderGfxBlock() { const { model } = this; const { rotate, hasMaxWidth } = model.props; diff --git a/blocksuite/affine/blocks/block-embed/src/common/insert-embed-card.ts b/blocksuite/affine/blocks/block-embed/src/common/insert-embed-card.ts index 080fad80b..6b7aa7d16 100644 --- a/blocksuite/affine/blocks/block-embed/src/common/insert-embed-card.ts +++ b/blocksuite/affine/blocks/block-embed/src/common/insert-embed-card.ts @@ -82,16 +82,15 @@ export function insertEmbedCard( surfaceBlock.model ); + gfx.tool.setTool( + // @ts-expect-error FIXME: resolve after gfx tool refactor + 'default' + ); gfx.selection.set({ elements: [cardId], editing: false, }); - gfx.tool.setTool( - // @ts-expect-error FIXME: resolve after gfx tool refactor - 'default' - ); - return cardId; } } diff --git a/blocksuite/affine/blocks/block-frame/src/frame-block.ts b/blocksuite/affine/blocks/block-frame/src/frame-block.ts index 169353257..f05222db4 100644 --- a/blocksuite/affine/blocks/block-frame/src/frame-block.ts +++ b/blocksuite/affine/blocks/block-frame/src/frame-block.ts @@ -1,6 +1,7 @@ import { DefaultTheme, type FrameBlockModel } from '@blocksuite/affine-model'; import { ThemeProvider } from '@blocksuite/affine-shared/services'; import { GfxBlockComponent } from '@blocksuite/block-std'; +import type { SelectedContext } from '@blocksuite/block-std/gfx'; import { Bound } from '@blocksuite/global/gfx'; import { cssVarV2 } from '@toeverything/theme/v2'; import { html } from 'lit'; @@ -52,6 +53,22 @@ export class FrameBlockComponent extends GfxBlockComponent { }; } + override onSelected(context: SelectedContext): void { + const { x, y } = context.position; + + if ( + !context.fallback && + // if the frame is selected by title, then ignore it because the title selection is handled by the title widget + (this.model.externalBound?.containsPoint([x, y]) || + // otherwise if the frame has title, then ignore it because in this case the frame cannot be selected by frame body + this.model.props.title.length) + ) { + return; + } + + super.onSelected(context); + } + override renderGfxBlock() { const { model, showBorder, std } = this; const backgroundColor = std diff --git a/blocksuite/affine/blocks/block-note/src/note-edgeless-block.ts b/blocksuite/affine/blocks/block-note/src/note-edgeless-block.ts index a130902de..9fd2d4657 100644 --- a/blocksuite/affine/blocks/block-note/src/note-edgeless-block.ts +++ b/blocksuite/affine/blocks/block-note/src/note-edgeless-block.ts @@ -1,16 +1,22 @@ import { EdgelessLegacySlotIdentifier } from '@blocksuite/affine-block-surface'; import type { DocTitle } from '@blocksuite/affine-fragment-doc-title'; import { NoteDisplayMode } from '@blocksuite/affine-model'; +import { focusTextModel } from '@blocksuite/affine-rich-text'; import { EDGELESS_BLOCK_CHILD_PADDING } from '@blocksuite/affine-shared/consts'; import { TelemetryProvider } from '@blocksuite/affine-shared/services'; -import { stopPropagation } from '@blocksuite/affine-shared/utils'; +import { + handleNativeRangeAtPoint, + stopPropagation, +} from '@blocksuite/affine-shared/utils'; import { toGfxBlockComponent } from '@blocksuite/block-std'; +import type { SelectedContext } from '@blocksuite/block-std/gfx'; import { Bound } from '@blocksuite/global/gfx'; import { html, nothing, type PropertyValues } from 'lit'; import { query, state } from 'lit/decorators.js'; import { classMap } from 'lit/directives/class-map.js'; import { ifDefined } from 'lit/directives/if-defined.js'; import { styleMap } from 'lit/directives/style-map.js'; +import clamp from 'lodash-es/clamp'; import { MoreIndicator } from './components/more-indicator'; import { NoteConfigExtension } from './config'; @@ -296,6 +302,69 @@ export class EdgelessNoteBlockComponent extends toGfxBlockComponent( `; } + override onSelected(context: SelectedContext) { + const { selected, multiSelect, event: e } = context; + const { editing } = this.gfx.selection; + const alreadySelected = this.gfx.selection.has(this.model.id); + + if (!multiSelect && selected && (alreadySelected || editing)) { + if (this.model.isLocked()) return; + + if (alreadySelected && editing) { + return; + } + + this.gfx.selection.set({ + elements: [this.model.id], + editing: true, + }); + + this.updateComplete + .then(() => { + if (!this.isConnected) { + return; + } + + if (this.model.children.length === 0) { + const blockId = this.doc.addBlock( + 'affine:paragraph', + { type: 'text' }, + this.model.id + ); + + if (blockId) { + focusTextModel(this.std, blockId); + } + } else { + const rect = this.querySelector( + '.affine-block-children-container' + )?.getBoundingClientRect(); + + if (rect) { + const offsetY = 8 * this.gfx.viewport.zoom; + const offsetX = 2 * this.gfx.viewport.zoom; + const x = clamp( + e.clientX, + rect.left + offsetX, + rect.right - offsetX + ); + const y = clamp( + e.clientY, + rect.top + offsetY, + rect.bottom - offsetY + ); + handleNativeRangeAtPoint(x, y); + } else { + handleNativeRangeAtPoint(e.clientX, e.clientY); + } + } + }) + .catch(console.error); + } else { + super.onSelected(context); + } + } + @state() private accessor _editing = false; diff --git a/blocksuite/affine/blocks/block-root/src/edgeless/edgeless-builtin-spec.ts b/blocksuite/affine/blocks/block-root/src/edgeless/edgeless-builtin-spec.ts index bc1f6d19d..a53bc6001 100644 --- a/blocksuite/affine/blocks/block-root/src/edgeless/edgeless-builtin-spec.ts +++ b/blocksuite/affine/blocks/block-root/src/edgeless/edgeless-builtin-spec.ts @@ -16,14 +16,12 @@ import { import { NoteTool } from '@blocksuite/affine-gfx-note'; import { ShapeTool } from '@blocksuite/affine-gfx-shape'; import { TextTool } from '@blocksuite/affine-gfx-text'; -import { - CanvasEventHandler, - ElementTransformManager, -} from '@blocksuite/block-std/gfx'; +import { ElementTransformManager } from '@blocksuite/block-std/gfx'; import type { ExtensionType } from '@blocksuite/store'; import { EdgelessElementToolbarExtension } from './configs/toolbar'; import { EdgelessRootBlockSpec } from './edgeless-root-spec.js'; +import { DblClickAddEdgelessText } from './element-transform/dblclick-add-edgeless-text.js'; import { SnapExtension } from './element-transform/snap-manager.js'; import { DefaultTool } from './gfx-tool/default-tool.js'; import { EmptyTool } from './gfx-tool/empty-tool.js'; @@ -53,9 +51,9 @@ export const EdgelessEditExtensions: ExtensionType[] = [ ElementTransformManager, ConnectorFilter, SnapExtension, - CanvasEventHandler, MindMapDragExtension, FrameHighlightManager, + DblClickAddEdgelessText, ]; export const EdgelessBuiltInManager: ExtensionType[] = [ diff --git a/blocksuite/affine/blocks/block-root/src/edgeless/edgeless-keyboard.ts b/blocksuite/affine/blocks/block-root/src/edgeless/edgeless-keyboard.ts index e9fd7ef45..8553973f6 100644 --- a/blocksuite/affine/blocks/block-root/src/edgeless/edgeless-keyboard.ts +++ b/blocksuite/affine/blocks/block-root/src/edgeless/edgeless-keyboard.ts @@ -707,6 +707,7 @@ export class EdgelessPageKeyboardManager extends PageKeyboardManager { const edgeless = this.rootComponent; const selection = edgeless.service.selection; const currentTool = edgeless.gfx.tool.currentTool$.peek()!; + const currentSel = selection.surfaceSelections; const isKeyDown = event.type === 'keydown'; if (edgeless.gfx.tool.dragging$.peek()) { @@ -720,6 +721,7 @@ export class EdgelessPageKeyboardManager extends PageKeyboardManager { currentTool.toolName, currentTool?.activatedOption ); + selection.set(currentSel); document.removeEventListener('keyup', revertToPrevTool, false); } }; diff --git a/blocksuite/affine/blocks/block-root/src/edgeless/edgeless-root-spec.ts b/blocksuite/affine/blocks/block-root/src/edgeless/edgeless-root-spec.ts index 9262ca540..480867608 100644 --- a/blocksuite/affine/blocks/block-root/src/edgeless/edgeless-root-spec.ts +++ b/blocksuite/affine/blocks/block-root/src/edgeless/edgeless-root-spec.ts @@ -1,5 +1,8 @@ import { ConnectorElementView } from '@blocksuite/affine-gfx-connector'; +import { GroupElementView } from '@blocksuite/affine-gfx-group'; import { MindMapView } from '@blocksuite/affine-gfx-mindmap'; +import { ShapeElementView } from '@blocksuite/affine-gfx-shape'; +import { TextElementView } from '@blocksuite/affine-gfx-text'; import { ViewportElementExtension } from '@blocksuite/affine-shared/services'; import { autoConnectWidget } from '@blocksuite/affine-widget-edgeless-auto-connect'; import { edgelessToolbarWidget } from '@blocksuite/affine-widget-edgeless-toolbar'; @@ -90,13 +93,20 @@ const EdgelessClipboardConfigs: ExtensionType[] = [ EdgelessClipboardEmbedSyncedDocConfig, ]; +export const gfxElementViews = [ + ConnectorElementView, + MindMapView, + GroupElementView, + TextElementView, + ShapeElementView, +]; + const EdgelessCommonExtension: ExtensionType[] = [ CommonSpecs, ToolController, EdgelessRootService, ViewportElementExtension('.affine-edgeless-viewport'), - MindMapView, - ConnectorElementView, + ...gfxElementViews, ...quickTools, ...seniorTools, ...EdgelessClipboardConfigs, diff --git a/blocksuite/affine/blocks/block-root/src/edgeless/element-transform/dblclick-add-edgeless-text.ts b/blocksuite/affine/blocks/block-root/src/edgeless/element-transform/dblclick-add-edgeless-text.ts new file mode 100644 index 000000000..a0dbd8854 --- /dev/null +++ b/blocksuite/affine/blocks/block-root/src/edgeless/element-transform/dblclick-add-edgeless-text.ts @@ -0,0 +1,47 @@ +import { insertEdgelessTextCommand } from '@blocksuite/affine-block-edgeless-text'; +import { addText } from '@blocksuite/affine-gfx-text'; +import { + FeatureFlagService, + TelemetryProvider, +} from '@blocksuite/affine-shared/services'; +import type { PointerEventState } from '@blocksuite/block-std'; +import { TransformExtension } from '@blocksuite/block-std/gfx'; + +export class DblClickAddEdgelessText extends TransformExtension { + static override key = 'dbl-click-add-edgeless-text'; + + override dblClick(e: PointerEventState): void { + const textFlag = this.std.store + .get(FeatureFlagService) + .getFlag('enable_edgeless_text'); + const picked = this.gfx.getElementByPoint( + ...this.gfx.viewport.toModelCoord(e.x, e.y) + ); + + if (picked) { + return; + } + + if (textFlag) { + const [x, y] = this.gfx.viewport.toModelCoord(e.x, e.y); + this.std.command.exec(insertEdgelessTextCommand, { x, y }); + } else { + const edgelessView = this.std.view.getBlock( + this.std.store.root?.id || '' + ); + + if (edgelessView) { + addText(edgelessView, e); + } + } + + this.std.getOptional(TelemetryProvider)?.track('CanvasElementAdded', { + control: 'canvas:dbclick', + page: 'whiteboard editor', + module: 'toolbar', + segment: 'toolbar', + type: 'text', + }); + return; + } +} diff --git a/blocksuite/affine/blocks/block-root/src/edgeless/gfx-tool/default-tool.ts b/blocksuite/affine/blocks/block-root/src/edgeless/gfx-tool/default-tool.ts index eb8faf067..e9baf5f5c 100644 --- a/blocksuite/affine/blocks/block-root/src/edgeless/gfx-tool/default-tool.ts +++ b/blocksuite/affine/blocks/block-root/src/edgeless/gfx-tool/default-tool.ts @@ -1,38 +1,17 @@ -import { insertEdgelessTextCommand } from '@blocksuite/affine-block-edgeless-text'; import { type FrameOverlay, isFrameBlock, } from '@blocksuite/affine-block-frame'; import { ConnectorUtils, - isNoteBlock, OverlayIdentifier, } from '@blocksuite/affine-block-surface'; -import { mountConnectorLabelEditor } from '@blocksuite/affine-gfx-connector'; -import { mountGroupTitleEditor } from '@blocksuite/affine-gfx-group'; -import { mountShapeTextEditor } from '@blocksuite/affine-gfx-shape'; -import { addText, mountTextElementEditor } from '@blocksuite/affine-gfx-text'; -import type { - EdgelessTextBlockModel, - NoteBlockModel, -} from '@blocksuite/affine-model'; import { - ConnectorElementModel, + type ConnectorElementModel, GroupElementModel, MindmapElementModel, - ShapeElementModel, - TextElementModel, } from '@blocksuite/affine-model'; -import { focusTextModel } from '@blocksuite/affine-rich-text'; -import { - FeatureFlagService, - TelemetryProvider, -} from '@blocksuite/affine-shared/services'; -import { - handleNativeRangeAtPoint, - resetNativeSelection, -} from '@blocksuite/affine-shared/utils'; -import { mountFrameTitleEditor } from '@blocksuite/affine-widget-frame-title'; +import { resetNativeSelection } from '@blocksuite/affine-shared/utils'; import type { BlockComponent, PointerEventState } from '@blocksuite/block-std'; import { BaseTool, @@ -46,13 +25,11 @@ import { DisposableGroup } from '@blocksuite/global/disposable'; import type { IVec } from '@blocksuite/global/gfx'; import { Bound, getCommonBoundWithRotation, Vec } from '@blocksuite/global/gfx'; import { effect } from '@preact/signals-core'; -import clamp from 'lodash-es/clamp'; -import last from 'lodash-es/last'; import { createElementsFromClipboardDataCommand } from '../clipboard/command.js'; import { prepareCloneData } from '../utils/clone-utils.js'; import { calPanDelta } from '../utils/panning-utils.js'; -import { isCanvasElement, isEdgelessTextBlock } from '../utils/query.js'; +import { isCanvasElement } from '../utils/query.js'; import { DefaultModeDragType } from './default-tool-ext/ext.js'; export class DefaultTool extends BaseTool { @@ -76,9 +53,6 @@ export class DefaultTool extends BaseTool { private _disposables: DisposableGroup | null = null; - // Do not select the text, when click again after activating the note. - private _isDoubleClickedOnMask = false; - private readonly _panViewport = (delta: IVec) => { this._accumulateDelta[0] += delta[0]; this._accumulateDelta[1] += delta[1]; @@ -222,19 +196,6 @@ export class DefaultTool extends BaseTool { return this.std.get(OverlayIdentifier('frame')) as FrameOverlay; } - private _addEmptyParagraphBlock( - block: NoteBlockModel | EdgelessTextBlockModel - ) { - const blockId = this.doc.addBlock( - 'affine:paragraph', - { type: 'text' }, - block.id - ); - if (blockId) { - focusTextModel(this.std, blockId); - } - } - private async _cloneContent() { if (!this._edgeless) return; @@ -354,19 +315,6 @@ export class DefaultTool extends BaseTool { return e; }; - const frameByPickingTitle = last( - this.gfx - .getElementByPoint(modelPos[0], modelPos[1], { - ...options, - all: true, - }) - .filter( - el => isFrameBlock(el) && el.externalBound?.isPointInBound(modelPos) - ) - ); - - if (frameByPickingTitle) return tryGetLockedAncestor(frameByPickingTitle); - const result = this.gfx.getElementInGroup( modelPos[0], modelPos[1], @@ -394,11 +342,6 @@ export class DefaultTool extends BaseTool { return tryGetLockedAncestor(picked[pickedIdx]) ?? null; } - // if the frame has title, it only can be picked by clicking the title - if (isFrameBlock(result) && result.externalXYWH) { - return null; - } - return tryGetLockedAncestor(result); } @@ -443,105 +386,14 @@ export class DefaultTool extends BaseTool { } } - override activate(_: Record): void { - if (this.gfx.selection.lastSurfaceSelections.length) { - this.gfx.selection.set(this.gfx.selection.lastSurfaceSelections); - } - } - override click(e: PointerEventState) { if (this.doc.readonly) return; - const selected = this._pick(e.x, e.y, { - ignoreTransparent: true, - }); - - if (selected) { - const { selectedIds, surfaceSelections } = this.edgelessSelectionManager; - const editing = surfaceSelections[0]?.editing ?? false; - - // click active canvas text, edgeless text block and note block - if ( - selectedIds.length === 1 && - selectedIds[0] === selected.id && - editing - ) { - // edgeless text block and note block - if ( - (isNoteBlock(selected) || isEdgelessTextBlock(selected)) && - selected.children.length === 0 - ) { - this._addEmptyParagraphBlock(selected); - } - // canvas text - return; - } - - // click non-active edgeless text block and note block, and then enter editing - if ( - !selected.isLocked() && - !e.keys.shift && - selectedIds.length === 1 && - (isNoteBlock(selected) || isEdgelessTextBlock(selected)) && - ((selectedIds[0] === selected.id && !editing) || - (editing && selectedIds[0] !== selected.id)) - ) { - // issue #1809 - // If the previously selected element is a noteBlock and is in an active state, - // then the currently clicked noteBlock should also be in an active state when selected. - this.edgelessSelectionManager.set({ - elements: [selected.id], - editing: true, - }); - this._edgeless?.updateComplete - .then(() => { - // check if block has children blocks, if not, add a paragraph block and focus on it - if (selected.children.length === 0) { - this._addEmptyParagraphBlock(selected); - } else { - const block = this.std.host.view.getBlock(selected.id); - if (block) { - const rect = block - .querySelector('.affine-block-children-container')! - .getBoundingClientRect(); - - const offsetY = 8 * this.gfx.viewport.zoom; - const offsetX = 2 * this.gfx.viewport.zoom; - const x = clamp( - e.raw.clientX, - rect.left + offsetX, - rect.right - offsetX - ); - const y = clamp( - e.raw.clientY, - rect.top + offsetY, - rect.bottom - offsetY - ); - handleNativeRangeAtPoint(x, y); - } else { - handleNativeRangeAtPoint(e.raw.clientX, e.raw.clientY); - } - } - }) - .catch(console.error); - return; - } - - this.edgelessSelectionManager.set({ - // hold shift key to multi select or de-select element - elements: e.keys.shift - ? this.edgelessSelectionManager.has(selected.id) - ? selectedIds.filter(id => id !== selected.id) - : [...selectedIds, selected.id] - : [selected.id], - editing: false, - }); - } else if (!e.keys.shift) { + if (!this.elementTransformMgr?.dispatchOnSelected(e)) { this.edgelessSelectionManager.clear(); resetNativeSelection(null); } - this._isDoubleClickedOnMask = false; this.elementTransformMgr?.dispatch('click', e); } @@ -564,71 +416,7 @@ export class DefaultTool extends BaseTool { return; } - const selected = this._pick(e.x, e.y, { - hitThreshold: 10, - }); - if (!this._edgeless) { - return; - } - - if (!selected) { - const textFlag = this.doc - .get(FeatureFlagService) - .getFlag('enable_edgeless_text'); - - if (textFlag) { - const [x, y] = this.gfx.viewport.toModelCoord(e.x, e.y); - this.std.command.exec(insertEdgelessTextCommand, { x, y }); - } else { - addText(this._edgeless, e); - } - this.std.getOptional(TelemetryProvider)?.track('CanvasElementAdded', { - control: 'canvas:dbclick', - page: 'whiteboard editor', - module: 'toolbar', - segment: 'toolbar', - type: 'text', - }); - return; - } else { - if (selected.isLocked()) return; - const [x, y] = this.gfx.viewport.toModelCoord(e.x, e.y); - if (selected instanceof TextElementModel) { - mountTextElementEditor(selected, this._edgeless, { - x, - y, - }); - return; - } - if (selected instanceof ShapeElementModel) { - mountShapeTextEditor(selected, this._edgeless); - return; - } - if (selected instanceof ConnectorElementModel) { - mountConnectorLabelEditor(selected, this._edgeless, [x, y]); - return; - } - if (isFrameBlock(selected)) { - mountFrameTitleEditor(selected, this._edgeless); - return; - } - if (selected instanceof GroupElementModel) { - mountGroupTitleEditor(selected, this._edgeless); - return; - } - } - this.elementTransformMgr?.dispatch('dblclick', e); - - if ( - e.raw.target && - e.raw.target instanceof HTMLElement && - e.raw.target.classList.contains('affine-note-mask') - ) { - this.click(e); - this._isDoubleClickedOnMask = true; - return; - } } override dragEnd() { @@ -753,9 +541,7 @@ export class DefaultTool extends BaseTool { this.elementTransformMgr?.dispatch('pointerup', e); } - override tripleClick() { - if (this._isDoubleClickedOnMask) return; - } + override tripleClick() {} override unmounted(): void {} } diff --git a/blocksuite/affine/blocks/block-root/src/edgeless/gfx-tool/pan-tool.ts b/blocksuite/affine/blocks/block-root/src/edgeless/gfx-tool/pan-tool.ts index c9c99f84a..ca66d9daa 100644 --- a/blocksuite/affine/blocks/block-root/src/edgeless/gfx-tool/pan-tool.ts +++ b/blocksuite/affine/blocks/block-root/src/edgeless/gfx-tool/pan-tool.ts @@ -53,9 +53,11 @@ export class PanTool extends BaseTool { evt.raw.preventDefault(); + const selection = this.gfx.selection.surfaceSelections; const currentTool = this.controller.currentToolOption$.peek(); const restoreToPrevious = () => { this.controller.setTool(currentTool); + this.gfx.selection.set(selection); }; this.controller.setTool('pan', { diff --git a/blocksuite/affine/gfx/connector/src/text/edgeless-connector-label-editor.ts b/blocksuite/affine/gfx/connector/src/text/edgeless-connector-label-editor.ts index 00e1a55c1..abd20ba4e 100644 --- a/blocksuite/affine/gfx/connector/src/text/edgeless-connector-label-editor.ts +++ b/blocksuite/affine/gfx/connector/src/text/edgeless-connector-label-editor.ts @@ -1,17 +1,23 @@ import { EdgelessCRUDIdentifier, - getSurfaceBlock, TextUtils, } from '@blocksuite/affine-block-surface'; import type { ConnectorElementModel } from '@blocksuite/affine-model'; import type { RichText } from '@blocksuite/affine-rich-text'; import { ThemeProvider } from '@blocksuite/affine-shared/services'; import { almostEqual } from '@blocksuite/affine-shared/utils'; -import { type BlockComponent, ShadowlessElement } from '@blocksuite/block-std'; +import { + type BlockComponent, + type BlockStdScope, + ShadowlessElement, + stdContext, +} from '@blocksuite/block-std'; import { GfxControllerIdentifier } from '@blocksuite/block-std/gfx'; import { RANGE_SYNC_EXCLUDE_ATTR } from '@blocksuite/block-std/inline'; -import { Bound, Vec } from '@blocksuite/global/gfx'; +import { BlockSuiteError, ErrorCode } from '@blocksuite/global/exceptions'; +import { Bound, type IVec, Vec } from '@blocksuite/global/gfx'; import { WithDisposable } from '@blocksuite/global/lit'; +import { consume } from '@lit/context'; import { css, html, nothing } from 'lit'; import { property, query } from 'lit/decorators.js'; import { styleMap } from 'lit/directives/style-map.js'; @@ -21,6 +27,60 @@ const HORIZONTAL_PADDING = 2; const VERTICAL_PADDING = 2; const BORDER_WIDTH = 1; +export function mountConnectorLabelEditor( + connector: ConnectorElementModel, + edgeless: BlockComponent, + point?: IVec +) { + const mountElm = edgeless.querySelector('.edgeless-mount-point'); + if (!mountElm) { + throw new BlockSuiteError( + ErrorCode.ValueNotExists, + "edgeless block's mount point does not exist" + ); + } + + const gfx = edgeless.std.get(GfxControllerIdentifier); + + // @ts-expect-error default tool should be migrated to std + gfx.tool.setTool('default'); + gfx.selection.set({ + elements: [connector.id], + editing: true, + }); + + if (!connector.text) { + const text = new Y.Text(); + const labelOffset = connector.labelOffset; + let labelXYWH = connector.labelXYWH ?? [0, 0, 16, 16]; + + if (point) { + const center = connector.getNearestPoint(point); + const distance = connector.getOffsetDistanceByPoint(center as IVec); + const bounds = Bound.fromXYWH(labelXYWH); + bounds.center = center; + labelOffset.distance = distance; + labelXYWH = bounds.toXYWH(); + } + + edgeless.std.get(EdgelessCRUDIdentifier).updateElement(connector.id, { + text, + labelXYWH, + labelOffset: { ...labelOffset }, + }); + } + + const editor = new EdgelessConnectorLabelEditor(); + editor.connector = connector; + + mountElm.append(editor); + editor.updateComplete + .then(() => { + editor.inlineEditor?.focusEnd(); + }) + .catch(console.error); +} + export class EdgelessConnectorLabelEditor extends WithDisposable( ShadowlessElement ) { @@ -58,11 +118,11 @@ export class EdgelessConnectorLabelEditor extends WithDisposable( `; get crud() { - return this.edgeless.std.get(EdgelessCRUDIdentifier); + return this.std.get(EdgelessCRUDIdentifier); } get gfx() { - return this.edgeless.std.get(GfxControllerIdentifier); + return this.std.get(GfxControllerIdentifier); } get selection() { @@ -76,8 +136,8 @@ export class EdgelessConnectorLabelEditor extends WithDisposable( private _resizeObserver: ResizeObserver | null = null; private readonly _updateLabelRect = () => { - const { connector, edgeless } = this; - if (!connector || !edgeless) return; + const { connector, isConnected } = this; + if (!connector || !isConnected) return; if (!this.inlineEditorContainer) return; @@ -119,9 +179,8 @@ export class EdgelessConnectorLabelEditor extends WithDisposable( } override firstUpdated() { - const { edgeless, connector, selection } = this; - const dispatcher = edgeless.std.event; - const store = edgeless.std.store; + const { connector, selection, std } = this; + const dispatcher = std.event; this._resizeObserver = new ResizeObserver(() => { this._updateLabelRect(); @@ -159,7 +218,7 @@ export class EdgelessConnectorLabelEditor extends WithDisposable( }) ); - const surface = getSurfaceBlock(store); + const surface = this.gfx.surface; if (surface) { this.disposables.add( @@ -275,7 +334,7 @@ export class EdgelessConnectorLabelEditor extends WithDisposable( ]; const isEmpty = !connector.text?.length && !this._isComposition; - const color = this.edgeless.std + const color = this.std .get(ThemeProvider) .generateColorProperty(labelColor, '#000000'); @@ -326,8 +385,10 @@ export class EdgelessConnectorLabelEditor extends WithDisposable( @property({ attribute: false }) accessor connector!: ConnectorElementModel; - @property({ attribute: false }) - accessor edgeless!: BlockComponent; + @consume({ + context: stdContext, + }) + accessor std!: BlockStdScope; @query('rich-text') accessor richText!: RichText; diff --git a/blocksuite/affine/gfx/connector/src/text/text.ts b/blocksuite/affine/gfx/connector/src/text/text.ts index 9a8c3406a..b84f43f49 100644 --- a/blocksuite/affine/gfx/connector/src/text/text.ts +++ b/blocksuite/affine/gfx/connector/src/text/text.ts @@ -54,7 +54,6 @@ export function mountConnectorLabelEditor( const editor = new EdgelessConnectorLabelEditor(); editor.connector = connector; - editor.edgeless = edgeless; mountElm.append(editor); editor.updateComplete diff --git a/blocksuite/affine/gfx/connector/src/view/view.ts b/blocksuite/affine/gfx/connector/src/view/view.ts index a2e3c2ce2..70a4382d2 100644 --- a/blocksuite/affine/gfx/connector/src/view/view.ts +++ b/blocksuite/affine/gfx/connector/src/view/view.ts @@ -6,6 +6,8 @@ import { GfxElementModelView, } from '@blocksuite/block-std/gfx'; +import { mountConnectorLabelEditor } from '../text/edgeless-connector-label-editor'; + export class ConnectorElementView extends GfxElementModelView { static override type = 'connector'; @@ -24,4 +26,24 @@ export class ConnectorElementView extends GfxElementModelView { + const edgeless = this.std.view.getBlock(this.std.store.root!.id); + + if (edgeless && !this.model.isLocked()) { + mountConnectorLabelEditor( + this.model, + edgeless, + this.gfx.viewport.toModelCoord(evt.x, evt.y) + ); + } + }); + } } diff --git a/blocksuite/affine/gfx/group/src/index.ts b/blocksuite/affine/gfx/group/src/index.ts index be0f63506..bc3b0a726 100644 --- a/blocksuite/affine/gfx/group/src/index.ts +++ b/blocksuite/affine/gfx/group/src/index.ts @@ -1,3 +1,4 @@ export * from './command'; export * from './text/text'; export * from './toolbar/config'; +export * from './view'; diff --git a/blocksuite/affine/gfx/group/src/text/edgeless-group-title-editor.ts b/blocksuite/affine/gfx/group/src/text/edgeless-group-title-editor.ts index a20d3d0a9..996f2a5a7 100644 --- a/blocksuite/affine/gfx/group/src/text/edgeless-group-title-editor.ts +++ b/blocksuite/affine/gfx/group/src/text/edgeless-group-title-editor.ts @@ -5,15 +5,49 @@ import { } from '@blocksuite/affine-block-surface'; import type { GroupElementModel } from '@blocksuite/affine-model'; import type { RichText } from '@blocksuite/affine-rich-text'; -import { type BlockComponent, ShadowlessElement } from '@blocksuite/block-std'; +import { + type BlockComponent, + type BlockStdScope, + ShadowlessElement, + stdContext, +} from '@blocksuite/block-std'; import { GfxControllerIdentifier } from '@blocksuite/block-std/gfx'; import { RANGE_SYNC_EXCLUDE_ATTR } from '@blocksuite/block-std/inline'; +import { BlockSuiteError, ErrorCode } from '@blocksuite/global/exceptions'; import { Bound } from '@blocksuite/global/gfx'; import { WithDisposable } from '@blocksuite/global/lit'; +import { consume } from '@lit/context'; import { html, nothing } from 'lit'; import { property, query } from 'lit/decorators.js'; import { styleMap } from 'lit/directives/style-map.js'; +export function mountGroupTitleEditor( + group: GroupElementModel, + edgeless: BlockComponent +) { + const mountElm = edgeless.querySelector('.edgeless-mount-point'); + if (!mountElm) { + throw new BlockSuiteError( + ErrorCode.ValueNotExists, + "edgeless block's mount point does not exist" + ); + } + + const gfx = edgeless.std.get(GfxControllerIdentifier); + + // @ts-expect-error FIXME: resolve after gfx tool refactor + gfx.tool.setTool('default'); + gfx.selection.set({ + elements: [group.id], + editing: true, + }); + + const groupEditor = new EdgelessGroupTitleEditor(); + groupEditor.group = group; + + mountElm.append(groupEditor); +} + export class EdgelessGroupTitleEditor extends WithDisposable( ShadowlessElement ) { @@ -26,7 +60,7 @@ export class EdgelessGroupTitleEditor extends WithDisposable( } get gfx() { - return this.edgeless.std.get(GfxControllerIdentifier); + return this.std.get(GfxControllerIdentifier); } get selection() { @@ -50,7 +84,7 @@ export class EdgelessGroupTitleEditor extends WithDisposable( } override firstUpdated(): void { - const dispatcher = this.edgeless.std.event; + const dispatcher = this.std.event; this.updateComplete .then(() => { @@ -141,12 +175,14 @@ export class EdgelessGroupTitleEditor extends WithDisposable( >`; } - @property({ attribute: false }) - accessor edgeless!: BlockComponent; - @property({ attribute: false }) accessor group!: GroupElementModel; + @consume({ + context: stdContext, + }) + accessor std!: BlockStdScope; + @query('rich-text') accessor richText!: RichText; } diff --git a/blocksuite/affine/gfx/group/src/text/text.ts b/blocksuite/affine/gfx/group/src/text/text.ts index da2ca1a6a..3a02da489 100644 --- a/blocksuite/affine/gfx/group/src/text/text.ts +++ b/blocksuite/affine/gfx/group/src/text/text.ts @@ -28,7 +28,6 @@ export function mountGroupTitleEditor( const groupEditor = new EdgelessGroupTitleEditor(); groupEditor.group = group; - groupEditor.edgeless = edgeless; mountElm.append(groupEditor); } diff --git a/blocksuite/affine/gfx/group/src/view.ts b/blocksuite/affine/gfx/group/src/view.ts new file mode 100644 index 000000000..410e337af --- /dev/null +++ b/blocksuite/affine/gfx/group/src/view.ts @@ -0,0 +1,24 @@ +import type { GroupElementModel } from '@blocksuite/affine-model'; +import { GfxElementModelView } from '@blocksuite/block-std/gfx'; + +import { mountGroupTitleEditor } from './text/edgeless-group-title-editor'; + +export class GroupElementView extends GfxElementModelView { + static override type: string = 'group'; + + override onCreated(): void { + super.onCreated(); + + this._initDblClickToEdit(); + } + + private _initDblClickToEdit(): void { + this.on('dblclick', () => { + const edgeless = this.std.view.getBlock(this.std.store.root!.id); + + if (edgeless && !this.model.isLocked()) { + mountGroupTitleEditor(this.model, edgeless); + } + }); + } +} diff --git a/blocksuite/affine/gfx/shape/src/index.ts b/blocksuite/affine/gfx/shape/src/index.ts index e458e7135..31d8ae4aa 100644 --- a/blocksuite/affine/gfx/shape/src/index.ts +++ b/blocksuite/affine/gfx/shape/src/index.ts @@ -4,3 +4,4 @@ export * from './overlay'; export * from './shape-tool'; export * from './text'; export * from './toolbar'; +export * from './view'; diff --git a/blocksuite/affine/gfx/shape/src/text/edgeless-shape-text-editor.ts b/blocksuite/affine/gfx/shape/src/text/edgeless-shape-text-editor.ts index a6b935adf..32850a82b 100644 --- a/blocksuite/affine/gfx/shape/src/text/edgeless-shape-text-editor.ts +++ b/blocksuite/affine/gfx/shape/src/text/edgeless-shape-text-editor.ts @@ -2,21 +2,73 @@ import { EdgelessCRUDIdentifier, TextUtils, } from '@blocksuite/affine-block-surface'; -import type { ShapeElementModel } from '@blocksuite/affine-model'; -import { MindmapElementModel, TextResizing } from '@blocksuite/affine-model'; +import { + MindmapElementModel, + ShapeElementModel, + TextResizing, +} from '@blocksuite/affine-model'; import type { RichText } from '@blocksuite/affine-rich-text'; import { ThemeProvider } from '@blocksuite/affine-shared/services'; import { getSelectedRect } from '@blocksuite/affine-shared/utils'; -import { type BlockComponent, ShadowlessElement } from '@blocksuite/block-std'; +import { + type BlockComponent, + type BlockStdScope, + ShadowlessElement, + stdContext, +} from '@blocksuite/block-std'; import { GfxControllerIdentifier } from '@blocksuite/block-std/gfx'; import { RANGE_SYNC_EXCLUDE_ATTR } from '@blocksuite/block-std/inline'; +import { BlockSuiteError, ErrorCode } from '@blocksuite/global/exceptions'; import { Bound, toRadian, Vec } from '@blocksuite/global/gfx'; import { WithDisposable } from '@blocksuite/global/lit'; +import { consume } from '@lit/context'; import { html, nothing } from 'lit'; import { property, query } from 'lit/decorators.js'; import { styleMap } from 'lit/directives/style-map.js'; import * as Y from 'yjs'; +export function mountShapeTextEditor( + shapeElement: ShapeElementModel, + edgeless: BlockComponent +) { + const mountElm = edgeless.querySelector('.edgeless-mount-point'); + if (!mountElm) { + throw new BlockSuiteError( + ErrorCode.ValueNotExists, + "edgeless block's mount point does not exist" + ); + } + + const gfx = edgeless.std.get(GfxControllerIdentifier); + const crud = edgeless.std.get(EdgelessCRUDIdentifier); + + const updatedElement = crud.getElementById(shapeElement.id); + + if (!(updatedElement instanceof ShapeElementModel)) { + console.error('Cannot mount text editor on a non-shape element'); + return; + } + + // @ts-expect-error FIXME: resolve after gfx tool refactor + gfx.tool.setTool('default'); + gfx.selection.set({ + elements: [shapeElement.id], + editing: true, + }); + + if (!shapeElement.text) { + const text = new Y.Text(); + edgeless.std + .get(EdgelessCRUDIdentifier) + .updateElement(shapeElement.id, { text }); + } + + const shapeEditor = new EdgelessShapeTextEditor(); + shapeEditor.element = updatedElement; + + mountElm.append(shapeEditor); +} + export class EdgelessShapeTextEditor extends WithDisposable(ShadowlessElement) { private _keeping = false; @@ -29,11 +81,11 @@ export class EdgelessShapeTextEditor extends WithDisposable(ShadowlessElement) { } get crud() { - return this.edgeless.std.get(EdgelessCRUDIdentifier); + return this.std.get(EdgelessCRUDIdentifier); } get gfx() { - return this.edgeless.std.get(GfxControllerIdentifier); + return this.std.get(GfxControllerIdentifier); } get selection() { @@ -179,7 +231,7 @@ export class EdgelessShapeTextEditor extends WithDisposable(ShadowlessElement) { } override firstUpdated(): void { - const dispatcher = this.edgeless.std.event; + const dispatcher = this.std.event; this.element.textDisplay = false; @@ -273,7 +325,7 @@ export class EdgelessShapeTextEditor extends WithDisposable(ShadowlessElement) { ); const [x, y] = this.gfx.viewport.toViewCoord(leftTopX, leftTopY); const autoWidth = textResizing === TextResizing.AUTO_WIDTH_AND_HEIGHT; - const color = this.edgeless.std + const color = this.std .get(ThemeProvider) .generateColorProperty(this.element.color, '#000000'); @@ -346,16 +398,13 @@ export class EdgelessShapeTextEditor extends WithDisposable(ShadowlessElement) { this._keeping = keeping; } - @property({ attribute: false }) - accessor edgeless!: BlockComponent; - @property({ attribute: false }) accessor element!: ShapeElementModel; - @property({ attribute: false }) - accessor mountEditor: - | ((element: ShapeElementModel, edgeless: BlockComponent) => void) - | undefined = undefined; + @consume({ + context: stdContext, + }) + accessor std!: BlockStdScope; @query('rich-text') accessor richText!: RichText; diff --git a/blocksuite/affine/gfx/shape/src/text/index.ts b/blocksuite/affine/gfx/shape/src/text/index.ts index 1a9ac1460..cc153ba39 100644 --- a/blocksuite/affine/gfx/shape/src/text/index.ts +++ b/blocksuite/affine/gfx/shape/src/text/index.ts @@ -1 +1 @@ -export * from './text'; +export * from './edgeless-shape-text-editor'; diff --git a/blocksuite/affine/gfx/shape/src/text/text.ts b/blocksuite/affine/gfx/shape/src/text/text.ts deleted file mode 100644 index 737833e32..000000000 --- a/blocksuite/affine/gfx/shape/src/text/text.ts +++ /dev/null @@ -1,52 +0,0 @@ -import { EdgelessCRUDIdentifier } from '@blocksuite/affine-block-surface'; -import { ShapeElementModel } from '@blocksuite/affine-model'; -import type { BlockComponent } from '@blocksuite/block-std'; -import { GfxControllerIdentifier } from '@blocksuite/block-std/gfx'; -import { BlockSuiteError, ErrorCode } from '@blocksuite/global/exceptions'; -import * as Y from 'yjs'; - -import { EdgelessShapeTextEditor } from './edgeless-shape-text-editor'; - -export function mountShapeTextEditor( - shapeElement: ShapeElementModel, - edgeless: BlockComponent -) { - const mountElm = edgeless.querySelector('.edgeless-mount-point'); - if (!mountElm) { - throw new BlockSuiteError( - ErrorCode.ValueNotExists, - "edgeless block's mount point does not exist" - ); - } - - const gfx = edgeless.std.get(GfxControllerIdentifier); - const crud = edgeless.std.get(EdgelessCRUDIdentifier); - - const updatedElement = crud.getElementById(shapeElement.id); - - if (!(updatedElement instanceof ShapeElementModel)) { - console.error('Cannot mount text editor on a non-shape element'); - return; - } - - // @ts-expect-error FIXME: resolve after gfx tool refactor - gfx.tool.setTool('default'); - gfx.selection.set({ - elements: [shapeElement.id], - editing: true, - }); - - if (!shapeElement.text) { - const text = new Y.Text(); - edgeless.std - .get(EdgelessCRUDIdentifier) - .updateElement(shapeElement.id, { text }); - } - - const shapeEditor = new EdgelessShapeTextEditor(); - shapeEditor.element = updatedElement; - shapeEditor.edgeless = edgeless; - shapeEditor.mountEditor = mountShapeTextEditor; - - mountElm.append(shapeEditor); -} diff --git a/blocksuite/affine/gfx/shape/src/toolbar/config.ts b/blocksuite/affine/gfx/shape/src/toolbar/config.ts index 261eca92c..5f061d534 100644 --- a/blocksuite/affine/gfx/shape/src/toolbar/config.ts +++ b/blocksuite/affine/gfx/shape/src/toolbar/config.ts @@ -47,7 +47,7 @@ import { html } from 'lit'; import isEqual from 'lodash-es/isEqual'; import type { ShapeToolOption } from '../shape-tool'; -import { mountShapeTextEditor } from '../text'; +import { mountShapeTextEditor } from '../text/edgeless-shape-text-editor'; import { ShapeComponentConfig } from './shape-menu-config'; export const shapeToolbarConfig = { diff --git a/blocksuite/affine/gfx/shape/src/view.ts b/blocksuite/affine/gfx/shape/src/view.ts new file mode 100644 index 000000000..c50d8eaec --- /dev/null +++ b/blocksuite/affine/gfx/shape/src/view.ts @@ -0,0 +1,24 @@ +import type { ShapeElementModel } from '@blocksuite/affine-model'; +import { GfxElementModelView } from '@blocksuite/block-std/gfx'; + +import { mountShapeTextEditor } from './text/edgeless-shape-text-editor'; + +export class ShapeElementView extends GfxElementModelView { + static override type: string = 'shape'; + + override onCreated(): void { + super.onCreated(); + + this._initDblClickToEdit(); + } + + private _initDblClickToEdit(): void { + const edgeless = this.std.view.getBlock(this.std.store.root!.id); + + this.on('dblclick', () => { + if (edgeless && !this.model.isLocked()) { + mountShapeTextEditor(this.model, edgeless); + } + }); + } +} diff --git a/blocksuite/affine/gfx/text/src/edgeless-text-editor.ts b/blocksuite/affine/gfx/text/src/edgeless-text-editor.ts index 9e99d09b6..3b906be76 100644 --- a/blocksuite/affine/gfx/text/src/edgeless-text-editor.ts +++ b/blocksuite/affine/gfx/text/src/edgeless-text-editor.ts @@ -1,14 +1,18 @@ import { + CanvasElementType, EdgelessCRUDIdentifier, getSurfaceBlock, + type IModelCoord, TextUtils, } from '@blocksuite/affine-block-surface'; -import type { TextElementModel } from '@blocksuite/affine-model'; +import { TextElementModel } from '@blocksuite/affine-model'; import type { RichText } from '@blocksuite/affine-rich-text'; import { ThemeProvider } from '@blocksuite/affine-shared/services'; import { getSelectedRect } from '@blocksuite/affine-shared/utils'; import { + type BlockComponent, type BlockStdScope, + type PointerEventState, ShadowlessElement, stdContext, } from '@blocksuite/block-std'; @@ -20,6 +24,71 @@ import { consume } from '@lit/context'; import { css, html, nothing } from 'lit'; import { property, query } from 'lit/decorators.js'; import { styleMap } from 'lit/directives/style-map.js'; +import * as Y from 'yjs'; + +export function mountTextElementEditor( + textElement: TextElementModel, + edgeless: BlockComponent, + focusCoord?: IModelCoord +) { + let cursorIndex = textElement.text.length; + if (focusCoord) { + cursorIndex = Math.min( + TextUtils.getCursorByCoord(textElement, focusCoord), + cursorIndex + ); + } + + const textEditor = new EdgelessTextEditor(); + textEditor.element = textElement; + + edgeless.append(textEditor); + textEditor.updateComplete + .then(() => { + textEditor.inlineEditor?.focusIndex(cursorIndex); + }) + .catch(console.error); + + const gfx = edgeless.std.get(GfxControllerIdentifier); + + // @ts-expect-error TODO: refactor gfx tool + gfx.tool.setTool('default'); + gfx.selection.set({ + elements: [textElement.id], + editing: true, + }); +} + +/** + * @deprecated + * + * Canvas Text has been deprecated + */ +export function addText(edgeless: BlockComponent, event: PointerEventState) { + const gfx = edgeless.std.get(GfxControllerIdentifier); + const crud = edgeless.std.get(EdgelessCRUDIdentifier); + const [x, y] = gfx.viewport.toModelCoord(event.x, event.y); + const selected = gfx.getElementByPoint(x, y); + + if (!selected) { + const [modelX, modelY] = gfx.viewport.toModelCoord(event.x, event.y); + + const id = edgeless.std + .get(EdgelessCRUDIdentifier) + .addElement(CanvasElementType.TEXT, { + xywh: new Bound(modelX, modelY, 32, 32).serialize(), + text: new Y.Text(), + }); + if (!id) return; + + edgeless.doc.captureSync(); + const textElement = crud.getElementById(id); + if (!textElement) return; + if (textElement instanceof TextElementModel) { + mountTextElementEditor(textElement, edgeless); + } + } +} export class EdgelessTextEditor extends WithDisposable(ShadowlessElement) { get crud() { diff --git a/blocksuite/affine/gfx/text/src/index.ts b/blocksuite/affine/gfx/text/src/index.ts index 058626955..070d14436 100644 --- a/blocksuite/affine/gfx/text/src/index.ts +++ b/blocksuite/affine/gfx/text/src/index.ts @@ -1,4 +1,4 @@ export * from './edgeless-text-editor'; -export * from './mount-text-editor'; export * from './tool'; export * from './toolbar'; +export * from './view'; diff --git a/blocksuite/affine/gfx/text/src/view.ts b/blocksuite/affine/gfx/text/src/view.ts new file mode 100644 index 000000000..a2e575644 --- /dev/null +++ b/blocksuite/affine/gfx/text/src/view.ts @@ -0,0 +1,28 @@ +import type { TextElementModel } from '@blocksuite/affine-model'; +import { GfxElementModelView } from '@blocksuite/block-std/gfx'; + +import { mountTextElementEditor } from './edgeless-text-editor'; + +export class TextElementView extends GfxElementModelView { + static override type: string = 'text'; + + override onCreated(): void { + super.onCreated(); + + this._initDblClickToEdit(); + } + + private _initDblClickToEdit(): void { + this.on('dblclick', evt => { + const edgeless = this.std.view.getBlock(this.std.store.root!.id); + const [x, y] = this.gfx.viewport.toModelCoord(evt.x, evt.y); + + if (edgeless && !this.model.isLocked()) { + mountTextElementEditor(this.model, edgeless, { + x, + y, + }); + } + }); + } +} diff --git a/blocksuite/affine/widgets/widget-frame-title/src/frame-title.ts b/blocksuite/affine/widgets/widget-frame-title/src/frame-title.ts index 0a0651461..da7a67cca 100644 --- a/blocksuite/affine/widgets/widget-frame-title/src/frame-title.ts +++ b/blocksuite/affine/widgets/widget-frame-title/src/frame-title.ts @@ -5,6 +5,7 @@ import { isTransparent, } from '@blocksuite/affine-model'; import { ThemeProvider } from '@blocksuite/affine-shared/services'; +import { on } from '@blocksuite/affine-shared/utils'; import { type BlockStdScope, PropTypes, @@ -19,6 +20,7 @@ import { themeToVar } from '@toeverything/theme/v2'; import { LitElement } from 'lit'; import { property, state } from 'lit/decorators.js'; +import { mountFrameTitleEditor } from './mount-frame-title-editor.js'; import { frameTitleStyle, frameTitleStyleVars } from './styles.js'; export const AFFINE_FRAME_TITLE = 'affine-frame-title'; @@ -203,6 +205,32 @@ export class AffineFrameTitle extends SignalWatcher( }) ); + _disposables.add( + on(this, 'click', evt => { + if (evt.shiftKey) { + this.gfx.selection.toggle(this.model); + } else { + this.gfx.selection.set({ + elements: [this.model.id], + }); + } + }) + ); + + _disposables.add( + on(this, 'dblclick', () => { + const edgeless = this.std.view.getBlock(this.std.store.root?.id || ''); + + if (edgeless && !this.model.isLocked()) { + mountFrameTitleEditor(this.model, edgeless); + } else { + this.gfx.selection.set({ + elements: [this.model.id], + }); + } + }) + ); + this._zoom = gfx.viewport.zoom; const updateTitle = () => { diff --git a/blocksuite/docs/api/@blocksuite/block-std/gfx/classes/GfxSelectionManager.md b/blocksuite/docs/api/@blocksuite/block-std/gfx/classes/GfxSelectionManager.md index 236c14429..71874a577 100644 --- a/blocksuite/docs/api/@blocksuite/block-std/gfx/classes/GfxSelectionManager.md +++ b/blocksuite/docs/api/@blocksuite/block-std/gfx/classes/GfxSelectionManager.md @@ -48,3 +48,21 @@ check if element is selected by remote peers #### Returns `boolean` + +*** + +### toggle() + +> **toggle**(`element`): `void` + +Toggle the selection state of single element + +#### Parameters + +##### element + +`string` | `GfxModel` + +#### Returns + +`void` diff --git a/blocksuite/framework/block-std/src/gfx/element-transform/extension/canvas-event-handler.ts b/blocksuite/framework/block-std/src/gfx/element-transform/extension/canvas-event-handler.ts index d943b0276..3292a76a4 100644 --- a/blocksuite/framework/block-std/src/gfx/element-transform/extension/canvas-event-handler.ts +++ b/blocksuite/framework/block-std/src/gfx/element-transform/extension/canvas-event-handler.ts @@ -2,12 +2,10 @@ import { Bound } from '@blocksuite/global/gfx'; import last from 'lodash-es/last'; import type { PointerEventState } from '../../../event'; +import type { GfxController } from '../..'; import type { GfxElementModelView } from '../../view/view'; -import { TransformExtension } from '../transform-manager'; - -export class CanvasEventHandler extends TransformExtension { - static override key = 'canvas-event-handler'; +export class CanvasEventHandler { private _currentStackedElm: GfxElementModelView[] = []; private _callInReverseOrder( @@ -21,22 +19,24 @@ export class CanvasEventHandler extends TransformExtension { } } - override click(_evt: PointerEventState): void { + constructor(private readonly gfx: GfxController) {} + + click(_evt: PointerEventState): void { last(this._currentStackedElm)?.dispatch('click', _evt); } - override dblClick(_evt: PointerEventState): void { + dblClick(_evt: PointerEventState): void { last(this._currentStackedElm)?.dispatch('dblclick', _evt); } - override pointerDown(_evt: PointerEventState): void { + pointerDown(_evt: PointerEventState): void { last(this._currentStackedElm)?.dispatch('pointerdown', _evt); } - override pointerMove(_evt: PointerEventState): void { + pointerMove(_evt: PointerEventState): void { const [x, y] = this.gfx.viewport.toModelCoord(_evt.x, _evt.y); const hoveredElmViews = this.gfx.grid - .search(new Bound(x, y, 1, 1), { + .search(new Bound(x - 5, y - 5, 10, 10), { filter: ['canvas', 'local'], }) .map(model => this.gfx.view.get(model)) as GfxElementModelView[]; @@ -57,7 +57,7 @@ export class CanvasEventHandler extends TransformExtension { this._currentStackedElm = hoveredElmViews; } - override pointerUp(_evt: PointerEventState): void { + pointerUp(_evt: PointerEventState): void { last(this._currentStackedElm)?.dispatch('pointerup', _evt); } } diff --git a/blocksuite/framework/block-std/src/gfx/element-transform/transform-manager.ts b/blocksuite/framework/block-std/src/gfx/element-transform/transform-manager.ts index 540e91686..0c0398880 100644 --- a/blocksuite/framework/block-std/src/gfx/element-transform/transform-manager.ts +++ b/blocksuite/framework/block-std/src/gfx/element-transform/transform-manager.ts @@ -12,6 +12,7 @@ import type { PointerEventState } from '../../event/state/pointer.js'; import { type GfxController } from '../controller.js'; import { GfxExtension, GfxExtensionIdentifier } from '../extension.js'; import { GfxControllerIdentifier } from '../identifiers.js'; +import type { GfxModel } from '../model/model.js'; import { type SupportedEvent } from '../view/view.js'; import type { DragExtensionInitializeContext, @@ -20,6 +21,7 @@ import type { ExtensionDragMoveContext, ExtensionDragStartContext, } from './drag.js'; +import { CanvasEventHandler } from './extension/canvas-event-handler.js'; type ExtensionPointerHandler = Exclude< SupportedEvent, @@ -31,10 +33,7 @@ export const TransformManagerIdentifier = GfxExtensionIdentifier( ) as ServiceIdentifier; const CAMEL_CASE_MAP: { - [key in ExtensionPointerHandler]: keyof Pick< - TransformExtension, - 'click' | 'dblClick' | 'pointerDown' | 'pointerMove' | 'pointerUp' - >; + [key in ExtensionPointerHandler]: keyof CanvasEventHandler; } = { click: 'click', dblclick: 'dblClick', @@ -48,8 +47,10 @@ export class ElementTransformManager extends GfxExtension { private readonly _disposable = new DisposableGroup(); + private canvasEventHandler = new CanvasEventHandler(this.gfx); + override mounted(): void { - // + this.canvasEventHandler = new CanvasEventHandler(this.gfx); } override unmounted(): void { @@ -72,20 +73,55 @@ export class ElementTransformManager extends GfxExtension { } } + /** + * Dispatch the event to canvas elements + * @param eventName + * @param evt + */ dispatch(eventName: ExtensionPointerHandler, evt: PointerEventState) { - const transformExtensions = this.transformExtensions; + const handlerName = CAMEL_CASE_MAP[eventName]; - transformExtensions.forEach(ext => { - const handlerMethodName = CAMEL_CASE_MAP[eventName]; + this.canvasEventHandler[handlerName](evt); - if (ext[handlerMethodName]) { - this._safeExecute(() => { - ext[handlerMethodName](evt); - }, `Error while executing extension \`${handlerMethodName}\` handler`); - } + const extension = this.transformExtensions; + + extension.forEach(ext => { + ext[handlerName]?.(evt); }); } + dispatchOnSelected(evt: PointerEventState) { + const { raw } = evt; + const { gfx } = this; + const [x, y] = gfx.viewport.toModelCoordFromClientCoord([raw.x, raw.y]); + const picked = this.gfx.getElementInGroup(x, y); + + const tryGetLockedAncestor = (e: GfxModel) => { + if (e?.isLockedByAncestor()) { + return e.groups.findLast(group => group.isLocked()) ?? e; + } + return e; + }; + + if (picked) { + const lockedElement = tryGetLockedAncestor(picked); + const multiSelect = raw.shiftKey; + const view = gfx.view.get(lockedElement); + const context = { + selected: multiSelect ? !gfx.selection.has(picked.id) : true, + multiSelect, + event: raw, + position: Point.from([x, y]), + fallback: lockedElement !== picked, + }; + + view?.onSelected(context); + return true; + } + + return false; + } + initializeDrag(options: DragInitializationOption) { let cancelledByExt = false; diff --git a/blocksuite/framework/block-std/src/gfx/element-transform/view-transform.ts b/blocksuite/framework/block-std/src/gfx/element-transform/view-transform.ts index ab2d2da6e..8abe9e1e7 100644 --- a/blocksuite/framework/block-std/src/gfx/element-transform/view-transform.ts +++ b/blocksuite/framework/block-std/src/gfx/element-transform/view-transform.ts @@ -1,4 +1,4 @@ -import type { Bound } from '@blocksuite/global/gfx'; +import type { Bound, IPoint } from '@blocksuite/global/gfx'; import type { GfxBlockComponent } from '../../view'; import type { GfxModel } from '../model/model'; @@ -34,10 +34,44 @@ export type DragMoveContext = DragStartContext & { export type DragEndContext = DragMoveContext; +export type SelectedContext = { + /** + * The selected state of the element + */ + selected: boolean; + + /** + * Whether is multi-select, usually triggered by shift key + */ + multiSelect: boolean; + + /** + * The pointer event that triggers the selection + */ + event: PointerEvent; + + /** + * The model position of the event pointer + */ + position: IPoint; + + /** + * If the current selection is a fallback selection, like selecting the element inside a group, the group will be selected instead + */ + fallback: boolean; +}; + 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 + * @param context + * @returns + */ + onSelected: (context: SelectedContext) => void; }; diff --git a/blocksuite/framework/block-std/src/gfx/index.ts b/blocksuite/framework/block-std/src/gfx/index.ts index 04ffbc72e..73b7c9c68 100644 --- a/blocksuite/framework/block-std/src/gfx/index.ts +++ b/blocksuite/framework/block-std/src/gfx/index.ts @@ -31,6 +31,7 @@ export type { DragMoveContext, DragStartContext, } from './element-transform/view-transform.js'; +export { type SelectedContext } from './element-transform/view-transform.js'; export { GfxExtension, GfxExtensionIdentifier } from './extension.js'; export { GridManager } from './grid.js'; export { GfxControllerIdentifier } from './identifiers.js'; diff --git a/blocksuite/framework/block-std/src/gfx/selection.ts b/blocksuite/framework/block-std/src/gfx/selection.ts index faa1cc96a..ecb2b8736 100644 --- a/blocksuite/framework/block-std/src/gfx/selection.ts +++ b/blocksuite/framework/block-std/src/gfx/selection.ts @@ -316,7 +316,7 @@ export class GfxSelectionManager extends GfxExtension { } const { blocks = [], elements = [] } = groupBy(selection.elements, id => { - return this.std.store.getModelById(id) ? 'blocks' : 'elements'; + return this.std.store.hasBlock(id) ? 'blocks' : 'elements'; }); let instances: (SurfaceSelection | CursorSelection)[] = []; @@ -372,6 +372,21 @@ export class GfxSelectionManager extends GfxExtension { } } + /** + * Toggle the selection state of single element + * @param element + * @returns + */ + toggle(element: GfxModel | string) { + element = typeof element === 'string' ? element : element.id; + + this.set({ + elements: this.has(element) + ? this.selectedIds.filter(id => id !== element) + : [...this.selectedIds, element], + }); + } + setCursor(cursor: CursorSelection | IPoint) { const instance = this.stdSelection.create( CursorSelection, diff --git a/blocksuite/framework/block-std/src/gfx/tool/tool-controller.ts b/blocksuite/framework/block-std/src/gfx/tool/tool-controller.ts index 7c31b6d19..ed33af89a 100644 --- a/blocksuite/framework/block-std/src/gfx/tool/tool-controller.ts +++ b/blocksuite/framework/block-std/src/gfx/tool/tool-controller.ts @@ -506,6 +506,7 @@ export class ToolController extends GfxExtension { return; } + // explicitly clear the selection when switching tools this.gfx.selection.set({ elements: [] }); this.currentTool$.peek()?.deactivate(); diff --git a/blocksuite/framework/block-std/src/gfx/view/view.ts b/blocksuite/framework/block-std/src/gfx/view/view.ts index eae50835a..a75893034 100644 --- a/blocksuite/framework/block-std/src/gfx/view/view.ts +++ b/blocksuite/framework/block-std/src/gfx/view/view.ts @@ -11,6 +11,7 @@ import type { DragMoveContext, DragStartContext, GfxViewTransformInterface, + SelectedContext, } from '../element-transform/view-transform.js'; import type { GfxController } from '../index.js'; import type { GfxElementGeometry, PointTestOptions } from '../model/base.js'; @@ -70,6 +71,10 @@ export class GfxElementModelView< return this.model.type; } + get std() { + return this.gfx.std; + } + constructor( model: T, readonly gfx: GfxController @@ -188,6 +193,16 @@ export class GfxElementModelView< this.model.xywh = currentBound.moveDelta(dx, dy).serialize(); } + onSelected(context: SelectedContext) { + if (this.model instanceof GfxPrimitiveElementModel) { + if (context.multiSelect) { + this.gfx.selection.toggle(this.model); + } else { + this.gfx.selection.set({ elements: [this.model.id] }); + } + } + } + onResize = () => {}; onRotate = () => {}; diff --git a/blocksuite/framework/block-std/src/view/element/gfx-block-component.ts b/blocksuite/framework/block-std/src/view/element/gfx-block-component.ts index b83b2e035..6dd332450 100644 --- a/blocksuite/framework/block-std/src/view/element/gfx-block-component.ts +++ b/blocksuite/framework/block-std/src/view/element/gfx-block-component.ts @@ -7,9 +7,10 @@ import type { BlockService } from '../../extension/index.js'; import type { DragMoveContext, GfxViewTransformInterface, + SelectedContext, } from '../../gfx/element-transform/view-transform.js'; import { GfxControllerIdentifier } from '../../gfx/identifiers.js'; -import type { GfxBlockElementModel } from '../../gfx/index.js'; +import { type GfxBlockElementModel } from '../../gfx/model/gfx-block-model.js'; import { SurfaceSelection } from '../../selection/index.js'; import { BlockComponent } from './block-component.js'; @@ -81,6 +82,14 @@ export abstract class GfxBlockComponent< this.model.pop('xywh'); } + onSelected(context: SelectedContext) { + if (context.multiSelect) { + this.gfx.selection.toggle(this.model); + } else { + this.gfx.selection.set({ elements: [this.model.id] }); + } + } + onRotate() {} onResize() {} @@ -186,6 +195,15 @@ export function toGfxBlockComponent< this.model.pop('xywh'); } + // eslint-disable-next-line sonarjs/no-identical-functions + onSelected(context: SelectedContext) { + if (context.multiSelect) { + this.gfx.selection.toggle(this.model); + } else { + this.gfx.selection.set({ elements: [this.model.id] }); + } + } + onRotate() {} onResize() {} diff --git a/blocksuite/integration-test/src/editors/editor-container.ts b/blocksuite/integration-test/src/editors/editor-container.ts index e9869390f..d0ea65412 100644 --- a/blocksuite/integration-test/src/editors/editor-container.ts +++ b/blocksuite/integration-test/src/editors/editor-container.ts @@ -166,7 +166,7 @@ export class TestAffineEditorContainer extends SignalWatcher( override firstUpdated() { if (this.mode === 'page') { setTimeout(() => { - if (this.autofocus) { + if (this.autofocus && this.mode === 'page') { const richText = this.querySelector('rich-text'); const inlineEditor = richText?.inlineEditor; inlineEditor?.focusEnd(); diff --git a/tests/blocksuite/e2e/edgeless/note/drag-handle.spec.ts b/tests/blocksuite/e2e/edgeless/note/drag-handle.spec.ts index b83955c30..63def08d0 100644 --- a/tests/blocksuite/e2e/edgeless/note/drag-handle.spec.ts +++ b/tests/blocksuite/e2e/edgeless/note/drag-handle.spec.ts @@ -51,6 +51,7 @@ test('drag handle should be shown when a note is activated in default mode or hi await page.mouse.move(0, 0); await setEdgelessTool(page, 'default'); await page.mouse.move(x, y); + await page.mouse.click(x, y); await expect(page.locator('.affine-drag-handle-container')).toBeVisible(); });