diff --git a/README.md b/README.md index cce0216..376868f 100644 --- a/README.md +++ b/README.md @@ -28,6 +28,7 @@ You can simply paste images you have copied in your clipboard, and then arrange * Crop images + the ability to re-crop the original image at any stage * Double click to focus images in the center of the screen * Fullscreen toggle +* Resize images on any corner ![](./images/screenshot1.png) ![](./images/screenshot2.png) diff --git a/tornado-common/src/api/media.ts b/tornado-common/src/api/media.ts index b98fa8c..111516b 100644 --- a/tornado-common/src/api/media.ts +++ b/tornado-common/src/api/media.ts @@ -6,6 +6,13 @@ export enum MediaSize { ORIGINAL = 4 } +export const MEDIA_SIZES = { + [MediaSize.SMALL]: 200, + [MediaSize.MEDIUM]: 500, + [MediaSize.LARGE]: 1000, + [MediaSize.X_LARGE]: 2000 +}; + export interface GetMediaRequest { image: number; size: MediaSize; diff --git a/tornado-frontend/src/routes/content/widgets/react-canvas/ConceptCanvasWidget.tsx b/tornado-frontend/src/routes/content/widgets/react-canvas/ConceptCanvasWidget.tsx index a2d3e5d..3847d07 100644 --- a/tornado-frontend/src/routes/content/widgets/react-canvas/ConceptCanvasWidget.tsx +++ b/tornado-frontend/src/routes/content/widgets/react-canvas/ConceptCanvasWidget.tsx @@ -35,10 +35,10 @@ export const ConceptCanvasWidget: React.FC = observer( } }, []); - const zoomToFix = useCallback(() => { + const zoomToFit = useCallback(() => { engine.zoomToFitElements({ elements: _.values(engine.getModel().getLayers()[0].getModels()) as unknown as ImageElement[], - margin: 10 + margin: 0 }); }, []); @@ -74,7 +74,7 @@ export const ConceptCanvasWidget: React.FC = observer( return; } if (!props.board.canvasTranslateCache) { - zoomToFix(); + zoomToFit(); } else { engine.getModel().setOffsetX(props.board.canvasTranslateCache.offsetX); engine.getModel().setOffsetY(props.board.canvasTranslateCache.offsetY); @@ -97,6 +97,7 @@ export const ConceptCanvasWidget: React.FC = observer( finished: (data) => { element.update(data); engine.repaintCanvas(); + engine.getModel().save(); } }); }); @@ -131,7 +132,7 @@ export const ConceptCanvasWidget: React.FC = observer( type={ButtonType.DISCRETE} icon="expand" action={async () => { - zoomToFix(); + zoomToFit(); }} /> diff --git a/tornado-frontend/src/routes/content/widgets/react-canvas/DefaultCanvasState.ts b/tornado-frontend/src/routes/content/widgets/react-canvas/DefaultCanvasState.ts index 47c4abf..427d14a 100644 --- a/tornado-frontend/src/routes/content/widgets/react-canvas/DefaultCanvasState.ts +++ b/tornado-frontend/src/routes/content/widgets/react-canvas/DefaultCanvasState.ts @@ -9,9 +9,13 @@ import { SelectingState, State } from '@projectstorm/react-canvas-core'; +import { ResizeElementState } from './ResizeElementState'; +import { CornerPosition } from './controls-layer/ControlsElementWidget'; +import { ImageElement } from './image-element/ImageElementFactory'; export class DefaultCanvasState extends State { dragCanvas: DragCanvasState; + resizeElement: ResizeElementState; dragItems: MoveItemsState; constructor() { @@ -20,6 +24,7 @@ export class DefaultCanvasState extends State { }); this.childStates = [new SelectingState()]; this.dragCanvas = new DragCanvasState({}); + this.resizeElement = new ResizeElementState(); this.dragItems = new MoveItemsState(); // determine what was clicked on @@ -29,6 +34,15 @@ export class DefaultCanvasState extends State { fire: (event: ActionEvent) => { const element = this.engine.getActionEventBus().getModelForEvent(event); + if ((event.event.target as HTMLDivElement).dataset.anchorposition) { + this.resizeElement.setup( + (event.event.target as HTMLDivElement).dataset.anchorposition as CornerPosition, + element as ImageElement + ); + this.transitionWithEvent(this.resizeElement, event); + return true; + } + // the canvas was clicked on, transition to the dragging canvas state if (!element) { this.transitionWithEvent(this.dragCanvas, event); diff --git a/tornado-frontend/src/routes/content/widgets/react-canvas/ResizeElementState.ts b/tornado-frontend/src/routes/content/widgets/react-canvas/ResizeElementState.ts new file mode 100644 index 0000000..eb72d2c --- /dev/null +++ b/tornado-frontend/src/routes/content/widgets/react-canvas/ResizeElementState.ts @@ -0,0 +1,52 @@ +import { AbstractDisplacementState, AbstractDisplacementStateEvent } from '@projectstorm/react-canvas-core'; +import { CornerPosition } from './controls-layer/ControlsElementWidget'; +import { ImageElement } from './image-element/ImageElementFactory'; + +export class ResizeElementState extends AbstractDisplacementState { + position: CornerPosition; + element: ImageElement; + width: number; + height: number; + x: number; + y: number; + + constructor() { + super({ + name: 'resize-elements' + }); + } + + setup(position: CornerPosition, element: ImageElement) { + this.position = position; + this.element = element; + this.width = element.width; + this.height = element.height; + this.x = element.getX(); + this.y = element.getY(); + } + + fireMouseMoved(event: AbstractDisplacementStateEvent): any { + if (this.position === CornerPosition.SE) { + const newWidth = this.width + event.virtualDisplacementX; + const newHeight = (newWidth / this.width) * this.height; + this.element.setSize(newWidth, newHeight); + } else if (this.position === CornerPosition.NW) { + const newWidth = this.width - event.virtualDisplacementX; + const newHeight = (newWidth / this.width) * this.height; + this.element.setSize(newWidth, newHeight); + this.element.setPosition(this.x + event.virtualDisplacementX, this.height - newHeight + this.y); + } else if (this.position === CornerPosition.SW) { + const newWidth = this.width - event.virtualDisplacementX; + const newHeight = (newWidth / this.width) * this.height; + this.element.setSize(newWidth, newHeight); + this.element.setPosition(this.x + event.virtualDisplacementX, this.y); + } else if (this.position === CornerPosition.NE) { + const newWidth = this.width + event.virtualDisplacementX; + const newHeight = (newWidth / this.width) * this.height; + this.element.setSize(newWidth, newHeight); + this.element.setPosition(this.x, this.height - newHeight + this.y); + } + + this.engine.repaintCanvas(); + } +} diff --git a/tornado-frontend/src/routes/content/widgets/react-canvas/controls-layer/ControlsElementWidget.tsx b/tornado-frontend/src/routes/content/widgets/react-canvas/controls-layer/ControlsElementWidget.tsx index 81ef5c3..3c34417 100644 --- a/tornado-frontend/src/routes/content/widgets/react-canvas/controls-layer/ControlsElementWidget.tsx +++ b/tornado-frontend/src/routes/content/widgets/react-canvas/controls-layer/ControlsElementWidget.tsx @@ -12,6 +12,15 @@ export interface ControlsElementWidgetProps { engine: CanvasEngine; } +export enum CornerPosition { + NW = 'nw', + NE = 'ne', + SW = 'sw', + SE = 'se' +} + +const ANCHOR_SIZE = 14; + export const ControlsElementWidget: React.FC = (props) => { const canvas = (props.model.getParent() as ImageLayerModel).getParent(); const zoom = canvas.getZoomLevel() / 100; @@ -21,6 +30,8 @@ export const ControlsElementWidget: React.FC = (prop return null; } + const offset = (-1 * ANCHOR_SIZE) / 2; + return ( = (prop > { @@ -41,7 +52,7 @@ export const ControlsElementWidget: React.FC = (prop }} /> { @@ -60,6 +71,42 @@ export const ControlsElementWidget: React.FC = (prop }} /> + + + + ); }; @@ -72,6 +119,16 @@ namespace S { pointer-events: none; `; + export const Anchor = styled.div` + box-sizing: border-box; + position: absolute; + border: solid 2px ${(p) => p.theme.editor.selected}; + width: ${ANCHOR_SIZE}px; + height: ${ANCHOR_SIZE}px; + background: ${(p) => p.theme.editor.selectedShadow}; + pointer-events: all; + `; + export const Controls = styled.div` position: absolute; top: -40px; diff --git a/tornado-frontend/src/routes/content/widgets/react-canvas/image-element/ImageElementFactory.tsx b/tornado-frontend/src/routes/content/widgets/react-canvas/image-element/ImageElementFactory.tsx index 53cd528..6914268 100644 --- a/tornado-frontend/src/routes/content/widgets/react-canvas/image-element/ImageElementFactory.tsx +++ b/tornado-frontend/src/routes/content/widgets/react-canvas/image-element/ImageElementFactory.tsx @@ -2,6 +2,8 @@ import * as React from 'react'; import { AbstractReactFactory, BasePositionModel, + BasePositionModelGenerics, + BasePositionModelListener, DeserializeEvent, GenerateModelEvent, GenerateWidgetEvent @@ -12,7 +14,11 @@ import { FileData } from '@projectstorm/tornado-common'; import { ConceptCanvasEngine } from '../ConceptCanvasEngine'; import { Rectangle } from '@projectstorm/geometry'; -export class ImageElement extends BasePositionModel { +export interface ImageElementListener extends BasePositionModelListener { + sizeUpdated: () => any; +} + +export class ImageElement extends BasePositionModel { public width: number; public height: number; @@ -30,11 +36,16 @@ export class ImageElement extends BasePositionModel { return (this.getParent() as ImageLayerModel).getParent(); } + setSize(width: number, height: number) { + this.width = width; + this.height = height; + this.fireEvent({}, 'sizeUpdated'); + } + update(data: FileData) { const size = 500; this.imageID = data.id; - this.width = size; - this.height = (size / data.width) * data.height; + this.setSize(size, (size / data.width) * data.height); } getBoundingBox(): Rectangle { diff --git a/tornado-frontend/src/routes/content/widgets/react-canvas/image-element/ResponseImageWidget.tsx b/tornado-frontend/src/routes/content/widgets/react-canvas/image-element/ResponseImageWidget.tsx index 8942d38..33e84d4 100644 --- a/tornado-frontend/src/routes/content/widgets/react-canvas/image-element/ResponseImageWidget.tsx +++ b/tornado-frontend/src/routes/content/widgets/react-canvas/image-element/ResponseImageWidget.tsx @@ -1,11 +1,12 @@ import * as React from 'react'; import { useEffect, useState } from 'react'; +import * as _ from 'lodash'; import { useSystem } from '../../../../../hooks/useSystem'; -import { MediaSize } from '@projectstorm/tornado-common'; +import { MEDIA_SIZES, MediaSize } from '@projectstorm/tornado-common'; import { ImageElement } from './ImageElementFactory'; -import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { styled } from '../../../../../theme/theme'; import { MediaObject } from '../../../../../client/MediaClient'; +import { keyframes } from '@emotion/react'; export interface ResponseImageWidgetProps { className?: any; @@ -26,24 +27,47 @@ export const ResponseImageWidget: React.FC = (props) = }, [props.model.imageID]); useEffect(() => { - const compute = (zoom: number) => { - if (zoom < 15) { - setSize(MediaSize.SMALL); - } else if (zoom >= 15 && zoom < 50) { - setSize(MediaSize.MEDIUM); - } else if (zoom >= 50 && zoom < 120) { - setSize(MediaSize.LARGE); - } else { - setSize(MediaSize.X_LARGE); + const compute = () => { + const zoom = props.model.getCanvasModel().getZoomLevel() / 100; + const width = zoom * props.model.width * window.devicePixelRatio; + const height = zoom * props.model.height * window.devicePixelRatio; + + // this is the final value we need to check + const length = Math.max(width, height); + + const keys = _.keys(MEDIA_SIZES) as unknown as MediaSize[]; + + if (length < MEDIA_SIZES[keys[0]]) { + return setSize(keys[0]); } + + for (let i = 1; i < keys.length; i++) { + if (length >= MEDIA_SIZES[keys[i - 1]] && length < MEDIA_SIZES[keys[i]]) { + return setSize(keys[i]); + } + } + + setSize(MediaSize.X_LARGE); }; - compute(props.model.getCanvasModel().getZoomLevel()); - return props.model.getCanvasModel().registerListener({ + compute(); + + const l1 = props.model.getCanvasModel().registerListener({ zoomUpdated(event) { - compute(event.zoom); + compute(); } }).deregister; + + const l2 = props.model.registerListener({ + sizeUpdated: () => { + compute(); + } + }).deregister; + + return () => { + l1?.(); + l2?.(); + }; }, []); useEffect(() => { @@ -53,23 +77,23 @@ export const ResponseImageWidget: React.FC = (props) = }, [object, size]); if (!url) { - return ( - - - - ); + return ; } return ; }; namespace S { - export const Icon = styled(FontAwesomeIcon)` - color: ${(p) => p.theme.text.description}; - font-size: 50px; + export const Animated = keyframes` + 0% { + opacity: 0; + } + 100% { + opacity: 1; + } `; - export const Loader = styled.div` + export const Loader = styled.div<{ delay: number }>` display: flex; align-items: center; justify-content: center; @@ -77,6 +101,8 @@ namespace S { border-radius: 5px; width: 100%; height: 100%; + animation: ${Animated} 1s infinite alternate-reverse; + animation-delay: ${(p) => p.delay}ms; `; export const Container = styled.div<{ url: string }>` diff --git a/tornado-frontend/src/routes/content/widgets/react-canvas/image-layer/ImageLayerFactory.tsx b/tornado-frontend/src/routes/content/widgets/react-canvas/image-layer/ImageLayerFactory.tsx index 20d0a28..63db55d 100644 --- a/tornado-frontend/src/routes/content/widgets/react-canvas/image-layer/ImageLayerFactory.tsx +++ b/tornado-frontend/src/routes/content/widgets/react-canvas/image-layer/ImageLayerFactory.tsx @@ -67,6 +67,9 @@ export class ImageLayerModel extends LayerModel { positionChanged: () => { this.save(); }, + sizeUpdated: () => { + this.save(); + }, entityRemoved: () => { this.removeModel(model); this.save(); diff --git a/tornado-server/src/api/MediaApi.ts b/tornado-server/src/api/MediaApi.ts index 32939fb..d82abab 100644 --- a/tornado-server/src/api/MediaApi.ts +++ b/tornado-server/src/api/MediaApi.ts @@ -5,17 +5,10 @@ import { ENV } from '../Env'; import * as path from 'path'; import { System } from '../System'; import { User } from '@prisma/client'; -import { MediaCropRequest, MediaSize } from '@projectstorm/tornado-common'; +import { MediaCropRequest, MediaSize, MEDIA_SIZES } from '@projectstorm/tornado-common'; import * as crypto from 'crypto'; export class MediaApi extends AbstractApi { - static SIZES = { - [MediaSize.SMALL]: 200, - [MediaSize.MEDIUM]: 500, - [MediaSize.LARGE]: 1000, - [MediaSize.X_LARGE]: 2000 - }; - constructor(system: System) { super({ name: 'MEDIA', @@ -34,8 +27,8 @@ export class MediaApi extends AbstractApi { } async resizeMedia(file: Buffer, id: number, crop?: { left: number; top: number; width: number; height: number }) { - for (let k in MediaApi.SIZES) { - const size = MediaApi.SIZES[k]; + for (let k in MEDIA_SIZES) { + const size = MEDIA_SIZES[k]; try { this.logger.info(`resizing to ${size}`); @@ -74,7 +67,7 @@ export class MediaApi extends AbstractApi { if (size === MediaSize.ORIGINAL) { return path.join(this.originalDir, `${image}`); } - return path.join(this.resizeDir, `${image}.${MediaApi.SIZES[size]}.jpg`); + return path.join(this.resizeDir, `${image}.${MEDIA_SIZES[size]}.jpg`); } async uploadFile(user: User, file: Buffer) {