diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7971efe..38d629c 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: | @@ -28,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]+)*$' 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/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..598d397 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); @@ -120,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 } @@ -193,6 +199,7 @@ export class TableStateManager { searchQuery: "", isFilterOpen: false, isSortOpen: false, + showCalculationRow: false, }; this.filterStates.set(tableId, state); } @@ -216,6 +223,7 @@ export class TableStateManager { conjunction: state.conjunction, rules: state.rules, sortRules: state.sortRules, + showCalculationRow: state.showCalculationRow === true, }; return ``; } @@ -232,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 : []; @@ -243,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/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/extensions/post-processor.ts b/src/extensions/post-processor.ts index 705d51e..d7400cd 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; } @@ -144,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/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/services/table-sync-service.ts b/src/services/table-sync-service.ts index 8767310..417886e 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,43 @@ 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 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"); }; - 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..2154053 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 { @@ -81,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, @@ -97,10 +100,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); @@ -130,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 ??= []; @@ -139,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); @@ -266,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({ @@ -285,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 92a64ca..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; } @@ -157,17 +161,20 @@ export type DateFormatOption = export interface PluginSettings { defaultTagFormat: TagFormat; dateFormat?: DateFormatOption; + autoConvertMarkdownTables: boolean; autoDetectMultiSelect: boolean; multiSelectColumnNames: string[]; customTagColors: Record; - enableCalculations: boolean; stickyFirstColumn: boolean; showRowNumbers: boolean; showEmptyBoardProperties: boolean; + tableColumnWidths: Record>; + tableBoardGroupOrders: Record>; } export const DEFAULT_SETTINGS: PluginSettings = { defaultTagFormat: "comma", + autoConvertMarkdownTables: true, autoDetectMultiSelect: true, multiSelectColumnNames: [ "Tags", @@ -180,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 db1af6f..6305f02 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, @@ -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); @@ -150,7 +165,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); } @@ -159,13 +174,14 @@ export class KanbanView { const noStatusRows: MarkdownTableRow[] = []; const tagGroups = new Map(); + const unknownGroups = new Map(); for (const tag of groupByCol?.uniqueTags ?? []) { tagGroups.set(tag.name, []); } 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; @@ -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(); })(); }); @@ -570,7 +597,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/settings-tab.ts b/src/ui/settings-tab.ts index 0fad0ec..e89278c 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).", @@ -67,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.", @@ -149,6 +149,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( @@ -231,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 6d11986..82a896e 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"; @@ -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); @@ -68,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(); } @@ -99,18 +107,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 +136,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 +168,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) { @@ -193,13 +204,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 +318,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/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..4a988b5 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?(this: void, widths: Record): Promise; } export type BoardViewActions = Pick< 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 edb30cf..66908db 100644 --- a/styles.css +++ b/styles.css @@ -607,14 +607,18 @@ 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; + 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, @@ -1201,12 +1205,22 @@ 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 { 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); @@ -2603,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 57faac3..0bb4da2 100644 --- a/tests/cells-date.test.ts +++ b/tests/cells-date.test.ts @@ -199,6 +199,69 @@ 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 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/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/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/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/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/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 9e84cb4..0f5e2fd 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"]]); @@ -554,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/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(); 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"] }, 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-sync-service.test.ts b/tests/table-sync-service.test.ts index c2a39a9..2e5ed87 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,146 @@ 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 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); diff --git a/tests/table-view-controller.test.ts b/tests/table-view-controller.test.ts index 7693c5d..7369eae 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,152 @@ 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 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]; + 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 () => {