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
This commit is contained in:
doouding
2025-03-26 07:32:43 +00:00
parent 61c0d01da3
commit ace5d44a61
38 changed files with 779 additions and 353 deletions

View File

@@ -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<EdgelessTextBl
};
}
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);
}
}
override renderGfxBlock() {
const { model } = this;
const { rotate, hasMaxWidth } = model.props;

View File

@@ -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;
}
}

View File

@@ -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<FrameBlockModel> {
};
}
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

View File

@@ -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;

View File

@@ -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[] = [

View File

@@ -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);
}
};

View File

@@ -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,

View File

@@ -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;
}
}

View File

@@ -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<string, unknown>): void {
if (this.gfx.selection.lastSurfaceSelections.length) {
this.gfx.selection.set(this.gfx.selection.lastSurfaceSelections);
}
}
override click(e: PointerEventState) {
if (this.doc.readonly) return;
const selected = this._pick(e.x, e.y, {
ignoreTransparent: true,
});
if (selected) {
const { selectedIds, surfaceSelections } = this.edgelessSelectionManager;
const editing = surfaceSelections[0]?.editing ?? false;
// click active canvas text, edgeless text block and note block
if (
selectedIds.length === 1 &&
selectedIds[0] === selected.id &&
editing
) {
// edgeless text block and note block
if (
(isNoteBlock(selected) || isEdgelessTextBlock(selected)) &&
selected.children.length === 0
) {
this._addEmptyParagraphBlock(selected);
}
// canvas text
return;
}
// click non-active edgeless text block and note block, and then enter editing
if (
!selected.isLocked() &&
!e.keys.shift &&
selectedIds.length === 1 &&
(isNoteBlock(selected) || isEdgelessTextBlock(selected)) &&
((selectedIds[0] === selected.id && !editing) ||
(editing && selectedIds[0] !== selected.id))
) {
// issue #1809
// If the previously selected element is a noteBlock and is in an active state,
// then the currently clicked noteBlock should also be in an active state when selected.
this.edgelessSelectionManager.set({
elements: [selected.id],
editing: true,
});
this._edgeless?.updateComplete
.then(() => {
// check if block has children blocks, if not, add a paragraph block and focus on it
if (selected.children.length === 0) {
this._addEmptyParagraphBlock(selected);
} else {
const block = this.std.host.view.getBlock(selected.id);
if (block) {
const rect = block
.querySelector('.affine-block-children-container')!
.getBoundingClientRect();
const offsetY = 8 * this.gfx.viewport.zoom;
const offsetX = 2 * this.gfx.viewport.zoom;
const x = clamp(
e.raw.clientX,
rect.left + offsetX,
rect.right - offsetX
);
const y = clamp(
e.raw.clientY,
rect.top + offsetY,
rect.bottom - offsetY
);
handleNativeRangeAtPoint(x, y);
} else {
handleNativeRangeAtPoint(e.raw.clientX, e.raw.clientY);
}
}
})
.catch(console.error);
return;
}
this.edgelessSelectionManager.set({
// hold shift key to multi select or de-select element
elements: e.keys.shift
? this.edgelessSelectionManager.has(selected.id)
? selectedIds.filter(id => id !== selected.id)
: [...selectedIds, selected.id]
: [selected.id],
editing: false,
});
} else if (!e.keys.shift) {
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 {}
}

View File

@@ -53,9 +53,11 @@ export class PanTool extends BaseTool<PanToolOption> {
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', {

View File

@@ -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;

View File

@@ -54,7 +54,6 @@ export function mountConnectorLabelEditor(
const editor = new EdgelessConnectorLabelEditor();
editor.connector = connector;
editor.edgeless = edgeless;
mountElm.append(editor);
editor.updateComplete

View File

@@ -6,6 +6,8 @@ import {
GfxElementModelView,
} from '@blocksuite/block-std/gfx';
import { mountConnectorLabelEditor } from '../text/edgeless-connector-label-editor';
export class ConnectorElementView extends GfxElementModelView<ConnectorElementModel> {
static override type = 'connector';
@@ -24,4 +26,24 @@ export class ConnectorElementView extends GfxElementModelView<ConnectorElementMo
this.model.moveTo(currentBound.moveDelta(dx, dy));
};
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);
if (edgeless && !this.model.isLocked()) {
mountConnectorLabelEditor(
this.model,
edgeless,
this.gfx.viewport.toModelCoord(evt.x, evt.y)
);
}
});
}
}

View File

@@ -1,3 +1,4 @@
export * from './command';
export * from './text/text';
export * from './toolbar/config';
export * from './view';

View File

@@ -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(
></rich-text>`;
}
@property({ attribute: false })
accessor edgeless!: BlockComponent;
@property({ attribute: false })
accessor group!: GroupElementModel;
@consume({
context: stdContext,
})
accessor std!: BlockStdScope;
@query('rich-text')
accessor richText!: RichText;
}

View File

@@ -28,7 +28,6 @@ export function mountGroupTitleEditor(
const groupEditor = new EdgelessGroupTitleEditor();
groupEditor.group = group;
groupEditor.edgeless = edgeless;
mountElm.append(groupEditor);
}

View File

@@ -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<GroupElementModel> {
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);
}
});
}
}

View File

@@ -4,3 +4,4 @@ export * from './overlay';
export * from './shape-tool';
export * from './text';
export * from './toolbar';
export * from './view';

View File

@@ -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;

View File

@@ -1 +1 @@
export * from './text';
export * from './edgeless-shape-text-editor';

View File

@@ -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);
}

View File

@@ -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 = {

View File

@@ -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<ShapeElementModel> {
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);
}
});
}
}

View File

@@ -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() {

View File

@@ -1,4 +1,4 @@
export * from './edgeless-text-editor';
export * from './mount-text-editor';
export * from './tool';
export * from './toolbar';
export * from './view';

View File

@@ -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<TextElementModel> {
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,
});
}
});
}
}

View File

@@ -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 = () => {