Skip to content

Commit

Permalink
#3 Import SVG from file
Browse files Browse the repository at this point in the history
  • Loading branch information
langonginc committed Jun 9, 2024
1 parent 5f818f7 commit 99f6d58
Show file tree
Hide file tree
Showing 7 changed files with 194 additions and 80 deletions.
18 changes: 12 additions & 6 deletions src/components/app-root.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import React from 'react';
import { Button, Flex } from '@chakra-ui/react';
import { Button, Flex, HStack } from '@chakra-ui/react';
import WindowHeader from './header/window-header';
import { RmgPage, RmgErrorBoundary, RmgThemeProvider, RmgWindow } from '@railmapgen/rmg-components';
import { useTranslation } from 'react-i18next';
Expand All @@ -22,6 +22,7 @@ export default function AppRoot() {
paletteAppClip: { input },
globalAlerts,
} = useRootSelector(state => state.runtime);
const [isDetailsOpen, setDetailsOpen] = React.useState(false);
const [openExport, setOpenExport] = React.useState(false);

return (
Expand All @@ -33,13 +34,18 @@ export default function AppRoot() {
<Flex direction="row" height="100%" overflow="hidden" sx={{ position: 'relative' }}>
<ToolsPanel />
<SvgWrapper />
<RmpDetails />
<RmpDetails isOpen={isDetailsOpen} onClose={() => setDetailsOpen(false)} />
</Flex>
<Flex p={2} direction="row" height="100%" overflow="hidden" sx={{ position: 'relative' }}>
<Settings />
<Button onClick={() => setOpenExport(true)} isDisabled={globalAlerts.size !== 0}>
Export
</Button>
<HStack>
<Settings />
<Button onClick={() => setOpenExport(true)} isDisabled={globalAlerts.size !== 0}>
Export
</Button>
<Button hidden={isDetailsOpen} onClick={() => setDetailsOpen(true)}>
Open RMP Details
</Button>
</HStack>
</Flex>
<Flex direction="row" height="100%" overflow="hidden" sx={{ position: 'relative' }}>
<DetailsSvgs />
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,20 +16,9 @@ import { nanoid } from '../../util/helper';
import { useRootDispatch } from '../../redux';
import { setSvgs } from '../../redux/param/param-slice';

export const ImportFromSvg = (props: { isOpen: boolean; onClose: () => void }) => {
const { isOpen, onClose } = props;
const { t } = useTranslation();
const dispatch = useRootDispatch();

const [svgString, setSvgString] = React.useState('');
const field: RmgFieldsField[] = [
{
label: 'SVG',
type: 'textarea',
value: '',
onChange: v => setSvgString(v),
},
];
export const loadSvgs = (svgString: string) => {
const parser = new DOMParser();
const doc = parser.parseFromString(svgString, 'image/svg+xml');

function convertStyleStringToObject(style: any): Record<string, string> {
if (typeof style === 'object') {
Expand All @@ -53,44 +42,58 @@ export const ImportFromSvg = (props: { isOpen: boolean; onClose: () => void }) =
return {};
}

const handleImport = () => {
const parser = new DOMParser();
const doc = parser.parseFromString(svgString, 'image/svg+xml');

const dfs = (element: Element): SvgsElem => {
const attributes: Record<string, string> = {};
Array.from(element.attributes).forEach(attr => {
if (attr.name === 'style') {
console.log(convertStyleStringToObject(attr.value));
attributes[attr.name] = JSON.stringify(convertStyleStringToObject(attr.value));
} else {
attributes[attr.name] = `"${attr.value}"`;
}
});

if (element.tagName !== 'g' && element.textContent) {
attributes['_rmp_children_text'] = `"${element.textContent}"`;
const dfs = (element: Element): SvgsElem => {
const attributes: Record<string, string> = {};
Array.from(element.attributes).forEach(attr => {
if (attr.name === 'style') {
console.log(convertStyleStringToObject(attr.value));
attributes[attr.name] = JSON.stringify(convertStyleStringToObject(attr.value));
} else {
attributes[attr.name] = `"${attr.value}"`;
}
});

const children: SvgsElem[] = [];
Array.from(element.children).forEach(child => {
children.push(dfs(child));
});
if (element.tagName !== 'g' && element.textContent) {
attributes['_rmp_children_text'] = `"${element.textContent}"`;
}

const children: SvgsElem[] = [];
Array.from(element.children).forEach(child => {
children.push(dfs(child));
});

return {
id: `id_${nanoid(10)}`,
type: element.tagName,
attrs: attributes,
children: children.length === 0 ? undefined : children,
};
return {
id: `id_${nanoid(10)}`,
type: element.tagName,
attrs: attributes,
children: children.length === 0 ? undefined : children,
};
};

const svgRoot = doc.documentElement;
const svgElements = dfs(svgRoot);
console.log(svgElements);
if (svgElements.children) {
dispatch(setSvgs(svgElements.children));
}
const svgRoot = doc.documentElement;
const svgElements = dfs(svgRoot);
console.log(svgElements);

return svgElements.children ?? [];
};

export const ImportFromSvg = (props: { isOpen: boolean; onClose: () => void }) => {
const { isOpen, onClose } = props;
const { t } = useTranslation();
const dispatch = useRootDispatch();

const [svgString, setSvgString] = React.useState('');
const field: RmgFieldsField[] = [
{
label: 'SVG',
type: 'textarea',
value: '',
onChange: v => setSvgString(v),
},
];

const handleImport = () => {
dispatch(setSvgs(loadSvgs(svgString)));
onClose();
};

Expand Down
122 changes: 122 additions & 0 deletions src/components/header/open-actions.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
import { IconButton, Menu, MenuButton, MenuItem, MenuList } from '@chakra-ui/react';
import React from 'react';
import { MdOpenInBrowser, MdOpenInNew, MdOutlineImage, MdUpload } from 'react-icons/md';
import { useTranslation } from 'react-i18next';
import { useRootDispatch } from '../../redux';
import { setParam, setSvgs } from '../../redux/param/param-slice';
import { defaultParam, Param } from '../../constants/constants';
import { ImportFromSvg, loadSvgs } from './import-svg-modal';

export default function OpenActions() {
const { t } = useTranslation();
const dispatch = useRootDispatch();
const fileSvgInputRef = React.useRef<HTMLInputElement | null>(null);
const fileInputRef = React.useRef<HTMLInputElement | null>(null);
const [openImportSvg, setOpenImportSvg] = React.useState(false);

const loadParam = async (paramStr: string) => {
const param = JSON.parse(paramStr);
if (
'id' in param &&
'type' in param &&
'svgs' in param &&
Array.isArray(param.svgs) &&
'components' in param &&
Array.isArray(param.components)
) {
dispatch(setParam(param as Param));
} else {
throw new Error('Invalid param');
}
};

const handleUploadParam = async (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
console.log('OpenActions.handleUpload():: received file', file);

if (file?.type !== 'application/json') {
console.error('OpenActions.handleUpload():: Invalid file type! Only file in JSON format is accepted.');
} else {
try {
const paramStr = await readFileAsText(file);
await loadParam(paramStr);
} catch (err) {
console.error(
'OpenActions.handleUpload():: Unknown error occurred while parsing the uploaded file',
err
);
}
}

// clear field for next upload
event.target.value = '';
};
const handleUploadSvg = async (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
console.log('OpenActions.handleUpload():: received file', file);

if (file?.type !== 'image/svg+xml') {
console.error('OpenActions.handleUpload():: Invalid file type! Only file in JSON format is accepted.');
} else {
try {
const svgStr = await readFileAsText(file);
dispatch(setSvgs(loadSvgs(svgStr)));
} catch (err) {
console.error(
'OpenActions.handleUpload():: Unknown error occurred while parsing the uploaded file',
err
);
}
}

// clear field for next upload
event.target.value = '';
};

return (
<Menu id="upload">
<MenuButton as={IconButton} size="sm" variant="ghost" icon={<MdUpload />} />
<MenuList>
<MenuItem icon={<MdOpenInNew />} onClick={() => dispatch(setParam(defaultParam))}>
{t('header.import.new')}
</MenuItem>
<input
id="upload_param"
ref={fileInputRef}
type="file"
accept=".json"
hidden={true}
onChange={handleUploadParam}
data-testid="file-upload"
/>
<MenuItem icon={<MdUpload />} onClick={() => fileInputRef?.current?.click()}>
{t('header.import.uploadParam')}
</MenuItem>
<MenuItem icon={<MdOutlineImage />} onClick={() => setOpenImportSvg(true)}>
{t('header.import.pasteSVG')}
</MenuItem>
<input
id="upload_svg"
ref={fileSvgInputRef}
type="file"
accept=".svg"
hidden={true}
onChange={handleUploadSvg}
data-testid="file-upload"
/>
<MenuItem icon={<MdOpenInBrowser />} onClick={() => fileSvgInputRef?.current?.click()}>
{t('header.import.uploadSVG')}
</MenuItem>
</MenuList>
<ImportFromSvg isOpen={openImportSvg} onClose={() => setOpenImportSvg(false)} />
</Menu>
);
}

const readFileAsText = (file: File) => {
return new Promise((resolve: (text: string) => void) => {
const reader = new FileReader();
reader.onloadend = () => resolve(reader.result as string);
reader.readAsText(file);
});
};
21 changes: 4 additions & 17 deletions src/components/header/window-header.tsx
Original file line number Diff line number Diff line change
@@ -1,16 +1,15 @@
import { Heading, HStack, IconButton, Menu, MenuButton, MenuItem, MenuList } from '@chakra-ui/react';
import { Heading, HStack, IconButton } from '@chakra-ui/react';
import { RmgEnvBadge, RmgWindowHeader } from '@railmapgen/rmg-components';
import rmgRuntime from '@railmapgen/rmg-runtime';
import React from 'react';
import { useTranslation } from 'react-i18next';
import { MdHelp, MdOpenInNew, MdRedo, MdSave, MdUndo, MdUpload } from 'react-icons/md';
import { MdHelp, MdRedo, MdUndo } from 'react-icons/md';
import { useRootDispatch, useRootSelector } from '../../redux';
import { setParam } from '../../redux/param/param-slice';
import { backupParam, backupRedo, backupRemove, backupUndo } from '../../redux/runtime/runtime-slice';
import { defaultParam } from '../../constants/constants';
import AboutModal from './about-modal';
import { ZoomPopover } from './zoom-popover';
import { ImportFromSvg } from './import-modal';
import OpenActions from './open-actions';

export default function WindowHeader() {
const { t } = useTranslation();
Expand All @@ -22,7 +21,6 @@ export default function WindowHeader() {
const appVersion = rmgRuntime.getAppVersion();

const [openAbout, setOpenAbout] = React.useState(false);
const [openImportSvg, setOpenImportSvg] = React.useState(false);

return (
<RmgWindowHeader>
Expand Down Expand Up @@ -57,17 +55,7 @@ export default function WindowHeader() {
}}
/>
<ZoomPopover />
<Menu id="download">
<MenuButton as={IconButton} size="sm" variant="ghost" icon={<MdUpload />} />
<MenuList>
<MenuItem icon={<MdOpenInNew />} onClick={() => dispatch(setParam(defaultParam))}>
{t('header.import.new')}
</MenuItem>
<MenuItem icon={<MdSave />} onClick={() => setOpenImportSvg(true)}>
{t('header.import.pasteSVG')}
</MenuItem>
</MenuList>
</Menu>
<OpenActions />
<IconButton
size="sm"
variant="ghost"
Expand All @@ -78,7 +66,6 @@ export default function WindowHeader() {
/>
</HStack>
<AboutModal isOpen={openAbout} onClose={() => setOpenAbout(false)} />
<ImportFromSvg isOpen={openImportSvg} onClose={() => setOpenImportSvg(false)} />
</RmgWindowHeader>
);
}
7 changes: 4 additions & 3 deletions src/components/panel/details-rmp.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,8 @@ import { setColor, setComponentValue } from '../../redux/param/param-slice';
import { backupParam, openPaletteAppClip } from '../../redux/runtime/runtime-slice';
import ColourUtil from './colour-util';

export function RmpDetails() {
export function RmpDetails(props: { isOpen: boolean; onClose: () => void }) {
const { isOpen, onClose } = props;
const dispatch = useRootDispatch();
const param = useRootSelector(store => store.param);
const {
Expand Down Expand Up @@ -76,8 +77,8 @@ export function RmpDetails() {
const color = param.color?.value ?? param.color?.defaultValue;

return (
<RmgSidePanel isOpen={true} header="Dummy header">
<RmgSidePanelHeader onClose={() => {}}>{t('panel.details.header')}</RmgSidePanelHeader>
<RmgSidePanel isOpen={isOpen} header="Dummy header">
<RmgSidePanelHeader onClose={onClose}>{t('panel.details.header')}</RmgSidePanelHeader>
<RmgSidePanelBody>
<RmgFields fields={field} />
{param.color ? (
Expand Down
8 changes: 1 addition & 7 deletions src/components/panel/settings.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { RmgFields, RmgFieldsField } from '@railmapgen/rmg-components';
import { useTranslation } from 'react-i18next';
import { useRootDispatch, useRootSelector } from '../../redux';
import { setColor, setId, setType } from '../../redux/param/param-slice';
import { setColor, setType } from '../../redux/param/param-slice';
import { colorComponents } from '../../constants/components';

export function Settings() {
Expand All @@ -10,12 +10,6 @@ export function Settings() {
const { t } = useTranslation();

const field: RmgFieldsField[] = [
{
label: 'Class name (BjsubwayBasic, GzmtrBasic)',
type: 'input',
value: param.id,
onChange: value => dispatch(setId(value)),
},
{
label: 'Type',
type: 'select',
Expand Down
1 change: 1 addition & 0 deletions src/i18n/translations/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
"import": {
"new": "New",
"pasteSVG": "Paste SVG",
"uploadParam": "Upload project",
"uploadSVG": "Upload SVG"
}
},
Expand Down

0 comments on commit 99f6d58

Please sign in to comment.