fix: fixes issue where exported image isn't positioned correctly

This commit is contained in:
Mark Mankarious
2023-11-01 16:58:33 +00:00
parent 0b9789746a
commit b525b8a8ab
13 changed files with 214 additions and 230 deletions

View File

@@ -13,7 +13,7 @@ import {
Colors,
Icons
} from 'src/types';
import { setWindowCursor } from 'src/utils';
import { setWindowCursor, modelFromModelStore } from 'src/utils';
import { modelSchema } from 'src/schemas/model';
import { useModelStore, ModelProvider } from 'src/stores/modelStore';
import { SceneProvider } from 'src/stores/sceneStore';
@@ -21,9 +21,8 @@ import { GlobalStyles } from 'src/styles/GlobalStyles';
import { Renderer } from 'src/components/Renderer/Renderer';
import { UiOverlay } from 'src/components/UiOverlay/UiOverlay';
import { UiStateProvider, useUiStateStore } from 'src/stores/uiStateStore';
import { INITIAL_DATA, MAIN_MENU_OPTIONS, INITIAL_UI_STATE } from 'src/config';
import { useModel } from 'src/hooks/useModel';
import { useIconCategories } from './hooks/useIconCategories';
import { INITIAL_DATA, MAIN_MENU_OPTIONS } from 'src/config';
import { useInitialDataManager } from 'src/hooks/useInitialDataManager';
const App = ({
initialData,
@@ -34,37 +33,24 @@ const App = ({
enableDebugTools = false,
editorMode = 'EDITABLE'
}: IsoflowProps) => {
const modelActions = useModelStore((state) => {
const uiStateActions = useUiStateStore((state) => {
return state.actions;
});
const uiActions = useUiStateStore((state) => {
return state.actions;
const initialDataManager = useInitialDataManager();
const model = useModelStore((state) => {
return modelFromModelStore(state);
});
const { setIconCategoriesState } = useIconCategories();
const model = useModel();
const { load } = initialDataManager;
useEffect(() => {
if (initialData?.zoom) {
uiActions.setZoom(initialData.zoom);
}
load({ ...INITIAL_DATA, ...initialData });
}, [initialData, load]);
if (initialData?.scroll) {
uiActions.setScroll({
position: initialData.scroll,
offset: INITIAL_UI_STATE.scroll.offset
});
}
uiActions.setEditorMode(editorMode);
uiActions.setMainMenuOptions(mainMenuOptions);
}, [
initialData?.zoom,
initialData?.scroll,
editorMode,
modelActions,
uiActions,
mainMenuOptions
]);
useEffect(() => {
uiStateActions.setEditorMode(editorMode);
uiStateActions.setMainMenuOptions(mainMenuOptions);
}, [editorMode, uiStateActions, mainMenuOptions]);
useEffect(() => {
return () => {
@@ -72,27 +58,17 @@ const App = ({
};
}, []);
const { load } = model;
useEffect(() => {
load({ ...INITIAL_DATA, ...initialData });
}, [initialData, load]);
useEffect(() => {
setIconCategoriesState();
}, [model.icons, setIconCategoriesState]);
useEffect(() => {
if (!model.isReady || !onModelUpdated) return;
if (!initialDataManager.isReady || !onModelUpdated) return;
onModelUpdated(model);
}, [model, onModelUpdated, model.isReady]);
}, [model, initialDataManager.isReady, onModelUpdated]);
useEffect(() => {
uiActions.setenableDebugTools(enableDebugTools);
}, [enableDebugTools, uiActions]);
uiStateActions.setEnableDebugTools(enableDebugTools);
}, [enableDebugTools, uiStateActions]);
if (!model.isReady) return null;
if (!initialDataManager.isReady) return null;
return (
<>

View File

@@ -12,7 +12,6 @@ import { useModelStore } from 'src/stores/modelStore';
import {
exportAsImage,
downloadFile as downloadFileUtil,
getTileScrollPosition,
base64ToBlob,
generateGenericFilename,
modelFromModelStore
@@ -32,7 +31,7 @@ export const ExportImageDialog = ({ onClose, quality = 1.5 }: Props) => {
const containerRef = useRef<HTMLDivElement>();
const debounceRef = useRef<NodeJS.Timeout>();
const [imageData, setImageData] = React.useState<string>();
const { getUnprojectedBounds, getFitToViewParams } = useDiagramUtils();
const { getUnprojectedBounds } = useDiagramUtils();
const uiStateActions = useUiStateStore((state) => {
return state.actions;
});
@@ -43,9 +42,6 @@ export const ExportImageDialog = ({ onClose, quality = 1.5 }: Props) => {
const unprojectedBounds = useMemo(() => {
return getUnprojectedBounds();
}, [getUnprojectedBounds]);
const previewParams = useMemo(() => {
return getFitToViewParams(unprojectedBounds);
}, [unprojectedBounds, getFitToViewParams]);
useEffect(() => {
uiStateActions.setMode({
@@ -113,11 +109,7 @@ export const ExportImageDialog = ({ onClose, quality = 1.5 }: Props) => {
<Isoflow
editorMode="NON_INTERACTIVE"
onModelUpdated={exportImage}
initialData={{
...model,
zoom: previewParams.zoom * quality,
scroll: getTileScrollPosition(previewParams.scrollTarget)
}}
initialData={{ ...model, fitToView: true }}
/>
</Box>
</Box>

View File

@@ -13,11 +13,15 @@ import { UiElement } from 'src/components/UiElement/UiElement';
import { IconButton } from 'src/components/IconButton/IconButton';
import { useUiStateStore } from 'src/stores/uiStateStore';
import { exportAsJSON, modelFromModelStore } from 'src/utils';
import { useModel } from 'src/hooks/useModel';
import { useInitialDataManager } from 'src/hooks/useInitialDataManager';
import { useModelStore } from 'src/stores/modelStore';
import { MenuItem } from './MenuItem';
export const MainMenu = () => {
const [anchorEl, setAnchorEl] = useState<null | HTMLElement>(null);
const model = useModelStore((state) => {
return modelFromModelStore(state);
});
const isMainMenuOpen = useUiStateStore((state) => {
return state.isMainMenuOpen;
});
@@ -27,7 +31,7 @@ export const MainMenu = () => {
const uiStateActions = useUiStateStore((state) => {
return state.actions;
});
const model = useModel();
const initialDataManager = useInitialDataManager();
const onToggleMenu = useCallback(
(event: React.MouseEvent<HTMLButtonElement>) => {
@@ -41,7 +45,7 @@ export const MainMenu = () => {
window.open(url, '_blank');
}, []);
const { load } = model;
const { load } = initialDataManager;
const onOpenModel = useCallback(async () => {
const fileInput = document.createElement('input');
@@ -71,7 +75,7 @@ export const MainMenu = () => {
}, [uiStateActions, load]);
const onExportAsJSON = useCallback(async () => {
exportAsJSON(modelFromModelStore(model));
exportAsJSON(model);
uiStateActions.setIsMainMenuOpen(false);
}, [model, uiStateActions]);
@@ -80,7 +84,7 @@ export const MainMenu = () => {
uiStateActions.setDialog('EXPORT_IMAGE');
}, [uiStateActions]);
const { clear } = model;
const { clear } = initialDataManager;
const onClearCanvas = useCallback(() => {
clear();

View File

@@ -1,6 +1,6 @@
import {
Size,
Model,
InitialData,
MainMenuOptions,
Icon,
Connector,
@@ -10,8 +10,8 @@ import {
Rectangle,
Colors
} from 'src/types';
import { CoordsUtils } from 'src/utils';
import { customVars } from './styles/theme';
import { CoordsUtils } from './utils';
// TODO: This file could do with better organisation and convention for easier reading.
export const UNPROJECTED_TILE_SIZE = 100;
@@ -72,13 +72,14 @@ export const MIN_ZOOM = 0.2;
export const MAX_ZOOM = 1;
export const TRANSFORM_ANCHOR_SIZE = 30;
export const TRANSFORM_CONTROLS_COLOR = '#0392ff';
export const INITIAL_DATA: Model = {
export const INITIAL_DATA: InitialData = {
title: 'Untitled',
version: '',
icons: [],
colors: [DEFAULT_COLOR],
items: [],
views: []
views: [],
fitToView: false
};
export const INITIAL_UI_STATE = {
zoom: 1,
@@ -105,5 +106,4 @@ export const DEFAULT_ICON: Icon = {
};
export const DEFAULT_LABEL_HEIGHT = 20;
export const EDITOR_CONFIG = {};
export const PROJECT_BOUNDING_BOX_PADDING = 3;

View File

@@ -3,5 +3,5 @@ import Isoflow from 'src/Isoflow';
import { initialData } from '../initialData';
export const BasicEditor = () => {
return <Isoflow initialData={initialData} />;
return <Isoflow initialData={{ ...initialData, fitToView: true }} />;
};

View File

@@ -2,19 +2,13 @@ import { useCallback } from 'react';
import { useUiStateStore } from 'src/stores/uiStateStore';
import { Size, Coords } from 'src/types';
import {
getBoundingBox,
getBoundingBoxSize,
sortByPosition,
clamp,
getTilePosition,
getUnprojectedBounds as getUnprojectedBoundsUtil,
getFitToViewParams as getFitToViewParamsUtil,
CoordsUtils
} from 'src/utils';
import { MAX_ZOOM } from 'src/config';
import { useScene } from 'src/hooks/useScene';
import { useResizeObserver } from './useResizeObserver';
const BOUNDING_BOX_PADDING = 3;
export const useDiagramUtils = () => {
const scene = useScene();
const rendererEl = useUiStateStore((state) => {
@@ -25,111 +19,24 @@ export const useDiagramUtils = () => {
return state.actions;
});
const getProjectBounds = useCallback((): Coords[] => {
const itemTiles = scene.items.map((item) => {
return item.tile;
});
const connectorTiles = scene.connectors.reduce<Coords[]>(
(acc, connector) => {
return [
...acc,
connector.path.rectangle.from,
connector.path.rectangle.to
];
},
[]
);
const rectangleTiles = scene.rectangles.reduce<Coords[]>(
(acc, rectangle) => {
return [...acc, rectangle.from, rectangle.to];
},
[]
);
const textBoxTiles = scene.textBoxes.reduce<Coords[]>((acc, textBox) => {
return [
...acc,
textBox.tile,
CoordsUtils.add(textBox.tile, {
x: textBox.size.width,
y: textBox.size.height
})
];
}, []);
let allTiles = [
...itemTiles,
...connectorTiles,
...rectangleTiles,
...textBoxTiles
];
if (allTiles.length === 0) {
const centerTile = CoordsUtils.zero();
allTiles = [centerTile, centerTile, centerTile, centerTile];
}
const corners = getBoundingBox(allTiles, {
x: BOUNDING_BOX_PADDING,
y: BOUNDING_BOX_PADDING
});
return corners;
}, [scene]);
const getUnprojectedBounds = useCallback((): Size & Coords => {
const projectBounds = getProjectBounds();
const cornerPositions = projectBounds.map((corner) => {
return getTilePosition({
tile: corner
});
});
const sortedCorners = sortByPosition(cornerPositions);
const topLeft = { x: sortedCorners.lowX, y: sortedCorners.lowY };
const size = getBoundingBoxSize(cornerPositions);
return {
width: size.width,
height: size.height,
x: topLeft.x,
y: topLeft.y
};
}, [getProjectBounds]);
return getUnprojectedBoundsUtil(scene.currentView);
}, [scene.currentView]);
const getFitToViewParams = useCallback(
(viewportSize: Size) => {
const projectBounds = getProjectBounds();
const sortedCornerPositions = sortByPosition(projectBounds);
const boundingBoxSize = getBoundingBoxSize(projectBounds);
const unprojectedBounds = getUnprojectedBounds();
const newZoom = clamp(
Math.min(
viewportSize.width / unprojectedBounds.width,
viewportSize.height / unprojectedBounds.height
),
0,
MAX_ZOOM
);
const scrollTarget: Coords = {
x: (sortedCornerPositions.lowX + boundingBoxSize.width / 2) * newZoom,
y: (sortedCornerPositions.lowY + boundingBoxSize.height / 2) * newZoom
};
return {
zoom: newZoom,
scrollTarget
};
return getFitToViewParamsUtil(scene.currentView, viewportSize);
},
[getProjectBounds, getUnprojectedBounds]
[scene.currentView]
);
const fitToView = useCallback(async () => {
const { zoom, scrollTarget } = getFitToViewParams(rendererSize);
const { zoom, scroll } = getFitToViewParams(rendererSize);
uiStateActions.scrollToTile(scrollTarget);
uiStateActions.setScroll({
position: scroll,
offset: CoordsUtils.zero()
});
uiStateActions.setZoom(zoom);
}, [uiStateActions, getFitToViewParams, rendererSize]);

View File

@@ -1,8 +1,7 @@
import { useMemo, useCallback } from 'react';
import { IconCollectionStateWithIcons, IconCollectionState } from 'src/types';
import { useMemo } from 'react';
import { IconCollectionStateWithIcons } from 'src/types';
import { useUiStateStore } from 'src/stores/uiStateStore';
import { useModelStore } from 'src/stores/modelStore';
import { categoriseIcons } from 'src/utils';
export const useIconCategories = () => {
const icons = useModelStore((state) => {
@@ -11,22 +10,6 @@ export const useIconCategories = () => {
const iconCategoriesState = useUiStateStore((state) => {
return state.iconCategoriesState;
});
const uiActions = useUiStateStore((state) => {
return state.actions;
});
const setIconCategoriesState = useCallback(() => {
const categoriesState: IconCollectionState[] = categoriseIcons(icons).map(
(collection) => {
return {
id: collection.name,
isExpanded: false
};
}
);
uiActions.setIconCategoriesState(categoriesState);
}, [icons, uiActions]);
const iconCategories = useMemo<IconCollectionStateWithIcons[]>(() => {
return iconCategoriesState.map((collection) => {
@@ -40,7 +23,6 @@ export const useIconCategories = () => {
}, [icons, iconCategoriesState]);
return {
iconCategories,
setIconCategoriesState
iconCategories
};
};

View File

@@ -1,14 +1,20 @@
import { useCallback, useState, useRef } from 'react';
import { InitialData } from 'src/types';
import { InitialData, IconCollectionState } from 'src/types';
import { INITIAL_DATA, VIEW_DEFAULTS } from 'src/config';
import { generateId } from 'src/utils';
import {
generateId,
getFitToViewParams,
CoordsUtils,
categoriseIcons
} from 'src/utils';
import { createView } from 'src/stores/reducers';
import { useModelStore } from 'src/stores/modelStore';
import { useView } from 'src/hooks/useView';
import { useUiStateStore } from 'src/stores/uiStateStore';
import { modelSchema } from 'src/schemas/model';
import { useResizeObserver } from './useResizeObserver';
export const useModel = () => {
export const useInitialDataManager = () => {
const [isReady, setIsReady] = useState(false);
const prevInitialData = useRef<InitialData>();
const model = useModelStore((state) => {
@@ -17,7 +23,11 @@ export const useModel = () => {
const uiStateActions = useUiStateStore((state) => {
return state.actions;
});
const rendererEl = useUiStateStore((state) => {
return state.rendererEl;
});
const { changeView } = useView();
const { size } = useResizeObserver(rendererEl);
const load = useCallback(
(_initialData: InitialData) => {
@@ -45,13 +55,35 @@ export const useModel = () => {
Object.assign(initialData, updates);
}
prevInitialData.current = initialData;
model.actions.set(initialData);
changeView(initialData.views[0].id, initialData);
if (initialData.fitToView) {
const { zoom, scroll } = getFitToViewParams(initialData.views[0], size);
uiStateActions.setScroll({
position: scroll,
offset: CoordsUtils.zero()
});
uiStateActions.setZoom(zoom);
}
const categoriesState: IconCollectionState[] = categoriseIcons(
initialData.icons
).map((collection) => {
return {
id: collection.name,
isExpanded: false
};
});
uiStateActions.setIconCategoriesState(categoriesState);
setIsReady(true);
},
[changeView, model.actions]
[changeView, model.actions, size, uiStateActions]
);
const clear = useCallback(() => {
@@ -62,7 +94,6 @@ export const useModel = () => {
return {
load,
clear,
isReady,
...model
isReady
};
};

View File

@@ -1,11 +1,7 @@
import { produce } from 'immer';
import { TextBox } from 'src/types';
import { getItemByIdOrThrow, getTextWidth } from 'src/utils';
import {
DEFAULT_FONT_FAMILY,
TEXTBOX_DEFAULTS,
TEXTBOX_FONT_WEIGHT
} from 'src/config';
import { getItemByIdOrThrow, getTextBoxDimensions } from 'src/utils';
import { State } from './types';
export const syncTextBox = (
@@ -17,15 +13,9 @@ export const syncTextBox = (
const view = getItemByIdOrThrow(draft.model.views, viewId);
const textBox = getItemByIdOrThrow(view.value.textBoxes ?? [], id);
const width = getTextWidth(textBox.value.content, {
fontSize: textBox.value.fontSize ?? TEXTBOX_DEFAULTS.fontSize,
fontFamily: DEFAULT_FONT_FAMILY,
fontWeight: TEXTBOX_FONT_WEIGHT
});
const height = 1;
const size = { width, height };
const textBoxSize = getTextBoxDimensions(textBox.value);
draft.scene.textBoxes[textBox.value.id] = { size };
draft.scene.textBoxes[textBox.value.id] = { size: textBoxSize };
});
return newState;

View File

@@ -4,8 +4,7 @@ import {
CoordsUtils,
incrementZoom,
decrementZoom,
getStartingMode,
getTileScrollPosition
getStartingMode
} from 'src/utils';
import { UiStateStore } from 'src/types';
import { INITIAL_UI_STATE } from 'src/config';
@@ -78,14 +77,6 @@ const initialState = () => {
setScroll: ({ position, offset }) => {
set({ scroll: { position, offset: offset ?? get().scroll.offset } });
},
scrollToTile: (tile, origin) => {
const scrollTo = getTileScrollPosition(tile, origin);
get().actions.setScroll({
offset: CoordsUtils.zero(),
position: scrollTo
});
},
setItemControls: (itemControls) => {
set({ itemControls });
},
@@ -95,7 +86,7 @@ const initialState = () => {
setMouse: (mouse) => {
set({ mouse });
},
setenableDebugTools: (enableDebugTools) => {
setEnableDebugTools: (enableDebugTools) => {
set({ enableDebugTools });
},
setRendererEl: (el) => {

View File

@@ -1,10 +1,8 @@
import { Coords } from 'src/types';
import type { EditorModeEnum, MainMenuOptions } from './common';
import type { Model } from './model';
export type InitialData = Model & {
zoom?: number;
scroll?: Coords;
fitToView?: boolean;
};
export interface IsoflowProps {

View File

@@ -1,6 +1,6 @@
import { Coords, EditorModeEnum, MainMenuOptions } from './common';
import { Icon } from './model';
import { TileOrigin, ItemReference } from './scene';
import { ItemReference } from './scene';
interface AddItemControls {
type: 'ADD_ITEM';
@@ -161,12 +161,11 @@ export interface UiStateActions {
setDialog: (dialog: keyof typeof DialogTypeEnum | null) => void;
setZoom: (zoom: number) => void;
setScroll: (scroll: Scroll) => void;
scrollToTile: (tile: Coords, origin?: TileOrigin) => void;
setItemControls: (itemControls: ItemControls | null) => void;
setContextMenu: (contextMenu: ContextMenu | null) => void;
setMouse: (mouse: Mouse) => void;
setRendererEl: (el: HTMLDivElement) => void;
setenableDebugTools: (enabled: boolean) => void;
setEnableDebugTools: (enabled: boolean) => void;
}
export type UiStateStore = UiState & {

View File

@@ -6,7 +6,11 @@ import {
MAX_ZOOM,
MIN_ZOOM,
TEXTBOX_PADDING,
CONNECTOR_SEARCH_OFFSET
CONNECTOR_SEARCH_OFFSET,
DEFAULT_FONT_FAMILY,
TEXTBOX_DEFAULTS,
TEXTBOX_FONT_WEIGHT,
PROJECT_BOUNDING_BOX_PADDING
} from 'src/config';
import {
Coords,
@@ -538,6 +542,17 @@ export const getTextWidth = (text: string, fontProps: FontProps) => {
return (metrics.width + paddingX * 2) / UNPROJECTED_TILE_SIZE - 0.8;
};
export const getTextBoxDimensions = (textBox: TextBox): Size => {
const width = getTextWidth(textBox.content, {
fontSize: textBox.fontSize ?? TEXTBOX_DEFAULTS.fontSize,
fontFamily: DEFAULT_FONT_FAMILY,
fontWeight: TEXTBOX_FONT_WEIGHT
});
const height = 1;
return { width, height };
};
export const outermostCornerPositions: TileOrigin[] = [
'BOTTOM',
'RIGHT',
@@ -647,3 +662,102 @@ export const getConnectorDirectionIcon = (connectorTiles: Coords[]) => {
rotation
};
};
export const getProjectBounds = (
view: View,
padding = PROJECT_BOUNDING_BOX_PADDING
): Coords[] => {
const itemTiles = view.items.map((item) => {
return item.tile;
});
const connectors = view.connectors ?? [];
const connectorTiles = connectors.reduce<Coords[]>((acc, connector) => {
const path = getConnectorPath({ anchors: connector.anchors, view });
return [...acc, path.rectangle.from, path.rectangle.to];
}, []);
const rectangles = view.rectangles ?? [];
const rectangleTiles = rectangles.reduce<Coords[]>((acc, rectangle) => {
return [...acc, rectangle.from, rectangle.to];
}, []);
const textBoxes = view.textBoxes ?? [];
const textBoxTiles = textBoxes.reduce<Coords[]>((acc, textBox) => {
const size = getTextBoxDimensions(textBox);
return [
...acc,
textBox.tile,
CoordsUtils.add(textBox.tile, {
x: size.width,
y: size.height
})
];
}, []);
let allTiles = [
...itemTiles,
...connectorTiles,
...rectangleTiles,
...textBoxTiles
];
if (allTiles.length === 0) {
const centerTile = CoordsUtils.zero();
allTiles = [centerTile, centerTile, centerTile, centerTile];
}
const corners = getBoundingBox(allTiles, {
x: padding,
y: padding
});
return corners;
};
export const getUnprojectedBounds = (view: View) => {
const projectBounds = getProjectBounds(view);
const cornerPositions = projectBounds.map((corner) => {
return getTilePosition({
tile: corner
});
});
const sortedCorners = sortByPosition(cornerPositions);
const topLeft = { x: sortedCorners.lowX, y: sortedCorners.lowY };
const size = getBoundingBoxSize(cornerPositions);
return {
width: size.width,
height: size.height,
x: topLeft.x,
y: topLeft.y
};
};
export const getFitToViewParams = (view: View, viewportSize: Size) => {
const projectBounds = getProjectBounds(view);
const sortedCornerPositions = sortByPosition(projectBounds);
const boundingBoxSize = getBoundingBoxSize(projectBounds);
const unprojectedBounds = getUnprojectedBounds(view);
const zoom = clamp(
Math.min(
viewportSize.width / unprojectedBounds.width,
viewportSize.height / unprojectedBounds.height
),
0,
MAX_ZOOM
);
const scrollTarget: Coords = {
x: (sortedCornerPositions.lowX + boundingBoxSize.width / 2) * zoom,
y: (sortedCornerPositions.lowY + boundingBoxSize.height / 2) * zoom
};
const scroll = getTileScrollPosition(scrollTarget);
return {
zoom,
scroll
};
};