File size: 8,504 Bytes
61aec16
 
35a414b
 
61aec16
35a414b
61aec16
35a414b
7068559
61aec16
 
 
 
 
 
 
35a414b
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
7068559
 
 
 
 
 
 
 
 
 
 
 
 
 
 
35a414b
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
61aec16
 
 
 
 
 
 
7f96410
 
 
 
 
35a414b
 
 
 
61aec16
 
35a414b
 
 
 
61aec16
 
35a414b
 
 
 
 
 
 
 
 
 
 
 
 
 
61aec16
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
35a414b
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
61aec16
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
/**
 * T074: Markdown rendering configuration and wikilink handling
 * T019-T028: Wikilink preview tooltips with HoverCard
 * T039-T040: Heading ID generation for Table of Contents
 */
import React, { useState } from 'react';
import type { Components } from 'react-markdown';
import { HoverCard, HoverCardTrigger, HoverCardContent } from '@/components/ui/hover-card';
import { getNote, searchNotes } from '@/services/api';

export interface WikilinkComponentProps {
  linkText: string;
  resolved: boolean;
  onClick?: (linkText: string) => void;
}

/**
 * T019: Preview cache for wikilink tooltips
 */
const previewCache = new Map<string, string>();

/**
 * T039-T040: Track slugs to handle duplicates
 */
const slugCache = new Map<string, number>();

/**
 * T040: Slugify heading text to create valid HTML IDs
 * Handles duplicates by appending -2, -3, etc.
 */
function slugify(text: string): string {
  // Basic slugification
  const baseSlug = text
    .toLowerCase()
    .replace(/\s+/g, '-')
    .replace(/[^\w-]/g, '');

  // T050: Handle duplicates
  const count = slugCache.get(baseSlug) || 0;
  slugCache.set(baseSlug, count + 1);

  if (count === 0) {
    return baseSlug;
  }

  return `${baseSlug}-${count + 1}`;
}

/**
 * Reset slug cache (call when rendering a new document)
 */
export function resetSlugCache(): void {
  slugCache.clear();
}

/**
 * T021-T026: Wikilink preview component with HoverCard
 */
function WikilinkPreview({
  linkText,
  children,
  onClick
}: {
  linkText: string;
  children: React.ReactNode;
  onClick?: () => void;
}) {
  const [preview, setPreview] = useState<string | null>(null);
  const [isLoading, setIsLoading] = useState(false);
  const [isBroken, setIsBroken] = useState(false);
  const [isOpen, setIsOpen] = useState(false);

  // T023: Fetch preview when hover card opens
  React.useEffect(() => {
    if (!isOpen) return;

    // T028: Check cache first
    if (previewCache.has(linkText)) {
      setPreview(previewCache.get(linkText)!);
      setIsLoading(false);
      setIsBroken(false);
      return;
    }

    // Start loading
    setIsLoading(true);

    const fetchPreview = async () => {
      try {
        // First search for the note by title/content to find its actual path
        const searchResults = await searchNotes(linkText);

        if (searchResults.length === 0) {
          // No results found - broken link
          setIsBroken(true);
          setPreview(null);
          setIsLoading(false);
          return;
        }

        // Get the first matching note
        const notePath = searchResults[0].note_path;
        const note = await getNote(notePath);

        // T024: Extract first 150 characters from note body
        const previewText = note.body
          .replace(/\[\[([^\]]+)\]\]/g, '$1') // Remove wikilinks
          .replace(/[#*_~`]/g, '') // Remove markdown formatting
          .trim()
          .slice(0, 150);
        const finalPreview = previewText.length < note.body.length
          ? `${previewText}...`
          : previewText;

        // T019: Cache the preview
        previewCache.set(linkText, finalPreview);
        setPreview(finalPreview);
        setIsBroken(false);
      } catch (error) {
        // T026: Handle broken wikilinks
        setIsBroken(true);
        setPreview(null);
      } finally {
        setIsLoading(false);
      }
    };

    fetchPreview();
  }, [isOpen, linkText]);

  return (
    <HoverCard openDelay={500} closeDelay={100} onOpenChange={setIsOpen}>
      <HoverCardTrigger asChild>
        <span onClick={onClick}>
          {children}
        </span>
      </HoverCardTrigger>
      <HoverCardContent className="w-80">
        {isLoading ? (
          // T025: Loading skeleton
          <div className="space-y-2">
            <div className="h-4 bg-muted animate-pulse rounded" />
            <div className="h-4 bg-muted animate-pulse rounded w-5/6" />
            <div className="h-4 bg-muted animate-pulse rounded w-4/6" />
          </div>
        ) : isBroken ? (
          // T026: Broken link message
          <div className="text-sm text-destructive">
            Note not found
          </div>
        ) : preview ? (
          <div className="text-sm text-muted-foreground">
            {preview}
          </div>
        ) : null}
      </HoverCardContent>
    </HoverCard>
  );
}

/**
 * Custom renderer for wikilinks in markdown
 */
export function createWikilinkComponent(
  onWikilinkClick?: (linkText: string) => void
): Components {
  return {
    // Style links
    a: ({ href, children, ...props }) => {
      if (href?.startsWith('wikilink:')) {
        const linkText = decodeURIComponent(href.replace('wikilink:', ''));
        return (
          <WikilinkPreview
            linkText={linkText}
            onClick={(e?: React.MouseEvent) => {
              e?.preventDefault();
              onWikilinkClick?.(linkText);
            }}
          >
            <span
              className="wikilink cursor-pointer text-primary hover:underline font-medium text-blue-500 dark:text-blue-400"
              onClick={(e) => {
                e.preventDefault();
                onWikilinkClick?.(linkText);
              }}
              role="link"
              tabIndex={0}
              onKeyDown={(e) => {
                if (e.key === 'Enter' || e.key === ' ') {
                  e.preventDefault();
                  onWikilinkClick?.(linkText);
                }
              }}
              title={`Go to ${linkText}`}
            >
              {children}
            </span>
          </WikilinkPreview>
        );
      }

      const isExternal = href?.startsWith('http');
      return (
        <a
          href={href}
          className="text-primary hover:underline"
          target={isExternal ? '_blank' : undefined}
          rel={isExternal ? 'noopener noreferrer' : undefined}
          {...props}
        >
          {children}
        </a>
      );
    },

    // T039: Style headings with ID generation for TOC
    h1: ({ children, ...props }) => {
      const text = typeof children === 'string' ? children : '';
      const id = text ? slugify(text) : undefined;
      return (
        <h1 id={id} className="text-3xl font-bold mt-6 mb-4" {...props}>
          {children}
        </h1>
      );
    },
    h2: ({ children, ...props }) => {
      const text = typeof children === 'string' ? children : '';
      const id = text ? slugify(text) : undefined;
      return (
        <h2 id={id} className="text-2xl font-semibold mt-5 mb-3" {...props}>
          {children}
        </h2>
      );
    },
    h3: ({ children, ...props }) => {
      const text = typeof children === 'string' ? children : '';
      const id = text ? slugify(text) : undefined;
      return (
        <h3 id={id} className="text-xl font-semibold mt-4 mb-2" {...props}>
          {children}
        </h3>
      );
    },

    // Style lists
    ul: ({ children, ...props }) => (
      <ul className="list-disc list-inside my-2 space-y-1" {...props}>
        {children}
      </ul>
    ),
    ol: ({ children, ...props }) => (
      <ol className="list-decimal list-inside my-2 space-y-1" {...props}>
        {children}
      </ol>
    ),

    // Style blockquotes
    blockquote: ({ children, ...props }) => (
      <blockquote className="border-l-4 border-muted-foreground pl-4 italic my-4" {...props}>
        {children}
      </blockquote>
    ),

    // Style tables
    table: ({ children, ...props }) => (
      <div className="overflow-x-auto my-4">
        <table className="min-w-full border-collapse border border-border" {...props}>
          {children}
        </table>
      </div>
    ),
    th: ({ children, ...props }) => (
      <th className="border border-border px-4 py-2 bg-muted font-semibold text-left" {...props}>
        {children}
      </th>
    ),
    td: ({ children, ...props }) => (
      <td className="border border-border px-4 py-2" {...props}>
        {children}
      </td>
    ),
  };
}

/**
 * Render broken wikilinks with distinct styling
 */
export function renderBrokenWikilink(
  linkText: string,
  onCreate?: () => void
): React.ReactElement {
  return (
    <span
      className="wikilink-broken text-destructive border-b border-dashed border-destructive cursor-pointer hover:bg-destructive/10"
      onClick={onCreate}
      role="link"
      tabIndex={0}
      title={`Note "${linkText}" not found. Click to create.`}
    >
      [[{linkText}]]
    </span>
  );
}