diff --git a/README.md b/README.md index 376868f..e3591cf 100644 --- a/README.md +++ b/README.md @@ -9,6 +9,9 @@ Concept and image reference board software for ambitious creatives 🎨 (Inspired by the awesome projects https://www.pureref.com/ and https://vizref.com/) +![](./images/example1.png) +![](./images/example2.png) + ## What Tornado is self-hosted software for the web (currently in development) that provides digital media artists with the ability to create concept and reference boards. @@ -21,7 +24,8 @@ You can simply paste images you have copied in your clipboard, and then arrange * Multiple Users * Light and dark mode * Email + password authentication -* Image resizing on the server +* Image resizing on the server (4 sizes based on the image zoom) +* Uses window DPI to determine which image variant to serve * Canvas zoom and translate * Image paste from clipboard and translate * Name and rename boards @@ -29,6 +33,8 @@ You can simply paste images you have copied in your clipboard, and then arrange * Double click to focus images in the center of the screen * Fullscreen toggle * Resize images on any corner +* Initial widths are computed based on the average sizes of the other images +* Lock mode to prevent editing, auto unlock on image paste ![](./images/screenshot1.png) ![](./images/screenshot2.png) @@ -139,5 +145,20 @@ _Note: Pull requests must have the appropriate label (eg 'feature') to be includ ## About -Author: Dylan Vorster (dylanvorster.com) - +Project Author: Dylan Vorster (dylanvorster.com) + +Links to a few of the awesome artists featured in the example images: + +* https://twitter.com/KilluKaela +* https://twitter.com/IndigoBeatss +* https://twitter.com/the_aftrmrkt +* https://twitter.com/Xezeno1 +* https://twitter.com/for_riner +* https://twitter.com/fwflunky +* https://twitter.com/Dev_Voxy +* https://twitter.com/_rat_riot +* https://twitter.com/Frayvuir +* https://twitter.com/DJayjesse +* https://twitter.com/bl_s21 +* https://twitter.com/yumesan_yume +* https://twitter.com/MegamanUMX \ No newline at end of file diff --git a/images/example1.png b/images/example1.png new file mode 100644 index 0000000..2f21cf9 Binary files /dev/null and b/images/example1.png differ diff --git a/images/example2.png b/images/example2.png new file mode 100644 index 0000000..f938fae Binary files /dev/null and b/images/example2.png differ diff --git a/tornado-frontend/src/System.ts b/tornado-frontend/src/System.ts index 72cc8bf..9ca9e24 100644 --- a/tornado-frontend/src/System.ts +++ b/tornado-frontend/src/System.ts @@ -25,7 +25,12 @@ export class System { @observable theme?: TornadoTheme; + @observable + title: string; + constructor() { + this.title = null; + if (window.localStorage.getItem(System.LIGHT_KEY) === 'true') { this.theme = ThemeLight; } else { @@ -61,6 +66,7 @@ export class System { } updateTitle(title: string) { + this.title = title; document.title = `Tornado${title ? ` | ${title}` : ''}`; } diff --git a/tornado-frontend/src/client/MediaClient.ts b/tornado-frontend/src/client/MediaClient.ts index 9a5039b..fdf9d48 100644 --- a/tornado-frontend/src/client/MediaClient.ts +++ b/tornado-frontend/src/client/MediaClient.ts @@ -63,9 +63,17 @@ export class MediaObject { (data) => new Promise((resolve) => { const url = window.URL.createObjectURL(data); + + // this wacky code sort-of hydrates the rendering buffer + // which greatly reduces initial flickering of the Image blob for the first time + // it is rendered in the DOM const img = document.createElement('img'); img.src = url; - resolve(url); + + // this kind of also helps a bit + _.defer(() => { + resolve(url); + }); }) ) ); diff --git a/tornado-frontend/src/fonts.ts b/tornado-frontend/src/fonts.ts index 30ec82f..87b0a2b 100644 --- a/tornado-frontend/src/fonts.ts +++ b/tornado-frontend/src/fonts.ts @@ -3,6 +3,8 @@ import { library } from '@fortawesome/fontawesome-svg-core'; import { faCrop, faExpand, + faLock, + faLockOpen, faMagnifyingGlassMinus, faMagnifyingGlassPlus, faMinimize, @@ -28,7 +30,9 @@ library.add( faMinimize, faUpRightAndDownLeftFromCenter, faMagnifyingGlassPlus, - faMagnifyingGlassMinus + faMagnifyingGlassMinus, + faLock, + faLockOpen ); export const FONT = css` diff --git a/tornado-frontend/src/hooks/useTitle.tsx b/tornado-frontend/src/hooks/useTitle.tsx new file mode 100644 index 0000000..278da45 --- /dev/null +++ b/tornado-frontend/src/hooks/useTitle.tsx @@ -0,0 +1,12 @@ +import { useEffect } from 'react'; +import { autorun } from 'mobx'; +import { useSystem } from './useSystem'; + +export const useTitle = (cb: () => string) => { + const system = useSystem(); + useEffect(() => { + return autorun(() => { + system.updateTitle(cb()); + }); + }, []); +}; diff --git a/tornado-frontend/src/routes/content/ConceptBoardPage.tsx b/tornado-frontend/src/routes/content/ConceptBoardPage.tsx index b1fe5dc..d0cf2b9 100644 --- a/tornado-frontend/src/routes/content/ConceptBoardPage.tsx +++ b/tornado-frontend/src/routes/content/ConceptBoardPage.tsx @@ -7,17 +7,25 @@ import { useSystem } from '../../hooks/useSystem'; import { ConceptCanvasWidget } from './widgets/react-canvas/ConceptCanvasWidget'; import { useParams } from 'react-router-dom'; import { observer } from 'mobx-react'; +import { useTitle } from '../../hooks/useTitle'; export const ConceptBoardPage: React.FC = observer((props) => { useAuthenticated(); const system = useSystem(); const { board } = useParams<{ board: string }>(); + useEffect(() => { - system.conceptStore?.loadConcept(parseInt(board)).then((concept) => { - system.updateTitle(`Concept ${concept.board.name}`); - }); + system.conceptStore?.loadConcept(parseInt(board)); }, [board]); + useTitle(() => { + const concept = system.conceptStore?.getConcept(parseInt(board)); + if (!concept) { + return 'Loading...'; + } + return `${concept.board.name}`; + }); + const concept = system.conceptStore?.getConcept(parseInt(board)); if (!concept) { return null; diff --git a/tornado-frontend/src/routes/content/ImageCropPage.tsx b/tornado-frontend/src/routes/content/ImageCropPage.tsx index 3dd2340..389b7a0 100644 --- a/tornado-frontend/src/routes/content/ImageCropPage.tsx +++ b/tornado-frontend/src/routes/content/ImageCropPage.tsx @@ -12,6 +12,7 @@ import { Routing } from '../routes'; import * as _ from 'lodash'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { styled } from '../../theme/theme'; +import { useTitle } from '../../hooks/useTitle'; export const ImageCropPage: React.FC = observer((props) => { useAuthenticated(); @@ -22,6 +23,14 @@ export const ImageCropPage: React.FC = observer((props) => { const cropperRef = createRef(); const [mediaUrl, setMedia] = useState(null); + useTitle(() => { + const concept = system.conceptStore?.getConcept(parseInt(board)); + if (!concept) { + return 'Loading...'; + } + return `${concept.board.name} - Crop image: ${image}`; + }); + useEffect(() => { const media = system.clientMedia.getMediaObject(parseInt(image)); media.getURL(MediaSize.ORIGINAL).then((url) => { @@ -150,5 +159,6 @@ namespace S { height: 100%; width: 100%; flex-grow: 1; + overflow: hidden; `; } 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 78d9902..8b43f7c 100644 --- a/tornado-frontend/src/routes/content/widgets/react-canvas/ConceptCanvasEngine.ts +++ b/tornado-frontend/src/routes/content/widgets/react-canvas/ConceptCanvasEngine.ts @@ -18,10 +18,14 @@ import { ControlsLayerFactory } from './controls-layer/ControlsLayerFactory'; import { boundingBoxFromPolygons, Rectangle } from '@projectstorm/geometry'; import { CustomZoomAction } from './CustomZoomAction'; +export interface ConceptCanvasEngineOptions { + isLocked: () => boolean; +} + export class ConceptCanvasEngine extends CanvasEngine { elementBank: FactoryBank; - constructor() { + constructor(options: ConceptCanvasEngineOptions) { super({ registerDefaultDeleteItemsAction: true }); @@ -33,7 +37,7 @@ export class ConceptCanvasEngine extends CanvasEngine = observer((props) => { const system = useSystem(); + const [locked, setLocked] = useState(true); + const ref = useRef(); + ref.current = locked; const [engine] = useState(() => { - return new ConceptCanvasEngine(); + return new ConceptCanvasEngine({ + isLocked: () => ref.current + }); }); const [position, setPosition] = useState(null); const [ready, setReady] = useState(false); @@ -87,6 +92,8 @@ export const ConceptCanvasWidget: React.FC = observer( // paste handler usePasteMedia({ gotMedia: (files) => { + setLocked(false); + files.forEach(async (file) => { const media = await system.clientMedia.uploadMedia(file); const element = engine.getModel().addImage(); @@ -113,6 +120,13 @@ export const ConceptCanvasWidget: React.FC = observer( + { + setLocked(!locked); + }} + /> boolean; +} + export class DefaultCanvasState extends State { dragCanvas: DragCanvasState; resizeElement: ResizeElementState; dragItems: MoveItemsState; - constructor() { + constructor(options: DefaultCanvasStateOptions) { super({ name: 'default-diagrams' }); @@ -32,8 +36,13 @@ export class DefaultCanvasState extends State { new Action({ type: InputType.MOUSE_DOWN, fire: (event: ActionEvent) => { - const element = this.engine.getActionEventBus().getModelForEvent(event); + const locked = options.isLocked(); + if (locked) { + this.transitionWithEvent(this.dragCanvas, event); + return; + } + 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, 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 6914268..be54c54 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 @@ -43,7 +43,19 @@ export class ImageElement extends BasePositionModel i !== this) + .map((i) => i.width); + + if (widths.length === 0) { + widths = [500]; + } + + let totalWidth = widths.reduce((prev, cur) => prev + cur, 0); + + const size = totalWidth / widths.length; this.imageID = data.id; this.setSize(size, (size / data.width) * data.height); } diff --git a/tornado-frontend/src/routes/manage/ConceptBoardsPage.tsx b/tornado-frontend/src/routes/manage/ConceptBoardsPage.tsx index da66788..2b97af7 100644 --- a/tornado-frontend/src/routes/manage/ConceptBoardsPage.tsx +++ b/tornado-frontend/src/routes/manage/ConceptBoardsPage.tsx @@ -11,6 +11,7 @@ import { observer } from 'mobx-react'; import { TableRowActionsWidget } from '../../widgets/table/TableRowActionsWidget'; import { ConceptBoardModel } from '../../stores/ConceptsStore'; import { RelativeDateCellWidget } from '../../widgets/table/RelativeDateCellWidget'; +import { useTitle } from '../../hooks/useTitle'; export interface ConceptBoardRow extends TableRow { board: ConceptBoardModel; @@ -22,8 +23,10 @@ export const ConceptBoardsPage: React.FC = observer((props) => { const system = useSystem(); useEffect(() => { system.conceptStore.loadConcepts(); - system.updateTitle('Concepts'); }, []); + useTitle(() => { + return 'Concepts'; + }); return ( diff --git a/tornado-frontend/src/routes/welcome/SignInPage.tsx b/tornado-frontend/src/routes/welcome/SignInPage.tsx index 20b7e8e..e889d2d 100644 --- a/tornado-frontend/src/routes/welcome/SignInPage.tsx +++ b/tornado-frontend/src/routes/welcome/SignInPage.tsx @@ -6,11 +6,15 @@ import { FormikFieldWidget } from '../../widgets/forms/FormikFieldWidget'; import { ButtonType, ButtonWidget } from '../../widgets/forms/ButtonWidget'; import { useSystem } from '../../hooks/useSystem'; import { useUnAuthenticated } from '../../hooks/useAuthenticated'; +import { useTitle } from '../../hooks/useTitle'; export interface SignInPageProps {} export const SignInPage: React.FC = (props) => { useUnAuthenticated(); + useTitle(() => { + return 'Sign in'; + }); const system = useSystem(); return ( diff --git a/tornado-frontend/src/widgets/header/HeaderWidget.tsx b/tornado-frontend/src/widgets/header/HeaderWidget.tsx index f826dc1..f8ad6c3 100644 --- a/tornado-frontend/src/widgets/header/HeaderWidget.tsx +++ b/tornado-frontend/src/widgets/header/HeaderWidget.tsx @@ -5,6 +5,7 @@ import { useNavigate } from 'react-router-dom'; import { Routing } from '../../routes/routes'; import { observer } from 'mobx-react'; import { useSystem } from '../../hooks/useSystem'; +import { FONT } from '../../fonts'; const logo_light = require('../../../media/logo-small-light.png'); const logo_dark = require('../../../media/logo-small-dark.png'); @@ -25,6 +26,8 @@ export const HeaderWidget: React.FC = observer((props) => { }} src={system.theme.light ? logo_dark : logo_light} > + {system.title} + ); @@ -40,6 +43,17 @@ namespace S { padding-right: 50px; `; + export const Title = styled.div` + font-size: 16px; + color: ${(p) => p.theme.text.description}; + margin-left: 20px; + ${FONT}; + `; + + export const Spacer = styled.div` + flex-grow: 1; + `; + export const Logo = styled.img` height: 40px; cursor: pointer; diff --git a/tornado-frontend/src/widgets/layout/RootWidget.tsx b/tornado-frontend/src/widgets/layout/RootWidget.tsx index 575e6d1..a42114f 100644 --- a/tornado-frontend/src/widgets/layout/RootWidget.tsx +++ b/tornado-frontend/src/widgets/layout/RootWidget.tsx @@ -61,6 +61,7 @@ namespace S { * { margin: 0; padding: 0; + user-select: none; } html {