Skip to content

Commit befbd6d

Browse files
committed
refactor(editor): improve date picker stability and architecture
- Extract date matching utilities to separate file for better maintainability - Replace MatchDecorator with custom widget registry system for more precise position tracking - Implement incremental update mechanism with position mapping to prevent widget repositioning errors - Improve cursor overlap detection to reduce widget flickering during editing - Add comprehensive date validation and task line detection using syntax tree - Remove debug console.log statements
1 parent 7a32446 commit befbd6d

3 files changed

Lines changed: 777 additions & 257 deletions

File tree

Lines changed: 390 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,390 @@
1+
/**
2+
* Utility functions for date picker functionality
3+
* Provides precise date matching using syntax tree and task line detection
4+
*/
5+
6+
import { EditorState, Text, Line } from "@codemirror/state";
7+
// @ts-ignore - This import is necessary but TypeScript can't find it
8+
import { syntaxTree } from "@codemirror/language";
9+
10+
/**
11+
* Whitelist of date emoji markers to prevent false positives
12+
*/
13+
export const DATE_EMOJI_WHITELIST = [
14+
"📅", // Calendar
15+
"🚀", // Rocket (start date)
16+
"✅", // Check mark (completed)
17+
"❌", // Cross mark (cancelled)
18+
"🛫", // Airplane departure (scheduled)
19+
"⏰", // Alarm clock (due)
20+
"🏁", // Checkered flag (deadline)
21+
"▶️", // Play button (start)
22+
];
23+
24+
/**
25+
* Interface representing a matched date in the document
26+
*/
27+
export interface DateMatch {
28+
from: number; // Absolute position in document
29+
to: number; // Absolute position in document
30+
dateText: string; // YYYY-MM-DD format
31+
marker: string; // Emoji or [field:: prefix
32+
fullMatch: string; // Complete matched text
33+
}
34+
35+
/**
36+
* Interface for widget information tracking
37+
*/
38+
export interface WidgetInfo {
39+
id: string;
40+
match: DateMatch;
41+
lineNumber: number;
42+
offsetInLine: number;
43+
lastValidated: number;
44+
}
45+
46+
/**
47+
* Check if a position is inside a task line using syntax tree
48+
* @param state Editor state
49+
* @param pos Position to check
50+
* @returns true if inside a task line
51+
*/
52+
export function isInsideTaskLine(state: EditorState, pos: number): boolean {
53+
try {
54+
// Get the line at this position first
55+
const line = state.doc.lineAt(pos);
56+
const lineText = line.text;
57+
58+
// Quick check: must match task syntax
59+
const taskRegex = /^\s*[-*+]\s*\[.]/;
60+
if (!taskRegex.test(lineText)) {
61+
return false;
62+
}
63+
64+
// Use syntax tree for more precise detection
65+
const tree = syntaxTree(state);
66+
let node = tree.resolveInner(pos, -1);
67+
68+
// Traverse up to find list-item node
69+
while (node) {
70+
if (node.name === "list-item" || node.name.includes("list")) {
71+
return true;
72+
}
73+
node = node.parent;
74+
}
75+
76+
// Fallback: if syntax tree doesn't help, rely on regex
77+
return taskRegex.test(lineText);
78+
} catch (e) {
79+
console.warn("Error checking task line:", e);
80+
return false;
81+
}
82+
}
83+
84+
/**
85+
* Find the task line at a given position
86+
* @param state Editor state
87+
* @param pos Position to check
88+
* @returns Task line info or null
89+
*/
90+
export function findTaskLineAt(
91+
state: EditorState,
92+
pos: number
93+
): { from: number; to: number; text: string; line: Line } | null {
94+
try {
95+
if (!isInsideTaskLine(state, pos)) {
96+
return null;
97+
}
98+
99+
const line = state.doc.lineAt(pos);
100+
return {
101+
from: line.from,
102+
to: line.to,
103+
text: line.text,
104+
line: line,
105+
};
106+
} catch (e) {
107+
console.warn("Error finding task line:", e);
108+
return null;
109+
}
110+
}
111+
112+
/**
113+
* Validate if a date string is in correct format
114+
* Prevents malformed dates like "2024-12-25-25" from being matched
115+
* @param dateStr Date string to validate
116+
* @returns true if valid
117+
*/
118+
function isValidDateFormat(dateStr: string): boolean {
119+
// Must match YYYY-MM-DD or YYYY-MM-DD HH:MM or YYYY-MM-DD HH:MM:SS
120+
const validDateRegex = /^\d{4}-\d{2}-\d{2}(?:\s+\d{2}:\d{2}(?::\d{2})?)?$/;
121+
122+
if (!validDateRegex.test(dateStr)) {
123+
return false;
124+
}
125+
126+
// Additional validation: ensure there are no extra dashes or numbers after the date
127+
const parts = dateStr.split(/[\s:]/);
128+
const datePart = parts[0];
129+
const dateComponents = datePart.split("-");
130+
131+
// Must have exactly 3 components for date (year, month, day)
132+
if (dateComponents.length !== 3) {
133+
return false;
134+
}
135+
136+
// Validate ranges
137+
const year = parseInt(dateComponents[0], 10);
138+
const month = parseInt(dateComponents[1], 10);
139+
const day = parseInt(dateComponents[2], 10);
140+
141+
if (year < 1900 || year > 2100) return false;
142+
if (month < 1 || month > 12) return false;
143+
if (day < 1 || day > 31) return false;
144+
145+
return true;
146+
}
147+
148+
/**
149+
* Find all dates in a task line
150+
* @param lineText Task line text
151+
* @param lineStart Absolute start position of the line
152+
* @param preferDataview Whether to use dataview format
153+
* @returns Array of date matches
154+
*/
155+
export function findDatesInTaskLine(
156+
lineText: string,
157+
lineStart: number,
158+
preferDataview: boolean
159+
): DateMatch[] {
160+
const matches: DateMatch[] = [];
161+
162+
try {
163+
if (preferDataview) {
164+
// Dataview format: [field:: YYYY-MM-DD]
165+
// Match common field names for dates
166+
const dataviewRegex =
167+
/\[([a-zA-Z]+)::\s*(\d{4}-\d{2}-\d{2}(?:\s+\d{2}:\d{2}(?::\d{2})?)?)\]/g;
168+
let match: RegExpExecArray | null;
169+
170+
while ((match = dataviewRegex.exec(lineText)) !== null) {
171+
const dateStr = match[2];
172+
173+
matches.push({
174+
from: lineStart + match.index,
175+
to: lineStart + match.index + match[0].length,
176+
dateText: dateStr,
177+
marker: `[${match[1]}::`,
178+
fullMatch: match[0],
179+
});
180+
}
181+
} else {
182+
// Emoji format: use whitelist to prevent false positives
183+
for (const emoji of DATE_EMOJI_WHITELIST) {
184+
const escapedEmoji = escapeRegex(emoji);
185+
// Strict regex: date must be followed by word boundary or space
186+
// This prevents matching "2024-12-25-25"
187+
const emojiRegex = new RegExp(
188+
`${escapedEmoji}\\s*(\\d{4}-\\d{2}-\\d{2}(?:\\s+\\d{2}:\\d{2}(?::\\d{2})?)?)(?=\\s|$|\\])`,
189+
"g"
190+
);
191+
let match: RegExpExecArray | null;
192+
193+
while ((match = emojiRegex.exec(lineText)) !== null) {
194+
const dateStr = match[1];
195+
196+
matches.push({
197+
from: lineStart + match.index,
198+
to: lineStart + match.index + match[0].length,
199+
dateText: dateStr,
200+
marker: emoji,
201+
fullMatch: match[0],
202+
});
203+
}
204+
}
205+
}
206+
} catch (e) {
207+
console.warn("Error finding dates in task line:", e);
208+
}
209+
210+
// Filter out overlapping matches (keep the first one)
211+
const filtered: DateMatch[] = [];
212+
for (const match of matches) {
213+
const overlaps = filtered.some(
214+
(existing) =>
215+
(match.from >= existing.from && match.from < existing.to) ||
216+
(match.to > existing.from && match.to <= existing.to)
217+
);
218+
if (!overlaps) {
219+
filtered.push(match);
220+
}
221+
}
222+
223+
return filtered;
224+
}
225+
226+
/**
227+
* Generate a unique ID for a widget based on its match
228+
* @param match Date match
229+
* @param lineNumber Line number
230+
* @returns Unique widget ID
231+
*/
232+
export function generateWidgetId(match: DateMatch, lineNumber: number): string {
233+
// Use line number + marker + offset for stable ID
234+
const offset = match.from;
235+
return `widget-${lineNumber}-${encodeURIComponent(match.marker)}-${offset}`;
236+
}
237+
238+
/**
239+
* Validate that a position range is valid in the document
240+
* @param state Editor state
241+
* @param from Start position
242+
* @param to End position
243+
* @returns true if valid
244+
*/
245+
export function isValidPosition(
246+
state: EditorState,
247+
from: number,
248+
to: number
249+
): boolean {
250+
try {
251+
// Basic range checks
252+
if (from < 0 || to < 0) {
253+
return false;
254+
}
255+
256+
if (from > state.doc.length || to > state.doc.length) {
257+
return false;
258+
}
259+
260+
if (from >= to) {
261+
return false;
262+
}
263+
264+
// Ensure positions are on the same line
265+
const fromLine = state.doc.lineAt(from);
266+
const toLine = state.doc.lineAt(to);
267+
268+
if (fromLine.number !== toLine.number) {
269+
return false;
270+
}
271+
272+
return true;
273+
} catch (e) {
274+
return false;
275+
}
276+
}
277+
278+
/**
279+
* Safely get a line by number
280+
* @param doc Document
281+
* @param lineNumber Line number (1-based)
282+
* @returns Line or null if invalid
283+
*/
284+
export function safeGetLine(doc: Text, lineNumber: number): Line | null {
285+
try {
286+
if (lineNumber < 1 || lineNumber > doc.lines) {
287+
return null;
288+
}
289+
return doc.line(lineNumber);
290+
} catch (e) {
291+
return null;
292+
}
293+
}
294+
295+
/**
296+
* Escape special regex characters
297+
* @param str String to escape
298+
* @returns Escaped string
299+
*/
300+
export function escapeRegex(str: string): string {
301+
return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
302+
}
303+
304+
/**
305+
* Get affected line numbers from a change set
306+
* @param state Editor state
307+
* @param changes Change set
308+
* @returns Set of affected line numbers
309+
*/
310+
export function getAffectedLineNumbers(
311+
state: EditorState,
312+
changes: {
313+
iterChangedRanges: (
314+
f: (fromA: number, toA: number, fromB: number, toB: number) => void
315+
) => void;
316+
}
317+
): Set<number> {
318+
const lines = new Set<number>();
319+
320+
try {
321+
changes.iterChangedRanges((fromA, toA, fromB, toB) => {
322+
try {
323+
// Get line numbers in the new document
324+
const startLine = state.doc.lineAt(fromB).number;
325+
const endLine = state.doc.lineAt(
326+
Math.min(toB, state.doc.length)
327+
).number;
328+
329+
for (let i = startLine; i <= endLine; i++) {
330+
lines.add(i);
331+
}
332+
} catch (e) {
333+
// Ignore errors for individual ranges
334+
}
335+
});
336+
} catch (e) {
337+
console.warn("Error getting affected lines:", e);
338+
}
339+
340+
return lines;
341+
}
342+
343+
/**
344+
* Check if a node is inside a code block or frontmatter
345+
* @param state Editor state
346+
* @param from Start position
347+
* @param to End position
348+
* @returns true if should skip rendering
349+
*/
350+
export function shouldSkipRendering(
351+
state: EditorState,
352+
from: number,
353+
to: number
354+
): boolean {
355+
try {
356+
const tree = syntaxTree(state);
357+
const node = tree.resolveInner(from, 1);
358+
359+
// Check node and parent nodes for special contexts
360+
let current = node;
361+
while (current) {
362+
const nodeName = current.name.toLowerCase();
363+
const nodeType = current.type.name.toLowerCase();
364+
365+
// Skip code blocks
366+
if (
367+
nodeName.includes("code") ||
368+
nodeName.includes("fenced") ||
369+
nodeType.includes("code")
370+
) {
371+
return true;
372+
}
373+
374+
// Skip frontmatter
375+
if (
376+
nodeName.includes("frontmatter") ||
377+
nodeType.includes("frontmatter")
378+
) {
379+
return true;
380+
}
381+
382+
current = current.parent;
383+
}
384+
385+
return false;
386+
} catch (e) {
387+
// On error, default to rendering (better than breaking)
388+
return false;
389+
}
390+
}

0 commit comments

Comments
 (0)