From 3c5f932327073d9bc973ef7ee7dcd2695df72cd6 Mon Sep 17 00:00:00 2001 From: bvandeusen Date: Sun, 1 Mar 2026 12:55:06 -0500 Subject: [PATCH] Render and navigate [[wikilink]] syntax in notes - Add WikilinkSyntax (InlineSyntax) that parses [[Note Title]] into anchor elements with a wikilink:// href - wikilinkExtensionSet extends GFM with this syntax; shared by detail view and edit preview so rendering is consistent - NoteDetailScreen handles onTapLink: wikilink:// hrefs look up the target note by title (case-insensitive) and push its detail route; unresolved links show a "Note not found" snackbar - Add markdown as an explicit dependency (was previously transitive) Co-Authored-By: Claude Sonnet 4.6 --- lib/core/wikilink_syntax.dart | 24 +++++++++++++++++++++++ lib/screens/notes/note_detail_screen.dart | 24 +++++++++++++++++++++++ lib/screens/notes/note_edit_screen.dart | 6 +++++- pubspec.lock | 2 +- pubspec.yaml | 1 + 5 files changed, 55 insertions(+), 2 deletions(-) create mode 100644 lib/core/wikilink_syntax.dart diff --git a/lib/core/wikilink_syntax.dart b/lib/core/wikilink_syntax.dart new file mode 100644 index 0000000..fff30bf --- /dev/null +++ b/lib/core/wikilink_syntax.dart @@ -0,0 +1,24 @@ +import 'package:markdown/markdown.dart' as md; + +/// Parses `[[Note Title]]` wikilinks into anchor elements with a custom +/// `wikilink://` href, handled by the app's onTapLink callback. +class WikilinkSyntax extends md.InlineSyntax { + WikilinkSyntax() : super(r'\[\[([^\[\]\n]+)\]\]'); + + @override + bool onMatch(md.InlineParser parser, Match match) { + final title = match[1]!.trim(); + if (title.isEmpty) return false; + final anchor = md.Element.text('a', title); + anchor.attributes['href'] = 'wikilink://${Uri.encodeComponent(title)}'; + parser.addNode(anchor); + return true; + } +} + +/// GitHub-flavored markdown extended with [[wikilink]] support. +/// Use this as the extensionSet wherever note bodies are rendered. +final wikilinkExtensionSet = md.ExtensionSet( + md.ExtensionSet.gitHubFlavored.blockSyntaxes, + [WikilinkSyntax(), ...md.ExtensionSet.gitHubFlavored.inlineSyntaxes], +); diff --git a/lib/screens/notes/note_detail_screen.dart b/lib/screens/notes/note_detail_screen.dart index 2a8fb4d..ab68025 100644 --- a/lib/screens/notes/note_detail_screen.dart +++ b/lib/screens/notes/note_detail_screen.dart @@ -4,6 +4,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; import '../../core/constants.dart'; +import '../../core/wikilink_syntax.dart'; import '../../providers/notes_provider.dart'; class NoteDetailScreen extends ConsumerWidget { @@ -15,6 +16,22 @@ class NoteDetailScreen extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { final noteAsync = ref.watch(noteDetailProvider(noteId)); + final allNotes = ref.watch(notesProvider).valueOrNull ?? []; + + void navigateByTitle(String title) { + final matches = allNotes.where( + (n) => n.title.toLowerCase() == title.toLowerCase(), + ); + if (matches.isNotEmpty) { + context.push( + Routes.noteDetail.replaceFirst(':id', '${matches.first.id}'), + ); + } else { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Note not found: "$title"')), + ); + } + } return Scaffold( appBar: AppBar( @@ -71,6 +88,13 @@ class NoteDetailScreen extends ConsumerWidget { data: (note) => Markdown( data: note.body, selectable: true, + extensionSet: wikilinkExtensionSet, + onTapLink: (text, href, _) { + if (href == null) return; + if (href.startsWith('wikilink://')) { + navigateByTitle(Uri.decodeComponent(href.substring(11))); + } + }, ), ), ); diff --git a/lib/screens/notes/note_edit_screen.dart b/lib/screens/notes/note_edit_screen.dart index 1296c0f..969e674 100644 --- a/lib/screens/notes/note_edit_screen.dart +++ b/lib/screens/notes/note_edit_screen.dart @@ -4,6 +4,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; import '../../core/exceptions.dart'; +import '../../core/wikilink_syntax.dart'; import '../../providers/api_client_provider.dart'; import '../../providers/notes_provider.dart'; @@ -149,7 +150,10 @@ class _NoteEditScreenState extends ConsumerState { const Divider(), Expanded( child: _preview - ? Markdown(data: _contentController.text) + ? Markdown( + data: _contentController.text, + extensionSet: wikilinkExtensionSet, + ) : Padding( padding: const EdgeInsets.symmetric(horizontal: 16), diff --git a/pubspec.lock b/pubspec.lock index a2db2d8..4a97d08 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -353,7 +353,7 @@ packages: source: hosted version: "1.3.0" markdown: - dependency: transitive + dependency: "direct main" description: name: markdown sha256: "935e23e1ff3bc02d390bad4d4be001208ee92cc217cb5b5a6c19bc14aaa318c1" diff --git a/pubspec.yaml b/pubspec.yaml index a9c4a40..3d4978a 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -20,6 +20,7 @@ dependencies: path_provider: ^2.1.4 shared_preferences: ^2.3.2 flutter_markdown: ^0.7.3 + markdown: ^7.2.2 flutter_inappwebview: ^6.1.5 dev_dependencies: