分享自己写的 HTML表格工具 给喜欢在Obsidian用HTML表格的小伙伴

一个用来建html表格框架的html工具,后面有时间给加上单元格富文本编辑功能。
目前只支持单元格单选或连选,插入删除或行列,表头加粗居中(“切换表头”功能,按一次加粗居中,按两次恢复原状),但重要的是能导出可以直接用在Obsidian的格式化简洁HTML代码。
:boom:选中单元格右击可以唤出右键菜单,不过目前会触发浏览器自带的右键菜单,不是很方便
:boom:合并、撤销操作按钮没有完善,建议不要用。


众所周知Obsidian的表格不好用,在尝试了一堆表格插件和Obsidian官方表格后最终还是拥抱了html表格,可以轻松实现各种样式,包括光标悬浮文本、合并单元格等等
imageimage
最重要的是能够缩进,


同时Obsidian专属的内链也可以通过直接插入对应的html代码实现,比如[[文档.docx]]的html代码就是<a data-href="文档.docx" href="文档.docx" class="internal-link" target="_blank" rel="noopener">文档.docx</a>,实现这个是为了用上Obsidian的悬浮预览功能。

但是每次新建html表格非常繁琐,找了许久也没有合适的插件,网上的在线html表格编辑复制的代码会带许多冗余的东西(各种标签的类、属性),所以自己写了个凑合用的小工具,复制内容保存为html文件在浏览器打开就可以用了。

<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<title>HTML表格编辑器</title>
<style>
  table {
    border-collapse: collapse;
    width: 100%;
  }
  th, td {
    border: 1px solid black;
    padding: 8px;
    text-align: left;
  }
  .selected {
    background-color: #f0f0f0;
  }
  h2{
      text-align: center;
  }
  .table{
      width: 90%;
      margin: 0 auto;
  }
</style>
</head>
<body>

  <h2>HTML表格编辑器</h2>
  <div style="display: flex; align-items: center; justify-content: center; margin: 10px; gap: 10px;">
      <label for="rows">行数:</label>
      <input type="number" id="rows" value="5">
      <label for="cols">列数:</label>
      <input type="number" id="cols" value="5">
      <button onclick="createTable()">创建表格</button>
  </div>
  <div id="tableContainer"></div>
  <div style="display: flex; align-items: center; justify-content: center; margin: 10px; gap: 10px;">
      <button onclick="copyHtml()">复制HTML代码</button>
      <button onclick="undo()">撤销</button>
      <button onclick="mergeCells()">合并单元格</button>
      <button id="insertAboveButton">向上插入行</button>
      <button id="insertBelowButton">向下插入行</button>
      <button id="insertLeftButton">向左插入列</button>
      <button id="insertRightButton">向右插入列</button>
      <button onclick="deleteRow()">删除行</button>
      <button onclick="deleteColumn()">删除列</button>
      <button onclick="toggleHeader()">切换表头</button>
  </div>
  <!-- 定义右键菜单 -->
  <div id="contextMenu" style="display: none; position: absolute;border: 1px solid black">
    <button id="insertAboveButton">向上插入行</button><br/>
    <button id="insertBelowButton">向下插入行</button><br/>
    <button id="insertLeftButton">向左插入列</button><br/>
    <button id="insertRightButton">向右插入列</button><br/>
    <button onclick="deleteRow()">删除当前行</button><br/>
    <button onclick="deleteColumn()">删除当前列</button>
  </div>


<script>
  let selectedCell = null;
  const historyStack = [];

  function createTable() {
    const rows = document.getElementById('rows').value;
    const cols = document.getElementById('cols').value;
    const table = document.createElement('table');
    table.className="table";    // 表格居中,限制宽度

    for (let i = 0; i < rows; i++) {
      const tr = document.createElement('tr');
      for (let j = 0; j < cols; j++) {
        const td = document.createElement('td');
        td.contentEditable = true;
        td.width = '100px';   // 限制宽度
        // td.textContent = `(${i + 1}, ${j + 1})`;
        td.addEventListener('click', () => selectCell(td));
        tr.appendChild(td);
      }
      table.appendChild(tr);
    }

    document.getElementById('tableContainer').innerHTML = '';
    document.getElementById('tableContainer').appendChild(table);
    setupTableEvents(table); // 绑定事件监听器  
    saveHistory();
  }

  // 选中单元格区域
  function setupTableEvents(table) {  
    let startCell = null;  
  
    table.addEventListener('mousedown', (e) => {  
      // 当用户在表格中按下鼠标左键时,获取目标单元格,并将其标记为 selected,同时添加 mousemove 和 mouseup 事件监听器。
      if (e.button === 0) {     // 0表示左键,1表示中键(滚轮按钮),2表示右键。
        const target = e.target;  
        if (target.tagName === 'TD') {  
          startCell = target;  
          startCell.classList.add('selected');  
          document.addEventListener('mousemove', onMouseMove);  
          document.addEventListener('mouseup', onMouseUp);  
          // console.log(selectedCell);
        }  
      }
    // 不管是不是左键,先清空表格中所有具有selected类单元格的selected类,避免之前用左键选择的区域未被清空格式
      clearSelection(table);
    });  
    
    // 当鼠标移动时,获取鼠标当前位置下的单元格,并调用 clearSelection 和 selectCells 函数来更新选中的单元格。
    function onMouseMove(e) {  
      if (e.button === 0) {     // 用户用左键点击单元格时才会触发函数
        const target = document.elementFromPoint(e.clientX, e.clientY);   // 获取鼠标当前位置下的单元格
        if (target && target.tagName === 'TD') {          // 判断 target 是否是一个<td>元素。
          if (startCell && startCell !== target) {        // 判断 startCell是否为空,光标的位置target是否为起点startCell
            clearSelection(table);                        // 若是,则移除表格中所有具有selected类单元格的selected类,然后调用selectCells函数来更新选中的单元格。
            selectCells(startCell, target);               // 但这种方式只能保证起点startCell与光标最远处的单元格target之间的单元格被选中,无法保证光标是否会重新回到起点或起点与最远处单元格之间的位置
          }  
          if (startCell == target) {  // 因此有必要进行二次验证,确保当光标最终回到起点或起点与最远处单元格之间的位置时,未被选中的单元格的selected类被清空。
            clearSelection(table);  
            selectCell(startCell);  
          }  
        }  
      }  
    }
    // 当用户释放鼠标按钮时,移除 mousemove 和 mouseup 事件监听器。
    function onMouseUp() {  
      document.removeEventListener('mousemove', onMouseMove);  
      document.removeEventListener('mouseup', onMouseUp);  
    }  
  
    // ---功能函数---
    // 移除表格中所有具有selected类单元格的selected类。
    function clearSelection(table) {  
      const selectedCells = table.querySelectorAll('.selected');  
      selectedCells.forEach(cell => cell.classList.remove('selected'));  
    }  
    // 光标回到起点时,选中起点单元格
    function selectCell(cell) {
      if (selectedCell) {       // selectedCell是外部变量,初值为空
        selectedCell.classList.remove('selected');
      }
      selectedCell = cell;
      selectedCell.classList.add('selected');
      clearSelection();
    }
    // 选择区域
    function selectCells(start, end) {  
      const startRow = start.parentNode.rowIndex;  
      const startCol = start.cellIndex;  
      const endRow = end.parentNode.rowIndex;  
      const endCol = end.cellIndex;  
  
      const rows = Math.abs(endRow - startRow) + 1;  // 计算行数
      const cols = Math.abs(endCol - startCol) + 1;  // 计算列数
  
      for (let i = 0; i < rows; i++) {  
        for (let j = 0; j < cols; j++) {  
          const cell = table.rows[Math.min(startRow, endRow) + i].cells[Math.min(startCol, endCol) + j];  
          cell.classList.add('selected');  
        }  
      }  
    }  
  }  
  
  // 选中单个单元格
  function selectCell(cell) {
    if (selectedCell) {
      selectedCell.classList.remove('selected');
      // 如果已选中的单元格selectedCell非空,则移除之前添加的mousedown事件监听器
      selectedCell.removeEventListener('mousedown', showContextMenu);
    }
    
    selectedCell = cell;
    selectedCell.classList.add('selected');

    selectedCell.addEventListener('mousedown', (e) => { // 展示右键菜单
      if (e.button === 2){
        showContextMenu(e)
      };
    })
  }
  // 初始化右键菜单
  const contextMenu = document.getElementById('contextMenu');
  contextMenu.addEventListener('click', hideContextMenu);
  document.addEventListener('click', hideContextMenu);
  // 显示右键菜单
  function showContextMenu(event) {
      event.preventDefault(); // 阻止浏览器的默认右键菜单弹出
      const menu = document.getElementById('contextMenu');
      menu.style.display = 'block';
      menu.style.left = `${event.pageX}px`;
      menu.style.top = `${event.pageY}px`;
      // console.log("11111")
    }
    // 隐藏右键菜单
  function hideContextMenu() {
    const menu = document.getElementById('contextMenu');
    menu.style.display = 'none';
  }



// 选中单元格区域,怎么用右键




  

  // 插入列
  function insertColumn(position) {
    if (!selectedCell) return;

    const table = selectedCell.parentNode.parentNode;   // 获取选中单元格selectedCell的父节点(<tr>)的父节点(<table>)
    const rowIndex = selectedCell.parentNode.rowIndex;  // 获取选中单元格selectedCell的父节点(<tr>)的行索引,等价于选中单元格的行索引
    const colIndex = selectedCell.cellIndex;            // 获取选中单元格selectedCell在其父元素(<tr>)中的位置索引,等价于选中单元格的列索引。
    
    for (let i = 0; i < table.rows.length; i++) {
      const row = table.rows[i];
      const cell = document.createElement('td');
      cell.contentEditable = true;      // 设置新列单元格为可编辑
      cell.width="100px";               // 设置新列单元格宽度
      cell.addEventListener('click', () => selectCell(cell));

    if (position === 'left') {
      row.insertBefore(cell, row.cells[colIndex]);
    } else if (position === 'right') {
      row.insertBefore(cell, row.cells[colIndex + 1]);
    }
    }
    saveHistory();
  }
  // 向左插入列
  document.getElementById('insertLeftButton').addEventListener('click', () => insertColumn('left'));
  // 向右插入列
  document.getElementById('insertRightButton').addEventListener('click', () => insertColumn('right'));

  // 插入行
  function insertRow(position) {
    if (!selectedCell) return;

    const table = selectedCell.parentNode.parentNode;
    const rowIndex = selectedCell.parentNode.rowIndex;
    const newRow = table.insertRow(rowIndex + (position === 'above' ? 0 : 1));
    
    for (let i = 0; i < table.rows[0].cells.length; i++) {
      const cell = newRow.insertCell();
      cell.contentEditable = true;      // 设置新行单元格为可编辑
      cell.width="100px";               // 设置新行单元格宽度
      cell.addEventListener('click', () => selectCell(cell));
    }
    saveHistory();
  }
  // 向上插入行
  document.getElementById('insertAboveButton').addEventListener('click', () => insertRow('above'));
  // 向下插入行
  document.getElementById('insertBelowButton').addEventListener('click', () => insertRow('below'));

  // 删除行
  function deleteRow() {
    if (!selectedCell) return;

    const row = selectedCell.parentNode;
    row.parentNode.removeChild(row);
    saveHistory();
  }

  // 删除列
  function deleteColumn() {
  if (!selectedCell) return;

  const table = selectedCell.parentNode.parentNode;
  const colIndex = Array.prototype.indexOf.call(selectedCell.parentNode.cells, selectedCell);
  for (let i = 0; i < table.rows.length; i++) {
    table.rows[i].deleteCell(colIndex);
  }
  saveHistory();
}

  // 复制代码
  function copyHtml() {
    // 获取用于定位的父元素,即table的父元素,这里为了便于引用,将其称为“主元素”
    const tableparentElement = document.getElementById('tableContainer');  
    // 从主元素中获取table元素,也就是我们想要的表格 
    const tableElement = tableparentElement.querySelector('table');   
    console.log(tableElement);

    const formattedHtml = formatHtml(tableElement);
    navigator.clipboard.writeText(formattedHtml).then(() => {
      alert('HTML代码已复制到剪贴板');
    }, (err) => {
      console.error('复制失败', err);
    });
  }

  // 代码格式化函数
  function formatHtml(table) {
    // // 去除所有类
    // html = html.replace(/ class="[^"]*"/g, '');

    const indentLevel = 0;
    const formattedhtml = ''
    // 将HTML代码中的<tbody>和</tbody>标签去掉
    const finalHtml = formatElement(table, indentLevel, formattedhtml).replace('<tbody>', '').replace('</tbody>', '')
    console.log(finalHtml);   
    return finalHtml;
}

  // 递归函数,用于格式化元素
  function formatElement(element, indentLevel, formattedhtml) {
    var finalHtml = ''; // 初始化一个空字符串,用于存储最终的格式化代码
    const childNodes = element.childNodes;     // 获取当前元素element的所有子节点
    
    // 统计表格类节点的个数
    var tableElements = element.querySelectorAll('table');
    var tbodyElements = element.querySelectorAll('tbody');
    var trElements = element.querySelectorAll('tr');
    var tdElements = element.querySelectorAll('td');
    var thElements = element.querySelectorAll('th');
    var count = tableElements.length + tbodyElements.length + trElements.length + tdElements.length + thElements.length;

    // 如果当前元素element的子节点存在表格类节点时,递归处理
    if (childNodes.length > 0 && count !== 0) {
      for (let i = 0; i < childNodes.length; i++) {
        finalHtml = finalHtml + formattedhtml + formatElement(childNodes[i], indentLevel+1, formattedhtml)     // 递归调用formatElement函数,对子元素进行格式化
      }

      // ----然后创建当前元素的HTML字符串,对其包裹----
      // 获取当前元素的标签名
      var tagname = element.tagName.toLowerCase()
      // 创建一个新的当前元素newelement,并复制其原始元素element的属性  
      
      var newelement = document.createElement(tagname);  // element.tagName.toLowerCase()获取 element 的类型
      // 使用一个循环来复制所有属性(除了class和style,这些可能需要特殊处理)  
      Array.from(element.attributes).forEach(attr => {  
          newelement.setAttribute(attr.name, attr.value);  
      });
      
      // 获取最终的HTML字符串  
      var finalHtml = '\n' + '  '.repeat(indentLevel) + '<' + tagname + '>' + finalHtml + '\n' + '  '.repeat(indentLevel) + '</' + tagname + '>';
      
    }
    // 如果当前元素element的子节点没有表格类节点时,将节点的HTML字符串连接起来,并进行缩进
    else if(childNodes.length > 0 && count === 0){           // 此类情况说明当前元素的内容是表格内容,所以暂时不需要缩进(除非有img等其它标签)
      var formatted =''     // 初始化一个空字符串,用于存储当前情况的HTML代码
      for (let i = 0; i < childNodes.length; i++) {
              formatted += childNodes[i].textContent;  // textContent返回元素的所有文本内容,包括元素内部的所有文本和注释节点,无论其是否可见。
          }
      
      // ----然后创建当前元素的HTML字符串,对其包裹----
      // 创建一个新的当前元素newelement,并复制其原始元素element的属性  
      var newelement = document.createElement(element.tagName.toLowerCase());  // element.tagName.toLowerCase()获取 element 的类型
      // 使用一个循环来复制所有属性(除了class和style,这些可能需要特殊处理)  
      Array.from(element.attributes).forEach(attr => {  
          newelement.setAttribute(attr.name, attr.value);  
      });
      
      // 将格式化后的子节点字符串formatted插入到新的当前元素newelement中  
      newelement.innerHTML = formatted;

      // 获取最终的HTML字符串  
      // 去除width和contenteditable属性
      var finalHtml = ('\n' + '  '.repeat(indentLevel+1) + newelement.outerHTML).replace(/ width="[^"]*"/g, '').replace(/ contenteditable="[^"]*"/g, '');
    }
    // 当元素连文本节点都没有时
    else if(childNodes.length === 0){
      // 创建一个新的当前元素newelement,并复制其原始元素element的属性  
      var newelement = document.createElement(element.tagName.toLowerCase());  // element.tagName.toLowerCase()获取 element 的类型
      // 使用一个循环来复制所有属性(除了class和style,这些可能需要特殊处理)  
      Array.from(element.attributes).forEach(attr => {  
          newelement.setAttribute(attr.name, attr.value);  
      });
      // 获取最终的HTML字符串  
      var finalHtml = ('\n' + '  '.repeat(indentLevel+1) + newelement.textContent); 
      // .replace(/ undefined/g, ''); // 使用正则表达式去除字符串中的 "undefined",因此当元素连文本节点都没有时,newelement.outerHTML返回的文本含有"undefined"
    }
    return finalHtml;
  }
  


  


  
  function mergeCells() {
    if (!selectedCell) return;

    const rowSpan = prompt('请输入合并的行数:');
    const colSpan = prompt('请输入合并的列数:');
    if (rowSpan && colSpan) {
      selectedCell.rowSpan = rowSpan;
      selectedCell.colSpan = colSpan;
    }
    saveHistory();
  }

  function undo() {
    if (historyStack.length > 0) {
      const prevState = historyStack.pop();
      document.getElementById('tableContainer').innerHTML = prevState;
    }
  }

  function saveHistory() {
    const table = document.querySelector('table');
    const html = table.outerHTML;
    historyStack.push(html);
  }



  function toggleHeader() {
    const table = document.querySelector('table');
    const rows = table.rows;
    const firstRow = rows[0];
    const cells = firstRow.cells;

    for (let i = 0; i < cells.length; i++) {
      if (cells[i].tagName === 'TD') {
        const th = document.createElement('th');
        th.innerHTML = cells[i].innerHTML;
        th.contentEditable = true;
        th.width = '100px';   // 限制宽度
        th.style.textAlign = 'center'; // 添加居中样式
        th.addEventListener('click', () => selectCell(th));
        cells[i].parentNode.replaceChild(th, cells[i]);
      } else {
        const td = document.createElement('td');
        td.innerHTML = cells[i].innerHTML;
        td.contentEditable = true;
        td.width = '100px';   // 限制宽度
        td.addEventListener('click', () => selectCell(td));
        cells[i].parentNode.replaceChild(td, cells[i]);
      }
   }
    saveHistory();
  }
</script>

</body>
</html>
2 个赞

感谢分享,我这边试了下 HTML 表格缩进,它事实上是页面居中的,而不是左对齐在缩进位置,也即利用了注册代码块的渲染巧妙地实现了缩进。

image

测试文本,点击展开
- 测试
    - 测试
        <table align="center">
            <tr><td align="center">宇</td><td align="center">宙</td><td align="center">洪</td><td align="center">荒</td></tr>
            <tr><td colspan="2" align="center">日月</td><td align="center">盈</td><td align="center">昃</td></tr>
        </table>
        测试
- 测试

受此启发,我尝试用注册代码块和 CSS 居中配合实现类似效果(单纯试试,没有更新的打算)。

测试文本,点击展开
- 测试
    - 测试
        ```sheet
        |     |     |     |             |
        | :-: | :-: | :-: | :---------: |
        |  宇  |  宙  |  洪  | 荒<br>昃<br>張 |
        | 日月  |  <  |  盈  |      ^      |
        |  辰  |  宿  |  列  |      ^      |
        ```
        测试
- 测试

感觉还不错!很有意思。

改成这样可以满足你的要求:

<table>
	<tr><td align="center">宇</td><td align="center">宙</td><td align="center">洪</td><td>荒</td align="center"></tr>
	<tr><td colspan="2" align="center">日月</td><td align="center">盈</td><td align="center">昃</td></tr>
</table>

image

之前有试过了,是这样的。

image

Obsidian的实时渲染模式和阅读模式的渲染机制不一样,实时渲染模式下是这样的,会被识别为两个div容器,默认上下排列。我一般用阅读模式分屏对照,没啥影响。

试试在这个css片段基础上改改实时渲染模式下表格的缩进吧

div[class="cm-html-embed cm-embed-block"] table{
	padding-left: var(--list-indent);

了解啦。其实如果是这样一般我会推荐新人用下面的方法:

后面有时间给加上单元格富文本编辑功能

另外看楼主有意增加编辑功能,如果楼主的策略是保持 Ob 稳定旧版本,在 v1.5.0 发布官方表格编辑前有类似的社区插件 ob-table-enhancer,可供楼主参考。

212839879-d5a86622-7f8a-433e-84f1-a78fa3c2735a

有机会试试,谢谢分享