extra functionality for [[ ]]. Gives the option for the user to create the note if it doesn't exist

extra functionality for [[ ]]. Gives the option for the user to create the note if it doesn't exist.
Reviewed-on: https://codeberg.org/ArkHost/HelixNotes/pulls/117
This commit is contained in:
blake
2026-06-16 15:09:08 +02:00
committed by ArkHost
parent 8454f2eca8
commit 6fcf7bdb5e
+121 -18
View File
@@ -1689,6 +1689,9 @@
// Navigation disambiguation: when clicking a wikilink with multiple matches // Navigation disambiguation: when clicking a wikilink with multiple matches
let wikiLinkNavDisambig = $state<{ entries: NoteTitleEntry[]; x: number; y: number } | null>(null); let wikiLinkNavDisambig = $state<{ entries: NoteTitleEntry[]; x: number; y: number } | null>(null);
let wikiLinkNavDisambigIndex = $state(0); let wikiLinkNavDisambigIndex = $state(0);
// Tracks the wiki-link mark under the cursor across transactions, used to detect
// when the cursor leaves a link so we can rename the linked note if needed.
let prevCursorWikiMark: any = null;
let wikiLinkFiltered = $derived.by(() => { let wikiLinkFiltered = $derived.by(() => {
// When disambiguating, show only the exact matches // When disambiguating, show only the exact matches
@@ -1767,6 +1770,55 @@
return entry.path.slice(vaultRoot.length + 1).replace(/\.md$/, ''); return entry.path.slice(vaultRoot.length + 1).replace(/\.md$/, '');
} }
// Scan the document for wiki-link marks whose visible text has been changed by
// the user (text ≠ stored title, and the link is not an explicit alias).
// Each match triggers a note rename and the mark is updated in-place.
async function checkWikiLinkRenames() {
if (!editor || !$appConfig?.enable_wiki_links || isLoadingNote) return;
type RenameItem = { pos: number; size: number; mark: any; newTitle: string };
const toRename: RenameItem[] = [];
editor.state.doc.descendants((node: any, pos: number) => {
if (!node.isText) return;
const wikiMark = node.marks.find((m: any) => m.type.name === 'wikiLink');
// Skip unresolved links (no path) and explicit aliases
if (!wikiMark || !wikiMark.attrs.path || wikiMark.attrs.aliased) return;
const text = (node.text as string || '').trim();
const storedTitle = (wikiMark.attrs.title as string || '').trim();
if (text && text !== storedTitle) {
toRename.push({ pos, size: node.nodeSize, mark: wikiMark, newTitle: text });
}
});
if (toRename.length === 0) return;
// Save the current note before renaming so the Rust backend has the latest content
await forceSave();
for (const item of toRename) {
try {
const newPath = await renameNote(item.mark.attrs.path, item.newTitle);
// Update the mark attrs in the editor (title + path) so the next save
// serialises [[NewTitle]] and doesn't re-trigger the rename check
if (editor) {
const wikiMarkType = editor.schema.marks.wikiLink;
const tr = editor.state.tr;
tr.addMark(
item.pos,
item.pos + item.size,
wikiMarkType.create({ title: item.newTitle, path: newPath, aliased: false }),
);
ignoreNextUpdate = true;
editor.view.dispatch(tr);
}
refreshWikiLinkTitles();
} catch (e) {
console.error('Failed to rename note from wiki-link edit:', e);
}
}
}
function insertWikiLink(entry: NoteTitleEntry, originalRef?: string) { function insertWikiLink(entry: NoteTitleEntry, originalRef?: string) {
if (!editor || !wikiLinkMenu) return; if (!editor || !wikiLinkMenu) return;
const { from } = wikiLinkMenu; const { from } = wikiLinkMenu;
@@ -1791,7 +1843,7 @@
.insertContent({ .insertContent({
type: 'text', type: 'text',
text: displayText, text: displayText,
marks: [{ type: 'wikiLink', attrs: { title: titleAttr, path: entry.path } }], marks: [{ type: 'wikiLink', attrs: { title: titleAttr, path: entry.path, aliased: displayText !== titleAttr } }],
}) })
.run(); .run();
}); });
@@ -1811,39 +1863,59 @@
const WikiLink = TiptapMark.create({ const WikiLink = TiptapMark.create({
name: 'wikiLink', name: 'wikiLink',
inclusive: false, // inclusive: true so typing at the end of a link extends the mark, allowing
// the user to edit a title in-place. Moving the cursor one step past the mark
// exits it, so typing plain text after a link still works normally.
inclusive: true,
excludes: 'link', excludes: 'link',
addAttributes() { addAttributes() {
return { return {
title: { default: null }, title: { default: null },
path: { default: null }, path: { default: null },
// aliased=true when the display text was explicitly different from the note
// title (e.g. [[Note|display]]). Aliased links are never used for rename detection.
aliased: { default: false },
}; };
}, },
parseHTML() { parseHTML() {
return [ return [
{ {
tag: 'span[data-wiki-link]', tag: 'span[data-wiki-link]',
getAttrs: (el: HTMLElement) => ({ getAttrs: (el: HTMLElement) => {
title: el.getAttribute('data-title') || null, const title = el.getAttribute('data-title') || null;
const text = el.textContent || '';
return {
title,
path: el.getAttribute('data-path') || null, path: el.getAttribute('data-path') || null,
}), // Detect alias from the HTML: if the visible text differs from the
// stored title the link was serialised as [[ref|display]]
aliased: el.getAttribute('data-aliased') === '1' || (!!title && text !== title),
};
},
}, },
{ {
tag: 'a[data-wiki-link]', tag: 'a[data-wiki-link]',
getAttrs: (el: HTMLElement) => ({ getAttrs: (el: HTMLElement) => {
title: el.getAttribute('data-title') || null, const title = el.getAttribute('data-title') || null;
const text = el.textContent || '';
return {
title,
path: el.getAttribute('data-path') || null, path: el.getAttribute('data-path') || null,
}), aliased: el.getAttribute('data-aliased') === '1' || (!!title && text !== title),
};
},
}, },
]; ];
}, },
renderHTML({ HTMLAttributes }: { HTMLAttributes: Record<string, any> }) { renderHTML({ HTMLAttributes }: { HTMLAttributes: Record<string, any> }) {
return ['span', { const attrs: Record<string, string> = {
'data-wiki-link': '', 'data-wiki-link': '',
'data-path': HTMLAttributes.path || '', 'data-path': HTMLAttributes.path || '',
'data-title': HTMLAttributes.title || '', 'data-title': HTMLAttributes.title || '',
class: 'wiki-link', class: 'wiki-link',
}, 0]; };
if (HTMLAttributes.aliased) attrs['data-aliased'] = '1';
return ['span', attrs, 0];
}, },
addProseMirrorPlugins() { addProseMirrorPlugins() {
return [ return [
@@ -1858,7 +1930,10 @@
event.preventDefault(); event.preventDefault();
event.stopPropagation(); event.stopPropagation();
const path = wikiLinkEl.getAttribute('data-path') || ''; const path = wikiLinkEl.getAttribute('data-path') || '';
const title = wikiLinkEl.getAttribute('data-title') || wikiLinkEl.textContent || ''; // Prefer displayed text over stored title — if the user edited the
// link text after the note was deleted, textContent reflects the
// new name they want, while data-title still holds the old one.
const title = wikiLinkEl.textContent || wikiLinkEl.getAttribute('data-title') || '';
navigateToWikiLink(path, title, event as MouseEvent); navigateToWikiLink(path, title, event as MouseEvent);
return true; return true;
} }
@@ -1948,7 +2023,7 @@
editor?.chain().focus().insertContent({ editor?.chain().focus().insertContent({
type: 'text', type: 'text',
text: display, text: display,
marks: [{ type: 'wikiLink', attrs: { title: noteRef, path: '' } }], marks: [{ type: 'wikiLink', attrs: { title: noteRef, path: '', aliased: display !== noteRef } }],
}).run(); }).run();
}); });
} }
@@ -1987,8 +2062,9 @@
textBefore += child.text!.slice(0, Math.min(child.nodeSize, cursorOffset - offset)); textBefore += child.text!.slice(0, Math.min(child.nodeSize, cursorOffset - offset));
} }
}); });
// Match [[ at start of line or after whitespace // Match [[ — also allow exactly one trailing ] so the menu stays open after
const match = textBefore.match(/\[\[([^\]]*)$/); // the first ] of ]] is typed, letting handleTextInput catch the closing ]]
const match = textBefore.match(/\[\[([^\]]*)\]?$/);
if (!match) { if (!match) {
closeWikiLinkMenu(); closeWikiLinkMenu();
return; return;
@@ -2050,20 +2126,25 @@
wikiLinkNavDisambigIndex = 0; wikiLinkNavDisambigIndex = 0;
return; return;
} else { } else {
// Offer to create the note (use clean title, not the anchor ref) // Create the note (use clean title, not the anchor ref)
const cleanTitle = noteTitle.includes('/') ? noteTitle.split('/').pop()! : noteTitle; const cleanTitle = noteTitle.includes('/') ? noteTitle.split('/').pop()! : noteTitle;
const notebookRel = $activeNotePath const notebookRel = $activeNotePath
? $activeNotePath.substring(($appConfig?.active_vault?.length ?? 0) + 1).split('/').slice(0, -1).join('/') ? $activeNotePath.substring(($appConfig?.active_vault?.length ?? 0) + 1).split('/').slice(0, -1).join('/')
: null; : null;
try { try {
// Save the current note before navigating away
await forceSave();
const { createNote } = await import('$lib/api'); const { createNote } = await import('$lib/api');
const newNote = await createNote(notebookRel || null, cleanTitle); const newNote = await createNote(notebookRel || null, cleanTitle);
// Refresh titles cache so the new note resolves on future clicks
refreshWikiLinkTitles();
// Navigate to the new note // Navigate to the new note
const content = await readNote(newNote.path); const content = await readNote(newNote.path);
$activeNote = { ...content, content: content.content }; $activeNote = { ...content, content: content.content };
$activeNotePath = newNote.path; $activeNotePath = newNote.path;
$editorDirty = false;
} catch (e) { } catch (e) {
console.error('Failed to create note:', e); console.error('Failed to create note from wiki-link:', e);
} }
return; return;
} }
@@ -2073,7 +2154,10 @@
$activeNote = { ...content, content: content.content }; $activeNote = { ...content, content: content.content };
$activeNotePath = path; $activeNotePath = path;
} catch (e) { } catch (e) {
console.error('Failed to navigate to wiki-link:', e); // Note at path no longer exists (deleted/moved). Refresh cache and
// retry as unresolved so the user can recreate it from the link.
await refreshWikiLinkTitles();
await navigateToWikiLink('', title, clickEvent);
} }
} }
@@ -3562,6 +3646,15 @@
// On mobile, only check menus when they're already open or user just typed trigger char // On mobile, only check menus when they're already open or user just typed trigger char
if (!isMobile || slashMenu || slashTypedByUser) updateSlashMenu(); if (!isMobile || slashMenu || slashTypedByUser) updateSlashMenu();
if (!isMobile || wikiLinkMenu || wikiLinkTypedByUser) updateWikiLinkMenu(); if (!isMobile || wikiLinkMenu || wikiLinkTypedByUser) updateWikiLinkMenu();
// Detect when cursor leaves a wiki-link mark and trigger rename check
if ($appConfig?.enable_wiki_links && editor) {
const curMarks = editor.state.selection.$from.marks();
const curWikiMark = curMarks.find((m: any) => m.type.name === 'wikiLink') ?? null;
if (prevCursorWikiMark && !curWikiMark) {
checkWikiLinkRenames();
}
prevCursorWikiMark = curWikiMark;
}
}, },
onUpdate: () => { onUpdate: () => {
if (ignoreNextUpdate || isLoadingNote) { if (ignoreNextUpdate || isLoadingNote) {
@@ -3702,7 +3795,7 @@
event.preventDefault(); event.preventDefault();
event.stopPropagation(); event.stopPropagation();
const path = wikiLinkEl.getAttribute('data-path') || ''; const path = wikiLinkEl.getAttribute('data-path') || '';
const title = wikiLinkEl.getAttribute('data-title') || wikiLinkEl.textContent || ''; const title = wikiLinkEl.textContent || wikiLinkEl.getAttribute('data-title') || '';
navigateToWikiLink(path, title, event); navigateToWikiLink(path, title, event);
return; return;
} }
@@ -4590,8 +4683,18 @@
}; };
}); });
// Refresh wiki-link title cache whenever vault files change so that deleted
// notes become unresolved and newly created notes become resolvable.
let unlistenFileChange: (() => void) | null = null;
if ($appConfig?.enable_wiki_links) {
listen('file-changed', () => {
if ($appConfig?.enable_wiki_links) refreshWikiLinkTitles();
}).then(fn => { unlistenFileChange = fn; });
}
onDestroy(() => { onDestroy(() => {
destroyEditor(); destroyEditor();
unlistenFileChange?.();
}); });
</script> </script>