Skip to content

Commit 964e342

Browse files
authored
fix(timestamps): capture into the cursor's table cell without breaking the row (#165) (#203)
Capture Timestamp derived a position with getCursor(), wrote it with replaceRange, then re-placed the caret with a hand-computed setCursor. Inside an Obsidian Live Preview table cell that pattern fights the table-editing widget, so the timestamp landed in the wrong cell and the cursor scattered. Raw pipes or newlines in the capture also broke the row. Insert with editor.replaceSelection so CodeMirror owns caret placement, and, when the cursor is inside a table (including one nested in a callout or blockquote), escape pipes and collapse newlines via prepareTimestampForInsertion so the row stays intact. Outside a table the capture is inserted verbatim. Closes #165
1 parent 659b6b8 commit 964e342

4 files changed

Lines changed: 259 additions & 3 deletions

File tree

docs/docs/timestamps.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,8 @@ For example, you might use `{{time:H\h mm\m ss\s}}` to get the time in the forma
1616
## Capturing timestamps
1717
You can use the `Capture Timestamp` command by using the `PodNotes: Capture Timestamp` command in the command palette.
1818

19+
The timestamp is inserted at your cursor. When the cursor is inside a markdown table cell, the captured text stays on that row: any pipes are escaped and newlines are collapsed to spaces so the table is not broken.
20+
1921
**On desktop**, it is possible to bind this command to a hotkey, which makes it faster to use while writing.
2022
You can bind hotkeys in the `Hotkeys` tab of the Obsidian settings.
2123

src/main.ts

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ import type { Episode } from "./types/Episode";
3939
import CurrentEpisodeController from "./store_controllers/CurrentEpisodeController";
4040
import { HidePlayedEpisodesController } from "./store_controllers/HidePlayedEpisodesController";
4141
import { TimestampTemplateEngine } from "./TemplateEngine";
42+
import { prepareTimestampForInsertion } from "./utility/prepareTimestampInsertion";
4243
import createPodcastNote from "./createPodcastNote";
4344
import createFeedNote from "./createFeedNote";
4445
import { FeedSuggestModal, orderFeedsByCurrent } from "./ui/FeedSuggestModal";
@@ -273,13 +274,24 @@ export default class PodNotes extends Plugin implements IPodNotes {
273274
return !!this.api.podcast && !!this.settings.timestamp.template;
274275
}
275276

276-
const cursorPos = editor.getCursor();
277277
const capture = TimestampTemplateEngine(
278278
this.settings.timestamp.template,
279279
);
280280

281-
editor.replaceRange(capture, cursorPos);
282-
editor.setCursor(cursorPos.line, cursorPos.ch + capture.length);
281+
// Insert with replaceSelection (not getCursor + replaceRange +
282+
// setCursor): it drops the text at the live cursor and lets the
283+
// editor place the caret after it, which is reliable inside Live
284+
// Preview table cells where hand-computed positions land in the
285+
// wrong cell. Inside a table the capture is escaped so pipes and
286+
// newlines don't break the row. See issue #165.
287+
const cursor = editor.getCursor("from");
288+
const textToInsert = prepareTimestampForInsertion(capture, {
289+
getLine: (line) => editor.getLine(line),
290+
lineCount: editor.lineCount(),
291+
cursorLine: cursor.line,
292+
});
293+
294+
editor.replaceSelection(textToInsert);
283295
},
284296
});
285297

Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,138 @@
1+
import { describe, expect, it } from "vitest";
2+
import {
3+
escapeForTableCell,
4+
isInsideTable,
5+
isTableDelimiterRow,
6+
prepareTimestampForInsertion,
7+
} from "./prepareTimestampInsertion";
8+
9+
// Build a line-accessor pair (getLine, lineCount) over an array of lines, the
10+
// shape the editor exposes, so detection can be exercised without a real editor.
11+
function fromLines(lines: string[]): {
12+
getLine: (line: number) => string;
13+
lineCount: number;
14+
} {
15+
return { getLine: (line: number) => lines[line] ?? "", lineCount: lines.length };
16+
}
17+
18+
const TABLE = [
19+
"| Time | Note |",
20+
"| ---- | ---- |",
21+
"| 0:00 | intro |",
22+
"| 1:23 | topic |",
23+
];
24+
25+
describe("isTableDelimiterRow", () => {
26+
it("recognises plain, aligned, and tight delimiter rows", () => {
27+
expect(isTableDelimiterRow("| --- | --- |")).toBe(true);
28+
expect(isTableDelimiterRow("| :--- | ---: | :--: |")).toBe(true);
29+
expect(isTableDelimiterRow("|---|---|")).toBe(true);
30+
expect(isTableDelimiterRow(" | ---- | ---- | ")).toBe(true);
31+
});
32+
33+
it("rejects content rows and pipe-free lines", () => {
34+
expect(isTableDelimiterRow("| Time | Note |")).toBe(false);
35+
expect(isTableDelimiterRow("| 0:00 | intro |")).toBe(false);
36+
// A bare thematic break / setext underline has no pipe and must not count.
37+
expect(isTableDelimiterRow("---")).toBe(false);
38+
expect(isTableDelimiterRow("")).toBe(false);
39+
});
40+
41+
it("recognises a delimiter row nested in a blockquote or callout", () => {
42+
expect(isTableDelimiterRow("> | ---- | ---- |")).toBe(true);
43+
expect(isTableDelimiterRow(">| ---- | ---- |")).toBe(true);
44+
expect(isTableDelimiterRow("> > | --- | --- |")).toBe(true);
45+
});
46+
});
47+
48+
describe("isInsideTable", () => {
49+
it("is true on the header, delimiter, and body rows", () => {
50+
const { getLine, lineCount } = fromLines(TABLE);
51+
expect(isInsideTable(getLine, lineCount, 0)).toBe(true);
52+
expect(isInsideTable(getLine, lineCount, 1)).toBe(true);
53+
expect(isInsideTable(getLine, lineCount, 2)).toBe(true);
54+
expect(isInsideTable(getLine, lineCount, 3)).toBe(true);
55+
});
56+
57+
it("is false in ordinary prose, even when the line contains a pipe", () => {
58+
const lines = ["Some prose here.", "a | b is not a table", "More prose."];
59+
const { getLine, lineCount } = fromLines(lines);
60+
expect(isInsideTable(getLine, lineCount, 0)).toBe(false);
61+
expect(isInsideTable(getLine, lineCount, 1)).toBe(false);
62+
expect(isInsideTable(getLine, lineCount, 2)).toBe(false);
63+
});
64+
65+
it("is false on a blank line separating a table from following text", () => {
66+
const lines = [...TABLE, "", "After the table."];
67+
const { getLine, lineCount } = fromLines(lines);
68+
expect(isInsideTable(getLine, lineCount, 4)).toBe(false);
69+
expect(isInsideTable(getLine, lineCount, 5)).toBe(false);
70+
});
71+
72+
it("detects a table nested inside a callout/blockquote", () => {
73+
const lines = [
74+
"> [!note] Timestamps",
75+
"> | Time | Note |",
76+
"> | ---- | ----- |",
77+
"> | 0:00 | intro |",
78+
];
79+
const { getLine, lineCount } = fromLines(lines);
80+
expect(isInsideTable(getLine, lineCount, 1)).toBe(true);
81+
expect(isInsideTable(getLine, lineCount, 3)).toBe(true);
82+
});
83+
});
84+
85+
describe("escapeForTableCell", () => {
86+
it("escapes unescaped pipes so they stay textual in a cell", () => {
87+
expect(escapeForTableCell("a | b")).toBe("a \\| b");
88+
});
89+
90+
it("does not double-escape an already-escaped pipe", () => {
91+
expect(escapeForTableCell("a \\| b")).toBe("a \\| b");
92+
});
93+
94+
it("collapses newlines (LF, CRLF, CR) to single spaces", () => {
95+
expect(escapeForTableCell("line1\nline2")).toBe("line1 line2");
96+
expect(escapeForTableCell("line1\r\nline2")).toBe("line1 line2");
97+
expect(escapeForTableCell("line1\rline2")).toBe("line1 line2");
98+
});
99+
100+
it("leaves a plain timestamp link untouched", () => {
101+
const link = "[1:23](obsidian://podnotes?episodeName=Show&time=83)";
102+
expect(escapeForTableCell(link)).toBe(link);
103+
});
104+
});
105+
106+
describe("prepareTimestampForInsertion", () => {
107+
it("escapes a pipe/newline capture when the cursor is in a table cell", () => {
108+
const { getLine, lineCount } = fromLines(TABLE);
109+
const result = prepareTimestampForInsertion("[1:23](x) | note\nmore", {
110+
getLine,
111+
lineCount,
112+
cursorLine: 2,
113+
});
114+
expect(result).toBe("[1:23](x) \\| note more");
115+
});
116+
117+
it("leaves the default '- {{time}} ' style capture unchanged in a table", () => {
118+
const { getLine, lineCount } = fromLines(TABLE);
119+
const result = prepareTimestampForInsertion("- 0:01:23 ", {
120+
getLine,
121+
lineCount,
122+
cursorLine: 2,
123+
});
124+
expect(result).toBe("- 0:01:23 ");
125+
});
126+
127+
it("never escapes outside a table", () => {
128+
const lines = ["Notes:", ""];
129+
const { getLine, lineCount } = fromLines(lines);
130+
const capture = "[1:23](x) | note\nmore";
131+
const result = prepareTimestampForInsertion(capture, {
132+
getLine,
133+
lineCount,
134+
cursorLine: 0,
135+
});
136+
expect(result).toBe(capture);
137+
});
138+
});
Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
// Helpers for inserting a captured timestamp at the editor cursor without
2+
// breaking the markdown around it. See issue #165: capturing a timestamp into a
3+
// markdown table cell would land the text in the wrong cell, scatter the cursor,
4+
// or break the row. The root causes were (1) deriving a position with
5+
// `getCursor()` and feeding it to `replaceRange` + a hand-computed `setCursor`,
6+
// which fights Obsidian's Live Preview table-editing widget, and (2) inserting
7+
// raw `|`/newline characters that are structural inside a table.
8+
//
9+
// The command now inserts with `editor.replaceSelection` (which lets CodeMirror
10+
// own cursor placement) and, when the cursor sits inside a table, runs the
11+
// capture through `escapeForTableCell` so pipes and newlines stay textual.
12+
13+
/**
14+
* Strip a leading blockquote/callout marker chain (`>`, `> >`, ...) so a table
15+
* nested inside a callout or blockquote is detected the same as a top-level one.
16+
* Obsidian renders `> | a | b |` as a table inside the callout, so for cell
17+
* detection the `>` prefix is not part of the row.
18+
*/
19+
function stripBlockquotePrefix(line: string): string {
20+
return line.replace(/^\s*(?:>\s?)+/, "");
21+
}
22+
23+
/** Does the line contain a table cell pipe, ignoring any blockquote prefix? */
24+
function lineHasCellPipe(line: string): boolean {
25+
return stripBlockquotePrefix(line).includes("|");
26+
}
27+
28+
/**
29+
* Is `line` a GFM table delimiter row (e.g. `| --- | :--: |`)? A delimiter row
30+
* is what distinguishes a real table from an ordinary line that merely contains
31+
* pipes, so it is the signal we key table detection on. Requires at least one
32+
* pipe so a bare `---` thematic break / setext underline is not misread. A
33+
* leading blockquote/callout marker is ignored so nested tables still match.
34+
*/
35+
export function isTableDelimiterRow(line: string): boolean {
36+
const trimmed = stripBlockquotePrefix(line).trim();
37+
if (!trimmed.includes("|")) return false;
38+
39+
const cells = trimmed
40+
.replace(/^\|/, "")
41+
.replace(/\|$/, "")
42+
.split("|");
43+
44+
return cells.length > 0 && cells.every((cell) => /^\s*:?-+:?\s*$/.test(cell));
45+
}
46+
47+
/**
48+
* Is the cursor inside a markdown table? True when the cursor line contains a
49+
* pipe and the contiguous block of pipe-bearing lines around it includes a
50+
* delimiter row. Scanning the block (rather than just an adjacent line) keeps
51+
* detection correct whether the cursor is on the header, the delimiter, or any
52+
* body row, while the "must contain a pipe" gate avoids escaping ordinary prose.
53+
* Blockquote/callout markers are ignored so tables nested in a callout match.
54+
*/
55+
export function isInsideTable(
56+
getLine: (line: number) => string,
57+
lineCount: number,
58+
cursorLine: number,
59+
): boolean {
60+
const current = getLine(cursorLine);
61+
if (!current || !lineHasCellPipe(current)) return false;
62+
63+
for (let i = cursorLine; i >= 0; i--) {
64+
const line = getLine(i);
65+
if (!line || !lineHasCellPipe(line)) break;
66+
if (isTableDelimiterRow(line)) return true;
67+
}
68+
69+
for (let i = cursorLine; i < lineCount; i++) {
70+
const line = getLine(i);
71+
if (!line || !lineHasCellPipe(line)) break;
72+
if (isTableDelimiterRow(line)) return true;
73+
}
74+
75+
return false;
76+
}
77+
78+
/**
79+
* Make `text` safe to drop into a single table cell: collapse newlines to a
80+
* space (a raw newline would end the row) and escape any unescaped pipe (a raw
81+
* pipe would open a new column). Already-escaped pipes (`\|`) are left alone so
82+
* the cell is never double-escaped.
83+
*/
84+
export function escapeForTableCell(text: string): string {
85+
return text.replace(/\r\n?|\n/g, " ").replace(/(?<!\\)\|/g, "\\|");
86+
}
87+
88+
/**
89+
* Resolve the exact string to insert for a captured timestamp: the raw capture
90+
* everywhere except inside a table cell, where it is escaped so the row stays
91+
* intact. Callers insert the result with `editor.replaceSelection`.
92+
*/
93+
export function prepareTimestampForInsertion(
94+
capture: string,
95+
context: {
96+
getLine: (line: number) => string;
97+
lineCount: number;
98+
cursorLine: number;
99+
},
100+
): string {
101+
return isInsideTable(context.getLine, context.lineCount, context.cursorLine)
102+
? escapeForTableCell(capture)
103+
: capture;
104+
}

0 commit comments

Comments
 (0)