diff --git a/packages/blocks/src/_specs/preset/edgeless-specs.ts b/packages/blocks/src/_specs/preset/edgeless-specs.ts index 794e6fd847b3..3a5937814ebb 100644 --- a/packages/blocks/src/_specs/preset/edgeless-specs.ts +++ b/packages/blocks/src/_specs/preset/edgeless-specs.ts @@ -2,11 +2,18 @@ import type { ExtensionType } from '@blocksuite/block-std'; import { EdgelessSurfaceBlockSpec } from '@blocksuite/affine-block-surface'; import { FontLoaderService } from '@blocksuite/affine-shared/services'; +import { GfxToolExtension } from '@blocksuite/block-std/gfx'; import { EdgelessTextBlockSpec } from '../../edgeless-text-block/edgeless-text-spec.js'; import { FrameBlockSpec } from '../../frame-block/frame-spec.js'; import { LatexBlockSpec } from '../../latex-block/latex-spec.js'; import { EdgelessRootBlockSpec } from '../../root-block/edgeless/edgeless-root-spec.js'; +import { DefaultTool } from '../../root-block/edgeless/gfx-tool/default-tool.js'; +import { EraserTool } from '../../root-block/edgeless/gfx-tool/eraser-tool.js'; +import { NoteTool } from '../../root-block/edgeless/gfx-tool/note-tool.js'; +import { PanTool } from '../../root-block/edgeless/gfx-tool/pan-tool.js'; +import { ShapeTool } from '../../root-block/edgeless/gfx-tool/shape-tool.js'; +import { TextTool } from '../../root-block/edgeless/gfx-tool/text-tool.js'; import { EdgelessSurfaceRefBlockSpec } from '../../surface-ref-block/surface-ref-spec.js'; import { EdgelessFirstPartyBlockSpecs } from '../common.js'; @@ -19,4 +26,12 @@ export const EdgelessEditorBlockSpecs: ExtensionType[] = [ EdgelessTextBlockSpec, LatexBlockSpec, FontLoaderService, + GfxToolExtension([ + DefaultTool, + PanTool, + EraserTool, + TextTool, + ShapeTool, + NoteTool, + ]), ].flat(); diff --git a/packages/blocks/src/root-block/edgeless/gfx-tool/default-tool.ts b/packages/blocks/src/root-block/edgeless/gfx-tool/default-tool.ts new file mode 100644 index 000000000000..61dcb393fc6a --- /dev/null +++ b/packages/blocks/src/root-block/edgeless/gfx-tool/default-tool.ts @@ -0,0 +1,13 @@ +import { BaseTool } from '@blocksuite/block-std/gfx'; + +export class DefaultTool extends BaseTool { + static override toolName: string = 'default'; +} + +declare global { + namespace BlockSuite { + interface GfxToolsMap { + default: DefaultTool; + } + } +} diff --git a/packages/blocks/src/root-block/edgeless/gfx-tool/eraser-tool.ts b/packages/blocks/src/root-block/edgeless/gfx-tool/eraser-tool.ts new file mode 100644 index 000000000000..c2816910379d --- /dev/null +++ b/packages/blocks/src/root-block/edgeless/gfx-tool/eraser-tool.ts @@ -0,0 +1,166 @@ +import type { PointerEventState } from '@blocksuite/block-std'; + +import { + CommonUtils, + Overlay, + type SurfaceBlockComponent, +} from '@blocksuite/affine-block-surface'; +import { BaseTool } from '@blocksuite/block-std/gfx'; +import { Bound, type IVec } from '@blocksuite/global/utils'; + +import { deleteElementsV2 } from '../utils/crud.js'; +import { isTopLevelBlock } from '../utils/query.js'; + +const { getSvgPathFromStroke, getStroke, linePolygonIntersects } = CommonUtils; + +class EraserOverlay extends Overlay { + d = ''; + + override render(ctx: CanvasRenderingContext2D): void { + ctx.globalAlpha = 0.33; + const path = new Path2D(this.d); + ctx.fillStyle = '#aaa'; + ctx.fill(path); + } +} + +export class EraserTool extends BaseTool { + static override toolName = 'eraser'; + + private _erasable = new Set(); + + private _eraserPoints: IVec[] = []; + + private _eraseTargets = new Set(); + + private _loop = () => { + const now = Date.now(); + const elapsed = now - this._timestamp; + + let didUpdate = false; + + if (this._prevEraserPoint !== this._prevPoint) { + didUpdate = true; + this._eraserPoints.push(this._prevPoint); + this._prevEraserPoint = this._prevPoint; + } + if (elapsed > 32) { + if (this._eraserPoints.length > 1) { + didUpdate = true; + this._eraserPoints.splice( + 0, + Math.ceil(this._eraserPoints.length * 0.1) + ); + this._timestamp = now; + } + } + if (didUpdate) { + const zoom = this.gfx.viewport.zoom; + const d = getSvgPathFromStroke( + getStroke(this._eraserPoints, { + size: 16 / zoom, + start: { taper: true }, + }) + ); + this._overlay.d = d; + (this.gfx.surfaceComponent as SurfaceBlockComponent)?.refresh(); + } + this._timer = requestAnimationFrame(this._loop); + }; + + private _overlay = new EraserOverlay(); + + private _prevEraserPoint: IVec = [0, 0]; + + private _prevPoint: IVec = [0, 0]; + + private _timer = 0; + + private _timestamp = 0; + + private _reset() { + cancelAnimationFrame(this._timer); + + if (!this.gfx.surface) { + return; + } + + ( + this.gfx.surfaceComponent as SurfaceBlockComponent + )?.renderer.removeOverlay(this._overlay); + this._erasable.clear(); + this._eraseTargets.clear(); + } + + override activate(_: Record): void { + this._eraseTargets.forEach(erasable => { + if (isTopLevelBlock(erasable)) { + const ele = this.std.view.getBlock(erasable.id); + ele && ((ele as HTMLElement).style.opacity = '1'); + } else { + erasable.opacity = 1; + } + }); + this._reset(); + } + + override dragEnd(_: PointerEventState): void { + deleteElementsV2(this.gfx, Array.from(this._eraseTargets)); + this._reset(); + this.doc.captureSync(); + } + + override dragMove(e: PointerEventState): void { + const currentPoint = this.gfx.viewport.toModelCoord(e.point.x, e.point.y); + this._erasable.forEach(erasable => { + if (this._eraseTargets.has(erasable)) return; + if (isTopLevelBlock(erasable)) { + const bound = Bound.deserialize(erasable.xywh); + if ( + linePolygonIntersects(this._prevPoint, currentPoint, bound.points) + ) { + this._eraseTargets.add(erasable); + const ele = this.std.view.getBlock(erasable.id); + ele && ((ele as HTMLElement).style.opacity = '0.3'); + } + } else { + if ( + erasable.getLineIntersections( + this._prevPoint as IVec, + currentPoint as IVec + ) + ) { + this._eraseTargets.add(erasable); + erasable.opacity = 0.3; + } + } + }); + + this._prevPoint = currentPoint; + } + + override dragStart(e: PointerEventState): void { + this.doc.captureSync(); + + const { point } = e; + const [x, y] = this.gfx.viewport.toModelCoord(point.x, point.y); + this._eraserPoints = [[x, y]]; + this._prevPoint = [x, y]; + this._erasable = new Set([ + ...this.gfx.layer.canvasElements, + ...this.gfx.layer.blocks, + ]); + this._loop(); + (this.gfx.surfaceComponent as SurfaceBlockComponent)?.renderer.addOverlay( + this._overlay + ); + } +} + +declare global { + namespace BlockSuite { + interface GfxToolsMap { + eraser: EraserTool; + } + } +} diff --git a/packages/blocks/src/root-block/edgeless/gfx-tool/note-tool.ts b/packages/blocks/src/root-block/edgeless/gfx-tool/note-tool.ts new file mode 100644 index 000000000000..30dd211fa178 --- /dev/null +++ b/packages/blocks/src/root-block/edgeless/gfx-tool/note-tool.ts @@ -0,0 +1,338 @@ +import type { SurfaceBlockComponent } from '@blocksuite/affine-block-surface'; +import type { PointerEventState } from '@blocksuite/block-std'; + +import { focusTextModel } from '@blocksuite/affine-components/rich-text'; +import { type NoteBlockModel, NoteDisplayMode } from '@blocksuite/affine-model'; +import { + EditPropsStore, + TelemetryProvider, +} from '@blocksuite/affine-shared/services'; +import { handleNativeRangeAtPoint } from '@blocksuite/affine-shared/utils'; +import { BaseTool, type GfxController } from '@blocksuite/block-std/gfx'; +import { type IPoint, Point, serializeXYWH } from '@blocksuite/global/utils'; +import { effect } from '@preact/signals-core'; + +import type { EdgelessTool } from '../types.js'; + +import { + hasClassNameInList, + type NoteChildrenFlavour, +} from '../../../_common/utils/index.js'; +import { + DEFAULT_NOTE_HEIGHT, + DEFAULT_NOTE_OFFSET_X, + DEFAULT_NOTE_OFFSET_Y, + DEFAULT_NOTE_WIDTH, + EXCLUDING_MOUSE_OUT_CLASS_LIST, + NOTE_INIT_HEIGHT, + NOTE_MIN_HEIGHT, + NOTE_MIN_WIDTH, +} from '../utils/consts.js'; +import { DraggingNoteOverlay, NoteOverlay } from '../utils/tool-overlay.js'; + +type NoteOptions = { + childFlavour: NoteChildrenFlavour; + childType: string | null; + collapse: boolean; +}; + +function addNoteWithPoint( + gfx: GfxController, + point: IPoint, + options: { + width?: number; + height?: number; + parentId?: string; + noteIndex?: number; + offsetX?: number; + offsetY?: number; + scale?: number; + } = {} +) { + const { + width = DEFAULT_NOTE_WIDTH, + height = DEFAULT_NOTE_HEIGHT, + offsetX = DEFAULT_NOTE_OFFSET_X, + offsetY = DEFAULT_NOTE_OFFSET_Y, + parentId = gfx.doc.root?.id, + noteIndex: noteIndex, + scale = 1, + } = options; + const [x, y] = gfx.viewport.toModelCoord(point.x, point.y); + const blockId = gfx.doc.addBlock( + 'affine:note', + { + xywh: serializeXYWH( + x - offsetX * scale, + y - offsetY * scale, + width, + height + ), + displayMode: NoteDisplayMode.EdgelessOnly, + }, + parentId, + noteIndex + ); + + gfx.std.getOptional(TelemetryProvider)?.track('CanvasElementAdded', { + control: 'canvas:draw', + page: 'whiteboard editor', + module: 'toolbar', + segment: 'toolbar', + type: 'note', + }); + + return blockId; +} + +function addNote( + gfx: GfxController, + point: Point, + options: NoteOptions, + width = DEFAULT_NOTE_WIDTH, + height = DEFAULT_NOTE_HEIGHT +) { + const noteId = addNoteWithPoint(gfx, point, { + width, + height, + }); + + const doc = gfx.doc; + + const blockId = doc.addBlock( + options.childFlavour, + { type: options.childType }, + noteId + ); + if (options.collapse && height > NOTE_MIN_HEIGHT) { + const note = doc.getBlockById(noteId) as NoteBlockModel; + doc.updateBlock(note, () => { + note.edgeless.collapse = true; + note.edgeless.collapsedHeight = height; + }); + } + gfx.tool.setTool('default'); + + // Wait for edgelessTool updated + requestAnimationFrame(() => { + const blocks = + (doc.root?.children.filter( + child => child.flavour === 'affine:note' + ) as BlockSuite.EdgelessBlockModelType[]) ?? []; + const element = blocks.find(b => b.id === noteId); + if (element) { + gfx.selection.set({ + elements: [element.id], + editing: true, + }); + + // Waiting dom updated, `note mask` is removed + if (blockId) { + focusTextModel(gfx.std, blockId); + } else { + // Cannot reuse `handleNativeRangeClick` directly here, + // since `retargetClick` will re-target to pervious editor + handleNativeRangeAtPoint(point.x, point.y); + } + } + }); +} + +export type NoteToolOption = { + type: 'affine:note'; + childFlavour: NoteChildrenFlavour; + childType: string | null; + tip: string; +}; + +export class NoteTool extends BaseTool { + static override toolName = 'affine:note'; + + private _draggingNoteOverlay: DraggingNoteOverlay | null = null; + + private _noteOverlay: NoteOverlay | null = null; + + // Ensure clear overlay before adding a new note + private _clearOverlay() { + this._noteOverlay = this._disposeOverlay(this._noteOverlay); + this._draggingNoteOverlay = this._disposeOverlay(this._draggingNoteOverlay); + (this.gfx.surfaceComponent as SurfaceBlockComponent).refresh(); + } + + private _disposeOverlay(overlay: NoteOverlay | null) { + if (!overlay) return null; + + overlay.dispose(); + ( + this.gfx.surfaceComponent as SurfaceBlockComponent + )?.renderer.removeOverlay(overlay); + return null; + } + + // Should hide overlay when mouse is out of viewport or on menu and toolbar + private _hideOverlay() { + if (!this._noteOverlay) return; + + this._noteOverlay.globalAlpha = 0; + (this.gfx.surfaceComponent as SurfaceBlockComponent)?.refresh(); + } + + private _resize(shift = false) { + const { _draggingNoteOverlay } = this; + if (!_draggingNoteOverlay) return; + + const draggingArea = this.controller.draggingArea$.peek(); + const { startX, startY } = draggingArea; + let { endX, endY } = this.controller.draggingArea$.peek(); + + if (shift) { + 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); + } + + _draggingNoteOverlay.slots.draggingNoteUpdated.emit({ + xywh: [ + Math.min(startX, endX), + Math.min(startY, endY), + Math.abs(startX - endX), + Math.abs(startY - endY), + ], + }); + } + + private _updateOverlayPosition(x: number, y: number) { + if (!this._noteOverlay) return; + this._noteOverlay.x = x; + this._noteOverlay.y = y; + (this.gfx.surfaceComponent as SurfaceBlockComponent).refresh(); + } + + override activate(newTool: EdgelessTool) { + if (newTool.type !== 'affine:note') return; + + const attributes = + this.std.get(EditPropsStore).lastProps$.value['affine:note']; + const background = attributes.background; + this._noteOverlay = new NoteOverlay(this.gfx, background); + this._noteOverlay.text = newTool.tip; + (this.gfx.surfaceComponent as SurfaceBlockComponent).renderer.addOverlay( + this._noteOverlay + ); + } + + override click(e: PointerEventState): void { + this._clearOverlay(); + + const { childFlavour, childType } = this.activatedOption; + const options = { + childFlavour, + childType, + collapse: false, + }; + const point = new Point(e.point.x, e.point.y); + addNote(this.gfx, point, options); + } + + override deactivate() { + this._clearOverlay(); + } + + override dragEnd() { + if (!this._draggingNoteOverlay) return; + + const { x, y, width, height } = this._draggingNoteOverlay; + + this._disposeOverlay(this._draggingNoteOverlay); + + const { childFlavour, childType } = this.activatedOption; + + const options = { + childFlavour, + childType, + collapse: true, + }; + + const [viewX, viewY] = this.gfx.viewport.toViewCoord(x, y); + + const point = new Point(viewX, viewY); + + this.doc.captureSync(); + + addNote( + this.gfx, + point, + options, + Math.max(width, NOTE_MIN_WIDTH), + Math.max(height, NOTE_INIT_HEIGHT) + ); + } + + override dragMove(e: PointerEventState) { + if (!this._draggingNoteOverlay) return; + + this._resize(e.keys.shift || this.gfx.keyboard.shiftKey$.peek()); + } + + override dragStart() { + this._clearOverlay(); + + const attributes = + this.std.get(EditPropsStore).lastProps$.value['affine:note']; + const background = attributes.background; + this._draggingNoteOverlay = new DraggingNoteOverlay(this.gfx, background); + (this.gfx.surfaceComponent as SurfaceBlockComponent).renderer.addOverlay( + this._draggingNoteOverlay + ); + } + + override onload() { + this.disposable.add( + effect(() => { + const shiftPressed = this.gfx.keyboard.shiftKey$.value; + + if (!this._draggingNoteOverlay) { + return; + } + + this._resize(shiftPressed); + }) + ); + } + + override pointerMove(e: PointerEventState) { + if (!this._noteOverlay) return; + + // if mouse is in viewport and move, update overlay pointion and show overlay + if (this._noteOverlay.globalAlpha === 0) this._noteOverlay.globalAlpha = 1; + const [x, y] = this.gfx.viewport.toModelCoord(e.x, e.y); + this._updateOverlayPosition(x, y); + } + + override pointerOut(e: PointerEventState) { + // should not hide the overlay when pointer on the area of other notes + if ( + e.raw.relatedTarget && + hasClassNameInList( + e.raw.relatedTarget as Element, + EXCLUDING_MOUSE_OUT_CLASS_LIST + ) + ) + return; + this._hideOverlay(); + } +} + +declare global { + namespace BlockSuite { + interface GfxToolsMap { + 'affine:note': NoteTool; + } + + interface GfxToolsOption { + 'affine:note': NoteToolOption; + } + } +} diff --git a/packages/blocks/src/root-block/edgeless/gfx-tool/pan-tool.ts b/packages/blocks/src/root-block/edgeless/gfx-tool/pan-tool.ts new file mode 100644 index 000000000000..82c95177ba98 --- /dev/null +++ b/packages/blocks/src/root-block/edgeless/gfx-tool/pan-tool.ts @@ -0,0 +1,45 @@ +import type { PointerEventState } from '@blocksuite/block-std'; + +import { BaseTool } from '@blocksuite/block-std/gfx'; +import { Signal } from '@preact/signals-core'; + +export class PanTool extends BaseTool { + static override toolName = 'pan'; + + private _lastPoint: [number, number] | null = null; + + readonly panning$ = new Signal(false); + + override dragEnd(_: PointerEventState): void { + this._lastPoint = null; + this.panning$.value = false; + } + + override dragMove(e: PointerEventState): void { + if (!this._lastPoint) return; + + const { viewport } = this.gfx; + const { zoom } = viewport; + + const [lastX, lastY] = this._lastPoint; + const deltaX = lastX - e.x; + const deltaY = lastY - e.y; + + this._lastPoint = [e.x, e.y]; + + viewport.applyDeltaCenter(deltaX / zoom, deltaY / zoom); + } + + override dragStart(e: PointerEventState): void { + this._lastPoint = [e.x, e.y]; + this.panning$.value = true; + } +} + +declare global { + namespace BlockSuite { + interface GfxToolsMap { + pan: PanTool; + } + } +} diff --git a/packages/blocks/src/root-block/edgeless/gfx-tool/shape-tool.ts b/packages/blocks/src/root-block/edgeless/gfx-tool/shape-tool.ts new file mode 100644 index 000000000000..c9d183f2bd20 --- /dev/null +++ b/packages/blocks/src/root-block/edgeless/gfx-tool/shape-tool.ts @@ -0,0 +1,357 @@ +import type { ShapeElementModel, ShapeName } from '@blocksuite/affine-model'; +import type { PointerEventState } from '@blocksuite/block-std'; +import type { IBound, IPoint } from '@blocksuite/global/utils'; + +import { + CanvasElementType, + type SurfaceBlockComponent, +} from '@blocksuite/affine-block-surface'; +import { + DEFAULT_SHAPE_FILL_COLOR, + DEFAULT_SHAPE_STROKE_COLOR, + getShapeType, +} from '@blocksuite/affine-model'; +import { + EditPropsStore, + TelemetryProvider, +} from '@blocksuite/affine-shared/services'; +import { ThemeObserver } from '@blocksuite/affine-shared/theme'; +import { BaseTool } from '@blocksuite/block-std/gfx'; +import { Bound } from '@blocksuite/global/utils'; +import { effect } from '@preact/signals-core'; + +import { hasClassNameInList } from '../../../_common/utils/index.js'; +import { + EXCLUDING_MOUSE_OUT_CLASS_LIST, + SHAPE_OVERLAY_HEIGHT, + SHAPE_OVERLAY_OPTIONS, + SHAPE_OVERLAY_WIDTH, +} from '../utils/consts.js'; +import { ShapeOverlay } from '../utils/tool-overlay.js'; + +export class ShapeTool extends BaseTool<{ + shapeName: ShapeName; +}> { + 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; + }; + pressedPos: IPoint; + } | 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 _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(); + } + + private _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 = ThemeObserver.getColorValue( + attributes.strokeColor, + DEFAULT_SHAPE_STROKE_COLOR, + true + ); + options.fill = ThemeObserver.getColorValue( + attributes.fillColor, + DEFAULT_SHAPE_FILL_COLOR, + 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 + ); + } + + private _hideOverlay() { + if (!this._shapeOverlay) return; + + this._shapeOverlay.globalAlpha = 0; + (this.gfx.surfaceComponent as SurfaceBlockComponent)?.refresh(); + } + + private _resize(shiftPressed = false, spacePressed = false) { + const { _draggingElementId: id, controller } = this; + if (!id) return; + + const { viewport } = this.gfx; + const draggingArea = this.controller.draggingArea$.peek(); + let { startX, startY, 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 lastMousePos = controller.lastMousePos$.peek(); + const pressedPos = this._spacePressedCtx.pressedPos; + const pressedArea = this._spacePressedCtx.draggingArea; + const [pressedX, pressedY] = viewport.toModelCoord( + pressedPos.x, + pressedPos.y + ); + const [currentX, currentY] = viewport.toModelCoord( + lastMousePos.x, + lastMousePos.y + ); + + const deltaX = currentX - pressedX; + const deltaY = currentY - pressedY; + + startX = pressedArea.startX + deltaX; + endX = pressedArea.endX + deltaX; + startY = pressedArea.startY + deltaY; + endY = pressedArea.endY + deltaY; + } + + const bound = new Bound( + Math.min(startX, endX), + Math.min(startY, endY), + Math.abs(startX - endX), + Math.abs(startY - endY) + ); + + this.gfx.surface?.updateElement(id, { + 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(option: typeof this.activatedOption) { + super.activate(option); + this._createOverlay(); + } + + 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; + + this.gfx.tool.setTool('default'); + this.gfx.selection.set({ + elements: [element.id], + editing: false, + }); + } + + 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; + + 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 onload() { + 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.draggingArea$.peek(), + pressedPos: this.controller.lastMousePos$.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(); + + this.gfx.tool.setTool('pan'); + } + + setDisableOverlay(disable: boolean) { + this._disableOverlay = disable; + } +} + +declare global { + namespace BlockSuite { + interface GfxToolsMap { + shape: ShapeTool; + } + + interface GfxToolsOption { + shape: { + shapeName: ShapeName; + }; + } + } +} diff --git a/packages/blocks/src/root-block/edgeless/gfx-tool/text-tool.ts b/packages/blocks/src/root-block/edgeless/gfx-tool/text-tool.ts new file mode 100644 index 000000000000..ede0ffa7224f --- /dev/null +++ b/packages/blocks/src/root-block/edgeless/gfx-tool/text-tool.ts @@ -0,0 +1,71 @@ +import type { TextElementModel } from '@blocksuite/affine-model'; +import type { PointerEventState } from '@blocksuite/block-std'; + +import { TelemetryProvider } from '@blocksuite/affine-shared/services'; +import { BaseTool, type GfxController } from '@blocksuite/block-std/gfx'; +import { Bound } from '@blocksuite/global/utils'; +import { DocCollection } from '@blocksuite/store'; + +import type { EdgelessRootBlockComponent } from '../edgeless-root-block.js'; + +import { mountTextElementEditor } from '../utils/text.js'; + +export function addText(gfx: GfxController, event: PointerEventState) { + 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); + + if (!gfx.surface) { + return; + } + + const id = gfx.surface.addElement({ + type: 'text', + xywh: new Bound(modelX, modelY, 32, 32).serialize(), + text: new DocCollection.Y.Text(), + }); + gfx.doc.captureSync(); + const textElement = gfx.getElementById(id) as TextElementModel; + const edgelessView = gfx.std.view.getBlock(gfx.std.doc.root!.id); + mountTextElementEditor( + textElement, + edgelessView as EdgelessRootBlockComponent + ); + } +} + +export class TextTool extends BaseTool { + static override toolName: string = 'text'; + + override click(e: PointerEventState): void { + const textFlag = this.gfx.doc.awarenessStore.getFlag( + 'enable_edgeless_text' + ); + + if (textFlag) { + const [x, y] = this.gfx.viewport.toModelCoord(e.x, e.y); + this.gfx.std.command.exec('insertEdgelessText', { x, y }); + this.gfx.tool.setTool('default'); + } else { + addText(this.gfx, e); + } + + this.gfx.std.get(TelemetryProvider)?.track('CanvasElementAdded', { + control: 'canvas:draw', + page: 'whiteboard editor', + module: 'toolbar', + segment: 'toolbar', + type: 'text', + }); + } +} + +declare global { + namespace BlockSuite { + interface GfxToolsMap { + text: TextTool; + } + } +} diff --git a/packages/blocks/src/root-block/edgeless/tools/note-tool.ts b/packages/blocks/src/root-block/edgeless/tools/note-tool.ts index cdef151020e0..2ecc3423e1a0 100644 --- a/packages/blocks/src/root-block/edgeless/tools/note-tool.ts +++ b/packages/blocks/src/root-block/edgeless/tools/note-tool.ts @@ -108,7 +108,7 @@ export class NoteToolController extends EdgelessToolController { const attributes = this._edgeless.std.get(EditPropsStore).lastProps$.value['affine:note']; const background = attributes.background; - this._noteOverlay = new NoteOverlay(this._edgeless, background); + this._noteOverlay = new NoteOverlay(this._service.gfx, background); this._noteOverlay.text = newTool.tip; this._edgeless.surface.renderer.addOverlay(this._noteOverlay); } @@ -183,7 +183,7 @@ export class NoteToolController extends EdgelessToolController { this._edgeless.std.get(EditPropsStore).lastProps$.value['affine:note']; const background = attributes.background; this._draggingNoteOverlay = new DraggingNoteOverlay( - this._edgeless, + this._service.gfx, background ); this._edgeless.surface.renderer.addOverlay(this._draggingNoteOverlay); diff --git a/packages/blocks/src/root-block/edgeless/tools/shape-tool.ts b/packages/blocks/src/root-block/edgeless/tools/shape-tool.ts index b8393f690126..bd940687d83e 100644 --- a/packages/blocks/src/root-block/edgeless/tools/shape-tool.ts +++ b/packages/blocks/src/root-block/edgeless/tools/shape-tool.ts @@ -216,11 +216,16 @@ export class ShapeToolController extends EdgelessToolController { default: options.strokeLineDash = []; } - this._shapeOverlay = new ShapeOverlay(this._edgeless, shapeName, options, { - shapeStyle: attributes.shapeStyle, - fillColor: attributes.fillColor, - strokeColor: attributes.strokeColor, - }); + this._shapeOverlay = new ShapeOverlay( + this._service.gfx, + shapeName, + options, + { + shapeStyle: attributes.shapeStyle, + fillColor: attributes.fillColor, + strokeColor: attributes.strokeColor, + } + ); this._edgeless.surface.renderer.addOverlay(this._shapeOverlay); } diff --git a/packages/blocks/src/root-block/edgeless/utils/crud.ts b/packages/blocks/src/root-block/edgeless/utils/crud.ts index 30fc9ca13d29..77991370c19f 100644 --- a/packages/blocks/src/root-block/edgeless/utils/crud.ts +++ b/packages/blocks/src/root-block/edgeless/utils/crud.ts @@ -1,8 +1,15 @@ +import type { SurfaceBlockModel } from '@blocksuite/affine-block-surface'; +import type { GfxController } from '@blocksuite/block-std/gfx'; + import type { Connectable } from '../../../_common/utils/index.js'; import type { EdgelessRootBlockComponent } from '../index.js'; import { isConnectable, isNoteBlock } from './query.js'; +/** + * Use deleteElementsV2 instead. + * @deprecated + */ export function deleteElements( edgeless: EdgelessRootBlockComponent, elements: BlockSuite.EdgelessModel[] @@ -29,3 +36,30 @@ export function deleteElements( } }); } + +export function deleteElementsV2( + gfx: GfxController, + elements: BlockSuite.EdgelessModel[] +) { + const set = new Set(elements); + + elements.forEach(element => { + if (isConnectable(element)) { + const connectors = (gfx.surface as SurfaceBlockModel).getConnectors( + element.id + ); + connectors.forEach(connector => set.add(connector)); + } + }); + + set.forEach(element => { + if (isNoteBlock(element)) { + const children = gfx.doc.root?.children ?? []; + if (children.length > 1) { + gfx.doc.deleteBlock(element); + } + } else { + gfx.deleteElement(element.id); + } + }); +} diff --git a/packages/blocks/src/root-block/edgeless/utils/tool-overlay.ts b/packages/blocks/src/root-block/edgeless/utils/tool-overlay.ts index dc5377ac75e6..bd2e5d516083 100644 --- a/packages/blocks/src/root-block/edgeless/utils/tool-overlay.ts +++ b/packages/blocks/src/root-block/edgeless/utils/tool-overlay.ts @@ -1,9 +1,11 @@ +import type { GfxController } from '@blocksuite/block-std/gfx'; import type { XYWH } from '@blocksuite/global/utils'; import { type Options, Overlay, type RoughCanvas, + type SurfaceBlockComponent, } from '@blocksuite/affine-block-surface'; import { type Color, @@ -14,9 +16,16 @@ import { type ShapeStyle, } from '@blocksuite/affine-model'; import { ThemeObserver } from '@blocksuite/affine-shared/theme'; -import { Bound, DisposableGroup, noop, Slot } from '@blocksuite/global/utils'; +import { + assertType, + Bound, + DisposableGroup, + noop, + Slot, +} from '@blocksuite/global/utils'; +import { effect } from '@preact/signals-core'; -import type { EdgelessRootBlockComponent } from '../edgeless-root-block.js'; +import type { ShapeTool } from '../gfx-tool/shape-tool.js'; import { NOTE_OVERLAY_CORNER_RADIUS, @@ -221,9 +230,9 @@ export class ShapeFactory { } class ToolOverlay extends Overlay { - protected disposables!: DisposableGroup; + protected disposables = new DisposableGroup(); - protected edgeless: EdgelessRootBlockComponent; + protected gfx: GfxController; globalAlpha: number; @@ -231,23 +240,18 @@ class ToolOverlay extends Overlay { y: number; - constructor(edgeless: EdgelessRootBlockComponent) { + constructor(gfx: GfxController) { super(); this.x = 0; this.y = 0; this.globalAlpha = 0; - this.edgeless = edgeless; - this.disposables = new DisposableGroup(); + this.gfx = gfx; this.disposables.add( - this.edgeless.service.viewport.viewportUpdated.on(() => { + this.gfx.viewport.viewportUpdated.on(() => { // when viewport is updated, we should keep the overlay in the same position // to get last mouse position and convert it to model coordinates - const lastX = this.edgeless.tools.lastMousePos.x; - const lastY = this.edgeless.tools.lastMousePos.y; - const [x, y] = this.edgeless.service.viewport.toModelCoord( - lastX, - lastY - ); + const pos = this.gfx.tool.lastMousePos$.value; + const [x, y] = this.gfx.viewport.toModelCoord(pos.x, pos.y); this.x = x; this.y = y; }) @@ -267,7 +271,7 @@ export class ShapeOverlay extends ToolOverlay { shape: Shape; constructor( - edgeless: EdgelessRootBlockComponent, + gfx: GfxController, type: string, options: Options, style: { @@ -276,7 +280,7 @@ export class ShapeOverlay extends ToolOverlay { strokeColor: Color; } ) { - super(edgeless); + super(gfx); const xywh = [ this.x, this.y, @@ -300,9 +304,14 @@ export class ShapeOverlay extends ToolOverlay { this.shape = ShapeFactory.createShape(xywh, type, options, shapeStyle); this.disposables.add( - this.edgeless.slots.edgelessToolUpdated.on(edgelessTool => { - if (edgelessTool.type !== 'shape') return; - const { shapeName } = edgelessTool; + effect(() => { + const currentTool = this.gfx.tool.currentTool$.value; + + if (currentTool?.toolName !== 'shape') return; + + assertType(currentTool); + + const { shapeName } = currentTool.activatedOption; const newOptions = { ...options, }; @@ -323,7 +332,8 @@ export class ShapeOverlay extends ToolOverlay { newOptions, shapeStyle ); - this.edgeless.surface.refresh(); + + (this.gfx.surfaceComponent as SurfaceBlockComponent).refresh(); }) ); } @@ -349,8 +359,8 @@ export class NoteOverlay extends ToolOverlay { text = ''; - constructor(edgeless: EdgelessRootBlockComponent, background: Color) { - super(edgeless); + constructor(gfx: GfxController, background: Color) { + super(gfx); this.globalAlpha = 0; this.backgroundColor = ThemeObserver.getColorValue( background, @@ -358,11 +368,13 @@ export class NoteOverlay extends ToolOverlay { true ); this.disposables.add( - this.edgeless.slots.edgelessToolUpdated.on(edgelessTool => { + effect(() => { // when change note child type, update overlay text - if (edgelessTool.type !== 'affine:note') return; - this.text = this._getOverlayText(edgelessTool.tip); - this.edgeless.surface.refresh(); + if (this.gfx.tool.currentToolName$.value !== 'affine:note') return; + const tool = + this.gfx.tool.currentTool$.peek() as BlockSuite.GfxToolsMap['affine:note']; + this.text = this._getOverlayText(tool.activatedOption.tip); + (this.gfx.surfaceComponent as SurfaceBlockComponent).refresh(); }) ); } @@ -449,8 +461,8 @@ export class DraggingNoteOverlay extends NoteOverlay { width: number; - constructor(edgeless: EdgelessRootBlockComponent, background: Color) { - super(edgeless, background); + constructor(gfx: GfxController, background: Color) { + super(gfx, background); this.slots = { draggingNoteUpdated: new Slot<{ xywh: XYWH; @@ -461,7 +473,7 @@ export class DraggingNoteOverlay extends NoteOverlay { this.disposables.add( this.slots.draggingNoteUpdated.on(({ xywh }) => { [this.x, this.y, this.width, this.height] = xywh; - this.edgeless.surface.refresh(); + (this.gfx.surfaceComponent as SurfaceBlockComponent).refresh(); }) ); } diff --git a/packages/framework/block-std/src/gfx/controller.ts b/packages/framework/block-std/src/gfx/controller.ts index c4648d1c68a2..f58d67e04d09 100644 --- a/packages/framework/block-std/src/gfx/controller.ts +++ b/packages/framework/block-std/src/gfx/controller.ts @@ -2,6 +2,7 @@ import type { ServiceIdentifier } from '@blocksuite/global/di'; import type { BlockModel } from '@blocksuite/store'; import { + assertType, Bound, DisposableGroup, type IBound, @@ -9,6 +10,7 @@ import { } from '@blocksuite/global/utils'; import type { BlockStdScope } from '../scope/block-std-scope.js'; +import type { BlockComponent } from '../view/index.js'; import type { SurfaceBlockModel } from './surface/surface-model.js'; import { LifeCycleWatcher } from '../extension/lifecycle-watcher.js'; @@ -18,6 +20,7 @@ import { GfxBlockElementModel, type GfxModel } from './gfx-block-model.js'; import { GridManager } from './grid.js'; import { KeyboardController } from './keyboard.js'; import { LayerManager } from './layer.js'; +import { GfxSelectionManager } from './selection.js'; import { GfxPrimitiveElementModel, type PointTestOptions, @@ -39,6 +42,8 @@ export class GfxController extends LifeCycleWatcher { readonly layer: LayerManager; + readonly selection: GfxSelectionManager; + readonly tool: ToolController; readonly viewport: Viewport = new Viewport(); @@ -51,6 +56,12 @@ export class GfxController extends LifeCycleWatcher { return this._surface; } + get surfaceComponent(): BlockComponent | null { + return this.surface + ? (this.std.view.getBlock(this.surface.id) ?? null) + : null; + } + constructor(std: BlockStdScope) { super(std); @@ -58,6 +69,7 @@ export class GfxController extends LifeCycleWatcher { this.layer = new LayerManager(this.doc, null); this.keyboard = new KeyboardController(std); this.tool = new ToolController(std); + this.selection = new GfxSelectionManager(std); this._disposables.add( onSurfaceAdded(this.doc, surface => { @@ -74,6 +86,20 @@ export class GfxController extends LifeCycleWatcher { this._disposables.add(this.viewport); this._disposables.add(this.keyboard); this._disposables.add(this.tool); + this._disposables.add(this.selection); + } + + deleteElement(element: GfxModel | BlockModel | string): void { + element = typeof element === 'string' ? element : element.id; + + assertType(element); + + if (this.surface?.hasElementById(element)) { + this.surface.removeElement(element); + } else { + const block = this.doc.getBlock(element)?.model; + block && this.doc.deleteBlock(block); + } } /** @@ -90,6 +116,7 @@ export class GfxController extends LifeCycleWatcher { this.surface?.getElementById(id) ?? this.doc.getBlock(id)?.model ?? null ); } + /** * Get elements on a specific point. * @param x @@ -101,7 +128,6 @@ export class GfxController extends LifeCycleWatcher { y: number, options: { all: true } & PointTestOptions ): GfxModel[]; - getElementByPoint( x: number, y: number, @@ -154,6 +180,7 @@ export class GfxController extends LifeCycleWatcher { bound: IBound | Bound, options?: { type: 'all' } ): GfxModel[]; + getElementsByBound( bound: IBound | Bound, options: { type: 'canvas' } diff --git a/packages/framework/block-std/src/gfx/selection.ts b/packages/framework/block-std/src/gfx/selection.ts new file mode 100644 index 000000000000..f7637890257d --- /dev/null +++ b/packages/framework/block-std/src/gfx/selection.ts @@ -0,0 +1,379 @@ +import { + assertType, + DisposableGroup, + getUnitedBound, + groupBy, + type IPoint, + Slot, +} from '@blocksuite/global/utils'; + +import type { BlockStdScope } from '../scope/block-std-scope.js'; +import type { CursorSelection, SurfaceSelection } from '../selection/index.js'; +import type { GfxModel } from './gfx-block-model.js'; + +import { GfxControllerIdentifier } from './controller.js'; +import { GfxGroupLikeElementModel } from './surface/element-model.js'; + +export interface SurfaceSelectionState { + /** + * The selected elements. Could be blocks or canvas elements + */ + elements: string[]; + + /** + * Indicate whether the selected element is in editing mode + */ + editing?: boolean; + + /** + * Cannot be operated, only box is displayed + */ + inoperable?: boolean; +} + +/** + * GfxSelectionManager is just a wrapper of std selection providing + * convenient method and states in gfx + */ +export class GfxSelectionManager { + private _activeGroup: GfxGroupLikeElementModel | null = null; + + private _cursorSelection: CursorSelection | null = null; + + private _lastSurfaceSelections: SurfaceSelection[] = []; + + private _remoteCursorSelectionMap = new Map(); + + private _remoteSelectedSet = new Set(); + + private _remoteSurfaceSelectionsMap = new Map(); + + private _selectedSet = new Set(); + + private _surfaceSelections: SurfaceSelection[] = []; + + disposable: DisposableGroup = new DisposableGroup(); + + readonly slots = { + updated: new Slot(), + remoteUpdated: new Slot(), + + cursorUpdated: new Slot(), + remoteCursorUpdated: new Slot(), + }; + + get activeGroup() { + return this._activeGroup; + } + + get cursorSelection() { + return this._cursorSelection; + } + + get editing() { + return this.surfaceSelections.some(sel => sel.editing); + } + + get empty() { + return this.surfaceSelections.every(sel => sel.elements.length === 0); + } + + get firstElement() { + return this.selectedElements[0]; + } + + get gfx() { + return this.std.get(GfxControllerIdentifier); + } + + get inoperable() { + return this.surfaceSelections.some(sel => sel.inoperable); + } + + get lastSurfaceSelections() { + return this._lastSurfaceSelections; + } + + get remoteCursorSelectionMap() { + return this._remoteCursorSelectionMap; + } + + get remoteSelectedSet() { + return this._remoteSelectedSet; + } + + get remoteSurfaceSelectionsMap() { + return this._remoteSurfaceSelectionsMap; + } + + get selectedBound() { + return getUnitedBound(this.selectedElements.map(el => el.elementBound)); + } + + get selectedElements() { + const elements: GfxModel[] = []; + + this.selectedIds.forEach(id => { + const el = this.gfx.getElementById(id) as GfxModel; + el && elements.push(el); + }); + + return elements; + } + + get selectedIds() { + return [...this._selectedSet]; + } + + get selectedSet() { + return this._selectedSet; + } + + get stdSelection() { + return this.std.selection; + } + + get surfaceModel() { + return this.gfx.surface; + } + + get surfaceSelections() { + return this._surfaceSelections; + } + + constructor(readonly std: BlockStdScope) { + this.mount(); + } + + clear() { + this.stdSelection.clear(); + + this.set({ + elements: [], + editing: false, + }); + } + + clearLast() { + this._lastSurfaceSelections = []; + } + + dispose() { + this.disposable.dispose(); + } + + equals(selection: SurfaceSelection[]) { + let count = 0; + let editing = false; + const exist = selection.every(sel => { + const exist = sel.elements.every(id => this._selectedSet.has(id)); + + if (exist) { + count += sel.elements.length; + } + + if (sel.editing) editing = true; + + return exist; + }); + + return ( + exist && count === this._selectedSet.size && editing === this.editing + ); + } + + /** + * check if the element is selected in local + * @param element + */ + has(element: string) { + return this._selectedSet.has(element); + } + + /** + * check if element is selected by remote peers + * @param element + */ + hasRemote(element: string) { + return this._remoteSelectedSet.has(element); + } + + isEmpty(selections: SurfaceSelection[]) { + return selections.every(sel => sel.elements.length === 0); + } + + isInSelectedRect(viewX: number, viewY: number) { + const selected = this.selectedElements; + if (!selected.length) return false; + + const commonBound = getUnitedBound(selected.map(el => el.elementBound)); + + const [modelX, modelY] = this.gfx.viewport.toModelCoord(viewX, viewY); + if (commonBound && commonBound.isPointInBound([modelX, modelY])) { + return true; + } + return false; + } + + mount() { + this.disposable.add( + this.stdSelection.slots.changed.on(selections => { + const { cursor = [], surface = [] } = groupBy(selections, sel => { + if (sel.is('surface')) { + return 'surface'; + } else if (sel.is('cursor')) { + return 'cursor'; + } + + return 'none'; + }); + + assertType(cursor); + assertType(surface); + + if (cursor[0] && !this.cursorSelection?.equals(cursor[0])) { + this._cursorSelection = cursor[0]; + this.slots.cursorUpdated.emit(cursor[0]); + } + + if ((surface.length === 0 && this.empty) || this.equals(surface)) { + return; + } + + this._lastSurfaceSelections = this.surfaceSelections; + this._surfaceSelections = surface; + this._selectedSet = new Set(); + + surface.forEach(sel => + sel.elements.forEach(id => { + this._selectedSet.add(id); + }) + ); + + this.slots.updated.emit(this.surfaceSelections); + }) + ); + + this.disposable.add( + this.stdSelection.slots.remoteChanged.on(states => { + const surfaceMap = new Map(); + const cursorMap = new Map(); + const selectedSet = new Set(); + + states.forEach((selections, id) => { + let hasTextSelection = false; + let hasBlockSelection = false; + + selections.forEach(selection => { + if (selection.is('text')) { + hasTextSelection = true; + } + + if (selection.is('block')) { + hasBlockSelection = true; + } + + if (selection.is('surface')) { + const surfaceSelections = surfaceMap.get(id) ?? []; + surfaceSelections.push(selection); + surfaceMap.set(id, surfaceSelections); + + selection.elements.forEach(id => selectedSet.add(id)); + } + + if (selection.is('cursor')) { + cursorMap.set(id, selection); + } + }); + + if (hasBlockSelection || hasTextSelection) { + surfaceMap.delete(id); + } + + if (hasTextSelection) { + cursorMap.delete(id); + } + }); + + this._remoteCursorSelectionMap = cursorMap; + this._remoteSurfaceSelectionsMap = surfaceMap; + this._remoteSelectedSet = selectedSet; + + this.slots.remoteUpdated.emit(); + this.slots.remoteCursorUpdated.emit(); + }) + ); + } + + set(selection: SurfaceSelectionState | SurfaceSelection[]) { + if (Array.isArray(selection)) { + this.stdSelection.setGroup( + 'gfx', + this.cursorSelection ? [...selection, this.cursorSelection] : selection + ); + return; + } + + const { blocks = [], elements = [] } = groupBy(selection.elements, id => { + return this.std.doc.getBlockById(id) ? 'blocks' : 'elements'; + }); + let instances: (SurfaceSelection | CursorSelection)[] = []; + + if (elements.length > 0 && this.surfaceModel) { + instances.push( + this.stdSelection.create( + 'surface', + this.surfaceModel.id, + elements, + selection.editing ?? false, + selection.inoperable + ) + ); + } + + if (blocks.length > 0) { + instances = instances.concat( + blocks.map(blockId => + this.stdSelection.create( + 'surface', + blockId, + [blockId], + selection.editing ?? false, + selection.inoperable + ) + ) + ); + } + + this.stdSelection.setGroup( + 'gfx', + this.cursorSelection + ? instances.concat([this.cursorSelection]) + : instances + ); + + if (instances.length > 0) { + this.stdSelection.setGroup('note', []); + } + + if ( + selection.elements.length === 1 && + this.firstElement instanceof GfxGroupLikeElementModel + ) { + this._activeGroup = this.firstElement; + } else { + if ( + this.selectedElements.some(ele => ele.group !== this._activeGroup) || + this.selectedElements.length === 0 + ) { + this._activeGroup = null; + } + } + } + + setCursor(cursor: CursorSelection | IPoint) { + const instance = this.stdSelection.create('cursor', cursor.x, cursor.y); + + this.stdSelection.setGroup('gfx', [...this.surfaceSelections, instance]); + } +} diff --git a/packages/framework/block-std/src/gfx/surface/surface-model.ts b/packages/framework/block-std/src/gfx/surface/surface-model.ts index 99197967f3a0..607e24710e36 100644 --- a/packages/framework/block-std/src/gfx/surface/surface-model.ts +++ b/packages/framework/block-std/src/gfx/surface/surface-model.ts @@ -525,6 +525,8 @@ export class SurfaceBlockModel extends BlockModel { throw new Error('Cannot remove element in readonly mode'); } + console.trace(id); + if (!this.hasElementById(id)) { return; } diff --git a/packages/framework/block-std/src/gfx/tool/tool-controller.ts b/packages/framework/block-std/src/gfx/tool/tool-controller.ts index 7bf3f542a9df..6d0227a9b870 100644 --- a/packages/framework/block-std/src/gfx/tool/tool-controller.ts +++ b/packages/framework/block-std/src/gfx/tool/tool-controller.ts @@ -1,20 +1,26 @@ -import { DisposableGroup, type IBound } from '@blocksuite/global/utils'; +import { + DisposableGroup, + type IBound, + type IPoint, +} from '@blocksuite/global/utils'; import { Signal } from '@preact/signals-core'; -import type { UIEventStateContext } from '../../event/index.js'; +import type { PointerEventState } from '../../event/index.js'; import type { BlockStdScope } from '../../scope/block-std-scope.js'; import type { BaseTool } from './tool.js'; +import { GfxControllerIdentifier } from '../controller.js'; + const supportedEvents = [ 'dragStart', 'dragEnd', 'dragMove', - 'click', - 'doubleClick', - 'tripleClick', 'pointerMove', 'pointerDown', 'pointerUp', + 'click', + 'doubleClick', + 'tripleClick', 'pointerOut', 'contextMenu', ] as const; @@ -22,7 +28,7 @@ const supportedEvents = [ export interface ToolEventTarget { addHook( evtName: (typeof supportedEvents)[number], - handler: (evtState: UIEventStateContext) => undefined | boolean + handler: (evtState: PointerEventState) => undefined | boolean ): () => void; } @@ -35,19 +41,43 @@ export class ToolController { private _tools = new Map(); - readonly currentToolName$ = new Signal(''); + readonly currentToolName$ = new Signal(); readonly dragging$ = new Signal(false); - readonly draggingArea$ = new Signal({ + /** + * The area that is being dragged. + * The coordinates are in model space. + */ + readonly draggingArea$ = new Signal< + IBound & { + startX: number; + startY: number; + endX: number; + endY: number; + } + >({ + startX: 0, + startY: 0, x: 0, y: 0, w: 0, h: 0, + endX: 0, + endY: 0, }); readonly [eventTarget]: ToolEventTarget; + /** + * The last mouse move position + * The coordinates are in browser space + */ + readonly lastMousePos$ = new Signal({ + x: 0, + y: 0, + }); + get currentTool$() { // eslint-disable-next-line @typescript-eslint/no-this-alias const self = this; @@ -62,6 +92,10 @@ export class ToolController { }; } + get gfx() { + return this.std.get(GfxControllerIdentifier); + } + constructor(readonly std: BlockStdScope) { const { addHook } = this._initializeEvents(); @@ -73,22 +107,22 @@ export class ToolController { private _initializeEvents() { const hooks: Record< string, - ((evtState: UIEventStateContext) => undefined | boolean)[] + ((evtState: PointerEventState) => undefined | boolean)[] > = {}; const invokeToolHandler = ( evtName: SupportedEvents, - ctx: UIEventStateContext + evt: PointerEventState ) => { const evtHooks = hooks[evtName]; const stopHandler = evtHooks?.reduce((pre, hook) => { - return pre || hook(ctx) === false; + return pre || hook(evt) === false; }, false); if (stopHandler || !this.currentTool$.peek()) { return; } - this.currentTool$.peek()?.[evtName](ctx); + this.currentTool$.peek()?.[evtName](evt); }; /** @@ -100,7 +134,7 @@ export class ToolController { */ const addHook = ( evtName: (typeof supportedEvents)[number], - handler: (evtState: UIEventStateContext) => undefined | boolean + handler: (evtState: PointerEventState) => undefined | boolean ) => { hooks[evtName] = hooks[evtName] ?? []; hooks[evtName].push(handler); @@ -113,24 +147,98 @@ export class ToolController { }; }; + let draggingStart: { + x: number; + y: number; + } = { + x: 0, + y: 0, + }; + this._disposableGroup.add( this.std.event.add('dragStart', ctx => { this.dragging$.value = true; - invokeToolHandler('dragStart', ctx); + const evt = ctx.get('pointerState'); + const [x, y] = this.gfx.viewport.toModelCoord(evt.x, evt.y); + + draggingStart = { x, y }; + + this.draggingArea$.value = { + startX: x, + startY: y, + endX: x, + endY: y, + x: draggingStart.x, + y: draggingStart.y, + w: Math.abs(x - draggingStart.x), + h: Math.abs(y - draggingStart.y), + }; + + invokeToolHandler('dragStart', evt); + }) + ); + + this._disposableGroup.add( + this.std.event.add('dragMove', ctx => { + this.dragging$.value = true; + const evt = ctx.get('pointerState'); + const [x, y] = this.gfx.viewport.toModelCoord(evt.x, evt.y); + + this.draggingArea$.value = { + ...this.draggingArea$.peek(), + w: Math.abs(x - draggingStart.x), + h: Math.abs(y - draggingStart.y), + endX: x, + endY: y, + }; + invokeToolHandler('dragMove', evt); }) ); this._disposableGroup.add( this.std.event.add('dragEnd', ctx => { this.dragging$.value = false; - invokeToolHandler('dragEnd', ctx); + const evt = ctx.get('pointerState'); + + try { + invokeToolHandler('dragEnd', evt); + } catch (e) { + console.warn('dragEnd handler throws an error', e); + } + + this.draggingArea$.value = { + x: 0, + y: 0, + startX: 0, + startY: 0, + endX: 0, + endY: 0, + w: 0, + h: 0, + }; + }) + ); + + this._disposableGroup.add( + this.std.event.add('pointerMove', ctx => { + this.dragging$.value = false; + const evt = ctx.get('pointerState'); + + this.lastMousePos$.value = { + x: evt.x, + y: evt.y, + }; + + invokeToolHandler('pointerMove', evt); }) ); - supportedEvents.slice(2).forEach(evtName => { + supportedEvents.slice(4).forEach(evtName => { this._disposableGroup.add( this.std.event.add(evtName, ctx => { - invokeToolHandler(evtName, ctx); + const evt = ctx.get('pointerState'); + + invokeToolHandler(evtName, evt); }) ); }); @@ -155,9 +263,14 @@ export class ToolController { tools.onload(); } - use(toolName: string, options: Record = {}) { + setTool( + toolName: K, + ...options: K extends keyof BlockSuite.GfxToolsOption + ? [option: BlockSuite.GfxToolsOption[K]] + : [option: void] + ) { this.currentTool$.peek()?.deactivate(); this.currentToolName$.value = toolName; - this.currentTool$.peek()?.activate(options); + this.currentTool$.peek()?.activate(options[0] ?? {}); } } diff --git a/packages/framework/block-std/src/gfx/tool/tool.ts b/packages/framework/block-std/src/gfx/tool/tool.ts index 84a5cab8b1e0..70116389c5f7 100644 --- a/packages/framework/block-std/src/gfx/tool/tool.ts +++ b/packages/framework/block-std/src/gfx/tool/tool.ts @@ -1,19 +1,43 @@ import { type Container, createIdentifier } from '@blocksuite/global/di'; import { BlockSuiteError, ErrorCode } from '@blocksuite/global/exceptions'; +import { + assertType, + type Constructor, + DisposableGroup, +} from '@blocksuite/global/utils'; -import type { UIEventStateContext } from '../../event/base.js'; +import type { PointerEventState } from '../../event/index.js'; import type { ExtensionType } from '../../extension/extension.js'; import { type GfxController, GfxControllerIdentifier } from '../controller.js'; import { eventTarget, type SupportedEvents } from './tool-controller.js'; -export abstract class BaseTool { +export abstract class BaseTool