import React from "react"; import { makeObservable, observable } from "mobx"; import { observer } from "mobx-react"; import { each } from "lodash"; import { Point, Rect } from "eez-studio-shared/geometry"; import { IEezObject, getParent, isAncestor } from "project-editor/core/object"; import type { TreeObjectAdapter } from "project-editor/core/objectAdapter"; import type { IFlowContext } from "project-editor/flow/flow-interfaces"; import type { Component } from "project-editor/flow/component"; import type { Flow } from "project-editor/flow/flow"; import { ProjectEditor } from "project-editor/project-editor-interface"; import { getObjectBoundingRect } from "project-editor/flow/editor/bounding-rects"; import type { EditorFlowContext } from "project-editor/flow/editor/context"; //////////////////////////////////////////////////////////////////////////////// const ADD_MARGIN_RECT = false; const MAX_SNAP_LINE_DISTANCE = 8; const SNAP_LINES_DRAW_THEME = { lineColor: "red", lineWidth: 1, lineDasharray: "5 5", rectLineColor: "rgba(255, 0, 0, 0.4)", rectLineWidth: 1, rectLineDasharray: "none", marginRectLineColor: "rgba(0, 255, 0, 0.6)", marginRectLineWidth: 1, marginRectLineDasharray: "none", markerSize: 12, markerLineColor: "red", markerLineWidth: 2 }; export const CONF_ACTIVATE_SNAP_TO_LINES_AFTER_TIME = 300; //////////////////////////////////////////////////////////////////////////////// interface SnapRect { rect: Rect; isMarginRect: boolean; } interface ISnapLine { pos: number; rects: SnapRect[]; } export interface IClosestSnapLines { diff: number; lines: ISnapLine[]; } export interface ISnapLines { horizontalLines: ISnapLine[]; verticalLines: ISnapLine[]; } //////////////////////////////////////////////////////////////////////////////// export function findSnapLines(flowContext: IFlowContext): ISnapLines { const selectedObjects = flowContext.viewState.selectedObjects.map( objectAdapter => objectAdapter.object ); const isSelectedObject = (object: IEezObject) => { return selectedObjects.find( selectedObject => selectedObject == object || isAncestor(object, selectedObject) ); }; const isHiddenBySelectWidget = ( flowContext: IFlowContext, childObject: IEezObject ) => { while (true) { let parent = getParent(childObject); if (!parent) { break; } parent = getParent(parent); if (!parent || !(parent instanceof ProjectEditor.WidgetClass)) { break; } if (parent instanceof ProjectEditor.SelectWidgetClass) { const index = parent.getSelectedIndex(flowContext); let selectedWidget = index >= 0 && index < parent.widgets.length ? parent.widgets[index] : null; if (selectedWidget != childObject) { return true; } } childObject = parent; } return false; }; let horizontalLines: ISnapLine[] = []; let verticalLines: ISnapLine[] = []; function findRect(rect: Rect, rects: SnapRect[]) { return rects.find( snapRect => snapRect.rect.left == rect.left && snapRect.rect.top == rect.top && snapRect.rect.width == rect.width && snapRect.rect.height == rect.height ); } function addLine( lines: ISnapLine[], pos: number, rect: Rect, isMarginRect: boolean ) { const line = lines.find(line => line.pos == pos); if (line) { if (!findRect(rect, line.rects)) { line.rects.push({ rect, isMarginRect }); } } else { lines.push({ pos, rects: [ { rect, isMarginRect } ] }); } } function addLines(rect: Rect, isMarginRect: boolean) { addLine(verticalLines, rect.left, rect, isMarginRect); if (rect.width > 0) { if (!isMarginRect) { addLine( verticalLines, rect.left + rect.width / 2, rect, isMarginRect ); } addLine(verticalLines, rect.left + rect.width, rect, isMarginRect); } addLine(horizontalLines, rect.top, rect, isMarginRect); if (rect.width > 0) { if (!isMarginRect) { addLine( horizontalLines, rect.top + rect.height / 2, rect, isMarginRect ); } addLine( horizontalLines, rect.top + rect.height, rect, isMarginRect ); } } function findSnapLinesInNode(node: TreeObjectAdapter) { if ( node.object && (node.object instanceof ProjectEditor.PageClass || node.object instanceof ProjectEditor.ComponentClass) && !isSelectedObject(node.object) && !( node.object instanceof ProjectEditor.WidgetClass && (node.object.hiddenInEditor || isHiddenBySelectWidget(flowContext, node.object)) ) ) { const rect1 = getObjectBoundingRect(flowContext.viewState, node); addLines(rect1, false); if (ADD_MARGIN_RECT) { const marginRect = Object.assign({}, rect1); if ( node.object instanceof ProjectEditor.PageClass || node.object instanceof ProjectEditor.ContainerWidgetClass ) { // add inner margin for page and container marginRect.left += 10; marginRect.top += 10; marginRect.width -= 20; marginRect.height -= 20; if (marginRect.width > 0 && marginRect.height > 0) { addLines(marginRect, true); } } else { // add outer margin for all other widgets marginRect.left -= 10; marginRect.top -= 10; marginRect.width += 20; marginRect.height += 20; addLines(marginRect, true); } } } each(node.children, (item: any) => findSnapLinesInNode(item)); } findSnapLinesInNode(flowContext.document.flow); const sortByPos = (a: ISnapLine, b: ISnapLine) => a.pos < b.pos ? -1 : a.pos > b.pos ? 1 : 0; horizontalLines.sort(sortByPos); verticalLines.sort(sortByPos); return { horizontalLines, verticalLines }; } //////////////////////////////////////////////////////////////////////////////// function findClosestSnapLinesToPosition(lines: ISnapLine[], pos: number) { let result: IClosestSnapLines | undefined; for (let i = 0; i < lines.length; i++) { let diff = Math.abs(pos - lines[i].pos); if (diff < MAX_SNAP_LINE_DISTANCE) { if (!result || diff < result.diff) { result = { diff, lines: [lines[i]] }; } else if ( result && diff == result.diff && lines[i].pos == result.lines[0].pos ) { result.lines.push(lines[i]); } } } return result; } export function findClosestHorizontalSnapLinesToPosition( snapLines: ISnapLines, pos: number ) { return findClosestSnapLinesToPosition(snapLines.horizontalLines, pos); } export function findClosestVerticalSnapLinesToPosition( snapLines: ISnapLines, pos: number ) { return findClosestSnapLinesToPosition(snapLines.verticalLines, pos); } //////////////////////////////////////////////////////////////////////////////// export function drawSnapLinesGeneric( snapLines: ISnapLines, selectionRect: Rect, drawLine: (horizontal: boolean, line: ISnapLine) => void ) { { const horizontalLines: ISnapLine[] = []; snapLines.horizontalLines.forEach(line => { if ( line.pos == selectionRect.top || Math.abs( line.pos - (selectionRect.top + selectionRect.height / 2) ) < 1 || line.pos == selectionRect.top + selectionRect.height ) { horizontalLines.push(line); } }); let drawnPositions = []; for (const line of horizontalLines) { if (drawnPositions.indexOf(line.pos) == -1) { drawLine(true, line); drawnPositions.push(line.pos); } } } { const verticalLines: ISnapLine[] = []; snapLines.verticalLines.forEach(line => { if ( line.pos == selectionRect.left || Math.abs( line.pos - (selectionRect.left + selectionRect.width / 2) ) < 1 || line.pos == selectionRect.left + selectionRect.width ) { verticalLines.push(line); } }); let drawnPositions = []; for (const line of verticalLines) { if (drawnPositions.indexOf(line.pos) == -1) { drawLine(false, line); drawnPositions.push(line.pos); } } } } //////////////////////////////////////////////////////////////////////////////// export class SnapLines { lines: ISnapLines; enabled: boolean = false; find(context: IFlowContext) { this.lines = findSnapLines(context); } findSnapPosition( pos: number, findFunc: ( snapLines: ISnapLines, pos: number ) => IClosestSnapLines | undefined, offsets: number[] ) { const arr = offsets .map(offset => ({ findResult: findFunc(this.lines, pos + offset), offset })) .filter(item => item.findResult != undefined); if (arr.length == 0) { return pos; } arr.sort((a, b) => a.findResult!.diff < b.findResult!.diff ? -1 : a.findResult!.diff > b.findResult!.diff ? 1 : 0 ); if (!arr[0].findResult) { return pos; } return Math.round(arr[0].findResult.lines[0].pos - arr[0].offset); } dragSnap(left: number, top: number, width: number, height: number) { if (this.enabled) { left = this.findSnapPosition( left, findClosestVerticalSnapLinesToPosition, [0, width / 2, width] ); top = this.findSnapPosition( top, findClosestHorizontalSnapLinesToPosition, [0, height / 2, height] ); } return { left, top }; } render(flowContext: IFlowContext, selectionRect: Rect) { if (!this.enabled) { return null; } const transform = flowContext.viewState.transform; const lines: JSX.Element[] = []; const lineStyle: React.CSSProperties = { stroke: SNAP_LINES_DRAW_THEME.lineColor, strokeWidth: SNAP_LINES_DRAW_THEME.lineWidth, strokeDasharray: SNAP_LINES_DRAW_THEME.lineDasharray }; const rects: JSX.Element[] = []; const rectStyle: React.CSSProperties = { stroke: SNAP_LINES_DRAW_THEME.rectLineColor, strokeWidth: SNAP_LINES_DRAW_THEME.rectLineWidth, strokeDasharray: SNAP_LINES_DRAW_THEME.rectLineDasharray }; const marginRectStyle: React.CSSProperties = { stroke: SNAP_LINES_DRAW_THEME.marginRectLineColor, strokeWidth: SNAP_LINES_DRAW_THEME.marginRectLineWidth, strokeDasharray: SNAP_LINES_DRAW_THEME.marginRectLineDasharray }; drawSnapLinesGeneric( this.lines, selectionRect, (horizontal: boolean, line: ISnapLine) => { const key = line.pos + horizontal.toString(); let points: Point[] = []; if (horizontal) { if ( line.pos == selectionRect.top || line.pos == selectionRect.top + selectionRect.height ) { points.push({ x: selectionRect.left, y: line.pos }); points.push({ x: selectionRect.left + selectionRect.width, y: line.pos }); } else { points.push({ x: selectionRect.left + Math.round(selectionRect.width / 2), y: line.pos }); } line.rects.forEach(snapRect => { points.push({ x: snapRect.rect.left, y: line.pos }); points.push({ x: snapRect.rect.left + snapRect.rect.width, y: line.pos }); }); points.sort((a, b) => (a.x < b.x ? -1 : a.x > b.x ? 1 : 0)); } else { if ( line.pos == selectionRect.left || line.pos == selectionRect.left + selectionRect.width ) { points.push({ x: line.pos, y: selectionRect.top }); points.push({ x: line.pos, y: selectionRect.top + selectionRect.height }); } else { points.push({ x: line.pos, y: selectionRect.top + Math.round(selectionRect.height / 2) }); } line.rects.forEach(snapRect => { points.push({ x: line.pos, y: snapRect.rect.top }); points.push({ x: line.pos, y: snapRect.rect.top + snapRect.rect.height }); }); points.sort((a, b) => (a.y < b.y ? -1 : a.y > b.y ? 1 : 0)); } lines.push( { point = transform.pageToOffsetPoint(point); return `${point.x},${point.y}`; }) .join(" ")} style={lineStyle} markerStart="url(#dot)" markerMid="url(#dot)" markerEnd="url(#dot)" /> ); line.rects.forEach(snapRect => { const rect = transform.pageToOffsetRect(snapRect.rect); rects.push( ); }); } ); const offsetRect = transform.clientToOffsetRect(transform.clientRect); const m = SNAP_LINES_DRAW_THEME.markerSize; return ( {rects} {lines} ); } } //////////////////////////////////////////////////////////////////////////////// export class DragSnapLines { snapLines: SnapLines | undefined; flowContext: EditorFlowContext | undefined; dragComponent: Component | undefined; constructor() { makeObservable(this, { snapLines: observable }); } start(flowContext: EditorFlowContext) { this.snapLines = new SnapLines(); this.flowContext = flowContext; this.dragComponent = flowContext.dragComponent; this.snapLines.find(flowContext); } clear() { this.snapLines = undefined; this.flowContext = undefined; this.dragComponent = undefined; } } export const DragSnapLinesOverlay = observer( class DragSnapLinesOverlay extends React.Component<{ dragSnapLines: DragSnapLines; }> { render() { const { dragSnapLines } = this.props; if (!dragSnapLines.snapLines) { return null; } const flow = dragSnapLines.flowContext!.document.flow .object as Flow; const dragComponent = dragSnapLines.dragComponent!; return (
{dragSnapLines.snapLines.render( dragSnapLines.flowContext!, { left: (dragComponent instanceof ProjectEditor.WidgetClass ? flow.pageRect.left : 0) + dragComponent.left, top: (dragComponent instanceof ProjectEditor.WidgetClass ? flow.pageRect.top : 0) + dragComponent.top, width: dragComponent.rect.width, height: dragComponent.rect.height } )}
); } } );