diff --git a/src/Components/Canvas/Canvas.tsx b/src/Components/Canvas/Canvas.tsx new file mode 100644 index 0000000..ca32b69 --- /dev/null +++ b/src/Components/Canvas/Canvas.tsx @@ -0,0 +1,146 @@ +import React, { useEffect, useRef } from 'react'; +import { IPoint } from '../../Interfaces/IPoint'; +import { BAR_WIDTH } from '../Bar/Bar'; + +interface ICanvasProps { + width: number + height: number + draw: (context: CanvasRenderingContext2D, frameCount: number, scale: number, translatePos: IPoint) => void + className?: string + style?: React.CSSProperties +}; + +function UseCanvas(draw: (context: CanvasRenderingContext2D, frameCount: number, scale: number, translatePos: IPoint) => void): React.RefObject { + const canvasRef = useRef(null); + const frameCount = useRef(0); + const translatePos = useRef({ + x: 0, + y: 0 + }); + + const startDragOffset = useRef({ + x: 0, + y: 0 + }); + + const scale = useRef(1.0); + const scaleMultiplier = useRef(0.8); + const dragging = useRef(false); + + useEffect(() => { + const canvas = canvasRef.current; + + let animationFrameId = 0; + if (canvas === null) { + return; + } + + const context = canvas.getContext('2d'); + + // add event listeners to handle screen drag + function MouseDown(evt: MouseEvent): void { + dragging.current = true; + startDragOffset.current.x = evt.clientX - translatePos.current.x; + startDragOffset.current.y = evt.clientY - translatePos.current.y; + } + + function MouseUp(evt: MouseEvent): void { + dragging.current = false; + } + + function MouseMove(evt: MouseEvent): void { + if (!dragging.current || context === null) { + return; + } + + translatePos.current.x = evt.clientX - startDragOffset.current.x; + translatePos.current.y = evt.clientY - startDragOffset.current.y; + frameCount.current++; + draw(context, frameCount.current, scale.current, translatePos.current); + } + function HandleScroll(evt: WheelEvent): void { + if (context === null) { + return; + } + + if (evt.deltaY >= 0) { + scale.current *= scaleMultiplier.current; + } else { + scale.current /= scaleMultiplier.current; + } + + draw(context, frameCount.current, scale.current, translatePos.current); + evt.preventDefault(); + } + canvas.addEventListener('mousedown', MouseDown); + window.addEventListener('mouseup', MouseUp); + canvas.addEventListener('mousemove', MouseMove); + canvas.addEventListener('wheel', HandleScroll); + + function Render(): void { + if (context === null) { + return; + } + + frameCount.current++; + draw(context, frameCount.current, scale.current, translatePos.current); + animationFrameId = window.requestAnimationFrame(Render); + } + + Render(); + + return () => { + window.cancelAnimationFrame(animationFrameId); + canvas.removeEventListener('mousedown', MouseDown); + window.removeEventListener('mouseup', MouseUp); + canvas.removeEventListener('mousemove', MouseMove); + canvas.removeEventListener('wheel', HandleScroll); + }; + }, [draw]); + + return canvasRef; +} + +function UseSVGAutoResizer( + setViewer: React.Dispatch> +): void { + React.useEffect(() => { + function OnResize(): void { + setViewer({ + viewerWidth: window.innerWidth - BAR_WIDTH, + viewerHeight: window.innerHeight + }); + } + window.addEventListener('resize', OnResize); + + return () => { + window.removeEventListener('resize', OnResize); + }; + }); +} + +interface Viewer { + viewerWidth: number + viewerHeight: number +} + +export function Canvas({ width, height, draw, style, className }: ICanvasProps): JSX.Element { + const canvasRef = UseCanvas(draw); + + const [{ viewerWidth, viewerHeight }, setViewer] = React.useState({ + viewerWidth: width, + viewerHeight: height + }); + + UseSVGAutoResizer(setViewer); + + return ( + + ); +} diff --git a/src/Components/Canvas/Dimension.ts b/src/Components/Canvas/Dimension.ts new file mode 100644 index 0000000..e95fc92 --- /dev/null +++ b/src/Components/Canvas/Dimension.ts @@ -0,0 +1,82 @@ +import { NOTCHES_LENGTH } from '../../utils/default'; + +interface IDimensionProps { + id: string + xStart: number + yStart: number + xEnd: number + yEnd: number + text: string + strokeWidth: number + scale?: number +} + +/** + * 2D Parametric function. Returns a new coordinate from the origin coordinate + * See for more details https://en.wikipedia.org/wiki/Parametric_equation. + * TL;DR a parametric function is a function with a parameter + * @param x0 Origin coordinate + * @param t The parameter + * @param vx Transform vector + * @returns Returns a new coordinate from the origin coordinate + */ +function ApplyParametric(x0: number, t: number, vx: number): number { + return x0 + t * vx; +} + +export function RenderDimension(ctx: CanvasRenderingContext2D, props: IDimensionProps): void { + const scale = props.scale ?? 1; + const strokeStyle = 'black'; + const lineWidth = 2 / scale; + + /// We need to find the points of the notches + // Get the vector of the line + const [deltaX, deltaY] = [(props.xEnd - props.xStart), (props.yEnd - props.yStart)]; + const rotation = (Math.atan2(deltaY, deltaX) / (2 * Math.PI)); + + // Get the unit vector + const norm = Math.sqrt(deltaX * deltaX + deltaY * deltaY); + const [unitX, unitY] = [deltaX / norm, deltaY / norm]; + + // Get the perpandicular vector + const [perpVecX, perpVecY] = [unitY, -unitX]; + + // Use the parametric function to get the coordinates (x = x0 + t * v.x) + const notchesLength = NOTCHES_LENGTH / scale; + const startTopX = ApplyParametric(props.xStart, notchesLength, perpVecX); + const startTopY = ApplyParametric(props.yStart, notchesLength, perpVecY); + const startBottomX = ApplyParametric(props.xStart, -notchesLength, perpVecX); + const startBottomY = ApplyParametric(props.yStart, -notchesLength, perpVecY); + + const endTopX = ApplyParametric(props.xEnd, notchesLength, perpVecX); + const endTopY = ApplyParametric(props.yEnd, notchesLength, perpVecY); + const endBottomX = ApplyParametric(props.xEnd, -notchesLength, perpVecX); + const endBottomY = ApplyParametric(props.yEnd, -notchesLength, perpVecY); + + ctx.save(); + + ctx.globalAlpha = 1; + ctx.lineWidth = lineWidth; + ctx.strokeStyle = strokeStyle; + ctx.fillStyle = strokeStyle; + ctx.moveTo(startTopX, startTopY); + ctx.lineTo(startBottomX, startBottomY); + ctx.stroke(); + ctx.moveTo(props.xStart, props.yStart); + ctx.lineTo(props.xEnd, props.yEnd); + ctx.stroke(); + ctx.moveTo(endTopX, endTopY); + ctx.lineTo(endBottomX, endBottomY); + ctx.stroke(); + const textX = (props.xStart + props.xEnd) / 2; + const textY = (props.yStart + props.yEnd) / 2; + ctx.font = `${16 / scale}px Verdana`; + ctx.textAlign = 'center'; + ctx.globalAlpha = 1; + ctx.textBaseline = 'bottom'; + ctx.translate(textX, textY); + ctx.rotate(rotation * Math.PI * 2); + ctx.fillText(props.text, 0, 0); + + ctx.restore(); +} diff --git a/src/Components/Canvas/DimensionLayer.ts b/src/Components/Canvas/DimensionLayer.ts new file mode 100644 index 0000000..c00c835 --- /dev/null +++ b/src/Components/Canvas/DimensionLayer.ts @@ -0,0 +1,345 @@ +import { Orientation } from '../../Enums/Orientation'; +import { Position } from '../../Enums/Position'; +import { IContainerModel } from '../../Interfaces/IContainerModel'; +import { SHOW_SELF_DIMENSIONS, SHOW_BORROWER_DIMENSIONS, SHOW_CHILDREN_DIMENSIONS } from '../../utils/default'; +import { MakeRecursionDFSIterator, Pairwise } from '../../utils/itertools'; +import { TransformX, TransformY } from '../../utils/svg'; +import { RenderDimension } from './Dimension'; + +const MODULE_STROKE_WIDTH = 1; + +export function AddDimensions( + ctx: CanvasRenderingContext2D, + container: IContainerModel, + dimMapped: number[], + currentTransform: [number, number], + scale: number, + depth: number +): void { + ctx.beginPath(); + if (SHOW_SELF_DIMENSIONS && container.properties.showSelfDimensions.length > 0) { + ActionByPosition( + ctx, + dimMapped, + container.properties.showSelfDimensions, + AddHorizontalSelfDimension, + AddVerticalSelfDimension, + [container, + currentTransform, + scale] + ); + } + + if (SHOW_BORROWER_DIMENSIONS && container.properties.showDimensionWithMarks.length > 0) { + ActionByPosition( + ctx, + dimMapped, + container.properties.showDimensionWithMarks, + AddHorizontalBorrowerDimension, + AddVerticalBorrowerDimension, + [container, + depth, + currentTransform, + scale] + ); + } + + if (SHOW_CHILDREN_DIMENSIONS && container.properties.showChildrenDimensions.length > 0 && container.children.length >= 2) { + ActionByPosition( + ctx, + dimMapped, + container.properties.showChildrenDimensions, + AddHorizontalChildrenDimension, + AddVerticalChildrenDimension, + [container, + currentTransform, + scale] + ); + } +} + +/** + * Fonction that call another function given the positions + * @param dimMapped Position mapped depending on the Position enum in order: + * [0:left, 1:bottom, 2:up, 3:right] + * @param positions List of positions + * @param horizontalAction Action called when a left or right position is present + * @param verticalAction Action called when a down or up position is present + * @param params Params for the actions + * (the two actions must have the same number of params, and in the same order) + */ +function ActionByPosition( + ctx: CanvasRenderingContext2D, + dimMapped: number[], + positions: Position[], + horizontalAction: (ctx: CanvasRenderingContext2D, dim: number, ...params: any[]) => void, + verticalAction: (ctx: CanvasRenderingContext2D, dim: number, ...params: any[]) => void, + params: any[] +): void { + positions.forEach((position: Position) => { + const dim = dimMapped[position]; + switch (position) { + case Position.Left: + case Position.Right: + verticalAction(ctx, dim, ...params); + break; + case Position.Down: + case Position.Up: + horizontalAction(ctx, dim, ...params); + break; + } + }); +} + +function AddHorizontalChildrenDimension( + ctx: CanvasRenderingContext2D, + yDim: number, + container: IContainerModel, + currentTransform: [number, number], + scale: number +): void { + const childrenId = `dim-y${yDim.toFixed(0)}-children-${container.properties.id}`; + + const lastChild = container.children[container.children.length - 1]; + let xChildrenStart = TransformX(lastChild.properties.x, lastChild.properties.width, lastChild.properties.positionReference); + let xChildrenEnd = TransformX(lastChild.properties.x, lastChild.properties.width, lastChild.properties.positionReference); + + // Find the min and max + for (let i = container.children.length - 2; i >= 0; i--) { + const child = container.children[i]; + const left = TransformX(child.properties.x, child.properties.width, child.properties.positionReference); + if (left < xChildrenStart) { + xChildrenStart = left; + } + const right = TransformX(child.properties.x, child.properties.width, child.properties.positionReference); + if (right > xChildrenEnd) { + xChildrenEnd = right; + } + } + + if (xChildrenStart === xChildrenEnd) { + // do not show an empty dimension + return; + } + + const textChildren = (xChildrenEnd - xChildrenStart) + .toFixed(2) + .toString(); + + const offset = currentTransform[0] + container.properties.x; + RenderDimension(ctx, { + id: childrenId, + xStart: xChildrenStart + offset, + xEnd: xChildrenEnd + offset, + yStart: yDim, + yEnd: yDim, + strokeWidth: MODULE_STROKE_WIDTH, + text: textChildren, + scale + }); +} + +function AddVerticalChildrenDimension( + ctx: CanvasRenderingContext2D, + xDim: number, + container: IContainerModel, + currentTransform: [number, number], + scale: number +): void { + const childrenId = `dim-x${xDim.toFixed(0)}-children-${container.properties.id}`; + + const lastChild = container.children[container.children.length - 1]; + let yChildrenStart = TransformY(lastChild.properties.y, lastChild.properties.height, lastChild.properties.positionReference); + let yChildrenEnd = TransformY(lastChild.properties.y, lastChild.properties.height, lastChild.properties.positionReference); + + // Find the min and max + for (let i = container.children.length - 2; i >= 0; i--) { + const child = container.children[i]; + const top = TransformY(child.properties.y, child.properties.height, child.properties.positionReference); + if (top < yChildrenStart) { + yChildrenStart = top; + } + const bottom = TransformY(child.properties.y, child.properties.height, child.properties.positionReference); + if (bottom > yChildrenEnd) { + yChildrenEnd = bottom; + } + } + + if (yChildrenStart === yChildrenEnd) { + // do not show an empty dimension + return; + } + + const textChildren = (yChildrenEnd - yChildrenStart) + .toFixed(2) + .toString(); + + const offset = currentTransform[0] + container.properties.x; + + RenderDimension(ctx, { + id: childrenId, + xStart: xDim, + xEnd: xDim, + yStart: yChildrenStart + offset, + yEnd: yChildrenEnd + offset, + strokeWidth: MODULE_STROKE_WIDTH, + text: textChildren, + scale + }); +} + +function AddHorizontalBorrowerDimension( + ctx: CanvasRenderingContext2D, + yDim: number, + container: IContainerModel, + depth: number, + currentTransform: [number, number], + scale: number +): void { + const it = MakeRecursionDFSIterator(container, depth, currentTransform); + const marks = []; // list of vertical lines for the dimension + for (const { + container: childContainer, currentTransform: childCurrentTransform + } of it) { + const isHidden = !childContainer.properties.markPosition.includes(Orientation.Horizontal); + if (isHidden) { + continue; + } + + const x = TransformX( + childContainer.properties.x, + childContainer.properties.width, + childContainer.properties.positionReference + ); + + const restoredX = x + childCurrentTransform[0]; + + marks.push( + restoredX + ); + } + + const restoredX = container.properties.x + currentTransform[0]; + marks.push(restoredX); + marks.push(restoredX + container.properties.width); + marks.sort((a, b) => a - b); + let count = 0; + for (const { cur, next } of Pairwise(marks)) { + const id = `dim-y${yDim.toFixed(0)}-borrow-${container.properties.id}-{${count}}`; + RenderDimension(ctx, { + id, + xStart: cur, + xEnd: next, + yStart: yDim, + yEnd: yDim, + strokeWidth: MODULE_STROKE_WIDTH, + text: (next - cur).toFixed(0).toString(), + scale + }); + count++; + } +} + +function AddVerticalBorrowerDimension( + ctx: CanvasRenderingContext2D, + xDim: number, + container: IContainerModel, + depth: number, + currentTransform: [number, number], + scale: number +): void { + const it = MakeRecursionDFSIterator(container, depth, currentTransform); + const marks = []; // list of vertical lines for the dimension + for (const { + container: childContainer, currentTransform: childCurrentTransform + } of it) { + const isHidden = !childContainer.properties.markPosition.includes(Orientation.Vertical); + if (isHidden) { + continue; + } + + const y = TransformY( + childContainer.properties.y, + childContainer.properties.height, + childContainer.properties.positionReference + ); + + const restoredy = y + childCurrentTransform[1]; + + marks.push( + restoredy + ); + } + + const restoredY = container.properties.y + currentTransform[1]; + marks.push(restoredY); + marks.push(restoredY + container.properties.height); + marks.sort((a, b) => a - b); + let count = 0; + for (const { cur, next } of Pairwise(marks)) { + const id = `dim-x${xDim.toFixed(0)}-borrow-${container.properties.id}-{${count}}`; + RenderDimension(ctx, { + id, + xStart: xDim, + xEnd: xDim, + yStart: cur, + yEnd: next, + strokeWidth: MODULE_STROKE_WIDTH, + text: (next - cur).toFixed(0).toString(), + scale + }); + count++; + } +} + +function AddVerticalSelfDimension( + ctx: CanvasRenderingContext2D, + xDim: number, + container: IContainerModel, + currentTransform: [number, number], + scale: number +): void { + const height = container.properties.height; + const idVert = `dim-x${xDim.toFixed(0)}-${container.properties.id}`; + const yStart = container.properties.y + currentTransform[1]; + const yEnd = yStart + height; + const textVert = height + .toFixed(0) + .toString(); + RenderDimension(ctx, { + id: idVert, + xStart: xDim, + yStart, + xEnd: xDim, + yEnd, + strokeWidth: MODULE_STROKE_WIDTH, + text: textVert, + scale + }); +} + +function AddHorizontalSelfDimension( + ctx: CanvasRenderingContext2D, + yDim: number, + container: IContainerModel, + currentTransform: [number, number], + scale: number +): void { + const width = container.properties.width; + const id = `dim-y${yDim.toFixed(0)}-${container.properties.id}`; + const xStart = container.properties.x + currentTransform[0]; + const xEnd = xStart + width; + const text = width + .toFixed(0) + .toString(); + RenderDimension(ctx, { + id, + xStart, + yStart: yDim, + xEnd, + yEnd: yDim, + strokeWidth: MODULE_STROKE_WIDTH, + text, + scale + }); +} diff --git a/src/Components/Canvas/Selector.ts b/src/Components/Canvas/Selector.ts new file mode 100644 index 0000000..81d86c1 --- /dev/null +++ b/src/Components/Canvas/Selector.ts @@ -0,0 +1,57 @@ +import { IContainerModel } from '../../Interfaces/IContainerModel'; +import { SHOW_SELECTOR_TEXT } from '../../utils/default'; +import { GetAbsolutePosition } from '../../utils/itertools'; +import { RemoveMargin } from '../../utils/svg'; + +interface ISelectorProps { + selected?: IContainerModel + scale?: number +} + +export function RenderSelector(ctx: CanvasRenderingContext2D, frameCount: number, props: ISelectorProps): void { + if (props.selected === undefined || props.selected === null) { + return; + } + + const scale = (props.scale ?? 1); + let [x, y] = GetAbsolutePosition(props.selected); + let [width, height] = [ + props.selected.properties.width, + props.selected.properties.height + ]; + + ({ x, y, width, height } = RemoveMargin(x, y, width, height, + props.selected.properties.margin.left, + props.selected.properties.margin.bottom, + props.selected.properties.margin.top, + props.selected.properties.margin.right + )); + + const xText = x + width / 2; + const yText = y + height / 2; + + const style: React.CSSProperties = { + stroke: '#3B82F6', + strokeWidth: 4 / scale, + fillOpacity: 0, + transitionProperty: 'all', + transitionTimingFunction: 'cubic-bezier(0.4, 0, 0.2, 1)', + transitionDuration: '150ms', + animation: 'fadein 750ms ease-in alternate infinite' + }; + + ctx.strokeStyle = '#3B82F6'; + ctx.lineWidth = 4 / scale; + ctx.globalAlpha = 0.25 * (Math.sin(frameCount * 0.0125) ** 2); + ctx.strokeRect(x, y, width, height); + ctx.globalAlpha = 1; + ctx.lineWidth = 1; + ctx.strokeStyle = 'black'; + + if (SHOW_SELECTOR_TEXT) { + ctx.font = `${16 / scale}px Verdana`; + ctx.textAlign = 'center'; + ctx.fillText(props.selected.properties.displayedText, xText, yText); + ctx.textAlign = 'left'; + } +} diff --git a/src/Components/Editor/Editor.tsx b/src/Components/Editor/Editor.tsx index c262f12..7c35165 100644 --- a/src/Components/Editor/Editor.tsx +++ b/src/Components/Editor/Editor.tsx @@ -9,12 +9,17 @@ import { SaveEditorAsJSON, SaveEditorAsSVG } from './Actions/Save'; import { OnKey } from './Actions/Shortcuts'; import { events as EVENTS } from '../../Events/EditorEvents'; import { IEditorState } from '../../Interfaces/IEditorState'; -import { DISABLE_API, MAX_HISTORY } from '../../utils/default'; +import { DIMENSION_MARGIN, DISABLE_API, MAX_HISTORY, USE_EXPERIMENTAL_CANVAS_API } from '../../utils/default'; import { AddSymbol, OnPropertyChange as OnSymbolPropertyChange, DeleteSymbol, SelectSymbol } from './Actions/SymbolOperations'; -import { FindContainerById } from '../../utils/itertools'; +import { FindContainerById, MakeRecursionDFSIterator } from '../../utils/itertools'; import { IMenuAction, Menu } from '../Menu/Menu'; import { GetAction } from './Actions/ContextMenuActions'; import { AddContainerToSelectedContainer, AddContainer } from './Actions/AddContainer'; +import { Canvas } from '../Canvas/Canvas'; +import { BAR_WIDTH } from '../Bar/Bar'; +import { IPoint } from '../../Interfaces/IPoint'; +import { AddDimensions } from '../Canvas/DimensionLayer'; +import { RenderSelector } from '../Canvas/Selector'; interface IEditorProps { root: Element | Document @@ -228,6 +233,55 @@ export function Editor(props: IEditorProps): JSX.Element { const configuration = props.configuration; const current = GetCurrentHistoryState(history, historyCurrentStep); const selected = FindContainerById(current.mainContainer, current.selectedContainerId); + + function Draw(ctx: CanvasRenderingContext2D, frameCount: number, scale: number, translatePos: IPoint): void { + const topDim = current.mainContainer.properties.y; + const leftDim = current.mainContainer.properties.x; + const rightDim = current.mainContainer.properties.x + current.mainContainer.properties.width; + const bottomDim = current.mainContainer.properties.y + current.mainContainer.properties.height; + + ctx.clearRect(0, 0, ctx.canvas.width, ctx.canvas.height); + ctx.save(); + ctx.translate(translatePos.x, translatePos.y); + ctx.scale(scale, scale); + ctx.fillStyle = '#000000'; + const it = MakeRecursionDFSIterator(current.mainContainer, 0, [0, 0]); + for (const { container, depth, currentTransform } of it) { + const [x, y] = [ + container.properties.x + currentTransform[0], + container.properties.y + currentTransform[1] + ]; + + // Draw container + ctx.strokeStyle = container.properties.style?.stroke ?? '#000000'; + ctx.fillStyle = container.properties.style?.fill ?? '#000000'; + ctx.lineWidth = Number(container.properties.style?.strokeWidth ?? 1); + ctx.globalAlpha = Number(container.properties.style?.fillOpacity ?? 1); + ctx.fillRect(x, y, container.properties.width, container.properties.height); + ctx.globalAlpha = Number(container.properties.style?.strokeOpacity ?? 1); + ctx.strokeRect(x, y, container.properties.width, container.properties.height); + ctx.globalAlpha = 1; + ctx.lineWidth = 1; + ctx.fillStyle = '#000000'; + ctx.strokeStyle = '#000000'; + + // Draw dimensions + const containerLeftDim = leftDim - (DIMENSION_MARGIN * (depth + 1)) / scale; + const containerTopDim = topDim - (DIMENSION_MARGIN * (depth + 1)) / scale; + const containerBottomDim = bottomDim + (DIMENSION_MARGIN * (depth + 1)) / scale; + const containerRightDim = rightDim + (DIMENSION_MARGIN * (depth + 1)) / scale; + const dimMapped = [containerLeftDim, containerBottomDim, containerTopDim, containerRightDim]; + AddDimensions(ctx, container, dimMapped, currentTransform, scale, depth); + + // Draw selector + RenderSelector(ctx, frameCount, { + scale, + selected + }); + } + ctx.restore(); + } + return (
SaveEditorAsSVG()} - loadState={(move) => setHistoryCurrentStep(move)} /> - - {current.mainContainer} - + loadState={(move) => setHistoryCurrentStep(move)} + /> + { + USE_EXPERIMENTAL_CANVAS_API + ? + : + {current.mainContainer} + + } + editorRef.current} actions={menuActions} diff --git a/src/utils/default.ts b/src/utils/default.ts index 2beffc0..6c351b9 100644 --- a/src/utils/default.ts +++ b/src/utils/default.ts @@ -17,6 +17,12 @@ export const FAST_BOOT = false; /** Disable any call to the API (default = false) */ export const DISABLE_API = false; +/** + * Replace the SVG viewer by a canvas + * EXPERIMENTAL: svg export wont work and it won't be possible to insert a custom svg) + */ +export const USE_EXPERIMENTAL_CANVAS_API = false; + /** Enable keyboard shortcuts (default = true) */ export const ENABLE_SHORTCUTS = true;