From d5605d8d5dc4bdd66627b0f95f0019e0de8e38bb Mon Sep 17 00:00:00 2001 From: a269ch Date: Mon, 19 Oct 2026 20:03:03 -0400 Subject: [PATCH 1/9] RELEASE-0.1.2 added: - Auto-convert Markdown tables setting with persisted user control - Tests for default settings, UI toggling, and post-processor behavior changed: - Gate standard Markdown table conversion without affecting interactive code blocks --- src/extensions/post-processor.ts | 11 +++++++++-- src/main.ts | 6 +++++- src/types.ts | 2 ++ src/ui/settings-tab.ts | 21 +++++++++++++++++++++ tests/column-type-change.test.ts | 9 ++++++++- tests/post-processor.test.ts | 14 +++++++++++++- tests/settings-service.test.ts | 1 + tests/settings-tab.test.ts | 18 ++++++++++++++++-- 8 files changed, 75 insertions(+), 7 deletions(-) diff --git a/src/extensions/post-processor.ts b/src/extensions/post-processor.ts index 705d51e..0ebbdb3 100644 --- a/src/extensions/post-processor.ts +++ b/src/extensions/post-processor.ts @@ -4,6 +4,7 @@ import { stripTypeAnnotation } from "../core/table-mutator"; import { toPlainText } from "../utils/link-renderer"; import { TableViewController } from "../services/table-view-controller"; import { TableViewRegistry } from "../services/table-view-registry"; +import { SettingsService } from "../services/settings-service"; import { MarkdownTableData, MarkdownTableRow } from "../types"; import { isPrintMode } from "./print-mode"; import { TableRenderChild } from "./table-render-child"; @@ -13,14 +14,20 @@ const SKIP_CONTAINER_CLASSES = ["ms-notion-database-container", "ms-kanban-conta export class TablePostProcessor { private viewController: TableViewController; private registry: TableViewRegistry; + private settingsService: SettingsService; - constructor(viewController: TableViewController, registry: TableViewRegistry) { + constructor( + viewController: TableViewController, + registry: TableViewRegistry, + settingsService: SettingsService + ) { this.viewController = viewController; this.registry = registry; + this.settingsService = settingsService; } public process(el: HTMLElement, ctx: MarkdownPostProcessorContext): void { - if (isPrintMode(el)) { + if (isPrintMode(el) || !this.settingsService.getSettings().autoConvertMarkdownTables) { return; } diff --git a/src/main.ts b/src/main.ts index fdc1613..8ee2edb 100644 --- a/src/main.ts +++ b/src/main.ts @@ -40,7 +40,11 @@ export default class TableBasePlugin extends Plugin { new CommandService(this).registerCommands(); - const postProcessor = new TablePostProcessor(viewController, this.viewRegistry); + const postProcessor = new TablePostProcessor( + viewController, + this.viewRegistry, + this.settingsService + ); this.registerMarkdownPostProcessor((el, ctx) => postProcessor.process(el, ctx)); const codeBlockProcessor = new TableCodeBlockProcessor(viewController, this.viewRegistry); diff --git a/src/types.ts b/src/types.ts index 92a64ca..67addb3 100644 --- a/src/types.ts +++ b/src/types.ts @@ -157,6 +157,7 @@ export type DateFormatOption = export interface PluginSettings { defaultTagFormat: TagFormat; dateFormat?: DateFormatOption; + autoConvertMarkdownTables: boolean; autoDetectMultiSelect: boolean; multiSelectColumnNames: string[]; customTagColors: Record; @@ -168,6 +169,7 @@ export interface PluginSettings { export const DEFAULT_SETTINGS: PluginSettings = { defaultTagFormat: "comma", + autoConvertMarkdownTables: true, autoDetectMultiSelect: true, multiSelectColumnNames: [ "Tags", diff --git a/src/ui/settings-tab.ts b/src/ui/settings-tab.ts index 0fad0ec..09b63d0 100644 --- a/src/ui/settings-tab.ts +++ b/src/ui/settings-tab.ts @@ -20,6 +20,14 @@ export class TableBaseSettingTab extends PluginSettingTab { } return [ + { + name: "Auto-convert Markdown tables", + desc: "Automatically convert standard Markdown tables into interactive TableBase tables.", + control: { + type: "toggle", + key: "autoConvertMarkdownTables", + }, + }, { name: "Auto-detect multi-select columns", desc: "Automatically detect multi-select columns by analyzing cell values (lists, wikilinks, tags).", @@ -149,6 +157,19 @@ export class TableBaseSettingTab extends PluginSettingTab { const settings = this.settingsService.getSettings(); this.addHeading(containerEl, "Column detection"); + new Setting(containerEl) + .setName("Auto-convert Markdown tables") + .setDesc( + "Automatically convert standard Markdown tables into interactive TableBase tables." + ) + .addToggle((toggle) => + toggle + .setValue(settings.autoConvertMarkdownTables) + .onChange(async (value) => { + await this.settingsService.updateSettings({ autoConvertMarkdownTables: value }); + }) + ); + new Setting(containerEl) .setName("Auto-detect multi-select columns") .setDesc( diff --git a/tests/column-type-change.test.ts b/tests/column-type-change.test.ts index 73406f0..18cfbc1 100644 --- a/tests/column-type-change.test.ts +++ b/tests/column-type-change.test.ts @@ -121,7 +121,14 @@ describe("changing a column type from the header menu", () => { "", ].join("\n"); - const processor = new TablePostProcessor(controller, new TableViewRegistry()); + const processor = new TablePostProcessor( + controller, + new TableViewRegistry(), + new SettingsService({ + loadData: async () => null, + saveData: async () => undefined, + }) + ); const ctx = { docId: "doc", sourcePath: "Notes/Test.md", diff --git a/tests/post-processor.test.ts b/tests/post-processor.test.ts index 9e84cb4..5161299 100644 --- a/tests/post-processor.test.ts +++ b/tests/post-processor.test.ts @@ -64,7 +64,7 @@ function createProcessor(sectionInfo?: SectionSource): { new TableSyncService(new VaultService(app)) ); - const processor = new TablePostProcessor(controller, new TableViewRegistry()); + const processor = new TablePostProcessor(controller, new TableViewRegistry(), settingsService); const ctx = { docId: "doc", @@ -110,6 +110,18 @@ describe("TablePostProcessor date columns", () => { expect(addColTh?.querySelector(".ms-db-th-add-col-icon svg")).not.toBeNull(); }); + it("should skip standard Markdown tables when auto-conversion is disabled", async () => { + const { processor, ctx, settingsService } = createProcessor(); + await settingsService.updateSettings({ autoConvertMarkdownTables: false }); + const wrapper = buildRenderedTable(["Task"], [["Buy bread"]]); + + processor.process(wrapper, ctx); + + expect(wrapper.querySelector(".ms-notion-database-container")).toBeNull(); + expect(wrapper.querySelector("table")).not.toBeNull(); + expect(ctx.addChild).not.toHaveBeenCalled(); + }); + it("should register the view for lifecycle teardown", () => { const { processor, ctx } = createProcessor(); const wrapper = buildRenderedTable(["Task", "Due Date [date]"], [["A", "2026-09-15"]]); diff --git a/tests/settings-service.test.ts b/tests/settings-service.test.ts index 2e36d90..7372f91 100644 --- a/tests/settings-service.test.ts +++ b/tests/settings-service.test.ts @@ -23,6 +23,7 @@ describe("SettingsService", () => { const loaded = await service.loadSettings(); expect(loaded.defaultTagFormat).toBe("wikilink"); expect(loaded.showRowNumbers).toBe(true); + expect(loaded.autoConvertMarkdownTables).toBe(true); expect(loaded.autoDetectMultiSelect).toBe(DEFAULT_SETTINGS.autoDetectMultiSelect); expect(storage.loadData).toHaveBeenCalledOnce(); }); diff --git a/tests/settings-tab.test.ts b/tests/settings-tab.test.ts index 2b80b55..9bee15a 100644 --- a/tests/settings-tab.test.ts +++ b/tests/settings-tab.test.ts @@ -53,12 +53,26 @@ describe("TableBaseSettingTab", () => { const toggles = tab.containerEl.querySelectorAll(".checkbox-container"); expect(toggles.length).toBeGreaterThan(0); - const autoDetectToggle = toggles[0]; + const autoDetectToggle = toggles[1]; autoDetectToggle.click(); expect(settingsService.getSettings().autoDetectMultiSelect).toBe(false); }); + it("should update autoConvertMarkdownTables when its toggle is clicked", async () => { + const plugin = createMockPlugin(); + const settingsService = new SettingsService(plugin); + await settingsService.loadSettings(); + + const tab = new TableBaseSettingTab({} as App, plugin, settingsService); + tab.display(); + + const toggles = tab.containerEl.querySelectorAll(".checkbox-container"); + toggles[0].click(); + + expect(settingsService.getSettings().autoConvertMarkdownTables).toBe(false); + }); + it("should update multiSelectColumnNames when textarea changes", async () => { const plugin = createMockPlugin(); const settingsService = new SettingsService(plugin); @@ -106,7 +120,7 @@ describe("TableBaseSettingTab", () => { tab.display(); const toggles = tab.containerEl.querySelectorAll(".checkbox-container"); - const rowNumberToggle = toggles[1]; + const rowNumberToggle = toggles[2]; expect(rowNumberToggle).toBeDefined(); rowNumberToggle.click(); From c102de2ba94d9d73fce612199bf182a5014871bc Mon Sep 17 00:00:00 2001 From: Aleksei Chekodanov <40733491+a269ch@users.noreply.github.com> Date: Fri, 23 Oct 2026 03:23:38 +0300 Subject: [PATCH 2/9] Improve table cell text wrapping (#14) changed: - Allow database tables to use the available container width - Wrap long cell content without changing explicit column sizing --- styles.css | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/styles.css b/styles.css index edb30cf..054df4f 100644 --- a/styles.css +++ b/styles.css @@ -607,8 +607,8 @@ input[type="text"].ms-filter-value-input:focus { } .ms-db-grid-table { - width: max-content; - min-width: 0; + width: 100%; + min-width: 100%; border-collapse: separate; border-spacing: 0; table-layout: auto; @@ -1201,6 +1201,7 @@ input[type="text"].ms-filter-value-input:focus { min-height: 20px; line-height: 1.4; word-break: break-word; + overflow-wrap: anywhere; } .ms-cell-text-wrapper.is-number { From 7b6bad1141a5e5df792148f060533d82bd8da75d Mon Sep 17 00:00:00 2001 From: Yoav Aharoni Date: Mon, 19 Oct 2026 02:03:53 +0300 Subject: [PATCH 3/9] fix(cells): render inline code spans in cell text Cells lost inline code: the renderer flattens a code span to its text, and the cell parser only knew links, so a note's `code` showed as plain text and an edited cell wrote it back without backticks. The parser now reads CommonMark code spans (a link that starts first keeps them in its label), the source cell is preferred because its flattened form matches, and sort, filter, type inference and Kanban grouping read the flattened text. Code renders as a styled span rather than a code element, so plugins that act on inline code (Dataview queries, Meta Bind inputs) never run on cell text, as before. Co-Authored-By: Claude Opus 5.5 --- src/core/filter-engine.ts | 2 + src/core/sort-engine.ts | 5 +- src/core/table-state.ts | 4 +- src/ui/kanban-view.ts | 8 +- src/ui/table/cells.ts | 8 +- src/utils/link-renderer.ts | 163 ++++++++++++++++++++++++++--------- styles.css | 9 ++ tests/filter-engine.test.ts | 5 ++ tests/link-renderer.test.ts | 54 +++++++++++- tests/post-processor.test.ts | 13 +++ tests/sort-engine.test.ts | 11 +++ 11 files changed, 230 insertions(+), 52 deletions(-) diff --git a/src/core/filter-engine.ts b/src/core/filter-engine.ts index ab93dbb..7e0f467 100644 --- a/src/core/filter-engine.ts +++ b/src/core/filter-engine.ts @@ -7,6 +7,7 @@ import { TableFilterState, } from "../types"; import { parseCellTags } from "./tag-parser"; +import { stripCodeSpans } from "../utils/link-renderer"; export function normalize(str: string): string { return (str || "").trim().toLowerCase(); @@ -29,6 +30,7 @@ export function evaluateRuleOnCell( operator: FilterOperator, filterValue: string | string[] ): boolean { + cellValue = stripCodeSpans(cellValue); const normCell = normalize(cellValue); const tags = columnType === "multi-select" ? parseCellTags(cellValue) : []; const tagNamesLower = tags.map((t) => normalize(t.name)); diff --git a/src/core/sort-engine.ts b/src/core/sort-engine.ts index 4b15fb1..bd4bdbe 100644 --- a/src/core/sort-engine.ts +++ b/src/core/sort-engine.ts @@ -1,6 +1,7 @@ import { MarkdownTableRow, SortDirection, SortRule, TableColumn } from "../types"; import { parseCellTags } from "./tag-parser"; import { compareDateStrings } from "./date-utils"; +import { stripCodeSpans } from "../utils/link-renderer"; const CHECKED_VALUES = new Set([ "[x]", @@ -91,8 +92,8 @@ export function compareRowsByColumn( direction: SortDirection, columnType: TableColumn["type"] = "text" ): number { - const valA = a.cells[columnIndex] || ""; - const valB = b.cells[columnIndex] || ""; + const valA = stripCodeSpans(a.cells[columnIndex] || ""); + const valB = stripCodeSpans(b.cells[columnIndex] || ""); const isEmptyA = isEmptyCell(valA); const isEmptyB = isEmptyCell(valB); diff --git a/src/core/table-state.ts b/src/core/table-state.ts index d39e682..f05f246 100644 --- a/src/core/table-state.ts +++ b/src/core/table-state.ts @@ -11,6 +11,7 @@ import { TableColumn, TableFilterState, } from "../types"; +import { stripCodeSpans } from "../utils/link-renderer"; import { isCalculationType } from "./calculation-engine"; import { parseColumnAlignment } from "./markdown-parser"; import { isCellChecked, parseCellNumber } from "./sort-engine"; @@ -42,7 +43,8 @@ export class TableStateManager { for (let c = 0; c < table.headers.length; c++) { const rawHeader = table.headers[c] || ""; - const columnCells = table.rows.map((r) => r.cells[c] || ""); + // Types are read from the text a cell shows, so "`42`" is a number. + const columnCells = table.rows.map((r) => stripCodeSpans(r.cells[c] || "")); let inferredType: ColumnType = "text"; let cleanHeaderName = stripTypeAnnotation(rawHeader); diff --git a/src/ui/kanban-view.ts b/src/ui/kanban-view.ts index db1af6f..56c340c 100644 --- a/src/ui/kanban-view.ts +++ b/src/ui/kanban-view.ts @@ -14,7 +14,7 @@ import { } from "../types"; import { appendIcon, appendIconLabel } from "../utils/dom"; import { attachStrictNumericInputHandlers, sanitizeNumericCellValue } from "../utils/input"; -import { renderTextWithLinks } from "../utils/link-renderer"; +import { renderTextWithLinks, stripCodeSpans } from "../utils/link-renderer"; import { ICON_CHECK_SQUARE, ICON_DUPLICATE, @@ -150,7 +150,7 @@ export class KanbanView { groups.set(CHECKED_GROUP, []); for (const row of visibleRows) { - const cellValue = (row.cells[this.groupByColIndex] || "").trim(); + const cellValue = stripCodeSpans(row.cells[this.groupByColIndex] || "").trim(); const key = isCellChecked(cellValue) ? CHECKED_GROUP : UNCHECKED_GROUP; groups.get(key)?.push(row); } @@ -165,7 +165,7 @@ export class KanbanView { } for (const row of visibleRows) { - const cellValue = (row.cells[this.groupByColIndex] || "").trim(); + const cellValue = stripCodeSpans(row.cells[this.groupByColIndex] || "").trim(); if (!cellValue) { noStatusRows.push(row); continue; @@ -570,7 +570,7 @@ export class KanbanView { // property name. badge.addClass("is-number"); badge.createSpan({ cls: "ms-kanban-prop-name", text: col.name }); - badge.createSpan({ cls: "ms-kanban-prop-value", text: cellValue }); + badge.createSpan({ cls: "ms-kanban-prop-value", text: stripCodeSpans(cellValue) }); return; } diff --git a/src/ui/table/cells.ts b/src/ui/table/cells.ts index 6d11986..2abe5f9 100644 --- a/src/ui/table/cells.ts +++ b/src/ui/table/cells.ts @@ -4,7 +4,7 @@ import { parseCellTags } from "../../core/tag-parser"; import { ColumnAlignment, MarkdownTableRow, TableColumn } from "../../types"; import { appendIcon } from "../../utils/dom"; import { attachStrictNumericInputHandlers, sanitizeNumericCellValue } from "../../utils/input"; -import { renderTextWithLinks } from "../../utils/link-renderer"; +import { renderTextWithLinks, stripCodeSpans } from "../../utils/link-renderer"; import { ICON_TYPE_DATE } from "../icons"; import { DatePickerPopover } from "../modals/date-picker-popover"; import { SingleSelectPopover } from "../modals/single-select-popover"; @@ -193,13 +193,13 @@ export class CellRenderer { colIndex: number, rawValue: string ): void { - const isNumber = col.type === "number" || isNumericCell(rawValue); + const isNumber = col.type === "number" || isNumericCell(stripCodeSpans(rawValue)); const wrapper = td.createDiv({ cls: `ms-cell-text-wrapper ${isNumber ? "is-number" : ""}`, }); if (isNumber) { - wrapper.textContent = rawValue; + wrapper.textContent = stripCodeSpans(rawValue); } else { renderTextWithLinks(wrapper, rawValue, { onExternalClick: (url, e) => { @@ -307,7 +307,7 @@ export class CellRenderer { td.classList.add("is-editing"); const col = this.ctx.columns[colIndex]; - const isNumber = col?.type === "number" || isNumericCell(currentValue); + const isNumber = col?.type === "number" || isNumericCell(stripCodeSpans(currentValue)); const alignment = col ? resolveColumnAlignment(col) : (isNumber ? "right" : "left"); const input = td.createEl("input", { diff --git a/src/utils/link-renderer.ts b/src/utils/link-renderer.ts index 8d924a9..8892890 100644 --- a/src/utils/link-renderer.ts +++ b/src/utils/link-renderer.ts @@ -1,5 +1,5 @@ export interface LinkToken { - type: "text" | "external" | "internal"; + type: "text" | "code" | "external" | "internal"; text: string; href?: string; } @@ -39,62 +39,141 @@ function appendTextToken(tokens: LinkToken[], text: string): void { } } -export function parseLinkTokens(input: string): LinkToken[] { - if (!input) return []; +/** Whether the character at `index` follows an odd run of backslashes. */ +function isEscaped(input: string, index: number): boolean { + let count = 0; + for (let i = index - 1; i >= 0 && input[i] === "\\"; i--) count++; + return count % 2 === 1; +} - const combinedRegex = - /(\[(?[^\]\r\n]+)\]\((?https?:\/\/(?:[^\s()\r\n]|\([^\s()\r\n]*\))+)\))|(\[\[(?[^\]|\r\n]+)(?:\|(?[^\]\r\n]+))?\]\])|(?https?:\/\/(?:[^\s<>[\]()"']|\([^\s<>[\]()"']*\))+)/g; +/** The end of the backtick run starting at `index`. */ +function backtickRunEnd(input: string, index: number): number { + let end = index; + while (input[end] === "`") end++; + return end; +} - const tokens: LinkToken[] = []; - let lastIndex = 0; - let match: RegExpExecArray | null; +interface CodeSpan { + start: number; + end: number; + text: string; +} - while ((match = combinedRegex.exec(input)) !== null) { - const matchIndex = match.index; - if (matchIndex > lastIndex) { - appendTextToken(tokens, input.slice(lastIndex, matchIndex)); - } +/** A code span's content: line endings become spaces, one space is stripped from each side. */ +function codeContent(raw: string): string { + const content = raw.replace(/\r\n|\r|\n/g, " "); + if (content.length > 1 && content.startsWith(" ") && content.endsWith(" ") && /[^ ]/.test(content)) { + return content.slice(1, -1); + } + return content; +} - const groups = match.groups; - if (groups?.mdUrl) { - tokens.push({ - type: "external", - text: groups.mdLabel || groups.mdUrl, - href: groups.mdUrl, - }); - } else if (groups?.wikiTarget) { - tokens.push({ - type: "internal", - text: groups.wikiAlias || groups.wikiTarget, - href: groups.wikiTarget.trim(), - }); - } else if (groups?.rawUrl) { - const { url, trailing } = trimTrailingUrlPunctuation(groups.rawUrl); - if (url.length > 0) { - tokens.push({ - type: "external", - text: url, - href: url, - }); +/** + * The first CommonMark code span at or after `from`: a backtick run opens a + * span that the next run of the same length closes. An unmatched run, or a + * backtick after a backslash, stays text. + */ +function findCodeSpan(input: string, from: number): CodeSpan | null { + let i = from; + while (i < input.length) { + if (input[i] !== "`" || isEscaped(input, i)) { + i++; + continue; + } + const openEnd = backtickRunEnd(input, i); + const length = openEnd - i; + for (let j = openEnd; j < input.length; ) { + if (input[j] !== "`") { + j++; + continue; } - if (trailing.length > 0) { - appendTextToken(tokens, trailing); + const runEnd = backtickRunEnd(input, j); + if (runEnd - j === length) { + return { start: i, end: runEnd, text: codeContent(input.slice(openEnd, j)) }; } + j = runEnd; } + i = openEnd; + } + return null; +} - lastIndex = combinedRegex.lastIndex; +/** The value with each code span replaced by its content: "`Done`" becomes "Done". */ +export function stripCodeSpans(input: string): string { + let out = ""; + let pos = 0; + for (let span = findCodeSpan(input, 0); span; span = findCodeSpan(input, pos)) { + out += input.slice(pos, span.start) + span.text; + pos = span.end; } + return out + input.slice(pos); +} + +const LINK_PATTERN = + /(\[(?[^\]\r\n]+)\]\((?https?:\/\/(?:[^\s()\r\n]|\([^\s()\r\n]*\))+)\))|(\[\[(?[^\]|\r\n]+)(?:\|(?[^\]\r\n]+))?\]\])|(?https?:\/\/(?:[^\s<>[\]()"']|\([^\s<>[\]()"']*\))+)/g; - if (lastIndex < input.length) { - appendTextToken(tokens, input.slice(lastIndex)); +export function parseLinkTokens(input: string): LinkToken[] { + if (!input) return []; + + const linkRegex = new RegExp(LINK_PATTERN); + const tokens: LinkToken[] = []; + let pos = 0; + + while (pos < input.length) { + linkRegex.lastIndex = pos; + const link = linkRegex.exec(input); + const code = findCodeSpan(input, pos); + // A link that starts first keeps any code span inside it; otherwise code binds first. + if (code && (!link || code.start < link.index)) { + appendTextToken(tokens, input.slice(pos, code.start)); + tokens.push({ type: "code", text: code.text }); + pos = code.end; + } else if (link) { + appendTextToken(tokens, input.slice(pos, link.index)); + appendLinkToken(tokens, link); + pos = link.index + link[0].length; + } else { + appendTextToken(tokens, input.slice(pos)); + break; + } } return tokens; } +function appendLinkToken(tokens: LinkToken[], match: RegExpExecArray): void { + const groups = match.groups; + if (groups?.mdUrl) { + tokens.push({ + type: "external", + text: groups.mdLabel ? stripCodeSpans(groups.mdLabel) : groups.mdUrl, + href: groups.mdUrl, + }); + } else if (groups?.wikiTarget) { + tokens.push({ + type: "internal", + text: groups.wikiAlias || groups.wikiTarget, + href: groups.wikiTarget.trim(), + }); + } else if (groups?.rawUrl) { + const { url, trailing } = trimTrailingUrlPunctuation(groups.rawUrl); + if (url.length > 0) { + tokens.push({ + type: "external", + text: url, + href: url, + }); + } + if (trailing.length > 0) { + appendTextToken(tokens, trailing); + } + } +} + /** * The text a renderer would leave behind for this value: "[[Page|Alias]]" - * becomes "Alias". Lets callers tell a flattened link from plain text. + * becomes "Alias", "`code`" becomes "code". Lets callers tell a flattened + * link or code span from plain text. */ export function toPlainText(input: string): string { return parseLinkTokens(input) @@ -116,6 +195,10 @@ export function renderTextWithLinks( } else { container.appendChild(container.ownerDocument.createTextNode(token.text)); } + } else if (token.type === "code") { + // A span, not `code`: plugins that act on inline code (queries, inputs) + // must not run on cell text. + container.createEl("span", { cls: "ms-cell-code", text: token.text }); } else if (token.type === "external") { const linkEl = container.createEl("a", { cls: "ms-cell-link external-link", diff --git a/styles.css b/styles.css index 054df4f..9cd2ffe 100644 --- a/styles.css +++ b/styles.css @@ -1208,6 +1208,15 @@ input[type="text"].ms-filter-value-input:focus { font-variant-numeric: tabular-nums; } +.ms-cell-code { + font-family: var(--font-monospace); + font-size: var(--code-size); + color: var(--code-normal); + background-color: var(--code-background); + border-radius: var(--code-radius, var(--radius-s)); + padding: 0.1em 0.25em; +} + .ms-cell-link { color: var(--link-color, var(--text-accent)); text-decoration: var(--link-decoration, underline); diff --git a/tests/filter-engine.test.ts b/tests/filter-engine.test.ts index 917a128..344f2c4 100644 --- a/tests/filter-engine.test.ts +++ b/tests/filter-engine.test.ts @@ -48,6 +48,11 @@ describe("Table Filter Engine", () => { }, ]; + it("should match code cells by the text they show", () => { + expect(evaluateRuleOnCell("`Done`", "text", "is_one_of", ["done"])).toBe(true); + expect(evaluateRuleOnCell("Loose in `System/AI`", "text", "contains", "in system")).toBe(true); + }); + describe("Multi-Select Operators on Cells", () => { const cellTags = "Frontend, Bug, Urgent"; diff --git a/tests/link-renderer.test.ts b/tests/link-renderer.test.ts index f354089..daad30f 100644 --- a/tests/link-renderer.test.ts +++ b/tests/link-renderer.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect, beforeAll, vi } from "vitest"; -import { parseLinkTokens, renderTextWithLinks } from "../src/utils/link-renderer"; +import { parseLinkTokens, renderTextWithLinks, stripCodeSpans, toPlainText } from "../src/utils/link-renderer"; import { installObsidianDomHelpers } from "./helpers/obsidian-dom"; describe("link-renderer", () => { @@ -158,4 +158,56 @@ describe("link-renderer", () => { expect(onInternalClick).toHaveBeenCalledTimes(1); expect(parentDblClick).not.toHaveBeenCalled(); }); + it("should parse inline code spans and leave links inside them as code", () => { + expect(parseLinkTokens("Loose in `System/AI` and `[[Note]]`")).toEqual([ + { type: "text", text: "Loose in " }, + { type: "code", text: "System/AI" }, + { type: "text", text: " and " }, + { type: "code", text: "[[Note]]" }, + ]); + }); + + it("should follow CommonMark backtick runs for code spans", () => { + // A longer run holds a backtick; one space each side is stripped. + expect(parseLinkTokens("`` a`b ``")).toEqual([{ type: "code", text: "a`b" }]); + expect(parseLinkTokens("` `")).toEqual([{ type: "code", text: " " }]); + // A run closes only on a run of the same length. + expect(parseLinkTokens("``a`b``")).toEqual([{ type: "code", text: "a`b" }]); + // Unmatched and escaped backticks stay text. + expect(parseLinkTokens("a ` b")).toEqual([{ type: "text", text: "a ` b" }]); + expect(parseLinkTokens("\\`a`")).toEqual([{ type: "text", text: "\\`a`" }]); + expect(parseLinkTokens("``a`")).toEqual([{ type: "text", text: "``a`" }]); + }); + + it("should keep a link whole when a code span sits in its label", () => { + expect(parseLinkTokens("see [`api`](https://e.test/api) or `https://e.test/x`")).toEqual([ + { type: "text", text: "see " }, + { type: "external", text: "api", href: "https://e.test/api" }, + { type: "text", text: " or " }, + { type: "code", text: "https://e.test/x" }, + ]); + }); + + it("should treat line endings in a code span as spaces", () => { + expect(parseLinkTokens("`a\rb\r\nc`")).toEqual([{ type: "code", text: "a b c" }]); + expect(parseLinkTokens("` \t `")).toEqual([{ type: "code", text: "\t" }]); + }); + + it("should strip code spans only, leaving links as written", () => { + expect(stripCodeSpans("`Done` and [[Page]] `` a`b ``")).toBe("Done and [[Page]] a`b"); + }); + + it("should flatten code spans to their text", () => { + expect(toPlainText("Loose in `System/AI`")).toBe("Loose in System/AI"); + }); + + it("should render a code span as a styled span, not a code element", () => { + const container = document.createElement("div"); + renderTextWithLinks(container, "Run `= this.file.name` now"); + const code = container.querySelector(".ms-cell-code"); + expect(code?.tagName).toBe("SPAN"); + expect(code?.textContent).toBe("= this.file.name"); + expect(container.querySelector("code")).toBeNull(); + expect(container.textContent).toBe("Run = this.file.name now"); + }); }); diff --git a/tests/post-processor.test.ts b/tests/post-processor.test.ts index 5161299..0f5e2fd 100644 --- a/tests/post-processor.test.ts +++ b/tests/post-processor.test.ts @@ -566,6 +566,19 @@ describe("TablePostProcessor source parsing", () => { expect(names).toEqual(["Модуль", "Категория"]); }); + it("should bring inline code back in cells the renderer flattened", () => { + const text = ["| Where | What |", "| --- | --- |", "| Loose in `System/AI` | scripts |"].join("\n"); + + const { processor, ctx } = createProcessor({ text, lineStart: 0, lineEnd: 2 }); + // Obsidian renders the code span as ; the cell's text loses the backticks. + const wrapper = buildRenderedTable(["Where", "What"], [["Loose in System/AI", "scripts"]]); + + processor.process(wrapper, ctx); + + const cell = wrapper.querySelector('.ms-db-td[data-col-index="0"]'); + expect(cell?.querySelector(".ms-cell-code")?.textContent).toBe("System/AI"); + }); + it("should bring wikilinks back to life in cells the renderer flattened", () => { const text = [ "| Заметка | Проверено |", diff --git a/tests/sort-engine.test.ts b/tests/sort-engine.test.ts index 469283e..14bc0e5 100644 --- a/tests/sort-engine.test.ts +++ b/tests/sort-engine.test.ts @@ -44,6 +44,17 @@ describe("Sort Engine", () => { expect(desc.map((r) => r.cells[0])).toEqual(["Cherry", "Banana", "Apple"]); }); + it("should sort code cells by the text they show", () => { + const rows: MarkdownTableRow[] = [ + { rowIndex: 0, rawLine: "", cells: ["banana"] }, + { rowIndex: 1, rawLine: "", cells: ["`cherry`"] }, + { rowIndex: 2, rawLine: "", cells: ["apple"] }, + ]; + + const asc = sortRows(rows, 0, "asc", "text"); + expect(asc.map((r) => r.cells[0])).toEqual(["apple", "banana", "`cherry`"]); + }); + it("should sort number rows numerically", () => { const rows: MarkdownTableRow[] = [ { rowIndex: 0, rawLine: "", cells: ["$100"] }, From 9a1aa0b1e2b25447e030f6e3893acf0ad5427675 Mon Sep 17 00:00:00 2001 From: Yoav Aharoni Date: Tue, 20 Oct 2026 02:00:27 +0300 Subject: [PATCH 4/9] fix(sync): write code block edits to the right block without a regex Saving a table code block built a regular expression from the block's whole source. On a large table (about 40 KB) V8 throws "Regular expression too large", modifyFile swallows it, and the cell shows the new value while the note never changes. The same pattern started matching at the first table fence in the note, so editing a later block replaced everything from the first block down, and String.replace expanded $`, $& and $' in cell text into other parts of the note. Blocks are now found by a line scan of fences (backtick and tilde, longer closing fences, blocks nested in other fences skipped, list and callout prefixes kept) and matched on their source, with the processor's section line telling identical tables apart. Only the body is replaced and each line keeps its line ending. When no single block matches, nothing is written and a Notice says so, instead of overwriting the first table. The view advances its expected source only after a successful save, and one view's saves run in order. The opening fence is now kept: tablebase and notion-table blocks are no longer renamed to table. Known limits: a fence on the same line as a list marker is not recognized (the save is skipped with a Notice), and indented code is not told apart from list continuation. Co-Authored-By: Claude Opus 5.5 --- src/extensions/codeblock-processor.ts | 1 + src/services/table-sync-service.ts | 102 ++++++++++++++++++---- src/services/table-view-controller.ts | 27 ++++-- tests/codeblock-processor.test.ts | 4 + tests/table-sync-service.test.ts | 116 ++++++++++++++++++++++++++ tests/table-view-controller.test.ts | 54 +++++++++++- 6 files changed, 278 insertions(+), 26 deletions(-) diff --git a/src/extensions/codeblock-processor.ts b/src/extensions/codeblock-processor.ts index b47d13c..b9af560 100644 --- a/src/extensions/codeblock-processor.ts +++ b/src/extensions/codeblock-processor.ts @@ -35,6 +35,7 @@ export class TableCodeBlockProcessor { sourcePath: ctx.sourcePath, tableData: tables[0], initialSource: source, + getLineStart: () => ctx.getSectionInfo(el)?.lineStart, }); const viewEl = view.getElement(); diff --git a/src/services/table-sync-service.ts b/src/services/table-sync-service.ts index 8767310..c2f2f35 100644 --- a/src/services/table-sync-service.ts +++ b/src/services/table-sync-service.ts @@ -1,7 +1,68 @@ import { serializeMarkdownTable } from "../core/markdown-parser"; +import { TABLE_CODE_BLOCK_LANGUAGES } from "../extensions/codeblock-processor"; import { MarkdownTableData } from "../types"; import { VaultService } from "./vault-service"; +// Optional blockquote/callout markers and indentation, the fence, then the info string. +const FENCE_OPEN = /^((?:[ \t]*>)*[ \t]*)(`{3,}|~{3,})[ \t]*([^\s`]*)/; + +interface FencedBlock { + openLine: number; + closeLine: number; + prefix: string; + language: string; + body: string; +} + +function stripPrefix(line: string, prefix: string): string | null { + if (line.startsWith(prefix)) return line.slice(prefix.length); + if (line.trimEnd() === prefix.trimEnd()) return ""; + return null; +} + +function isClosingFence(line: string, prefix: string, fence: string): boolean { + const rest = (stripPrefix(line, prefix) ?? line).trim(); + return rest.length >= fence.length && [...rest].every((char) => char === fence[0]); +} + +// Lines are passed without "\r"; blocks inside other fenced blocks are skipped. +function findFencedBlocks(lines: string[]): FencedBlock[] { + const blocks: FencedBlock[] = []; + for (let i = 0; i < lines.length; i++) { + const match = FENCE_OPEN.exec(lines[i]); + if (!match) continue; + const [, prefix, fence, language] = match; + if (fence[0] === "`" && lines[i].slice(match[0].length).includes("`")) continue; + + const closeLine = lines.findIndex((line, j) => j > i && isClosingFence(line, prefix, fence)); + if (closeLine === -1) break; + + const bodyLines = lines.slice(i + 1, closeLine).map((line) => stripPrefix(line, prefix) ?? line); + blocks.push({ openLine: i, closeLine, prefix, language: language.toLowerCase(), body: bodyLines.join("\n") }); + i = closeLine; + } + return blocks; +} + +// A table block at the hinted line must match; otherwise the only match wins. Anything ambiguous returns undefined. +function pickBlock( + blocks: FencedBlock[], + initialSource?: string, + lineHint?: number +): FencedBlock | undefined { + const tableBlocks = blocks.filter((block) => + (TABLE_CODE_BLOCK_LANGUAGES as readonly string[]).includes(block.language) + ); + const isMatch = (block: FencedBlock) => + initialSource === undefined || block.body.trim() === initialSource.replace(/\r/g, "").trim(); + + const hinted = tableBlocks.find((block) => block.openLine === lineHint); + if (hinted) return isMatch(hinted) ? hinted : undefined; + + const candidates = tableBlocks.filter(isMatch); + return candidates.length === 1 ? candidates[0] : undefined; +} + export class TableSyncService { private vaultService: VaultService; @@ -9,32 +70,37 @@ export class TableSyncService { this.vaultService = vaultService; } + /** Resolves true when the block was found and rewritten. */ public async syncCodeBlock( sourcePath: string, tableData: MarkdownTableData, - initialSource?: string - ): Promise { - if (!sourcePath) return; + initialSource?: string, + lineHint?: number + ): Promise { + if (!sourcePath) return false; - const newMarkdown = serializeMarkdownTable(tableData); - const replacement = `\`\`\`table\n${newMarkdown}\n\`\`\``; + const newBody = serializeMarkdownTable(tableData).split("\n"); + let replaced = false; const transform = (content: string): string => { - if (initialSource) { - const escaped = initialSource.trim().replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); - const exactBlockRegex = new RegExp( - "```(?:table|notion-table|tablebase)[\\s\\S]*?" + escaped + "[\\s\\S]*?```" - ); - if (exactBlockRegex.test(content)) { - return content.replace(exactBlockRegex, replacement); - } - } - - const firstTableBlockRegex = /```(?:table|notion-table|tablebase)[\s\S]*?```/; - return content.replace(firstTableBlockRegex, replacement); + replaced = false; + const lines = content.split("\n"); + const block = pickBlock( + findFencedBlocks(lines.map((line) => line.replace(/\r$/, ""))), + initialSource, + lineHint + ); + if (!block) return content; + + const lineEnding = lines[block.openLine].endsWith("\r") ? "\r" : ""; + const bodyLines = newBody.map((line) => `${block.prefix}${line}${lineEnding}`); + lines.splice(block.openLine + 1, block.closeLine - block.openLine - 1, ...bodyLines); + replaced = true; + return lines.join("\n"); }; - await this.vaultService.modifyFile(sourcePath, transform); + const modified = await this.vaultService.modifyFile(sourcePath, transform); + return modified && replaced; } public async syncInlineTable( diff --git a/src/services/table-view-controller.ts b/src/services/table-view-controller.ts index 1803532..99eb5b9 100644 --- a/src/services/table-view-controller.ts +++ b/src/services/table-view-controller.ts @@ -1,4 +1,4 @@ -import { App } from "obsidian"; +import { App, Notice } from "obsidian"; import { addColumnToDocument, addRowToDocument, @@ -44,6 +44,7 @@ export interface CodeBlockViewParams { sourcePath: string; tableData: MarkdownTableData; initialSource: string; + getLineStart?: () => number | undefined; } export interface InlineTableViewParams { @@ -97,10 +98,26 @@ export class TableViewController { public createCodeBlockView(params: CodeBlockViewParams): NotionTableView { const { sourcePath, tableData } = params; let currentSource = params.initialSource; - - const persist: PersistOperation = async () => { - await this.syncService.syncCodeBlock(sourcePath, tableData, currentSource); - currentSource = serializeMarkdownTable(tableData); + let pendingSave: Promise = Promise.resolve(); + + // Saves run one at a time so each finds the block as the previous one left it. + const persist: PersistOperation = () => { + const save = pendingSave.then(async () => { + const written = serializeMarkdownTable(tableData); + const saved = await this.syncService.syncCodeBlock( + sourcePath, + tableData, + currentSource, + params.getLineStart?.() + ); + if (!saved) { + new Notice("TableBase: could not find this table in the note, so the change was not saved."); + return; + } + currentSource = written; + }); + pendingSave = save.catch(() => undefined); + return save; }; return this.createView(tableData, persist, sourcePath); diff --git a/tests/codeblock-processor.test.ts b/tests/codeblock-processor.test.ts index b7bcb63..a6fb9ca 100644 --- a/tests/codeblock-processor.test.ts +++ b/tests/codeblock-processor.test.ts @@ -75,6 +75,10 @@ describe("TableCodeBlockProcessor", () => { }) ); + const { getLineStart } = vi.mocked(mockViewController.createCodeBlockView).mock.calls[0][0]; + (ctx as unknown as { getSectionInfo: () => unknown }).getSectionInfo = () => ({ lineStart: 7 }); + expect(getLineStart?.()).toBe(7); + expect(el.querySelector(".ms-notion-database-container")).toBe(viewEl); expect(ctx.addChild).toHaveBeenCalled(); diff --git a/tests/table-sync-service.test.ts b/tests/table-sync-service.test.ts index c2a39a9..99d14d4 100644 --- a/tests/table-sync-service.test.ts +++ b/tests/table-sync-service.test.ts @@ -2,6 +2,7 @@ import { describe, it, expect, vi } from "vitest"; import { TableSyncService } from "../src/services/table-sync-service"; import { VaultService } from "../src/services/vault-service"; import { MarkdownTableData } from "../src/types"; +import { serializeMarkdownTable } from "../src/core/markdown-parser"; describe("TableSyncService", () => { const createMockVaultService = (initialContent: string) => { @@ -51,6 +52,121 @@ describe("TableSyncService", () => { expect(updated).toContain("Footer text"); }); + it("should sync a table too large to embed in a regular expression", async () => { + const longCell = "x".repeat(400); + const rows = Array.from({ length: 200 }, (_, i) => `| ${longCell} ${i} | Todo |`); + const source = ["| Task | Status |", "| --- | --- |", ...rows].join("\n"); + const { vaultService, getContent } = createMockVaultService(`# Note\n\n\`\`\`table\n${source}\n\`\`\`\n`); + const syncService = new TableSyncService(vaultService); + + await syncService.syncCodeBlock("test.md", sampleTableData, source); + + expect(getContent()).toMatch(/\|\s*Write docs\s*\|\s*Done\s*\|/); + expect(getContent()).not.toContain(longCell); + }); + + const sync = async (doc: string, source?: string, lineHint?: number, data = sampleTableData) => { + const { vaultService, getContent } = createMockVaultService(doc); + const saved = await new TableSyncService(vaultService).syncCodeBlock("test.md", data, source, lineHint); + return { saved, content: getContent() }; + }; + const oldSource = "| Task | Status |\n| --- | --- |\n| Old | Todo |"; + const newBody = serializeMarkdownTable(sampleTableData); + + it("should replace only the matching block and keep its fence", async () => { + const first = "| A | B |\n| --- | --- |\n| 1 | 2 |"; + const doc = `\`\`\`table\n${first}\n\`\`\`\n\nMiddle text\n\n\`\`\`notion-table\n${oldSource}\n\`\`\`\n`; + + const { saved, content } = await sync(doc, oldSource); + + expect(saved).toBe(true); + expect(content).toBe(`\`\`\`table\n${first}\n\`\`\`\n\nMiddle text\n\n\`\`\`notion-table\n${newBody}\n\`\`\`\n`); + }); + + it("should write cell text containing $ patterns literally", async () => { + const data: MarkdownTableData = { + ...sampleTableData, + rows: [{ rowIndex: 0, rawLine: "", cells: ["Run `$` then $& and $'", "Done"] }], + }; + + const { content } = await sync(`# Head\n\n\`\`\`table\n${oldSource}\n\`\`\`\n`, oldSource, undefined, data); + + expect(content).toContain("Run `$` then $& and $'"); + expect(content.match(/# Head/g)).toHaveLength(1); + }); + + it("should not overwrite another block when the source matches none", async () => { + const single = "```table\n| A |\n| --- |\n| 1 |\n```\n"; + const several = `${single}\n${single.replace(/A/g, "B")}`; + + for (const doc of [single, several]) { + const { saved, content } = await sync(doc, "| C |\n| --- |\n| 3 |"); + expect(saved).toBe(false); + expect(content).toBe(doc); + } + }); + + it("should use the line hint to pick among identical tables", async () => { + const block = `\`\`\`table\n${oldSource}\n\`\`\``; + const doc = `${block}\n\n${block}\n`; + + const { saved, content } = await sync(doc, oldSource, 6); + expect(saved).toBe(true); + expect(content).toBe(`${block}\n\n\`\`\`table\n${newBody}\n\`\`\`\n`); + + const ambiguous = await sync(doc, oldSource); + expect(ambiguous.saved).toBe(false); + expect(ambiguous.content).toBe(doc); + }); + + it("should not redirect to another match when the hinted block has changed", async () => { + const doc = `\`\`\`table\n${oldSource}\n\`\`\`\n\n\`\`\`table\n| Changed |\n| --- |\n\`\`\`\n`; + + const { saved, content } = await sync(doc, oldSource, 6); + + expect(saved).toBe(false); + expect(content).toBe(doc); + }); + + it("should skip table fences inside other fenced blocks", async () => { + const example = `\`\`\`\`markdown\n\`\`\`table\n${oldSource}\n\`\`\`\n\`\`\`\``; + const live = `\`\`\`table\n${oldSource}\n\`\`\``; + + const { content } = await sync(`${example}\n\n${live}\n`, oldSource); + + expect(content).toBe(`${example}\n\n\`\`\`table\n${newBody}\n\`\`\`\n`); + }); + + it("should honor tilde fences and longer closing fences", async () => { + const tilde = `~~~table\n${oldSource}\n~~~~\n`; + expect((await sync(tilde, oldSource)).content).toBe(`~~~table\n${newBody}\n~~~~\n`); + + const longClose = `\`\`\`table\n${oldSource}\n\`\`\`\`\n\ntext\n\n\`\`\`table\n| X |\n| --- |\n\`\`\`\n`; + const { content } = await sync(longClose, oldSource); + expect(content).toBe(longClose.replace(oldSource, newBody)); + }); + + it("should keep list indentation and callout markers", async () => { + const indented = (prefix: string, body: string) => + [`${prefix}\`\`\`table`, ...body.split("\n").map((line) => prefix + line), `${prefix}\`\`\``].join("\n"); + + for (const prefix of [" ", "> "]) { + const doc = `- item\n${indented(prefix, oldSource)}\nafter`; + const { saved, content } = await sync(doc, oldSource); + expect(saved).toBe(true); + expect(content).toBe(`- item\n${indented(prefix, newBody)}\nafter`); + } + }); + + it("should keep each line ending as it was", async () => { + const crlf = (text: string) => text.replace(/\n/g, "\r\n"); + const doc = `# Note\n\n${crlf(`\`\`\`table\n${oldSource}\n\`\`\`\n`)}End`; + + const { content } = await sync(doc, oldSource); + + expect(content).toBe(`# Note\n\n${crlf(`\`\`\`table\n${newBody}\n\`\`\`\n`)}End`); + }); + it("should sync inline table by executing document mutation transform", async () => { const originalDoc = `# My Note\n\n| Col1 | Col2 |\n| --- | --- |\n| A | B |\n`; const { vaultService, getContent } = createMockVaultService(originalDoc); diff --git a/tests/table-view-controller.test.ts b/tests/table-view-controller.test.ts index 7693c5d..fd8b78d 100644 --- a/tests/table-view-controller.test.ts +++ b/tests/table-view-controller.test.ts @@ -1,6 +1,6 @@ import { describe, it, expect, beforeAll, beforeEach, vi } from "vitest"; import { App } from "obsidian"; -import { parseMarkdownTables } from "../src/core/markdown-parser"; +import { parseMarkdownTables, serializeMarkdownTable } from "../src/core/markdown-parser"; import { TableStateManager } from "../src/core/table-state"; import { SettingsService } from "../src/services/settings-service"; import { TableSyncService } from "../src/services/table-sync-service"; @@ -45,19 +45,67 @@ describe("TableViewController", () => { const raw = "| Col A | Col B |\n| --- | --- |\n| 1 | 2 |"; const tableData: MarkdownTableData = parseMarkdownTables(raw)[0]; - const syncSpy = vi.spyOn(syncService, "syncCodeBlock").mockResolvedValue(); + const syncSpy = vi.spyOn(syncService, "syncCodeBlock").mockResolvedValue(true); const view = controller.createCodeBlockView({ sourcePath: "note.md", tableData, initialSource: raw, + getLineStart: () => 4, }); expect(view).not.toBeNull(); expect(view.columns).toHaveLength(2); await view.actions.onCellUpdate(0, 0, "Updated"); - expect(syncSpy).toHaveBeenCalledWith("note.md", tableData, raw); + expect(syncSpy).toHaveBeenCalledWith("note.md", tableData, raw, 4); + const savedSource = serializeMarkdownTable(tableData); + + await view.actions.onCellUpdate(0, 1, "Again"); + expect(syncSpy).toHaveBeenLastCalledWith("note.md", tableData, savedSource, 4); + }); + + it("should run overlapping code block saves one at a time", async () => { + const raw = "| Col A | Col B |\n| --- | --- |\n| 1 | 2 |"; + const tableData: MarkdownTableData = parseMarkdownTables(raw)[0]; + const sources: (string | undefined)[] = []; + let release: () => void = () => {}; + + vi.spyOn(syncService, "syncCodeBlock").mockImplementation(async (_path, _data, source) => { + sources.push(source); + if (sources.length === 1) await new Promise((resolve) => (release = resolve)); + return true; + }); + + const view = controller.createCodeBlockView({ sourcePath: "note.md", tableData, initialSource: raw }); + + const first = view.actions.onCellUpdate(0, 0, "A"); + await Promise.resolve(); + const afterFirst = serializeMarkdownTable(tableData); + const second = view.actions.onCellUpdate(0, 1, "B"); + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(sources).toHaveLength(1); + + release(); + await Promise.all([first, second]); + expect(sources).toEqual([raw, afterFirst]); + }); + + it("should keep the last saved source when a code block save fails", async () => { + const raw = "| Col A | Col B |\n| --- | --- |\n| 1 | 2 |"; + const tableData: MarkdownTableData = parseMarkdownTables(raw)[0]; + + const syncSpy = vi.spyOn(syncService, "syncCodeBlock").mockResolvedValue(false); + + const view = controller.createCodeBlockView({ + sourcePath: "note.md", + tableData, + initialSource: raw, + }); + + await view.actions.onCellUpdate(0, 0, "Updated"); + await view.actions.onCellUpdate(0, 1, "Again"); + expect(syncSpy).toHaveBeenLastCalledWith("note.md", tableData, raw, undefined); }); it("should create inline table view and dispatch all table mutations to syncInlineTable", async () => { From b1e80b5e65425c7492fe2faa2d69f9dd233abc16 Mon Sep 17 00:00:00 2001 From: a269ch Date: Wed, 28 Oct 2026 20:20:43 -0400 Subject: [PATCH 5/9] Preserve maintainer review fixes fixed: - Handle typed cells containing inline code values - Preserve mixed line endings when synchronizing code blocks added: - Add regression coverage for typed renderers and mixed line endings --- src/services/table-sync-service.ts | 10 ++++++-- src/ui/table/cells.ts | 22 ++++++++++------- tests/cells-date.test.ts | 38 ++++++++++++++++++++++++++++++ tests/table-sync-service.test.ts | 25 ++++++++++++++++++++ 4 files changed, 84 insertions(+), 11 deletions(-) diff --git a/src/services/table-sync-service.ts b/src/services/table-sync-service.ts index c2f2f35..417886e 100644 --- a/src/services/table-sync-service.ts +++ b/src/services/table-sync-service.ts @@ -92,8 +92,14 @@ export class TableSyncService { ); if (!block) return content; - const lineEnding = lines[block.openLine].endsWith("\r") ? "\r" : ""; - const bodyLines = newBody.map((line) => `${block.prefix}${line}${lineEnding}`); + const defaultLineEnding = lines[block.openLine].endsWith("\r") ? "\r" : ""; + const originalBodyLineEndings = lines + .slice(block.openLine + 1, block.closeLine) + .map((line) => (line.endsWith("\r") ? "\r" : "")); + const bodyLines = newBody.map((line, index) => { + const lineEnding = originalBodyLineEndings[index] ?? defaultLineEnding; + return `${block.prefix}${line}${lineEnding}`; + }); lines.splice(block.openLine + 1, block.closeLine - block.openLine - 1, ...bodyLines); replaced = true; return lines.join("\n"); diff --git a/src/ui/table/cells.ts b/src/ui/table/cells.ts index 2abe5f9..3aa95c3 100644 --- a/src/ui/table/cells.ts +++ b/src/ui/table/cells.ts @@ -46,19 +46,20 @@ export class CellRenderer { colIndex: number ): void { const rawValue = row.cells[colIndex] || ""; + const displayValue = stripCodeSpans(rawValue); switch (col.type) { case "checkbox": - this.renderCheckbox(td, row, colIndex, rawValue); + this.renderCheckbox(td, row, colIndex, displayValue); break; case "date": - this.renderDate(td, row, col, colIndex, rawValue); + this.renderDate(td, row, col, colIndex, displayValue, rawValue); break; case "select": - this.renderSingleSelect(td, row, col, colIndex, rawValue); + this.renderSingleSelect(td, row, col, colIndex, displayValue, rawValue); break; case "multi-select": - this.renderMultiSelect(td, row, col, colIndex, rawValue); + this.renderMultiSelect(td, row, col, colIndex, displayValue, rawValue); break; default: this.renderText(td, row, col, colIndex, rawValue); @@ -99,18 +100,19 @@ export class CellRenderer { row: MarkdownTableRow, col: TableColumn, colIndex: number, + displayValue: string, rawValue: string ): void { const wrapper = td.createDiv({ cls: "ms-cell-date-wrapper" }); - if (rawValue.trim()) { + if (displayValue.trim()) { appendIcon(wrapper.createSpan({ cls: "ms-date-icon" }), ICON_TYPE_DATE); const targetFormat = col.dateFormat ?? this.ctx.settings.dateFormat ?? "YYYY-MM-DD"; - const parsedDate = parseAnyDate(rawValue, targetFormat); + const parsedDate = parseAnyDate(displayValue, targetFormat); const displayText = parsedDate ? formatDateByOption(parsedDate, targetFormat) - : rawValue; + : displayValue; wrapper.createSpan({ cls: "ms-date-text", text: displayText }); } else { wrapper.createSpan({ cls: "ms-cell-empty-placeholder", text: "Empty" }); @@ -127,9 +129,10 @@ export class CellRenderer { row: MarkdownTableRow, col: TableColumn, colIndex: number, + displayValue: string, rawValue: string ): void { - const tags = parseCellTags(rawValue, this.ctx.settings.customTagColors); + const tags = parseCellTags(displayValue, this.ctx.settings.customTagColors); const container = td.createDiv({ cls: "ms-cell-badges-container is-single-select" }); if (tags.length === 0) { @@ -158,9 +161,10 @@ export class CellRenderer { row: MarkdownTableRow, col: TableColumn, colIndex: number, + displayValue: string, rawValue: string ): void { - const tags = parseCellTags(rawValue, this.ctx.settings.customTagColors); + const tags = parseCellTags(displayValue, this.ctx.settings.customTagColors); const container = td.createDiv({ cls: "ms-cell-badges-container" }); if (tags.length === 0) { diff --git a/tests/cells-date.test.ts b/tests/cells-date.test.ts index 57faac3..bc7b4b0 100644 --- a/tests/cells-date.test.ts +++ b/tests/cells-date.test.ts @@ -199,6 +199,44 @@ describe("CellRenderer date cells", () => { expect(td.querySelector(".ms-date-text")?.textContent).toBe("15.09.2026"); }); + it("should parse inline code values in typed cells while preserving raw values for editing", () => { + const checkboxCol: TableColumn = { name: "Done", index: 0, type: "checkbox" }; + const checkboxRow: MarkdownTableRow = { rowIndex: 0, rawLine: "", cells: ["`[x]`"] }; + const checkboxContext = createContext([checkboxCol], [checkboxRow]); + const checkboxRenderer = new CellRenderer(checkboxContext.ctx); + const checkboxTd = checkboxContext.containerEl.createEl("td"); + checkboxRenderer.render(checkboxTd, checkboxRow, checkboxCol, 0); + + expect(checkboxTd.querySelector("input")?.checked).toBe(true); + + const dateRow: MarkdownTableRow = { rowIndex: 1, rawLine: "", cells: ["`2026-09-15`"] }; + const dateContext = createContext([DATE_COLUMN], [dateRow]); + const dateRenderer = new CellRenderer(dateContext.ctx); + const dateTd = dateContext.containerEl.createEl("td"); + dateRenderer.render(dateTd, dateRow, dateContext.ctx.columns[0], 0); + + expect(dateTd.querySelector(".ms-date-text")?.textContent).toBe("15.09.2026"); + + const selectCol: TableColumn = { name: "Status", index: 0, type: "select" }; + const selectRow: MarkdownTableRow = { rowIndex: 2, rawLine: "", cells: ["`Done`"] }; + const selectContext = createContext([selectCol], [selectRow]); + const selectRenderer = new CellRenderer(selectContext.ctx); + const selectTd = selectContext.containerEl.createEl("td"); + selectRenderer.render(selectTd, selectRow, selectCol, 0); + + expect(selectTd.textContent).toContain("Done"); + + const multiCol: TableColumn = { name: "Tags", index: 0, type: "multi-select" }; + const multiRow: MarkdownTableRow = { rowIndex: 3, rawLine: "", cells: ["`One, Two`"] }; + const multiContext = createContext([multiCol], [multiRow]); + const multiRenderer = new CellRenderer(multiContext.ctx); + const multiTd = multiContext.containerEl.createEl("td"); + multiRenderer.render(multiTd, multiRow, multiCol, 0); + + expect(multiTd.textContent).toContain("One"); + expect(multiTd.textContent).toContain("Two"); + }); + it("should render links in text cells and open them on click", () => { const textCol: TableColumn = { name: "Resource", index: 0, type: "text" }; const row: MarkdownTableRow = { diff --git a/tests/table-sync-service.test.ts b/tests/table-sync-service.test.ts index 99d14d4..2e5ed87 100644 --- a/tests/table-sync-service.test.ts +++ b/tests/table-sync-service.test.ts @@ -167,6 +167,31 @@ describe("TableSyncService", () => { expect(content).toBe(`# Note\n\n${crlf(`\`\`\`table\n${newBody}\n\`\`\`\n`)}End`); }); + it("should preserve mixed line endings in the replaced table body", async () => { + const doc = [ + "```table\r", + "| Task | Status |\r", + "| --- | --- |", + "| Old | Todo |\r", + "```", + "", + ].join("\n"); + + const { content } = await sync(doc, oldSource); + + const newLines = newBody.split("\n"); + expect(content).toBe( + [ + "```table\r", + `${newLines[0]}\r`, + newLines[1], + `${newLines[2]}\r`, + "```", + "", + ].join("\n") + ); + }); + it("should sync inline table by executing document mutation transform", async () => { const originalDoc = `# My Note\n\n| Col1 | Col2 |\n| --- | --- |\n| A | B |\n`; const { vaultService, getContent } = createMockVaultService(originalDoc); From 85c64f205b006f1d45eefcb312bcf94c3e29022e Mon Sep 17 00:00:00 2001 From: a269ch Date: Thu, 22 Oct 2026 20:27:10 -0400 Subject: [PATCH 6/9] Skip policy checks for closed pull requests changed: - Run contribution policy only while pull requests are open --- .github/workflows/ci.yml | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7971efe..07846c9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -14,11 +14,14 @@ on: jobs: contribution-policy: name: Contribution policy - if: github.event_name == 'pull_request' + if: >- + github.event_name == 'pull_request' && + github.event.pull_request.state == 'open' runs-on: ubuntu-latest steps: - name: Check pull request commit count + if: ${{ !startsWith(github.event.pull_request.head.ref, 'release/') }} env: COMMIT_COUNT: ${{ github.event.pull_request.commits }} run: | From f9adc33c2201b9a99147974f33877158ac406947 Mon Sep 17 00:00:00 2001 From: a269ch Date: Wed, 28 Oct 2026 20:30:21 -0400 Subject: [PATCH 7/9] Skip contribution policy for release pull requests changed: - Run contribution policy only for open pull requests - Skip commit count and message validation for release branches fixed: - Allow release pull requests to contain release merge history --- .github/workflows/ci.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 07846c9..38d629c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -31,12 +31,14 @@ jobs: fi - name: Check commit message format + if: ${{ !startsWith(github.event.pull_request.head.ref, 'release/') }} uses: actions/checkout@v4 with: ref: ${{ github.event.pull_request.head.sha }} fetch-depth: 1 - name: Validate commit message + if: ${{ !startsWith(github.event.pull_request.head.ref, 'release/') }} run: | subject="$(git log -1 --format=%s)" task_release='^TASK-[0-9]+(, TASK-[0-9]+)*: RELEASE-[0-9]+(\.[0-9]+)*$' From c15ee88735684c631a14c4b46f30c89f6a96c0c1 Mon Sep 17 00:00:00 2001 From: a269ch Date: Wed, 28 Oct 2026 21:21:28 -0400 Subject: [PATCH 8/9] RELEASE-0.1.2 changed: - Fix column resizing with stable pixel widths and horizontal overflow - Make calculation rows a per-table property - Preserve table scroll coordinates during refresh and cell updates - Preserve Board group order independently from row order - Retain empty Board value columns and stabilize card moves fixed: - Prevent neighboring columns and scroll position from changing unexpectedly - Prevent Board columns from disappearing, reordering, or redrawing twice after card moves added: - Regression coverage for resize, scroll, calculation-row, and Board behavior --- README.md | 2 +- src/core/table-state.ts | 8 +++ src/extensions/post-processor.ts | 2 +- src/services/table-view-controller.ts | 100 ++++++++++++++++++++++++++ src/types.ts | 10 ++- src/ui/kanban-view.ts | 41 +++++++++-- src/ui/settings-tab.ts | 21 ------ src/ui/table/cells.ts | 7 ++ src/ui/table/dnd.ts | 75 +++++++++++++++++-- src/ui/table/grid.ts | 49 +++++++++++-- src/ui/table/menus.ts | 17 +++++ src/ui/table/types.ts | 1 + styles.css | 7 +- tests/cells-date.test.ts | 25 +++++++ tests/kanban-view.test.ts | 78 +++++++++++++++++++- tests/table-grid-ux.test.ts | 58 +++++++++++---- tests/table-state.test.ts | 29 ++++++++ tests/table-view-controller.test.ts | 85 ++++++++++++++++++++++ 18 files changed, 550 insertions(+), 65 deletions(-) diff --git a/README.md b/README.md index 6402bc8..f068fcc 100644 --- a/README.md +++ b/README.md @@ -92,7 +92,7 @@ To view and edit an interactive database directly in **Live Preview** (Edit mode * **Properties Menu**: Click **Properties** to toggle visibility of any columns. ### 🧮 Summary Calculations -* Hover over the footer row beneath the table and click a column to pick a summary: +* Enable **Calculation row** for a table from its **Properties** menu, then hover over the footer row and click a column to pick a summary: * **Numbers**: `Sum`, `Average`, `Min`, `Max`, `Count` * **Tags & Text**: `Count all`, `Unique count`, `Empty`, `Not empty` * **Checkboxes**: `Checked count`, `Unchecked count`, `Percent completed` diff --git a/src/core/table-state.ts b/src/core/table-state.ts index f05f246..598d397 100644 --- a/src/core/table-state.ts +++ b/src/core/table-state.ts @@ -122,11 +122,15 @@ export class TableStateManager { ? parseColumnAlignment(rawAlign) : (inferredType === "number" ? "right" : inferredType === "checkbox" ? "center" : "left"); + const savedWidth = settings.tableColumnWidths?.[table.id]?.[String(c)]; columns.push({ name: cleanHeaderName, index: c, type: inferredType, align, + ...(typeof savedWidth === "number" && Number.isFinite(savedWidth) + ? { width: savedWidth } + : {}), dateFormat: columnDateFormat, ...(columnCalculation && columnCalculation !== "none" ? { calculation: columnCalculation } @@ -195,6 +199,7 @@ export class TableStateManager { searchQuery: "", isFilterOpen: false, isSortOpen: false, + showCalculationRow: false, }; this.filterStates.set(tableId, state); } @@ -218,6 +223,7 @@ export class TableStateManager { conjunction: state.conjunction, rules: state.rules, sortRules: state.sortRules, + showCalculationRow: state.showCalculationRow === true, }; return ``; } @@ -234,6 +240,7 @@ export class TableStateManager { conjunction?: "AND" | "OR"; rules?: FilterRule[]; sortRules?: SortRule[]; + showCalculationRow?: boolean; }; const rules = Array.isArray(parsed?.rules) ? parsed.rules : []; const sortRules = Array.isArray(parsed?.sortRules) ? parsed.sortRules : []; @@ -245,6 +252,7 @@ export class TableStateManager { searchQuery: "", isFilterOpen: rules.length > 0, isSortOpen: sortRules.length > 0, + showCalculationRow: parsed?.showCalculationRow === true, }; } catch { return null; diff --git a/src/extensions/post-processor.ts b/src/extensions/post-processor.ts index 0ebbdb3..d7400cd 100644 --- a/src/extensions/post-processor.ts +++ b/src/extensions/post-processor.ts @@ -151,7 +151,7 @@ export class TablePostProcessor { const source = this.findSourceTable(sectionInfo, headers, rows.length, tableIndex); const tableData: MarkdownTableData = { - id: `tbl_${ctx.sourcePath || "doc"}_${startLine}_${tableIndex}`, + id: `tbl_${ctx.sourcePath || "doc"}_${tableIndex}`, headers: this.reconcileHeaders(headers, source), alignments: source ? [...source.alignments] diff --git a/src/services/table-view-controller.ts b/src/services/table-view-controller.ts index 99eb5b9..2154053 100644 --- a/src/services/table-view-controller.ts +++ b/src/services/table-view-controller.ts @@ -82,6 +82,8 @@ export class TableViewController { private settingsService: SettingsService; private stateManager: TableStateManager; private syncService: TableSyncService; + private editorScroll: Map = new Map(); + private editorScrollObservers: Map = new Map(); constructor( app: App, @@ -147,6 +149,39 @@ export class TableViewController { ): NotionTableView { const settings = this.settingsService.getSettings(); const columns = this.stateManager.analyzeColumns(tableData, settings); + const savedGroupOrders = settings.tableBoardGroupOrders?.[tableData.id] ?? {}; + const tableGroupOrders: Record = { ...savedGroupOrders }; + + for (const column of columns) { + if (column.type !== "select" && column.type !== "multi-select") continue; + + const currentNames = (column.uniqueTags ?? []).map((tag) => tag.name); + const savedNames = tableGroupOrders[String(column.index)] ?? []; + const orderedNames = [ + ...savedNames, + ...currentNames.filter((name) => !savedNames.includes(name)), + ]; + tableGroupOrders[String(column.index)] = orderedNames; + const tagsByName = new Map((column.uniqueTags ?? []).map((tag) => [tag.name, tag])); + column.uniqueTags = orderedNames.flatMap((name) => { + return [ + tagsByName.get(name) ?? { + id: name.toLowerCase(), + name, + color: "gray" as const, + }, + ]; + }); + } + + if (JSON.stringify(savedGroupOrders) !== JSON.stringify(tableGroupOrders)) { + void this.settingsService.updateSettings({ + tableBoardGroupOrders: { + ...(settings.tableBoardGroupOrders ?? {}), + [tableData.id]: tableGroupOrders, + }, + }); + } const filterState = this.stateManager.getOrCreateFilterState(tableData.id); filterState.sortRules ??= []; @@ -156,11 +191,26 @@ export class TableViewController { mutate: () => TableOperation | null, options: { refresh?: boolean } = {} ): Promise => { + this.captureEditorScroll(sourcePath); + this.watchEditorScroll(sourcePath); const previousHeaders = [...tableData.headers]; const operation = mutate(); if (!operation) return; await persist(operation, previousHeaders); + this.restoreEditorScroll(sourcePath); + if (typeof window !== "undefined") { + let frames = 0; + const restoreForSeveralFrames = (): void => { + this.restoreEditorScroll(sourcePath); + if (++frames < 12) { + window.requestAnimationFrame(restoreForSeveralFrames); + } else { + this.stopWatchingEditorScroll(sourcePath); + } + }; + window.requestAnimationFrame(restoreForSeveralFrames); + } if (options.refresh !== false) { view?.updateData(tableData, columns); @@ -283,6 +333,18 @@ export class TableViewController { onTagColorChange: async (tagName, color) => { await this.settingsService.setTagColor(tagName, color); }, + + onColumnResize: async (widths) => { + const settings = this.settingsService.getSettings(); + const tableColumnWidths = { + ...settings.tableColumnWidths, + [tableData.id]: { + ...(settings.tableColumnWidths?.[tableData.id] ?? {}), + ...widths, + }, + }; + await this.settingsService.updateSettings({ tableColumnWidths }); + }, }; view = new NotionTableView({ @@ -302,6 +364,44 @@ export class TableViewController { return view; } + + private captureEditorScroll(sourcePath: string): void { + if (typeof document === "undefined") return; + const scroller = document.querySelector( + ".workspace-leaf.mod-active .cm-scroller" + ); + if (scroller) { + this.editorScroll.set(sourcePath, { + top: scroller.scrollTop, + left: scroller.scrollLeft, + }); + } + } + + private watchEditorScroll(sourcePath: string): void { + if (typeof document === "undefined" || typeof MutationObserver === "undefined") return; + this.stopWatchingEditorScroll(sourcePath); + const observer = new MutationObserver(() => this.restoreEditorScroll(sourcePath)); + observer.observe(document.body, { childList: true, subtree: true }); + this.editorScrollObservers.set(sourcePath, observer); + } + + private stopWatchingEditorScroll(sourcePath: string): void { + const observer = this.editorScrollObservers.get(sourcePath); + observer?.disconnect(); + this.editorScrollObservers.delete(sourcePath); + } + + private restoreEditorScroll(sourcePath: string): void { + const position = this.editorScroll.get(sourcePath); + if (!position || typeof document === "undefined") return; + const scroller = document.querySelector( + ".workspace-leaf.mod-active .cm-scroller" + ); + if (!scroller) return; + scroller.scrollTop = position.top; + scroller.scrollLeft = position.left; + } } function createDocumentTransform( diff --git a/src/types.ts b/src/types.ts index 67addb3..cb672cb 100644 --- a/src/types.ts +++ b/src/types.ts @@ -140,7 +140,11 @@ export interface TableFilterState { groupByColumnIndex?: number; stickyFirstColumn?: boolean; showRowNumbers?: boolean; + showCalculationRow?: boolean; scrollLeft?: number; + scrollTop?: number; + boardScrollLeft?: number; + boardScrollTops?: Record; selection?: SerializedSelection; } @@ -161,10 +165,11 @@ export interface PluginSettings { autoDetectMultiSelect: boolean; multiSelectColumnNames: string[]; customTagColors: Record; - enableCalculations: boolean; stickyFirstColumn: boolean; showRowNumbers: boolean; showEmptyBoardProperties: boolean; + tableColumnWidths: Record>; + tableBoardGroupOrders: Record>; } export const DEFAULT_SETTINGS: PluginSettings = { @@ -182,10 +187,11 @@ export const DEFAULT_SETTINGS: PluginSettings = { "Keywords", ], customTagColors: {}, - enableCalculations: true, stickyFirstColumn: true, showRowNumbers: false, showEmptyBoardProperties: false, + tableColumnWidths: {}, + tableBoardGroupOrders: {}, }; export interface MarkdownTableRow { diff --git a/src/ui/kanban-view.ts b/src/ui/kanban-view.ts index 56c340c..6305f02 100644 --- a/src/ui/kanban-view.ts +++ b/src/ui/kanban-view.ts @@ -49,6 +49,10 @@ const NO_STATUS_KEY = "__NO_STATUS__"; const CHECKED_GROUP = "[x]"; const UNCHECKED_GROUP = "[ ]"; +export function getRowDropIndex(fromIndex: number, targetIndex: number): number { + return fromIndex < targetIndex ? targetIndex - 1 : targetIndex; +} + export class KanbanView { private options: KanbanViewOptions; private containerEl: HTMLElement; @@ -78,6 +82,13 @@ export class KanbanView { public getElement(): HTMLElement { this.render(); + const boardWrapper = this.containerEl.querySelector(".ms-kanban-board-wrapper"); + const scrollLeft = this.options.filterState.boardScrollLeft ?? 0; + if (boardWrapper && typeof queueMicrotask === "function") { + queueMicrotask(() => { + boardWrapper.scrollLeft = scrollLeft; + }); + } return this.containerEl; } @@ -89,6 +100,10 @@ export class KanbanView { const isCheckboxGroup = groupByCol?.type === "checkbox"; const groups = this.groupRows(groupByCol); const boardWrapper = this.containerEl.createDiv({ cls: "ms-kanban-board-wrapper" }); + boardWrapper.scrollLeft = this.options.filterState.boardScrollLeft ?? 0; + boardWrapper.addEventListener("scroll", () => { + this.options.filterState.boardScrollLeft = boardWrapper.scrollLeft; + }); groups.forEach((rows, groupName) => { this.renderColumn(boardWrapper, groupName, rows, groupByCol, isCheckboxGroup); @@ -159,6 +174,7 @@ export class KanbanView { const noStatusRows: MarkdownTableRow[] = []; const tagGroups = new Map(); + const unknownGroups = new Map(); for (const tag of groupByCol?.uniqueTags ?? []) { tagGroups.set(tag.name, []); @@ -176,15 +192,19 @@ export class KanbanView { tags.length > 0 ? tags[0].name : cellValue.replace(/^\[\[|\]\]$/g, "").trim(); if (!tagGroups.has(key)) { - tagGroups.set(key, []); + if (!unknownGroups.has(key)) { + unknownGroups.set(key, []); + } } - tagGroups.get(key)?.push(row); + (tagGroups.get(key) ?? unknownGroups.get(key))?.push(row); } - if (noStatusRows.length > 0 || tagGroups.size === 0) { - groups.set(NO_STATUS_KEY, noStatusRows); - } + Array.from(unknownGroups.keys()) + .sort((left, right) => left.localeCompare(right)) + .forEach((name) => tagGroups.set(name, unknownGroups.get(name) ?? [])); + tagGroups.forEach((rows, name) => groups.set(name, rows)); + groups.set(NO_STATUS_KEY, noStatusRows); return groups; } @@ -232,6 +252,13 @@ export class KanbanView { const targetValue = isCheckboxGroup ? groupName : isNoStatus ? "" : groupName; const cardsList = colEl.createDiv({ cls: "ms-kanban-cards-list" }); + cardsList.scrollTop = this.options.filterState.boardScrollTops?.[groupName] ?? 0; + cardsList.addEventListener("scroll", () => { + this.options.filterState.boardScrollTops = { + ...(this.options.filterState.boardScrollTops ?? {}), + [groupName]: cardsList.scrollTop, + }; + }); cardsList.addEventListener("dragover", (e) => { e.preventDefault(); @@ -311,9 +338,9 @@ export class KanbanView { void (async () => { await this.options.actions.onCellUpdate(fromIndex, this.groupByColIndex, targetValue); if (fromIndex !== row.rowIndex) { - await this.options.actions.onReorderRows(fromIndex, row.rowIndex); + const targetIndex = getRowDropIndex(fromIndex, row.rowIndex); + await this.options.actions.onReorderRows(fromIndex, targetIndex); } - this.render(); })(); }); diff --git a/src/ui/settings-tab.ts b/src/ui/settings-tab.ts index 09b63d0..e89278c 100644 --- a/src/ui/settings-tab.ts +++ b/src/ui/settings-tab.ts @@ -75,14 +75,6 @@ export class TableBaseSettingTab extends PluginSettingTab { key: "showRowNumbers", }, }, - { - name: "Show the calculation row", - desc: "Keep a summary row under every table: hover it to pick Sum, Average, Count and the rest.", - control: { - type: "toggle", - key: "enableCalculations", - }, - }, { name: "Show empty properties on board cards", desc: "Display placeholders for empty fields on Kanban cards so they can be filled in. Checkboxes are always shown.", @@ -252,19 +244,6 @@ export class TableBaseSettingTab extends PluginSettingTab { }) ); - new Setting(containerEl) - .setName("Show the calculation row") - .setDesc( - "Keep a summary row under every table: hover it to pick Sum, Average, Count and the rest." - ) - .addToggle((toggle) => - toggle - .setValue(settings.enableCalculations ?? true) - .onChange(async (value) => { - await this.settingsService.updateSettings({ enableCalculations: value }); - }) - ); - new Setting(containerEl) .setName("Show empty properties on board cards") .setDesc( diff --git a/src/ui/table/cells.ts b/src/ui/table/cells.ts index 3aa95c3..82a896e 100644 --- a/src/ui/table/cells.ts +++ b/src/ui/table/cells.ts @@ -69,6 +69,13 @@ export class CellRenderer { private focusCell(row: number, col: number): void { closeAllFloatingPopovers(); + const scrollWrapper = this.ctx.containerEl.querySelector( + ".ms-db-table-scroll-wrapper" + ); + if (scrollWrapper) { + this.ctx.filterState.scrollLeft = scrollWrapper.scrollLeft; + this.ctx.filterState.scrollTop = scrollWrapper.scrollTop; + } this.ctx.selection.focusCell(row, col); this.ctx.applySelection(); } diff --git a/src/ui/table/dnd.ts b/src/ui/table/dnd.ts index e97f193..5117922 100644 --- a/src/ui/table/dnd.ts +++ b/src/ui/table/dnd.ts @@ -551,7 +551,10 @@ export function bindColumnResizer( registry: DisposableRegistry, resizer: HTMLElement, th: HTMLElement, - column: TableColumn + column: TableColumn, + table: HTMLTableElement, + columns: TableColumn[], + onResize?: (widths: Record) => void | Promise ): void { let startX = 0; let startWidth = 0; @@ -562,6 +565,13 @@ export function bindColumnResizer( if (currentWidth > 0) { column.width = currentWidth; } + table.classList.remove("is-resizing-columns"); + const widths = Object.fromEntries( + columns + .filter((col) => typeof col.width === "number" && Number.isFinite(col.width)) + .map((col) => [String(col.index), col.width as number]) + ); + void onResize?.(widths); dragRegistry.disposeAll(); }; @@ -570,19 +580,70 @@ export function bindColumnResizer( e.preventDefault(); startX = e.clientX; - startWidth = th.offsetWidth; + const headers = Array.from(table.querySelectorAll("th.ms-db-th")); + const colElements = new Map( + Array.from(table.querySelectorAll("col[data-column-index]")).map((el) => [ + el.dataset.columnIndex, + el, + ]) + ); + + for (const header of headers) { + const index = Number(header.dataset.colIndex); + const target = columns.find((col) => col.index === index); + if (!target) continue; + const measured = header.getBoundingClientRect().width || header.offsetWidth; + const width = Math.max( + MIN_COLUMN_WIDTH, + measured || target.width || MIN_COLUMN_WIDTH + ); + target.width = width; + setColumnWidth(header, width); + const colElement = colElements.get(header.dataset.colIndex); + if (colElement) setColumnWidth(colElement, width); + } + + setTableWidth(table, columns); + table.classList.add("is-resizing-columns"); + + startWidth = + Math.max( + MIN_COLUMN_WIDTH, + th.getBoundingClientRect().width || th.offsetWidth || column.width || MIN_COLUMN_WIDTH + ); currentWidth = startWidth; dragRegistry.disposeAll(); dragRegistry.listen(document, "mousemove", (moveEvent: MouseEvent) => { currentWidth = Math.max(MIN_COLUMN_WIDTH, startWidth + (moveEvent.clientX - startX)); - th.setCssStyles({ - width: `${currentWidth}px`, - minWidth: `${currentWidth}px`, - maxWidth: `${currentWidth}px`, - }); + column.width = currentWidth; + setColumnWidth(th, currentWidth); + const colElement = colElements.get(th.dataset.colIndex); + if (colElement) setColumnWidth(colElement, currentWidth); + setTableWidth(table, columns); }); dragRegistry.listen(document, "mouseup", stopResize); }); } + +function setColumnWidth(element: HTMLElement, width: number): void { + element.setCssStyles({ + width: `${width}px`, + minWidth: `${width}px`, + maxWidth: `${width}px`, + }); +} + +function setTableWidth(table: HTMLTableElement, columns: TableColumn[]): void { + const dataWidth = columns.reduce( + (sum, col) => sum + (col.width ?? MIN_COLUMN_WIDTH), + 0 + ); + const fixedWidth = Array.from( + table.querySelectorAll("col.ms-db-col-index, col.ms-db-col-add") + ).reduce((sum, col) => sum + (col.getBoundingClientRect().width || col.offsetWidth), 0); + const width = dataWidth + fixedWidth; + table.style.width = `${width}px`; + table.style.minWidth = `${width}px`; +} diff --git a/src/ui/table/grid.ts b/src/ui/table/grid.ts index 25009ba..800bc16 100644 --- a/src/ui/table/grid.ts +++ b/src/ui/table/grid.ts @@ -65,8 +65,6 @@ export class TableGrid { this.ctx.clearFocus(); }); - this.bindScrollWrapper(scrollWrapper); - const { settings } = this.ctx; const showRowNumbers = this.ctx.isRowNumbersVisible(); @@ -98,6 +96,7 @@ export class TableGrid { this.tbodyEl = tableEl.createEl("tbody"); this.rowDrag.setBody(this.tbodyEl); this.renderBody(); + this.bindScrollWrapper(scrollWrapper); } public get tableElement(): HTMLTableElement | null { @@ -106,6 +105,8 @@ export class TableGrid { private bindScrollWrapper(scrollWrapper: HTMLElement): void { const { filterState } = this.ctx; + const savedScrollLeft = filterState.scrollLeft ?? 0; + const savedScrollTop = filterState.scrollTop ?? 0; // Each render builds a fresh wrapper, so drop what was bound to the old one. const registry = this.scrollRegistry; registry.disposeAll(); @@ -121,6 +122,7 @@ export class TableGrid { registry.listen(scrollWrapper, "scroll", () => { filterState.scrollLeft = scrollWrapper.scrollLeft; + filterState.scrollTop = scrollWrapper.scrollTop; }); if (typeof ResizeObserver !== "undefined") { @@ -129,15 +131,24 @@ export class TableGrid { registry.addFn(() => observer.disconnect()); } - // The wrapper is still detached while the view is being built, so both - // measuring and restoring the offset have to wait for layout. + const restoreScroll = (): void => { + scrollWrapper.scrollLeft = savedScrollLeft; + scrollWrapper.scrollTop = savedScrollTop; + }; + // The view container is appended by its caller after render() returns. + // Restore in a microtask so layout exists, but before the next paint. + restoreScroll(); + if (typeof queueMicrotask === "function") { + queueMicrotask(restoreScroll); + } + registry.animationFrame(() => { syncVisibleWidth(); - scrollWrapper.scrollLeft = filterState.scrollLeft ?? 0; }); } private renderHead(tableEl: HTMLTableElement, showRowNumbers: boolean): void { + this.renderColumnGroup(tableEl, showRowNumbers); const thead = tableEl.createEl("thead"); const headerRow = thead.createEl("tr"); @@ -152,6 +163,22 @@ export class TableGrid { this.renderAddColumnHeader(headerRow); } + private renderColumnGroup(tableEl: HTMLTableElement, showRowNumbers: boolean): void { + const colgroup = tableEl.createEl("colgroup"); + if (showRowNumbers) { + const indexCol = colgroup.createEl("col", { cls: "ms-db-col-index" }); + setFixedWidth(indexCol, INDEX_COLUMN_WIDTH); + } + for (const col of this.ctx.getVisibleColumns()) { + const colEl = colgroup.createEl("col", { + attr: { "data-column-index": `${col.index}` }, + }); + if (col.width) setFixedWidth(colEl, col.width); + } + const addCol = colgroup.createEl("col", { cls: "ms-db-col-add" }); + setFixedWidth(addCol, ADD_COLUMN_WIDTH); + } + private renderIndexHeader(headerRow: HTMLTableRowElement): void { const indexTh = headerRow.createEl("th", { cls: "ms-db-th-index" }); setFixedWidth(indexTh, INDEX_COLUMN_WIDTH); @@ -247,7 +274,15 @@ export class TableGrid { }); const resizer = th.createDiv({ cls: "ms-col-resizer" }); - bindColumnResizer(this.ctx.registry, resizer, th, col); + bindColumnResizer( + this.ctx.registry, + resizer, + th, + col, + this.tableEl!, + this.ctx.getVisibleColumns(), + this.ctx.actions.onColumnResize + ); } private toggleColumnMenu( @@ -311,7 +346,7 @@ export class TableGrid { // The bar is the only way into the calculations, so it is always there; // its cells stay blank until hovered. - if (this.ctx.settings.enableCalculations && visibleColumns.length > 0) { + if (this.ctx.filterState.showCalculationRow === true && visibleColumns.length > 0) { this.renderCalculationBar(tableEl, visibleRows, visibleColumns, showRowNumbers); } diff --git a/src/ui/table/menus.ts b/src/ui/table/menus.ts index 5b0a199..93577d4 100644 --- a/src/ui/table/menus.ts +++ b/src/ui/table/menus.ts @@ -166,6 +166,23 @@ export class TableMenus { this.setRowNumbersVisible(!isRowNumbersVisible); }); + const showCalculationRow = this.ctx.filterState.showCalculationRow === true; + const calculationRowItem = menu.createDiv({ + cls: `ms-menu-item ms-prop-toggle-item ${showCalculationRow ? "is-selected" : ""}`, + }); + appendIcon(calculationRowItem.createSpan({ cls: "ms-prop-icon" }), ICON_TYPE_NUMBER); + calculationRowItem.createSpan({ text: "Calculation row" }); + const calculationRowCheck = calculationRowItem.createSpan({ cls: "ms-prop-check" }); + if (showCalculationRow) { + appendIcon(calculationRowCheck, ICON_CHECK); + } + calculationRowItem.addEventListener("click", () => { + this.closeActivePopover(); + this.ctx.filterState.showCalculationRow = !showCalculationRow; + this.ctx.actions.onFilterChange(this.ctx.filterState); + this.ctx.render(); + }); + menu.createDiv({ cls: "ms-menu-divider" }); menu.createDiv({ cls: "ms-menu-section-title", text: "Visible Properties" }); diff --git a/src/ui/table/types.ts b/src/ui/table/types.ts index 722f441..16fe79e 100644 --- a/src/ui/table/types.ts +++ b/src/ui/table/types.ts @@ -41,6 +41,7 @@ export interface TableViewActions { onFilterChange(state: TableFilterState): void; onSortChange(sortRules: SortRule[]): void; onTagColorChange(tagName: string, color: TagColor): Promise; + onColumnResize?(widths: Record): Promise; } export type BoardViewActions = Pick< diff --git a/styles.css b/styles.css index 9cd2ffe..66908db 100644 --- a/styles.css +++ b/styles.css @@ -611,10 +611,14 @@ input[type="text"].ms-filter-value-input:focus { min-width: 100%; border-collapse: separate; border-spacing: 0; - table-layout: auto; + table-layout: fixed; margin: 0; } +.ms-db-grid-table.is-resizing-columns { + transition: none; +} + .markdown-rendered .ms-notion-database-container table.ms-db-grid-table, .cm-html-embed .ms-notion-database-container table.ms-db-grid-table, .markdown-source-view .ms-notion-database-container table.ms-db-grid-table, @@ -2613,4 +2617,3 @@ input[type="text"].ms-modal-name-input.is-invalid { } } - diff --git a/tests/cells-date.test.ts b/tests/cells-date.test.ts index bc7b4b0..0bb4da2 100644 --- a/tests/cells-date.test.ts +++ b/tests/cells-date.test.ts @@ -237,6 +237,31 @@ describe("CellRenderer date cells", () => { expect(multiTd.textContent).toContain("Two"); }); + it("should capture table scroll before updating a checkbox", () => { + const checkboxCol: TableColumn = { name: "Approved", index: 0, type: "checkbox" }; + const checkboxRow: MarkdownTableRow = { rowIndex: 0, rawLine: "", cells: ["[ ]"] }; + const { ctx, containerEl, actions } = createContext([checkboxCol], [checkboxRow]); + const scrollWrapper = containerEl.createDiv({ cls: "ms-db-table-scroll-wrapper" }); + let scrollLeft = 240; + let scrollTop = 18; + Object.defineProperties(scrollWrapper, { + scrollLeft: { configurable: true, get: () => scrollLeft, set: (value) => (scrollLeft = value) }, + scrollTop: { configurable: true, get: () => scrollTop, set: (value) => (scrollTop = value) }, + }); + const td = scrollWrapper.createEl("td"); + new CellRenderer(ctx).render(td, checkboxRow, checkboxCol, 0); + + const checkbox = td.querySelector("input"); + if (checkbox) { + checkbox.checked = true; + checkbox.dispatchEvent(new Event("change", { bubbles: true })); + } + + expect(ctx.filterState.scrollLeft).toBe(240); + expect(ctx.filterState.scrollTop).toBe(18); + expect(actions.onCellUpdate).toHaveBeenCalledWith(0, 0, "[x]"); + }); + it("should render links in text cells and open them on click", () => { const textCol: TableColumn = { name: "Resource", index: 0, type: "text" }; const row: MarkdownTableRow = { diff --git a/tests/kanban-view.test.ts b/tests/kanban-view.test.ts index 5db67f4..a9684d3 100644 --- a/tests/kanban-view.test.ts +++ b/tests/kanban-view.test.ts @@ -1,7 +1,7 @@ import { describe, it, expect, beforeAll, beforeEach, vi } from "vitest"; import { App } from "obsidian"; import { DEFAULT_SETTINGS, MarkdownTableData, TableColumn, TableFilterState } from "../src/types"; -import { KanbanView } from "../src/ui/kanban-view"; +import { getRowDropIndex, KanbanView } from "../src/ui/kanban-view"; import { BoardViewActions } from "../src/ui/table/types"; import { installObsidianDomHelpers } from "./helpers/obsidian-dom"; @@ -110,6 +110,78 @@ describe("KanbanView", () => { expect(titles).toContain("Write docs"); }); + it("should restore Board scroll positions from table state", async () => { + const filterState = { + ...defaultFilterState, + boardScrollLeft: 120, + boardScrollTops: { Todo: 45 }, + }; + const kanban = new KanbanView({ + app: {} as App, + sourcePath: "notes/test.md", + tableData: sampleTableData, + columns: sampleColumns, + filterState, + settings: { ...DEFAULT_SETTINGS }, + actions: createActions(), + onSwitchView: vi.fn(), + }); + + const board = kanban.getElement(); + const boardWrapper = board.querySelector(".ms-kanban-board-wrapper"); + const todoList = Array.from( + board.querySelectorAll(".ms-kanban-cards-list") + ).find((list) => list.parentElement?.dataset.groupName === "Todo"); + + await Promise.resolve(); + expect(boardWrapper?.scrollLeft).toBe(120); + expect(todoList?.scrollTop).toBe(45); + }); + + it("should keep select groups in property order and retain empty groups", () => { + const columns = sampleColumns.map((column) => + column.name === "Status" + ? { + ...column, + uniqueTags: [ + { id: "done", name: "Done", color: "green" as const }, + { id: "todo", name: "Todo", color: "blue" as const }, + { id: "blocked", name: "Blocked", color: "red" as const }, + ], + } + : column + ); + const tableData = { + ...sampleTableData, + rows: sampleTableData.rows.map((row) => ({ ...row, cells: [...row.cells] })), + }; + tableData.rows[0].cells[1] = "Done"; + tableData.rows[1].cells[1] = "Done"; + tableData.rows[2].cells[1] = ""; + + const kanban = new KanbanView({ + app: {} as App, + sourcePath: "notes/test.md", + tableData, + columns, + filterState: { ...defaultFilterState }, + settings: { ...DEFAULT_SETTINGS }, + actions: createActions(), + onSwitchView: vi.fn(), + }); + + const board = kanban.getElement(); + const headers = Array.from(board.querySelectorAll(".ms-kanban-column-title")) + .map((header) => header.textContent?.replace(/\d+$/, "").trim()); + + expect(headers).toEqual(["Done", "Todo", "Blocked", "No Status"]); + }); + + it("should adjust the target index when moving a card across the target", () => { + expect(getRowDropIndex(0, 1)).toBe(0); + expect(getRowDropIndex(3, 1)).toBe(1); + }); + it("should handle switching view back to table", () => { const actions = createActions(); const onSwitchView = vi.fn(); @@ -214,7 +286,7 @@ describe("KanbanView", () => { input.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true })); } - expect(actions.onCellUpdate).toHaveBeenCalledWith(2, 0, "Redesign UI completely"); + expect(actions.onCellUpdate).toHaveBeenCalledWith(0, 0, "Redesign UI completely"); }); it("should toggle checkbox property on click", async () => { @@ -239,7 +311,7 @@ describe("KanbanView", () => { expect(checkboxBadges.length).toBeGreaterThan(0); checkboxBadges[0].click(); - expect(actions.onCellUpdate).toHaveBeenCalledWith(2, 4, "[x]"); + expect(actions.onCellUpdate).toHaveBeenCalledWith(0, 4, "[x]"); }); it("should render clickable links in kanban card titles and handle click events", () => { diff --git a/tests/table-grid-ux.test.ts b/tests/table-grid-ux.test.ts index e1c2c1e..7880f9a 100644 --- a/tests/table-grid-ux.test.ts +++ b/tests/table-grid-ux.test.ts @@ -73,10 +73,6 @@ function buildGrid(raw: string, columns: TableColumn[]) { return { ctx, actions, containerEl, grid }; } -function nextFrame(): Promise { - return new Promise((resolve) => window.requestAnimationFrame(() => resolve())); -} - function dragEvent(type: string): Event { const event = new Event(type, { bubbles: true, cancelable: true }); Object.defineProperty(event, "dataTransfer", { @@ -87,7 +83,7 @@ function dragEvent(type: string): Event { /** jsdom has no layout, so scrollLeft has to be made observable by hand. */ function trackScrollLeft(el: HTMLElement): { get value(): number; set value(next: number) } { - let current = 0; + let current = el.scrollLeft; Object.defineProperty(el, "scrollLeft", { configurable: true, get: () => current, @@ -131,7 +127,7 @@ describe("Table grid UX", () => { expect(inner?.querySelector("button.ms-db-add-row-btn")).not.toBeNull(); }); - it("should remember the horizontal scroll offset and restore it on the next render", async () => { + it("should restore the horizontal scroll offset synchronously on the next render", () => { const { ctx, grid } = buildGrid(SAMPLE, SAMPLE_COLUMNS); grid.render(ctx.containerEl); @@ -153,7 +149,6 @@ describe("Table grid UX", () => { if (!rebuilt) return; const restored = trackScrollLeft(rebuilt); - await nextFrame(); expect(restored.value).toBe(240); }); @@ -169,11 +164,11 @@ describe("Table grid UX", () => { sourceTh?.dispatchEvent(dragEvent("dragstart")); const draggedCells = ctx.containerEl.querySelectorAll('[data-col-index="0"].is-dragging-col'); - expect(draggedCells.length).toBe(4); // header + two body cells + calculation cell + expect(draggedCells.length).toBe(3); // header + two body cells targetTh?.dispatchEvent(dragEvent("dragover")); const afterMarked = ctx.containerEl.querySelectorAll('[data-col-index="2"].is-col-drop-after'); - expect(afterMarked.length).toBe(4); + expect(afterMarked.length).toBe(3); expect(ctx.containerEl.querySelectorAll(".is-col-drop-before").length).toBe(0); targetTh?.dispatchEvent(dragEvent("drop")); @@ -192,7 +187,7 @@ describe("Table grid UX", () => { .querySelector('th.ms-db-th[data-col-index="0"]') ?.dispatchEvent(dragEvent("dragover")); - expect(ctx.containerEl.querySelectorAll('[data-col-index="0"].is-col-drop-before').length).toBe(4); + expect(ctx.containerEl.querySelectorAll('[data-col-index="0"].is-col-drop-before').length).toBe(3); expect(ctx.containerEl.querySelectorAll(".is-col-drop-after").length).toBe(0); }); @@ -233,18 +228,53 @@ describe("Table grid UX", () => { expect(actions.onAddRow).toHaveBeenCalledTimes(2); }); - it("should always offer the calculation bar and show the result once chosen", () => { + it("should resize only the selected column from the pointer delta", () => { + const { ctx, grid } = buildGrid(SAMPLE, SAMPLE_COLUMNS); + const onColumnResize = vi.fn(async () => undefined); + ctx.actions.onColumnResize = onColumnResize; + grid.render(ctx.containerEl); + + const headers = Array.from( + ctx.containerEl.querySelectorAll("th.ms-db-th") + ); + const widths = [120, 180, 90]; + headers.forEach((header, index) => { + vi.spyOn(header, "getBoundingClientRect").mockReturnValue({ + width: widths[index], + left: widths.slice(0, index).reduce((sum, width) => sum + width, 0), + right: widths.slice(0, index + 1).reduce((sum, width) => sum + width, 0), + top: 0, + bottom: 30, + height: 30, + x: 0, + y: 0, + toJSON: () => ({}), + }); + }); + + const resizer = headers[1].querySelector(".ms-col-resizer"); + expect(resizer).not.toBeNull(); + resizer?.dispatchEvent(new MouseEvent("mousedown", { bubbles: true, clientX: 200 })); + document.dispatchEvent(new MouseEvent("mousemove", { clientX: 250 })); + expect(headers[1].style.width).toBe("230px"); + expect(headers[0].style.width).toBe("120px"); + expect(headers[2].style.width).toBe("90px"); + + document.dispatchEvent(new MouseEvent("mouseup")); + expect(onColumnResize).toHaveBeenCalledWith({ "0": 120, "1": 230, "2": 90 }); + }); + + it("should show the calculation bar only when enabled for the table", () => { const plain = buildGrid(SAMPLE, SAMPLE_COLUMNS); plain.grid.render(plain.ctx.containerEl); - const placeholders = plain.ctx.containerEl.querySelectorAll(".ms-calc-placeholder"); - expect(placeholders.length).toBe(SAMPLE_COLUMNS.length); - expect(plain.ctx.containerEl.querySelector(".ms-db-tfoot.is-active")).toBeNull(); + expect(plain.ctx.containerEl.querySelector(".ms-db-tfoot")).toBeNull(); const withSum: TableColumn[] = SAMPLE_COLUMNS.map((col) => col.name === "Amount" ? { ...col, calculation: "sum" as const } : col ); const summed = buildGrid(SAMPLE, withSum); + summed.ctx.filterState.showCalculationRow = true; summed.grid.render(summed.ctx.containerEl); const calcCell = summed.ctx.containerEl.querySelector( diff --git a/tests/table-state.test.ts b/tests/table-state.test.ts index 514bba7..493bf98 100644 --- a/tests/table-state.test.ts +++ b/tests/table-state.test.ts @@ -69,6 +69,22 @@ describe("Table State Manager", () => { expect(columns[4].dateFormat).toBe("DD.MM.YYYY"); }); + it("should restore saved column widths for the matching table only", () => { + const manager = new TableStateManager(); + const settings = { + ...DEFAULT_SETTINGS, + tableColumnWidths: { + [tableData.id]: { "1": 234 }, + other_table: { "1": 999 }, + }, + }; + + const columns = manager.analyzeColumns(tableData, settings); + + expect(columns[1].width).toBe(234); + expect(columns[0].width).toBeUndefined(); + }); + it("should manage filter state per tableId", () => { const manager = new TableStateManager(); const state = manager.getOrCreateFilterState("tbl_123"); @@ -93,6 +109,19 @@ describe("Table State Manager", () => { expect(manager.getOrCreateFilterState("tbl_123").rules.length).toBe(0); }); + it("should persist the calculation row property in the table state comment", () => { + const manager = new TableStateManager(); + const state = manager.getOrCreateFilterState("tbl_123"); + state.showCalculationRow = true; + + const restored = manager.deserializeFromComment( + manager.serializeToComment(state), + "tbl_123" + ); + + expect(restored?.showCalculationRow).toBe(true); + }); + it("should serialize and deserialize filter comment", () => { const manager = new TableStateManager(); const state: TableFilterState = { diff --git a/tests/table-view-controller.test.ts b/tests/table-view-controller.test.ts index fd8b78d..7369eae 100644 --- a/tests/table-view-controller.test.ts +++ b/tests/table-view-controller.test.ts @@ -65,6 +65,91 @@ describe("TableViewController", () => { expect(syncSpy).toHaveBeenLastCalledWith("note.md", tableData, savedSource, 4); }); + it("should preserve Board group order when rows are reparsed after a move", async () => { + const first = parseMarkdownTables( + "| Task | Status |\n| --- | --- |\n| Buy milk | Todo |\n| Read book | Done |" + )[0]; + controller.createInlineTableView({ + sourcePath: "inline.md", + tableData: first, + startLine: 0, + tableIndex: 0, + }); + await Promise.resolve(); + + const reordered = parseMarkdownTables( + "| Task | Status |\n| --- | --- |\n| Read book | Done |\n| Buy milk | Todo |" + )[0]; + const view = controller.createInlineTableView({ + sourcePath: "inline.md", + tableData: reordered, + startLine: 0, + tableIndex: 0, + }); + + expect(view.columns[1].uniqueTags?.map((tag) => tag.name)).toEqual(["Todo", "Done"]); + }); + + it("should restore editor scroll after a file update replaces the view DOM", async () => { + const leaf = document.createElement("div"); + leaf.className = "workspace-leaf mod-active"; + const scroller = document.createElement("div"); + scroller.className = "cm-scroller"; + leaf.appendChild(scroller); + scroller.scrollTop = 320; + scroller.scrollLeft = 18; + document.body.appendChild(leaf); + + vi.spyOn(syncService, "syncInlineTable").mockImplementation(async () => { + leaf.empty(); + const replacement = document.createElement("div"); + replacement.className = "cm-scroller"; + leaf.appendChild(replacement); + replacement.scrollTop = 0; + replacement.scrollLeft = 0; + }); + + const tableData = parseMarkdownTables( + "| Task | Status |\n| --- | --- |\n| Buy milk | Todo |" + )[0]; + const view = controller.createInlineTableView({ + sourcePath: "inline.md", + tableData, + startLine: 0, + tableIndex: 0, + }); + + await view.actions.onCellUpdate(0, 0, "Updated"); + + const replacement = leaf.querySelector(".cm-scroller"); + expect(replacement?.scrollTop).toBe(320); + expect(replacement?.scrollLeft).toBe(18); + }); + + it("should retain an empty Board group after its last card moves away", async () => { + const tableData = parseMarkdownTables( + "| Task | Status |\n| --- | --- |\n| Buy milk | Todo |\n| Read book | Done |" + )[0]; + const view = controller.createInlineTableView({ + sourcePath: "inline.md", + tableData, + startLine: 0, + tableIndex: 0, + }); + + await view.actions.onCellUpdate(0, 1, "Done"); + const refreshed = controller.createInlineTableView({ + sourcePath: "inline.md", + tableData: parseMarkdownTables( + "| Task | Status |\n| --- | --- |\n| Buy milk | Done |\n| Read book | Done |" + )[0], + startLine: 0, + tableIndex: 0, + }); + + expect(refreshed.columns[1].uniqueTags?.map((tag) => tag.name)).toEqual(["Todo", "Done"]); + }); + it("should run overlapping code block saves one at a time", async () => { const raw = "| Col A | Col B |\n| --- | --- |\n| 1 | 2 |"; const tableData: MarkdownTableData = parseMarkdownTables(raw)[0]; From a826105436d4e5bdd094f3882479efcc93106cf2 Mon Sep 17 00:00:00 2001 From: a269ch Date: Wed, 28 Oct 2026 21:21:28 -0400 Subject: [PATCH 9/9] RELEASE-0.1.2 changed: - Fix column resizing with stable pixel widths and horizontal overflow - Make calculation rows a per-table property - Preserve table scroll coordinates during refresh and cell updates - Preserve Board group order independently from row order - Retain empty Board value columns and stabilize card moves fixed: - Prevent neighboring columns and scroll position from changing unexpectedly - Prevent Board columns from disappearing, reordering, or redrawing twice after card moves added: - Regression coverage for resize, scroll, calculation-row, and Board behavior --- src/ui/table/types.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/ui/table/types.ts b/src/ui/table/types.ts index 16fe79e..4a988b5 100644 --- a/src/ui/table/types.ts +++ b/src/ui/table/types.ts @@ -41,7 +41,7 @@ export interface TableViewActions { onFilterChange(state: TableFilterState): void; onSortChange(sortRules: SortRule[]): void; onTagColorChange(tagName: string, color: TagColor): Promise; - onColumnResize?(widths: Record): Promise; + onColumnResize?(this: void, widths: Record): Promise; } export type BoardViewActions = Pick<