diff --git a/README.md b/README.md index 88fa536..04d8e38 100644 --- a/README.md +++ b/README.md @@ -24,6 +24,7 @@ You can simply paste images you have copied in your clipboard, and then arrange * Image paste from clipboard and translate * Name and rename boards * Crop images + the ability to re-crop the original image at any stage +* Double click to focus images in the center of the screen ![](./images/screenshot.png) ![](./images/screenshot2.png) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b1fd060..443c8de 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -98,6 +98,9 @@ importers: react-switch: specifier: ^7.0.0 version: 7.0.0(react-dom@18.2.0)(react@18.2.0) + screenfull: + specifier: ^6.0.2 + version: 6.0.2 uuid: specifier: ^9.0.0 version: 9.0.0 @@ -2785,6 +2788,11 @@ packages: ajv-keywords: 3.5.2(ajv@6.12.6) dev: true + /screenfull@6.0.2: + resolution: {integrity: sha512-AQdy8s4WhNvUZ6P8F6PB21tSPIYKniic+Ogx0AacBMjKP1GUHN2E9URxQHtCusiwxudnCKkdy4GrHXPPJSkCCw==} + engines: {node: ^14.13.1 || >=16.0.0} + dev: false + /semver@5.7.1: resolution: {integrity: sha512-sauaDf/PZdVgrLTNYHRtpXa1iRiKcaebiKQ1BJdpQlWH2lCvexQdX55snPFyK7QzpudqbCI0qXFfOasHdyNDGQ==} hasBin: true diff --git a/tornado-frontend/package.json b/tornado-frontend/package.json index 31c26b7..6021a7d 100644 --- a/tornado-frontend/package.json +++ b/tornado-frontend/package.json @@ -28,7 +28,8 @@ "react-cropper": "^2.3.3", "react-router-dom": "^6.12.1", "uuid": "^9.0.0", - "cropperjs": "^1.5.13" + "cropperjs": "^1.5.13", + "screenfull": "^6.0.2" }, "devDependencies": { "@types/lodash": "^4.14.195", diff --git a/tornado-frontend/src/fonts.ts b/tornado-frontend/src/fonts.ts index a803755..9e7c597 100644 --- a/tornado-frontend/src/fonts.ts +++ b/tornado-frontend/src/fonts.ts @@ -1,9 +1,19 @@ import '@fontsource/open-sans'; import { library } from '@fortawesome/fontawesome-svg-core'; -import { faCrop, faMoon, faPlus, faSignOut, faSpinner, faSun, faTrash } from '@fortawesome/free-solid-svg-icons'; +import { + faCrop, + faExpand, + faMinimize, + faMoon, + faPlus, + faSignOut, + faSpinner, + faSun, + faTrash +} from '@fortawesome/free-solid-svg-icons'; import { css } from '@emotion/react'; -library.add(faPlus, faSpinner, faSignOut, faSun, faMoon, faCrop, faTrash); +library.add(faPlus, faSpinner, faSignOut, faSun, faMoon, faCrop, faTrash, faExpand, faMinimize); export const FONT = css` font-family: 'Open sans', sans-serif; diff --git a/tornado-frontend/src/routes/content/widgets/react-canvas/ConceptCanvasEngine.ts b/tornado-frontend/src/routes/content/widgets/react-canvas/ConceptCanvasEngine.ts index 4d39e52..a12863c 100644 --- a/tornado-frontend/src/routes/content/widgets/react-canvas/ConceptCanvasEngine.ts +++ b/tornado-frontend/src/routes/content/widgets/react-canvas/ConceptCanvasEngine.ts @@ -1,18 +1,21 @@ import * as React from 'react'; import { BaseModel, + BasePositionModel, CanvasEngine, CanvasModel, FactoryBank, SelectionBoxLayerFactory, Toolkit } from '@projectstorm/react-canvas-core'; +import * as _ from 'lodash'; import { ImageElementFactory } from './image-element/ImageElementFactory'; import { ImageLayerFactory } from './image-layer/ImageLayerFactory'; import { DefaultCanvasState } from './DefaultCanvasState'; import { CanvasEngineListener } from '@projectstorm/react-canvas-core'; import { ConceptCanvasModel } from './ConceptCanvasModel'; import { ControlsLayerFactory } from './controls-layer/ControlsLayerFactory'; +import { boundingBoxFromPolygons, Rectangle } from '@projectstorm/geometry'; export class ConceptCanvasEngine extends CanvasEngine { elementBank: FactoryBank; @@ -22,8 +25,8 @@ export class ConceptCanvasEngine extends CanvasEngine node.getBoundingBox()))); + } + + /** + * FIXME this should go back into react-diagrams along with the dependent functions (the CanvasEngine) + */ + zoomToFitElements(options: { margin: number; elements: BasePositionModel[]; maxZoom: number }) { + const { margin, elements, maxZoom } = options; + const nodesRect = this.getBoundingNodesRect(elements); + // there is something we should zoom on + let canvasRect = this.canvas.getBoundingClientRect(); + + const calculate = (margin: number = 0) => { + // work out zoom + const xFactor = this.canvas.clientWidth / (nodesRect.getWidth() + margin * 2); + const yFactor = this.canvas.clientHeight / (nodesRect.getHeight() + margin * 2); + + let zoomFactor = xFactor < yFactor ? xFactor : yFactor; + if (maxZoom && zoomFactor > maxZoom) { + zoomFactor = maxZoom; + } + + return { + zoom: zoomFactor, + x: + canvasRect.width / 2 - + ((nodesRect.getWidth() + margin * 2) / 2 + nodesRect.getTopLeft().x) * zoomFactor + + margin, + y: + canvasRect.height / 2 - + ((nodesRect.getHeight() + margin * 2) / 2 + nodesRect.getTopLeft().y) * zoomFactor + + margin + }; + }; + + let params = calculate(0); + if (margin) { + if (params.x < margin || params.y < margin) { + params = calculate(margin); + } + } + + // apply + this.model.setZoomLevel(params.zoom * 100); + this.model.setOffset(params.x, params.y); + this.repaintCanvas(); + } } 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 06aa6a9..14d0a94 100644 --- a/tornado-frontend/src/routes/content/widgets/react-canvas/ConceptCanvasWidget.tsx +++ b/tornado-frontend/src/routes/content/widgets/react-canvas/ConceptCanvasWidget.tsx @@ -1,12 +1,14 @@ import * as React from 'react'; import { useEffect, useState } from 'react'; import styled from '@emotion/styled'; +import * as _ from 'lodash'; import { Action, CanvasWidget, InputType } from '@projectstorm/react-canvas-core'; import { ConceptBoardModel } from '../../../../stores/ConceptsStore'; import { ConceptCanvasEngine } from './ConceptCanvasEngine'; import { ConceptCanvasModel } from './ConceptCanvasModel'; import { usePasteMedia } from '../../../../hooks/usePasteMedia'; import { useSystem } from '../../../../hooks/useSystem'; +import { ImageElement } from './image-element/ImageElementFactory'; export interface ConceptCanvasWidgetProps { board: ConceptBoardModel; @@ -53,6 +55,19 @@ export const ConceptCanvasWidget: React.FC = (props) = const model = new ConceptCanvasModel(props.board); model.load(engine); engine.setModel(model); + + if (!props.board.canvasTranslateCache) { + engine.zoomToFitElements({ + maxZoom: 2, + elements: _.values(model.getLayers()[0].getModels()) as unknown as ImageElement[], + margin: 10 + }); + } else { + model.setOffsetX(props.board.canvasTranslateCache.offsetX); + model.setOffsetY(props.board.canvasTranslateCache.offsetY); + model.setZoomLevel(props.board.canvasTranslateCache.zoom); + } + props.board.canvasTranslateCache = null; }, [props.board]); if (!engine) { 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 0a97790..81ef5c3 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 @@ -45,6 +45,12 @@ export const ControlsElementWidget: React.FC = (prop icon="crop" instant={true} action={async () => { + const canvasModel = props.model.getCanvasModel(); + canvasModel.model.canvasTranslateCache = { + zoom: canvasModel.getZoomLevel(), + offsetX: canvasModel.getOffsetX(), + offsetY: canvasModel.getOffsetY() + }; navigate( generatePath(Routing.CONCEPTS_BOARD_IMAGE_CROP, { board: `${props.model.getCanvasModel().model.id}`, 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 93c23a1..d570277 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 @@ -9,6 +9,8 @@ import { import { ImageElementWidget } from './ImageElementWidget'; import { ImageLayerModel } from '../image-layer/ImageLayerFactory'; import { FileData } from '@projectstorm/tornado-common'; +import { ConceptCanvasEngine } from '../ConceptCanvasEngine'; +import { Rectangle } from '@projectstorm/geometry'; export class ImageElement extends BasePositionModel { public width: number; @@ -35,6 +37,10 @@ export class ImageElement extends BasePositionModel { this.height = (size / data.width) * data.height; } + getBoundingBox(): Rectangle { + return Rectangle.fromPointAndSize(this.position, this.width, this.height); + } + serialize() { return { ...super.serialize(), @@ -53,7 +59,7 @@ export class ImageElement extends BasePositionModel { } } -export class ImageElementFactory extends AbstractReactFactory { +export class ImageElementFactory extends AbstractReactFactory { static TYPE = 'concept/image'; constructor() { @@ -65,6 +71,18 @@ export class ImageElementFactory extends AbstractReactFactory } generateReactWidget(event: GenerateWidgetEvent): JSX.Element { - return ; + return ( + { + this.engine.zoomToFitElements({ + margin: 10, + elements: [event.model], + maxZoom: 2 + }); + }} + key={event.model.getID()} + model={event.model} + /> + ); } } diff --git a/tornado-frontend/src/routes/content/widgets/react-canvas/image-element/ImageElementWidget.tsx b/tornado-frontend/src/routes/content/widgets/react-canvas/image-element/ImageElementWidget.tsx index d52998d..94c0545 100644 --- a/tornado-frontend/src/routes/content/widgets/react-canvas/image-element/ImageElementWidget.tsx +++ b/tornado-frontend/src/routes/content/widgets/react-canvas/image-element/ImageElementWidget.tsx @@ -1,17 +1,19 @@ import * as React from 'react'; -import { useEffect } from 'react'; import { ImageElement } from './ImageElementFactory'; import { ResponseImageWidget } from './ResponseImageWidget'; -import { useForceUpdate } from '../../../../../hooks/useForceUpdate'; import { styled } from '../../../../../theme/theme'; export interface ImageElementWidgetProps { model: ImageElement; + focus: () => any; } export const ImageElementWidget: React.FC = (props) => { return ( { + props.focus?.(); + }} data-imageid={props.model.getID()} style={{ left: props.model.getX(), diff --git a/tornado-frontend/src/stores/ConceptsStore.ts b/tornado-frontend/src/stores/ConceptsStore.ts index 9f14f9c..4275729 100644 --- a/tornado-frontend/src/stores/ConceptsStore.ts +++ b/tornado-frontend/src/stores/ConceptsStore.ts @@ -33,9 +33,16 @@ export class ConceptBoardModel extends BaseObserver { }; }; + canvasTranslateCache: { + offsetX: number; + offsetY: number; + zoom: number; + }; + constructor(protected options: ConceptBoardModelOptions) { super(); this.board = options.board; + this.canvasTranslateCache = null; makeObservable(this); } diff --git a/tornado-frontend/src/widgets/header/HeaderUserWidget.tsx b/tornado-frontend/src/widgets/header/HeaderUserWidget.tsx index 6e277af..9e90fd6 100644 --- a/tornado-frontend/src/widgets/header/HeaderUserWidget.tsx +++ b/tornado-frontend/src/widgets/header/HeaderUserWidget.tsx @@ -1,4 +1,5 @@ import * as React from 'react'; +import { useEffect } from 'react'; import { observer } from 'mobx-react'; import { useSystem } from '../../hooks/useSystem'; import { FONT } from '../../fonts'; @@ -7,14 +8,27 @@ import Avatar from 'react-avatar'; import { ButtonType, ButtonWidget } from '../forms/ButtonWidget'; import ReactSwitch from 'react-switch'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; +import screenfull from 'screenfull'; +import { useForceUpdate } from '../../hooks/useForceUpdate'; export interface HeaderUserWidgetProps { className?: any; + bodyRef: React.RefObject; } export const HeaderUserWidget: React.FC = observer((props) => { const system = useSystem(); + const forceUpdate = useForceUpdate(); const user = system.userStore.authenticatedUser; + useEffect(() => { + const h = () => { + forceUpdate(); + }; + screenfull.on('change', h); + return () => { + screenfull.off('change', h); + }; + }, []); return ( = observer((props color={system.theme.controls.button.primary.backgroundHover} fgColor={system.theme.controls.button.primary.colorHover} /> + { + if (screenfull.isFullscreen) { + screenfull.exit(); + } else { + screenfull.request(props.bodyRef.current); + } + }} + /> ; +} export const HeaderWidget: React.FC = observer((props) => { const navigate = useNavigate(); @@ -23,8 +25,7 @@ export const HeaderWidget: React.FC = observer((props) => { }} src={system.theme.light ? logo_dark : logo_light} > - - + ); }); diff --git a/tornado-frontend/src/widgets/layout/RootWidget.tsx b/tornado-frontend/src/widgets/layout/RootWidget.tsx index 598cc50..6d3405f 100644 --- a/tornado-frontend/src/widgets/layout/RootWidget.tsx +++ b/tornado-frontend/src/widgets/layout/RootWidget.tsx @@ -10,24 +10,26 @@ import { System } from '../../System'; import { SystemContext } from '../../hooks/useSystem'; import { LayersWidget } from './LayersWidget'; import { observer } from 'mobx-react'; +import { useRef } from 'react'; export interface RootWidgetProps { system: System; } export const RootWidget: React.FC = observer((props) => { + const bodyRef = useRef(); return ( <> - - + + + -