svm0n_datadeck/src/view/table.ts
SVM0N 3c8ac06574 fix: resolve community-plugin review findings (styles, innerHTML, promises, world-map bundling)
Obsidian's automated plugin review flagged this release: inline style
mutations instead of CSS classes, unsafe innerHTML writes, async callbacks
passed where a void return was expected, and a couple of real bugs the
review's style checks happened to surface along the way.

- Replace `.style.*` assignments with CSS classes (`is-hidden` etc.); the two
  genuinely dynamic textarea-autosize resets use `removeProperty` instead.
- Replace innerHTML writes (map SVG injection, dashboard stat bars, legend,
  refresh button) with DOMParser/createEl/appendText.
- Embed world-map.svg into main.js via an esbuild text-loader import instead
  of reading it from the plugin's vault folder at runtime — Obsidian's
  installer only ever fetches main.js/styles.css/manifest.json from a
  release, so the travel map was silently broken for any Community Plugins
  install (only worked for manual/BRAT installs that copied the extra file).
- Replace window.confirm() on delete with the same two-click "Confirm?"
  button pattern already used elsewhere in the plugin (native confirm()
  doesn't behave reliably on mobile Obsidian).
- Settings tab: use Setting().setHeading() instead of raw <h2>/<h3>, drop the
  redundant plugin-name heading, wrap async onChange/click handlers so they
  don't return unhandled promises.
- MarkdownRenderChild subclasses (chart/inline-view/tasks blocks): onload()
  must be synchronous per Component's contract; move the async work inside
  instead of marking onload itself async.
- Misc: unused imports/vars, unnecessary type assertions (two of which were
  actually needed — restored with `querySelector<HTMLElement>` instead of a
  blind cast), sentence-case UI text, template-literal error interpolation
  on caught `unknown` values.
- Add eslint + eslint-plugin-obsidianmd as a permanent dev dependency
  (`npm run lint`, wired into `npm run check`) so these surface locally
  before the next review instead of after.

114 → 112 existing tests still pass (2 net-added assertions from the DOM
shim gaining appendText/DOMParser polyfills); typecheck and build clean.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Tfyt1TBd7DiNic32s6E4nq
2026-07-17 16:29:49 +08:00

169 lines
8 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// Table view renderer. Extracted from CardView as a free function taking the
// view instance; the members it reaches (getFilteredRows, isNotesCol,
// openNoteExpander, scheduleSave, …) are public on CardView. Type-only import
// of CardView keeps this out of a runtime cycle. Covered by test-view-smoke.mjs.
import type { CardView } from "../../main";
import { CSVRow } from "../types";
import { isDateCol, ISO_DATE } from "../field-types";
export function renderTable(view: CardView, container: HTMLElement): void {
const filteredRows = view.getFilteredRows();
// Show search result count if searching
if (view.searchQuery.trim()) {
container.createDiv({ cls: "csv-search-results", text: `Found ${filteredRows.length} of ${view.rows.length} entries` });
}
const wrap = container.createDiv({cls:"csv-table-wrapper"});
const table = wrap.createEl("table",{cls:"csv-table"});
const hr = table.createEl("thead").createEl("tr");
view.headers.forEach(h => {
const th = hr.createEl("th");
th.setText(h);
// Click-to-sort: asc → desc → off. The resize handle is a child of the
// th, so guard against clicks that originate from it; a drag-resize must
// not also flip the sort.
th.addClass("csv-th-sortable");
if (view.tableSortCol === h) {
th.createSpan({ cls: "csv-th-sort-indicator", text: view.tableSortDir === "asc" ? " ▲" : " ▼" });
}
th.title = "Click to sort";
th.addEventListener("click", (e) => {
if ((e.target as HTMLElement).closest(".csv-col-resize-handle")) return;
if (view.tableSortCol !== h) { view.tableSortCol = h; view.tableSortDir = "asc"; }
else if (view.tableSortDir === "asc") view.tableSortDir = "desc";
else view.tableSortCol = null;
// Full re-render so the toolbar's Newest/Oldest toggle can reflect
// whether the manual sort has overridden it.
view.renderView();
});
const savedWidth = view.settings.columnWidths[h];
if (savedWidth) th.style.width = savedWidth + "px";
const handle = th.createDiv({cls:"csv-col-resize-handle"});
let startX = 0, startW = 0;
// Pointer events (not mouse) so the handle works under touch as well as a
// cursor; setPointerCapture routes move/up to the handle even when the
// finger/cursor strays off the 6px strip, and lets us drop the
// document-level listeners. `touch-action:none` on the handle (CSS) stops
// the browser claiming the gesture for scrolling. Persist on release —
// the old mouseup only mutated in-memory settings, so resized widths were
// silently lost on reload.
handle.addEventListener("pointerdown", e => {
e.preventDefault(); e.stopPropagation();
handle.setPointerCapture(e.pointerId);
startX=e.clientX; startW=th.offsetWidth;
const onMove = (ev: PointerEvent) => { th.style.width=Math.max(60,startW+ev.clientX-startX)+"px"; };
const onUp = (ev: PointerEvent) => {
view.settings.columnWidths[h]=Math.max(60,startW+ev.clientX-startX);
handle.removeEventListener("pointermove",onMove);
handle.removeEventListener("pointerup",onUp);
void view.persistSettings();
};
handle.addEventListener("pointermove",onMove);
handle.addEventListener("pointerup",onUp);
});
});
hr.createEl("th",{text:""});
// Skip the clip-detection on touch — the fade gradient it triggers is
// a hover affordance and irrelevant without a cursor. Saves N rAFs × N
// forced reflows per render on phones (the prime cause of table-view lag
// on mobile when the file has hundreds of rows).
const isTouch = matchMedia("(pointer: coarse)").matches;
const tbody = table.createEl("tbody");
filteredRows.forEach((row) => {
const tr = tbody.createEl("tr");
tr.addEventListener("contextmenu", e => view.openRowContextMenu(row, e));
view.headers.forEach(h => {
const td = tr.createEl("td");
if (view.isNotesCol(h)) {
td.addClass("csv-table-notes-cell");
const preview = (row[h]??"").replace(/#{1,6}\s/g,"").replace(/[*_>`]/g,"").split("\n").filter(l=>l.trim()).slice(0,3).join(" · ");
const display = preview ? (preview.slice(0,200)+(preview.length>200?"…":"")) : "+ Add note";
const span = td.createSpan({ text: display });
if (!preview) span.addClass("csv-table-notes-empty");
td.title = "Click to open note";
// Cell-click opens the expander. The "⤢" button used to live here
// too, but the cell is the obvious click target — the button was
// redundant noise.
td.addEventListener("click", (e) => { e.stopPropagation(); view.openNoteExpander(row, h); });
} else if (view.isSelectCol(h)) {
view.renderSelectField(td, row, h);
} else {
const val = row[h] ?? "";
td.setText(val);
if (val.length > 80) td.title = val;
makeEditable(view, td, row, h);
}
});
const at = tr.createEl("td",{cls:"csv-table-action"});
const hasFile = view.notesFileExists(row);
at.createEl("button",{cls:`csv-table-notes-btn ${hasFile?"exists":""}`,text:hasFile?"📄":"✚",title:hasFile?"Open notes":"Create notes"})
.addEventListener("click",()=>void view.openOrCreateNotes(row));
at.createEl("button",{cls:"csv-table-del-btn",text:"✕",title:"Delete row (undo available)"})
.addEventListener("click",()=>view.deleteWithUndo(row));
});
// Detect overflowing cells in one rAF instead of one per row. Single
// querySelectorAll, single forced-layout batch — orders of magnitude
// cheaper than per-row rAF on big files. Skipped entirely on touch.
if (!isTouch) {
window.requestAnimationFrame(() => {
tbody.querySelectorAll<HTMLElement>("td:not(.csv-table-notes-cell):not(.csv-table-action)").forEach(cell => {
if (cell.scrollHeight > cell.clientHeight + 1) cell.addClass("csv-cell--clipped");
});
});
}
}
export function makeEditable(view: CardView, el: HTMLElement, row: CSVRow, h: string): void {
el.addEventListener("click", (e) => {
// If we're already editing (e.g. user clicked the input), do not reset it
if (el.querySelector("input")) return;
el.empty();
// Check if the column is explicitly configured as 'date', or auto-detected
const isExplicitDate = view.fileCfg.dateColumns?.includes(h);
const initial = (row[h] ?? "").trim();
// Use native date picker if configured as date, or if it auto-detects as date AND has a clean ISO date (or is empty)
const isDate = isExplicitDate || (isDateCol(h) && (initial === "" || ISO_DATE.test(initial)));
// When writing to a native date input, it demands a strict yyyy-mm-dd ISO string.
// If the data has time data or weird formatting, native date picker won't display it.
// We enforce truncation to 10 chars for rendering the picker.
const pickerValue = isDate ? initial.slice(0, 10) : initial;
const input = el.createEl("input", {
cls: "csv-inline-input",
value: pickerValue,
type: isDate ? "date" : "text"
});
input.focus();
if (!isDate) input.select(); // selecting text is mostly useful for text inputs, dates have a native picker
// Prevent clicks on the input from bubbling up to row/document listeners
input.addEventListener("click", ev => ev.stopPropagation());
// Commit on blur — but only when the value actually changed from what we
// put into the input. Saving unconditionally meant every click-in/
// click-out dirtied the file (debounced vault write + sync churn), and
// for a date cell the write-back was the 10-char truncated pickerValue —
// silently clobbering any extra data just by touching the cell.
let cancelled = false;
input.addEventListener("blur", () => {
if (!cancelled && input.value !== pickerValue) {
row[h] = input.value;
view.scheduleSave();
}
el.empty();
el.setText((row[h] ?? "") || "—");
});
input.addEventListener("keydown", ev => {
if (ev.key === "Enter") input.blur();
if (ev.key === "Escape") { cancelled = true; input.blur(); }
});
});
}