mirror of
https://github.com/svm0n/datadeck.git
synced 2026-07-22 08:31:46 +00:00
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
169 lines
8 KiB
TypeScript
169 lines
8 KiB
TypeScript
// 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(); }
|
||
});
|
||
});
|
||
}
|