refactor(editor): extract selected rect widget (#12290)

<!-- This is an auto-generated comment: release notes by coderabbit.ai -->

## Summary by CodeRabbit

- **New Features**
  - Introduced the Edgeless Selected Rectangle widget, providing enhanced selection and interaction capabilities in edgeless mode.
  - Added rotation-aware resize cursors for improved usability when resizing selections.
  - Integrated new autocomplete panels and selection components for a smoother user experience.

- **Refactor**
  - Modularized the Edgeless Selected Rectangle widget as a standalone package for better maintainability and integration.
  - Updated internal references and imports to utilize the new widget package.

- **Chores**
  - Updated project and package configurations to include the new widget and ensure proper build and type-checking across the workspace.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
Saul-Mirone
2025-05-15 04:25:06 +00:00
parent 43948f205e
commit 6052743671
27 changed files with 266 additions and 127 deletions

View File

@@ -0,0 +1,45 @@
{
"name": "@blocksuite/affine-widget-edgeless-selected-rect",
"description": "Affine edgeless selected-rect widget.",
"type": "module",
"scripts": {
"build": "tsc"
},
"sideEffects": false,
"keywords": [],
"author": "toeverything",
"license": "MIT",
"dependencies": {
"@blocksuite/affine-block-frame": "workspace:*",
"@blocksuite/affine-block-note": "workspace:*",
"@blocksuite/affine-block-surface": "workspace:*",
"@blocksuite/affine-components": "workspace:*",
"@blocksuite/affine-ext-loader": "workspace:*",
"@blocksuite/affine-gfx-connector": "workspace:*",
"@blocksuite/affine-gfx-shape": "workspace:*",
"@blocksuite/affine-gfx-text": "workspace:*",
"@blocksuite/affine-model": "workspace:*",
"@blocksuite/affine-shared": "workspace:*",
"@blocksuite/global": "workspace:*",
"@blocksuite/icons": "^2.2.12",
"@blocksuite/std": "workspace:*",
"@lit/context": "^1.1.2",
"@preact/signals-core": "^1.8.0",
"@toeverything/theme": "^1.1.14",
"lit": "^3.2.0",
"rxjs": "^7.8.1",
"yjs": "^13.6.21"
},
"exports": {
".": "./src/index.ts",
"./effects": "./src/effects.ts",
"./view": "./src/view.ts"
},
"files": [
"src",
"dist",
"!src/__tests__",
"!dist/__tests__"
],
"version": "0.21.0"
}

View File

@@ -0,0 +1,680 @@
import { EdgelessFrameManagerIdentifier } from '@blocksuite/affine-block-frame';
import {
CanvasElementType,
EdgelessCRUDIdentifier,
getSurfaceBlock,
getSurfaceComponent,
} from '@blocksuite/affine-block-surface';
import { FontFamilyIcon } from '@blocksuite/affine-components/icons';
import {
mountShapeTextEditor,
SHAPE_OVERLAY_HEIGHT,
SHAPE_OVERLAY_WIDTH,
ShapeComponentConfig,
} from '@blocksuite/affine-gfx-shape';
import {
insertEdgelessTextCommand,
mountTextElementEditor,
} from '@blocksuite/affine-gfx-text';
import type {
Connection,
ConnectorElementModel,
ShapeElementModel,
} from '@blocksuite/affine-model';
import {
DEFAULT_NOTE_WIDTH,
DefaultTheme,
FontFamily,
FontStyle,
FontWeight,
getShapeName,
GroupElementModel,
NoteBlockModel,
ShapeStyle,
TextElementModel,
} from '@blocksuite/affine-model';
import {
EditPropsStore,
FeatureFlagService,
ThemeProvider,
} from '@blocksuite/affine-shared/services';
import {
captureEventTarget,
matchModels,
} from '@blocksuite/affine-shared/utils';
import type { XYWH } from '@blocksuite/global/gfx';
import {
Bound,
clamp,
normalizeDegAngle,
serializeXYWH,
toDegree,
Vec,
} from '@blocksuite/global/gfx';
import { WithDisposable } from '@blocksuite/global/lit';
import { FrameIcon, PageIcon } from '@blocksuite/icons/lit';
import {
type BlockComponent,
type BlockStdScope,
stdContext,
} from '@blocksuite/std';
import { GfxControllerIdentifier } from '@blocksuite/std/gfx';
import { consume } from '@lit/context';
import { baseTheme } from '@toeverything/theme';
import { css, html, LitElement, nothing, unsafeCSS } from 'lit';
import { property } from 'lit/decorators.js';
import { repeat } from 'lit/directives/repeat.js';
import { styleMap } from 'lit/directives/style-map.js';
import * as Y from 'yjs';
import {
type AUTO_COMPLETE_TARGET_TYPE,
AutoCompleteFrameOverlay,
AutoCompleteNoteOverlay,
AutoCompleteShapeOverlay,
AutoCompleteTextOverlay,
capitalizeFirstLetter,
createShapeElement,
DEFAULT_NOTE_OVERLAY_HEIGHT,
DEFAULT_TEXT_HEIGHT,
DEFAULT_TEXT_WIDTH,
Direction,
isShape,
PANEL_HEIGHT,
PANEL_WIDTH,
type TARGET_SHAPE_TYPE,
} from './utils.js';
export class EdgelessAutoCompletePanel extends WithDisposable(LitElement) {
static override styles = css`
.auto-complete-panel-container {
position: absolute;
display: flex;
width: 136px;
flex-wrap: wrap;
align-items: center;
justify-content: center;
padding: 8px 0;
gap: 8px;
border-radius: 8px;
background: var(--affine-background-overlay-panel-color);
box-shadow: var(--affine-shadow-2);
z-index: 1;
}
.row-button {
display: flex;
align-items: center;
justify-content: center;
width: 120px;
height: 28px;
padding: 4px 0;
text-align: center;
border-radius: 8px;
font-family: ${unsafeCSS(baseTheme.fontSansFamily)};
font-size: 12px;
font-style: normal;
font-weight: 500;
border: 1px solid var(--affine-border-color, #e3e2e4);
box-sizing: border-box;
}
`;
private _overlay:
| AutoCompleteShapeOverlay
| AutoCompleteNoteOverlay
| AutoCompleteFrameOverlay
| AutoCompleteTextOverlay
| null = null;
get gfx() {
return this.std.get(GfxControllerIdentifier);
}
constructor(
position: [number, number],
edgeless: BlockComponent,
currentSource: ShapeElementModel | NoteBlockModel,
connector: ConnectorElementModel
) {
super();
this.position = position;
this.edgeless = edgeless;
this.currentSource = currentSource;
this.connector = connector;
}
get crud() {
return this.std.get(EdgelessCRUDIdentifier);
}
get surface() {
return getSurfaceComponent(this.std);
}
private _addFrame() {
const bound = this._generateTarget(this.connector)?.nextBound;
if (!bound) return;
const { h } = bound;
const w = h / 0.75;
const target = this._getTargetXYWH(w, h);
if (!target) return;
const { xywh, position } = target;
const edgeless = this.edgeless;
const surfaceBlockModel = getSurfaceBlock(this.std.store);
if (!surfaceBlockModel) return;
const frameMgr = this.std.get(EdgelessFrameManagerIdentifier);
const frameIndex = frameMgr.frames.length + 1;
const props = this.std.get(EditPropsStore).applyLastProps('affine:frame', {
title: new Y.Text(`Frame ${frameIndex}`),
xywh: serializeXYWH(...xywh),
presentationIndex: frameMgr.generatePresentationIndex(),
});
const id = this.crud.addBlock('affine:frame', props, surfaceBlockModel);
edgeless.store.captureSync();
const frame = this.crud.getElementById(id);
if (!frame) return;
this.connector.target = {
id,
position,
};
this.gfx.selection.set({
elements: [frame.id],
editing: false,
});
}
private _addNote() {
const { store } = this.edgeless;
const target = this._getTargetXYWH(
DEFAULT_NOTE_WIDTH,
DEFAULT_NOTE_OVERLAY_HEIGHT
);
if (!target) return;
const { xywh, position } = target;
const id = this.crud.addBlock(
'affine:note',
{
xywh: serializeXYWH(...xywh),
},
store.root?.id
);
const note = store.getBlock(id)?.model;
if (!matchModels(note, [NoteBlockModel])) {
return;
}
store.addBlock('affine:paragraph', { type: 'text' }, id);
const group = this.currentSource.group;
if (group instanceof GroupElementModel) {
group.addChild(note);
}
this.connector.target = {
id,
position: position as [number, number],
};
this.crud.updateElement(this.connector.id, {
target: { id, position },
});
this.gfx.selection.set({
elements: [id],
editing: false,
});
}
private _addShape(targetType: TARGET_SHAPE_TYPE) {
const edgeless = this.edgeless;
const result = this._generateTarget(this.connector);
if (!result) return;
const currentSource = this.currentSource;
const { nextBound, position } = result;
const id = createShapeElement(edgeless, currentSource, targetType);
if (!id) return;
this.crud.updateElement(id, { xywh: nextBound.serialize() });
this.crud.updateElement(this.connector.id, {
target: { id, position },
});
mountShapeTextEditor(
this.crud.getElementById(id) as ShapeElementModel,
this.edgeless
);
this.gfx.selection.set({
elements: [id],
editing: true,
});
edgeless.store.captureSync();
}
private _addText() {
const target = this._getTargetXYWH(DEFAULT_TEXT_WIDTH, DEFAULT_TEXT_HEIGHT);
if (!target) return;
const { xywh, position } = target;
const bound = Bound.fromXYWH(xywh);
const textFlag = this.edgeless.store
.get(FeatureFlagService)
.getFlag('enable_edgeless_text');
if (textFlag) {
const [_, { textId }] = this.edgeless.std.command.exec(
insertEdgelessTextCommand,
{
x: bound.x,
y: bound.y,
}
);
if (!textId) return;
const textElement = this.crud.getElementById(textId);
if (!textElement) return;
this.crud.updateElement(this.connector.id, {
target: { id: textId, position },
});
if (this.currentSource.group instanceof GroupElementModel) {
this.currentSource.group.addChild(textElement);
}
this.gfx.selection.set({
elements: [textId],
editing: false,
});
this.edgeless.store.captureSync();
} else {
const textId = this.crud.addElement(CanvasElementType.TEXT, {
xywh: bound.serialize(),
text: new Y.Text(),
textAlign: 'left',
fontSize: 24,
fontFamily: FontFamily.Inter,
color: DefaultTheme.textColor,
fontWeight: FontWeight.Regular,
fontStyle: FontStyle.Normal,
});
if (!textId) return;
const textElement = this.crud.getElementById(textId);
if (!(textElement instanceof TextElementModel)) {
return;
}
this.crud.updateElement(this.connector.id, {
target: { id: textId, position },
});
if (this.currentSource.group instanceof GroupElementModel) {
this.currentSource.group.addChild(textElement);
}
this.gfx.selection.set({
elements: [textId],
editing: false,
});
this.edgeless.store.captureSync();
mountTextElementEditor(textElement, this.edgeless);
}
}
private _autoComplete(targetType: AUTO_COMPLETE_TARGET_TYPE) {
this._removeOverlay();
if (!this._connectorExist()) return;
switch (targetType) {
case 'text':
this._addText();
break;
case 'note':
this._addNote();
break;
case 'frame':
this._addFrame();
break;
default:
this._addShape(targetType);
}
this.remove();
}
private _connectorExist() {
return !!this.crud.getElementById(this.connector.id);
}
private _generateTarget(connector: ConnectorElementModel) {
const { currentSource } = this;
let w = SHAPE_OVERLAY_WIDTH;
let h = SHAPE_OVERLAY_HEIGHT;
if (isShape(currentSource)) {
const bound = Bound.deserialize(currentSource.xywh);
w = bound.w;
h = bound.h;
}
const point = connector.target.position;
if (!point) return;
const len = connector.path.length;
const angle = normalizeDegAngle(
toDegree(Vec.angle(connector.path[len - 2], connector.path[len - 1]))
);
let nextBound: Bound;
let position: Connection['position'];
// direction of the connector target arrow
let direction: Direction;
if (angle >= 45 && angle <= 135) {
nextBound = new Bound(point[0] - w / 2, point[1], w, h);
position = [0.5, 0];
direction = Direction.Bottom;
} else if (angle >= 135 && angle <= 225) {
nextBound = new Bound(point[0] - w, point[1] - h / 2, w, h);
position = [1, 0.5];
direction = Direction.Left;
} else if (angle >= 225 && angle <= 315) {
nextBound = new Bound(point[0] - w / 2, point[1] - h, w, h);
position = [0.5, 1];
direction = Direction.Top;
} else {
nextBound = new Bound(point[0], point[1] - h / 2, w, h);
position = [0, 0.5];
direction = Direction.Right;
}
return { nextBound, position, direction };
}
private _getCurrentSourceInfo(): {
style: ShapeStyle;
type: AUTO_COMPLETE_TARGET_TYPE;
} {
const { currentSource } = this;
if (isShape(currentSource)) {
const { shapeType, shapeStyle, radius } = currentSource;
return {
style: shapeStyle,
type: getShapeName(shapeType, radius),
};
}
return {
style: ShapeStyle.General,
type: 'note',
};
}
private _getPanelPosition() {
const { viewport } = this.gfx;
const { boundingClientRect: viewportRect, zoom } = viewport;
const result = this._getTargetXYWH(PANEL_WIDTH / zoom, PANEL_HEIGHT / zoom);
const pos = result ? result.xywh.slice(0, 2) : this.position;
const coord = viewport.toViewCoord(pos[0], pos[1]);
const { width, height } = viewportRect;
coord[0] = clamp(coord[0], 20, width - 20 - PANEL_WIDTH);
coord[1] = clamp(coord[1], 20, height - 20 - PANEL_HEIGHT);
return coord;
}
private _getTargetXYWH(width: number, height: number) {
const result = this._generateTarget(this.connector);
if (!result) return null;
const { nextBound: bound, direction, position } = result;
if (!bound) return null;
const { w, h } = bound;
let x = bound.x;
let y = bound.y;
switch (direction) {
case Direction.Right:
y += h / 2 - height / 2;
break;
case Direction.Bottom:
x -= width / 2 - w / 2;
break;
case Direction.Left:
y += h / 2 - height / 2;
x -= width - w;
break;
case Direction.Top:
x -= width / 2 - w / 2;
y += h - height;
break;
}
const xywh = [x, y, width, height] as XYWH;
return { xywh, position };
}
private _removeOverlay() {
if (this._overlay && this.surface) {
this.surface.renderer.removeOverlay(this._overlay);
}
}
private _showFrameOverlay() {
if (!this.surface) return;
const bound = this._generateTarget(this.connector)?.nextBound;
if (!bound) return;
const { h } = bound;
const w = h / 0.75;
const xywh = this._getTargetXYWH(w, h)?.xywh;
if (!xywh) return;
const strokeColor = this.std
.get(ThemeProvider)
.getCssVariableColor('--affine-black-30');
this._overlay = new AutoCompleteFrameOverlay(this.gfx, xywh, strokeColor);
this.surface.renderer.addOverlay(this._overlay);
}
private _showNoteOverlay() {
const xywh = this._getTargetXYWH(
DEFAULT_NOTE_WIDTH,
DEFAULT_NOTE_OVERLAY_HEIGHT
)?.xywh;
if (!xywh) return;
if (!this.surface) return;
const background = this.edgeless.std
.get(ThemeProvider)
.getColorValue(
this.edgeless.std.get(EditPropsStore).lastProps$.value['affine:note']
.background,
DefaultTheme.noteBackgrounColor,
true
);
this._overlay = new AutoCompleteNoteOverlay(this.gfx, xywh, background);
this.surface.renderer.addOverlay(this._overlay);
}
private _showOverlay(targetType: AUTO_COMPLETE_TARGET_TYPE) {
this._removeOverlay();
if (!this._connectorExist()) return;
if (!this.surface) return;
switch (targetType) {
case 'text':
this._showTextOverlay();
break;
case 'note':
this._showNoteOverlay();
break;
case 'frame':
this._showFrameOverlay();
break;
default:
this._showShapeOverlay(targetType);
}
this.surface.refresh();
}
private _showShapeOverlay(targetType: TARGET_SHAPE_TYPE) {
const bound = this._generateTarget(this.connector)?.nextBound;
if (!bound) return;
if (!this.surface) return;
const { x, y, w, h } = bound;
const xywh = [x, y, w, h] as XYWH;
const { shapeStyle, strokeColor, fillColor, strokeWidth, roughness } =
this.edgeless.std.get(EditPropsStore).lastProps$.value[
`shape:${targetType}`
];
const stroke = this.edgeless.std
.get(ThemeProvider)
.getColorValue(strokeColor, DefaultTheme.shapeStrokeColor, true);
const fill = this.edgeless.std
.get(ThemeProvider)
.getColorValue(fillColor, DefaultTheme.shapeFillColor, true);
const options = {
seed: 666,
roughness: roughness,
strokeLineDash: [0, 0],
stroke,
strokeWidth,
fill,
};
this._overlay = new AutoCompleteShapeOverlay(
this.gfx,
xywh,
targetType,
options,
shapeStyle
);
this.surface.renderer.addOverlay(this._overlay);
}
private _showTextOverlay() {
const xywh = this._getTargetXYWH(
DEFAULT_TEXT_WIDTH,
DEFAULT_TEXT_HEIGHT
)?.xywh;
if (!xywh) return;
if (!this.surface) return;
this._overlay = new AutoCompleteTextOverlay(this.gfx, xywh);
this.surface.renderer.addOverlay(this._overlay);
}
override connectedCallback() {
super.connectedCallback();
this.edgeless.handleEvent('click', ctx => {
const { target } = ctx.get('pointerState').raw;
const element = captureEventTarget(target);
const clickAway = !element?.closest('edgeless-auto-complete-panel');
if (clickAway) this.remove();
});
}
override disconnectedCallback() {
super.disconnectedCallback();
this._removeOverlay();
}
override firstUpdated() {
this.disposables.add(
this.gfx.viewport.viewportUpdated.subscribe(() => this.requestUpdate())
);
}
override render() {
const position = this._getPanelPosition();
if (!position) return nothing;
const style = styleMap({
left: `${position[0]}px`,
top: `${position[1]}px`,
});
const { style: currentSourceStyle, type: currentSourceType } =
this._getCurrentSourceInfo();
const shapeButtons = repeat(
ShapeComponentConfig,
({ name, generalIcon, scribbledIcon, tooltip }) => html`
<edgeless-tool-icon-button
.tooltip=${tooltip}
.iconSize=${'20px'}
@pointerenter=${() => this._showOverlay(name)}
@pointerleave=${() => this._removeOverlay()}
@click=${() => this._autoComplete(name)}
>
${currentSourceStyle === 'General' ? generalIcon : scribbledIcon}
</edgeless-tool-icon-button>
`
);
return html`<div class="auto-complete-panel-container" style=${style}>
${shapeButtons}
<edgeless-tool-icon-button
.tooltip=${'Text'}
.iconSize=${'20px'}
@pointerenter=${() => this._showOverlay('text')}
@pointerleave=${() => this._removeOverlay()}
@click=${() => this._autoComplete('text')}
>
${FontFamilyIcon}
</edgeless-tool-icon-button>
<edgeless-tool-icon-button
.tooltip=${'Note'}
.iconSize=${'20px'}
@pointerenter=${() => this._showOverlay('note')}
@pointerleave=${() => this._removeOverlay()}
@click=${() => this._autoComplete('note')}
>
${PageIcon()}
</edgeless-tool-icon-button>
<edgeless-tool-icon-button
.tooltip=${'Frame'}
.iconSize=${'20px'}
@pointerenter=${() => this._showOverlay('frame')}
@pointerleave=${() => this._removeOverlay()}
@click=${() => this._autoComplete('frame')}
>
${FrameIcon()}
</edgeless-tool-icon-button>
<edgeless-tool-icon-button
.iconContainerPadding=${0}
.tooltip=${capitalizeFirstLetter(currentSourceType)}
@pointerenter=${() => this._showOverlay(currentSourceType)}
@pointerleave=${() => this._removeOverlay()}
@click=${() => this._autoComplete(currentSourceType)}
>
<div class="row-button">Add a same object</div>
</edgeless-tool-icon-button>
</div>`;
}
@property({ attribute: false })
accessor connector: ConnectorElementModel;
@property({ attribute: false })
accessor currentSource: ShapeElementModel | NoteBlockModel;
@property({ attribute: false })
accessor edgeless: BlockComponent;
@property({ attribute: false })
accessor position: [number, number];
@consume({
context: stdContext,
})
accessor std!: BlockStdScope;
}

View File

@@ -0,0 +1,787 @@
import {
CanvasElementType,
EdgelessCRUDIdentifier,
getSurfaceBlock,
getSurfaceComponent,
isNoteBlock,
Overlay,
OverlayIdentifier,
type RoughCanvas,
} from '@blocksuite/affine-block-surface';
import {
type ConnectionOverlay,
ConnectorPathGenerator,
} from '@blocksuite/affine-gfx-connector';
import { mountShapeTextEditor } from '@blocksuite/affine-gfx-shape';
import type {
Connection,
ConnectorElementModel,
NoteBlockModel,
ShapeType,
} from '@blocksuite/affine-model';
import {
DEFAULT_NOTE_HEIGHT,
DefaultTheme,
LayoutType,
MindmapElementModel,
ShapeElementModel,
shapeMethods,
} from '@blocksuite/affine-model';
import { ToolbarRegistryIdentifier } from '@blocksuite/affine-shared/services';
import type { SelectedRect } from '@blocksuite/affine-shared/types';
import { handleNativeRangeAtPoint } from '@blocksuite/affine-shared/utils';
import { DisposableGroup } from '@blocksuite/global/disposable';
import type { Bound, IVec } from '@blocksuite/global/gfx';
import { Vec } from '@blocksuite/global/gfx';
import { WithDisposable } from '@blocksuite/global/lit';
import {
ArrowUpBigIcon,
PlusIcon,
SiblingNodeIcon,
SubNodeIcon,
} from '@blocksuite/icons/lit';
import {
type BlockComponent,
type BlockStdScope,
stdContext,
} from '@blocksuite/std';
import { GfxControllerIdentifier } from '@blocksuite/std/gfx';
import { consume } from '@lit/context';
import { css, html, LitElement, nothing } from 'lit';
import { property, state } from 'lit/decorators.js';
import { classMap } from 'lit/directives/class-map.js';
import { styleMap } from 'lit/directives/style-map.js';
import { EdgelessAutoCompletePanel } from './auto-complete-panel.js';
import {
createEdgelessElement,
Direction,
getPosition,
isShape,
MAIN_GAP,
nextBound,
} from './utils.js';
class AutoCompleteOverlay extends Overlay {
linePoints: IVec[] = [];
renderShape: ((ctx: CanvasRenderingContext2D) => void) | null = null;
stroke = '';
override render(ctx: CanvasRenderingContext2D, _rc: RoughCanvas) {
if (this.linePoints.length && this.renderShape) {
ctx.setLineDash([2, 2]);
ctx.strokeStyle = this.stroke;
ctx.beginPath();
this.linePoints.forEach((p, index) => {
if (index === 0) ctx.moveTo(p[0], p[1]);
else ctx.lineTo(p[0], p[1]);
});
ctx.stroke();
this.renderShape(ctx);
ctx.stroke();
}
}
}
export class EdgelessAutoComplete extends WithDisposable(LitElement) {
static override styles = css`
.edgeless-auto-complete-container {
position: absolute;
z-index: 1;
pointer-events: none;
}
.edgeless-auto-complete-arrow-wrapper {
width: 72px;
height: 44px;
position: absolute;
z-index: 1;
pointer-events: auto;
display: flex;
align-items: center;
justify-content: center;
}
.edgeless-auto-complete-arrow-wrapper.hidden {
display: none;
}
.edgeless-auto-complete-arrow {
display: flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
border-radius: 19px;
cursor: pointer;
pointer-events: auto;
transition:
background 0.3s linear,
box-shadow 0.2s linear;
}
.edgeless-auto-complete-arrow-wrapper.mindmap {
width: 26px;
height: 26px;
}
.edgeless-auto-complete-arrow-wrapper:hover
> .edgeless-auto-complete-arrow {
border: 1px solid var(--affine-border-color);
box-shadow: var(--affine-shadow-1);
background: var(--affine-white);
}
.edgeless-auto-complete-arrow-wrapper
> .edgeless-auto-complete-arrow:hover {
border: 1px solid var(--affine-white-10);
box-shadow: var(--affine-shadow-1);
background: var(--affine-primary-color);
}
.edgeless-auto-complete-arrow-wrapper.mindmap
> .edgeless-auto-complete-arrow {
border: 1px solid var(--affine-border-color);
box-shadow: var(--affine-shadow-1);
background: var(--affine-white);
transition:
background 0.3s linear,
color 0.2s linear;
}
.edgeless-auto-complete-arrow-wrapper.mindmap
> .edgeless-auto-complete-arrow:hover {
border: 1px solid var(--affine-white-10);
box-shadow: var(--affine-shadow-1);
background: var(--affine-primary-color);
}
.edgeless-auto-complete-arrow svg {
fill: #77757d;
color: #77757d;
}
.edgeless-auto-complete-arrow:hover svg {
fill: #ffffff;
color: #ffffff;
}
`;
private get _surface() {
return getSurfaceBlock(this.std.store);
}
private _autoCompleteOverlay!: AutoCompleteOverlay;
private readonly _onPointerDown = (e: PointerEvent, type: Direction) => {
const viewportRect = this.gfx.viewport.boundingClientRect;
const start = this.gfx.viewport.toModelCoord(
e.clientX - viewportRect.left,
e.clientY - viewportRect.top
);
if (!this.edgeless.std.event) return;
let connector: ConnectorElementModel | null;
this._disposables.addFromEvent(document, 'pointermove', e => {
const point = this.gfx.viewport.toModelCoord(
e.clientX - viewportRect.left,
e.clientY - viewportRect.top
);
if (Vec.dist(start, point) > 8 && !this._isMoving) {
if (!this.canShowAutoComplete) return;
this._isMoving = true;
const { startPosition } = getPosition(type);
connector = this._addConnector(
{
id: this.current.id,
position: startPosition,
},
{
position: point,
}
);
}
if (this._isMoving) {
if (!connector) {
return;
}
const otherSideId = connector.source.id;
connector.target = this.connectionOverlay.renderConnector(
point,
otherSideId ? [otherSideId] : []
);
this.std.get(ToolbarRegistryIdentifier).flags.hide();
}
});
this._disposables.addFromEvent(document, 'pointerup', e => {
if (!this._isMoving) {
this._generateElementOnClick(type);
} else if (connector && !connector.target.id) {
this.gfx.selection.clear();
this._createAutoCompletePanel(e, connector);
}
this.std.get(ToolbarRegistryIdentifier).flags.show();
this._isMoving = false;
this.connectionOverlay.clear();
this._disposables.dispose();
this._disposables = new DisposableGroup();
});
};
private _pathGenerator!: ConnectorPathGenerator;
private _timer: ReturnType<typeof setTimeout> | null = null;
get canShowAutoComplete() {
const { current } = this;
return isShape(current) || isNoteBlock(current);
}
get connectionOverlay() {
return this.std.get(OverlayIdentifier('connection')) as ConnectionOverlay;
}
get crud() {
return this.std.get(EdgelessCRUDIdentifier);
}
get gfx() {
return this.std.get(GfxControllerIdentifier);
}
private _addConnector(source: Connection, target: Connection) {
const id = this.crud.addElement(CanvasElementType.CONNECTOR, {
source,
target,
});
if (!id) return null;
return this.crud.getElementById(id) as ConnectorElementModel;
}
private _addMindmapNode(target: 'sibling' | 'child') {
const mindmap = this.current.group;
if (!(mindmap instanceof MindmapElementModel)) return;
const parent =
target === 'sibling'
? (mindmap.getParentNode(this.current.id) ?? this.current)
: this.current;
const parentNode = mindmap.getNode(parent.id);
if (!parentNode) return;
const newNode = mindmap.addNode(
parentNode.id,
target === 'sibling' ? this.current.id : undefined,
undefined,
undefined
);
if (parentNode.detail.collapsed) {
mindmap.toggleCollapse(parentNode);
}
requestAnimationFrame(() => {
mountShapeTextEditor(
this.crud.getElementById(newNode) as ShapeElementModel,
this.edgeless
);
});
}
private _computeLine(
type: Direction,
curShape: ShapeElementModel,
nextBound: Bound
) {
const startBound = this.current.elementBound;
const { startPosition, endPosition } = getPosition(type);
const nextShape = {
xywh: nextBound.serialize(),
rotate: curShape.rotate,
shapeType: curShape.shapeType,
};
const startPoint = curShape.getRelativePointLocation(startPosition);
const endPoint = curShape.getRelativePointLocation.call(
nextShape,
endPosition
);
return this._pathGenerator.generateOrthogonalConnectorPath({
startBound,
endBound: nextBound,
startPoint,
endPoint,
});
}
private _computeNextBound(type: Direction) {
if (isShape(this.current)) {
const connectedShapes = this._getConnectedElements(this.current).filter(
e => e instanceof ShapeElementModel
) as ShapeElementModel[];
return nextBound(type, this.current, connectedShapes);
} else {
const bound = this.current.elementBound;
switch (type) {
case Direction.Right: {
bound.x += bound.w + MAIN_GAP;
break;
}
case Direction.Bottom: {
bound.y += bound.h + MAIN_GAP;
break;
}
case Direction.Left: {
bound.x -= bound.w + MAIN_GAP;
break;
}
case Direction.Top: {
bound.y -= bound.h + MAIN_GAP;
break;
}
}
return bound;
}
}
private _createAutoCompletePanel(
e: PointerEvent,
connector: ConnectorElementModel
) {
if (!this.canShowAutoComplete) return;
const position = this.gfx.viewport.toModelCoord(e.clientX, e.clientY);
const autoCompletePanel = new EdgelessAutoCompletePanel(
position,
this.edgeless,
this.current,
connector
);
this.edgeless.append(autoCompletePanel);
}
private _generateElementOnClick(type: Direction) {
const { store } = this.edgeless;
const bound = this._computeNextBound(type);
const id = createEdgelessElement(this.edgeless, this.current, bound);
if (!id) return;
if (isShape(this.current)) {
const { startPosition, endPosition } = getPosition(type);
this._addConnector(
{
id: this.current.id,
position: startPosition,
},
{
id,
position: endPosition,
}
);
mountShapeTextEditor(
this.crud.getElementById(id) as ShapeElementModel,
this.edgeless
);
} else {
const model = store.getModelById(id);
if (!model) {
return;
}
const [x, y] = this.gfx.viewport.toViewCoord(
bound.center[0],
bound.y + DEFAULT_NOTE_HEIGHT / 2
);
requestAnimationFrame(() => {
handleNativeRangeAtPoint(x, y);
});
}
this.gfx.selection.set({
elements: [id],
editing: true,
});
this.removeOverlay();
}
private _getConnectedElements(element: ShapeElementModel) {
if (!this._surface) return [];
return this._surface.getConnectors(element.id).reduce((prev, current) => {
if (current.target.id === element.id && current.source.id) {
prev.push(
this.crud.getElementById(current.source.id) as ShapeElementModel
);
}
if (current.source.id === element.id && current.target.id) {
prev.push(
this.crud.getElementById(current.target.id) as ShapeElementModel
);
}
return prev;
}, [] as ShapeElementModel[]);
}
private _getMindmapButtons() {
const mindmap = this.current.group as MindmapElementModel;
const mindmapDirection =
this.current instanceof ShapeElementModel &&
mindmap instanceof MindmapElementModel
? mindmap.getLayoutDir(this.current.id)
: null;
const isRoot = mindmap?.tree.id === this.current.id;
const mindmapNode = mindmap.getNode(this.current.id);
let buttons: [
Direction,
'child' | 'sibling',
LayoutType.LEFT | LayoutType.RIGHT,
][] = [];
switch (mindmapDirection) {
case LayoutType.LEFT:
buttons = [[Direction.Left, 'child', LayoutType.LEFT]];
if (!isRoot) {
buttons.push([Direction.Bottom, 'sibling', mindmapDirection]);
}
break;
case LayoutType.RIGHT:
buttons = [[Direction.Right, 'child', LayoutType.RIGHT]];
if (!isRoot) {
buttons.push([Direction.Bottom, 'sibling', mindmapDirection]);
}
break;
case LayoutType.BALANCE:
buttons = [
[Direction.Right, 'child', LayoutType.RIGHT],
[Direction.Left, 'child', LayoutType.LEFT],
];
break;
default:
buttons = [];
}
return buttons.length
? {
mindmapNode,
buttons,
}
: null;
}
private _initOverlay() {
const surface = getSurfaceComponent(this.std);
if (!surface) return;
this._autoCompleteOverlay = new AutoCompleteOverlay(this.gfx);
surface.renderer.addOverlay(this._autoCompleteOverlay);
}
private _renderArrow() {
const isShape = this.current instanceof ShapeElementModel;
const { selectedRect } = this;
const { zoom } = this.gfx.viewport;
const width = 72;
const height = 44;
// Auto-complete arrows for shape and note are different
// Shape: right, bottom, left, top
// Note: right, left
const arrowDirections = isShape
? [Direction.Right, Direction.Bottom, Direction.Left, Direction.Top]
: [Direction.Right, Direction.Left];
const arrowMargin = isShape ? height / 2 : height * (2 / 3);
const Arrows = arrowDirections.map(type => {
let transform = '';
const iconSize = { width: '16px', height: '16px' };
const icon = (isShape ? ArrowUpBigIcon : PlusIcon)(iconSize);
switch (type) {
case Direction.Top:
transform += `translate(${
selectedRect.width / 2
}px, ${-arrowMargin}px)`;
break;
case Direction.Right:
transform += `translate(${selectedRect.width + arrowMargin}px, ${
selectedRect.height / 2
}px)`;
isShape && (transform += `rotate(90deg)`);
break;
case Direction.Bottom:
transform += `translate(${selectedRect.width / 2}px, ${
selectedRect.height + arrowMargin
}px)`;
isShape && (transform += `rotate(180deg)`);
break;
case Direction.Left:
transform += `translate(${-arrowMargin}px, ${
selectedRect.height / 2
}px)`;
isShape && (transform += `rotate(-90deg)`);
break;
}
transform += `translate(${-width / 2}px, ${-height / 2}px)`;
const arrowWrapperClasses = classMap({
'edgeless-auto-complete-arrow-wrapper': true,
hidden: !isShape && type === Direction.Left && zoom >= 1.5,
});
return html`<div
class=${arrowWrapperClasses}
style=${styleMap({
transform,
transformOrigin: 'left top',
})}
>
<div
class="edgeless-auto-complete-arrow"
@mouseenter=${() => {
this._timer = setTimeout(() => {
if (this.current instanceof ShapeElementModel) {
const bound = this._computeNextBound(type);
const path = this._computeLine(type, this.current, bound);
this._showNextShape(
this.current,
bound,
path,
this.current.shapeType
);
}
}, 300);
}}
@mouseleave=${() => {
this.removeOverlay();
}}
@pointerdown=${(e: PointerEvent) => {
this._onPointerDown(e, type);
}}
>
${icon}
</div>
</div>`;
});
return Arrows;
}
private _renderMindMapButtons() {
const mindmapButtons = this._getMindmapButtons();
if (!mindmapButtons) {
return;
}
const { selectedRect } = this;
const { zoom } = this.gfx.viewport;
const size = 26;
const buttonMargin =
(mindmapButtons.mindmapNode?.children.length ?? 0) > 0
? size / 2 + 32 * zoom
: size / 2 + 6;
const verticalMargin = size / 2 + 6;
return mindmapButtons.buttons.map(type => {
let transform = '';
const [position, target, layout] = type;
const isLeftLayout = layout === LayoutType.LEFT;
const icon = (target === 'child' ? SubNodeIcon : SiblingNodeIcon)({
width: '16px',
height: '16px',
});
switch (position) {
case Direction.Bottom:
transform += `translate(${selectedRect.width / 2}px, ${
selectedRect.height + verticalMargin
}px)`;
isLeftLayout && (transform += `scale(-1)`);
break;
case Direction.Right:
transform += `translate(${selectedRect.width + buttonMargin}px, ${
selectedRect.height / 2
}px)`;
break;
case Direction.Left:
transform += `translate(${-buttonMargin}px, ${
selectedRect.height / 2
}px)`;
transform += `scale(-1)`;
break;
}
transform += `translate(${-size / 2}px, ${-size / 2}px)`;
const arrowWrapperClasses = classMap({
'edgeless-auto-complete-arrow-wrapper': true,
hidden: position === Direction.Left && zoom >= 1.5,
mindmap: true,
});
return html`<div
class=${arrowWrapperClasses}
style=${styleMap({
transform,
transformOrigin: 'left top',
})}
>
<div
class="edgeless-auto-complete-arrow"
@pointerdown=${() => {
this._addMindmapNode(target);
}}
>
${icon}
</div>
</div>`;
});
}
private _showNextShape(
current: ShapeElementModel,
bound: Bound,
path: IVec[],
targetType: ShapeType
) {
const surface = getSurfaceComponent(this.std);
if (!surface) return;
this._autoCompleteOverlay.stroke = surface.renderer.getColorValue(
current.strokeColor,
DefaultTheme.shapeStrokeColor,
true
);
this._autoCompleteOverlay.linePoints = path;
this._autoCompleteOverlay.renderShape = ctx => {
shapeMethods[targetType].draw(ctx, { ...bound, rotate: current.rotate });
};
surface.refresh();
}
override connectedCallback(): void {
super.connectedCallback();
this._pathGenerator = new ConnectorPathGenerator({
getElementById: id => this.crud.getElementById(id),
});
this._initOverlay();
}
override firstUpdated() {
const { _disposables, edgeless, gfx } = this;
_disposables.add(
this.gfx.selection.slots.updated.subscribe(() => {
this._autoCompleteOverlay.linePoints = [];
this._autoCompleteOverlay.renderShape = null;
})
);
_disposables.add(
gfx.selection.slots.updated.subscribe(() => {
this.requestUpdate();
})
);
_disposables.add(() => this.removeOverlay());
_disposables.add(
edgeless.host.event.add('pointerMove', ctx => {
const evt = ctx.get('pointerState');
const [x, y] = gfx.viewport.toModelCoord(evt.x, evt.y);
const elm = gfx.getElementByPoint(x, y);
if (!elm) {
this._isHover = false;
return;
}
this._isHover = elm === this.current ? true : false;
})
);
this.edgeless.handleEvent('dragStart', () => {
this._isMoving = true;
});
this.edgeless.handleEvent('dragEnd', () => {
this._isMoving = false;
});
}
private _canAutoComplete() {
const selection = this.gfx.selection;
return (
selection.selectedElements.length === 1 &&
(selection.selectedElements[0] instanceof ShapeElementModel ||
isNoteBlock(selection.selectedElements[0]))
);
}
removeOverlay() {
this._timer && clearTimeout(this._timer);
const surface = getSurfaceComponent(this.std);
if (!surface) return;
surface.renderer.removeOverlay(this._autoCompleteOverlay);
}
override render() {
const isShape = this.current instanceof ShapeElementModel;
const isMindMap = this.current.group instanceof MindmapElementModel;
if (
this._isMoving ||
(this._isHover && !isShape && this._canAutoComplete())
) {
this.removeOverlay();
return nothing;
}
const { selectedRect } = this;
return html`<div
class="edgeless-auto-complete-container"
style=${styleMap({
top: selectedRect.top + 'px',
left: selectedRect.left + 'px',
width: selectedRect.width + 'px',
height: selectedRect.height + 'px',
transform: `rotate(${selectedRect.rotate}deg)`,
})}
>
${isMindMap ? this._renderMindMapButtons() : this._renderArrow()}
</div>`;
}
@state()
private accessor _isHover = true;
@state()
private accessor _isMoving = false;
@property({ attribute: false })
accessor current!: ShapeElementModel | NoteBlockModel;
@property({ attribute: false })
accessor edgeless!: BlockComponent;
@property({ attribute: false })
accessor selectedRect!: SelectedRect;
@consume({
context: stdContext,
})
accessor std!: BlockStdScope;
}

View File

@@ -0,0 +1,807 @@
import { type FrameOverlay } from '@blocksuite/affine-block-frame';
import {
EdgelessLegacySlotIdentifier,
OverlayIdentifier,
} from '@blocksuite/affine-block-surface';
import {
ConnectorElementModel,
type RootBlockModel,
} from '@blocksuite/affine-model';
import { unsafeCSSVarV2 } from '@blocksuite/affine-shared/theme';
import type { SelectedRect } from '@blocksuite/affine-shared/types';
import {
getSelectedRect,
requestThrottledConnectedFrame,
stopPropagation,
} from '@blocksuite/affine-shared/utils';
import { deserializeXYWH } from '@blocksuite/global/gfx';
import { WidgetComponent } from '@blocksuite/std';
import {
type CursorType,
GfxControllerIdentifier,
type GfxModel,
InteractivityIdentifier,
type ResizeHandle,
} from '@blocksuite/std/gfx';
import { css, html, nothing } from 'lit';
import { state } from 'lit/decorators.js';
import { ifDefined } from 'lit/directives/if-defined.js';
import { repeat } from 'lit/directives/repeat.js';
import { styleMap } from 'lit/directives/style-map.js';
import { type Subscription } from 'rxjs';
import { RenderResizeHandles } from './resize-handles.js';
import { generateCursorUrl, getRotatedResizeCursor } from './utils.js';
export const EDGELESS_SELECTED_RECT_WIDGET = 'edgeless-selected-rect';
export class EdgelessSelectedRectWidget extends WidgetComponent<RootBlockModel> {
// disable change-in-update warning
static override enabledWarnings = [];
static override styles = css`
:host {
display: block;
user-select: none;
contain: size layout;
position: absolute;
top: 0;
left: 0;
z-index: 1;
}
.affine-edgeless-selected-rect {
position: absolute;
top: 0;
left: 0;
transform-origin: center center;
border-radius: 0;
pointer-events: none;
box-sizing: border-box;
z-index: 1;
border-color: var(--affine-blue);
border-width: 2px;
border-style: solid;
transform: translate(0, 0) rotate(0);
}
.affine-edgeless-selected-rect[data-locked='true'] {
border-color: ${unsafeCSSVarV2('edgeless/lock/locked', '#00000085')};
}
.affine-edgeless-selected-rect .handle {
position: absolute;
user-select: none;
outline: none;
pointer-events: auto;
/**
* Fix: pointerEvent stops firing after a short time.
* When a gesture is started, the browser intersects the touch-action values of the touched element and its ancestors,
* up to the one that implements the gesture (in other words, the first containing scrolling element)
* https://developer.mozilla.org/en-US/docs/Web/CSS/touch-action
*/
touch-action: none;
}
.affine-edgeless-selected-rect .handle[aria-label^='top-'],
.affine-edgeless-selected-rect .handle[aria-label^='bottom-'] {
width: 18px;
height: 18px;
box-sizing: border-box;
z-index: 10;
}
.affine-edgeless-selected-rect .handle[aria-label^='top-'] .resize,
.affine-edgeless-selected-rect .handle[aria-label^='bottom-'] .resize {
position: absolute;
width: 12px;
height: 12px;
box-sizing: border-box;
border-radius: 50%;
border: 2px var(--affine-blue) solid;
background: white;
}
.affine-edgeless-selected-rect .handle[aria-label^='top-'] .rotate,
.affine-edgeless-selected-rect .handle[aria-label^='bottom-'] .rotate {
position: absolute;
width: 12px;
height: 12px;
box-sizing: border-box;
background: transparent;
}
/* -18 + 6.5 */
.affine-edgeless-selected-rect .handle[aria-label='top-left'] {
left: -12px;
top: -12px;
}
.affine-edgeless-selected-rect .handle[aria-label='top-left'] .resize {
right: 0;
bottom: 0;
}
.affine-edgeless-selected-rect .handle[aria-label='top-left'] .rotate {
right: 6px;
bottom: 6px;
}
.affine-edgeless-selected-rect .handle[aria-label='top-right'] {
top: -12px;
right: -12px;
}
.affine-edgeless-selected-rect .handle[aria-label='top-right'] .resize {
left: 0;
bottom: 0;
}
.affine-edgeless-selected-rect .handle[aria-label='top-right'] .rotate {
left: 6px;
bottom: 6px;
}
.affine-edgeless-selected-rect .handle[aria-label='bottom-right'] {
right: -12px;
bottom: -12px;
}
.affine-edgeless-selected-rect .handle[aria-label='bottom-right'] .resize {
left: 0;
top: 0;
}
.affine-edgeless-selected-rect .handle[aria-label='bottom-right'] .rotate {
left: 6px;
top: 6px;
}
.affine-edgeless-selected-rect .handle[aria-label='bottom-left'] {
bottom: -12px;
left: -12px;
}
.affine-edgeless-selected-rect .handle[aria-label='bottom-left'] .resize {
right: 0;
top: 0;
}
.affine-edgeless-selected-rect .handle[aria-label='bottom-left'] .rotate {
right: 6px;
top: 6px;
}
.affine-edgeless-selected-rect .handle[aria-label='top'],
.affine-edgeless-selected-rect .handle[aria-label='bottom'],
.affine-edgeless-selected-rect .handle[aria-label='left'],
.affine-edgeless-selected-rect .handle[aria-label='right'] {
border: 0;
background: transparent;
border-color: var('--affine-blue');
}
.affine-edgeless-selected-rect .handle[aria-label='left'],
.affine-edgeless-selected-rect .handle[aria-label='right'] {
top: 0;
bottom: 0;
height: 100%;
width: 6px;
}
.affine-edgeless-selected-rect .handle[aria-label='top'],
.affine-edgeless-selected-rect .handle[aria-label='bottom'] {
left: 0;
right: 0;
width: 100%;
height: 6px;
}
/* calc(-1px - (6px - 1px) / 2) = -3.5px */
.affine-edgeless-selected-rect .handle[aria-label='left'] {
left: -3.5px;
}
.affine-edgeless-selected-rect .handle[aria-label='right'] {
right: -3.5px;
}
.affine-edgeless-selected-rect .handle[aria-label='top'] {
top: -3.5px;
}
.affine-edgeless-selected-rect .handle[aria-label='bottom'] {
bottom: -3.5px;
}
.affine-edgeless-selected-rect .handle[aria-label='top'] .resize,
.affine-edgeless-selected-rect .handle[aria-label='bottom'] .resize,
.affine-edgeless-selected-rect .handle[aria-label='left'] .resize,
.affine-edgeless-selected-rect .handle[aria-label='right'] .resize {
width: 100%;
height: 100%;
}
.affine-edgeless-selected-rect .handle[aria-label='top'] .resize:after,
.affine-edgeless-selected-rect .handle[aria-label='bottom'] .resize:after,
.affine-edgeless-selected-rect .handle[aria-label='left'] .resize:after,
.affine-edgeless-selected-rect .handle[aria-label='right'] .resize:after {
position: absolute;
width: 7px;
height: 7px;
box-sizing: border-box;
border-radius: 6px;
z-index: 10;
content: '';
background: white;
}
.affine-edgeless-selected-rect
.handle[aria-label='top']
.transparent-handle:after,
.affine-edgeless-selected-rect
.handle[aria-label='bottom']
.transparent-handle:after,
.affine-edgeless-selected-rect
.handle[aria-label='left']
.transparent-handle:after,
.affine-edgeless-selected-rect
.handle[aria-label='right']
.transparent-handle:after {
opacity: 0;
}
.affine-edgeless-selected-rect .handle[aria-label='left'] .resize:after,
.affine-edgeless-selected-rect .handle[aria-label='right'] .resize:after {
top: calc(50% - 6px);
}
.affine-edgeless-selected-rect .handle[aria-label='top'] .resize:after,
.affine-edgeless-selected-rect .handle[aria-label='bottom'] .resize:after {
left: calc(50% - 6px);
}
.affine-edgeless-selected-rect .handle[aria-label='left'] .resize:after {
left: -0.5px;
}
.affine-edgeless-selected-rect .handle[aria-label='right'] .resize:after {
right: -0.5px;
}
.affine-edgeless-selected-rect .handle[aria-label='top'] .resize:after {
top: -0.5px;
}
.affine-edgeless-selected-rect .handle[aria-label='bottom'] .resize:after {
bottom: -0.5px;
}
.affine-edgeless-selected-rect .handle .resize::before {
content: '';
display: none;
position: absolute;
width: 20px;
height: 20px;
background-image: url("data:image/svg+xml,%3Csvg width='26' height='26' viewBox='0 0 26 26' fill='none' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M23 3H19C10.1634 3 3 10.1634 3 19V23' stroke='black' stroke-opacity='0.3' stroke-width='5' stroke-linecap='round'/%3E%3C/svg%3E");
background-size: contain;
background-repeat: no-repeat;
}
.affine-edgeless-selected-rect[data-mode='scale']
.handle[aria-label='top-left']
.resize:hover::before,
.affine-edgeless-selected-rect[data-scale-direction='top-left'][data-scale-percent]
.handle[aria-label='top-left']
.resize::before {
display: block;
top: 0px;
left: 0px;
transform: translate(-100%, -100%);
}
.affine-edgeless-selected-rect[data-mode='scale']
.handle[aria-label='top-right']
.resize:hover::before,
.affine-edgeless-selected-rect[data-scale-direction='top-right'][data-scale-percent]
.handle[aria-label='top-right']
.resize::before {
display: block;
top: 0px;
right: 0px;
transform: translate(100%, -100%) rotate(90deg);
}
.affine-edgeless-selected-rect[data-mode='scale']
.handle[aria-label='bottom-right']
.resize:hover::before,
.affine-edgeless-selected-rect[data-scale-direction='bottom-right'][data-scale-percent]
.handle[aria-label='bottom-right']
.resize::before {
display: block;
bottom: 0px;
right: 0px;
transform: translate(100%, 100%) rotate(180deg);
}
.affine-edgeless-selected-rect[data-mode='scale']
.handle[aria-label='bottom-left']
.resize:hover::before,
.affine-edgeless-selected-rect[data-scale-direction='bottom-left'][data-scale-percent]
.handle[aria-label='bottom-left']
.resize::before {
display: block;
bottom: 0px;
left: 0px;
transform: translate(-100%, 100%) rotate(-90deg);
}
.affine-edgeless-selected-rect::after {
content: attr(data-scale-percent);
display: none;
position: absolute;
color: var(--affine-icon-color);
font-feature-settings:
'clig' off,
'liga' off;
font-family: var(--affine-font-family);
font-size: 12px;
font-style: normal;
font-weight: 400;
line-height: 24px;
}
.affine-edgeless-selected-rect[data-scale-direction='top-left']::after {
display: block;
top: -20px;
left: -20px;
transform: translate(-100%, -100%);
}
.affine-edgeless-selected-rect[data-scale-direction='top-right']::after {
display: block;
top: -20px;
right: -20px;
transform: translate(100%, -100%);
}
.affine-edgeless-selected-rect[data-scale-direction='bottom-right']::after {
display: block;
bottom: -20px;
right: -20px;
transform: translate(100%, 100%);
}
.affine-edgeless-selected-rect[data-scale-direction='bottom-left']::after {
display: block;
bottom: -20px;
left: -20px;
transform: translate(-100%, 100%);
}
`;
private readonly _initSelectedSlot = () => {
this._propDisposables.forEach(disposable => disposable.unsubscribe());
this._propDisposables = [];
this.selection.selectedElements.forEach(element => {
if ('flavour' in element) {
this._propDisposables.push(
element.propsUpdated.subscribe(() => {
this._updateOnElementChange(element.id);
})
);
}
});
};
private readonly _dragEndCleanup = () => {
this._isWidthLimit = false;
this._isHeightLimit = false;
this._scalePercent = undefined;
this._scaleDirection = undefined;
this._updateCursor();
this.frameOverlay.clear();
};
private _propDisposables: Subscription[] = [];
private readonly _updateCursor = (options?: {
type: 'resize' | 'rotate';
angle: number;
handle: ResizeHandle;
}) => {
if (!options) {
!this._isResizing && (this.gfx.cursor$.value = 'default');
return;
}
const { type, angle, handle } = options;
let cursor: CursorType = 'default';
if (type === 'rotate') {
cursor = generateCursorUrl(angle, handle);
} else {
cursor = getRotatedResizeCursor({
handle,
angle,
});
}
this.gfx.cursor$.value = cursor;
};
private readonly _updateOnElementChange = (
element: string | { id: string }
) => {
const id = typeof element === 'string' ? element : element.id;
if (this.selection.has(id)) {
this._updateSelectedRect();
}
};
private readonly _updateOnSelectionChange = () => {
this._initSelectedSlot();
this._updateSelectedRect();
// Reset the cursor
this._updateCursor();
};
@state()
private accessor _selectedRect: SelectedRect = {
width: 0,
height: 0,
left: 0,
top: 0,
rotate: 0,
borderWidth: 0,
borderStyle: 'solid',
};
private readonly _updateSelectedRect = requestThrottledConnectedFrame(() => {
const { zoom, selection, gfx } = this;
const elements = selection.selectedElements;
// in surface
const rect = getSelectedRect(elements);
// in viewport
const [left, top] = gfx.viewport.toViewCoord(rect.left, rect.top);
const [width, height] = [rect.width * zoom, rect.height * zoom];
let rotate = 0;
if (elements.length === 1 && elements[0].rotate) {
rotate = elements[0].rotate;
}
this._selectedRect = {
width,
height,
left,
top,
rotate,
borderStyle: 'solid',
borderWidth: 2,
};
}, this);
get frameOverlay() {
return this.std.get(OverlayIdentifier('frame')) as FrameOverlay;
}
get gfx() {
return this.std.get(GfxControllerIdentifier);
}
get selection() {
return this.gfx.selection;
}
get surface() {
return this.gfx.surface;
}
get zoom() {
return this.gfx.viewport.zoom;
}
constructor() {
super();
this.addEventListener('pointerdown', stopPropagation);
}
private _shouldRenderSelection(elements?: GfxModel[]) {
elements = elements ?? this.selection.selectedElements;
return elements.length > 0 && !this.selection.editing;
}
override firstUpdated() {
const { _disposables, selection, gfx } = this;
_disposables.add(
// viewport zooming / scrolling
gfx.viewport.viewportUpdated.subscribe(this._updateSelectedRect)
);
if (gfx.surface) {
_disposables.add(
gfx.surface.elementAdded.subscribe(this._updateOnElementChange)
);
_disposables.add(
gfx.surface.elementRemoved.subscribe(this._updateOnElementChange)
);
_disposables.add(
gfx.surface.elementUpdated.subscribe(this._updateOnElementChange)
);
}
_disposables.add(
this.store.slots.blockUpdated.subscribe(this._updateOnElementChange)
);
_disposables.add(
selection.slots.updated.subscribe(this._updateOnSelectionChange)
);
_disposables.add(
this._slots.readonlyUpdated.subscribe(() => this.requestUpdate())
);
_disposables.add(
this._slots.elementResizeEnd.subscribe(() => (this._isResizing = false))
);
if (this._interaction) {
_disposables.add(
this._interaction.activeInteraction$.subscribe(val => {
const pre = this._isResizing;
const newVal = val?.type === 'resize' || val?.type === 'rotate';
if (pre === newVal) {
return;
}
this._isResizing = newVal;
if (newVal) {
this._slots.elementResizeStart.next();
} else {
this._slots.elementResizeEnd.next();
}
})
);
}
_disposables.add(() => {
this._propDisposables.forEach(disposable => disposable.unsubscribe());
});
}
private get _interaction() {
return this.std.getOptional(InteractivityIdentifier);
}
private get _slots() {
return this.std.get(EdgelessLegacySlotIdentifier);
}
private _renderHandles() {
const { selection, gfx, block, store } = this;
const elements = selection.selectedElements;
if (selection.inoperable) {
return [];
}
const handles = [];
if (
this._interaction &&
!selection.editing &&
!store.readonly &&
!elements.some(element => element.isLocked())
) {
const interaction = this._interaction;
const resizeHandlers = interaction.getResizeHandlers({
elements,
});
const { rotatable } = interaction.getRotateConfig({
elements,
});
handles.push(
RenderResizeHandles(
resizeHandlers,
rotatable,
(e: PointerEvent, handle: ResizeHandle) => {
const isRotate = (e.target as HTMLElement).classList.contains(
'rotate'
);
if (isRotate) {
interaction.handleElementRotate({
elements: this.selection.selectedElements,
event: e,
onRotateStart: () => {
this._mode = 'rotate';
},
onRotateUpdate: payload => {
this._updateCursor({
type: 'rotate',
angle: payload.currentAngle,
handle,
});
},
onRotateEnd: () => {
this._mode = 'resize';
this._dragEndCleanup();
},
});
} else {
interaction.handleElementResize({
elements: this.selection.selectedElements,
handle,
event: e,
onResizeStart: () => {
this._mode = 'resize';
},
onResizeUpdate: ({ lockRatio, scaleX, exceed }) => {
if (lockRatio) {
this._scaleDirection = handle;
this._scalePercent = `${Math.round(scaleX * 100)}%`;
}
if (exceed) {
this._isWidthLimit = exceed.w;
this._isHeightLimit = exceed.h;
}
},
onResizeEnd: () => {
this._mode = 'resize';
this._dragEndCleanup();
},
});
}
},
option => {
if (option) {
this._updateCursor({
...option,
angle: elements.length > 1 ? 0 : (elements[0]?.rotate ?? 0),
});
} else {
this._updateCursor();
}
}
)
);
}
if (
elements.length === 1 &&
elements[0] instanceof ConnectorElementModel &&
!elements[0].isLocked()
) {
handles.push(html`
<edgeless-connector-handle
.connector=${elements[0]}
.edgeless=${block}
></edgeless-connector-handle>
`);
}
if (
elements.length > 1 &&
!elements.some(e => e instanceof ConnectorElementModel)
) {
handles.push(
repeat(
elements,
element => element.id,
element => {
const [modelX, modelY, w, h] = deserializeXYWH(element.xywh);
const [x, y] = gfx.viewport.toViewCoord(modelX, modelY);
const { left, top, borderWidth } = this._selectedRect;
const style = {
position: 'absolute',
boxSizing: 'border-box',
left: `${x - left - borderWidth}px`,
top: `${y - top - borderWidth}px`,
width: `${w * this.zoom}px`,
height: `${h * this.zoom}px`,
transform: `rotate(${element.rotate}deg)`,
border: `1px solid var(--affine-primary-color)`,
};
return html`<div
class="element-handle"
style=${styleMap(style)}
></div>`;
}
)
);
}
return handles;
}
private _renderAutoComplete() {
const { store, selection, block, _selectedRect } = this;
return !store.readonly &&
!selection.inoperable &&
!this.autoCompleteOff &&
!this._isResizing
? html`<edgeless-auto-complete
.current=${selection.selectedElements[0]}
.edgeless=${block}
.selectedRect=${_selectedRect}
>
</edgeless-auto-complete>`
: nothing;
}
override render() {
const elements = this.selection.selectedElements;
if (!this._shouldRenderSelection(elements)) return nothing;
const { _selectedRect } = this;
const hasElementLocked = elements.some(element => element.isLocked());
const handlers = this._renderHandles();
const isConnector =
elements.length === 1 && elements[0] instanceof ConnectorElementModel;
return html`
<style>
.affine-edgeless-selected-rect .handle[aria-label='right']::after {
content: '';
display: ${this._isWidthLimit ? 'initial' : 'none'};
position: absolute;
top: 0;
left: 1.5px;
width: 2px;
height: 100%;
background: var(--affine-error-color);
filter: drop-shadow(-6px 0px 12px rgba(235, 67, 53, 0.35));
}
.affine-edgeless-selected-rect .handle[aria-label='bottom']::after {
content: '';
display: ${this._isHeightLimit ? 'initial' : 'none'};
position: absolute;
top: 1.5px;
left: 0px;
width: 100%;
height: 2px;
background: var(--affine-error-color);
filter: drop-shadow(-6px 0px 12px rgba(235, 67, 53, 0.35));
}
</style>
${this._renderAutoComplete()}
<div
class="affine-edgeless-selected-rect"
style=${styleMap({
width: `${_selectedRect.width}px`,
height: `${_selectedRect.height}px`,
borderWidth: `${_selectedRect.borderWidth}px`,
borderStyle: isConnector ? 'none' : _selectedRect.borderStyle,
transform: `translate(${_selectedRect.left}px, ${_selectedRect.top}px) rotate(${_selectedRect.rotate}deg)`,
})}
disabled="true"
data-mode=${this._mode}
data-scale-percent=${ifDefined(this._scalePercent)}
data-scale-direction=${ifDefined(this._scaleDirection)}
data-locked=${hasElementLocked}
>
${handlers}
</div>
`;
}
@state()
private accessor _isHeightLimit = false;
@state()
private accessor _isResizing = false;
@state()
private accessor _isWidthLimit = false;
@state()
private accessor _mode: 'resize' | 'scale' | 'rotate' = 'resize';
@state()
private accessor _scaleDirection: ResizeHandle | undefined = undefined;
@state()
private accessor _scalePercent: string | undefined = undefined;
@state()
accessor autoCompleteOff = false;
}

View File

@@ -0,0 +1,18 @@
import { EdgelessAutoCompletePanel } from './auto-complete-panel';
import { EdgelessAutoComplete } from './edgeless-auto-complete';
import {
EDGELESS_SELECTED_RECT_WIDGET,
EdgelessSelectedRectWidget,
} from './edgeless-selected-rect';
export function effects() {
customElements.define(
'edgeless-auto-complete-panel',
EdgelessAutoCompletePanel
);
customElements.define('edgeless-auto-complete', EdgelessAutoComplete);
customElements.define(
EDGELESS_SELECTED_RECT_WIDGET,
EdgelessSelectedRectWidget
);
}

View File

@@ -0,0 +1 @@
export * from './edgeless-selected-rect';

View File

@@ -0,0 +1,95 @@
import type { ResizeHandle } from '@blocksuite/std/gfx';
import { html, nothing } from 'lit';
import { repeat } from 'lit/directives/repeat.js';
export enum HandleDirection {
Bottom = 'bottom',
BottomLeft = 'bottom-left',
BottomRight = 'bottom-right',
Left = 'left',
Right = 'right',
Top = 'top',
TopLeft = 'top-left',
TopRight = 'top-right',
}
function ResizeHandleRenderer(
handle: ResizeHandle,
rotatable: boolean,
onPointerDown?: (e: PointerEvent, direction: ResizeHandle) => void,
updateCursor?: (options?: {
type: 'resize' | 'rotate';
handle: ResizeHandle;
}) => void
) {
const handlerPointerDown = (e: PointerEvent) => {
e.stopPropagation();
onPointerDown && onPointerDown(e, handle);
};
const pointerEnter = (type: 'resize' | 'rotate') => (e: PointerEvent) => {
e.stopPropagation();
if (e.buttons === 1 || !updateCursor) return;
updateCursor({ type, handle });
};
const pointerLeave = (e: PointerEvent) => {
e.stopPropagation();
if (e.buttons === 1 || !updateCursor) return;
updateCursor();
};
const rotationTpl =
handle.length > 6 && rotatable
? html`<div
class="rotate"
@pointerover=${pointerEnter('rotate')}
@pointerout=${pointerLeave}
></div>`
: nothing;
return html`<div
class="handle"
aria-label=${handle}
@pointerdown=${handlerPointerDown}
>
${rotationTpl}
<div
class="resize transparent-handle"
@pointerover=${pointerEnter('resize')}
@pointerout=${pointerLeave}
></div>
</div>`;
}
/**
* Indicate how selected elements can be resized.
*
* - edge: The selected elements can only be resized dragging edge, usually when note element is selected
* - all: The selected elements can be resize both dragging edge or corner, usually when all elements are `shape`
* - none: The selected elements can't be resized, usually when all elements are `connector`
* - corner: The selected elements can only be resize dragging corner, this is by default mode
* - edgeAndCorner: The selected elements can be resize both dragging left right edge or corner, usually when all elements are 'text'
*/
export type ResizeMode = 'edge' | 'all' | 'none' | 'corner' | 'edgeAndCorner';
export function RenderResizeHandles(
resizeHandles: ResizeHandle[],
rotatable: boolean,
onPointerDown: (e: PointerEvent, direction: ResizeHandle) => void,
updateCursor?: (options?: {
type: 'resize' | 'rotate';
handle: ResizeHandle;
}) => void
) {
return html`
${repeat(
resizeHandles,
handle => handle,
handle =>
ResizeHandleRenderer(handle, rotatable, onPointerDown, updateCursor)
)}
`;
}

View File

@@ -0,0 +1,10 @@
import { WidgetViewExtension } from '@blocksuite/std';
import { literal, unsafeStatic } from 'lit/static-html.js';
import { EDGELESS_SELECTED_RECT_WIDGET } from './edgeless-selected-rect';
export const edgelessSelectedRectWidget = WidgetViewExtension(
'affine:page',
EDGELESS_SELECTED_RECT_WIDGET,
literal`${unsafeStatic(EDGELESS_SELECTED_RECT_WIDGET)}`
);

View File

@@ -0,0 +1,416 @@
import {
EdgelessCRUDIdentifier,
type Options,
Overlay,
type RoughCanvas,
} from '@blocksuite/affine-block-surface';
import { type Shape, ShapeFactory } from '@blocksuite/affine-gfx-shape';
import {
type Connection,
getShapeRadius,
getShapeType,
GroupElementModel,
type NoteBlockModel,
ShapeElementModel,
type ShapeName,
type ShapeStyle,
} from '@blocksuite/affine-model';
import { BlockSuiteError, ErrorCode } from '@blocksuite/global/exceptions';
import { Bound, normalizeDegAngle, type XYWH } from '@blocksuite/global/gfx';
import { assertType } from '@blocksuite/global/utils';
import type { BlockComponent } from '@blocksuite/std';
import type {
CursorType,
GfxController,
GfxModel,
ResizeHandle,
StandardCursor,
} from '@blocksuite/std/gfx';
import * as Y from 'yjs';
export enum Direction {
Right,
Bottom,
Left,
Top,
}
export const PANEL_WIDTH = 136;
export const PANEL_HEIGHT = 108;
export const MAIN_GAP = 100;
export const SECOND_GAP = 20;
export const DEFAULT_NOTE_OVERLAY_HEIGHT = 110;
export const DEFAULT_TEXT_WIDTH = 116;
export const DEFAULT_TEXT_HEIGHT = 24;
export type TARGET_SHAPE_TYPE = ShapeName;
export type AUTO_COMPLETE_TARGET_TYPE =
| TARGET_SHAPE_TYPE
| 'text'
| 'note'
| 'frame';
class AutoCompleteTargetOverlay extends Overlay {
xywh: XYWH;
constructor(gfx: GfxController, xywh: XYWH) {
super(gfx);
this.xywh = xywh;
}
override render(_ctx: CanvasRenderingContext2D, _rc: RoughCanvas) {}
}
export class AutoCompleteTextOverlay extends AutoCompleteTargetOverlay {
constructor(gfx: GfxController, xywh: XYWH) {
super(gfx, xywh);
}
override render(ctx: CanvasRenderingContext2D, _rc: RoughCanvas) {
const [x, y, w, h] = this.xywh;
ctx.globalAlpha = 0.4;
ctx.strokeStyle = '#1e96eb';
ctx.lineWidth = 1;
ctx.strokeRect(x, y, w, h);
// fill text placeholder
ctx.font = '15px sans-serif';
ctx.fillStyle = '#C0BFC1';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText("Type '/' to insert", x + w / 2, y + h / 2);
}
}
export class AutoCompleteNoteOverlay extends AutoCompleteTargetOverlay {
private readonly _background: string;
constructor(gfx: GfxController, xywh: XYWH, background: string) {
super(gfx, xywh);
this._background = background;
}
override render(ctx: CanvasRenderingContext2D, _rc: RoughCanvas) {
const [x, y, w, h] = this.xywh;
ctx.globalAlpha = 0.4;
ctx.fillStyle = this._background;
ctx.strokeStyle = 'rgba(0, 0, 0, 0.10)';
ctx.lineWidth = 2;
ctx.beginPath();
ctx.roundRect(x, y, w, h, 8);
ctx.closePath();
ctx.fill();
ctx.stroke();
// fill text placeholder
ctx.font = '15px sans-serif';
ctx.fillStyle = 'black';
ctx.textAlign = 'left';
ctx.textBaseline = 'middle';
ctx.fillText("Type '/' for command", x + 24, y + h / 2);
}
}
export class AutoCompleteFrameOverlay extends AutoCompleteTargetOverlay {
private readonly _strokeColor;
constructor(gfx: GfxController, xywh: XYWH, strokeColor: string) {
super(gfx, xywh);
this._strokeColor = strokeColor;
}
override render(ctx: CanvasRenderingContext2D, _rc: RoughCanvas) {
const [x, y, w, h] = this.xywh;
// frame title background
const titleWidth = 72;
const titleHeight = 30;
const titleY = y - titleHeight - 10;
ctx.globalAlpha = 0.4;
ctx.fillStyle = 'rgba(0, 0, 0, 0.8)';
ctx.beginPath();
ctx.roundRect(x, titleY, titleWidth, titleHeight, 4);
ctx.closePath();
ctx.fill();
// fill title text
ctx.globalAlpha = 1;
ctx.font = '14px sans-serif';
ctx.fillStyle = 'white';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText('Frame', x + titleWidth / 2, titleY + titleHeight / 2);
// frame stroke
ctx.globalAlpha = 0.4;
ctx.strokeStyle = this._strokeColor;
ctx.lineWidth = 2;
ctx.beginPath();
ctx.roundRect(x, y, w, h, 8);
ctx.closePath();
ctx.stroke();
}
}
export class AutoCompleteShapeOverlay extends Overlay {
private readonly _shape: Shape;
constructor(
gfx: GfxController,
xywh: XYWH,
type: TARGET_SHAPE_TYPE,
options: Options,
shapeStyle: ShapeStyle
) {
super(gfx);
this._shape = ShapeFactory.createShape(xywh, type, options, shapeStyle);
}
override render(ctx: CanvasRenderingContext2D, rc: RoughCanvas) {
ctx.globalAlpha = 0.4;
this._shape.draw(ctx, rc);
}
}
export function nextBound(
type: Direction,
curShape: ShapeElementModel,
elements: ShapeElementModel[]
) {
const bound = Bound.deserialize(curShape.xywh);
const { x, y, w, h } = bound;
let nextBound: Bound;
let angle = 0;
switch (type) {
case Direction.Right:
angle = 0;
break;
case Direction.Bottom:
angle = 90;
break;
case Direction.Left:
angle = 180;
break;
case Direction.Top:
angle = 270;
break;
}
angle = normalizeDegAngle(angle + curShape.rotate);
if (angle >= 45 && angle <= 135) {
nextBound = new Bound(x, y + h + MAIN_GAP, w, h);
} else if (angle >= 135 && angle <= 225) {
nextBound = new Bound(x - w - MAIN_GAP, y, w, h);
} else if (angle >= 225 && angle <= 315) {
nextBound = new Bound(x, y - h - MAIN_GAP, w, h);
} else {
nextBound = new Bound(x + w + MAIN_GAP, y, w, h);
}
function isValidBound(bound: Bound) {
return !elements.some(a => bound.isOverlapWithBound(a.elementBound));
}
let count = 0;
function findValidBound() {
count++;
const number = Math.ceil(count / 2);
const next = nextBound.clone();
switch (type) {
case Direction.Right:
case Direction.Left:
next.y =
count % 2 === 1
? nextBound.y - (h + SECOND_GAP) * number
: nextBound.y + (h + SECOND_GAP) * number;
break;
case Direction.Bottom:
case Direction.Top:
next.x =
count % 2 === 1
? nextBound.x - (w + SECOND_GAP) * number
: nextBound.x + (w + SECOND_GAP) * number;
break;
}
if (isValidBound(next)) return next;
return findValidBound();
}
return isValidBound(nextBound) ? nextBound : findValidBound();
}
export function getPosition(type: Direction) {
let startPosition: Connection['position'];
let endPosition: Connection['position'];
switch (type) {
case Direction.Right:
startPosition = [1, 0.5];
endPosition = [0, 0.5];
break;
case Direction.Bottom:
startPosition = [0.5, 1];
endPosition = [0.5, 0];
break;
case Direction.Left:
startPosition = [0, 0.5];
endPosition = [1, 0.5];
break;
case Direction.Top:
startPosition = [0.5, 0];
endPosition = [0.5, 1];
break;
}
return { startPosition, endPosition };
}
export function isShape(element: unknown): element is ShapeElementModel {
return element instanceof ShapeElementModel;
}
export function capitalizeFirstLetter(str: string) {
return str.charAt(0).toUpperCase() + str.slice(1);
}
export function createEdgelessElement(
edgeless: BlockComponent,
current: ShapeElementModel | NoteBlockModel,
bound: Bound
) {
const crud = edgeless.std.get(EdgelessCRUDIdentifier);
let id;
let element: GfxModel | null = null;
if (isShape(current)) {
id = crud.addElement(current.type, {
...current.serialize(),
text: new Y.Text(),
xywh: bound.serialize(),
});
if (!id) return null;
element = crud.getElementById(id);
} else {
const { store } = edgeless;
id = store.addBlock(
'affine:note',
{
background: current.props.background,
displayMode: current.props.displayMode,
edgeless: current.props.edgeless,
xywh: bound.serialize(),
},
edgeless.model.id
);
const note = store.getBlock(id)?.model;
if (!note) {
throw new BlockSuiteError(
ErrorCode.GfxBlockElementError,
'Note block is not found after creation'
);
}
assertType<NoteBlockModel>(note);
store.updateBlock(note, () => {
note.props.edgeless.collapse = true;
});
store.addBlock('affine:paragraph', {}, note.id);
element = note;
}
if (!element) {
throw new BlockSuiteError(
ErrorCode.GfxBlockElementError,
'Element is not found after creation'
);
}
const group = current.group;
if (group instanceof GroupElementModel) {
group.addChild(element);
}
return id;
}
export function createShapeElement(
edgeless: BlockComponent,
current: ShapeElementModel | NoteBlockModel,
targetType: TARGET_SHAPE_TYPE
) {
const crud = edgeless.std.get(EdgelessCRUDIdentifier);
const id = crud.addElement('shape', {
shapeType: getShapeType(targetType),
radius: getShapeRadius(targetType),
text: new Y.Text(),
});
if (!id) return null;
const element = crud.getElementById(id);
const group = current.group;
if (group instanceof GroupElementModel && element) {
group.addChild(element);
}
return id;
}
const rotateCursorMap: {
[key in ResizeHandle]: number;
} = {
'top-right': 0,
'bottom-right': 90,
'bottom-left': 180,
'top-left': 270,
// not used
left: 0,
right: 0,
top: 0,
bottom: 0,
};
export function generateCursorUrl(
angle = 0,
handle: ResizeHandle,
fallback: StandardCursor = 'default'
): CursorType {
angle = ((angle % 360) + 360) % 360;
return `url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='32' height='32' viewBox='0 0 32 32'%3E%3Cg transform='rotate(${rotateCursorMap[handle] + angle} 16 16)'%3E%3Cpath fill='white' d='M13.7,18.5h3.9l0-1.5c0-1.4-1.2-2.6-2.6-2.6h-1.5v3.9l-5.8-5.8l5.8-5.8v3.9h2.3c3.1,0,5.6,2.5,5.6,5.6v2.3h3.9l-5.8,5.8L13.7,18.5z'/%3E%3Cpath d='M20.4,19.4v-3.2c0-2.6-2.1-4.7-4.7-4.7h-3.2l0,0V9L9,12.6l3.6,3.6v-2.6l0,0H15c1.9,0,3.5,1.6,3.5,3.5v2.4l0,0h-2.6l3.6,3.6l3.6-3.6L20.4,19.4L20.4,19.4z'/%3E%3C/g%3E%3C/svg%3E") 16 16, ${fallback}`;
}
const handleToRotateMap: {
[key in ResizeHandle]: number;
} = {
'top-left': 45,
'top-right': 135,
'bottom-right': 45,
'bottom-left': 135,
left: 0,
right: 0,
top: 90,
bottom: 90,
};
const rotateToHandleMap: {
[key: number]: StandardCursor;
} = {
0: 'ew-resize',
45: 'nwse-resize',
90: 'ns-resize',
135: 'nesw-resize',
};
export function getRotatedResizeCursor(option: {
handle: ResizeHandle;
angle: number;
}) {
const angle =
(Math.round(
(handleToRotateMap[option.handle] + ((option.angle + 360) % 360)) / 45
) %
4) *
45;
return rotateToHandleMap[angle] || 'default';
}

View File

@@ -0,0 +1,23 @@
import {
type ViewExtensionContext,
ViewExtensionProvider,
} from '@blocksuite/affine-ext-loader';
import { effects } from './effects';
import { edgelessSelectedRectWidget } from './spec';
export class EdgelessSelectedRectViewExtension extends ViewExtensionProvider {
override name = 'affine-edgeless-selected-rect-widget';
override effect() {
super.effect();
effects();
}
override setup(context: ViewExtensionContext) {
super.setup(context);
if (this.isEdgeless(context.scope)) {
context.register(edgelessSelectedRectWidget);
}
}
}

View File

@@ -0,0 +1,23 @@
{
"extends": "../../../tsconfig.json",
"compilerOptions": {
"rootDir": "./src",
"outDir": "./dist",
"tsBuildInfoFile": "./dist/tsconfig.tsbuildinfo"
},
"include": ["./src"],
"references": [
{ "path": "../../blocks/frame" },
{ "path": "../../blocks/note" },
{ "path": "../../blocks/surface" },
{ "path": "../../components" },
{ "path": "../../ext-loader" },
{ "path": "../../gfx/connector" },
{ "path": "../../gfx/shape" },
{ "path": "../../gfx/text" },
{ "path": "../../model" },
{ "path": "../../shared" },
{ "path": "../../../framework/global" },
{ "path": "../../../framework/std" }
]
}