mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-02-17 06:16:59 +08:00
1568 lines
42 KiB
TypeScript
1568 lines
42 KiB
TypeScript
import type { EdgelessTextBlockComponent } from '@blocksuite/affine-block-edgeless-text';
|
|
import { EDGELESS_TEXT_BLOCK_MIN_WIDTH } from '@blocksuite/affine-block-edgeless-text';
|
|
import {
|
|
EMBED_HTML_MIN_HEIGHT,
|
|
EMBED_HTML_MIN_WIDTH,
|
|
SYNCED_MIN_HEIGHT,
|
|
SYNCED_MIN_WIDTH,
|
|
} from '@blocksuite/affine-block-embed';
|
|
import {
|
|
CanvasElementType,
|
|
CommonUtils,
|
|
normalizeShapeBound,
|
|
OverlayIdentifier,
|
|
TextUtils,
|
|
} from '@blocksuite/affine-block-surface';
|
|
import {
|
|
type BookmarkBlockModel,
|
|
ConnectorElementModel,
|
|
type EdgelessTextBlockModel,
|
|
type EmbedHtmlModel,
|
|
type EmbedSyncedDocModel,
|
|
FrameBlockModel,
|
|
NOTE_MIN_HEIGHT,
|
|
NOTE_MIN_WIDTH,
|
|
NoteBlockModel,
|
|
type RootBlockModel,
|
|
ShapeElementModel,
|
|
TextElementModel,
|
|
} from '@blocksuite/affine-model';
|
|
import { EMBED_CARD_HEIGHT } from '@blocksuite/affine-shared/consts';
|
|
import { unsafeCSSVarV2 } from '@blocksuite/affine-shared/theme';
|
|
import {
|
|
clamp,
|
|
requestThrottledConnectedFrame,
|
|
stopPropagation,
|
|
} from '@blocksuite/affine-shared/utils';
|
|
import { WidgetComponent } from '@blocksuite/block-std';
|
|
import {
|
|
type CursorType,
|
|
getTopElements,
|
|
GfxControllerIdentifier,
|
|
GfxExtensionIdentifier,
|
|
type GfxModel,
|
|
} from '@blocksuite/block-std/gfx';
|
|
import type {
|
|
Disposable,
|
|
IPoint,
|
|
IVec,
|
|
PointLocation,
|
|
} from '@blocksuite/global/utils';
|
|
import {
|
|
assertType,
|
|
Bound,
|
|
deserializeXYWH,
|
|
pickValues,
|
|
Slot,
|
|
} from '@blocksuite/global/utils';
|
|
import { css, html, nothing } from 'lit';
|
|
import { state } from 'lit/decorators.js';
|
|
import { ifDefined } from 'lit/directives/if-defined.js';
|
|
import { styleMap } from 'lit/directives/style-map.js';
|
|
|
|
import { isMindmapNode } from '../../../../_common/edgeless/mindmap/index.js';
|
|
import type { EdgelessRootBlockComponent } from '../../edgeless-root-block.js';
|
|
import type {
|
|
EdgelessFrameManager,
|
|
FrameOverlay,
|
|
} from '../../frame-manager.js';
|
|
import {
|
|
AI_CHAT_BLOCK_MAX_HEIGHT,
|
|
AI_CHAT_BLOCK_MAX_WIDTH,
|
|
AI_CHAT_BLOCK_MIN_HEIGHT,
|
|
AI_CHAT_BLOCK_MIN_WIDTH,
|
|
} from '../../utils/consts.js';
|
|
import { getElementsWithoutGroup } from '../../utils/group.js';
|
|
import {
|
|
getSelectableBounds,
|
|
getSelectedRect,
|
|
isAIChatBlock,
|
|
isAttachmentBlock,
|
|
isBookmarkBlock,
|
|
isCanvasElement,
|
|
isEdgelessTextBlock,
|
|
isEmbeddedBlock,
|
|
isEmbedFigmaBlock,
|
|
isEmbedGithubBlock,
|
|
isEmbedHtmlBlock,
|
|
isEmbedLinkedDocBlock,
|
|
isEmbedLoomBlock,
|
|
isEmbedSyncedDocBlock,
|
|
isEmbedYoutubeBlock,
|
|
isFrameBlock,
|
|
isImageBlock,
|
|
isNoteBlock,
|
|
} from '../../utils/query.js';
|
|
import {
|
|
HandleDirection,
|
|
ResizeHandles,
|
|
type ResizeMode,
|
|
} from '../resize/resize-handles.js';
|
|
import { HandleResizeManager } from '../resize/resize-manager.js';
|
|
import {
|
|
calcAngle,
|
|
calcAngleEdgeWithRotation,
|
|
calcAngleWithRotation,
|
|
generateCursorUrl,
|
|
getResizeLabel,
|
|
rotateResizeCursor,
|
|
} from '../utils.js';
|
|
|
|
export type SelectedRect = {
|
|
left: number;
|
|
top: number;
|
|
width: number;
|
|
height: number;
|
|
borderWidth: number;
|
|
borderStyle: string;
|
|
rotate: number;
|
|
};
|
|
|
|
export const EDGELESS_SELECTED_RECT_WIDGET = 'edgeless-selected-rect';
|
|
|
|
export class EdgelessSelectedRectWidget extends WidgetComponent<
|
|
RootBlockModel,
|
|
EdgelessRootBlockComponent
|
|
> {
|
|
// 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 _cursorRotate = 0;
|
|
|
|
private _dragEndCallback: (() => void)[] = [];
|
|
|
|
private readonly _initSelectedSlot = () => {
|
|
this._propDisposables.forEach(disposable => disposable.dispose());
|
|
this._propDisposables = [];
|
|
|
|
this.selection.selectedElements.forEach(element => {
|
|
if ('flavour' in element) {
|
|
this._propDisposables.push(
|
|
element.propsUpdated.on(() => {
|
|
this._updateOnElementChange(element.id);
|
|
})
|
|
);
|
|
}
|
|
});
|
|
};
|
|
|
|
private readonly _onDragEnd = () => {
|
|
this.slots.dragEnd.emit();
|
|
|
|
this.doc.transact(() => {
|
|
this._dragEndCallback.forEach(cb => cb());
|
|
});
|
|
|
|
this._dragEndCallback = [];
|
|
this._isWidthLimit = false;
|
|
this._isHeightLimit = false;
|
|
|
|
this._updateCursor(false);
|
|
|
|
this._scalePercent = undefined;
|
|
this._scaleDirection = undefined;
|
|
this._updateMode();
|
|
|
|
this.block.slots.elementResizeEnd.emit();
|
|
|
|
this.frameOverlay.clear();
|
|
};
|
|
|
|
private readonly _onDragMove = (
|
|
newBounds: Map<
|
|
string,
|
|
{
|
|
bound: Bound;
|
|
path?: PointLocation[];
|
|
matrix?: DOMMatrix;
|
|
}
|
|
>,
|
|
direction: HandleDirection
|
|
) => {
|
|
this.slots.dragMove.emit();
|
|
|
|
const { gfx } = this;
|
|
|
|
newBounds.forEach(({ bound, matrix, path }, id) => {
|
|
const element = gfx.getElementById(id) as GfxModel;
|
|
if (!element) return;
|
|
|
|
if (isNoteBlock(element)) {
|
|
this.#adjustNote(element, bound, direction);
|
|
return;
|
|
}
|
|
|
|
if (isEdgelessTextBlock(element)) {
|
|
this.#adjustEdgelessText(element, bound, direction);
|
|
return;
|
|
}
|
|
|
|
if (isEmbedSyncedDocBlock(element)) {
|
|
this.#adjustEmbedSyncedDoc(element, bound, direction);
|
|
return;
|
|
}
|
|
|
|
if (isEmbedHtmlBlock(element)) {
|
|
this.#adjustEmbedHtml(element, bound, direction);
|
|
return;
|
|
}
|
|
|
|
if (isAIChatBlock(element)) {
|
|
this.#adjustAIChat(element, bound, direction);
|
|
return;
|
|
}
|
|
|
|
if (this._isProportionalElement(element)) {
|
|
this.#adjustProportional(element, bound, direction);
|
|
return;
|
|
}
|
|
|
|
if (element instanceof TextElementModel) {
|
|
this.#adjustText(element, bound, direction);
|
|
return;
|
|
}
|
|
|
|
if (element instanceof ShapeElementModel) {
|
|
this.#adjustShape(element, bound, direction);
|
|
return;
|
|
}
|
|
|
|
if (element instanceof ConnectorElementModel && matrix && path) {
|
|
this.#adjustConnector(element, bound, matrix, path);
|
|
return;
|
|
}
|
|
|
|
if (element instanceof FrameBlockModel) {
|
|
this.#adjustFrame(element, bound);
|
|
return;
|
|
}
|
|
|
|
this.#adjustUseFallback(element, bound, direction);
|
|
});
|
|
};
|
|
|
|
private readonly _onDragRotate = (center: IPoint, delta: number) => {
|
|
this.slots.dragRotate.emit();
|
|
|
|
const { selection } = this;
|
|
const m = new DOMMatrix()
|
|
.translateSelf(center.x, center.y)
|
|
.rotateSelf(delta)
|
|
.translateSelf(-center.x, -center.y);
|
|
|
|
const elements = selection.selectedElements.filter(
|
|
element =>
|
|
isImageBlock(element) ||
|
|
isEdgelessTextBlock(element) ||
|
|
isCanvasElement(element)
|
|
);
|
|
|
|
getElementsWithoutGroup(elements).forEach(element => {
|
|
const { id, rotate } = element;
|
|
const bounds = Bound.deserialize(element.xywh);
|
|
const originalCenter = bounds.center;
|
|
const point = new DOMPoint(...originalCenter).matrixTransform(m);
|
|
bounds.center = [point.x, point.y];
|
|
|
|
if (
|
|
isCanvasElement(element) &&
|
|
element instanceof ConnectorElementModel
|
|
) {
|
|
this.#adjustConnector(
|
|
element,
|
|
bounds,
|
|
m,
|
|
element.absolutePath.map(p => p.clone())
|
|
);
|
|
} else {
|
|
this.gfx.updateElement(id, {
|
|
xywh: bounds.serialize(),
|
|
rotate: CommonUtils.normalizeDegAngle(rotate + delta),
|
|
});
|
|
}
|
|
});
|
|
|
|
this._updateCursor(true, { type: 'rotate', angle: delta });
|
|
this._updateMode();
|
|
};
|
|
|
|
private readonly _onDragStart = () => {
|
|
this.slots.dragStart.emit();
|
|
|
|
const rotation = this._resizeManager.rotation;
|
|
|
|
this._dragEndCallback = [];
|
|
this.block.slots.elementResizeStart.emit();
|
|
this.selection.selectedElements.forEach(el => {
|
|
el.stash('xywh');
|
|
|
|
if (el instanceof NoteBlockModel) {
|
|
el.stash('edgeless');
|
|
}
|
|
|
|
if (rotation) {
|
|
el.stash('rotate' as 'xywh');
|
|
}
|
|
|
|
if (el instanceof TextElementModel && !rotation) {
|
|
el.stash('fontSize');
|
|
el.stash('hasMaxWidth');
|
|
}
|
|
|
|
this._dragEndCallback.push(() => {
|
|
el.pop('xywh');
|
|
|
|
if (el instanceof NoteBlockModel) {
|
|
el.pop('edgeless');
|
|
}
|
|
|
|
if (rotation) {
|
|
el.pop('rotate' as 'xywh');
|
|
}
|
|
|
|
if (el instanceof TextElementModel && !rotation) {
|
|
el.pop('fontSize');
|
|
el.pop('hasMaxWidth');
|
|
}
|
|
});
|
|
});
|
|
this._updateResizeManagerState(true);
|
|
};
|
|
|
|
private _propDisposables: Disposable[] = [];
|
|
|
|
private readonly _resizeManager: HandleResizeManager;
|
|
|
|
private readonly _updateCursor = (
|
|
dragging: boolean,
|
|
options?: {
|
|
type: 'resize' | 'rotate';
|
|
angle?: number;
|
|
target?: HTMLElement;
|
|
point?: IVec;
|
|
}
|
|
) => {
|
|
let cursor: CursorType = 'default';
|
|
|
|
if (dragging && options) {
|
|
const { type, target, point } = options;
|
|
let { angle } = options;
|
|
if (type === 'rotate') {
|
|
if (target && point) {
|
|
angle = calcAngle(target, point, 45);
|
|
}
|
|
this._cursorRotate += angle || 0;
|
|
cursor = generateCursorUrl(this._cursorRotate);
|
|
} else {
|
|
if (this.resizeMode === 'edge') {
|
|
cursor = 'ew-resize';
|
|
} else if (target && point) {
|
|
const label = getResizeLabel(target);
|
|
const { width, height, left, top } = this._selectedRect;
|
|
if (
|
|
label === 'top' ||
|
|
label === 'bottom' ||
|
|
label === 'left' ||
|
|
label === 'right'
|
|
) {
|
|
angle = calcAngleEdgeWithRotation(
|
|
target,
|
|
this._selectedRect.rotate
|
|
);
|
|
} else {
|
|
angle = calcAngleWithRotation(
|
|
target,
|
|
point,
|
|
new DOMRect(
|
|
left + this.gfx.viewport.left,
|
|
top + this.gfx.viewport.top,
|
|
width,
|
|
height
|
|
),
|
|
this._selectedRect.rotate
|
|
);
|
|
}
|
|
cursor = rotateResizeCursor((angle * Math.PI) / 180);
|
|
}
|
|
}
|
|
} else {
|
|
this._cursorRotate = 0;
|
|
}
|
|
this.gfx.cursor$.value = cursor;
|
|
};
|
|
|
|
private readonly _updateMode = () => {
|
|
if (this._cursorRotate) {
|
|
this._mode = 'rotate';
|
|
return;
|
|
}
|
|
|
|
const { selection } = this;
|
|
const elements = selection.selectedElements;
|
|
|
|
if (elements.length !== 1) this._mode = 'scale';
|
|
|
|
const element = elements[0];
|
|
|
|
if (isNoteBlock(element) || isEmbedSyncedDocBlock(element)) {
|
|
this._mode = this._shiftKey ? 'scale' : 'resize';
|
|
} else if (this._isProportionalElement(element)) {
|
|
this._mode = 'scale';
|
|
} else {
|
|
this._mode = 'resize';
|
|
}
|
|
|
|
if (this._mode !== 'scale') {
|
|
this._scalePercent = undefined;
|
|
this._scaleDirection = undefined;
|
|
}
|
|
};
|
|
|
|
private readonly _updateOnElementChange = (
|
|
element: string | { id: string },
|
|
fromRemote: boolean = false
|
|
) => {
|
|
if ((fromRemote && this._resizeManager.dragging) || !this.isConnected) {
|
|
return;
|
|
}
|
|
|
|
const id = typeof element === 'string' ? element : element.id;
|
|
|
|
if (this._resizeManager.bounds.has(id) || this.selection.has(id)) {
|
|
this._updateSelectedRect();
|
|
this._updateMode();
|
|
}
|
|
};
|
|
|
|
private readonly _updateOnSelectionChange = () => {
|
|
this._initSelectedSlot();
|
|
this._updateSelectedRect();
|
|
this._updateResizeManagerState(true);
|
|
// Reset the cursor
|
|
this._updateCursor(false);
|
|
this._updateMode();
|
|
};
|
|
|
|
private readonly _updateOnViewportChange = () => {
|
|
if (this.selection.empty) {
|
|
return;
|
|
}
|
|
|
|
this._updateSelectedRect();
|
|
this._updateMode();
|
|
};
|
|
|
|
/**
|
|
* @param refresh indicate whether to completely refresh the state of resize manager, otherwise only update the position
|
|
*/
|
|
private readonly _updateResizeManagerState = (refresh: boolean) => {
|
|
const {
|
|
_resizeManager,
|
|
_selectedRect,
|
|
resizeMode,
|
|
zoom,
|
|
selection: { selectedElements },
|
|
} = this;
|
|
|
|
const rect = getSelectedRect(selectedElements);
|
|
const proportion = selectedElements.some(element =>
|
|
this._isProportionalElement(element)
|
|
);
|
|
// if there are more than one element, we need to refresh the state of resize manager
|
|
if (selectedElements.length > 1) refresh = true;
|
|
|
|
_resizeManager.updateState(
|
|
resizeMode,
|
|
_selectedRect.rotate,
|
|
zoom,
|
|
refresh ? undefined : rect,
|
|
refresh ? rect : undefined,
|
|
proportion
|
|
);
|
|
_resizeManager.updateBounds(getSelectableBounds(selectedElements));
|
|
};
|
|
|
|
@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);
|
|
|
|
readonly slots = {
|
|
dragStart: new Slot(),
|
|
dragMove: new Slot(),
|
|
dragRotate: new Slot(),
|
|
dragEnd: new Slot(),
|
|
};
|
|
|
|
get dragDirection() {
|
|
return this._resizeManager.dragDirection;
|
|
}
|
|
|
|
get edgelessSlots() {
|
|
return this.block.slots;
|
|
}
|
|
|
|
get frameOverlay() {
|
|
return this.std.get(OverlayIdentifier('frame')) as FrameOverlay;
|
|
}
|
|
|
|
get gfx() {
|
|
return this.std.get(GfxControllerIdentifier);
|
|
}
|
|
|
|
get resizeMode(): ResizeMode {
|
|
const elements = this.selection.selectedElements;
|
|
|
|
let areAllConnectors = true;
|
|
let areAllIndependentConnectors = elements.length > 1;
|
|
let areAllShapes = true;
|
|
let areAllTexts = true;
|
|
let hasMindmapNode = false;
|
|
|
|
for (const element of elements) {
|
|
if (isNoteBlock(element) || isEmbedSyncedDocBlock(element)) {
|
|
areAllConnectors = false;
|
|
if (this._shiftKey) {
|
|
areAllShapes = false;
|
|
areAllTexts = false;
|
|
}
|
|
} else if (isEmbedHtmlBlock(element)) {
|
|
areAllConnectors = false;
|
|
} else if (isFrameBlock(element)) {
|
|
areAllConnectors = false;
|
|
} else if (this._isProportionalElement(element)) {
|
|
areAllConnectors = false;
|
|
areAllShapes = false;
|
|
areAllTexts = false;
|
|
} else if (isEdgelessTextBlock(element)) {
|
|
areAllConnectors = false;
|
|
areAllShapes = false;
|
|
} else {
|
|
assertType<BlockSuite.SurfaceElementModel>(element);
|
|
if (element.type === CanvasElementType.CONNECTOR) {
|
|
const connector = element as ConnectorElementModel;
|
|
areAllIndependentConnectors &&= !(
|
|
connector.source.id || connector.target.id
|
|
);
|
|
} else {
|
|
areAllConnectors = false;
|
|
}
|
|
if (
|
|
element.type !== CanvasElementType.SHAPE &&
|
|
element.type !== CanvasElementType.GROUP
|
|
)
|
|
areAllShapes = false;
|
|
if (element.type !== CanvasElementType.TEXT) areAllTexts = false;
|
|
|
|
if (isMindmapNode(element)) {
|
|
hasMindmapNode = true;
|
|
}
|
|
}
|
|
}
|
|
|
|
if (areAllConnectors) {
|
|
if (areAllIndependentConnectors) {
|
|
return 'all';
|
|
} else {
|
|
return 'none';
|
|
}
|
|
}
|
|
|
|
if (hasMindmapNode) return 'none';
|
|
if (areAllShapes) return 'all';
|
|
if (areAllTexts) return 'edgeAndCorner';
|
|
|
|
return 'corner';
|
|
}
|
|
|
|
get selection() {
|
|
return this.gfx.selection;
|
|
}
|
|
|
|
get surface() {
|
|
return this.gfx.surface;
|
|
}
|
|
|
|
get zoom() {
|
|
return this.gfx.viewport.zoom;
|
|
}
|
|
|
|
constructor() {
|
|
super();
|
|
this._resizeManager = new HandleResizeManager(
|
|
this._onDragStart,
|
|
this._onDragMove,
|
|
this._onDragRotate,
|
|
this._onDragEnd
|
|
);
|
|
this.addEventListener('pointerdown', stopPropagation);
|
|
}
|
|
|
|
/**
|
|
* TODO: Remove this function after the edgeless refactor completed
|
|
* This function is used to adjust the element bound and scale
|
|
* Should not be used in the future
|
|
* Related issue: https://linear.app/affine-design/issue/BS-1009/
|
|
* @deprecated
|
|
*/
|
|
#adjustAIChat(
|
|
element: BlockSuite.EdgelessModel,
|
|
bound: Bound,
|
|
direction: HandleDirection
|
|
) {
|
|
const curBound = Bound.deserialize(element.xywh);
|
|
|
|
let scale = 1;
|
|
if ('scale' in element) {
|
|
scale = element.scale as number;
|
|
}
|
|
let width = curBound.w / scale;
|
|
let height = curBound.h / scale;
|
|
if (this._shiftKey) {
|
|
scale = bound.w / width;
|
|
this._scalePercent = `${Math.round(scale * 100)}%`;
|
|
this._scaleDirection = direction;
|
|
}
|
|
|
|
width = bound.w / scale;
|
|
width = clamp(width, AI_CHAT_BLOCK_MIN_WIDTH, AI_CHAT_BLOCK_MAX_WIDTH);
|
|
bound.w = width * scale;
|
|
|
|
height = bound.h / scale;
|
|
height = clamp(height, AI_CHAT_BLOCK_MIN_HEIGHT, AI_CHAT_BLOCK_MAX_HEIGHT);
|
|
bound.h = height * scale;
|
|
|
|
this._isWidthLimit =
|
|
width === AI_CHAT_BLOCK_MIN_WIDTH || width === AI_CHAT_BLOCK_MAX_WIDTH;
|
|
this._isHeightLimit =
|
|
height === AI_CHAT_BLOCK_MIN_HEIGHT ||
|
|
height === AI_CHAT_BLOCK_MAX_HEIGHT;
|
|
|
|
this.gfx.updateElement(element.id, {
|
|
scale,
|
|
xywh: bound.serialize(),
|
|
});
|
|
}
|
|
|
|
#adjustConnector(
|
|
element: ConnectorElementModel,
|
|
bounds: Bound,
|
|
matrix: DOMMatrix,
|
|
originalPath: PointLocation[]
|
|
) {
|
|
const props = element.resize(bounds, originalPath, matrix);
|
|
this.gfx.updateElement(element.id, props);
|
|
}
|
|
|
|
#adjustEdgelessText(
|
|
element: EdgelessTextBlockModel,
|
|
bound: Bound,
|
|
direction: HandleDirection
|
|
) {
|
|
const oldXYWH = Bound.deserialize(element.xywh);
|
|
if (
|
|
direction === HandleDirection.TopLeft ||
|
|
direction === HandleDirection.TopRight ||
|
|
direction === HandleDirection.BottomRight ||
|
|
direction === HandleDirection.BottomLeft
|
|
) {
|
|
const newScale = element.scale * (bound.w / oldXYWH.w);
|
|
this._scalePercent = `${Math.round(newScale * 100)}%`;
|
|
this._scaleDirection = direction;
|
|
|
|
bound.h = bound.w * (oldXYWH.h / oldXYWH.w);
|
|
this.gfx.updateElement(element.id, {
|
|
scale: newScale,
|
|
xywh: bound.serialize(),
|
|
});
|
|
} else if (
|
|
direction === HandleDirection.Left ||
|
|
direction === HandleDirection.Right
|
|
) {
|
|
const textPortal = this.host.view.getBlock(
|
|
element.id
|
|
) as EdgelessTextBlockComponent | null;
|
|
if (!textPortal) return;
|
|
|
|
if (!textPortal.checkWidthOverflow(bound.w)) return;
|
|
|
|
const newRealWidth = clamp(
|
|
bound.w / element.scale,
|
|
EDGELESS_TEXT_BLOCK_MIN_WIDTH,
|
|
Infinity
|
|
);
|
|
bound.w = newRealWidth * element.scale;
|
|
this.gfx.updateElement(element.id, {
|
|
xywh: Bound.serialize({
|
|
...bound,
|
|
h: oldXYWH.h,
|
|
}),
|
|
hasMaxWidth: true,
|
|
});
|
|
}
|
|
}
|
|
|
|
#adjustEmbedHtml(
|
|
element: EmbedHtmlModel,
|
|
bound: Bound,
|
|
_direction: HandleDirection
|
|
) {
|
|
bound.w = clamp(bound.w, EMBED_HTML_MIN_WIDTH, Infinity);
|
|
bound.h = clamp(bound.h, EMBED_HTML_MIN_HEIGHT, Infinity);
|
|
|
|
this._isWidthLimit = bound.w === EMBED_HTML_MIN_WIDTH;
|
|
this._isHeightLimit = bound.h === EMBED_HTML_MIN_HEIGHT;
|
|
|
|
this.gfx.updateElement(element.id, {
|
|
xywh: bound.serialize(),
|
|
});
|
|
}
|
|
|
|
#adjustEmbedSyncedDoc(
|
|
element: EmbedSyncedDocModel,
|
|
bound: Bound,
|
|
direction: HandleDirection
|
|
) {
|
|
const curBound = Bound.deserialize(element.xywh);
|
|
|
|
let scale = element.scale ?? 1;
|
|
let width = curBound.w / scale;
|
|
let height = curBound.h / scale;
|
|
if (this._shiftKey) {
|
|
scale = bound.w / width;
|
|
this._scalePercent = `${Math.round(scale * 100)}%`;
|
|
this._scaleDirection = direction;
|
|
}
|
|
|
|
width = bound.w / scale;
|
|
width = clamp(width, SYNCED_MIN_WIDTH, Infinity);
|
|
bound.w = width * scale;
|
|
|
|
height = bound.h / scale;
|
|
height = clamp(height, SYNCED_MIN_HEIGHT, Infinity);
|
|
bound.h = height * scale;
|
|
|
|
this._isWidthLimit = width === SYNCED_MIN_WIDTH;
|
|
this._isHeightLimit = height === SYNCED_MIN_HEIGHT;
|
|
|
|
this.gfx.updateElement(element.id, {
|
|
scale,
|
|
xywh: bound.serialize(),
|
|
});
|
|
}
|
|
|
|
#adjustFrame(frame: FrameBlockModel, bound: Bound) {
|
|
const frameManager = this.std.get(
|
|
GfxExtensionIdentifier('frame-manager')
|
|
) as EdgelessFrameManager;
|
|
|
|
const oldChildren = frameManager.getChildElementsInFrame(frame);
|
|
|
|
this.gfx.updateElement(frame.id, {
|
|
xywh: bound.serialize(),
|
|
});
|
|
|
|
const newChildren = getTopElements(
|
|
frameManager.getElementsInFrameBound(frame)
|
|
).concat(
|
|
oldChildren.filter(oldChild => {
|
|
return frame.intersectsBound(oldChild.elementBound);
|
|
})
|
|
);
|
|
|
|
frameManager.removeAllChildrenFromFrame(frame);
|
|
frameManager.addElementsToFrame(frame, newChildren);
|
|
this.frameOverlay.highlight(frame, true, false);
|
|
}
|
|
|
|
#adjustNote(
|
|
element: NoteBlockModel,
|
|
bound: Bound,
|
|
direction: HandleDirection
|
|
) {
|
|
const curBound = Bound.deserialize(element.xywh);
|
|
|
|
let scale = element.edgeless.scale ?? 1;
|
|
if (this._shiftKey) {
|
|
scale = (bound.w / curBound.w) * scale;
|
|
this._scalePercent = `${Math.round(scale * 100)}%`;
|
|
this._scaleDirection = direction;
|
|
}
|
|
|
|
bound.w = clamp(bound.w, NOTE_MIN_WIDTH * scale, Infinity);
|
|
bound.h = clamp(bound.h, NOTE_MIN_HEIGHT * scale, Infinity);
|
|
|
|
this._isWidthLimit = bound.w === NOTE_MIN_WIDTH * scale;
|
|
this._isHeightLimit = bound.h === NOTE_MIN_HEIGHT * scale;
|
|
|
|
if (bound.h >= NOTE_MIN_HEIGHT * scale) {
|
|
this.doc.updateBlock(element, () => {
|
|
element.edgeless.collapse = true;
|
|
element.edgeless.collapsedHeight = bound.h / scale;
|
|
});
|
|
}
|
|
|
|
this.gfx.updateElement(element.id, {
|
|
edgeless: {
|
|
...element.edgeless,
|
|
scale,
|
|
},
|
|
xywh: bound.serialize(),
|
|
});
|
|
}
|
|
|
|
#adjustProportional(
|
|
element: BlockSuite.EdgelessModel,
|
|
bound: Bound,
|
|
direction: HandleDirection
|
|
) {
|
|
const curBound = Bound.deserialize(element.xywh);
|
|
|
|
if (isImageBlock(element)) {
|
|
const { height } = element;
|
|
if (height) {
|
|
this._scalePercent = `${Math.round((bound.h / height) * 100)}%`;
|
|
this._scaleDirection = direction;
|
|
}
|
|
} else {
|
|
const cardStyle = (element as BookmarkBlockModel).style;
|
|
const height = EMBED_CARD_HEIGHT[cardStyle];
|
|
this._scalePercent = `${Math.round((bound.h / height) * 100)}%`;
|
|
this._scaleDirection = direction;
|
|
}
|
|
if (
|
|
direction === HandleDirection.Left ||
|
|
direction === HandleDirection.Right
|
|
) {
|
|
bound.h = (curBound.h / curBound.w) * bound.w;
|
|
} else if (
|
|
direction === HandleDirection.Top ||
|
|
direction === HandleDirection.Bottom
|
|
) {
|
|
bound.w = (curBound.w / curBound.h) * bound.h;
|
|
}
|
|
|
|
this.gfx.updateElement(element.id, {
|
|
xywh: bound.serialize(),
|
|
});
|
|
}
|
|
|
|
#adjustShape(
|
|
element: ShapeElementModel,
|
|
bound: Bound,
|
|
_direction: HandleDirection
|
|
) {
|
|
bound = normalizeShapeBound(element, bound);
|
|
this.gfx.updateElement(element.id, {
|
|
xywh: bound.serialize(),
|
|
});
|
|
}
|
|
|
|
#adjustText(
|
|
element: TextElementModel,
|
|
bound: Bound,
|
|
direction: HandleDirection
|
|
) {
|
|
let p = 1;
|
|
if (
|
|
direction === HandleDirection.Left ||
|
|
direction === HandleDirection.Right
|
|
) {
|
|
const {
|
|
text: yText,
|
|
fontFamily,
|
|
fontSize,
|
|
fontStyle,
|
|
fontWeight,
|
|
hasMaxWidth,
|
|
} = element;
|
|
// If the width of the text element has been changed by dragging,
|
|
// We need to set hasMaxWidth to true for wrapping the text
|
|
bound = TextUtils.normalizeTextBound(
|
|
{
|
|
yText,
|
|
fontFamily,
|
|
fontSize,
|
|
fontStyle,
|
|
fontWeight,
|
|
hasMaxWidth,
|
|
},
|
|
bound,
|
|
true
|
|
);
|
|
// If the width of the text element has been changed by dragging,
|
|
// We need to set hasMaxWidth to true for wrapping the text
|
|
this.gfx.updateElement(element.id, {
|
|
xywh: bound.serialize(),
|
|
fontSize: element.fontSize * p,
|
|
hasMaxWidth: true,
|
|
});
|
|
} else {
|
|
p = bound.h / element.h;
|
|
// const newFontsize = element.fontSize * p;
|
|
// bound = normalizeTextBound(element, bound, false, newFontsize);
|
|
|
|
this.gfx.updateElement(element.id, {
|
|
xywh: bound.serialize(),
|
|
fontSize: element.fontSize * p,
|
|
});
|
|
}
|
|
}
|
|
|
|
#adjustUseFallback(
|
|
element: BlockSuite.EdgelessModel,
|
|
bound: Bound,
|
|
_direction: HandleDirection
|
|
) {
|
|
this.gfx.updateElement(element.id, {
|
|
xywh: bound.serialize(),
|
|
});
|
|
}
|
|
|
|
private _canAutoComplete() {
|
|
return (
|
|
!this.autoCompleteOff &&
|
|
!this._isResizing &&
|
|
this.selection.selectedElements.length === 1 &&
|
|
(this.selection.selectedElements[0] instanceof ShapeElementModel ||
|
|
isNoteBlock(this.selection.selectedElements[0]))
|
|
);
|
|
}
|
|
|
|
private _canRotate() {
|
|
return !this.selection.selectedElements.every(
|
|
ele =>
|
|
isNoteBlock(ele) ||
|
|
isFrameBlock(ele) ||
|
|
isBookmarkBlock(ele) ||
|
|
isAttachmentBlock(ele) ||
|
|
isEmbeddedBlock(ele)
|
|
);
|
|
}
|
|
|
|
private _isProportionalElement(element: BlockSuite.EdgelessModel) {
|
|
return (
|
|
isAttachmentBlock(element) ||
|
|
isImageBlock(element) ||
|
|
isBookmarkBlock(element) ||
|
|
isEmbedFigmaBlock(element) ||
|
|
isEmbedGithubBlock(element) ||
|
|
isEmbedYoutubeBlock(element) ||
|
|
isEmbedLoomBlock(element) ||
|
|
isEmbedLinkedDocBlock(element)
|
|
);
|
|
}
|
|
|
|
private _shouldRenderSelection(elements?: BlockSuite.EdgelessModel[]) {
|
|
elements = elements ?? this.selection.selectedElements;
|
|
return elements.length > 0 && !this.selection.editing;
|
|
}
|
|
|
|
override firstUpdated() {
|
|
const { _disposables, block, selection, gfx } = this;
|
|
|
|
_disposables.add(
|
|
// viewport zooming / scrolling
|
|
gfx.viewport.viewportUpdated.on(this._updateOnViewportChange)
|
|
);
|
|
|
|
pickValues(gfx.surface!, [
|
|
'elementAdded',
|
|
'elementRemoved',
|
|
'elementUpdated',
|
|
]).forEach(slot => {
|
|
_disposables.add(slot.on(this._updateOnElementChange));
|
|
});
|
|
|
|
_disposables.add(
|
|
this.doc.slots.blockUpdated.on(this._updateOnElementChange)
|
|
);
|
|
|
|
_disposables.add(selection.slots.updated.on(this._updateOnSelectionChange));
|
|
|
|
_disposables.add(
|
|
block.slots.readonlyUpdated.on(() => this.requestUpdate())
|
|
);
|
|
|
|
_disposables.add(
|
|
block.slots.elementResizeStart.on(() => (this._isResizing = true))
|
|
);
|
|
_disposables.add(
|
|
block.slots.elementResizeEnd.on(() => (this._isResizing = false))
|
|
);
|
|
_disposables.add(() => {
|
|
this._propDisposables.forEach(disposable => disposable.dispose());
|
|
});
|
|
|
|
this.block.handleEvent(
|
|
'keyDown',
|
|
ctx => {
|
|
const event = ctx.get('defaultState').event;
|
|
if (event instanceof KeyboardEvent) {
|
|
this._shift(event);
|
|
}
|
|
},
|
|
{ global: true }
|
|
);
|
|
|
|
this.block.handleEvent(
|
|
'keyUp',
|
|
ctx => {
|
|
const event = ctx.get('defaultState').event;
|
|
if (event instanceof KeyboardEvent) {
|
|
this._shift(event);
|
|
}
|
|
},
|
|
{ global: true }
|
|
);
|
|
}
|
|
|
|
private _shift(event: KeyboardEvent) {
|
|
if (event.repeat) return;
|
|
|
|
const pressed = event.key.toLowerCase() === 'shift' && event.shiftKey;
|
|
|
|
this._shiftKey = pressed;
|
|
this._resizeManager.onPressShiftKey(pressed);
|
|
this._updateSelectedRect();
|
|
this._updateMode();
|
|
}
|
|
|
|
override render() {
|
|
if (!this.isConnected) return nothing;
|
|
|
|
const { selection } = this;
|
|
const elements = selection.selectedElements;
|
|
|
|
if (!this._shouldRenderSelection(elements)) return nothing;
|
|
|
|
const {
|
|
block,
|
|
gfx,
|
|
doc,
|
|
resizeMode,
|
|
_resizeManager,
|
|
_selectedRect,
|
|
_updateCursor,
|
|
} = this;
|
|
|
|
const hasResizeHandles = !selection.editing && !doc.readonly;
|
|
const inoperable = selection.inoperable;
|
|
const hasElementLocked = elements.some(element => element.isLocked());
|
|
const handlers = [];
|
|
|
|
if (!inoperable) {
|
|
const resizeHandles =
|
|
hasResizeHandles && !hasElementLocked
|
|
? ResizeHandles(
|
|
resizeMode,
|
|
(e: PointerEvent, direction: HandleDirection) => {
|
|
const target = e.target as HTMLElement;
|
|
if (target.classList.contains('rotate') && !this._canRotate()) {
|
|
return;
|
|
}
|
|
const proportional = elements.some(
|
|
el => el instanceof TextElementModel
|
|
);
|
|
_resizeManager.onPointerDown(e, direction, proportional);
|
|
},
|
|
(
|
|
dragging: boolean,
|
|
options?: {
|
|
type: 'resize' | 'rotate';
|
|
angle?: number;
|
|
target?: HTMLElement;
|
|
point?: IVec;
|
|
}
|
|
) => {
|
|
if (!this._canRotate() && options?.type === 'rotate') return;
|
|
_updateCursor(dragging, options);
|
|
}
|
|
)
|
|
: nothing;
|
|
|
|
const connectorHandle =
|
|
elements.length === 1 &&
|
|
elements[0] instanceof ConnectorElementModel &&
|
|
!elements[0].isLocked()
|
|
? html`
|
|
<edgeless-connector-handle
|
|
.connector=${elements[0]}
|
|
.edgeless=${block}
|
|
></edgeless-connector-handle>
|
|
`
|
|
: nothing;
|
|
|
|
const elementHandle =
|
|
elements.length > 1 &&
|
|
!elements.reduce(
|
|
(p, e) => p && e instanceof ConnectorElementModel,
|
|
true
|
|
)
|
|
? elements.map(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>`;
|
|
})
|
|
: nothing;
|
|
|
|
handlers.push(resizeHandles, connectorHandle, elementHandle);
|
|
}
|
|
|
|
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>
|
|
|
|
${!doc.readonly && !inoperable && this._canAutoComplete()
|
|
? html`<edgeless-auto-complete
|
|
.current=${this.selection.selectedElements[0]}
|
|
.edgeless=${block}
|
|
.selectedRect=${_selectedRect}
|
|
>
|
|
</edgeless-auto-complete>`
|
|
: nothing}
|
|
|
|
<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: HandleDirection | undefined = undefined;
|
|
|
|
@state()
|
|
private accessor _scalePercent: string | undefined = undefined;
|
|
|
|
@state()
|
|
private accessor _shiftKey = false;
|
|
|
|
@state()
|
|
accessor autoCompleteOff = false;
|
|
}
|
|
|
|
declare global {
|
|
interface HTMLElementTagNameMap {
|
|
'edgeless-selected-rect': EdgelessSelectedRectWidget;
|
|
}
|
|
}
|