diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 33c69aa..0a59c17 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -14,8 +14,8 @@ "format": "prettier --write ." }, "devDependencies": { - "@haptic/tailwind-config": "workspace:*", "@haptic/eslint-config": "workspace:*", + "@haptic/tailwind-config": "workspace:*", "@sveltejs/adapter-auto": "^3.0.0", "@sveltejs/adapter-static": "^3.0.1", "@sveltejs/kit": ">=2.4.3", @@ -60,6 +60,7 @@ "clsx": "^2.1.0", "lucide-svelte": "^0.321.0", "markdown-it": "^14.1.0", + "remark-wiki-link": "^2.0.1", "tailwind-merge": "^2.2.1", "tailwind-variants": "^0.1.20", "tauri-plugin-fs-extra-api": "github:tauri-apps/tauri-plugin-fs-extra#v1", diff --git a/apps/desktop/src/lib/components/shared/editor/editor.svelte b/apps/desktop/src/lib/components/shared/editor/editor.svelte index f8f7434..6c8f356 100644 --- a/apps/desktop/src/lib/components/shared/editor/editor.svelte +++ b/apps/desktop/src/lib/components/shared/editor/editor.svelte @@ -16,6 +16,220 @@ import Shortcut from '../shortcut.svelte'; import SearchAndReplace from './extensions'; + import { Mark, mergeAttributes } from '@tiptap/core'; + import { Plugin, PluginKey } from '@tiptap/pm/state'; + + import { getAllItems } from '@/components/shared/command-menu/helpers'; + import { openNote } from '@/api/notes'; + + async function getAvailableNotes( + searchTerm: string = '' + ): Promise<{ name: string; path: string }[]> { + try { + // Get all notes using the existing getAllItems function + const allNotes = await getAllItems(false); // false = get notes, not folders + + console.log('All notes from getAllItems:', allNotes); // Debug log + + // Map to objects containing both display name and full path + const noteData = allNotes.map((note) => { + // Remove .md extension and any leading slashes + let name = note.name.replace(/\.md$/, ''); + if (name.startsWith('/')) { + name = name.substring(1); + } + + return { + name: name, // Clean display name + path: note.path // Full filesystem path + }; + }); + + // Filter based on search term if provided + if (searchTerm) { + const lowerSearchTerm = searchTerm.toLowerCase(); + return noteData.filter((item) => item.name.toLowerCase().includes(lowerSearchTerm)); + } + + return noteData; + } catch (error) { + console.error('Error getting available notes:', error); + return []; + } + } + + // Custom Wikilink mark + const WikiLink = Mark.create({ + name: 'wikilink', + inclusive: false, + + addAttributes() { + return { + 'data-note': { + default: null + }, + 'data-type': { + default: 'wikilink' + } + }; + }, + + parseHTML() { + return [ + { + tag: 'a[data-type="wikilink"]' + } + ]; + }, + + renderHTML({ HTMLAttributes }) { + return [ + 'a', + mergeAttributes(HTMLAttributes, { + 'data-type': 'wikilink', + class: 'wiki-link', + 'data-note': HTMLAttributes['data-note'] + }), + 0 + ]; + }, + + addProseMirrorPlugins() { + return [ + new Plugin({ + key: new PluginKey('wikilink'), + props: { + handleTextInput: (view, from, to, text) => { + const { state } = view; + const { tr } = state; + + // Get text before cursor + const textBefore = state.doc.textBetween(Math.max(0, from - 50), from, '\n', ' '); + const fullText = textBefore + text; + + // Check for autocomplete trigger + const autocompleteMatch = fullText.match(/\[\[([^\]]*)$/); + if (autocompleteMatch) { + const searchTerm = autocompleteMatch[1]; + + // Show autocomplete if we have [[ followed by text + if (text !== ']') { + // Get cursor position for autocomplete placement + const coords = view.coordsAtPos(from); + + // Get suggestions asynchronously + getAvailableNotes(searchTerm).then((suggestions) => { + if (suggestions.length > 0) { + showAutocomplete(suggestions, { + x: coords.left, + y: coords.bottom + }); + } + }); + } + } else { + // Hide autocomplete if we're not in wikilink context + hideAutocomplete(); + } + + // Check for completed wikilink pattern + // In the handleTextInput function, update the completed match handling: + const completedMatch = fullText.match(/\[\[([^\]]+)\]\]$/); + if (completedMatch) { + hideAutocomplete(); + + const linkText = completedMatch[1]; + const matchStart = from - completedMatch[0].length + text.length; + const matchEnd = from + text.length; + + // For now, just create the text without the mark + tr.delete(matchStart, matchEnd); + tr.insertText(linkText, matchStart); + + // Insert a space after to exit the mark + tr.insertText(' ', matchStart + linkText.length); + + // Store position for async path resolution + const tempMark = { + start: matchStart, + end: matchStart + linkText.length, + text: linkText + }; + + // Dispatch the text change first + view.dispatch(tr); + + // Then resolve the path and add the mark + getAllItems(false).then((allNotes) => { + const matchedNote = allNotes.find( + (note) => note.name.replace(/\.md$/, '') === linkText + ); + + if (matchedNote && tiptapEditor) { + // Create a new transaction with the mark + const markTr = tiptapEditor.state.tr; + const mark = this.type.create({ + 'data-note': matchedNote.path + }); + + // Add mark only to the link text, not the space + markTr.addMark(tempMark.start, tempMark.end, mark); + + // Ensure cursor is after the space + markTr.setSelection( + tiptapEditor.state.selection.constructor.create(markTr.doc, tempMark.end + 1) + ); + + tiptapEditor.view.dispatch(markTr); + } + }); + + return true; + } + + return false; + }, + + handleKeyDown: (view, event) => { + if (!autocompleteActive) return false; + + switch (event.key) { + case 'ArrowDown': + event.preventDefault(); + selectedSuggestionIndex = Math.min( + selectedSuggestionIndex + 1, + currentSuggestions.length - 1 + ); + updateSelection(); + return true; + + case 'ArrowUp': + event.preventDefault(); + selectedSuggestionIndex = Math.max(selectedSuggestionIndex - 1, 0); + updateSelection(); + return true; + + case 'Enter': + case 'Tab': + event.preventDefault(); + if (selectedSuggestionIndex >= 0) { + insertSuggestion(currentSuggestions[selectedSuggestionIndex]); + } + return true; + + case 'Escape': + hideAutocomplete(); + return true; + } + + return false; + } + } + }) + ]; + } + }); + let element: HTMLDivElement; let tiptapEditor: Editor; let timeout: NodeJS.Timeout; @@ -54,6 +268,7 @@ 'text-primary underline hover:text-primary/80 transition-all cursor-pointer text-base [&>*]:font-normal' } }), + WikiLink, Markdown.configure({ linkify: true, transformPastedText: true @@ -90,13 +305,183 @@ }, $collectionSettings.editor.auto_save_debounce); } }); + + if (element) { + element.addEventListener('click', handleWikilinkClick, true); // Use capture phase + } }); + // Add this function after onMount and before onDestroy + function handleWikilinkClick(event: MouseEvent) { + const target = event.target as HTMLElement; + + if (target.matches('a[data-type="wikilink"]') || target.classList.contains('wiki-link')) { + event.preventDefault(); + event.stopPropagation(); + + // Get the FULL PATH from data-note + const fullPath = target.getAttribute('data-note'); + + console.log('Wikilink clicked, full path:', fullPath); + + if (fullPath) { + // Always use the full path + openNote(fullPath, true); + } else { + console.error('No path found on wikilink!'); + } + + return false; + } + } + onDestroy(() => { - if (editor) { + if (tiptapEditor) { tiptapEditor.destroy(); } + if (element) { + element.removeEventListener('click', handleWikilinkClick, true); + } + // Clean up autocomplete + hideAutocomplete(); + if (autocompleteElement) { + document.body.removeChild(autocompleteElement); + autocompleteElement = null; + } }); + + // Autocomplete functionality + let autocompleteElement: HTMLDivElement | null = null; + let currentSuggestions: { name: string; path: string }[] = []; + + let selectedSuggestionIndex = -1; + let autocompleteActive = false; + + function createAutocompleteElement() { + if (autocompleteElement) return autocompleteElement; + + autocompleteElement = document.createElement('div'); + autocompleteElement.className = 'wikilink-autocomplete'; + autocompleteElement.style.cssText = ` + position: absolute; + background: hsl(var(--background)); + border: 1px solid hsl(var(--border)); + border-radius: 6px; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); + max-height: 200px; + overflow-y: auto; + z-index: 1000; + display: none; + min-width: 200px; + `; + + document.body.appendChild(autocompleteElement); + return autocompleteElement; + } + + function showAutocomplete( + suggestions: { name: string; path: string }[], + position: { x: number; y: number } + ) { + const element = createAutocompleteElement(); + currentSuggestions = suggestions; + selectedSuggestionIndex = -1; + + element.innerHTML = suggestions + .map( + (suggestion, index) => + `
${suggestion.name}
` + ) + .join(''); + + // Add click handlers + element.querySelectorAll('.autocomplete-item').forEach((item, index) => { + item.addEventListener('mouseenter', () => { + selectedSuggestionIndex = index; + updateSelection(); + }); + + item.addEventListener('click', () => { + insertSuggestion(suggestions[index]); + }); + }); + + element.style.left = `${position.x}px`; + element.style.top = `${position.y + 20}px`; + element.style.display = 'block'; + autocompleteActive = true; + + updateSelection(); + } + + function hideAutocomplete() { + if (autocompleteElement) { + autocompleteElement.style.display = 'none'; + } + autocompleteActive = false; + selectedSuggestionIndex = -1; + } + + function updateSelection() { + if (!autocompleteElement) return; + + const items = autocompleteElement.querySelectorAll('.autocomplete-item'); + items.forEach((item, index) => { + if (index === selectedSuggestionIndex) { + (item as HTMLElement).style.backgroundColor = 'hsl(var(--accent))'; + } else { + (item as HTMLElement).style.backgroundColor = 'transparent'; + } + }); + } + + function insertSuggestion(suggestion: { name: string; path: string }) { + if (!tiptapEditor) return; + + const { state } = tiptapEditor; + const { selection } = state; + const { from } = selection; + + // Find the [[ before the cursor + const textBefore = state.doc.textBetween(Math.max(0, from - 50), from, '\n', ' '); + const match = textBefore.match(/\[\[([^\]]*)$/); + + if (match) { + const matchStart = from - match[0].length; + const tr = state.tr; + + // Delete the partial wikilink + tr.delete(matchStart, from); + + // Insert the complete wikilink text + const linkText = suggestion.name; + tr.insertText(linkText, matchStart); + + // Add the wikilink mark with the FULL PATH + const mark = tiptapEditor.schema.marks.wikilink.create({ + 'data-note': suggestion.path + }); + tr.addMark(matchStart, matchStart + linkText.length, mark); + + // Insert a space after the link and move cursor there + tr.insertText(' ', matchStart + linkText.length); + + // Remove the mark from the space + tr.removeMark(matchStart + linkText.length, matchStart + linkText.length + 1, mark); + + // Set cursor position after the space + tr.setSelection(state.selection.constructor.create(tr.doc, matchStart + linkText.length + 1)); + + tiptapEditor.view.dispatch(tr); + } + + hideAutocomplete(); + } @@ -114,6 +499,31 @@