Edit tables in /Note

A table button inserts a 2x2 table after the line the caret is on, or in place of it when that line is empty, with an empty line after it so there is somewhere to type below. Inside a table the same button shows as pressed and deletes the table instead: this editor does not nest tables, so the button has nothing else to do there. Deleting takes the empty line insertTable left behind, so toggling a table on and off leaves the note as it was. Rows and columns are added above, below, left or right and deleted from a second row of buttons, shown only while the caret is in a table. Tab and Shift-Tab move between cells and Tab in the last cell adds a row; in a list inside a cell Tab still nests the item, and outside both it still leaves the editor. No browser has editing commands for tables, so these change the DOM directly and are outside the browser's undo history: Ctrl+Z undoes typing and formatting, not a row. Taking over undo for the whole editor to fix that is more than this page needs. Table tags join the allowlist, so a table pasted from a spreadsheet or a page keeps its cells; colspan and rowspan go with every other attribute. The cells get a border on every side, unlike the rules-only tables elsewhere, because an empty cell has to show where to click. Verified in the browser: insert, Tab through and past the last cell, add a column beside a header row, add a row above, delete a column, a row and the table, toggle a table on and off, and the note survives the round trip through the URL. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

author
Marijn Besseling <njirambem@gmail.com> · 2026-09-25 16:00 UTC
commit
a22eb69ceb2c4e508b6bbcd9864d20ea202815a7
parent
fc31017b11
tree
browse at this commit

3 files changed +284 -10

Blog/Components/Pages/Note.razor +9 -0

@@ -9,6 +9,15 @@
9 9 <button type="button" data-command="underline" aria-pressed="false" title="Underline (Ctrl+U)"><u>U</u></button>
10 10 <button type="button" data-command="insertUnorderedList" aria-pressed="false" title="Bulleted list">• list</button>
11 11 <button type="button" data-command="insertOrderedList" aria-pressed="false" title="Numbered list">1. list</button>
12 + <button type="button" id="table-toggle" data-table="toggle" aria-pressed="false" title="Insert a table">table</button>
13 + <div id="table-tools" class="note-toolbar-group" role="group" aria-label="Table" hidden>
14 + <button type="button" data-table="rowAbove" title="Insert a row above">↑ row</button>
15 + <button type="button" data-table="rowBelow" title="Insert a row below">↓ row</button>
16 + <button type="button" data-table="columnLeft" title="Insert a column to the left">← col</button>
17 + <button type="button" data-table="columnRight" title="Insert a column to the right">→ col</button>
18 + <button type="button" data-table="deleteRow" title="Delete this row">× row</button>
19 + <button type="button" data-table="deleteColumn" title="Delete this column">× col</button>
20 + </div>
12 21 </div>
13 22 <div id="input" class="editor-tall note-editor" contenteditable="true"
14 23 role="textbox" aria-multiline="true" aria-label="Note" autofocus></div>

Blog/Components/Pages/Note.razor.js +249 -10

@@ -8,13 +8,30 @@ const htmlPrefix = "h:";
8 8 // Everything the toolbar can produce, plus what the browser uses for lines. Anything else is
9 9 // unwrapped to its contents, and every attribute is dropped: a note arrives in a link someone
10 10 // else made, so it is never trusted with an onerror or a style.
11 -const allowedTags = new Set(["B", "STRONG", "I", "EM", "U", "UL", "OL", "LI", "DIV", "P", "BR"]);
11 +const allowedTags = new Set([
12 + "B", "STRONG", "I", "EM", "U", "UL", "OL", "LI", "DIV", "P", "BR",
13 + "TABLE", "THEAD", "TBODY", "TFOOT", "TR", "TH", "TD",
14 +]);
12 15 const droppedTags = new Set(["SCRIPT", "STYLE", "TITLE", "TEXTAREA"]);
13 16
14 17 const input = getById("input");
15 18 const toolbar = getById("toolbar");
19 +const tableToggle = getById("table-toggle");
20 +const tableTools = getById("table-tools");
16 21 const buttons = [...toolbar.querySelectorAll("button[data-command]")];
17 22
23 +// No browser has commands for tables (Firefox had some, off by default), so these edit the DOM
24 +// themselves. That puts them outside the browser's undo history: Ctrl+Z undoes typing, not a
25 +// row that was added or deleted.
26 +const tableActions = {
27 + rowAbove: cell => focusCell(insertRow(cell.parentElement, "before"), cell.cellIndex),
28 + rowBelow: cell => focusCell(insertRow(cell.parentElement, "after"), cell.cellIndex),
29 + columnLeft: cell => placeCaret(insertColumn(cell, "before")),
30 + columnRight: cell => placeCaret(insertColumn(cell, "after")),
31 + deleteRow,
32 + deleteColumn,
33 +};
34 +
18 35 // The hash this page wrote itself, so the hashchange that follows doesn't reload the editor
19 36 // under the caret.
20 37 let written = null;
@@ -27,22 +44,44 @@ toolbar.addEventListener("mousedown", event => {
27 44 if (event.target.closest("button")) event.preventDefault();
28 45 });
29 46 toolbar.addEventListener("click", event => {
30 - const button = event.target.closest("button[data-command]");
47 + const button = event.target.closest("button");
31 48 if (!button) return;
32 49 input.focus();
33 - document.execCommand(button.dataset.command);
50 + const { command, table } = button.dataset;
51 + if (command) {
52 + document.execCommand(command);
53 + } else if (table === "toggle") {
54 + // A table inside a table is not something this editor makes, so inside one the button
55 + // is the way out of it instead.
56 + const cell = selectedCell();
57 + if (cell) {
58 + removeTable(cell.closest("table"));
59 + } else {
60 + insertTable();
61 + }
62 + changed();
63 + } else if (table) {
64 + const cell = selectedCell();
65 + if (!cell) return;
66 + tableActions[table](cell);
67 + changed();
68 + }
34 69 updateToolbar();
35 70 });
36 71
37 -// Tab nests a list item and Shift+Tab lifts it back out. Outside a list Tab still leaves the
38 -// editor, so it isn't a keyboard trap.
72 +// Tab nests a list item and Shift+Tab lifts it back out. In a table they move between cells, and
73 +// Tab in the last cell adds a row. Anywhere else Tab still leaves the editor, so it isn't a
74 +// keyboard trap.
39 75 input.addEventListener("keydown", event => {
40 76 if (event.key !== "Tab" || event.ctrlKey || event.altKey || event.metaKey) return;
41 - const node = document.getSelection()?.anchorNode;
42 - const element = node instanceof Element ? node : node?.parentElement;
43 - if (!element?.closest("li")) return;
44 - event.preventDefault();
45 - document.execCommand(event.shiftKey ? "outdent" : "indent");
77 + const item = selectedElement()?.closest("li");
78 + const cell = selectedCell();
79 + if (item && (!cell || cell.contains(item))) {
80 + event.preventDefault();
81 + document.execCommand(event.shiftKey ? "outdent" : "indent");
82 + } else if (cell && moveFromCell(cell, event.shiftKey ? -1 : 1)) {
83 + event.preventDefault();
84 + }
46 85 });
47 86
48 87 // A paste from another page or a word processor brings its classes and styles along. Keep only
@@ -95,6 +134,206 @@ function updateToolbar() {
95 134 for (const button of buttons) {
96 135 button.setAttribute("aria-pressed", String(document.queryCommandState(button.dataset.command)));
97 136 }
137 + const inTable = selectedCell() !== null;
138 + tableToggle.setAttribute("aria-pressed", String(inTable));
139 + tableToggle.title = inTable ? "Delete this table" : "Insert a table";
140 + tableTools.hidden = !inTable;
141 +}
142 +
143 +/** Save the note after an edit the browser didn't make, and so didn't announce. */
144 +function changed() {
145 + input.dispatchEvent(new Event("input"));
146 +}
147 +
148 +/** @returns {Element|null} */
149 +function selectedElement() {
150 + const node = document.getSelection()?.anchorNode;
151 + return node instanceof Element ? node : node?.parentElement ?? null;
152 +}
153 +
154 +/** @returns {HTMLTableCellElement|null} */
155 +function selectedCell() {
156 + const cell = selectedElement()?.closest("td, th");
157 + return cell && input.contains(cell) ? cell : null;
158 +}
159 +
160 +/** @param {Node} node */
161 +function placeCaret(node) {
162 + const range = document.createRange();
163 + range.selectNodeContents(node);
164 + range.collapse(true);
165 + const selection = document.getSelection();
166 + selection.removeAllRanges();
167 + selection.addRange(range);
168 +}
169 +
170 +/**
171 + * A new table goes after the line the caret is on rather than splitting it, or takes its place
172 + * if that line is empty. A line follows it, or there would be nowhere to type below it.
173 + */
174 +function insertTable() {
175 + const table = h("table", [h("tbody", [newRow(2), newRow(2)])]);
176 + const line = currentLine();
177 + if (line === null) {
178 + input.prepend(table);
179 + } else if (line instanceof Element && ["DIV", "P"].includes(line.tagName)
180 + && line.textContent === '' && !line.querySelector("table")) {
181 + line.replaceWith(table);
182 + } else {
183 + endOfLine(line).after(table);
184 + }
185 + if (!table.nextSibling) {
186 + table.after(h("div", [h("br")]));
187 + }
188 + placeCaret(table.rows[0].cells[0]);
189 +}
190 +
191 +/**
192 + * The editor's top-level node holding the caret, or null when the caret is before all of them.
193 + * @returns {ChildNode|null}
194 + */
195 +function currentLine() {
196 + const selection = document.getSelection();
197 + let node = selection?.anchorNode;
198 + if (!node || !input.contains(node)) return null;
199 + if (node === input) return input.childNodes[selection.anchorOffset - 1] ?? null;
200 + while (node.parentNode !== input) node = node.parentNode;
201 + return node;
202 +}
203 +
204 +/**
205 + * The first line of a note is bare text and inline elements until the first Enter, so a line
206 + * is not always one node: follow it along to its last one.
207 + * @param {ChildNode} node
208 + */
209 +function endOfLine(node) {
210 + const isBlock = n => n instanceof Element && ["DIV", "P", "UL", "OL", "TABLE"].includes(n.tagName);
211 + while (!isBlock(node) && node.nodeName !== "BR" && node.nextSibling && !isBlock(node.nextSibling)) {
212 + node = node.nextSibling;
213 + }
214 + return node;
215 +}
216 +
217 +/** @param {string} tag */
218 +function newCell(tag = "td") {
219 + return h(tag, [h("br")]);
220 +}
221 +
222 +/** @param {number} count */
223 +function newRow(count) {
224 + return h("tr", Array.from({ length: count }, () => newCell()));
225 +}
226 +
227 +/**
228 + * As wide as the widest row: a pasted table can be ragged.
229 + * @param {HTMLTableElement} table
230 + */
231 +function columnCount(table) {
232 + return Math.max(...[...table.rows].map(row => row.cells.length));
233 +}
234 +
235 +/**
236 + * @param {HTMLTableRowElement} row
237 + * @param {"before"|"after"} where
238 + */
239 +function insertRow(row, where) {
240 + const created = newRow(columnCount(row.closest("table")));
241 + row[where](created);
242 + return created;
243 +}
244 +
245 +/**
246 + * @param {HTMLTableCellElement} cell
247 + * @param {"before"|"after"} where
248 + * @returns {HTMLTableCellElement} the new cell in the same row as `cell`
249 + */
250 +function insertColumn(cell, where) {
251 + const index = cell.cellIndex;
252 + for (const row of cell.closest("table").rows) {
253 + const reference = row.cells[index];
254 + if (reference) {
255 + // A header row stays a header row.
256 + reference[where](newCell(reference.localName));
257 + } else {
258 + row.append(newCell());
259 + }
260 + }
261 + return where === "before" ? cell.previousElementSibling : cell.nextElementSibling;
262 +}
263 +
264 +/** @param {HTMLTableCellElement} cell */
265 +function deleteRow(cell) {
266 + const row = cell.parentElement;
267 + const table = row.closest("table");
268 + const rows = [...table.rows];
269 + const neighbour = rows[rows.indexOf(row) + 1] ?? rows[rows.indexOf(row) - 1];
270 + row.remove();
271 + if (neighbour) {
272 + focusCell(neighbour, cell.cellIndex);
273 + } else {
274 + removeTable(table);
275 + }
276 +}
277 +
278 +/** @param {HTMLTableCellElement} cell */
279 +function deleteColumn(cell) {
280 + const index = cell.cellIndex;
281 + const row = cell.parentElement;
282 + const table = row.closest("table");
283 + for (const r of [...table.rows]) {
284 + r.cells[index]?.remove();
285 + if (r.cells.length === 0) r.remove();
286 + }
287 + if (table.rows.length === 0) {
288 + removeTable(table);
289 + } else {
290 + focusCell(row.isConnected ? row : table.rows[0], index);
291 + }
292 +}
293 +
294 +/**
295 + * Leaves an empty line where the table was, with the caret on it. That is usually the one
296 + * insertTable put after it, so inserting and deleting a table puts the note back as it was.
297 + * @param {HTMLTableElement} table
298 + */
299 +function removeTable(table) {
300 + const next = table.nextElementSibling;
301 + if (next?.tagName === "DIV" && next.textContent === '' && !next.querySelector("table")) {
302 + table.remove();
303 + placeCaret(next);
304 + } else {
305 + const line = h("div", [h("br")]);
306 + table.replaceWith(line);
307 + placeCaret(line);
308 + }
309 +}
310 +
311 +/**
312 + * @param {HTMLTableRowElement} row
313 + * @param {number} index
314 + */
315 +function focusCell(row, index) {
316 + placeCaret(row.cells[Math.min(index, row.cells.length - 1)]);
317 +}
318 +
319 +/**
320 + * @param {HTMLTableCellElement} cell
321 + * @param {1|-1} step
322 + * @returns {boolean} false when Shift+Tab is already in the first cell, and should leave the editor
323 + */
324 +function moveFromCell(cell, step) {
325 + const table = cell.closest("table");
326 + const cells = [...table.rows].flatMap(row => [...row.cells]);
327 + const next = cells[cells.indexOf(cell) + step];
328 + if (next) {
329 + placeCaret(next);
330 + } else if (step > 0) {
331 + focusCell(insertRow(cell.parentElement, "after"), 0);
332 + changed();
333 + } else {
334 + return false;
335 + }
336 + return true;
98 337 }
99 338
100 339 /**

Blog/wwwroot/app.css +26 -0

@@ -932,6 +932,19 @@ progress {
932 932 margin-block-end: var(--s-1);
933 933 }
934 934
935 +/* The row and column buttons, on a row of their own under the rest and only while the caret is
936 + in a table. */
937 +.note-toolbar-group {
938 + flex-basis: 100%;
939 + display: flex;
940 + flex-wrap: wrap;
941 + gap: var(--s-1);
942 +}
943 +
944 +.note-toolbar-group[hidden] {
945 + display: none;
946 +}
947 +
935 948 /* One letter or two words each: the 8ch every other button gets would make this a row of gaps. */
936 949 .note-toolbar button {
937 950 min-inline-size: 4ch;
@@ -960,6 +973,19 @@ progress {
960 973 margin-block: 0;
961 974 }
962 975
976 +.note-editor table {
977 + margin-block: var(--s-1);
978 +}
979 +
980 +/* Every cell boxed, unlike the rules-only tables elsewhere: an empty cell has to show where to
981 + click. Written to match the specificity of the last-row rule under Tables, which it has to
982 + beat by coming later. */
983 +.note-editor table tr :is(th, td) {
984 + border: var(--border-width) dotted var(--border);
985 + padding-inline: var(--s-1);
986 + vertical-align: top;
987 +}
988 +
963 989
964 990 /* -- Scroll shortcuts ------------------------------------------------------ */
965 991