Для одного проекта потребовалось создать поле для таблицы с изменяемым размером колонок и строк.
Вот так выглядит готовое поле:

Сделать несложно:
  1. Создаем Пространство имен gridtv и прописываем пути:

  2. Создаем папки core/components/gridtv/tv/input/tpl/
  3. В core/components/gridtv/tv/input создаем файл gridtv.class.php:
    <?php
    if(!class_exists('GridTVRender')) {
        class GridTVRender extends modTemplateVarInputRender {
            public function getTemplate() {
                return $this->modx->getOption('core_path').'components/gridtv/tv/input/tpl/gridtv.tpl';
            }
            public function process($value,array $params = array()) {
            }
        }
    }
    return 'GridTVRender';

  4. В core/components/gridtv/tv/input/tpl создаем файл gridtv.tpl:
    <style>
    {literal}
    table.editable th {
      padding:5px 10px;
      background:#E4E9EE;
      font-weight:bold;
      height:20px;
    }
    table.editable td {
    background:#e9e9e9;
    padding:5px 10px;
    height:20px;
    }
    table.editable input{
      width:50px;
    }
    {/literal}
    </style>
    <table id="table{$tv->id}" class="editable" >
      <tbody id="tbody{$tv->id}">
    
    </tbody>
    </table>
    <input type="hidden" id="tv{$tv->id}" name="tv{$tv->id}" value="{$tv->value}">
    <script type="text/javascript">
    // <![CDATA[
    {literal}
      var data=[[{"type":"TH","value":"ТИРАЖ/ШИРИНА, мм"}]];
    {/literal}
    
    if(document.getElementById("tv{$tv->id}").value!=""){
    data=JSON.parse(document.getElementById("tv{$tv->id}").value.replace(/\'/gi,'"'));
    }
    console.log(data);
    var tbody=document.getElementById("tbody{$tv->id}");
    for(var r=0;r<data.length;r++){
    
      var tr=document.createElement("tr");
      if(r==0){
        tr.id="frow{$tv->id}";
      }
      for(var c=0;c<data[r].length;c++){
          if(undefined!=data[r][c]){
        var cell=document.createElement(data[r][c].type);
        var input=document.createElement("input");
        input.type="text";
      input.value=data[r][c].value;
        cell.appendChild(input);
        tr.appendChild(cell);
      }
      }
      if(r==0){
        var btn=document.createElement("button");
        btn.onclick=addColumn{$tv->id};
        btn.textContent="+";
        var btn2=document.createElement("button");
        btn2.onclick=deleteColumn{$tv->id};
        btn2.textContent="-";
        var cth=document.createElement("th");
        cth.id="cth{$tv->id}";
        cth.appendChild(btn);
        cth.appendChild(btn2);
        tr.appendChild(cth);
      }
      tbody.appendChild(tr);
    }
    var btn=document.createElement("button");
    btn.onclick=addRow{$tv->id};
    btn.textContent="+";
    var btn2=document.createElement("button");
    btn2.onclick=deleteRow{$tv->id};
    btn2.textContent="-";
    var rth=document.createElement("th");
    
    var tr=document.createElement("tr");
    tr.id="rth{$tv->id}";
    rth.appendChild(btn);
    rth.appendChild(btn2);
    tr.appendChild(rth);
    tbody.appendChild(tr);
    
    var inputs=document.getElementById("table{$tv->id}").getElementsByTagName("input");
    for(var k=0;k<inputs.length;k++){
      inputs[k].onkeyup=function(e){
        document.getElementById("tv{$tv->id}").value=tableToJson{$tv->id}();
      }
    }
    function addColumn{$tv->id}(){
      var node = document.getElementById("frow{$tv->id}");
      var input=document.createElement("input");
      input.type="text";
      input.onkeyup=function(e){
        document.getElementById("tv{$tv->id}").value=tableToJson{$tv->id}();
      }
      var th=document.createElement("th");
      th.appendChild(input);
    
      node.insertBefore(th,document.getElementById("cth{$tv->id}"));
      rebuidlTable{$tv->id}();
    
    }
    function addRow{$tv->id}(){
      var node = document.getElementById("tbody{$tv->id}");
    
      var tr=document.createElement("tr");
      var input=document.createElement("input");
      input.type="text";
      var th=document.createElement("th");
      th.appendChild(input);
      tr.appendChild(th);
      node.insertBefore(tr,document.getElementById("rth{$tv->id}"));
      rebuidlTable{$tv->id}();
    
    }
    function rebuidlTable{$tv->id}(){
      var frow=document.getElementById("frow{$tv->id}");
      var tbody=document.getElementById("tbody{$tv->id}");
      //console.log(tbody.childElementCount);
      var rows=tbody.childElementCount-2;
      var cols=frow.childElementCount-1;
    
    for(var i=1;i<=rows;i++){
      var cm=cols-tbody.children[i].childElementCount;
      for(var j=0;j<cm;j++){
      var td=document.createElement("td");
      var input=document.createElement("input");
      input.type="text";
    
      td.appendChild(input);
      tbody.children[i].appendChild(td);
    }
    
    }
    }
    function deleteRow{$tv->id}(){
      if(tbody.childElementCount>=3){
    tbody.children[tbody.childElementCount-2].remove();
    }
    }
    function deleteColumn{$tv->id}(){
    for(var i=0;i<tbody.childElementCount-1;i++){
      var rows=tbody.children[i].children;
    
      if((i>0)&&(rows.length>1)){
    
      rows[rows.length-1].remove();
    }else{
      if(rows.length>2){
      rows[rows.length-2].remove();
    }
    }
    }
    }
    function tableToJson{$tv->id}(){
      var arr=Array();
    
      var rows=document.getElementById("tbody{$tv->id}").children;
      for(var i=0;i<rows.length;i++){
    
        var cells=rows[i].children;
        arr[i]=Array();
        for(var j=0;j<cells.length;j++){
          if(cells[j].textContent!="+-"){
          if(cells[j].getElementsByTagName("input").length>0){
          var val=cells[j].getElementsByTagName("input")[0].value;
        }else{
          var val=cells[j].textContent;
        }
          {literal}
          arr[i].push({"type":cells[j].tagName,"value":val});
          {/literal}
        }
      }
    
      }
      if(arr[arr.length-1].length==0){
        arr.pop();
      }
      return JSON.stringify(arr).replace(/\"/gi,"'");
    }
    // ]]>
    </script>

  5. Создаем новый плагин и отмечаем указанные события:
    <?php
    $corePath = $modx->getOption('core_path',null,MODX_CORE_PATH).'components/gridtv/';
    switch ($modx->event->name) {
        case 'OnTVInputRenderList':
            $modx->event->output($corePath.'tv/input/');
            break;
        case 'OnTVOutputRenderList':
            $modx->event->output($corePath.'tv/output/');
            break;
        case 'OnTVInputPropertiesList':
            $modx->event->output($corePath.'tv/inputoptions/');
            break;
        case 'OnTVOutputRenderPropertiesList':
            $modx->event->output($corePath.'tv/properties/');
            break;
     
    }
  6. Создаем новую TV и выбираем тип gridtv
Написано на чистом JS, очень не хотелось возится с ExtJS. Синхронизация с полем идет по нажатию кнопки, так что аккуратнее с копированием мышкой.

На выходе TV дает JSON с полями «type»:«TH» или «TD» и «value» ячейки.

Обрабатывать можно, например, вот таким сниппетом:

<?php
$page = $modx->getObject('modResource', $modx->resource->id);
$data=$modx->fromJSON(str_replace("'",'"',$page->getTVValue("grid")));
if(count($data)>1){
$out='<table>
              <tbody>';
$frow=true;
foreach($data as $row){
    $out.="<tr>";
    foreach($row as $cell){
        if($frow){
            $out.='<th class="first_row">'.$cell['value'].'</th>';
        }else{
            if($cell['type']=="TH"){
                $out.='<th>'.$cell['value'].'</th>';
            }else{
                $out.='<td>'.$cell['value'].'</td>';
            }
        }
        
    }

    $out.="</tr>";
    $frow=false;
}
echo $out."</tbody></table>";
}