From 3cd90f5af565592c6e442ec8978506950e6aa7d4 Mon Sep 17 00:00:00 2001 From: net <96362337+netqo@users.noreply.github.com> Date: Wed, 2 Sep 2026 17:17:33 -0300 Subject: [PATCH] feat: add hover controls to add/remove table rows and columns Editing tables in the live editor was hard: rows could only be added via Enter/Tab inside a cell, and the column insert/delete primitive (_tableColumnResult) existed but had no caller, so columns could not be changed at all from the UI. Add hover-revealed controls anchored to the live table: - a "+" bar on the right edge appends a column, and one on the bottom edge appends a row; - a small "x" above each column and to the left of each body row deletes that specific column/row. Controls are rendered as part of the table markup, so they are recreated on every re-render and never touch the markdown source: they are contenteditable="false", carry no data-editable/data-from, and are ignored by the DOM-to-source mapping. Handles are positioned by anchoring to real th/td boxes, so they track column/row geometry with no JS measurement. Clicks are dispatched through the existing delegated live click handler to the existing row/column mutation primitives, so undo, redo, and screen-reader announcements work unchanged. Controls are hidden when the editor is read-only or disabled, the delete-column handle is hidden at one column, and delete-row handles are absent when the body is empty. --- templates/writemark.js | 77 +++++++++++++++++++++++++++++++++++++++--- 1 file changed, 72 insertions(+), 5 deletions(-) diff --git a/templates/writemark.js b/templates/writemark.js index ae99fef..b7f18f5 100644 --- a/templates/writemark.js +++ b/templates/writemark.js @@ -1606,6 +1606,25 @@ class WritemarkEditorElement extends HTMLElement { .md-table th, .md-table td { border: 1px solid var(--md-editor-border); padding: 6px 8px; vertical-align: top; } .md-table th { background: color-mix(in srgb, CanvasText 7%, Canvas 93%); font-weight: 700; text-align: left; } .md-cell { min-height: 1.35em; outline: none; white-space: pre-wrap; overflow-wrap: anywhere; } + /* Hover controls (add/remove rows and columns): identically sized, + 2px-corner tabs. The block adds no padding, so table spacing matches + the base rule (margin-block: 0.5em). Each tab is centered on a table + edge: column-delete on the top, row-add on the bottom, and column-add + and row-delete on the right (over the cells' right padding). Nothing + extends horizontally, so there is no scrollbar; overflow is shown so + the top/bottom tabs are not clipped. */ + .md-has-controls { position: relative; overflow: visible; } + .md-has-controls .md-table th, .md-has-controls .md-table td { position: relative; } + .md-tbl-ctl { position: absolute; z-index: 3; box-sizing: border-box; width: 18px; height: 18px; display: inline-flex; align-items: center; justify-content: center; padding: 0; border: 1px solid var(--md-editor-border); border-radius: 2px; background: var(--md-editor-code-bg, Canvas); color: var(--md-editor-fg); cursor: pointer; opacity: 0; transition: opacity 0.12s ease; user-select: none; } + .md-tbl-ctl svg { width: 12px; height: 12px; fill: none; stroke: currentColor; stroke-width: 2; stroke-linecap: round; } + .md-tbl-ctl:focus-visible { opacity: 1; outline: 2px solid var(--md-editor-accent, Highlight); outline-offset: 1px; } + [data-table-control="add-col"] { right: 0; top: 50%; transform: translateY(-50%); } + [data-table-control="add-row"] { left: 50%; bottom: 0; transform: translate(-50%, 50%); } + [data-table-control="del-col"] { left: 50%; top: 0; transform: translate(-50%, -50%); } + [data-table-control="del-row"] { right: 0; top: 50%; transform: translateY(-50%); } + .md-has-controls:hover [data-table-control^="add-"], .md-has-controls:focus-within [data-table-control^="add-"] { opacity: 0.5; } + .md-table th:hover [data-table-control="del-col"], .md-table tr:hover [data-table-control="del-row"] { opacity: 0.6; } + .md-has-controls .md-tbl-ctl:hover { opacity: 1; background: color-mix(in srgb, CanvasText 12%, Canvas 88%); } .completion-popup { position: absolute; z-index: 20; min-inline-size: 240px; max-inline-size: min(420px, 90vw); max-block-size: min(320px, 50vh); overflow: auto; border: 1px solid var(--md-editor-popup-border); border-radius: var(--md-editor-radius); background: var(--md-editor-popup-bg); color: var(--md-editor-popup-fg); box-shadow: var(--md-editor-popup-shadow); padding: 4px; } .completion-popup[hidden] { display: none; } /* Floating selection toolbar (bubble menu) shown above a text selection. @@ -2249,14 +2268,33 @@ class WritemarkEditorElement extends HTMLElement { _renderTable(block) { const cols = Math.max(block.header.cells.length, ...block.rows.map(r => r.cells.length), 1); const alignments = Array.from({ length: cols }, (_, i) => tableAlignmentFromDelimiter(block.delimiter.cells[i]?.text)); - const renderCell = (cell, tag, row, col) => `<${tag}${tableAlignmentStyle(alignments[col])}>
${decorateInline(unescapeTableCellText(cell?.text ?? ""), this._rendererOptions())}
`; - const header = `${Array.from({ length: cols }, (_, i) => renderCell(block.header.cells[i] ?? { text: "", from: block.header.end, to: block.header.end }, "th", -1, i)).join("")}`; - const bodyRows = block.rows.length ? block.rows : [{ cells: Array.from({ length: cols }, () => ({ text: "", from: block.delimiter.end, to: block.delimiter.end })) }]; - const body = `${bodyRows.map((row, r) => `${Array.from({ length: cols }, (_, i) => renderCell(row.cells[i] ?? { text: "", from: row.end, to: row.end }, "td", r, i)).join("")}`).join("")}`; + // Hover controls (add/remove rows and columns), rendered inline so they are + // recreated on every render. They carry no data-editable/data-from and are + // contenteditable="false", so the DOM-to-source mapping ignores them. + const editable = this._lineEditable() === "true"; + const icon = inner => ``; + const plus = icon(''); + const minus = icon(''); + const control = (kind, label, glyph, extra = "") => + ``; + // Column-delete tabs sit above each header cell; the single column-add tab + // and each row-delete tab overlap the table's right edge (last cell); row-add + // sits below. Delete tabs are hidden while only two columns / one row remain, + // keeping those as the minimums. + const addColBtn = editable ? control("add-col", "Add column", plus) : ""; + const addRowBtn = editable ? control("add-row", "Add row", plus) : ""; + const delColBtn = i => (editable && cols > 2) ? control("del-col", "Delete column", minus, ` data-index="${i}"`) : ""; + const delRowBtn = r => (editable && block.rows.length > 1) ? control("del-row", "Delete row", minus, ` data-index="${r}"`) : ""; + const lastCol = cols - 1; + const renderCell = (cell, tag, row, col, extra = "") => `<${tag}${tableAlignmentStyle(alignments[col])}>${extra}
${decorateInline(unescapeTableCellText(cell?.text ?? ""), this._rendererOptions())}
`; + const header = `${Array.from({ length: cols }, (_, i) => renderCell(block.header.cells[i] ?? { text: "", from: block.header.end, to: block.header.end }, "th", -1, i, `${delColBtn(i)}${i === lastCol ? addColBtn : ""}`)).join("")}`; + const hasRealRows = block.rows.length > 0; + const bodyRows = hasRealRows ? block.rows : [{ cells: Array.from({ length: cols }, () => ({ text: "", from: block.delimiter.end, to: block.delimiter.end })) }]; + const body = `${bodyRows.map((row, r) => `${Array.from({ length: cols }, (_, i) => renderCell(row.cells[i] ?? { text: "", from: row.end, to: row.end }, "td", r, i, i === lastCol && hasRealRows ? delRowBtn(r) : "")).join("")}`).join("")}`; const afterAnchor = block.newlineEnd === block.to ? `

` : ""; - return `
${header}${body}
${afterAnchor}`; + return `
${header}${body}
${addRowBtn}
${afterAnchor}`; } _onSourceInput(event) { @@ -2577,6 +2615,12 @@ class WritemarkEditorElement extends HTMLElement { } if (this._navigateFragmentLink(event, this._liveEditor)) return; this._structuredSelection = null; + const tableControl = event.target.closest?.("[data-table-control]"); + if (tableControl) { + event.preventDefault(); + this._handleTableControlClick(tableControl); + return; + } const checkbox = event.target.closest?.("[data-task-checkbox]"); if (checkbox) { event.preventDefault(); @@ -2604,6 +2648,8 @@ class WritemarkEditorElement extends HTMLElement { _onLiveMouseDown(event) { if (this.disabled || this.readonly || this.mode === "source" || event.button !== 0 || event.detail > 1) return; + // Keep a control click from moving the caret or stealing focus. + if (event.target.closest?.("[data-table-control]")) { event.preventDefault(); return; } if (event.target.closest?.("[data-task-checkbox]")) return; const anchor = this._sourceOffsetForClientPoint(event.clientX, event.clientY); if (anchor == null) return; @@ -3419,6 +3465,27 @@ class WritemarkEditorElement extends HTMLElement { return this._getBlocks().find(block => block.type === "table" && block.from === from && block.to === to) || null; } + // Dispatch a hover-control click to the matching table mutation primitive. + // Adds append after the last column/row; deletes target the index on the + // control. The primitive stamps its own actionId onto the transaction. + _handleTableControlClick(control) { + if (this.disabled || this.readonly) return; + const block = this._findTableBlockForCell(control); + if (!block) return; + const ctx = this._getContext(); + const cols = Math.max(block.header.cells.length, ...block.rows.map(row => row.cells.length), 1); + const index = Number(control.dataset.index); + const result = { + "add-col": () => this._tableColumnResult(ctx, block, cols - 1, "insert"), + "del-col": () => this._tableColumnResult(ctx, block, index, "delete"), + "add-row": () => this._tableRowInsertionResult(ctx, block, block.rows.at(-1) || block.delimiter, block.rows.length ? "after-row" : "after-delimiter"), + "del-row": () => this._tableDeleteRowResult(ctx, block, index), + }[control.dataset.tableControl]?.(); + if (!result) return; + if (!result.ok) { if (result.message) this._announce(result.message); return; } + this._applyActionResult(result.transaction.actionId, result, { source: "pointer" }); + } + _tableInfoForCell(cell) { const block = this._findTableBlockForCell(cell); if (!block) return null;