mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-07-22 20:46:38 +08:00
823bf40a57
Close [BS-3029](https://linear.app/affine-design/issue/BS-3029/frame-里面的-shape-没办法进入文本编辑模式) Close [BS-3082](https://linear.app/affine-design/issue/BS-3082/按s切换至shape工具,在白板上点击会创建两个shape) Close [BS-3091](https://linear.app/affine-design/issue/BS-3082/按s切换至shape工具,在白板上点击会创建两个shape) ## Fix Shape Tool Issues This PR addresses several issues with the shape and mindmap tools functionality in the editor: 1. **Fix text editing after mode switching**: Resolves an issue where users couldn't edit text in shapes after switching editor modes. The fix ensures the edgeless block is properly retrieved when double-clicking on a shape. 2. **Improve tool switching behavior**: Fixes issues with tool overlays not showing or being repeated when switching between tools. This includes: - Properly handling tool overlay visibility - Ensuring only one tool is active at a time when using keyboard shortcuts - Adding proper cleanup when switching tools 3. **Add comprehensive tests**: Adds new test cases to verify: - Shape creation with keyboard shortcuts - Shape text editing after mode switching - Tool switching behavior with keyboard shortcuts
350 lines
9.0 KiB
TypeScript
350 lines
9.0 KiB
TypeScript
import {
|
|
CanvasElementType,
|
|
EXCLUDING_MOUSE_OUT_CLASS_LIST,
|
|
type SurfaceBlockComponent,
|
|
} from '@blocksuite/affine-block-surface';
|
|
import type { ShapeElementModel, ShapeName } from '@blocksuite/affine-model';
|
|
import { DefaultTheme, getShapeType } from '@blocksuite/affine-model';
|
|
import {
|
|
EditPropsStore,
|
|
TelemetryProvider,
|
|
ThemeProvider,
|
|
} from '@blocksuite/affine-shared/services';
|
|
import { hasClassNameInList } from '@blocksuite/affine-shared/utils';
|
|
import type { IBound } from '@blocksuite/global/gfx';
|
|
import { Bound } from '@blocksuite/global/gfx';
|
|
import type { PointerEventState } from '@blocksuite/std';
|
|
import { BaseTool } from '@blocksuite/std/gfx';
|
|
import { effect } from '@preact/signals-core';
|
|
|
|
import {
|
|
SHAPE_OVERLAY_HEIGHT,
|
|
SHAPE_OVERLAY_OPTIONS,
|
|
SHAPE_OVERLAY_WIDTH,
|
|
} from './consts.js';
|
|
import { ShapeOverlay } from './overlay/shape-overlay.js';
|
|
|
|
export type ShapeToolOption = {
|
|
shapeName: ShapeName;
|
|
};
|
|
|
|
export class ShapeTool extends BaseTool<ShapeToolOption> {
|
|
static override toolName: string = 'shape';
|
|
|
|
private _disableOverlay = false;
|
|
|
|
private _draggingElement: ShapeElementModel | null = null;
|
|
|
|
private _draggingElementId: string | null = null;
|
|
|
|
// shape overlay
|
|
private _shapeOverlay: ShapeOverlay | null = null;
|
|
|
|
private _spacePressedCtx: {
|
|
draggingArea: IBound & {
|
|
endX: number;
|
|
endY: number;
|
|
startX: number;
|
|
startY: number;
|
|
};
|
|
} | null = null;
|
|
|
|
private _addNewShape(
|
|
e: PointerEventState,
|
|
width: number,
|
|
height: number
|
|
): string {
|
|
const { viewport } = this.gfx;
|
|
const { shapeName } = this.activatedOption;
|
|
const attributes =
|
|
this.std.get(EditPropsStore).lastProps$.value[`shape:${shapeName}`];
|
|
|
|
if (shapeName === 'roundedRect') {
|
|
width += 40;
|
|
}
|
|
// create a shape block when drag start
|
|
const [modelX, modelY] = viewport.toModelCoord(e.point.x, e.point.y);
|
|
const bound = new Bound(modelX, modelY, width, height);
|
|
|
|
const id = this.gfx.surface!.addElement({
|
|
type: CanvasElementType.SHAPE,
|
|
shapeType: getShapeType(shapeName),
|
|
xywh: bound.serialize(),
|
|
radius: attributes.radius,
|
|
});
|
|
|
|
this.std.getOptional(TelemetryProvider)?.track('CanvasElementAdded', {
|
|
control: 'canvas:draw',
|
|
page: 'whiteboard editor',
|
|
module: 'toolbar',
|
|
segment: 'toolbar',
|
|
type: CanvasElementType.SHAPE,
|
|
other: {
|
|
shapeName,
|
|
},
|
|
});
|
|
|
|
return id;
|
|
}
|
|
|
|
private _hideOverlay() {
|
|
if (!this._shapeOverlay) return;
|
|
this._shapeOverlay.globalAlpha = 0;
|
|
(this.gfx.surfaceComponent as SurfaceBlockComponent)?.refresh();
|
|
}
|
|
|
|
private _resize(shiftPressed = false, spacePressed = false) {
|
|
const { _draggingElement, _draggingElementId: id, controller } = this;
|
|
if (!id || !_draggingElement) return;
|
|
|
|
const draggingArea = this.controller.draggingArea$.peek();
|
|
const { startX, startY } = draggingArea;
|
|
let { endX, endY } = draggingArea;
|
|
|
|
if (shiftPressed) {
|
|
const w = Math.abs(endX - startX);
|
|
const h = Math.abs(endY - startY);
|
|
const m = Math.max(w, h);
|
|
|
|
endX = startX + (endX > startX ? m : -m);
|
|
endY = startY + (endY > startY ? m : -m);
|
|
}
|
|
|
|
if (spacePressed && this._spacePressedCtx) {
|
|
const {
|
|
startX,
|
|
startY,
|
|
w,
|
|
h,
|
|
endX: pressedX,
|
|
endY: pressedY,
|
|
} = this._spacePressedCtx.draggingArea;
|
|
const curDraggingArea = controller.draggingViewArea$.peek();
|
|
const { endX: lastX, endY: lastY } = curDraggingArea;
|
|
const dx = lastX - pressedX;
|
|
const dy = lastY - pressedY;
|
|
|
|
this.controller.draggingViewArea$.value = {
|
|
...curDraggingArea,
|
|
x: Math.min(startX + dx, lastX),
|
|
y: Math.min(startY + dy, lastY),
|
|
w,
|
|
h,
|
|
startX: startX + dx,
|
|
startY: startY + dy,
|
|
};
|
|
}
|
|
|
|
const bound = new Bound(
|
|
Math.min(startX, endX),
|
|
Math.min(startY, endY),
|
|
Math.abs(startX - endX),
|
|
Math.abs(startY - endY)
|
|
);
|
|
|
|
this.gfx.updateElement(_draggingElement, {
|
|
xywh: bound.serialize(),
|
|
});
|
|
}
|
|
|
|
private _updateOverlayPosition(x: number, y: number) {
|
|
if (!this._shapeOverlay) return;
|
|
this._shapeOverlay.x = x;
|
|
this._shapeOverlay.y = y;
|
|
(this.gfx.surfaceComponent as SurfaceBlockComponent)?.refresh();
|
|
}
|
|
|
|
override activate() {
|
|
this.createOverlay();
|
|
}
|
|
|
|
clearOverlay() {
|
|
if (!this._shapeOverlay) return;
|
|
|
|
this._shapeOverlay.dispose();
|
|
(
|
|
this.gfx.surfaceComponent as SurfaceBlockComponent
|
|
)?.renderer.removeOverlay(this._shapeOverlay);
|
|
this._shapeOverlay = null;
|
|
(this.gfx.surfaceComponent as SurfaceBlockComponent)?.renderer.refresh();
|
|
}
|
|
|
|
override click(e: PointerEventState): void {
|
|
this.clearOverlay();
|
|
if (this._disableOverlay) return;
|
|
|
|
this.doc.captureSync();
|
|
|
|
const id = this._addNewShape(e, SHAPE_OVERLAY_WIDTH, SHAPE_OVERLAY_HEIGHT);
|
|
|
|
const element = this.gfx.getElementById(id);
|
|
if (!element) return;
|
|
|
|
// @ts-expect-error FIXME: resolve after gfx tool refactor
|
|
this.gfx.tool.setTool('default');
|
|
this.gfx.selection.set({
|
|
elements: [element.id],
|
|
editing: false,
|
|
});
|
|
}
|
|
|
|
createOverlay() {
|
|
this.clearOverlay();
|
|
if (this._disableOverlay) return;
|
|
const options = SHAPE_OVERLAY_OPTIONS;
|
|
const { shapeName } = this.activatedOption;
|
|
const attributes =
|
|
this.std.get(EditPropsStore).lastProps$.value[`shape:${shapeName}`];
|
|
|
|
options.stroke = this.std
|
|
.get(ThemeProvider)
|
|
.getColorValue(
|
|
attributes.strokeColor,
|
|
DefaultTheme.shapeStrokeColor,
|
|
true
|
|
);
|
|
options.fill = this.std
|
|
.get(ThemeProvider)
|
|
.getColorValue(attributes.fillColor, DefaultTheme.shapeFillColor, true);
|
|
|
|
switch (attributes.strokeStyle!) {
|
|
case 'dash':
|
|
options.strokeLineDash = [12, 12];
|
|
break;
|
|
case 'none':
|
|
options.strokeLineDash = [];
|
|
options.stroke = 'transparent';
|
|
break;
|
|
default:
|
|
options.strokeLineDash = [];
|
|
}
|
|
this._shapeOverlay = new ShapeOverlay(this.gfx, shapeName, options, {
|
|
shapeStyle: attributes.shapeStyle,
|
|
fillColor: attributes.fillColor,
|
|
strokeColor: attributes.strokeColor,
|
|
});
|
|
(this.gfx.surfaceComponent as SurfaceBlockComponent)?.renderer.addOverlay(
|
|
this._shapeOverlay
|
|
);
|
|
}
|
|
|
|
override deactivate() {
|
|
this.clearOverlay();
|
|
}
|
|
|
|
override dragEnd() {
|
|
if (this._disableOverlay) return;
|
|
if (this._draggingElement) {
|
|
const draggingElement = this._draggingElement;
|
|
|
|
draggingElement.pop('xywh');
|
|
}
|
|
|
|
const id = this._draggingElementId;
|
|
if (!id) return;
|
|
const draggingArea = this.controller.draggingArea$.peek();
|
|
|
|
if (draggingArea.w < 20 && draggingArea.h < 20) {
|
|
this.gfx.deleteElement(id);
|
|
return;
|
|
}
|
|
|
|
this._draggingElement = null;
|
|
this._draggingElementId = null;
|
|
|
|
this.doc.captureSync();
|
|
|
|
const element = this.gfx.getElementById(id);
|
|
if (!element) return;
|
|
|
|
// @ts-expect-error FIXME: resolve after gfx tool refactor
|
|
this.controller.setTool('default');
|
|
this.gfx.selection.set({
|
|
elements: [element.id],
|
|
});
|
|
}
|
|
|
|
override dragMove(e: PointerEventState) {
|
|
if (this._disableOverlay || !this._draggingElementId) return;
|
|
|
|
this._resize(
|
|
e.keys.shift || this.gfx.keyboard.shiftKey$.peek(),
|
|
this.gfx.keyboard.spaceKey$.peek()
|
|
);
|
|
}
|
|
|
|
override dragStart(e: PointerEventState) {
|
|
if (this._disableOverlay) return;
|
|
this.clearOverlay();
|
|
|
|
this.doc.captureSync();
|
|
|
|
const id = this._addNewShape(e, 0, 0);
|
|
|
|
this._spacePressedCtx = null;
|
|
this._draggingElementId = id;
|
|
this._draggingElement = this.gfx.getElementById(id) as ShapeElementModel;
|
|
this._draggingElement.stash('xywh');
|
|
}
|
|
|
|
override mounted() {
|
|
this.disposable.add(
|
|
effect(() => {
|
|
const pressed = this.gfx.keyboard.shiftKey$.value;
|
|
if (!this._draggingElementId || !this.activate) {
|
|
return;
|
|
}
|
|
|
|
this._resize(pressed);
|
|
})
|
|
);
|
|
|
|
this.disposable.add(
|
|
effect(() => {
|
|
const spacePressed = this.gfx.keyboard.spaceKey$.value;
|
|
|
|
if (spacePressed && this._draggingElementId) {
|
|
this._spacePressedCtx = {
|
|
draggingArea: this.controller.draggingViewArea$.peek(),
|
|
};
|
|
}
|
|
})
|
|
);
|
|
}
|
|
|
|
override pointerMove(e: PointerEventState) {
|
|
if (!this._shapeOverlay) return;
|
|
// shape options, like stroke color, fill color, etc.
|
|
if (this._shapeOverlay.globalAlpha === 0)
|
|
this._shapeOverlay.globalAlpha = 1;
|
|
const [x, y] = this.gfx.viewport.toModelCoord(e.x, e.y);
|
|
this._updateOverlayPosition(x, y);
|
|
}
|
|
|
|
override pointerOut(e: PointerEventState) {
|
|
if (
|
|
e.raw.relatedTarget &&
|
|
hasClassNameInList(
|
|
e.raw.relatedTarget as Element,
|
|
EXCLUDING_MOUSE_OUT_CLASS_LIST
|
|
)
|
|
)
|
|
return;
|
|
this._hideOverlay();
|
|
}
|
|
|
|
setDisableOverlay(disable: boolean) {
|
|
this._disableOverlay = disable;
|
|
}
|
|
}
|
|
|
|
declare module '@blocksuite/std/gfx' {
|
|
interface GfxToolsMap {
|
|
shape: ShapeTool;
|
|
}
|
|
|
|
interface GfxToolsOption {
|
|
shape: ShapeToolOption;
|
|
}
|
|
}
|