diff --git a/src/actions/actionDeleteSelected.tsx b/src/actions/actionDeleteSelected.tsx index 5d58c9fb..e3a80917 100644 --- a/src/actions/actionDeleteSelected.tsx +++ b/src/actions/actionDeleteSelected.tsx @@ -4,9 +4,10 @@ import { KEYS } from "../keys"; export const actionDeleteSelected: Action = { name: "deleteSelectedElements", - perform: elements => { + perform: (elements, appState) => { return { elements: deleteSelectedElements(elements), + appState: { ...appState, elementType: "selection", multiElement: null }, }; }, contextItemLabel: "labels.delete", diff --git a/src/actions/actionFinalize.tsx b/src/actions/actionFinalize.tsx new file mode 100644 index 00000000..1a96248a --- /dev/null +++ b/src/actions/actionFinalize.tsx @@ -0,0 +1,27 @@ +import { Action } from "./types"; +import { KEYS } from "../keys"; +import { clearSelection } from "../scene"; + +export const actionFinalize: Action = { + name: "finalize", + perform: (elements, appState) => { + if (window.document.activeElement instanceof HTMLElement) { + window.document.activeElement.blur(); + } + return { + elements: clearSelection(elements), + appState: { + ...appState, + elementType: "selection", + draggingElement: null, + multiElement: null, + }, + }; + }, + keyTest: (event, appState) => + (event.key === KEYS.ESCAPE && + !appState.draggingElement && + appState.multiElement === null) || + ((event.key === KEYS.ESCAPE || event.key === KEYS.ENTER) && + appState.multiElement !== null), +}; diff --git a/src/actions/index.ts b/src/actions/index.ts index 0b60a0fd..79db254f 100644 --- a/src/actions/index.ts +++ b/src/actions/index.ts @@ -23,6 +23,8 @@ export { actionClearCanvas, } from "./actionCanvas"; +export { actionFinalize } from "./actionFinalize"; + export { actionChangeProjectName, actionChangeExportBackground, diff --git a/src/actions/manager.tsx b/src/actions/manager.tsx index 757d47ca..a36af04b 100644 --- a/src/actions/manager.tsx +++ b/src/actions/manager.tsx @@ -34,7 +34,7 @@ export class ActionManager implements ActionsManagerInterface { const data = Object.values(this.actions) .sort((a, b) => (b.keyPriority || 0) - (a.keyPriority || 0)) .filter( - action => action.keyTest && action.keyTest(event, elements, appState), + action => action.keyTest && action.keyTest(event, appState, elements), ); if (data.length === 0) return null; diff --git a/src/actions/types.ts b/src/actions/types.ts index 921709d4..178f9bc1 100644 --- a/src/actions/types.ts +++ b/src/actions/types.ts @@ -29,8 +29,8 @@ export interface Action { keyPriority?: number; keyTest?: ( event: KeyboardEvent, - elements?: readonly ExcalidrawElement[], - appState?: AppState, + appState: AppState, + elements: readonly ExcalidrawElement[], ) => boolean; contextItemLabel?: string; contextMenuOrder?: number; diff --git a/src/appState.ts b/src/appState.ts index 8ca9fabb..e7a828e8 100644 --- a/src/appState.ts +++ b/src/appState.ts @@ -7,6 +7,7 @@ export function getDefaultAppState(): AppState { return { draggingElement: null, resizingElement: null, + multiElement: null, editingElement: null, elementType: "selection", elementLocked: false, @@ -26,3 +27,9 @@ export function getDefaultAppState(): AppState { name: DEFAULT_PROJECT_NAME, }; } + +export function cleanAppStateForExport(appState: AppState) { + return { + viewBackgroundColor: appState.viewBackgroundColor, + }; +} diff --git a/src/element/bounds.ts b/src/element/bounds.ts index d3d259bf..08428379 100644 --- a/src/element/bounds.ts +++ b/src/element/bounds.ts @@ -1,11 +1,16 @@ import { ExcalidrawElement } from "./types"; import { rotate } from "../math"; +import { Drawable } from "roughjs/bin/core"; +import { Point } from "roughjs/bin/geometry"; // If the element is created from right to left, the width is going to be negative // This set of functions retrieves the absolute position of the 4 points. // We can't just always normalize it since we need to remember the fact that an arrow // is pointing left or right. export function getElementAbsoluteCoords(element: ExcalidrawElement) { + if (element.type === "arrow") { + return getArrowAbsoluteBounds(element); + } return [ element.width >= 0 ? element.x : element.x + element.width, // x1 element.height >= 0 ? element.y : element.y + element.height, // y1 @@ -29,11 +34,95 @@ export function getDiamondPoints(element: ExcalidrawElement) { return [topX, topY, rightX, rightY, bottomX, bottomY, leftX, leftY]; } +export function getArrowAbsoluteBounds(element: ExcalidrawElement) { + if (element.points.length < 2 || !element.shape) { + const { minX, minY, maxX, maxY } = element.points.reduce( + (limits, [x, y]) => { + limits.minY = Math.min(limits.minY, y); + limits.minX = Math.min(limits.minX, x); + + limits.maxX = Math.max(limits.maxX, x); + limits.maxY = Math.max(limits.maxY, y); + + return limits; + }, + { minX: Infinity, minY: Infinity, maxX: -Infinity, maxY: -Infinity }, + ); + return [ + minX + element.x, + minY + element.y, + maxX + element.x, + maxY + element.y, + ]; + } + + const shape = element.shape as Drawable[]; + + const ops = shape[1].sets[0].ops; + + let currentP: Point = [0, 0]; + + const { minX, minY, maxX, maxY } = ops.reduce( + (limits, { op, data }) => { + // There are only four operation types: + // move, bcurveTo, lineTo, and curveTo + if (op === "move") { + // change starting point + currentP = data as Point; + // move operation does not draw anything; so, it always + // returns false + } else if (op === "bcurveTo") { + // create points from bezier curve + // bezier curve stores data as a flattened array of three positions + // [x1, y1, x2, y2, x3, y3] + const p1 = [data[0], data[1]] as Point; + const p2 = [data[2], data[3]] as Point; + const p3 = [data[4], data[5]] as Point; + + const p0 = currentP; + currentP = p3; + + const equation = (t: number, idx: number) => + Math.pow(1 - t, 3) * p3[idx] + + 3 * t * Math.pow(1 - t, 2) * p2[idx] + + 3 * Math.pow(t, 2) * (1 - t) * p1[idx] + + p0[idx] * Math.pow(t, 3); + + let t = 0; + while (t <= 1.0) { + const x = equation(t, 0); + const y = equation(t, 1); + + limits.minY = Math.min(limits.minY, y); + limits.minX = Math.min(limits.minX, x); + + limits.maxX = Math.max(limits.maxX, x); + limits.maxY = Math.max(limits.maxY, y); + + t += 0.1; + } + } else if (op === "lineTo") { + // TODO: Implement this + } else if (op === "qcurveTo") { + // TODO: Implement this + } + return limits; + }, + { minX: Infinity, minY: Infinity, maxX: -Infinity, maxY: -Infinity }, + ); + + return [ + minX + element.x, + minY + element.y, + maxX + element.x, + maxY + element.y, + ]; +} + export function getArrowPoints(element: ExcalidrawElement) { - const x1 = 0; - const y1 = 0; - const x2 = element.width; - const y2 = element.height; + const points = element.points; + const [x1, y1] = points.length >= 2 ? points[points.length - 2] : [0, 0]; + const [x2, y2] = points[points.length - 1]; const size = 30; // pixels const distance = Math.hypot(x2 - x1, y2 - y1); @@ -46,7 +135,7 @@ export function getArrowPoints(element: ExcalidrawElement) { const [x3, y3] = rotate(xs, ys, x2, y2, (-angle * Math.PI) / 180); const [x4, y4] = rotate(xs, ys, x2, y2, (angle * Math.PI) / 180); - return [x1, y1, x2, y2, x3, y3, x4, y4]; + return [x2, y2, x3, y3, x4, y4]; } export function getLinePoints(element: ExcalidrawElement) { diff --git a/src/element/collision.ts b/src/element/collision.ts index 3a4990eb..97965f02 100644 --- a/src/element/collision.ts +++ b/src/element/collision.ts @@ -2,11 +2,13 @@ import { distanceBetweenPointAndSegment } from "../math"; import { ExcalidrawElement } from "./types"; import { - getArrowPoints, getDiamondPoints, getElementAbsoluteCoords, getLinePoints, + getArrowAbsoluteBounds, } from "./bounds"; +import { Point } from "roughjs/bin/geometry"; +import { Drawable, OpSet } from "roughjs/bin/core"; function isElementDraggableFromInside(element: ExcalidrawElement): boolean { return element.backgroundColor !== "transparent" || element.isSelected; @@ -145,18 +147,25 @@ export function hitTest( lineThreshold ); } else if (element.type === "arrow") { - let [x1, y1, x2, y2, x3, y3, x4, y4] = getArrowPoints(element); - // The computation is done at the origin, we need to add a translation - x -= element.x; - y -= element.y; + if (!element.shape) { + return false; + } + const shape = element.shape as Drawable[]; + // If shape does not consist of curve and two line segments + // for arrow shape, return false + if (shape.length < 3) return false; + const [x1, y1, x2, y2] = getArrowAbsoluteBounds(element); + if (x < x1 || y < y1 - 10 || x > x2 || y > y2 + 10) return false; + + const relX = x - element.x; + const relY = y - element.y; + + // hit test curve and lien segments for arrow return ( - // \ - distanceBetweenPointAndSegment(x, y, x3, y3, x2, y2) < lineThreshold || - // ----- - distanceBetweenPointAndSegment(x, y, x1, y1, x2, y2) < lineThreshold || - // / - distanceBetweenPointAndSegment(x, y, x4, y4, x2, y2) < lineThreshold + hitTestRoughShape(shape[0].sets, relX, relY) || + hitTestRoughShape(shape[1].sets, relX, relY) || + hitTestRoughShape(shape[2].sets, relX, relY) ); } else if (element.type === "line") { const [x1, y1, x2, y2] = getLinePoints(element); @@ -176,3 +185,82 @@ export function hitTest( throw new Error("Unimplemented type " + element.type); } } + +const pointInBezierEquation = ( + p0: Point, + p1: Point, + p2: Point, + p3: Point, + [mx, my]: Point, +) => { + // B(t) = p0 * (1-t)^3 + 3p1 * t * (1-t)^2 + 3p2 * t^2 * (1-t) + p3 * t^3 + const equation = (t: number, idx: number) => + Math.pow(1 - t, 3) * p3[idx] + + 3 * t * Math.pow(1 - t, 2) * p2[idx] + + 3 * Math.pow(t, 2) * (1 - t) * p1[idx] + + p0[idx] * Math.pow(t, 3); + + const epsilon = 20; + // go through t in increments of 0.01 + let t = 0; + while (t <= 1.0) { + const tx = equation(t, 0); + const ty = equation(t, 1); + + const diff = Math.sqrt(Math.pow(tx - mx, 2) + Math.pow(ty - my, 2)); + + if (diff < epsilon) { + return true; + } + + t += 0.01; + } + + return false; +}; + +const hitTestRoughShape = (opSet: OpSet[], x: number, y: number) => { + // read operations from first opSet + const ops = opSet[0].ops; + + // set start position as (0,0) just in case + // move operation does not exist (unlikely but it is worth safekeeping it) + let currentP: Point = [0, 0]; + + return ops.some(({ op, data }, idx) => { + // There are only four operation types: + // move, bcurveTo, lineTo, and curveTo + if (op === "move") { + // change starting point + currentP = data as Point; + // move operation does not draw anything; so, it always + // returns false + } else if (op === "bcurveTo") { + // create points from bezier curve + // bezier curve stores data as a flattened array of three positions + // [x1, y1, x2, y2, x3, y3] + const p1 = [data[0], data[1]] as Point; + const p2 = [data[2], data[3]] as Point; + const p3 = [data[4], data[5]] as Point; + + const p0 = currentP; + currentP = p3; + + // check if points are on the curve + // cubic bezier curves require four parameters + // the first parameter is the last stored position (p0) + let retVal = pointInBezierEquation(p0, p1, p2, p3, [x, y]); + + // set end point of bezier curve as the new starting point for + // upcoming operations as each operation is based on the last drawn + // position of the previous operation + return retVal; + } else if (op === "lineTo") { + // TODO: Implement this + } else if (op === "qcurveTo") { + // TODO: Implement this + } + + return false; + }); +}; diff --git a/src/element/handlerRectangles.ts b/src/element/handlerRectangles.ts index ab5374ba..d202fd81 100644 --- a/src/element/handlerRectangles.ts +++ b/src/element/handlerRectangles.ts @@ -1,5 +1,6 @@ import { ExcalidrawElement } from "./types"; import { SceneScroll } from "../scene/types"; +import { getArrowAbsoluteBounds } from "./bounds"; type Sides = "n" | "s" | "w" | "e" | "nw" | "ne" | "sw" | "se"; @@ -7,18 +8,31 @@ export function handlerRectangles( element: ExcalidrawElement, { scrollX, scrollY }: SceneScroll, ) { - const elementX1 = element.x; - const elementX2 = element.x + element.width; - const elementY1 = element.y; - const elementY2 = element.y + element.height; + let elementX2 = 0; + let elementY2 = 0; + let elementX1 = Infinity; + let elementY1 = Infinity; + let marginX = -8; + let marginY = -8; + + let minimumSize = 40; + if (element.type === "arrow") { + [elementX1, elementY1, elementX2, elementY2] = getArrowAbsoluteBounds( + element, + ); + } else { + elementX1 = element.x; + elementX2 = element.x + element.width; + elementY1 = element.y; + elementY2 = element.y + element.height; + + marginX = element.width < 0 ? 8 : -8; + marginY = element.height < 0 ? 8 : -8; + } const margin = 4; - const minimumSize = 40; const handlers = {} as { [T in Sides]: number[] }; - const marginX = element.width < 0 ? 8 : -8; - const marginY = element.height < 0 ? 8 : -8; - if (Math.abs(elementX2 - elementX1) > minimumSize) { handlers["n"] = [ elementX1 + (elementX2 - elementX1) / 2 + scrollX - 4, @@ -76,11 +90,58 @@ export function handlerRectangles( 8, ]; // se - if (element.type === "arrow" || element.type === "line") { + if (element.type === "line") { return { nw: handlers.nw, se: handlers.se, } as typeof handlers; + } else if (element.type === "arrow") { + if (element.points.length === 2) { + // only check the last point because starting point is always (0,0) + const [, p1] = element.points; + + if (p1[0] === 0 || p1[1] === 0) { + return { + nw: handlers.nw, + se: handlers.se, + } as typeof handlers; + } + + if (p1[0] > 0 && p1[1] < 0) { + return { + ne: handlers.ne, + sw: handlers.sw, + } as typeof handlers; + } + + if (p1[0] > 0 && p1[1] > 0) { + return { + nw: handlers.nw, + se: handlers.se, + } as typeof handlers; + } + + if (p1[0] < 0 && p1[1] > 0) { + return { + ne: handlers.ne, + sw: handlers.sw, + } as typeof handlers; + } + + if (p1[0] < 0 && p1[1] < 0) { + return { + nw: handlers.nw, + se: handlers.se, + } as typeof handlers; + } + } + + return { + n: handlers.n, + s: handlers.s, + w: handlers.w, + e: handlers.e, + } as typeof handlers; } return handlers; diff --git a/src/element/index.ts b/src/element/index.ts index 62ed2125..6a68a727 100644 --- a/src/element/index.ts +++ b/src/element/index.ts @@ -5,6 +5,7 @@ export { getDiamondPoints, getArrowPoints, getLinePoints, + getArrowAbsoluteBounds, } from "./bounds"; export { handlerRectangles } from "./handlerRectangles"; diff --git a/src/element/newElement.ts b/src/element/newElement.ts index f5ce32e6..812afb4c 100644 --- a/src/element/newElement.ts +++ b/src/element/newElement.ts @@ -1,6 +1,7 @@ import { randomSeed } from "roughjs/bin/math"; import nanoid from "nanoid"; import { Drawable } from "roughjs/bin/core"; +import { Point } from "roughjs/bin/geometry"; import { ExcalidrawElement, ExcalidrawTextElement } from "../element/types"; import { measureText } from "../utils"; @@ -34,6 +35,7 @@ export function newElement( isSelected: false, seed: randomSeed(), shape: null as Drawable | Drawable[] | null, + points: [] as Point[], }; return element; } diff --git a/src/element/resizeTest.ts b/src/element/resizeTest.ts index 0e8c98a2..532b9b94 100644 --- a/src/element/resizeTest.ts +++ b/src/element/resizeTest.ts @@ -17,6 +17,7 @@ export function resizeTest( const filter = Object.keys(handlers).filter(key => { const handler = handlers[key as HandlerRectanglesRet]!; + if (!handler) return false; return ( x + scrollX >= handler[0] && diff --git a/src/index.tsx b/src/index.tsx index 0806f01b..d1147bd9 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -42,7 +42,13 @@ import { renderScene } from "./renderer"; import { AppState } from "./types"; import { ExcalidrawElement } from "./element/types"; -import { isInputLike, debounce, capitalizeString, distance } from "./utils"; +import { + isInputLike, + debounce, + capitalizeString, + distance, + distance2d, +} from "./utils"; import { KEYS, isArrowKey } from "./keys"; import { findShapeByKey, shapesShortcutKeys, SHAPES } from "./shapes"; @@ -76,6 +82,7 @@ import { actionSaveScene, actionCopyStyles, actionPasteStyles, + actionFinalize, } from "./actions"; import { Action, ActionResult } from "./actions/types"; import { getDefaultAppState } from "./appState"; @@ -88,6 +95,7 @@ import { ExportDialog } from "./components/ExportDialog"; import { withTranslation } from "react-i18next"; import { LanguageList } from "./components/LanguageList"; import i18n, { languages, parseDetectedLang } from "./i18n"; +import { Point } from "roughjs/bin/geometry"; import { StoredScenesList } from "./components/StoredScenesList"; let { elements } = createScene(); @@ -109,6 +117,7 @@ function setCursorForShape(shape: string) { } } +const DRAGGING_THRESHOLD = 10; // 10px const ELEMENT_SHIFT_TRANSLATE_AMOUNT = 5; const ELEMENT_TRANSLATE_AMOUNT = 1; const TEXT_TO_CENTER_SNAP_THRESHOLD = 30; @@ -168,6 +177,7 @@ export class App extends React.Component { canvasOnlyActions: Array; constructor(props: any) { super(props); + this.actionManager.registerAction(actionFinalize); this.actionManager.registerAction(actionDeleteSelected); this.actionManager.registerAction(actionSendToBack); this.actionManager.registerAction(actionBringToFront); @@ -328,17 +338,7 @@ export class App extends React.Component { }; private onKeyDown = (event: KeyboardEvent) => { - if (event.key === KEYS.ESCAPE && !this.state.draggingElement) { - elements = clearSelection(elements); - this.setState({}); - this.setState({ elementType: "selection" }); - if (window.document.activeElement instanceof HTMLElement) { - window.document.activeElement.blur(); - } - event.preventDefault(); - return; - } - if (isInputLike(event.target)) return; + if (isInputLike(event.target) && event.key !== KEYS.ESCAPE) return; const actionResult = this.actionManager.handleKeyDown( event, @@ -387,19 +387,27 @@ export class App extends React.Component { } else if (event[KEYS.META] && event.code === "KeyZ") { event.preventDefault(); + if ( + this.state.resizingElement || + this.state.multiElement || + this.state.editingElement + ) { + return; + } + if (event.shiftKey) { // Redo action const data = history.redoOnce(); if (data !== null) { elements = data.elements; - this.setState(data.appState); + this.setState({ ...data.appState }); } } else { // undo action const data = history.undoOnce(); if (data !== null) { elements = data.elements; - this.setState(data.appState); + this.setState({ ...data.appState }); } } } else if (event.key === KEYS.SPACE && !isHoldingMouseButton) { @@ -570,7 +578,7 @@ export class App extends React.Component { aria-label={capitalizeString(label)} aria-keyshortcuts={`${label[0]} ${index + 1}`} onChange={() => { - this.setState({ elementType: value }); + this.setState({ elementType: value, multiElement: null }); elements = clearSelection(elements); document.documentElement.style.cursor = value === "text" ? CURSOR_TYPE.TEXT : CURSOR_TYPE.CROSSHAIR; @@ -1036,11 +1044,28 @@ export class App extends React.Component { editingElement: element, }); return; + } else if (this.state.elementType === "arrow") { + if (this.state.multiElement) { + const { multiElement } = this.state; + const { x: rx, y: ry } = multiElement; + multiElement.isSelected = true; + multiElement.points.push([x - rx, y - ry]); + multiElement.shape = null; + this.setState({ draggingElement: multiElement }); + } else { + element.isSelected = false; + element.points.push([0, 0]); + element.shape = null; + elements = [...elements, element]; + this.setState({ + draggingElement: element, + }); + } + } else { + elements = [...elements, element]; + this.setState({ multiElement: null, draggingElement: element }); } - elements = [...elements, element]; - this.setState({ draggingElement: element }); - let lastX = x; let lastY = y; @@ -1049,6 +1074,75 @@ export class App extends React.Component { lastY = e.clientY - CANVAS_WINDOW_OFFSET_TOP; } + let resizeArrowFn: + | (( + element: ExcalidrawElement, + p1: Point, + deltaX: number, + deltaY: number, + mouseX: number, + mouseY: number, + perfect: boolean, + ) => void) + | null = null; + + const arrowResizeOrigin = ( + element: ExcalidrawElement, + p1: Point, + deltaX: number, + deltaY: number, + mouseX: number, + mouseY: number, + perfect: boolean, + ) => { + // TODO: Implement perfect sizing for origin + if (perfect) { + const absPx = p1[0] + element.x; + const absPy = p1[1] + element.y; + + let { width, height } = getPerfectElementSize( + "arrow", + mouseX - element.x - p1[0], + mouseY - element.y - p1[1], + ); + + const dx = element.x + width + p1[0]; + const dy = element.y + height + p1[1]; + element.x = dx; + element.y = dy; + p1[0] = absPx - element.x; + p1[1] = absPy - element.y; + } else { + element.x += deltaX; + element.y += deltaY; + p1[0] -= deltaX; + p1[1] -= deltaY; + } + }; + + const arrowResizeEnd = ( + element: ExcalidrawElement, + p1: Point, + deltaX: number, + deltaY: number, + mouseX: number, + mouseY: number, + perfect: boolean, + ) => { + if (perfect) { + const { width, height } = getPerfectElementSize( + "arrow", + mouseX - element.x, + mouseY - element.y, + ); + p1[0] = width; + p1[1] = height; + } else { + p1[0] += deltaX; + p1[1] += deltaY; + } + }; + const onMouseMove = (e: MouseEvent) => { const target = e.target; if (!(target instanceof HTMLElement)) { @@ -1075,6 +1169,16 @@ export class App extends React.Component { return; } + // for arrows, don't start dragging until a given threshold + // to ensure we don't create a 2-point arrow by mistake when + // user clicks mouse in a way that it moves a tiny bit (thus + // triggering mousemove) + if (!draggingOccurred && this.state.elementType === "arrow") { + const { x, y } = viewportCoordsToSceneCoords(e, this.state); + if (distance2d(x, y, originX, originY) < DRAGGING_THRESHOLD) + return; + } + if (isResizingElements && this.state.resizingElement) { const el = this.state.resizingElement; const selectedElements = elements.filter(el => el.isSelected); @@ -1087,73 +1191,217 @@ export class App extends React.Component { element.type === "line" || element.type === "arrow"; switch (resizeHandle) { case "nw": - element.width -= deltaX; - element.x += deltaX; + if ( + element.type === "arrow" && + element.points.length === 2 + ) { + const [, p1] = element.points; - if (e.shiftKey) { - if (isLinear) { - resizePerfectLineForNWHandler(element, x, y); - } else { - element.y += element.height - element.width; - element.height = element.width; + if (!resizeArrowFn) { + if (p1[0] < 0 || p1[1] < 0) { + resizeArrowFn = arrowResizeEnd; + } else { + resizeArrowFn = arrowResizeOrigin; + } } + resizeArrowFn( + element, + p1, + deltaX, + deltaY, + x, + y, + e.shiftKey, + ); } else { - element.height -= deltaY; - element.y += deltaY; + element.width -= deltaX; + element.x += deltaX; + + if (e.shiftKey) { + if (isLinear) { + resizePerfectLineForNWHandler(element, x, y); + } else { + element.y += element.height - element.width; + element.height = element.width; + } + } else { + element.height -= deltaY; + element.y += deltaY; + } } break; case "ne": - element.width += deltaX; - if (e.shiftKey) { - element.y += element.height - element.width; - element.height = element.width; + if ( + element.type === "arrow" && + element.points.length === 2 + ) { + const [, p1] = element.points; + if (!resizeArrowFn) { + if (p1[0] >= 0) { + resizeArrowFn = arrowResizeEnd; + } else { + resizeArrowFn = arrowResizeOrigin; + } + } + resizeArrowFn( + element, + p1, + deltaX, + deltaY, + x, + y, + e.shiftKey, + ); } else { - element.height -= deltaY; - element.y += deltaY; + element.width += deltaX; + if (e.shiftKey) { + element.y += element.height - element.width; + element.height = element.width; + } else { + element.height -= deltaY; + element.y += deltaY; + } } break; case "sw": - element.width -= deltaX; - element.x += deltaX; - if (e.shiftKey) { - element.height = element.width; + if ( + element.type === "arrow" && + element.points.length === 2 + ) { + const [, p1] = element.points; + if (!resizeArrowFn) { + if (p1[0] <= 0) { + resizeArrowFn = arrowResizeEnd; + } else { + resizeArrowFn = arrowResizeOrigin; + } + } + resizeArrowFn( + element, + p1, + deltaX, + deltaY, + x, + y, + e.shiftKey, + ); } else { - element.height += deltaY; + element.width -= deltaX; + element.x += deltaX; + if (e.shiftKey) { + element.height = element.width; + } else { + element.height += deltaY; + } } break; case "se": - if (e.shiftKey) { - if (isLinear) { - const { width, height } = getPerfectElementSize( - element.type, - x - element.x, - y - element.y, - ); - element.width = width; - element.height = height; + if ( + element.type === "arrow" && + element.points.length === 2 + ) { + const [, p1] = element.points; + if (!resizeArrowFn) { + if (p1[0] > 0 || p1[1] > 0) { + resizeArrowFn = arrowResizeEnd; + } else { + resizeArrowFn = arrowResizeOrigin; + } + } + resizeArrowFn( + element, + p1, + deltaX, + deltaY, + x, + y, + e.shiftKey, + ); + } else { + if (e.shiftKey) { + if (isLinear) { + const { width, height } = getPerfectElementSize( + element.type, + x - element.x, + y - element.y, + ); + element.width = width; + element.height = height; + } else { + element.width += deltaX; + element.height = element.width; + } } else { element.width += deltaX; - element.height = element.width; + element.height += deltaY; } - } else { - element.width += deltaX; - element.height += deltaY; } break; - case "n": + case "n": { element.height -= deltaY; element.y += deltaY; + + if (element.points.length > 0) { + const len = element.points.length; + + const points = [...element.points].sort( + (a, b) => a[1] - b[1], + ); + + for (let i = 1; i < points.length; ++i) { + const pnt = points[i]; + pnt[1] -= deltaY / (len - i); + } + } break; - case "w": + } + case "w": { element.width -= deltaX; element.x += deltaX; + + if (element.points.length > 0) { + const len = element.points.length; + const points = [...element.points].sort( + (a, b) => a[0] - b[0], + ); + + for (let i = 0; i < points.length; ++i) { + const pnt = points[i]; + pnt[0] -= deltaX / (len - i); + } + } break; - case "s": + } + case "s": { element.height += deltaY; + if (element.points.length > 0) { + const len = element.points.length; + const points = [...element.points].sort( + (a, b) => a[1] - b[1], + ); + + for (let i = 1; i < points.length; ++i) { + const pnt = points[i]; + pnt[1] += deltaY / (len - i); + } + } break; - case "e": + } + case "e": { element.width += deltaX; + if (element.points.length > 0) { + const len = element.points.length; + const points = [...element.points].sort( + (a, b) => a[0] - b[0], + ); + + for (let i = 1; i < points.length; ++i) { + const pnt = points[i]; + pnt[0] += deltaX / (len - i); + } + } break; + } } if (resizeHandle) { @@ -1235,6 +1483,30 @@ export class App extends React.Component { draggingElement.width = width; draggingElement.height = height; + + if (this.state.elementType === "arrow") { + draggingOccurred = true; + const points = draggingElement.points; + let dx = x - draggingElement.x; + let dy = y - draggingElement.y; + + if (e.shiftKey && points.length === 2) { + ({ width: dx, height: dy } = getPerfectElementSize( + this.state.elementType, + dx, + dy, + )); + } + + if (points.length === 1) { + points.push([dx, dy]); + } else if (points.length > 1) { + const pnt = points[points.length - 1]; + pnt[0] = dx; + pnt[1] = dy; + } + } + draggingElement.shape = null; if (this.state.elementType === "selection") { @@ -1258,15 +1530,33 @@ export class App extends React.Component { const { draggingElement, resizingElement, + multiElement, elementType, elementLocked, } = this.state; + resizeArrowFn = null; lastMouseUp = null; isHoldingMouseButton = false; window.removeEventListener("mousemove", onMouseMove); window.removeEventListener("mouseup", onMouseUp); + if (elementType === "arrow") { + if (draggingElement!.points.length > 1) { + history.resumeRecording(); + } + if (!draggingOccurred && !multiElement) { + this.setState({ multiElement: this.state.draggingElement }); + } else if (draggingOccurred && !multiElement) { + this.state.draggingElement!.isSelected = true; + this.setState({ + draggingElement: null, + elementType: "selection", + }); + } + return; + } + if ( elementType !== "selection" && draggingElement && @@ -1351,9 +1641,15 @@ export class App extends React.Component { window.addEventListener("mousemove", onMouseMove); window.addEventListener("mouseup", onMouseUp); - // We don't want to save history on mouseDown, only on mouseUp when it's fully configured - history.skipRecording(); - this.setState({}); + if ( + !this.state.multiElement || + (this.state.multiElement && + this.state.multiElement.points.length < 2) + ) { + // We don't want to save history on mouseDown, only on mouseUp when it's fully configured + history.skipRecording(); + this.setState({}); + } }} onDoubleClick={e => { const { x, y } = viewportCoordsToSceneCoords(e, this.state); diff --git a/src/math.ts b/src/math.ts index bbcbe17d..fae4d950 100644 --- a/src/math.ts +++ b/src/math.ts @@ -1,3 +1,5 @@ +import { Point } from "roughjs/bin/geometry"; + // https://stackoverflow.com/a/6853926/232122 export function distanceBetweenPointAndSegment( x: number, @@ -52,3 +54,66 @@ export function rotate( (x1 - x2) * Math.sin(angle) + (y1 - y2) * Math.cos(angle) + y2, ]; } + +export const getPointOnAPath = (point: Point, path: Point[]) => { + const [px, py] = point; + const [start, ...other] = path; + let [lastX, lastY] = start; + let kLine: number = 0; + let idx: number = 0; + + // if any item in the array is true, it means that a point is + // on some segment of a line based path + const retVal = other.some(([x2, y2], i) => { + // we always take a line when dealing with line segments + const x1 = lastX; + const y1 = lastY; + + lastX = x2; + lastY = y2; + + // if a point is not within the domain of the line segment + // it is not on the line segment + if (px < x1 || px > x2) { + return false; + } + + // check if all points lie on the same line + // y1 = kx1 + b, y2 = kx2 + b + // y2 - y1 = k(x2 - x2) -> k = (y2 - y1) / (x2 - x1) + + // coefficient for the line (p0, p1) + const kL = (y2 - y1) / (x2 - x1); + + // coefficient for the line segment (p0, point) + const kP1 = (py - y1) / (px - x1); + + // coefficient for the line segment (point, p1) + const kP2 = (py - y2) / (px - x2); + + // because we are basing both lines from the same starting point + // the only option for collinearity is having same coefficients + + // using it for floating point comparisons + const epsilon = 0.3; + + // if coefficient is more than an arbitrary epsilon, + // these lines are nor collinear + if (Math.abs(kP1 - kL) > epsilon && Math.abs(kP2 - kL) > epsilon) { + return false; + } + + // store the coefficient because we are goint to need it + kLine = kL; + idx = i; + + return true; + }); + + // Return a coordinate that is always on the line segment + if (retVal === true) { + return { x: point[0], y: kLine * point[0], segment: idx }; + } + + return null; +}; diff --git a/src/renderer/renderElement.ts b/src/renderer/renderElement.ts index cbe33110..0e9d175a 100644 --- a/src/renderer/renderElement.ts +++ b/src/renderer/renderElement.ts @@ -7,6 +7,7 @@ import { } from "../element/bounds"; import { RoughCanvas } from "roughjs/bin/canvas"; import { Drawable } from "roughjs/bin/core"; +import { Point } from "roughjs/bin/geometry"; import { RoughSVG } from "roughjs/bin/svg"; import { RoughGenerator } from "roughjs/bin/generator"; import { SVG_NS } from "../utils"; @@ -89,18 +90,23 @@ function generateElement( ); break; case "arrow": { - const [x1, y1, x2, y2, x3, y3, x4, y4] = getArrowPoints(element); + const [x2, y2, x3, y3, x4, y4] = getArrowPoints(element); const options = { stroke: element.strokeColor, strokeWidth: element.strokeWidth, roughness: element.roughness, seed: element.seed, }; + // points array can be empty in the beginning, so it is important to add + // initial position to it + const points: Point[] = element.points.length + ? element.points + : [[0, 0]]; element.shape = [ // \ generator.line(x3, y3, x2, y2, options), // ----- - generator.line(x1, y1, x2, y2, options), + generator.curve(points, options), // / generator.line(x4, y4, x2, y2, options), ]; @@ -169,7 +175,6 @@ export function renderElement( context.fillStyle = fillStyle; context.font = font; context.globalAlpha = 1; - break; } else { throw new Error("Unimplemented type " + element.type); } diff --git a/src/renderer/renderScene.ts b/src/renderer/renderScene.ts index 59d0a36d..80b2daf0 100644 --- a/src/renderer/renderScene.ts +++ b/src/renderer/renderScene.ts @@ -76,10 +76,7 @@ export function renderScene( element.y + sceneState.scrollY, ); renderElement(element, rc, context); - context.translate( - -element.x - sceneState.scrollX, - -element.y - sceneState.scrollY, - ); + context.resetTransform(); }); if (renderSelection) { @@ -107,9 +104,11 @@ export function renderScene( if (selectedElements.length === 1 && selectedElements[0].type !== "text") { const handlers = handlerRectangles(selectedElements[0], sceneState); - Object.values(handlers).forEach(handler => { - context.strokeRect(handler[0], handler[1], handler[2], handler[3]); - }); + Object.values(handlers) + .filter(handler => handler !== undefined) + .forEach(handler => { + context.strokeRect(handler[0], handler[1], handler[2], handler[3]); + }); } } @@ -149,11 +148,20 @@ function isVisibleElement( canvasHeight: number, ) { let [x1, y1, x2, y2] = getElementAbsoluteCoords(element); - x1 += scrollX; - y1 += scrollY; - x2 += scrollX; - y2 += scrollY; - return x2 >= 0 && x1 <= canvasWidth && y2 >= 0 && y1 <= canvasHeight; + if (element.type !== "arrow") { + x1 += scrollX; + y1 += scrollY; + x2 += scrollX; + y2 += scrollY; + return x2 >= 0 && x1 <= canvasWidth && y2 >= 0 && y1 <= canvasHeight; + } else { + return ( + x2 + scrollX >= 0 && + x1 + scrollX <= canvasWidth && + y2 + scrollY >= 0 && + y1 + scrollY <= canvasHeight + ); + } } // This should be only called for exporting purposes diff --git a/src/scene/data.ts b/src/scene/data.ts index f9481b46..2c380c7c 100644 --- a/src/scene/data.ts +++ b/src/scene/data.ts @@ -1,6 +1,6 @@ import { ExcalidrawElement } from "../element/types"; -import { getDefaultAppState } from "../appState"; +import { getDefaultAppState, cleanAppStateForExport } from "../appState"; import { AppState } from "../types"; import { ExportType, PreviousScene } from "./types"; @@ -24,7 +24,7 @@ const BACKEND_GET = "https://json.excalidraw.com/api/v1/"; interface DataState { elements: readonly ExcalidrawElement[]; - appState: AppState; + appState: AppState | null; selectedId?: number; } @@ -36,10 +36,9 @@ export function serializeAsJSON( { type: "excalidraw", version: 1, - appState: { - viewBackgroundColor: appState.viewBackgroundColor, - }, + source: window.location.origin, elements: elements.map(({ shape, isSelected, ...el }) => el), + appState: cleanAppStateForExport(appState), }, null, 2, @@ -255,7 +254,7 @@ export async function exportCanvas( function restore( savedElements: readonly ExcalidrawElement[], - savedState: AppState, + savedState: AppState | null, ): DataState { return { elements: savedElements.map(element => ({ @@ -291,7 +290,7 @@ export function restoreFromLocalStorage() { let appState = null; if (savedState) { try { - appState = JSON.parse(savedState); + appState = JSON.parse(savedState) as AppState; } catch (e) { // Do nothing because appState is already null } diff --git a/src/types.ts b/src/types.ts index 284c1d87..f85d2ebd 100644 --- a/src/types.ts +++ b/src/types.ts @@ -3,6 +3,7 @@ import { ExcalidrawElement } from "./element/types"; export type AppState = { draggingElement: ExcalidrawElement | null; resizingElement: ExcalidrawElement | null; + multiElement: ExcalidrawElement | null; // element being edited, but not necessarily added to elements array yet // (e.g. text element when typing into the input) editingElement: ExcalidrawElement | null; diff --git a/src/utils.ts b/src/utils.ts index ff7ea588..5e667e6a 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -103,3 +103,9 @@ export function removeSelection() { export function distance(x: number, y: number) { return Math.abs(x - y); } + +export function distance2d(x1: number, y1: number, x2: number, y2: number) { + const xd = x2 - x1; + const yd = y2 - y1; + return Math.sqrt(xd * xd + yd * yd); +}