Canvas: Add element snapping and alignment (#80407)

Co-authored-by: drew08t <drew08@gmail.com>
Co-authored-by: Isabel <76437239+imatwawana@users.noreply.github.com>
This commit is contained in:
Nathan Marrs 2024-01-30 16:37:46 -07:00 committed by GitHub
parent c377644c48
commit b0130ecb82
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 111 additions and 5 deletions

View File

@ -107,6 +107,16 @@ When right clicking an element, you are able to edit, delete, duplicate, and mod
{{< figure src="/static/img/docs/canvas-panel/canvas-context-menu-9-2-0.png" max-width="750px" caption="Canvas element context menu" >}}
### Element snapping and alignment
When you're moving elements around the canvas, snapping and alignment guides help you create more precise layouts.
{{% admonition type="note" %}}
Currently, element snapping and alignment only works when the canvas is not zoomed in.
{{% /admonition %}}
<!-- TODO: Add gif showcasing feature (when creating what's new entry for 10.4) -->
## Canvas options
### Inline editing

View File

@ -13,10 +13,37 @@ type SceneTransformWrapperProps = {
export const SceneTransformWrapper = ({ scene, children: sceneDiv }: SceneTransformWrapperProps) => {
const onZoom = (zoomPanPinchRef: ReactZoomPanPinchRef) => {
const scale = zoomPanPinchRef.state.scale;
scene.scale = scale;
};
const onZoomStop = (zoomPanPinchRef: ReactZoomPanPinchRef) => {
const scale = zoomPanPinchRef.state.scale;
scene.scale = scale;
updateMoveable(scale);
};
const onTransformed = (
_: ReactZoomPanPinchRef,
state: {
scale: number;
positionX: number;
positionY: number;
}
) => {
const scale = state.scale;
scene.scale = scale;
updateMoveable(scale);
};
const updateMoveable = (scale: number) => {
if (scene.moveable && scale > 0) {
scene.moveable.zoom = 1 / scale;
if (scale === 1) {
scene.moveable.snappable = true;
} else {
scene.moveable.snappable = false;
}
}
scene.scale = scale;
};
const onSceneContainerMouseDown = (e: React.MouseEvent<HTMLDivElement, MouseEvent>) => {
@ -38,9 +65,8 @@ export const SceneTransformWrapper = ({ scene, children: sceneDiv }: SceneTransf
doubleClick={{ mode: 'reset' }}
ref={scene.transformComponentRef}
onZoom={onZoom}
onTransformed={(_, state) => {
scene.scale = state.scale;
}}
onZoomStop={onZoomStop}
onTransformed={onTransformed}
limitToBounds={true}
disabled={!config.featureToggles.canvasPanelPanZoom || !scene.shouldPanZoom}
panning={{ allowLeftClickPan: false }}

View File

@ -397,9 +397,19 @@ export class Scene {
hitRate: 0,
});
const snapDirections = { top: true, left: true, bottom: true, right: true, center: true, middle: true };
const elementSnapDirections = { top: true, left: true, bottom: true, right: true, center: true, middle: true };
this.moveable = new Moveable(this.div!, {
draggable: allowChanges && !this.editModeEnabled.getValue(),
resizable: allowChanges,
// Setup snappable
snappable: allowChanges,
snapDirections: snapDirections,
elementSnapDirections: elementSnapDirections,
elementGuidelines: targetElements,
ables: [dimensionViewable, constraintViewable(this), settingsViewable(this)],
props: {
dimensionViewable: allowChanges,
@ -426,9 +436,27 @@ export class Scene {
.on('dragStart', (event) => {
this.ignoreDataUpdate = true;
this.setNonTargetPointerEvents(event.target, true);
// Remove the selected element from the snappable guidelines
if (this.moveable && this.moveable.elementGuidelines) {
const targetIndex = this.moveable.elementGuidelines.indexOf(event.target);
if (targetIndex > -1) {
this.moveable.elementGuidelines.splice(targetIndex, 1);
}
}
})
.on('dragGroupStart', (event) => {
.on('dragGroupStart', (e) => {
this.ignoreDataUpdate = true;
// Remove the selected elements from the snappable guidelines
if (this.moveable && this.moveable.elementGuidelines) {
for (let event of e.events) {
const targetIndex = this.moveable.elementGuidelines.indexOf(event.target);
if (targetIndex > -1) {
this.moveable.elementGuidelines.splice(targetIndex, 1);
}
}
}
})
.on('drag', (event) => {
const targetedElement = this.findElementByTarget(event.target);
@ -463,6 +491,11 @@ export class Scene {
if (targetedElement) {
targetedElement.setPlacementFromConstraint(undefined, undefined, this.scale);
}
// re-add the selected elements to the snappable guidelines
if (this.moveable && this.moveable.elementGuidelines) {
this.moveable.elementGuidelines.push(event.target);
}
}
});
@ -478,11 +511,24 @@ export class Scene {
this.moved.next(Date.now());
this.ignoreDataUpdate = false;
this.setNonTargetPointerEvents(event.target, false);
// re-add the selected element to the snappable guidelines
if (this.moveable && this.moveable.elementGuidelines) {
this.moveable.elementGuidelines.push(event.target);
}
})
.on('resizeStart', (event) => {
const targetedElement = this.findElementByTarget(event.target);
if (targetedElement) {
// Remove the selected element from the snappable guidelines
if (this.moveable && this.moveable.elementGuidelines) {
const targetIndex = this.moveable.elementGuidelines.indexOf(event.target);
if (targetIndex > -1) {
this.moveable.elementGuidelines.splice(targetIndex, 1);
}
}
targetedElement.tempConstraint = { ...targetedElement.options.constraint };
targetedElement.options.constraint = {
vertical: VerticalConstraint.Top,
@ -491,6 +537,17 @@ export class Scene {
targetedElement.setPlacementFromConstraint(undefined, undefined, this.scale);
}
})
.on('resizeGroupStart', (e) => {
// Remove the selected elements from the snappable guidelines
if (this.moveable && this.moveable.elementGuidelines) {
for (let event of e.events) {
const targetIndex = this.moveable.elementGuidelines.indexOf(event.target);
if (targetIndex > -1) {
this.moveable.elementGuidelines.splice(targetIndex, 1);
}
}
}
})
.on('resize', (event) => {
const targetedElement = this.findElementByTarget(event.target);
if (targetedElement) {
@ -531,6 +588,19 @@ export class Scene {
}
targetedElement.setPlacementFromConstraint(undefined, undefined, this.scale);
// re-add the selected element to the snappable guidelines
if (this.moveable && this.moveable.elementGuidelines) {
this.moveable.elementGuidelines.push(event.target);
}
}
})
.on('resizeGroupEnd', (e) => {
// re-add the selected elements to the snappable guidelines
if (this.moveable && this.moveable.elementGuidelines) {
for (let event of e.events) {
this.moveable.elementGuidelines.push(event.target);
}
}
});