feat: add rich mobile wikilink autocomplete

This commit is contained in:
lucaronin
2026-05-13 17:15:06 +02:00
parent 453c68a7a3
commit c76ef01eba
8 changed files with 346 additions and 100 deletions

View File

@@ -1,7 +1,9 @@
import { useEffect, useMemo, useRef } from 'react'
import { useEffect, useMemo, useRef, useState } from 'react'
import { KeyboardAvoidingView, Platform, View } from 'react-native'
import type { WebViewMessageEvent } from 'react-native-webview'
import { RichText, Toolbar, useEditorBridge } from '@10play/tentap-editor'
import { MobileEditorWikilinkSuggestions } from './MobileEditorWikilinkSuggestions'
import { parseEditorMessage } from './mobileEditorMessages'
import type { MobileNote } from './mobileNoteProjection'
import { createMobileEditorDraft, type MobileEditorDraft } from './mobileEditorDraft'
import {
@@ -9,6 +11,8 @@ import {
createMobileEditorHtml,
} from './mobileEditorDocument'
import { resolveMobileRelationshipNote } from './mobileRelationshipRefs'
import { mobileEditorBridgeExtensions } from './mobileWikilinkEditorBridge'
import { mobileEditorCss, mobileEditorSetupScript } from './mobileEditorWebViewSetup'
import { styles } from './styles'
export function MobileEditorAdapter({
@@ -26,12 +30,14 @@ export function MobileEditorAdapter({
}) {
const document = useMemo(() => createMobileEditorDocument(note), [note])
const initialContent = useMemo(() => createMobileEditorHtml(document), [document])
const [wikilinkQuery, setWikilinkQuery] = useState<string | null>(null)
const draftTargetRef = useRef({ note, onDraftChange })
useEffect(() => {
draftTargetRef.current = { note, onDraftChange }
}, [note, onDraftChange])
const editor = useEditorBridge({
avoidIosKeyboard: true,
bridgeExtensions: mobileEditorBridgeExtensions,
initialContent,
onChange: () => {
const draftTarget = draftTargetRef.current
@@ -48,6 +54,10 @@ export function MobileEditorAdapter({
onCreateNote()
return
}
if (message.type === 'wikilinkQuery') {
setWikilinkQuery(message.query)
return
}
if (message.type !== 'openWikilink') return
const targetNote = resolveMobileRelationshipNote({ notes, target: message.target })
@@ -68,6 +78,15 @@ export function MobileEditorAdapter({
<View style={styles.tentapEditor}>
<RichText key={note.id} editor={editor} onLoad={() => applyMobileEditorWebViewSetup(editor)} onMessage={handleMessage} />
</View>
<MobileEditorWikilinkSuggestions
excludeNoteId={note.id}
notes={notes}
onSelectNote={(targetNote) => {
editor.insertWikilink({ label: targetNote.title, target: targetNote.id })
setWikilinkQuery(null)
}}
query={wikilinkQuery}
/>
<KeyboardAvoidingView
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
style={styles.tentapToolbar}
@@ -82,97 +101,3 @@ function applyMobileEditorWebViewSetup(editor: ReturnType<typeof useEditorBridge
editor.injectCSS(mobileEditorCss, 'tolaria-mobile-editor')
editor.injectJS(mobileEditorSetupScript)
}
type MobileEditorMessage =
| { target: string; type: 'openWikilink' }
| { command: 'fileNewNote'; type: 'shortcut' }
function parseEditorMessage(data: string): MobileEditorMessage | null {
try {
const parsed = JSON.parse(data) as { command?: unknown; target?: unknown; type?: unknown }
if (parsed.type === 'openWikilink' && typeof parsed.target === 'string') {
return { target: parsed.target, type: 'openWikilink' }
}
if (parsed.type === 'shortcut' && parsed.command === 'fileNewNote') {
return { command: 'fileNewNote', type: 'shortcut' }
}
return null
} catch {
return null
}
}
const mobileEditorSetupScript = `
document.documentElement.lang = navigator.language || "en";
document.addEventListener("keydown", function(event) {
if ((event.metaKey || event.ctrlKey) && !event.altKey && String(event.key).toLowerCase() === "n") {
event.preventDefault();
window.ReactNativeWebView && window.ReactNativeWebView.postMessage(JSON.stringify({
type: "shortcut",
command: "fileNewNote"
}));
return;
}
if (event.key !== "Tab") return;
var selection = window.getSelection();
var node = selection && selection.anchorNode;
var editor = document.querySelector(".ProseMirror");
if (!editor || !node || !editor.contains(node.nodeType === 1 ? node : node.parentNode)) return;
event.preventDefault();
document.execCommand(event.shiftKey ? "outdent" : "indent");
}, true);
document.addEventListener("click", function(event) {
var link = event.target && event.target.closest && event.target.closest("a[data-tolaria-wikilink='true']");
if (!link) return;
event.preventDefault();
window.ReactNativeWebView && window.ReactNativeWebView.postMessage(JSON.stringify({
type: "openWikilink",
target: decodeURIComponent(String(link.getAttribute("href") || "").replace(/^tolaria-note:/, ""))
}));
}, true);
true;
`
const mobileEditorCss = `
* {
font-family: -apple-system, BlinkMacSystemFont, "SF Pro Text", "Segoe UI", sans-serif !important;
}
html,
body,
#root,
.ProseMirror {
color: #292825;
font-family: -apple-system, BlinkMacSystemFont, "SF Pro Text", "Segoe UI", sans-serif;
font-size: 18px;
line-height: 1.55;
}
.ProseMirror {
padding: 0;
}
.ProseMirror h1 {
font-family: inherit;
font-size: 42px;
font-weight: 760;
letter-spacing: 0;
line-height: 1.08;
margin: 18px 0 28px;
}
.ProseMirror p,
.ProseMirror li,
.ProseMirror blockquote {
font-family: inherit;
}
.ProseMirror a[data-tolaria-wikilink="true"] {
color: #3367f6;
font-weight: 650;
text-decoration: none;
border-radius: 5px;
background: #e8eeff;
padding: 1px 4px;
}
`

View File

@@ -0,0 +1,42 @@
import { useMemo } from 'react'
import { Pressable, Text, View } from 'react-native'
import type { MobileNote } from './mobileNoteProjection'
import { mobileNoteSuggestions } from './mobileWikilinkAutocomplete'
import { styles } from './styles'
export function MobileEditorWikilinkSuggestions({
excludeNoteId,
notes,
onSelectNote,
query,
}: {
excludeNoteId: string
notes: MobileNote[]
onSelectNote: (note: MobileNote) => void
query: string | null
}) {
const suggestions = useMemo(() => {
return query === null
? []
: mobileNoteSuggestions({ excludeNoteId, notes, query })
}, [excludeNoteId, notes, query])
if (!query || suggestions.length === 0) {
return null
}
return (
<View style={styles.rawEditorSuggestionMenu}>
{suggestions.map((suggestion) => (
<Pressable
key={suggestion.id}
onPress={() => onSelectNote(suggestion)}
style={({ pressed }) => [styles.rawEditorSuggestion, pressed ? styles.pressed : null]}
>
<Text style={styles.rawEditorSuggestionTitle}>{suggestion.title}</Text>
<Text style={styles.rawEditorSuggestionMeta}>{suggestion.id}</Text>
</Pressable>
))}
</View>
)
}

View File

@@ -0,0 +1,49 @@
export type MobileEditorMessage =
| { target: string; type: 'openWikilink' }
| { command: 'fileNewNote'; type: 'shortcut' }
| { query: string | null; type: 'wikilinkQuery' }
export function parseEditorMessage(data: string): MobileEditorMessage | null {
try {
return normalizeEditorMessage(JSON.parse(data))
} catch {
return null
}
}
function normalizeEditorMessage(value: unknown): MobileEditorMessage | null {
if (!isMessageRecord(value)) {
return null
}
if (isWikilinkQueryMessage(value)) {
return { query: value.query, type: 'wikilinkQuery' }
}
if (value.type === 'openWikilink' && typeof value.target === 'string') {
return { target: value.target, type: 'openWikilink' }
}
if (value.type === 'shortcut' && value.command === 'fileNewNote') {
return { command: 'fileNewNote', type: 'shortcut' }
}
return null
}
function isMessageRecord(value: unknown): value is {
command?: unknown
query?: unknown
target?: unknown
type?: unknown
} {
return typeof value === 'object' && value !== null
}
function isWikilinkQueryMessage(value: {
query?: unknown
type?: unknown
}): value is {
query: string | null
type: 'wikilinkQuery'
} {
return value.type === 'wikilinkQuery'
&& (typeof value.query === 'string' || value.query === null)
}

View File

@@ -0,0 +1,117 @@
export const mobileEditorSetupScript = `
document.documentElement.lang = navigator.language || "en";
document.addEventListener("keydown", function(event) {
if (isFileNewShortcut(event)) {
event.preventDefault();
postEditorMessage({ type: "shortcut", command: "fileNewNote" });
return;
}
if (!isTabInsideEditor(event)) return;
event.preventDefault();
document.execCommand(event.shiftKey ? "outdent" : "indent");
}, true);
document.addEventListener("click", function(event) {
var link = event.target && event.target.closest && event.target.closest("a[href^='tolaria-note:']");
if (!link) return;
event.preventDefault();
postEditorMessage({
type: "openWikilink",
target: decodeURIComponent(String(link.getAttribute("href") || "").replace(/^tolaria-note:/, ""))
});
}, true);
function isFileNewShortcut(event) {
return (event.metaKey || event.ctrlKey)
&& !event.altKey
&& String(event.key).toLowerCase() === "n";
}
function isTabInsideEditor(event) {
if (event.key !== "Tab") return false;
var selection = window.getSelection();
var node = selection && selection.anchorNode;
return Boolean(node && containingEditor(node));
}
function containingEditor(node) {
var editor = document.querySelector(".ProseMirror");
var container = node.nodeType === 1 ? node : node.parentNode;
return editor && container && editor.contains(container);
}
function activeWikilinkQuery() {
var selection = window.getSelection();
if (!isCollapsedTextSelection(selection)) return null;
if (!containingEditor(selection.anchorNode)) return null;
return wikilinkQueryBeforeCursor(selection);
}
function isCollapsedTextSelection(selection) {
return Boolean(selection
&& selection.anchorNode
&& selection.anchorNode.nodeType === 3
&& selection.rangeCount > 0
&& selection.isCollapsed);
}
function wikilinkQueryBeforeCursor(selection) {
var prefix = String(selection.anchorNode.textContent || "").slice(0, selection.anchorOffset);
var start = prefix.lastIndexOf("[[");
if (start < 0) return null;
return cleanWikilinkQuery(prefix.slice(start + 2));
}
function cleanWikilinkQuery(query) {
return query.indexOf("]]") >= 0 || query.indexOf("\\n") >= 0 ? null : query;
}
function emitWikilinkQuery() {
postEditorMessage({
type: "wikilinkQuery",
query: activeWikilinkQuery()
});
}
function postEditorMessage(message) {
window.ReactNativeWebView && window.ReactNativeWebView.postMessage(JSON.stringify(message));
}
document.addEventListener("keyup", emitWikilinkQuery, true);
document.addEventListener("mouseup", emitWikilinkQuery, true);
document.addEventListener("selectionchange", emitWikilinkQuery, true);
true;
`
export const mobileEditorCss = `
* {
font-family: -apple-system, BlinkMacSystemFont, "SF Pro Text", "Segoe UI", sans-serif !important;
}
html,
body,
#root,
.ProseMirror {
color: #292825;
font-family: -apple-system, BlinkMacSystemFont, "SF Pro Text", "Segoe UI", sans-serif;
font-size: 18px;
line-height: 1.55;
}
.ProseMirror {
padding: 0;
}
.ProseMirror h1 {
font-family: inherit;
font-size: 42px;
font-weight: 760;
letter-spacing: 0;
line-height: 1.08;
margin: 18px 0 28px;
}
.ProseMirror p,
.ProseMirror li,
.ProseMirror blockquote {
font-family: inherit;
}
.ProseMirror a[href^="tolaria-note:"] {
color: #3367f6;
font-weight: 650;
text-decoration: none;
border-radius: 5px;
background: #e8eeff;
padding: 1px 4px;
}
`

View File

@@ -0,0 +1,104 @@
import {
BridgeExtension,
LinkBridge,
TenTapStartKit,
} from '@10play/tentap-editor'
import type { Editor } from '@tiptap/core'
type InsertWikilinkMessage = {
payload: {
label: string
target: string
}
type: 'insert-wikilink'
}
type InsertWikilinkCommand = {
insertWikilink: (payload: InsertWikilinkMessage['payload']) => void
}
declare module '@10play/tentap-editor' {
interface EditorBridge {
insertWikilink: InsertWikilinkCommand['insertWikilink']
}
}
export const mobileEditorBridgeExtensions = [
...TenTapStartKit,
LinkBridge.configureExtension({
autolink: true,
openOnClick: false,
protocols: ['tolaria-note'],
}),
new BridgeExtension<unknown, InsertWikilinkCommand, InsertWikilinkMessage>({
forceName: 'tolaria-wikilink',
onBridgeMessage: (editor, message) => {
if (message.type !== 'insert-wikilink') {
return false
}
return insertWikilink({ editor, ...message.payload })
},
extendEditorInstance: (sendBridgeMessage) => ({
insertWikilink: (payload) => sendBridgeMessage({
payload,
type: 'insert-wikilink',
}),
}),
}),
]
function insertWikilink({
editor,
label,
target,
}: {
editor: Editor
label: string
target: string
}) {
const range = activeWikilinkRange(editor)
if (!range) {
return false
}
return editor
.chain()
.focus()
.deleteRange(range)
.insertContent(`<a href="${wikilinkHref(target)}">${escapeHtml(label)}</a>`)
.run()
}
function activeWikilinkRange(editor: Editor) {
const { from } = editor.state.selection
const textStart = Math.max(0, from - 200)
const textBefore = editor.state.doc.textBetween(textStart, from, '\n', '\n')
const triggerOffset = textBefore.lastIndexOf('[[')
if (triggerOffset < 0) {
return null
}
const query = textBefore.slice(triggerOffset + 2)
if (query.includes(']]') || query.includes('\n')) {
return null
}
return {
from: textStart + triggerOffset,
to: from,
}
}
function wikilinkHref(target: string) {
return `tolaria-note:${encodeURIComponent(target.trim())}`
}
function escapeHtml(value: string) {
return value
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;')
}

View File

@@ -1,6 +1,6 @@
# Mobile Progress
Last updated: 2026-05-05
Last updated: 2026-05-13
This file is the resumable working log for Tolaria mobile. The strategy and roadmap live in [MOBILE_STRATEGY.md](./MOBILE_STRATEGY.md); this file records the current execution state.
@@ -479,3 +479,12 @@ Continue Phase 4 as a quality remediation pass before new feature work:
- `pnpm --filter @tolaria/mobile test` passed: 59 files / 194 tests.
- `pnpm --filter @tolaria/mobile exec expo install --check --json` passed.
- `pnpm --filter @tolaria/mobile exec expo export --platform ios --output-dir /tmp/tolaria-mobile-export-quality-pass` passed.
## 2026-05-13 Rich Wikilink Follow-Up
- Added a TenTap bridge extension for mobile wikilink insertion, with the Link bridge configured to preserve `tolaria-note:` links.
- Added a rich-editor `[[` suggestion overlay backed by the same mobile note suggestion logic used by the raw editor.
- Split the editor WebView setup, message parser, suggestion UI, and wikilink bridge into focused files so each touched code file stays at CodeScene `10`.
- `pnpm --filter @tolaria/mobile typecheck` passed.
- `pnpm --filter @tolaria/mobile test` passed: 59 files / 194 tests.
- `pnpm --filter @tolaria/mobile exec expo export --platform ios --output-dir /tmp/tolaria-mobile-export-wikilink-pass` passed.

View File

@@ -89,7 +89,7 @@ Desktop reference:
Mobile current state:
- Raw editor detects `[[` and inserts aliased links, but suggestions are basic and do not share desktop ranking, aliases, keyboard behavior, or relative path semantics.
- Rich editor now renders persisted wikilinks as colored clickable links and routes taps back to mobile note navigation. Rich `[[` autocomplete is still incomplete and needs a TenTap extension rather than ad hoc DOM overlays.
- Rich editor renders persisted wikilinks as colored clickable links, routes taps back to mobile note navigation, and inserts selected `[[` suggestions through a TenTap bridge command.
- Relationship add currently reuses note suggestions but does not guarantee canonical wikilink output.
Required correction:
@@ -185,7 +185,7 @@ Required correction:
## Immediate Remediation Order
1. Finish the editor parity slice.
- Rich `[[` autocomplete through a TenTap extension.
- Rich `[[` autocomplete now exists; upgrade ranking, keyboard control, and create-missing-note behavior to desktop parity.
- Clickable wikilinks with path/alias resolution matching desktop.
- Hardware keyboard shortcuts and Tab behavior verified on physical iPad keyboard.
- No-crash behavior for empty notes, notes without H1, and title changes through breadcrumb.

View File

@@ -140,8 +140,8 @@ Current state:
- The supported serializer subset is growing and tested.
- Leading-H1 removal is preserved.
- Rich wikilinks render and navigate.
- Rich `[[` autocomplete still needs a proper TenTap extension.
- Rich wikilinks render, navigate, and insert through a TenTap bridge-backed `[[` suggestion flow.
- The current rich autocomplete is intentionally minimal; desktop-grade ranking, keyboard navigation, and create-missing-note behavior remain to be added.
### 4. Properties, Types, And Relationships