From 53ab46126d2b62a7984484f1cf73d05c535dd24b Mon Sep 17 00:00:00 2001 From: Daishi Kato Date: Mon, 8 Jun 2020 18:25:20 +0900 Subject: [PATCH] support resizing multiple elements including texts (#1726) Co-authored-by: David Luzar --- src/components/App.tsx | 55 ++++---- src/element/index.ts | 1 - src/element/resizeElements.ts | 234 ++++++++++++++++++---------------- src/renderer/renderScene.ts | 97 +++++++------- 4 files changed, 192 insertions(+), 195 deletions(-) diff --git a/src/components/App.tsx b/src/components/App.tsx index a57b59e1..00c8a444 100644 --- a/src/components/App.tsx +++ b/src/components/App.tsx @@ -23,7 +23,6 @@ import { newLinearElement, resizeElements, getElementWithResizeHandler, - canResizeMutlipleElements, getResizeOffsetXY, getResizeArrowDirection, getResizeHandlerFromCoords, @@ -1771,20 +1770,18 @@ class App extends React.Component { return; } } else if (selectedElements.length > 1 && !isOverScrollBar) { - if (canResizeMutlipleElements(selectedElements)) { - const resizeHandle = getResizeHandlerFromCoords( - getCommonBounds(selectedElements), - scenePointerX, - scenePointerY, - this.state.zoom, - event.pointerType, - ); - if (resizeHandle) { - document.documentElement.style.cursor = getCursorForResizingElement({ - resizeHandle, - }); - return; - } + const resizeHandle = getResizeHandlerFromCoords( + getCommonBounds(selectedElements), + scenePointerX, + scenePointerY, + this.state.zoom, + event.pointerType, + ); + if (resizeHandle) { + document.documentElement.style.cursor = getCursorForResizingElement({ + resizeHandle, + }); + return; } } const hitElement = getElementAtPosition( @@ -2054,22 +2051,18 @@ class App extends React.Component { isResizingElements = true; } } else if (selectedElements.length > 1) { - if (canResizeMutlipleElements(selectedElements)) { - resizeHandle = getResizeHandlerFromCoords( - getCommonBounds(selectedElements), - x, - y, - this.state.zoom, - event.pointerType, - ); - if (resizeHandle) { - document.documentElement.style.cursor = getCursorForResizingElement( - { - resizeHandle, - }, - ); - isResizingElements = true; - } + resizeHandle = getResizeHandlerFromCoords( + getCommonBounds(selectedElements), + x, + y, + this.state.zoom, + event.pointerType, + ); + if (resizeHandle) { + document.documentElement.style.cursor = getCursorForResizingElement({ + resizeHandle, + }); + isResizingElements = true; } } if (isResizingElements) { diff --git a/src/element/index.ts b/src/element/index.ts index ff4e235d..23379b3d 100644 --- a/src/element/index.ts +++ b/src/element/index.ts @@ -35,7 +35,6 @@ export { } from "./resizeTest"; export { resizeElements, - canResizeMutlipleElements, getResizeOffsetXY, getResizeArrowDirection, } from "./resizeElements"; diff --git a/src/element/resizeElements.ts b/src/element/resizeElements.ts index 5b0199ef..f7e38673 100644 --- a/src/element/resizeElements.ts +++ b/src/element/resizeElements.ts @@ -204,6 +204,37 @@ const rescalePointsInElement = ( } : {}; +// This is not computationally ideal, but can't be helped. +const measureFontSizeFromWH = ( + element: NonDeleted, + nextWidth: number, + nextHeight: number, +): { size: number; baseline: number } | null => { + let scale = Math.min(nextWidth / element.width, nextHeight / element.height); + let nextFontSize = element.fontSize * scale; + let metrics = measureText( + element.text, + getFontString({ fontSize: nextFontSize, fontFamily: element.fontFamily }), + ); + if (metrics.width - nextWidth < 1 && metrics.height - nextHeight < 1) { + return { size: nextFontSize, baseline: metrics.baseline }; + } + // second measurement + scale = Math.min( + Math.min(nextWidth, metrics.width) / element.width, + Math.min(nextHeight, metrics.height) / element.height, + ); + nextFontSize = element.fontSize * scale; + metrics = measureText( + element.text, + getFontString({ fontSize: nextFontSize, fontFamily: element.fontFamily }), + ); + if (metrics.width - nextWidth < 1 && metrics.height - nextHeight < 1) { + return { size: nextFontSize, baseline: metrics.baseline }; + } + return null; +}; + const resizeSingleTextElement = ( element: NonDeleted, resizeHandle: "nw" | "ne" | "sw" | "se", @@ -250,22 +281,16 @@ const resizeSingleTextElement = ( break; } if (scale > 0) { - const newFontSize = Math.max(element.fontSize * scale, 10); - const metrics = measureText( - element.text, - getFontString({ fontSize: newFontSize, fontFamily: element.fontFamily }), - ); - if ( - Math.abs(metrics.width - element.width) <= 1 || - Math.abs(metrics.height - element.height) <= 1 - ) { - // we ignore 1px change to avoid janky behavior + const nextWidth = element.width * scale; + const nextHeight = element.height * scale; + const nextFont = measureFontSizeFromWH(element, nextWidth, nextHeight); + if (nextFont === null) { return; } const [nextX1, nextY1, nextX2, nextY2] = getResizedElementAbsoluteCoords( element, - metrics.width, - metrics.height, + nextWidth, + nextHeight, ); const deltaX1 = (x1 - nextX1) / 2; const deltaY1 = (y1 - nextY1) / 2; @@ -283,10 +308,10 @@ const resizeSingleTextElement = ( isResizeFromCenter, ); mutateElement(element, { - fontSize: newFontSize, - width: metrics.width, - height: metrics.height, - baseline: metrics.baseline, + fontSize: nextFont.size, + width: nextWidth, + height: nextHeight, + baseline: nextFont.baseline, x: nextElementX, y: nextElementY, }); @@ -398,124 +423,107 @@ const resizeMultipleElements = ( pointerY: number, ) => { const [x1, y1, x2, y2] = getCommonBounds(elements); + let scale: number; + let getNextXY: ( + element: NonDeletedExcalidrawElement, + origCoords: readonly [number, number, number, number], + finalCoords: readonly [number, number, number, number], + ) => { x: number; y: number }; switch (resizeHandle) { - case "se": { - const scale = Math.max( + case "se": + scale = Math.max( (pointerX - x1) / (x2 - x1), (pointerY - y1) / (y2 - y1), ); - if (scale > 0) { - elements.forEach((element) => { - const width = element.width * scale; - const height = element.height * scale; - const [origX1, origY1] = getElementAbsoluteCoords(element); - const rescaledPoints = rescalePointsInElement(element, width, height); - const [finalX1, finalY1] = getResizedElementAbsoluteCoords( - { - ...element, - ...rescaledPoints, - }, - width, - height, - ); - const x = element.x + (origX1 - x1) * (scale - 1) + origX1 - finalX1; - const y = element.y + (origY1 - y1) * (scale - 1) + origY1 - finalY1; - mutateElement(element, { width, height, x, y, ...rescaledPoints }); - }); - } + getNextXY = (element, [origX1, origY1], [finalX1, finalY1]) => { + const x = element.x + (origX1 - x1) * (scale - 1) + origX1 - finalX1; + const y = element.y + (origY1 - y1) * (scale - 1) + origY1 - finalY1; + return { x, y }; + }; break; - } - case "nw": { - const scale = Math.max( + case "nw": + scale = Math.max( (x2 - pointerX) / (x2 - x1), (y2 - pointerY) / (y2 - y1), ); - if (scale > 0) { - elements.forEach((element) => { - const width = element.width * scale; - const height = element.height * scale; - const [, , origX2, origY2] = getElementAbsoluteCoords(element); - const rescaledPoints = rescalePointsInElement(element, width, height); - const [, , finalX2, finalY2] = getResizedElementAbsoluteCoords( - { - ...element, - ...rescaledPoints, - }, - width, - height, - ); - const x = element.x - (x2 - origX2) * (scale - 1) + origX2 - finalX2; - const y = element.y - (y2 - origY2) * (scale - 1) + origY2 - finalY2; - mutateElement(element, { width, height, x, y, ...rescaledPoints }); - }); - } + getNextXY = (element, [, , origX2, origY2], [, , finalX2, finalY2]) => { + const x = element.x - (x2 - origX2) * (scale - 1) + origX2 - finalX2; + const y = element.y - (y2 - origY2) * (scale - 1) + origY2 - finalY2; + return { x, y }; + }; break; - } - case "ne": { - const scale = Math.max( + case "ne": + scale = Math.max( (pointerX - x1) / (x2 - x1), (y2 - pointerY) / (y2 - y1), ); - if (scale > 0) { - elements.forEach((element) => { - const width = element.width * scale; - const height = element.height * scale; - const [origX1, , , origY2] = getElementAbsoluteCoords(element); - const rescaledPoints = rescalePointsInElement(element, width, height); - const [finalX1, , , finalY2] = getResizedElementAbsoluteCoords( - { - ...element, - ...rescaledPoints, - }, - width, - height, - ); - const x = element.x + (origX1 - x1) * (scale - 1) + origX1 - finalX1; - const y = element.y - (y2 - origY2) * (scale - 1) + origY2 - finalY2; - mutateElement(element, { width, height, x, y, ...rescaledPoints }); - }); - } + getNextXY = (element, [origX1, , , origY2], [finalX1, , , finalY2]) => { + const x = element.x + (origX1 - x1) * (scale - 1) + origX1 - finalX1; + const y = element.y - (y2 - origY2) * (scale - 1) + origY2 - finalY2; + return { x, y }; + }; break; - } - case "sw": { - const scale = Math.max( + case "sw": + scale = Math.max( (x2 - pointerX) / (x2 - x1), (pointerY - y1) / (y2 - y1), ); - if (scale > 0) { - elements.forEach((element) => { - const width = element.width * scale; - const height = element.height * scale; - const [, origY1, origX2] = getElementAbsoluteCoords(element); - const rescaledPoints = rescalePointsInElement(element, width, height); - const [, finalY1, finalX2] = getResizedElementAbsoluteCoords( - { - ...element, - ...rescaledPoints, - }, - width, - height, - ); - const x = element.x - (x2 - origX2) * (scale - 1) + origX2 - finalX2; - const y = element.y + (origY1 - y1) * (scale - 1) + origY1 - finalY1; - mutateElement(element, { width, height, x, y, ...rescaledPoints }); - }); - } + getNextXY = (element, [, origY1, origX2], [, finalY1, finalX2]) => { + const x = element.x - (x2 - origX2) * (scale - 1) + origX2 - finalX2; + const y = element.y + (origY1 - y1) * (scale - 1) + origY1 - finalY1; + return { x, y }; + }; break; + } + if (scale > 0) { + const updates = elements.reduce( + (prev, element) => { + if (!prev) { + return prev; + } + const width = element.width * scale; + const height = element.height * scale; + let font: { fontSize?: number; baseline?: number } = {}; + if (element.type === "text") { + const nextFont = measureFontSizeFromWH(element, width, height); + if (nextFont === null) { + return null; + } + font = { fontSize: nextFont.size, baseline: nextFont.baseline }; + } + const origCoords = getElementAbsoluteCoords(element); + const rescaledPoints = rescalePointsInElement(element, width, height); + const finalCoords = getResizedElementAbsoluteCoords( + { + ...element, + ...rescaledPoints, + }, + width, + height, + ); + const { x, y } = getNextXY(element, origCoords, finalCoords); + return [...prev, { width, height, x, y, ...rescaledPoints, ...font }]; + }, + [] as + | { + width: number; + height: number; + x: number; + y: number; + points?: (readonly [number, number])[]; + fontSize?: number; + baseline?: number; + }[] + | null, + ); + if (updates) { + elements.forEach((element, index) => { + mutateElement(element, updates[index]); + }); } } }; -export const canResizeMutlipleElements = ( - elements: readonly NonDeletedExcalidrawElement[], -) => { - return elements.every( - (element) => - ["rectangle", "diamond", "ellipse"].includes(element.type) || - isLinearElement(element), - ); -}; - export const getResizeOffsetXY = ( resizeHandle: ResizeTestType, selectedElements: NonDeletedExcalidrawElement[], diff --git a/src/renderer/renderScene.ts b/src/renderer/renderScene.ts index 11037071..8d6245b5 100644 --- a/src/renderer/renderScene.ts +++ b/src/renderer/renderScene.ts @@ -16,7 +16,6 @@ import { handlerRectanglesFromCoords, handlerRectangles, getCommonBounds, - canResizeMutlipleElements, } from "../element"; import { roundRect } from "./roundRect"; @@ -372,55 +371,53 @@ export const renderScene = ( }); context.translate(-sceneState.scrollX, -sceneState.scrollY); } else if (locallySelectedElements.length > 1) { - if (canResizeMutlipleElements(locallySelectedElements)) { - const dashedLinePadding = 4 / sceneState.zoom; - context.translate(sceneState.scrollX, sceneState.scrollY); - context.fillStyle = oc.white; - const [x1, y1, x2, y2] = getCommonBounds(locallySelectedElements); - const initialLineDash = context.getLineDash(); - context.setLineDash([2 / sceneState.zoom]); - const lineWidth = context.lineWidth; - context.lineWidth = 1 / sceneState.zoom; - strokeRectWithRotation( - context, - x1 - dashedLinePadding, - y1 - dashedLinePadding, - x2 - x1 + dashedLinePadding * 2, - y2 - y1 + dashedLinePadding * 2, - (x1 + x2) / 2, - (y1 + y2) / 2, - 0, - ); - context.lineWidth = lineWidth; - context.setLineDash(initialLineDash); - const handlers = handlerRectanglesFromCoords( - [x1, y1, x2, y2], - 0, - sceneState.zoom, - undefined, - OMIT_SIDES_FOR_MULTIPLE_ELEMENTS, - ); - Object.keys(handlers).forEach((key) => { - const handler = handlers[key as HandlerRectanglesRet]; - if (handler !== undefined) { - const lineWidth = context.lineWidth; - context.lineWidth = 1 / sceneState.zoom; - strokeRectWithRotation( - context, - handler[0], - handler[1], - handler[2], - handler[3], - handler[0] + handler[2] / 2, - handler[1] + handler[3] / 2, - 0, - true, // fill before stroke - ); - context.lineWidth = lineWidth; - } - }); - context.translate(-sceneState.scrollX, -sceneState.scrollY); - } + const dashedLinePadding = 4 / sceneState.zoom; + context.translate(sceneState.scrollX, sceneState.scrollY); + context.fillStyle = oc.white; + const [x1, y1, x2, y2] = getCommonBounds(locallySelectedElements); + const initialLineDash = context.getLineDash(); + context.setLineDash([2 / sceneState.zoom]); + const lineWidth = context.lineWidth; + context.lineWidth = 1 / sceneState.zoom; + strokeRectWithRotation( + context, + x1 - dashedLinePadding, + y1 - dashedLinePadding, + x2 - x1 + dashedLinePadding * 2, + y2 - y1 + dashedLinePadding * 2, + (x1 + x2) / 2, + (y1 + y2) / 2, + 0, + ); + context.lineWidth = lineWidth; + context.setLineDash(initialLineDash); + const handlers = handlerRectanglesFromCoords( + [x1, y1, x2, y2], + 0, + sceneState.zoom, + undefined, + OMIT_SIDES_FOR_MULTIPLE_ELEMENTS, + ); + Object.keys(handlers).forEach((key) => { + const handler = handlers[key as HandlerRectanglesRet]; + if (handler !== undefined) { + const lineWidth = context.lineWidth; + context.lineWidth = 1 / sceneState.zoom; + strokeRectWithRotation( + context, + handler[0], + handler[1], + handler[2], + handler[3], + handler[0] + handler[2] / 2, + handler[1] + handler[3] / 2, + 0, + true, // fill before stroke + ); + context.lineWidth = lineWidth; + } + }); + context.translate(-sceneState.scrollX, -sceneState.scrollY); } }