Chapter 13

第8章 機能を実装してツールを仕上げる その3 -プレビューモードと画像出力-

てべすてん
てべすてん
2022.03.11に更新

この章では作成したユーザが作成したフローチャートを画像に出力するための機能を実装します。

プレビューモードの実装

今回フローチャートを実装する機能を実装するために表示されているDOM要素をスクリーンショットする方法をとります。画像として出力する場合、追加ボタンなどは不要なためプレビューモードを用意し、プレビューモード中は追加ボタンなどを非表示にするようソースコードを編集していきます。

まずプレビューモード中は非表示にしたいUIを列挙します。

  • 「フローを追加」ボタン
  • 「子要素がありません」の表示
  • 「記号を追加する」ボタン
  • 「追加」ボタン

これらを表示しているコンポーネント内で、プレビューモードならこれらを表示しないようにします。

また今がプレビューモードなのか編集モードなのかはstoreState.app.modeに保持することにします。

そのため、まずはreduxのstoreStateにmodeを追加します。またモードの種類もtypes.tsに定義しておきます。

プロジェクトルート/src/redux/app/types.ts

export type Mode = "edit" | "preview" ;

プロジェクトルート/src/redux/app/reducers.ts
+ import { Mode } from "./types";
  import { ItemId } from "redux/items/types";
  import { reducerWithInitialState } from "typescript-fsa-reducers";
  import * as actions from "./actions" ;

  export const init = {
      selectItemId:null as (null|ItemId),
+     mode:"edit" as Mode,
  } ;

  export const app = reducerWithInitialState(init)
      .case( actions.setSelectItemId, (state,payload)=>{
          if(state.selectItemId === payload.itemId){
              return state ;
          }
          return {
              ...state,
              selectItemId:payload.itemId,
          } ;
    } ) ;

つづいてモードを取得するselector,モードを変更するactionを定義します。

プロジェクトルート/src/redux/app/selectors.ts
  import { StoreState } from "redux/store";

  export const getSelectItemId = ()=>{
      return (store :StoreState)=>store.app.selectItemId ;
  } ;

+ export const getMode = ()=>{
+     return (store :StoreState)=>store.app.mode ;
+ } ;

プロジェクトルート/src/redux/app/actions.ts
  import { ItemId } from "redux/items/types";
  import actionCreatorFactory from "typescript-fsa" ;
+ import { Mode } from "./types";

  const actionCreator = actionCreatorFactory() ;

  //いつ使うのかわからないけど公式ドキュメントに載っていたのでおいておきます
  export const init = actionCreator("app/init");
  //選択中アイテムを更新
  export const setSelectItemId = actionCreator<{itemId:null|ItemId}>("app/selectItemId/set");

+ //モードを切り替える
+ export const setMode = actionCreator<{mode:Mode}>("app/mode/set") ;

reducerでactionsを処理します。

```diff tsx:プロジェクトルート/src/redux/app/reducers.ts
  import { Mode } from "./types";
  import { ItemId } from "redux/items/types";
  import { reducerWithInitialState } from "typescript-fsa-reducers";
  import * as actions from "./actions" ;

  export const init = {
      selectItemId:null as (null|ItemId),
      mode:"edit" as Mode,
  } ;

  export const app = reducerWithInitialState(init)
      .case( actions.setSelectItemId, (state,payload)=>{
          if(state.selectItemId === payload.itemId){
              return state ;
          }
          return {
              ...state,
              selectItemId:payload.itemId,
          } ;
      } )
+     .case( actions.setMode, (state,payload)=>{
+         if(state.mode === payload.mode){
+             return state ;
+         }
+         return {
+             ...state,
+             mode:payload.mode,
+         } ;
+     }) ;


後はhooksで扱いやすくします。

プロジェクトルート/src/redux/app/hooks.ts
  import { useDispatch, useSelector } from "react-redux";
  import { ItemId } from "redux/items/types";
  import { setSelectItemId,setMode } from "./actions";
+ import { getMode, getSelectItemId } from "./selectors";
+ import { Mode } from "./types";

  export function useSelectItem(){
      const selectItemId = useSelector(getSelectItemId());
      const dispatch = useDispatch() ;
      const changeSelectItemId = (itemId:null|ItemId)=>{
          dispatch(setSelectItemId({itemId}));
      }
      return {
          selectItemId,
          changeSelectItemId,
      } ;
  }

+ export function useMode() {
+     const mode = useSelector(getMode()) ;
+     const dispatch = useDispatch() ;
+     const set = (mode:Mode)=>{
+         dispatch(setMode(mode)) ;
+     } ;
+     return {
+         mode,
+         setMode:set,
+     } ;
+ }

これでmode stateが追加できました。先ほども示した通り、

  • 「フローを追加」ボタン
  • 「子要素がありません」の表示
  • 「記号を追加する」ボタン
  • 「追加」ボタン

これらを表示しているコンポーネントでuseModeを使って"edit"モード中のみ表示するように編集していきます。

「フローを追加」ボタンを表示しないように変更

「フローを追加」ボタンはBuildPanelで表示されているので編集していきます。

プロジェクトルート/src/components/App/BuildPanel.tsx
  import Box from "@mui/material/Box";
  import Button from "@mui/material/Button";
  import Stack from "@mui/material/Stack";
  import { FC } from "react" ; 
+ import { useMode } from "redux/app/hooks";
  import { useItems,useItemOperations } from "redux/items/hooks";
  import { Flow } from "redux/items/types";
  import { useFlows } from "redux/meta/hooks";
  import flowCreator from "sym/flow/creator";
  import FlowComp from "sym/flow/Flow";


  export interface BuildPanelProps{
  }

  const BuildPanel :FC<BuildPanelProps> = ({})=>{
    const {
      flows:flowIds,
      addFlow,
    } = useFlows();
    const flows = useItems(flowIds) ;
    const { setItem } = useItemOperations() ;
  
    //フローを追加する処理
    const handleAddFlow = ()=>{
      //フローオブジェクト作成
      const flow = flowCreator() ;
      //フローのIDを決定(ランダム)
      const flowId = `id-flow-${Math.floor(Math.random()*10000000)}` ;
      //idとオブジェクトを紐づける(setItem呼び出し)
      setItem(flowId,flow);
      //meta.flowsにフローのID追加する
      addFlow(flowId);
    } ;
  
    //previewモード中はボタンを表示しない
+   const {
+     mode,
+   } = useMode() ;
  
    return (
      <div>
        <Stack spacing={2}>
          {Object.entries(flows).map(([flowId,_item])=>{
            return (
              <FlowComp flowId={flowId}/>
            ) ;
          })}
+         {
+           mode!=="preview"?
            <Box sx={{border:"dashed 1px black",p:2,width:"fit-content"}}>
              <Button onClick={handleAddFlow}>フローを追加</Button>
            </Box>
+           :
+           ""
+         }
        </Stack>
      </div>
    )
  } 
  export default BuildPanel ;

「子要素がありません」,「追加」ボタン,「記号を追加する」ボタンを表示しない

つづいてFlow.tsxを編集します。

プロジェクトルート/src/sym/flow/Flow.tsx
  import Box from "@mui/material/Box";
  import Stack from "@mui/material/Stack";
  import Button from "components/util/Button";
  import { FC } from "react";
+ import { useMode } from "redux/app/hooks";
  import { useItem, useItemOperations } from "redux/items/hooks";
  import { ItemId,Flow, Sym } from "redux/items/types";
  import { baseSetting } from "sym/base/SymBase";
  import rectCreator from "sym/rect/creator";
  import RectSym from "sym/rect/RectSym";
  import { createRandomItemId } from "sym/util";
  import Arrow from "./Arrow";

  export interface FlowProps {
      flowId:ItemId,
  }
  
  const FlowComp: FC<FlowProps> = ({flowId}) => {
      const flow = useItem(flowId) as Flow;
      const { setItem } = useItemOperations() ;
      //追加処理
      const handleAddSym = (idx:number)=>{
          //子要素の追加処理
          //idxで指定した位置に追加する
          const sym :Sym = rectCreator(flowId) ;
          const symId = createRandomItemId(sym.itemType) ;
          setItem(symId,sym);
          const newChildrenSyms = [...flow.childrenSyms] ;
          newChildrenSyms.splice(idx,0,symId) ;
          const newFlow = {
              ...flow,
              childrenSyms:newChildrenSyms,
          } ;
          setItem(flowId,newFlow)
      } ;
+     const {mode} = useMode() ;
      return (
          <Stack direction="column">
              {flow.childrenSyms.map((symId, idx) => (
                  <>
                      {idx === 0 ? null : <Arrow />}
+                     {mode!=="preview"?
                      <Box sx={{display:"flex",justifyContent:"center",width:baseSetting.size.width}}>
                          <Button onClick={()=>handleAddSym(idx)} sx={{width:"fit-content"}}>追加</Button>
                      </Box>
+                     :""}
  
                      <RectSym itemId={symId} />
  
+                     {mode!=="preview"?
                      <Box sx={{display:"flex",justifyContent:"center",width:baseSetting.size.width}}>
                          <Button onClick={()=>handleAddSym(idx+1)} sx={{width:"fit-content"}}>追加</Button>
                      </Box>
+                     :""}
                  </>
              ))}
  
              {/* 子要素がない */}
+             {flow.childrenSyms.length === 0 && mode !== "preview"? 
              <>
                  子要素がありません
                  <Button onClick={()=>handleAddSym(0)}>
                      記号を追加する
                  </Button>
              </>
+             : ""}
          </Stack>
      );
  };
  export default FlowComp;

これでモードが切り替わるとこれらが非表示になるはずです。ヘッダーにモード切替ボタンを用意してモードを切り替えてみましょう。

プロジェクトルート/src/components/App/BuildPanel.tsx
  import AppBar, { AppBarProps } from "@mui/material/AppBar";
  import IconButton from "@mui/material/IconButton/IconButton";
  import Toolbar from "@mui/material/Toolbar/Toolbar";
  import MenuIcon from "@mui/icons-material/Menu" ;
+ import PreviewIcon from "@mui/icons-material/Preview" ;
+ import EditIcon from "@mui/icons-material/Edit" ;
  import { FC } from "react";
  import { useTitle } from "redux/meta/hooks";
  import TextField from "components/util/TextField";
+ import { useMode } from "redux/app/hooks";

  export type HeaderProps = {} & AppBarProps;

  const Header: FC<HeaderProps> = (props) => {
      const {title,setTitle} = useTitle() ;
+     //モード関係
+     const {mode,setMode} = useMode();
+     const handleToggleMode = ()=>{
+         if(mode === "edit") setMode("preview") 
+         if(mode === "preview") setMode("edit") 
+     } ;
      return (
          <AppBar position="static">
              <Toolbar>
                  <IconButton
                      size="large"
                      edge="start"
                      color="inherit"
                      sx={{ mr: 2 }}
                  >
                      <MenuIcon />
                  </IconButton>
                  <TextField value={title} onChange={e=>setTitle(e.target.value)} style={{flexGrow:1,color:"white"}}/> 
+                 <IconButton
+                     size="large"
+                     edge="start"
+                     color="inherit"
+                     sx={{ml:2}}
+                     onClick={handleToggleMode}>
+                     {
+                         mode==="edit"?
+                         <EditIcon />
+                         :
+                         mode==="preview"?
+                         <PreviewIcon />
+                         :
+                         "???"
+                     }
+                 </IconButton>
              </Toolbar>
          </AppBar>
      );
  };
  export default Header;

うまく非表示にできました!


画像を出力する

プレビューモードの時のみ「画像として出力する」ボタンをサイドバーに表示します。

ここでサイドバーの表示を考え直します。今まで表示していた記号のオプション編集は編集モード時に、プレビューモード時には出力系のメニューを表示するように変更します。また、それぞれのモード時の表示内容をEditSidebarPreviewSidebarとしてコンポーネントに切り抜きます。

プロジェクトルート/src/components/App/EditSidebar.tsx

//ほぼ変更前のSidebarと同じです

import List from "@mui/material/List";
import ListItem from "@mui/material/ListItem";
import Button from "components/util/Button";
import TextField from "components/util/TextField";
import {ChangeEvent, FC, } from "react" ; 
import { useSelectItem } from "redux/app/hooks";
import { useItem, useItemOperations, } from "redux/items/hooks";
import { Sym } from "redux/items/types";

export interface EditSidebarProps {}

const EditSidebar: FC<EditSidebarProps> = ({}) => {
    const {
        selectItemId,
      } = useSelectItem() ;
      const selectItem = useItem(selectItemId??"") as Sym ;
      const { setItem,removeItem } = useItemOperations();
      const handleOptionChange = (idx:number, e :ChangeEvent<HTMLInputElement|HTMLTextAreaElement>)=>{
        //オプションの更新処理
        const newValue = e.target.value ;
        const newOptions = [...selectItem.options] ;
        newOptions[idx] = {
          ...newOptions[idx],
          value:newValue,
        } ;
        const newItem = {
          ...selectItem,
          options:newOptions,
        } ;
        setItem(selectItemId??"",newItem);
      }
      const handleRemoveSym = ()=>{
        if(selectItemId){
          //親から削除
          //アイテム自体を削除
          removeItem(selectItemId);
        }
      } ;
      return (
        <div>
          {selectItemId && selectItem?
          <>
          <List>
              {selectItem.options.map((option,idx)=>{
                return (
                  <ListItem>
                    {option.name}
                    :
                    <TextField value={option.value} onChange={e=>handleOptionChange(idx,e)}/>
                  </ListItem>
                ) ;
              })}
            </List>
            <Button onClick={handleRemoveSym}>
              記号を削除する
            </Button>
          </>
          :"アイテムが選択されていません"}
        </div>
      )
    };
export default EditSidebar;

プロジェクトルート/src/components/App/PreviewSidebar.tsx
import Button from "components/util/Button";
import { FC } from "react";

export interface PreviewSidebarProps {}

const PreviewSidebar: FC<PreviewSidebarProps> = ({}) => {
    const handleDownloadImage = ()=>{
        //画像として保存する処理
    } ;
    return (
        <div>
            <Button onClick={handleDownloadImage}>
                画像として保存
            </Button>
        </div>
    );
};
export default PreviewSidebar;

プロジェクトルート/src/components/App/Sidebar.tsx
import { FC, } from "react" ; 
import { useMode } from "redux/app/hooks";
import EditSidebar from "./EditSidebar";
import PreviewSidebar from "./PreviewSidebar";


export interface SidebarProps{
}

const Sidebar :FC<SidebarProps> = ({})=>{
  const {mode} = useMode() ;
  return (
    <div>
      {mode==="edit"?<EditSidebar />:""}
      {mode==="preview"?<PreviewSidebar />:""}
    </div>
  )
} 
export default Sidebar ;

あとは画像に変換する処理を考えます。html-to-image便利なライブラリがあるみたいなのでそれを使って、以下の手順で行うことにします。

  1. html-to-imageをインストール
  2. 対象DOM要素にid"flowchart"を付与
  3. document.getElemnetById("flowchart")でDOM要素を取得し、それをhtml-to-imageのtoPng関数で画像に変換
  4. 画像をダウンロード

html-to-imageをインストールします。

html-to-imageのインストール
npm i html-to-image

対象DOM要素にid"flowchart"を付与します。

プロジェクトルート/src/components/App/BuildPanel.tsx
  import Box from "@mui/material/Box";
  import Button from "@mui/material/Button";
  import Stack from "@mui/material/Stack";
  import { FC } from "react" ; 
  import { useMode } from "redux/app/hooks";
  import { useItems,useItemOperations } from "redux/items/hooks";
  import { Flow } from "redux/items/types";
  import { useFlows } from "redux/meta/hooks";
  import flowCreator from "sym/flow/creator";
  import FlowComp from "sym/flow/Flow";


  export interface BuildPanelProps{
  }

  const BuildPanel :FC<BuildPanelProps> = ({})=>{
    const {
      flows:flowIds,
      addFlow,
    } = useFlows();
    const flows = useItems(flowIds) ;
    const { setItem } = useItemOperations() ;
  
    //フローを追加する処理
    const handleAddFlow = ()=>{
      //フローオブジェクト作成
      const flow = flowCreator() ;
      //フローのIDを決定(ランダム)
      const flowId = `id-flow-${Math.floor(Math.random()*10000000)}` ;
      //idとオブジェクトを紐づける(setItem呼び出し)
      setItem(flowId,flow);
      //meta.flowsにフローのID追加する
      addFlow(flowId);
    } ;
  
    //previewモード中はボタンを表示しない
    const {
      mode,
    } = useMode() ;
  
    return (
      <div>
+       <Stack spacing={2} id="flowchart">
          {Object.entries(flows).map(([flowId,_item])=>{
            return (
              <FlowComp flowId={flowId}/>
            ) ;
          })}
          {
            mode!=="preview"?
            <Box sx={{border:"dashed 1px black",p:2,width:"fit-content"}}>
              <Button onClick={handleAddFlow}>フローを追加</Button>
            </Box>
            :
            ""
          }
        </Stack>
      </div>
    )
  } 
  export default BuildPanel ;

#flowchartのDOM要素を画像に変換し、ダウンロードします。

プロジェクトルート/src/components/App/PreviewSidebar.tsx
import Button from "components/util/Button";
import { FC } from "react";

import {toPng} from "html-to-image" ;
import { useTitle } from "redux/meta/hooks";

export interface PreviewSidebarProps {}

function downloadImage(title:string, image:string){
    const aEle = document.createElement("a") ;
    aEle.download = title+".png" ;
    aEle.href = image ;
    aEle.click();
    aEle.remove();
}

const PreviewSidebar: FC<PreviewSidebarProps> = ({}) => {
    const {title} = useTitle() ;
    const handleDownloadImage = async ()=>{
        //DOM要素を取得
        const ele = document.getElementById("flowchart");
        if(ele){
            //画像に変換
            const image = await toPng(ele) ;
            //画像をダウンロード
            downloadImage(title,image) ;
        }else{
            alert("エラー:画像に変換できませんでした")
        }
    } ;
    return (
        <div>
            <Button onClick={handleDownloadImage}>
                画像として保存
            </Button>
        </div>
    );
};
export default PreviewSidebar;

これでボタンをクリックすると画像がダウンロードできました!

画像への変換はhtml-to-imageのtoPngを呼び出すだけなのでとても簡単ですね!

ここまでのソースコード
プロジェクトルート/src/redux/app/types.ts

export type Mode = "edit" | "preview" ;

プロジェクトルート/src/redux/app/reducers.ts
import { Mode } from "./types";
import { ItemId } from "redux/items/types";
import { reducerWithInitialState } from "typescript-fsa-reducers";
import * as actions from "./actions" ;

export const init = {
    selectItemId:null as (null|ItemId),
    mode:"edit" as Mode,
} ;

export const app = reducerWithInitialState(init)
    .case( actions.setSelectItemId, (state,payload)=>{
        if(state.selectItemId === payload.itemId){
            return state ;
        }
        return {
            ...state,
            selectItemId:payload.itemId,
        } ;
    } )
    .case( actions.setMode, (state,payload)=>{
        if(state.mode === payload.mode){
            return state ;
        }
        return {
            ...state,
            mode:payload.mode,
        } ;
    }) ;


プロジェクトルート/src/redux/app/selectors.ts
import { StoreState } from "redux/store";

export const getSelectItemId = ()=>{
    return (store :StoreState)=>store.app.selectItemId ;
} ;

export const getMode = ()=>{
    return (store :StoreState)=>store.app.mode ;
} ;

プロジェクトルート/src/redux/app/actions.ts
import { ItemId } from "redux/items/types";
import actionCreatorFactory from "typescript-fsa" ;
import { Mode } from "./types";

const actionCreator = actionCreatorFactory() ;

//いつ使うのかわからないけど公式ドキュメントに載っていたのでおいておきます
export const init = actionCreator("app/init");
//選択中アイテムを更新
export const setSelectItemId = actionCreator<{itemId:null|ItemId}>("app/selectItemId/set");

//モードを切り替える
export const setMode = actionCreator<{mode:Mode}>("app/mode/set") ;


プロジェクトルート/src/redux/app/hooks.ts
import { useDispatch, useSelector } from "react-redux";
import { ItemId } from "redux/items/types";
import { setSelectItemId,setMode } from "./actions";
import { getMode, getSelectItemId } from "./selectors";
import { Mode } from "./types";

export function useSelectItem(){
    const selectItemId = useSelector(getSelectItemId());
    const dispatch = useDispatch() ;
    const changeSelectItemId = (itemId:null|ItemId)=>{
        dispatch(setSelectItemId({itemId}));
    }
    return {
        selectItemId,
        changeSelectItemId,
    } ;
}

export function useMode() {
    const mode = useSelector(getMode()) ;
    const dispatch = useDispatch() ;
    const set = (mode:Mode)=>{
        dispatch(setMode({mode})) ;
    } ;
    return {
        mode,
        setMode:set,
    } ;
}

プロジェクトルート/src/sym/flow/Flow.tsx
import Box from "@mui/material/Box";
import Stack from "@mui/material/Stack";
import Button from "components/util/Button";
import { FC } from "react";
import { useMode } from "redux/app/hooks";
import { useItem, useItemOperations } from "redux/items/hooks";
import { ItemId,Flow, Sym } from "redux/items/types";
import { baseSetting } from "sym/base/SymBase";
import rectCreator from "sym/rect/creator";
import RectSym from "sym/rect/RectSym";
import { createRandomItemId } from "sym/util";
import Arrow from "./Arrow";

export interface FlowProps {
    flowId:ItemId,
}

const FlowComp: FC<FlowProps> = ({flowId}) => {
    const flow = useItem(flowId) as Flow;
    const { setItem } = useItemOperations() ;
    //追加処理
    const handleAddSym = (idx:number)=>{
        //子要素の追加処理
        //idxで指定した位置に追加する
        const sym :Sym = rectCreator(flowId) ;
        const symId = createRandomItemId(sym.itemType) ;
        setItem(symId,sym);
        const newChildrenSyms = [...flow.childrenSyms] ;
        newChildrenSyms.splice(idx,0,symId) ;
        const newFlow = {
            ...flow,
            childrenSyms:newChildrenSyms,
        } ;
        setItem(flowId,newFlow)
    } ;
    const {mode} = useMode() ;
    return (
        <Stack direction="column" sx={{width:"fit-content"}}>

            {flow.childrenSyms.map((symId, idx) => (
                <>
                    {idx === 0 ? null : <Arrow />}
                    {mode!=="preview"?
                    <Box sx={{display:"flex",justifyContent:"center",width:baseSetting.size.width}}>
                        <Button onClick={()=>handleAddSym(idx)} sx={{width:"fit-content"}}>追加</Button>
                    </Box>
                    :""}

                    <RectSym itemId={symId} />

                    {mode!=="preview"?
                    <Box sx={{display:"flex",justifyContent:"center",width:baseSetting.size.width}}>
                        <Button onClick={()=>handleAddSym(idx+1)} sx={{width:"fit-content"}}>追加</Button>
                    </Box>
                    :""}
                </>
            ))}

            {/* 子要素がない */}
            {flow.childrenSyms.length === 0 && mode !== "preview"? 
            <>
                子要素がありません
                <Button onClick={()=>handleAddSym(0)}>
                    記号を追加する
                </Button>
            </>
            : ""}
        </Stack>
    );
};
export default FlowComp;

プロジェクトルート/src/components/App/BuildPanel.tsx

import Box from "@mui/material/Box";
import Button from "@mui/material/Button";
import Stack from "@mui/material/Stack";
import { FC } from "react" ; 
import { useMode } from "redux/app/hooks";
import { useItems,useItemOperations } from "redux/items/hooks";
import { Flow } from "redux/items/types";
import { useFlows } from "redux/meta/hooks";
import flowCreator from "sym/flow/creator";
import FlowComp from "sym/flow/Flow";


export interface BuildPanelProps{
}

const BuildPanel :FC<BuildPanelProps> = ({})=>{
  const {
    flows:flowIds,
    addFlow,
  } = useFlows();
  const flows = useItems(flowIds) ;
  const { setItem } = useItemOperations() ;

  //フローを追加する処理
  const handleAddFlow = ()=>{
    //フローオブジェクト作成
    const flow = flowCreator() ;
    //フローのIDを決定(ランダム)
    const flowId = `id-flow-${Math.floor(Math.random()*10000000)}` ;
    //idとオブジェクトを紐づける(setItem呼び出し)
    setItem(flowId,flow);
    //meta.flowsにフローのID追加する
    addFlow(flowId);
  } ;

  //previewモード中はボタンを表示しない
  const {
    mode,
  } = useMode() ;

  return (
    <div>
      <Stack spacing={2} id="flowchart">
        {Object.entries(flows).map(([flowId,_item])=>{
          return (
            <FlowComp flowId={flowId}/>
          ) ;
        })}
        {
          mode!=="preview"?
          <Box sx={{border:"dashed 1px black",p:2,width:"fit-content"}}>
            <Button onClick={handleAddFlow}>フローを追加</Button>
          </Box>
          :
          ""
        }
      </Stack>
    </div>
  )
} 
export default BuildPanel ;


プロジェクトルート/src/components/App/EditSidebar.tsx
import List from "@mui/material/List";
import ListItem from "@mui/material/ListItem";
import Button from "components/util/Button";
import TextField from "components/util/TextField";
import {ChangeEvent, FC, } from "react" ; 
import { useSelectItem } from "redux/app/hooks";
import { useItem, useItemOperations, } from "redux/items/hooks";
import { Sym } from "redux/items/types";

export interface EditSidebarProps {}

const EditSidebar: FC<EditSidebarProps> = ({}) => {
    const {
        selectItemId,
      } = useSelectItem() ;
      const selectItem = useItem(selectItemId??"") as Sym ;
      const { setItem,removeItem } = useItemOperations();
      const handleOptionChange = (idx:number, e :ChangeEvent<HTMLInputElement|HTMLTextAreaElement>)=>{
        //オプションの更新処理
        const newValue = e.target.value ;
        const newOptions = [...selectItem.options] ;
        newOptions[idx] = {
          ...newOptions[idx],
          value:newValue,
        } ;
        const newItem = {
          ...selectItem,
          options:newOptions,
        } ;
        setItem(selectItemId??"",newItem);
      }
      const handleRemoveSym = ()=>{
        if(selectItemId){
          //親から削除
          //アイテム自体を削除
          removeItem(selectItemId);
        }
      } ;
      return (
        <div>
          {selectItemId && selectItem?
          <>
          <List>
              {selectItem.options.map((option,idx)=>{
                return (
                  <ListItem>
                    {option.name}
                    :
                    <TextField value={option.value} onChange={e=>handleOptionChange(idx,e)}/>
                  </ListItem>
                ) ;
              })}
            </List>
            <Button onClick={handleRemoveSym}>
              記号を削除する
            </Button>
          </>
          :"アイテムが選択されていません"}
        </div>
      )
    };
export default EditSidebar;

プロジェクトルート/src/components/App/PreviewSidebar.ts
import Button from "components/util/Button";
import { FC } from "react";

import {toPng} from "html-to-image" ;
import { useTitle } from "redux/meta/hooks";

export interface PreviewSidebarProps {}

function downloadImage(title:string, image:string){
    const aEle = document.createElement("a") ;
    aEle.download = title+".png" ;
    aEle.href = image ;
    aEle.click();
    aEle.remove();
}

const PreviewSidebar: FC<PreviewSidebarProps> = ({}) => {
    const {title} = useTitle() ;
    const handleDownloadImage = async ()=>{
        //DOM要素を取得
        const ele = document.getElementById("flowchart");
        if(ele){
            //画像に変換
            const image = await toPng(ele) ;
            //画像をダウンロード
            downloadImage(title,image) ;
        }else{
            alert("エラー:画像に変換できませんでした")
        }
    } ;
    return (
        <div>
            <Button onClick={handleDownloadImage}>
                画像として保存
            </Button>
        </div>
    );
};
export default PreviewSidebar;

プロジェクトルート/src/components/App/Sidebar.tsx
import { FC, } from "react" ; 
import { useMode } from "redux/app/hooks";
import EditSidebar from "./EditSidebar";
import PreviewSidebar from "./PreviewSidebar";


export interface SidebarProps{
}

const Sidebar :FC<SidebarProps> = ({})=>{
  const {mode} = useMode() ;
  return (
    <div>
      {mode==="edit"?<EditSidebar />:""}
      {mode==="preview"?<PreviewSidebar />:""}
    </div>
  )
} 
export default Sidebar ;

質問・指摘などはこちらから

https://zenn.dev/tbsten/scraps/7123b1257c2097