diff --git a/packages/blocks/src/_common/types.ts b/packages/blocks/src/_common/types.ts index fc48a5ae2ea2..5161c063d096 100644 --- a/packages/blocks/src/_common/types.ts +++ b/packages/blocks/src/_common/types.ts @@ -199,6 +199,10 @@ export type PanTool = { panning: boolean; }; +export type CopilotSelectionTool = { + type: 'copilot'; +}; + export type NoteChildrenFlavour = | 'affine:paragraph' | 'affine:list' @@ -239,7 +243,8 @@ export type EdgelessTool = | ConnectorTool | EraserTool | FrameTool - | FrameNavigatorTool; + | FrameNavigatorTool + | CopilotSelectionTool; export type EmbedBlockDoubleClickData = { blockId: string; diff --git a/packages/blocks/src/_common/utils/event.ts b/packages/blocks/src/_common/utils/event.ts index caa592370f95..66278f9c6a70 100644 --- a/packages/blocks/src/_common/utils/event.ts +++ b/packages/blocks/src/_common/utils/event.ts @@ -19,10 +19,22 @@ export enum MOUSE_BUTTONS { FIFTH = 16, } +export enum MOUSE_BUTTON { + MAIN = 0, + AUXILIARY = 1, + SECONDARY = 2, + FORTH = 3, + FIFTH = 4, +} + export function isMiddleButtonPressed(e: MouseEvent) { return (MOUSE_BUTTONS.AUXILIARY & e.buttons) === MOUSE_BUTTONS.AUXILIARY; } +export function isRightButtonPressed(e: MouseEvent) { + return (MOUSE_BUTTONS.SECONDARY & e.buttons) === MOUSE_BUTTONS.SECONDARY; +} + export function stopPropagation(event: Event) { event.stopPropagation(); } diff --git a/packages/blocks/src/_specs/_specs.ts b/packages/blocks/src/_specs/_specs.ts index 29e2df70bde4..cea8d82a510b 100644 --- a/packages/blocks/src/_specs/_specs.ts +++ b/packages/blocks/src/_specs/_specs.ts @@ -36,6 +36,7 @@ import { PageRootService } from '../root-block/page/page-root-service.js'; import { RootBlockSchema } from '../root-block/root-model.js'; import { AFFINE_DOC_REMOTE_SELECTION_WIDGET } from '../root-block/widgets/doc-remote-selection/doc-remote-selection.js'; import { AFFINE_DRAG_HANDLE_WIDGET } from '../root-block/widgets/drag-handle/drag-handle.js'; +import { AFFINE_EDGELESS_COPILOT_WIDGET } from '../root-block/widgets/edgeless-copilot/index.js'; import { AFFINE_EDGELESS_REMOTE_SELECTION_WIDGET } from '../root-block/widgets/edgeless-remote-selection/index.js'; import { AFFINE_EDGELESS_ZOOM_TOOLBAR_WIDGET } from '../root-block/widgets/edgeless-zoom-toolbar/index.js'; import { AFFINE_FORMAT_BAR_WIDGET } from '../root-block/widgets/format-bar/format-bar.js'; @@ -116,6 +117,7 @@ const EdgelessPageSpec: BlockSpec = { [AFFINE_EDGELESS_ZOOM_TOOLBAR_WIDGET]: literal`${unsafeStatic( AFFINE_EDGELESS_ZOOM_TOOLBAR_WIDGET )}`, + [AFFINE_EDGELESS_COPILOT_WIDGET]: literal`${unsafeStatic(AFFINE_EDGELESS_COPILOT_WIDGET)}`, }, }, }; diff --git a/packages/blocks/src/root-block/edgeless/controllers/tools/copilot-tool.ts b/packages/blocks/src/root-block/edgeless/controllers/tools/copilot-tool.ts new file mode 100644 index 000000000000..edfbf3c43e83 --- /dev/null +++ b/packages/blocks/src/root-block/edgeless/controllers/tools/copilot-tool.ts @@ -0,0 +1,76 @@ +import type { PointerEventState } from '@blocksuite/block-std'; +import { Slot } from '@blocksuite/store'; + +import type { CopilotSelectionTool } from '../../../../_common/utils/index.js'; +import { EdgelessToolController } from './index.js'; + +export class CopilotSelectionController extends EdgelessToolController { + readonly tool = { + type: 'copilot', + }; + + private _dragStartPoint: [number, number] = [0, 0]; + private _dragLastPoint: [number, number] = [0, 0]; + private _dragging = false; + + draggingAreaUpdated = new Slot(); + + get area() { + const start = new DOMPoint( + this._dragStartPoint[0], + this._dragStartPoint[1] + ); + const end = new DOMPoint(this._dragLastPoint[0], this._dragLastPoint[1]); + + const minX = Math.min(start.x, end.x); + const minY = Math.min(start.y, end.y); + const maxX = Math.max(start.x, end.x); + const maxY = Math.max(start.y, end.y); + + return new DOMRect(minX, minY, maxX - minX, maxY - minY); + } + + private _initDragState(e: PointerEventState) { + this._dragStartPoint = this._service.viewport.toModelCoord(e.x, e.y); + this._dragLastPoint = this._dragStartPoint; + } + + override onContainerDragStart(e: PointerEventState): void { + this._initDragState(e); + this._dragging = true; + this.draggingAreaUpdated.emit(); + } + + override onContainerDragMove(e: PointerEventState): void { + if (!this._dragging) return; + + this._dragLastPoint = this._service.viewport.toModelCoord(e.x, e.y); + this.draggingAreaUpdated.emit(); + } + + override onContainerDragEnd(): void { + this._dragging = false; + } + + onContainerPointerDown(): void {} + + onContainerClick(): void {} + + onContainerContextMenu(): void {} + + onContainerDblClick(): void {} + + onContainerTripleClick(): void {} + + onContainerMouseMove(): void {} + + onContainerMouseOut(): void {} + + onPressShiftKey(): void {} + + onPressSpaceBar(): void {} + + beforeModeSwitch(): void {} + + afterModeSwitch(): void {} +} diff --git a/packages/blocks/src/root-block/edgeless/edgeless-keyboard.ts b/packages/blocks/src/root-block/edgeless/edgeless-keyboard.ts index 0419f494fbe8..8d2e23dc3a3e 100644 --- a/packages/blocks/src/root-block/edgeless/edgeless-keyboard.ts +++ b/packages/blocks/src/root-block/edgeless/edgeless-keyboard.ts @@ -195,6 +195,12 @@ export class EdgelessPageKeyboardManager extends PageKeyboardManager { global: true, } ); + + this._bindShiftKey(); + this._bindToggleHand(); + } + + private _bindShiftKey() { this.rootElement.handleEvent( 'keyDown', ctx => { @@ -217,7 +223,6 @@ export class EdgelessPageKeyboardManager extends PageKeyboardManager { global: true, } ); - this._bindToggleHand(); } private _bindToggleHand() { @@ -288,12 +293,16 @@ export class EdgelessPageKeyboardManager extends PageKeyboardManager { private _shift(event: KeyboardEvent) { const edgeless = this.rootElement; - if (!event.repeat) { - if (event.key.toLowerCase() === 'shift' && event.shiftKey) { - edgeless.slots.pressShiftKeyUpdated.emit(true); - } else { - edgeless.slots.pressShiftKeyUpdated.emit(false); - } + + if (event.repeat) return; + + const shiftKeyPressed = + event.key.toLowerCase() === 'shift' && event.shiftKey; + + if (shiftKeyPressed) { + edgeless.slots.pressShiftKeyUpdated.emit(true); + } else { + edgeless.slots.pressShiftKeyUpdated.emit(false); } } diff --git a/packages/blocks/src/root-block/edgeless/edgeless-root-block.ts b/packages/blocks/src/root-block/edgeless/edgeless-root-block.ts index 58ca6878dd94..beb4b8ef311f 100644 --- a/packages/blocks/src/root-block/edgeless/edgeless-root-block.ts +++ b/packages/blocks/src/root-block/edgeless/edgeless-root-block.ts @@ -67,6 +67,7 @@ import { readImageSize } from './components/utils.js'; import { EdgelessClipboardController } from './controllers/clipboard.js'; import { BrushToolController } from './controllers/tools/brush-tool.js'; import { ConnectorToolController } from './controllers/tools/connector-tool.js'; +import { CopilotSelectionController } from './controllers/tools/copilot-tool.js'; import { DefaultToolController } from './controllers/tools/default-tool.js'; import { EraserToolController } from './controllers/tools/eraser-tool.js'; import { PresentToolController } from './controllers/tools/frame-navigator-tool.js'; @@ -717,6 +718,7 @@ export class EdgelessRootBlockComponent extends BlockElement< FrameToolController, PanToolController, PresentToolController, + CopilotSelectionController, ] as EdgelessToolConstructor[]; tools.forEach(tool => { diff --git a/packages/blocks/src/root-block/edgeless/services/tools-manager.ts b/packages/blocks/src/root-block/edgeless/services/tools-manager.ts index b7dae7fa7d01..c5ed1b96377f 100644 --- a/packages/blocks/src/root-block/edgeless/services/tools-manager.ts +++ b/packages/blocks/src/root-block/edgeless/services/tools-manager.ts @@ -5,11 +5,13 @@ import type { UIEventHandler, UIEventState, } from '@blocksuite/block-std'; +import { IS_MAC } from '@blocksuite/global/env'; import { DisposableGroup } from '@blocksuite/global/utils'; import { type EdgelessTool, isMiddleButtonPressed, + isRightButtonPressed, NoteDisplayMode, } from '../../../_common/utils/index.js'; import type { Bound } from '../../../surface-block/utils/bound.js'; @@ -101,6 +103,10 @@ export class EdgelessToolsManager { return this._controllers[this.edgelessTool.type]; } + get controllers() { + return this._controllers; + } + get draggingArea() { if (!this.currentController.draggingArea) return null; @@ -111,21 +117,23 @@ export class EdgelessToolsManager { const maxY = Math.max(start.y, end.y); return new DOMRect(minX, minY, maxX - minX, maxY - minY); } + + set spaceBar(pressed: boolean) { + this._spaceBar = pressed; + this.currentController.onPressSpaceBar(pressed); + } + get spaceBar() { return this._spaceBar; } - get shiftKey() { - return this._shiftKey; - } set shiftKey(pressed: boolean) { this._shiftKey = pressed; this.currentController.onPressShiftKey(pressed); } - set spaceBar(pressed: boolean) { - this._spaceBar = pressed; - this.currentController.onPressSpaceBar(pressed); + get shiftKey() { + return this._shiftKey; } get doc() { @@ -241,7 +249,12 @@ export class EdgelessToolsManager { // only allow pan tool in readonly mode if (this.doc.readonly && this.edgelessTool.type !== 'pan') return; // do nothing when holding right-key and not in pan mode - if (e.button === 2 && this.edgelessTool.type !== 'pan') return; + if ( + e.button === 2 && + this.edgelessTool.type !== 'pan' && + this.edgelessTool.type !== 'copilot' + ) + return; return this.currentController.onContainerDragStart(e); }; @@ -250,7 +263,12 @@ export class EdgelessToolsManager { // only allow pan tool in readonly mode if (this.doc.readonly && this.edgelessTool.type !== 'pan') return; // do nothing when holding right-key and not in pan mode - if (e.button === 2 && this.edgelessTool.type !== 'pan') return; + if ( + e.button === 2 && + this.edgelessTool.type !== 'pan' && + this.edgelessTool.type !== 'copilot' + ) + return; return this.currentController.onContainerDragMove(e); }; @@ -259,7 +277,12 @@ export class EdgelessToolsManager { // only allow pan tool in readonly mode if (this.doc.readonly && this.edgelessTool.type !== 'pan') return; // do nothing when holding right-key and not in pan mode - if (e.button === 2 && this.edgelessTool.type !== 'pan') return; + if ( + e.button === 2 && + this.edgelessTool.type !== 'pan' && + this.edgelessTool.type !== 'copilot' + ) + return; return this.currentController.onContainerDragEnd(e); }; @@ -290,33 +313,53 @@ export class EdgelessToolsManager { }; private _onContainerPointerDown = (e: PointerEventState) => { - if (!isMiddleButtonPressed(e.raw)) { - if (this.doc.readonly) return; + const pointEvt = e.raw; + const metaKeyPressed = IS_MAC ? pointEvt.metaKey : pointEvt.ctrlKey; - return this.currentController.onContainerPointerDown(e); + if ( + isMiddleButtonPressed(pointEvt) || + isRightButtonPressed(pointEvt) || + metaKeyPressed + ) { + const isRightButton = isRightButtonPressed(pointEvt); + const targetTool = ( + isRightButton || metaKeyPressed + ? { + type: 'copilot', + } + : { type: 'pan', panning: true } + ) as EdgelessTool; + const prevEdgelessTool = this._edgelessTool; + const targetButtonRelease = (_e: MouseEvent) => + (isMiddleButtonPressed(e.raw) && !isMiddleButtonPressed(_e)) || + (isRightButton && !isRightButtonPressed(_e)) || + metaKeyPressed; + + const switchToPreMode = (_e: MouseEvent) => { + if (targetButtonRelease(_e)) { + this.setEdgelessTool( + prevEdgelessTool, + undefined, + !isRightButton && !metaKeyPressed + ); + document.removeEventListener('pointerup', switchToPreMode, false); + document.removeEventListener('pointerover', switchToPreMode, false); + } + }; + + this.dispatcher.disposables.addFromEvent( + document, + 'pointerup', + switchToPreMode + ); + + this.setEdgelessTool(targetTool); + return; } - const prevEdgelessTool = this._edgelessTool; - const switchToPreMode = (_e: MouseEvent) => { - if (!isMiddleButtonPressed(_e)) { - this.setEdgelessTool(prevEdgelessTool); - document.removeEventListener('pointerup', switchToPreMode, false); - document.removeEventListener('pointerover', switchToPreMode, false); - } - }; + if (this.doc.readonly) return; - this.dispatcher.disposables.addFromEvent( - document, - 'pointerover', - switchToPreMode - ); - this.dispatcher.disposables.addFromEvent( - document, - 'pointerup', - switchToPreMode - ); - - this.setEdgelessTool({ type: 'pan', panning: true }); + return this.currentController.onContainerPointerDown(e); }; private _onContainerPointerUp = (_ev: PointerEventState) => {}; @@ -353,7 +396,8 @@ export class EdgelessToolsManager { state: EdgelessSelectionState | SurfaceSelection[] = { elements: [], editing: false, - } + }, + restoreToLastSelection = true ) => { const { type } = edgelessTool; if (this.doc.readonly && type !== 'pan' && type !== 'frameNavigator') { @@ -380,7 +424,8 @@ export class EdgelessToolsManager { isDefaultType && isEmptyState && hasLastState && - isNotSingleDocOnlyNote + isNotSingleDocOnlyNote && + restoreToLastSelection ) { state = this.selection.lastState; } diff --git a/packages/blocks/src/root-block/types.ts b/packages/blocks/src/root-block/types.ts index 331780a24f56..3404f51436e1 100644 --- a/packages/blocks/src/root-block/types.ts +++ b/packages/blocks/src/root-block/types.ts @@ -4,6 +4,7 @@ import type { EdgelessRootBlockComponent } from './edgeless/edgeless-root-block. import type { PageRootBlockComponent } from './page/page-root-block.js'; import type { AFFINE_DOC_REMOTE_SELECTION_WIDGET } from './widgets/doc-remote-selection/doc-remote-selection.js'; import type { AFFINE_DRAG_HANDLE_WIDGET } from './widgets/drag-handle/drag-handle.js'; +import type { AFFINE_EDGELESS_COPILOT_WIDGET } from './widgets/edgeless-copilot/index.js'; import type { AFFINE_EDGELESS_REMOTE_SELECTION_WIDGET } from './widgets/edgeless-remote-selection/index.js'; import type { AFFINE_EDGELESS_ZOOM_TOOLBAR_WIDGET } from './widgets/edgeless-zoom-toolbar/index.js'; import type { AFFINE_FORMAT_BAR_WIDGET } from './widgets/format-bar/format-bar.js'; @@ -34,7 +35,8 @@ export type EdgelessRootBlockWidgetName = | typeof AFFINE_FORMAT_BAR_WIDGET | typeof AFFINE_DOC_REMOTE_SELECTION_WIDGET | typeof AFFINE_EDGELESS_REMOTE_SELECTION_WIDGET - | typeof AFFINE_EDGELESS_ZOOM_TOOLBAR_WIDGET; + | typeof AFFINE_EDGELESS_ZOOM_TOOLBAR_WIDGET + | typeof AFFINE_EDGELESS_COPILOT_WIDGET; export type RootBlockComponent = | PageRootBlockComponent diff --git a/packages/blocks/src/root-block/widgets/edgeless-copilot/index.ts b/packages/blocks/src/root-block/widgets/edgeless-copilot/index.ts new file mode 100644 index 000000000000..c63b12616095 --- /dev/null +++ b/packages/blocks/src/root-block/widgets/edgeless-copilot/index.ts @@ -0,0 +1,131 @@ +import { WidgetElement } from '@blocksuite/block-std'; +import { css, html, nothing } from 'lit'; +import { customElement, state } from 'lit/decorators.js'; +import { styleMap } from 'lit/directives/style-map.js'; + +import { + MOUSE_BUTTON, + on, + requestConnectedFrame, +} from '../../../_common/utils/event.js'; +import type { CopilotSelectionController } from '../../edgeless/controllers/tools/copilot-tool.js'; +import type { EdgelessRootBlockComponent } from '../../index.js'; + +export const AFFINE_EDGELESS_COPILOT_WIDGET = 'affine-edgeless-copilot-widget'; + +@customElement(AFFINE_EDGELESS_COPILOT_WIDGET) +export class EdgelessCopilotWidget extends WidgetElement { + static override styles = css` + .copilot-selection-rect { + position: absolute; + box-sizing: border-box; + border-radius: 4px; + border: 2px dashed var(--affine-brand-color, #1e96eb); + } + `; + + @state() + private _selectionRect: { + x: number; + y: number; + width: number; + height: number; + } | null = null; + + @state() + private _visible = false; + + private _clickOutsideOff: (() => void) | null = null; + private _listenClickOutsideId: number | null = null; + + get edgeless() { + return this.blockElement; + } + + private _updateSelection(rect: DOMRect) { + const zoom = this.edgeless.service.viewport.zoom; + const [x, y] = this.edgeless.service.viewport.toViewCoord( + rect.left, + rect.top + ); + const [width, height] = [rect.width * zoom, rect.height * zoom]; + + this._selectionRect = { x, y, width, height }; + } + + private _watchClickOutside() { + this._clickOutsideOff?.(); + + const { width, height } = this._selectionRect || {}; + + if (width && height) { + this._listenClickOutsideId && + cancelAnimationFrame(this._listenClickOutsideId); + this._listenClickOutsideId = requestConnectedFrame(() => { + if (!this.isConnected) { + return; + } + + const off = on(this.ownerDocument, 'mousedown', e => { + if ( + e.button === MOUSE_BUTTON.MAIN && + !this.contains(e.target as HTMLElement) + ) { + off(); + this._visible = false; + } + }); + this._listenClickOutsideId = null; + this._clickOutsideOff = off; + }, this); + } + } + + override connectedCallback(): void { + super.connectedCallback(); + + const CopilotSelectionTool = this.edgeless.tools.controllers[ + 'copilot' + ] as CopilotSelectionController; + + this._disposables.add( + CopilotSelectionTool.draggingAreaUpdated.on(() => { + this._visible = true; + this._updateSelection(CopilotSelectionTool.area); + this._watchClickOutside(); + }) + ); + + this._disposables.add( + this.edgeless.service.viewport.viewportUpdated.on(() => { + this._updateSelection(CopilotSelectionTool.area); + }) + ); + } + + override render() { + if (!this._visible) return nothing; + + const rect = this._selectionRect; + + return html`
+ ${rect?.width && rect?.height + ? html`
` + : nothing} +
`; + } +} + +declare global { + interface HTMLElementTagNameMap { + [AFFINE_EDGELESS_COPILOT_WIDGET]: EdgelessCopilotWidget; + } +} diff --git a/tests/edgeless/selection.spec.ts b/tests/edgeless/selection.spec.ts index a90f645b9a5c..83b514d0be74 100644 --- a/tests/edgeless/selection.spec.ts +++ b/tests/edgeless/selection.spec.ts @@ -16,6 +16,7 @@ import { initEmptyEdgelessState, initThreeParagraphs, pressEnter, + SHORT_KEY, waitNextFrame, } from '../utils/actions/index.js'; import { @@ -559,3 +560,30 @@ test('selection drag-area start should be same when space is pressed again', asy } ); }); + +test('copilot selection rect should appears when drag with meta key pressed', async ({ + page, +}) => { + await enterPlaygroundRoom(page); + await initEmptyEdgelessState(page); + await switchEditorMode(page); + + await actions.zoomResetByKeyboard(page); + + await page.keyboard.down(SHORT_KEY); + await dragBetweenCoords(page, { x: 100, y: 100 }, { x: 200, y: 200 }); + await page.keyboard.up(SHORT_KEY); + + const aiSelectionRect = await page + .locator('.copilot-selection-rect') + .boundingBox(); + + expect(aiSelectionRect).not.toBeNull(); + expect(aiSelectionRect!.width).toBe(100); + expect(aiSelectionRect!.height).toBe(100); + expect(aiSelectionRect!.x).toBe(100); + expect(aiSelectionRect!.y).toBe(100); + + await page.mouse.click(205, 150); + await expect(page.locator('.copilot-selection-rect')).toBeHidden(); +});