feat: optimize edgeless alignment (#10435)

### Changed
- alignment line style
- threshold of snapping
- optimize alignment checking performance
This commit is contained in:
doouding
2025-03-04 03:03:30 +00:00
parent 321247a2fa
commit 1c29d0e269
6 changed files with 428 additions and 172 deletions

View File

@@ -21,7 +21,7 @@ import { ShapeTool } from './gfx-tool/shape-tool.js';
import { TemplateTool } from './gfx-tool/template-tool.js'; import { TemplateTool } from './gfx-tool/template-tool.js';
import { TextTool } from './gfx-tool/text-tool.js'; import { TextTool } from './gfx-tool/text-tool.js';
import { EditPropsMiddlewareBuilder } from './middlewares/base.js'; import { EditPropsMiddlewareBuilder } from './middlewares/base.js';
import { EdgelessSnapManager } from './utils/snap-manager.js'; import { SnapManager } from './utils/snap-manager.js';
export const EdgelessToolExtension: ExtensionType[] = [ export const EdgelessToolExtension: ExtensionType[] = [
DefaultTool, DefaultTool,
@@ -43,7 +43,7 @@ export const EdgelessBuiltInManager: ExtensionType[] = [
ConnectionOverlay, ConnectionOverlay,
FrameOverlay, FrameOverlay,
MindMapIndicatorOverlay, MindMapIndicatorOverlay,
EdgelessSnapManager, SnapManager,
EdgelessFrameManager, EdgelessFrameManager,
EditPropsMiddlewareBuilder, EditPropsMiddlewareBuilder,
]; ];

View File

@@ -58,7 +58,7 @@ import { prepareCloneData } from '../utils/clone-utils.js';
import { isSingleMindMapNode } from '../utils/mindmap.js'; import { isSingleMindMapNode } from '../utils/mindmap.js';
import { calPanDelta } from '../utils/panning-utils.js'; import { calPanDelta } from '../utils/panning-utils.js';
import { isCanvasElement, isEdgelessTextBlock } from '../utils/query.js'; import { isCanvasElement, isEdgelessTextBlock } from '../utils/query.js';
import type { EdgelessSnapManager } from '../utils/snap-manager.js'; import type { SnapManager } from '../utils/snap-manager.js';
import { import {
addText, addText,
mountConnectorLabelEditor, mountConnectorLabelEditor,
@@ -277,9 +277,7 @@ export class DefaultTool extends BaseTool {
} }
get snapOverlay() { get snapOverlay() {
return this.std.get( return this.std.get(OverlayIdentifier('snap-manager')) as SnapManager;
OverlayIdentifier('snap-manager')
) as EdgelessSnapManager;
} }
private _addEmptyParagraphBlock( private _addEmptyParagraphBlock(
@@ -580,7 +578,7 @@ export class DefaultTool extends BaseTool {
) { ) {
const mindmap = this._toBeMoved[0].group as MindmapElementModel; const mindmap = this._toBeMoved[0].group as MindmapElementModel;
this._alignBound = this.snapOverlay.setupAlignables(this._toBeMoved, [ this._alignBound = this.snapOverlay.setMovingElements(this._toBeMoved, [
mindmap, mindmap,
...(mindmap?.childElements || []), ...(mindmap?.childElements || []),
]); ]);
@@ -640,7 +638,7 @@ export class DefaultTool extends BaseTool {
); );
} }
this._alignBound = this.snapOverlay.setupAlignables( this._alignBound = this.snapOverlay.setMovingElements(
this._toBeMoved this._toBeMoved
); );
@@ -882,7 +880,7 @@ export class DefaultTool extends BaseTool {
if (this.edgelessSelectionManager.editing) return; if (this.edgelessSelectionManager.editing) return;
this._selectedBounds = []; this._selectedBounds = [];
this.snapOverlay.cleanupAlignables(); this.snapOverlay.clear();
this.frameOverlay.clear(); this.frameOverlay.clear();
this._toBeMoved = []; this._toBeMoved = [];
this._selectedConnector = null; this._selectedConnector = null;

View File

@@ -1,30 +1,49 @@
import type { import { Overlay } from '@blocksuite/affine-block-surface';
SurfaceBlockComponent, import { ConnectorElementModel } from '@blocksuite/affine-model';
SurfaceBlockModel, import type { GfxModel } from '@blocksuite/block-std/gfx';
} from '@blocksuite/affine-block-surface'; import { almostEqual, Bound, Point } from '@blocksuite/global/utils';
import { getSurfaceBlock, Overlay } from '@blocksuite/affine-block-surface';
import type { ConnectorElementModel } from '@blocksuite/affine-model';
import type { GfxController, GfxModel } from '@blocksuite/block-std/gfx';
import { BlockSuiteError, ErrorCode } from '@blocksuite/global/exceptions';
import { Bound, Point } from '@blocksuite/global/utils';
import { isConnectable } from '../utils/query.js';
interface Distance { interface Distance {
absXDistance: number; horiz?: {
absYDistance: number; /**
xDistance: number; * the minimum x moving distance to align with other bound
yDistance: number; */
indexX: number; distance: number;
indexY: number;
/**
* the indices of the align position
*/
alignPositionIndices: number[];
};
vert?: {
/**
* the minimum y moving distance to align with other bound
*/
distance: number;
/**
* the indices of the align position
*/
alignPositionIndices: number[];
};
} }
const ALIGN_THRESHOLD = 5; const ALIGN_THRESHOLD = 8;
const DISTRIBUTION_LINE_OFFSET = 1;
export class EdgelessSnapManager extends Overlay { export class SnapManager extends Overlay {
static override overlayName: string = 'snap-manager'; static override overlayName: string = 'snap-manager';
private _alignableBounds: Bound[] = []; private _referenceBounds: {
vertical: Bound[];
horizontal: Bound[];
all: Bound[];
} = {
vertical: [],
horizontal: [],
all: [],
};
/** /**
* This variable contains reference lines that are * This variable contains reference lines that are
@@ -44,28 +63,16 @@ export class EdgelessSnapManager extends Overlay {
*/ */
private _intraGraphicAlignLines: [Point, Point][] = []; private _intraGraphicAlignLines: [Point, Point][] = [];
cleanupAlignables = () => { override clear() {
this._alignableBounds = []; super.clear();
this._referenceBounds = {
vertical: [],
horizontal: [],
all: [],
};
this._intraGraphicAlignLines = []; this._intraGraphicAlignLines = [];
this._distributedAlignLines = []; this._distributedAlignLines = [];
// FIXME: not sure why renderer can be undefined sometimes
this._surface.renderer?.removeOverlay(this);
};
private get _surface() {
const surfaceModel = getSurfaceBlock(this.gfx.doc);
if (!surfaceModel) {
throw new BlockSuiteError(
ErrorCode.ValueNotExists,
'Surface block not found in doc when creating snap manager'
);
}
return this.gfx.std.view.getBlock(surfaceModel.id) as SurfaceBlockComponent;
}
constructor(gfx: GfxController) {
super(gfx);
} }
private _alignDistributeHorizontally( private _alignDistributeHorizontally(
@@ -75,31 +82,52 @@ export class EdgelessSnapManager extends Overlay {
viewport: { zoom: number } viewport: { zoom: number }
) { ) {
const wBoxes: Bound[] = []; const wBoxes: Bound[] = [];
this._alignableBounds.forEach(box => { this._referenceBounds.horizontal.forEach(box => {
if (box.isHorizontalCross(bound)) { if (box.isHorizontalCross(bound)) {
wBoxes.push(box); wBoxes.push(box);
} }
}); });
wBoxes.sort((a, b) => a.center[0] - b.center[0]);
let dif = Infinity; let dif = Infinity;
let min = Infinity; let min = Infinity;
let aveDis = Number.MAX_SAFE_INTEGER;
let curBound!: {
leftIdx: number;
rightIdx: number;
spacing: number;
points: [Point, Point][];
};
for (let i = 0; i < wBoxes.length; i++) { for (let i = 0; i < wBoxes.length; i++) {
for (let j = i + 1; j < wBoxes.length; j++) { for (let j = i + 1; j < wBoxes.length; j++) {
let lb = wBoxes[i], let lb = wBoxes[i],
rb = wBoxes[j]; rb = wBoxes[j];
// it means these bound need to be horizontally across // it means these bound need to be horizontally across
if (!lb.isHorizontalCross(rb)) continue; if (!lb.isHorizontalCross(rb) || lb.isIntersectWithBound(rb)) continue;
if (lb.isIntersectWithBound(rb)) continue;
let switchFlag = false;
// exchange lb and rb to make sure lb is on the left of rb
if (rb.maxX < lb.minX) { if (rb.maxX < lb.minX) {
const temp = rb; const temp = rb;
rb = lb; rb = lb;
lb = temp; lb = temp;
switchFlag = true;
} }
/** align middle */
let _centerX = 0; let _centerX = 0;
const updateDif = () => { const updateDif = () => {
dif = Math.abs(bound.center[0] - _centerX); dif = Math.abs(bound.center[0] - _centerX);
if (dif <= threshold && dif < min) { const curAveDis =
(Math.abs(lb.center[0] - bound.center[0]) +
Math.abs(rb.center[0] - bound.center[0])) /
2;
if (
dif <= threshold &&
(dif < min || (almostEqual(dif, min) && curAveDis < aveDis))
) {
min = dif; min = dif;
aveDis = curAveDis;
rst.dx = _centerX - bound.center[0]; rst.dx = _centerX - bound.center[0];
/** /**
* calculate points to draw * calculate points to draw
@@ -108,37 +136,102 @@ export class EdgelessSnapManager extends Overlay {
(a, b) => a - b (a, b) => a - b
); );
const y = (ys[1] + ys[2]) / 2; const y = (ys[1] + ys[2]) / 2;
const offset = 2 / viewport.zoom; const offset = DISTRIBUTION_LINE_OFFSET / viewport.zoom;
const xs = [ const xs = [
_centerX - bound.w / 2 - offset, _centerX - bound.w / 2,
_centerX + bound.w / 2 + offset, _centerX + bound.w / 2,
rb.minX, rb.minX,
rb.maxX, rb.maxX,
lb.minX, lb.minX,
lb.maxX, lb.maxX,
].sort((a, b) => a - b); ].sort((a, b) => a - b);
this._distributedAlignLines[0] = [
new Point(xs[1], y), curBound = {
new Point(xs[2], y), leftIdx: switchFlag ? j : i,
]; rightIdx: switchFlag ? i : j,
this._distributedAlignLines[1] = [ spacing: xs[2] - xs[1],
new Point(xs[3], y), points: [
new Point(xs[4], y), [new Point(xs[1] + offset, y), new Point(xs[2] - offset, y)],
]; [new Point(xs[3] + offset, y), new Point(xs[4] - offset, y)],
],
};
} }
}; };
/**
* align between left and right bound
*/
if (lb.horizontalDistance(rb) > bound.w) { if (lb.horizontalDistance(rb) > bound.w) {
_centerX = (lb.maxX + rb.minX) / 2; _centerX = (lb.maxX + rb.minX) / 2;
updateDif(); updateDif();
} }
/** align left */
/**
* align to the left bounds
*/
_centerX = lb.minX - (rb.minX - lb.maxX) - bound.w / 2; _centerX = lb.minX - (rb.minX - lb.maxX) - bound.w / 2;
updateDif(); updateDif();
/** align right */ /** align right */
_centerX = rb.minX - lb.maxX + rb.maxX + bound.w / 2; _centerX = rb.minX - lb.maxX + rb.maxX + bound.w / 2;
updateDif(); updateDif();
} }
} }
// find the boxes that has same spacing
if (curBound) {
const { leftIdx, rightIdx, spacing, points } = curBound;
this._distributedAlignLines.push(...points);
{
let curLeftBound = wBoxes[leftIdx];
for (let i = leftIdx - 1; i >= 0; i--) {
if (almostEqual(wBoxes[i].maxX, curLeftBound.minX - spacing)) {
const targetBound = wBoxes[i];
const ys = [
targetBound.minY,
targetBound.maxY,
curLeftBound.minY,
curLeftBound.maxY,
].sort((a, b) => a - b);
const y = (ys[1] + ys[2]) / 2;
this._distributedAlignLines.push([
new Point(wBoxes[i].maxX, y),
new Point(curLeftBound.minX, y),
]);
curLeftBound = wBoxes[i];
}
}
}
{
let curRightBound = wBoxes[rightIdx];
for (let i = rightIdx + 1; i < wBoxes.length; i++) {
if (almostEqual(wBoxes[i].minX, curRightBound.maxX + spacing)) {
const targetBound = wBoxes[i];
const ys = [
targetBound.minY,
targetBound.maxY,
curRightBound.minY,
curRightBound.maxY,
].sort((a, b) => a - b);
const y = (ys[1] + ys[2]) / 2;
this._distributedAlignLines.push([
new Point(curRightBound.maxX, y),
new Point(wBoxes[i].minX, y),
]);
curRightBound = wBoxes[i];
}
}
}
}
} }
private _alignDistributeVertically( private _alignDistributeVertically(
@@ -148,29 +241,50 @@ export class EdgelessSnapManager extends Overlay {
viewport: { zoom: number } viewport: { zoom: number }
) { ) {
const hBoxes: Bound[] = []; const hBoxes: Bound[] = [];
this._alignableBounds.forEach(box => { this._referenceBounds.vertical.forEach(box => {
if (box.isVerticalCross(bound)) { if (box.isVerticalCross(bound)) {
hBoxes.push(box); hBoxes.push(box);
} }
}); });
hBoxes.sort((a, b) => a.center[0] - b.center[0]);
let dif = Infinity; let dif = Infinity;
let min = Infinity; let min = Infinity;
let aveDis = Number.MAX_SAFE_INTEGER;
let curBound!: {
upperIdx: number;
lowerIdx: number;
spacing: number;
points: [Point, Point][];
};
for (let i = 0; i < hBoxes.length; i++) { for (let i = 0; i < hBoxes.length; i++) {
for (let j = i + 1; j < hBoxes.length; j++) { for (let j = i + 1; j < hBoxes.length; j++) {
let ub = hBoxes[i], let ub = hBoxes[i],
db = hBoxes[j]; db = hBoxes[j];
if (!ub.isVerticalCross(db)) continue; if (!ub.isVerticalCross(db) || ub.isIntersectWithBound(db)) continue;
if (ub.isIntersectWithBound(db)) continue;
let switchFlag = false;
if (db.maxY < ub.minX) { if (db.maxY < ub.minX) {
const temp = ub; const temp = ub;
ub = db; ub = db;
db = temp; db = temp;
switchFlag = true;
} }
/** align middle */ /** align middle */
let _centerY = 0; let _centerY = 0;
const updateDiff = () => { const updateDiff = () => {
dif = Math.abs(bound.center[1] - _centerY); dif = Math.abs(bound.center[1] - _centerY);
if (dif <= threshold && dif < min) { const curAveDis =
(Math.abs(ub.center[1] - bound.center[1]) +
Math.abs(db.center[1] - bound.center[1])) /
2;
if (
dif <= threshold &&
(dif < min || (almostEqual(dif, min) && curAveDis < aveDis))
) {
min = dif; min = dif;
rst.dy = _centerY - bound.center[1]; rst.dy = _centerY - bound.center[1];
/** /**
@@ -180,29 +294,33 @@ export class EdgelessSnapManager extends Overlay {
(a, b) => a - b (a, b) => a - b
); );
const x = (xs[1] + xs[2]) / 2; const x = (xs[1] + xs[2]) / 2;
const offset = 2 / viewport.zoom; const offset = DISTRIBUTION_LINE_OFFSET / viewport.zoom;
const ys = [ const ys = [
_centerY - bound.h / 2 - offset, _centerY - bound.h / 2,
_centerY + bound.h / 2 + offset, _centerY + bound.h / 2,
db.minY, db.minY,
db.maxY, db.maxY,
ub.minY, ub.minY,
ub.maxY, ub.maxY,
].sort((a, b) => a - b); ].sort((a, b) => a - b);
this._distributedAlignLines[3] = [
new Point(x, ys[1]), curBound = {
new Point(x, ys[2]), upperIdx: switchFlag ? j : i,
]; lowerIdx: switchFlag ? i : j,
this._distributedAlignLines[4] = [ spacing: ys[2] - ys[1],
new Point(x, ys[3]), points: [
new Point(x, ys[4]), [new Point(x, ys[1] + offset), new Point(x, ys[2] - offset)],
]; [new Point(x, ys[3] + offset), new Point(x, ys[4] - offset)],
],
};
} }
}; };
if (ub.verticalDistance(db) > bound.h) { if (ub.verticalDistance(db) > bound.h) {
_centerY = (ub.maxY + db.minY) / 2; _centerY = (ub.maxY + db.minY) / 2;
updateDiff(); updateDiff();
} }
/** align upper */ /** align upper */
_centerY = ub.minY - (db.minY - ub.maxY) - bound.h / 2; _centerY = ub.minY - (db.minY - ub.maxY) - bound.h / 2;
updateDiff(); updateDiff();
@@ -211,6 +329,61 @@ export class EdgelessSnapManager extends Overlay {
updateDiff(); updateDiff();
} }
} }
// find the boxes that has same spacing
if (curBound) {
const { upperIdx, lowerIdx, spacing, points } = curBound;
this._distributedAlignLines.push(...points);
{
let curUpperBound = hBoxes[upperIdx];
for (let i = upperIdx - 1; i >= 0; i--) {
if (almostEqual(hBoxes[i].maxY, curUpperBound.minY - spacing)) {
const targetBound = hBoxes[i];
const xs = [
targetBound.minX,
targetBound.maxX,
curUpperBound.minX,
curUpperBound.maxX,
].sort((a, b) => a - b);
const x = (xs[1] + xs[2]) / 2;
this._distributedAlignLines.push([
new Point(x, hBoxes[i].maxY),
new Point(x, curUpperBound.minY),
]);
curUpperBound = hBoxes[i];
}
}
}
{
let curLowerBound = hBoxes[lowerIdx];
for (let i = lowerIdx + 1; i < hBoxes.length; i++) {
if (almostEqual(hBoxes[i].minY, curLowerBound.maxY + spacing)) {
const targetBound = hBoxes[i];
const xs = [
targetBound.minX,
targetBound.maxX,
curLowerBound.minX,
curLowerBound.maxX,
].sort((a, b) => a - b);
const x = (xs[1] + xs[2]) / 2;
this._distributedAlignLines.push([
new Point(x, curLowerBound.maxY),
new Point(x, hBoxes[i].minY),
]);
curLowerBound = hBoxes[i];
}
}
}
}
} }
private _calculateClosestDistances(bound: Bound, other: Bound): Distance { private _calculateClosestDistances(bound: Bound, other: Bound): Distance {
@@ -263,94 +436,150 @@ export class EdgelessSnapManager extends Overlay {
const closestX = Math.min(...xDistancesAbs); const closestX = Math.min(...xDistancesAbs);
const closestY = Math.min(...yDistancesAbs); const closestY = Math.min(...yDistancesAbs);
const indexX = xDistancesAbs.indexOf(closestX); const threshold = ALIGN_THRESHOLD / this.gfx.viewport.zoom;
const indexY = yDistancesAbs.indexOf(closestY);
// the x and y distances will be useful for locating the align point // the x and y distances will be useful for locating the align point
return { return {
absXDistance: closestX, horiz:
absYDistance: closestY, closestX <= threshold
xDistance: xDistances[indexX], ? {
yDistance: yDistances[indexY], distance: xDistances[xDistancesAbs.indexOf(closestX)],
indexX, get alignPositionIndices() {
indexY, const indices: number[] = [];
xDistancesAbs.forEach(
(val, idx) => almostEqual(val, closestX) && indices.push(idx)
);
return indices;
},
}
: undefined,
vert:
closestY <= threshold
? {
distance: yDistances[yDistancesAbs.indexOf(closestY)],
get alignPositionIndices() {
const indices: number[] = [];
yDistancesAbs.forEach(
(val, idx) => almostEqual(val, closestY) && indices.push(idx)
);
return indices;
},
}
: undefined,
}; };
} }
private _draw() { /**
this._surface.refresh(); * Update horizontal moving distance `rst.dx` to align with other bound.
} * Also, update the align points to draw.
* @param rst
// Update X align point * @param bound
* @param other
* @param distance
*/
private _updateXAlignPoint( private _updateXAlignPoint(
rst: { dx: number; dy: number }, rst: { dx: number; dy: number },
bound: Bound, bound: Bound,
other: Bound, other: Bound,
distance: Distance distance: Distance
) { ) {
const index = distance.indexX; if (!distance.horiz) return;
rst.dx = distance.xDistance;
const alignPointX = [ const { distance: dx, alignPositionIndices: distanceIndices } =
distance.horiz;
const alignXPosition = [
other.center[0], other.center[0],
other.minX, other.minX,
other.maxX, other.maxX,
bound.minX + rst.dx, bound.minX + dx,
bound.minX + rst.dx, bound.minX + dx,
bound.maxX + rst.dx, bound.maxX + dx,
bound.maxX + rst.dx, bound.maxX + dx,
][index];
this._intraGraphicAlignLines[0] = [
new Point(alignPointX, bound.center[1]),
new Point(alignPointX, other.center[1]),
]; ];
rst.dx = dx;
const dy = distance.vert?.distance ?? 0;
const top = Math.min(bound.minY + dy, other.minY);
const down = Math.max(bound.maxY + dy, other.maxY);
this._intraGraphicAlignLines.push(
...distanceIndices.map(
idx =>
[
new Point(alignXPosition[idx], top),
new Point(alignXPosition[idx], down),
] as [Point, Point]
)
);
} }
// Update Y align point /**
* Update vertical moving distance `rst.dy` to align with other bound.
* Also, update the align points to draw.
* @param rst
* @param bound
* @param other
* @param distance
*/
private _updateYAlignPoint( private _updateYAlignPoint(
rst: { dx: number; dy: number }, rst: { dx: number; dy: number },
bound: Bound, bound: Bound,
other: Bound, other: Bound,
distance: Distance distance: Distance
) { ) {
const index = distance.indexY; if (!distance.vert) return;
rst.dy = distance.yDistance;
const alignPointY = [ const { distance: dy, alignPositionIndices } = distance.vert;
const alignXPosition = [
other.center[1], other.center[1],
other.minY, other.minY,
other.maxY, other.maxY,
bound.minY + rst.dy, bound.minY + dy,
bound.minY + rst.dy, bound.minY + dy,
bound.maxY + rst.dy, bound.maxY + dy,
bound.maxY + rst.dy, bound.maxY + dy,
][index];
this._intraGraphicAlignLines[1] = [
new Point(bound.center[0], alignPointY),
new Point(other.center[0], alignPointY),
]; ];
rst.dy = dy;
const dx = distance.horiz?.distance ?? 0;
const left = Math.min(bound.minX + dx, other.minX);
const right = Math.max(bound.maxX + dx, other.maxX);
this._intraGraphicAlignLines.push(
...alignPositionIndices.map(
idx =>
[
new Point(left, alignXPosition[idx]),
new Point(right, alignXPosition[idx]),
] as [Point, Point]
)
);
} }
align(bound: Bound): { dx: number; dy: number } { align(bound: Bound): { dx: number; dy: number } {
const rst = { dx: 0, dy: 0 }; const rst = { dx: 0, dy: 0 };
const threshold = ALIGN_THRESHOLD; const threshold = ALIGN_THRESHOLD / this.gfx.viewport.zoom;
const { viewport } = this.gfx; const { viewport } = this.gfx;
this._intraGraphicAlignLines = []; this._intraGraphicAlignLines = [];
this._distributedAlignLines = []; this._distributedAlignLines = [];
for (const other of this._alignableBounds) { for (const other of this._referenceBounds.all) {
const closestDistances = this._calculateClosestDistances(bound, other); const closestDistances = this._calculateClosestDistances(bound, other);
if (closestDistances.absXDistance < threshold) { if (closestDistances.horiz) {
this._updateXAlignPoint(rst, bound, other, closestDistances); this._updateXAlignPoint(rst, bound, other, closestDistances);
} }
if (closestDistances.absYDistance < threshold) { if (closestDistances.vert) {
this._updateYAlignPoint(rst, bound, other, closestDistances); this._updateYAlignPoint(rst, bound, other, closestDistances);
} }
} }
// point align prority is higher than distribute align // point align priority is higher than distribute align
if (rst.dx === 0) { if (rst.dx === 0) {
this._alignDistributeHorizontally(rst, bound, threshold, viewport); this._alignDistributeHorizontally(rst, bound, threshold, viewport);
} }
@@ -358,7 +587,9 @@ export class EdgelessSnapManager extends Overlay {
if (rst.dy === 0) { if (rst.dy === 0) {
this._alignDistributeVertically(rst, bound, threshold, viewport); this._alignDistributeVertically(rst, bound, threshold, viewport);
} }
this._draw();
this._renderer?.refresh();
return rst; return rst;
} }
@@ -369,9 +600,9 @@ export class EdgelessSnapManager extends Overlay {
) )
return; return;
const { viewport } = this.gfx; const { viewport } = this.gfx;
const strokeWidth = 1 / viewport.zoom; const strokeWidth = 2 / viewport.zoom;
const offset = 5 / viewport.zoom;
ctx.strokeStyle = '#1672F3'; ctx.strokeStyle = '#8B5CF6';
ctx.lineWidth = strokeWidth; ctx.lineWidth = strokeWidth;
ctx.beginPath(); ctx.beginPath();
@@ -381,30 +612,31 @@ export class EdgelessSnapManager extends Overlay {
const x = line[0].x; const x = line[0].x;
const minY = Math.min(line[0].y, line[1].y); const minY = Math.min(line[0].y, line[1].y);
const maxY = Math.max(line[0].y, line[1].y); const maxY = Math.max(line[0].y, line[1].y);
d = `M${x},${minY - offset}L${x},${maxY}`; d = `M${x},${minY}L${x},${maxY}`;
} else { } else {
const y = line[0].y; const y = line[0].y;
const minX = Math.min(line[0].x, line[1].x); const minX = Math.min(line[0].x, line[1].x);
const maxX = Math.max(line[0].x, line[1].x); const maxX = Math.max(line[0].x, line[1].x);
d = `M${minX - offset},${y}L${maxX + offset},${y}`; d = `M${minX},${y}L${maxX},${y}`;
} }
ctx.stroke(new Path2D(d)); ctx.stroke(new Path2D(d));
}); });
ctx.strokeStyle = '#CC4187';
this._distributedAlignLines.forEach(line => { this._distributedAlignLines.forEach(line => {
const bar = 10 / viewport.zoom; const bar = 10 / viewport.zoom;
let d = ''; let d = '';
if (line[0].x === line[1].x) { if (line[0].x === line[1].x) {
const x = line[0].x; const x = line[0].x;
const minY = Math.min(line[0].y, line[1].y) + offset; const minY = Math.min(line[0].y, line[1].y);
const maxY = Math.max(line[0].y, line[1].y) - offset; const maxY = Math.max(line[0].y, line[1].y);
d = `M${x},${minY}L${x},${maxY} d = `M${x},${minY}L${x},${maxY}
M${x - bar},${minY}L${x + bar},${minY} M${x - bar},${minY}L${x + bar},${minY}
M${x - bar},${maxY}L${x + bar},${maxY} `; M${x - bar},${maxY}L${x + bar},${maxY} `;
} else { } else {
const y = line[0].y; const y = line[0].y;
const minX = Math.min(line[0].x, line[1].x) + offset; const minX = Math.min(line[0].x, line[1].x);
const maxX = Math.max(line[0].x, line[1].x) - offset; const maxX = Math.max(line[0].x, line[1].x);
d = `M${minX},${y}L${maxX},${y} d = `M${minX},${y}L${maxX},${y}
M${minX},${y - bar}L${minX},${y + bar} M${minX},${y - bar}L${minX},${y + bar}
M${maxX},${y - bar}L${maxX},${y + bar}`; M${maxX},${y - bar}L${maxX},${y + bar}`;
@@ -413,42 +645,68 @@ export class EdgelessSnapManager extends Overlay {
}); });
} }
setupAlignables(alignables: GfxModel[], exclude: GfxModel[] = []): Bound { setMovingElements(
if (alignables.length === 0) return new Bound(); movingElements: GfxModel[],
excludes: GfxModel[] = []
): Bound {
if (movingElements.length === 0) return new Bound();
const connectors = alignables.filter(isConnectable).reduce((prev, el) => { const skipped = new Set(movingElements);
const connectors = (this.gfx.surface as SurfaceBlockModel).getConnectors( excludes.forEach(e => skipped.add(e));
el.id
);
if (connectors.length > 0) { const viewportBound = this.gfx.viewport.viewportBounds;
prev = prev.concat(connectors); const movingBound = movingElements
} .reduce(
(prev, element) => prev.unite(element.elementBound),
return prev; movingElements[0].elementBound
}, [] as ConnectorElementModel[]); )
.expand(ALIGN_THRESHOLD * this.gfx.viewport.zoom);
const { viewport } = this.gfx; const horizAreaBound = new Bound(
const viewportBounds = Bound.from(viewport.viewportBounds); Math.min(movingBound.x, viewportBound.x),
this._surface.renderer.addOverlay(this); movingBound.y,
const canvasElements = this.gfx.layer.canvasElements; Math.max(movingBound.w, viewportBound.w),
const excludes = new Set([...alignables, ...exclude, ...connectors]); movingBound.h
this._alignableBounds = []; );
([...this.gfx.layer.blocks, ...canvasElements] as GfxModel[]).forEach( const vertAreaBound = new Bound(
alignable => { movingBound.x,
const bounds = alignable.elementBound; Math.min(movingBound.y, viewportBound.y),
if ( movingBound.w,
viewportBounds.isOverlapWithBound(bounds) && Math.max(movingBound.h, viewportBound.h)
!excludes.has(alignable)
) {
this._alignableBounds.push(bounds);
}
}
); );
return alignables.reduce((prev, element) => { const vertCandidates = this.gfx.grid.search(vertAreaBound, {
const bounds = element.elementBound; useSet: true,
return prev.unite(bounds); });
}, Bound.deserialize(alignables[0].xywh)); const horizCandidates = this.gfx.grid.search(horizAreaBound, {
useSet: true,
});
const verticalBounds: Bound[] = [];
const horizBounds: Bound[] = [];
const allBounds: Bound[] = [];
vertCandidates.forEach(candidate => {
if (skipped.has(candidate) || candidate instanceof ConnectorElementModel)
return;
verticalBounds.push(candidate.elementBound);
allBounds.push(candidate.elementBound);
});
horizCandidates.forEach(candidate => {
if (skipped.has(candidate) || candidate instanceof ConnectorElementModel)
return;
horizBounds.push(candidate.elementBound);
allBounds.push(candidate.elementBound);
});
this._referenceBounds = {
horizontal: horizBounds,
vertical: verticalBounds,
all: allBounds,
};
return movingElements.reduce(
(prev, element) => prev.unite(element.elementBound),
Bound.deserialize(movingElements[0].xywh)
);
} }
} }

View File

@@ -194,16 +194,16 @@ test.describe('lock', () => {
const { lock, unlock } = getButtons(page); const { lock, unlock } = getButtons(page);
await lock.click(); await lock.click();
await assertEdgelessSelectedModelRect(page, [0, 0, 120, 120]); // frame outline and shape await assertEdgelessSelectedModelRect(page, [0, 0, 125, 125]); // frame outline and shape
await pressEscape(page); await pressEscape(page);
await clickView(page, [100, 100]); await clickView(page, [100, 100]);
await assertEdgelessSelectedModelRect(page, [0, 0, 120, 120]); await assertEdgelessSelectedModelRect(page, [0, 0, 125, 125]);
await unlock.click(); await unlock.click();
await assertEdgelessSelectedModelRect(page, [0, 0, 100, 100]); await assertEdgelessSelectedModelRect(page, [0, 0, 100, 100]);
await pressEscape(page); await pressEscape(page);
await clickView(page, [100, 100]); await clickView(page, [100, 100]);
await assertEdgelessSelectedModelRect(page, [70, 70, 50, 50]); await assertEdgelessSelectedModelRect(page, [75, 75, 50, 50]);
await deleteAll(page); await deleteAll(page);

View File

@@ -170,7 +170,7 @@ test('selection box of shape element sync on fast dragging', async ({
{ click: true } { click: true }
); );
await assertEdgelessSelectedRect(page, [650, 446, 100, 100]); await assertEdgelessSelectedRect(page, [650, 450, 100, 100]);
}); });
test('when the selection is always a note, it should remain in an active state', async ({ test('when the selection is always a note, it should remain in an active state', async ({

View File

@@ -18,11 +18,11 @@ test.describe('snap', () => {
await assertSelectedBound(page, [300, 0, 100, 100]); await assertSelectedBound(page, [300, 0, 100, 100]);
await dragBetweenViewCoords(page, [300 + 5, 50], [300 + 5, 50 + 5]); await dragBetweenViewCoords(page, [350, 50], [350, 50 + 10]);
await assertSelectedBound(page, [300, 5, 100, 100]); await assertSelectedBound(page, [300, 10, 100, 100]);
await undoByClick(page); await undoByClick(page);
await dragBetweenViewCoords(page, [300 + 5, 50], [300 + 5, 50 + 3]); await dragBetweenViewCoords(page, [350, 50], [350, 50 + 7]);
await assertSelectedBound(page, [300, 0, 100, 100]); await assertSelectedBound(page, [300, 0, 100, 100]);
}); });