Chapter 12

第8章 機能を実装してツールを仕上げる その2 -記号の更新・削除-

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

アイテムクリエイターにオプションを設定する

今までは処理の簡略化のためにオプションを1つも設定していませんでした(options:[]としていしていた)。ここではオプションを設定してみましょう。

長方形記号にテキストオプションを設定します。テキストオプションで指定した文字列は長方形記号の中に表示されるようにしましょう。

プロジェクトルート/src/sym/rect/creatorを編集します。

プロジェクトルート/src/sym/rect/creator.tsx
  import { Sym } from "redux/items/types";
  import itemCreator from "sym/base/creator";

  export default function rectCreator() :Sym{
      return {
          ...itemCreator("rect"),
          options:[
+             {name:"テキスト", value:"表示文字列", },
          ],
      } ;
  }

これをRectSymで表示してみます。

プロジェクトルート/src/sym/rect/RectSym.tsx
  import { Sym } from "redux/items/types";
  import SymBase, { SymChild, SymRender } from "sym/base/SymBase";

+ const Child :SymChild = ({itemId,item : _item})=>{
+     const item = _item as Sym ;
+     return (
+         <>
+             {item.options[0].name}
+             :
+             {item.options[0].value}
+         </>
+     )
  } ;

  const render :SymRender = (ctx,{size,color})=>{
      ctx.fillRect(0,0,size.width,size.height)
      ctx.strokeRect(0,0,size.width,size.height);
  } ;

  const RectSym = SymBase(Child,render) ;

  export default RectSym ;

これを編集できるようにしましょう。オプションの編集はサイドバーから行うようにします。

しかし通常フローチャート上には複数のアイテムが存在します。今回はどのアイテムのオプションを編集するのかをユーザが選択してから、選択したアイテムのオプションを編集できるように実装することにします。

ユーザが記号をクリックした場合、storeState.app.selectItemIdに選択したアイテムのIDを設定し、storeState.app.selectItemIdがnullでない場合にSideBarはstoreState.app.selectItemIdのアイテムのオプションを一覧で表示するようにしましょう。

ということでapp stateにselectItemIdを追加していきます。

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

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

  export const app = reducerWithInitialState(init) ;

なお何も選択されていない場合はselectItemIdにはnullを設定することにします。

つづいてselectorにselectItemIdを取得するセレクタを記述します。

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

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

つづいてselectItemIdを変更するアクションを記述します。

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

const actionCreator = actionCreatorFactory() ;

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

reducerでアクションを処理します。

プロジェクトルート/src/redux/app/reducers.ts
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),
} ;

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

最後にhookに処理をまとめます。

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

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

これでuseSelectItemを使えば選択中のアイテムの取得・更新ができるようになります。Sidebarに使ってみましょう。

プロジェクトルート/src/components/App/Sidebar.tsx
  import {FC} from "react" ; 
+ import { useSelectItem } from "redux/app/hooks";
+ import { useItem } from "redux/items/hooks";


  export interface SidebarProps{
  }

  const Sidebar :FC<SidebarProps> = ({})=>{
+   const {
+     selectItemId,
+   } = useSelectItem() ;
+   const selectItem = useItem(selectItemId??"") ;  //selectItemは後で使います
    return (
      <div>
+       {selectItemId?`選択中のアイテムは${selectItemId}です`:"アイテムが選択されていません"}
      </div>
    )
  } 
  export default Sidebar ;

あとはそれぞれの記号をクリックしたときに選択するようにしましょう。

プロジェクトルート/src/sym/base/SymBase.tsx
  import Box from "@mui/material/Box";
  import { FC, useEffect, useRef } from "react";
+ import { useSelectItem } from "redux/app/hooks";
  import { useItem } from "redux/items/hooks";
  import { Item, ItemId } from "redux/items/types";

  export const baseSetting = {
      size:{
          width:180,
          height:40,
          lineWidth:2,
      },
      color:{
          fore:"black",
          back:"white",
      },
  } ;
  const width = baseSetting.size.width ;
  const height = baseSetting.size.height ;

  export type SymChild = FC<{itemId:ItemId,item:Item}> ;
  export type SymRender = (
      ctx:CanvasRenderingContext2D,
      setting:typeof baseSetting,
  )=>void ;

  const SymBase = (Child:SymChild, render:SymRender)=>{
      const Sym :FC<{itemId:ItemId}> = ({itemId})=>{
          //アイテムの取得 -> Childに渡す
          const item = useItem(itemId) ;
          //render関連の処理
          const ref = useRef<HTMLCanvasElement>(null) ;
          useEffect(() => {
              const canvas = ref.current ;
              if(canvas){
                  const ctx = canvas.getContext("2d") ;
                  if(ctx){
                      const setting = baseSetting ;
                      ctx.fillStyle = setting.color.back ;
                      ctx.strokeStyle = setting.color.fore ;
                      ctx.lineWidth = setting.size.lineWidth ;
                    
                      //renderの呼び出し
                      render(ctx,setting) ;
                  }
              }
          }, []) ;
          //選択処理
+         const { changeSelectItemId, } = useSelectItem() ;
          return (
              <Box sx={{
                position:"relative",width:width,height:baseSetting.size,
                textAlign:"center",fontSize:"14px"}} 
+               onClick={()=>changeSelectItemId(itemId)}>
                    
                  <Box sx={{position:"absolute",left:0,top:0,width,height,}}>
                      <canvas 
                          ref={ref} 
                          width={width}
                          height={height}/>
                  </Box>
                  <Box sx={{position:"absolute",left:0,top:0,width,height,}}>
                    <Child itemId={itemId} item={item} />
                  </Box>

              </Box>
          ) ;
      } ;
      return Sym ;
  } ;

  export default SymBase ;

選択した記号がサイドバーで取得できました。ただこれではどの記号が選択されたのかが分かりずらいので選択された記号は青色で囲われるようにしましょう。書き込む色はctx.strokeStyleで指定しているので、その記号が選択中ならctx.strokeStyleが青色になるように変更します。

プロジェクトルート/src/sym/base/SymBase.tsx
  import Box from "@mui/material/Box";
  import { FC, useEffect, useRef } from "react";
  import { useSelectItem } from "redux/app/hooks";
  import { useItem } from "redux/items/hooks";
  import { Item, ItemId } from "redux/items/types";

  export const baseSetting = {
      size:{
          width:180,
          height:40,
          lineWidth:2,
      },
      color:{
          fore:"black",
          back:"white",
      },
  } ;
  const width = baseSetting.size.width ;
  const height = baseSetting.size.height ;

  export type SymChild = FC<{itemId:ItemId,item:Item}> ;
  export type SymRender = (
      ctx:CanvasRenderingContext2D,
      setting:typeof baseSetting,
  )=>void ;

  const SymBase = (Child:SymChild, render:SymRender)=>{
      const Sym :FC<{itemId:ItemId}> = ({itemId})=>{
          //選択処理
          const { selectItemId,changeSelectItemId, } = useSelectItem() ;
  
          //アイテムの取得 -> Childに渡す
          const item = useItem(itemId) ;
          //render関連の処理
          const ref = useRef<HTMLCanvasElement>(null) ;
          useEffect(() => {
              const canvas = ref.current ;
              if(canvas){
                  const ctx = canvas.getContext("2d") ;
                  if(ctx){
                      const setting = baseSetting ;
                      ctx.fillStyle = setting.color.back ;
                      ctx.strokeStyle = setting.color.fore ;
                      ctx.lineWidth = setting.size.lineWidth ;

+                     if(selectItemId === itemId){
+                         ctx.strokeStyle = "blue" ;
+                     }
                    
                      //renderの呼び出し
                      render(ctx,setting) ;
                  }
              }
          }, [itemId,selectItemId,]) ;
          return (
              <Box sx={{
                  position:"relative",width:width,height:baseSetting.size,
                  textAlign:"center",fontSize:"14px"}} 
                  onClick={()=>changeSelectItemId(itemId)}>
                    
                  <Box sx={{position:"absolute",left:0,top:0,width,height,}}>
                      <canvas 
                          ref={ref} 
                          width={width}
                          height={height}/>
                  </Box>
                  <Box sx={{position:"absolute",left:0,top:0,width,height,}}>
                      <Child itemId={itemId} item={item} />
                  </Box>

              </Box>
          ) ;
      } ;
      return Sym ;
  } ;

  export default SymBase ;

これで選択中の記号が分かりやすくなりました。

アイテムのオプションの更新更新機能を実装

あとはサイドバーでオプションを変更する処理を書いていきます。

とりあえずすべてのオプションをMUIのList,ListItemを使って表示します。

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


export interface SidebarProps{
}

const Sidebar :FC<SidebarProps> = ({})=>{
  const {
    selectItemId,
  } = useSelectItem() ;
  const selectItem = useItem(selectItemId??"") as Sym ;
  return (
    <div>
      {selectItemId?
      <List>
        {selectItem.options.map(option=>{
          return (
            <ListItem>
              {option.name}
              :
              {option.value}
            </ListItem>
          ) ;
        })}
      </List>
      :"アイテムが選択されていません"}
    </div>
  )
} 
export default Sidebar ;

あとはvalueをTextFieldにすればオプションを編集できるでしょう。

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


  export interface SidebarProps{
  }

  const Sidebar :FC<SidebarProps> = ({})=>{
    const {
      selectItemId,
    } = useSelectItem() ;
    const selectItem = useItem(selectItemId??"") as Sym ;
+   const handleOptionChange = (idx:number, e :ChangeEvent<HTMLInputElement|HTMLTextAreaElement>)=>{
+     //オプションの更新処理
+   }
    return (
      <div>
        {selectItemId?
        <List>
          {selectItem.options.map((option,idx)=>{
            return (
              <ListItem>
                {option.name}
                :
+               <TextField value={option.value} onChange={e=>handleOptionChange(idx,e)}/>
              </ListItem>
            ) ;
          })}
        </List>
        :"アイテムが選択されていません"}
      </div>
    )
  } 
  export default Sidebar ;

handleOptionChangeの第1引数idxはどのオプションを更新したいのかを示す数字です。記号オブジェクト.options[idx]でオプションオブジェクトにアクセスできます。

最後にオプションを更新します。

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


  export interface SidebarProps{
  }

  const Sidebar :FC<SidebarProps> = ({})=>{
    const {
      selectItemId,
    } = useSelectItem() ;
    const selectItem = useItem(selectItemId??"") as Sym ;
    const { setItem } = 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);
    }
    return (
      <div>
        {selectItemId?
        <List>
          {selectItem.options.map((option,idx)=>{
            return (
              <ListItem>
                {option.name}
                :
                <TextField value={option.value} onChange={e=>handleOptionChange(idx,e)}/>
              </ListItem>
            ) ;
          })}
        </List>
        :"アイテムが選択されていません"}
      </div>
    )
  } 
  export default Sidebar ;

handleOptionChangeでは少し複雑ですが、次のような処理を行っています。

  1. eから入力値を取得
  2. 更新前のオプションをnewOptions(更新後のオプション)としてコピー
  3. newOptionsのidx番目のvalueを入力地に上書き
  4. 更新後のオプションを反映したnewItemを作成
  5. selectItemId(選択中のアイテムのID)でアイテムを更新。(この時selectItemIdはnullの可能性があるので??を使ってnullならばIDが""のアイテムを更新させる)

これでオプションの更新ができました。

アイテムの削除機能を実装

アイテムの削除はサイドバーから行うことにします。

サイドバーに削除ボタンを作成し,handleRemoveSymで記号を削除します。

プロジェクトルート/src/components/App/Sidebar.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, SyntheticEvent} from "react" ; 
  import { useSelectItem } from "redux/app/hooks";
  import { useItem, useItemOperations, } from "redux/items/hooks";
  import { Sym } from "redux/items/types";


  export interface SidebarProps{
  }

  const Sidebar :FC<SidebarProps> = ({})=>{
    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?
        <>
        <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 Sidebar ;

ただしこのコードはエラーを引き起こします。

これはremoveItem関数(が呼び出すreducer)ではアイテム一覧からしかアイテムを削除せず、親フローのchildrenSymsからは削除しないために、親フローの描画時に存在しない(削除された)アイテムIDを参照しようとしてしまうからです。なので子アイテムをフローしたときに同時に親フローのchildrenSymsからも削除されるようにitems のreducerを変更します。

ただ削除するときに取得できるのは子アイテムのIDだけで、その子アイテムの親アイテムがどれなのか判別する方法がありません。そこで記号オブジェクトのプロパティとしてparentFlow(親フローのID)を設定し、記号オブジェクト作成時にそれを登録するように変更します。

プロジェクトルート/src/redux/items/types.ts
  export type ItemId = string ;

  export type OptionValue = 
      string | number | boolean | 
      string[] | number[] | boolean[] ;
  export interface Option {
      name:string;
      value:OptionValue ;
  }

  export interface Item {
      itemType:string;
  }

  export interface Sym extends Item {
      options:Option[];
+     parentFlow:ItemId,
  }

  export interface Flow extends Item {
      childrenSyms:ItemId[] ;
  }

  export type Items = {
      [key:ItemId] :Item ;
  } ;

  
プロジェクトルート/src/sym/rect/creator.tsx
+ import { ItemId, Sym } from "redux/items/types";
  import itemCreator from "sym/base/creator";

+ export default function rectCreator(parentFlow:ItemId) :Sym{
      return {
          ...itemCreator("rect"),
+         parentFlow,
          options:[
              {name:"テキスト", value:"表示文字列", },
          ],
      } ;
  }


プロジェクトルート/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 { 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)
      } ;
      return (
          <Stack direction="column">
              {flow.childrenSyms.map((symId, idx) => (
                  <>
                      {idx === 0 ? null : <Arrow />}
                      <Box sx={{display:"flex",justifyContent:"center",width:baseSetting.size.width}}>
                          <Button onClick={()=>handleAddSym(idx)} sx={{width:"fit-content"}}>追加</Button>
                      </Box>
                      <RectSym itemId={symId} />
                      <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 ? 
              <>
                  子要素がありません
                  <Button onClick={()=>handleAddSym(0)}>
                      記号を追加する
                  </Button>
              </>
              : ""}
          </Stack>
      );
  };
  export default FlowComp;

これで子アイテム(記号)からその親フローを取得することができます。取得した親フローのchildrenSymsからも該当記号を削除する処理をitemsのreducerに追加します。

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

  export const init :Items = {} ;

  export const items = reducerWithInitialState(init)
      .case( actions.set ,(state,payload)=>{
          const newState = {
              ...state,
              [payload.itemId] : payload.item,
          } ;
          return newState ;
      })
      .case( actions.remove ,(state,payload)=>{
          const deleteItem = state[payload.itemId] ;
          let newState = {...state} as Items ;
          let isUpdate = false ;
          //アイテム一覧から削除
          const keys = Object.keys(state)
          if(keys.includes(payload.itemId)){
              isUpdate = true ;
              delete newState[payload.itemId] ;
          }
+         //フローの場合はchildrenSymsからも削除
+         if(isSym(deleteItem)){  //isSym関数は後述のtypes.tsにて定義
+             const parentFlow = (newState[deleteItem.parentFlow]) as Flow ;
+             parentFlow.childrenSyms = parentFlow.childrenSyms.filter(symId=>symId!==payload.itemId) ;
+         }
+         //戻り値
+         if(isUpdate){
+             return newState ;
+         }
+         return state ;
+     })

ここで使用するisSymのような与えられた値がSymオブジェクトなのかどうかを判断する関数をいくつか用意したtypes.tsを示します。

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

  export type ItemId = string ;

  export type OptionValue = 
      string | number | boolean | 
      string[] | number[] | boolean[] ;
  export interface Option {
      name:string;
      value:OptionValue ;
  }

  export interface Item {
      itemType:string;
  }

  export interface Sym extends Item {
      options:Option[];
      parentFlow:ItemId,
  }

  export interface Flow extends Item {
      childrenSyms:ItemId[] ;
  }

  export type Items = {
      [key:ItemId] :Item ;
  } ;

+ export function isItem(arg: any):arg is Item{
+     return (
+         arg && 
+         typeof arg === "object" &&
+         typeof arg.itemType === "string"
+     ) ;
+ }

+ export function isSym(arg: any):arg is Sym{
+     return (
+         arg && 
+         arg.options instanceof Array && 
+         isItem(arg)
+     ) ;
+ }

+ export function isFlow(arg: any):arg is Flow{
+     return (
+         arg && 
+         arg.childrenSyms instanceof Array && 
+         isItem(arg)
+     ) ;
+ }

これで削除の処理も実装できました😁

これまでのソースコード
プロジェクトルート/src/sym/base/SymBase.tsx
import Box from "@mui/material/Box";
import { FC, useEffect, useRef } from "react";
import { useSelectItem } from "redux/app/hooks";
import { useItem } from "redux/items/hooks";
import { Item, ItemId } from "redux/items/types";

export const baseSetting = {
    size:{
        width:180,
        height:40,
        lineWidth:2,
    },
    color:{
        fore:"black",
        back:"white",
    },
} ;
const width = baseSetting.size.width ;
const height = baseSetting.size.height ;

export type SymChild = FC<{itemId:ItemId,item:Item}> ;
export type SymRender = (
    ctx:CanvasRenderingContext2D,
    setting:typeof baseSetting,
)=>void ;

const SymBase = (Child:SymChild, render:SymRender)=>{
    const Sym :FC<{itemId:ItemId}> = ({itemId})=>{
        //選択処理
        const { selectItemId,changeSelectItemId, } = useSelectItem() ;

        //アイテムの取得 -> Childに渡す
        const item = useItem(itemId) ;
        //render関連の処理
        const ref = useRef<HTMLCanvasElement>(null) ;
        useEffect(() => {
            const canvas = ref.current ;
            if(canvas){
                const ctx = canvas.getContext("2d") ;
                if(ctx){
                    const setting = baseSetting ;
                    ctx.fillStyle = setting.color.back ;
                    ctx.strokeStyle = setting.color.fore ;
                    ctx.lineWidth = setting.size.lineWidth ;

                    if(selectItemId === itemId){
                        ctx.strokeStyle = "blue" ;
                    }
                    
                    //renderの呼び出し
                    render(ctx,setting) ;
                }
            }
        }, [itemId, selectItemId]) ;
        return (
            <Box sx={{
                position:"relative",width:width,height:baseSetting.size,
                textAlign:"center",fontSize:"14px"}} 
                onClick={()=>changeSelectItemId(itemId)}>
                    
                <Box sx={{position:"absolute",left:0,top:0,width,height,}}>
                    <canvas 
                        ref={ref} 
                        width={width}
                        height={height}/>
                </Box>
                <Box sx={{position:"absolute",left:0,top:0,width,height,}}>
                    <Child itemId={itemId} item={item} />
                </Box>

            </Box>
        ) ;
    } ;
    return Sym ;
} ;

export default SymBase ;

プロジェクトルート/src/sym/rect/creator
import { ItemId, Sym } from "redux/items/types";
import itemCreator from "sym/base/creator";

export default function rectCreator(parentFlow:ItemId) :Sym{
    return {
        ...itemCreator("rect"),
        parentFlow,
        options:[
            {name:"テキスト", value:"表示文字列", },
        ],
    } ;
}


プロジェクトルート/src/sym/rect/RectSym.tsx
import { Sym } from "redux/items/types";
import SymBase, { SymChild, SymRender } from "sym/base/SymBase";

const Child :SymChild = ({itemId,item : _item})=>{
    const item = _item as Sym ;
    return (
        <>
            {item.options[0].name}
            :
            {item.options[0].value}
        </>
    )
} ;

const render :SymRender = (ctx,{size,color})=>{
    ctx.fillRect(0,0,size.width,size.height)
    ctx.strokeRect(0,0,size.width,size.height);
} ;

const RectSym = SymBase(Child,render) ;

export default RectSym ;

プロジェクトルート/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 { 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)
    } ;
    return (
        <Stack direction="column">
            {flow.childrenSyms.map((symId, idx) => (
                <>
                    {idx === 0 ? null : <Arrow />}
                    <Box sx={{display:"flex",justifyContent:"center",width:baseSetting.size.width}}>
                        <Button onClick={()=>handleAddSym(idx)} sx={{width:"fit-content"}}>追加</Button>
                    </Box>
                    <RectSym itemId={symId} />
                    <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 ? 
            <>
                子要素がありません
                <Button onClick={()=>handleAddSym(0)}>
                    記号を追加する
                </Button>
            </>
            : ""}
        </Stack>
    );
};
export default FlowComp;

プロジェクトルート/src/redux/app/reducers.ts
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),
} ;

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



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

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

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

const actionCreator = actionCreatorFactory() ;

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


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

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


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

export const init :Items = {} ;

export const items = reducerWithInitialState(init)
    .case( actions.set ,(state,payload)=>{
        const newState = {
            ...state,
            [payload.itemId] : payload.item,
        } ;
        return newState ;
    })
    .case( actions.remove ,(state,payload)=>{
        const deleteItem = state[payload.itemId] ;
        let newState = {...state} as Items ;
        let isUpdate = false ;
        //アイテム一覧から削除
        const keys = Object.keys(state)
        if(keys.includes(payload.itemId)){
            isUpdate = true ;
            delete newState[payload.itemId] ;
        }
        //フローの場合はchildrenSymsからも削除
        if(isSym(deleteItem)){
            const parentFlow = (newState[deleteItem.parentFlow]) as Flow ;
            parentFlow.childrenSyms = parentFlow.childrenSyms.filter(symId=>symId!==payload.itemId) ;
        }
        //戻り値
        if(isUpdate){
            return newState ;
        }
        return state ;
    })

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

export type ItemId = string ;

export type OptionValue = 
    string | number | boolean | 
    string[] | number[] | boolean[] ;
export interface Option {
    name:string;
    value:OptionValue ;
}

export interface Item {
    itemType:string;
}

export interface Sym extends Item {
    options:Option[];
    parentFlow:ItemId,
}

export interface Flow extends Item {
    childrenSyms:ItemId[] ;
}

export type Items = {
    [key:ItemId] :Item ;
} ;

export function isItem(arg: any):arg is Item{
    return (
        arg && 
        typeof arg === "object" &&
        typeof arg.itemType === "string"
    ) ;
}

export function isSym(arg: any):arg is Sym{
    return (
        arg && 
        arg.options instanceof Array && 
        isItem(arg)
    ) ;
}

export function isFlow(arg: any):arg is Flow{
    return (
        arg && 
        arg.childrenSyms instanceof Array && 
        isItem(arg)
    ) ;
}

プロジェクトルート/src/components/App/Sidebar.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, SyntheticEvent} from "react" ; 
import { useSelectItem } from "redux/app/hooks";
import { useItem, useItemOperations, } from "redux/items/hooks";
import { Sym } from "redux/items/types";


export interface SidebarProps{
}

const Sidebar :FC<SidebarProps> = ({})=>{
  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 Sidebar ;


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

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