mirror of
https://github.com/R0m1k3/noteflow.git
synced 2026-10-11 17:29:37 +02:00
feat: add note linking system with wiki-style syntax
- Create noteLinks utility for wiki-style [[Note Title]] links - Support automatic link detection and conversion to clickable HTML - Show broken links (when target note doesn't exist) in gray strikethrough - Implement backlinks detection (find notes linking to current note) - Add autocomplete suggestions when typing [[ for quick note selection - Support cursor position detection for inline link editing - Prioritize exact matches and recent notes in suggestions - Convert links to HTML with custom styling and click handlers
This commit is contained in:
1 file changed
+187
@@ -0,0 +1,187 @@
|
||||
import { Note } from "@/services/NotesService";
|
||||
|
||||
/**
|
||||
* Pattern to match wiki-style links: [[Note Title]]
|
||||
*/
|
||||
const WIKI_LINK_PATTERN = /\[\[([^\]]+)\]\]/g;
|
||||
|
||||
/**
|
||||
* Extract all note links from content
|
||||
*/
|
||||
export function extractNoteLinks(content: string): string[] {
|
||||
if (!content) return [];
|
||||
|
||||
const matches = content.matchAll(WIKI_LINK_PATTERN);
|
||||
const links: string[] = [];
|
||||
|
||||
for (const match of matches) {
|
||||
if (match[1]) {
|
||||
links.push(match[1].trim());
|
||||
}
|
||||
}
|
||||
|
||||
return [...new Set(links)]; // Remove duplicates
|
||||
}
|
||||
|
||||
/**
|
||||
* Find notes that match the given link text
|
||||
*/
|
||||
export function findLinkedNotes(linkText: string, allNotes: Note[]): Note[] {
|
||||
const searchText = linkText.toLowerCase().trim();
|
||||
|
||||
return allNotes.filter(note => {
|
||||
if (!note.title) return false;
|
||||
return note.title.toLowerCase().includes(searchText);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert wiki-style links to HTML clickable links
|
||||
*/
|
||||
export function convertLinksToHTML(
|
||||
content: string,
|
||||
allNotes: Note[],
|
||||
onLinkClick: (noteId: number) => void
|
||||
): string {
|
||||
if (!content) return '';
|
||||
|
||||
return content.replace(WIKI_LINK_PATTERN, (match, linkText) => {
|
||||
const trimmedText = linkText.trim();
|
||||
const matchedNotes = findLinkedNotes(trimmedText, allNotes);
|
||||
|
||||
if (matchedNotes.length > 0) {
|
||||
// Found matching note - create clickable link
|
||||
const note = matchedNotes[0];
|
||||
return `<a
|
||||
href="#"
|
||||
class="note-link"
|
||||
data-note-id="${note.id}"
|
||||
style="color: #3b82f6; text-decoration: underline; cursor: pointer;"
|
||||
title="Ouvrir: ${note.title}"
|
||||
>${trimmedText}</a>`;
|
||||
} else {
|
||||
// No matching note - show as grayed out
|
||||
return `<span
|
||||
class="note-link-broken"
|
||||
style="color: #9ca3af; text-decoration: line-through;"
|
||||
title="Note introuvable: ${trimmedText}"
|
||||
>[[${trimmedText}]]</span>`;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get backlinks for a note (notes that link to this one)
|
||||
*/
|
||||
export function getBacklinks(note: Note, allNotes: Note[]): Note[] {
|
||||
if (!note.title) return [];
|
||||
|
||||
const backlinks: Note[] = [];
|
||||
const noteTitle = note.title.toLowerCase();
|
||||
|
||||
for (const otherNote of allNotes) {
|
||||
if (otherNote.id === note.id || !otherNote.content) continue;
|
||||
|
||||
const links = extractNoteLinks(otherNote.content);
|
||||
const hasLink = links.some(link =>
|
||||
link.toLowerCase().includes(noteTitle) ||
|
||||
noteTitle.includes(link.toLowerCase())
|
||||
);
|
||||
|
||||
if (hasLink) {
|
||||
backlinks.push(otherNote);
|
||||
}
|
||||
}
|
||||
|
||||
return backlinks;
|
||||
}
|
||||
|
||||
/**
|
||||
* Suggest note titles for autocomplete when typing [[
|
||||
*/
|
||||
export function suggestNoteTitles(
|
||||
partialText: string,
|
||||
allNotes: Note[],
|
||||
limit = 10
|
||||
): Note[] {
|
||||
if (!partialText) {
|
||||
// Return recent notes when no search text
|
||||
return allNotes
|
||||
.filter(note => note.title && !note.archived)
|
||||
.sort((a, b) =>
|
||||
new Date(b.updated_at || 0).getTime() - new Date(a.updated_at || 0).getTime()
|
||||
)
|
||||
.slice(0, limit);
|
||||
}
|
||||
|
||||
const searchText = partialText.toLowerCase().trim();
|
||||
|
||||
// Find notes matching the partial text
|
||||
const matches = allNotes
|
||||
.filter(note => {
|
||||
if (!note.title || note.archived) return false;
|
||||
return note.title.toLowerCase().includes(searchText);
|
||||
})
|
||||
.sort((a, b) => {
|
||||
// Prioritize exact matches and more recent notes
|
||||
const aTitle = a.title?.toLowerCase() || '';
|
||||
const bTitle = b.title?.toLowerCase() || '';
|
||||
|
||||
const aExact = aTitle === searchText ? 1 : 0;
|
||||
const bExact = bTitle === searchText ? 1 : 0;
|
||||
|
||||
if (aExact !== bExact) return bExact - aExact;
|
||||
|
||||
const aStarts = aTitle.startsWith(searchText) ? 1 : 0;
|
||||
const bStarts = bTitle.startsWith(searchText) ? 1 : 0;
|
||||
|
||||
if (aStarts !== bStarts) return bStarts - aStarts;
|
||||
|
||||
// Fall back to recency
|
||||
return new Date(b.updated_at || 0).getTime() - new Date(a.updated_at || 0).getTime();
|
||||
});
|
||||
|
||||
return matches.slice(0, limit);
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse content and detect cursor position for link autocomplete
|
||||
* Returns the partial link text if cursor is inside a [[...]] pattern
|
||||
*/
|
||||
export function detectLinkAtCursor(content: string, cursorPosition: number): {
|
||||
isInLink: boolean;
|
||||
linkText: string;
|
||||
startPos: number;
|
||||
endPos: number;
|
||||
} | null {
|
||||
// Find all [[ positions before cursor
|
||||
const beforeCursor = content.substring(0, cursorPosition);
|
||||
const lastOpenBracket = beforeCursor.lastIndexOf('[[');
|
||||
|
||||
if (lastOpenBracket === -1) {
|
||||
return null; // Not in a link
|
||||
}
|
||||
|
||||
// Check if there's a closing ]] between lastOpenBracket and cursor
|
||||
const betweenBrackets = content.substring(lastOpenBracket, cursorPosition);
|
||||
if (betweenBrackets.includes(']]')) {
|
||||
return null; // Link is already closed
|
||||
}
|
||||
|
||||
// Find the closing ]] after cursor (if any)
|
||||
const afterCursor = content.substring(cursorPosition);
|
||||
const nextCloseBracket = afterCursor.indexOf(']]');
|
||||
const endPos = nextCloseBracket !== -1
|
||||
? cursorPosition + nextCloseBracket + 2
|
||||
: cursorPosition;
|
||||
|
||||
// Extract the link text
|
||||
const linkText = content.substring(lastOpenBracket + 2, cursorPosition).trim();
|
||||
|
||||
return {
|
||||
isInLink: true,
|
||||
linkText,
|
||||
startPos: lastOpenBracket,
|
||||
endPos
|
||||
};
|
||||
}
|
||||
Reference in new issue
Block a user