fix: repair mobile wikilink autocomplete

This commit is contained in:
lucaronin
2026-05-14 11:16:43 +02:00
parent 456d6f9887
commit 97f6a8c2b4
8 changed files with 137 additions and 16 deletions

View File

@@ -3,7 +3,7 @@ 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 { parseEditorMessage, type MobileEditorWikilinkFrame } from './mobileEditorMessages'
import type { MobileNote } from './mobileNoteProjection'
import { createMobileEditorDraft, type MobileEditorDraft } from './mobileEditorDraft'
import {
@@ -31,6 +31,7 @@ export function MobileEditorAdapter({
const document = useMemo(() => createMobileEditorDocument(note), [note])
const initialContent = useMemo(() => createMobileEditorHtml(document), [document])
const [wikilinkQuery, setWikilinkQuery] = useState<string | null>(null)
const [wikilinkFrame, setWikilinkFrame] = useState<MobileEditorWikilinkFrame | null>(null)
const draftTargetRef = useRef({ note, onDraftChange })
useEffect(() => {
draftTargetRef.current = { note, onDraftChange }
@@ -56,6 +57,7 @@ export function MobileEditorAdapter({
}
if (message.type === 'wikilinkQuery') {
setWikilinkQuery(message.query)
setWikilinkFrame(message.frame)
return
}
if (message.type === 'listIndent') {
@@ -84,10 +86,12 @@ export function MobileEditorAdapter({
</View>
<MobileEditorWikilinkSuggestions
excludeNoteId={note.id}
frame={wikilinkFrame}
notes={notes}
onSelectNote={(targetNote) => {
editor.insertWikilink({ label: targetNote.title, target: targetNote.id })
setWikilinkQuery(null)
setWikilinkFrame(null)
}}
query={wikilinkQuery}
/>

View File

@@ -1,15 +1,18 @@
import { useMemo } from 'react'
import { Pressable, Text, View } from 'react-native'
import type { MobileEditorWikilinkFrame } from './mobileEditorMessages'
import type { MobileNote } from './mobileNoteProjection'
import { mobileNoteSuggestions } from './mobileWikilinkAutocomplete'
import { styles } from './styles'
export function MobileEditorWikilinkSuggestions({
frame,
excludeNoteId,
notes,
onSelectNote,
query,
}: {
frame: MobileEditorWikilinkFrame | null
excludeNoteId: string
notes: MobileNote[]
onSelectNote: (note: MobileNote) => void
@@ -21,12 +24,12 @@ export function MobileEditorWikilinkSuggestions({
: mobileNoteSuggestions({ excludeNoteId, notes, query })
}, [excludeNoteId, notes, query])
if (!query || suggestions.length === 0) {
if (query === null || suggestions.length === 0) {
return null
}
return (
<View style={styles.rawEditorSuggestionMenu}>
<View style={[styles.rawEditorSuggestionMenu, suggestionMenuPosition(frame)]}>
{suggestions.map((suggestion) => (
<Pressable
key={suggestion.id}
@@ -40,3 +43,15 @@ export function MobileEditorWikilinkSuggestions({
</View>
)
}
function suggestionMenuPosition(frame: MobileEditorWikilinkFrame | null) {
if (!frame) {
return null
}
return {
bottom: undefined,
left: Math.max(16, frame.left),
top: Math.max(16, frame.bottom + 8),
}
}

View File

@@ -2,6 +2,30 @@ import { describe, expect, it } from 'vitest'
import { parseEditorMessage } from './mobileEditorMessages'
describe('mobile editor messages', () => {
it('parses empty wikilink queries with cursor geometry', () => {
expect(parseEditorMessage(JSON.stringify({
frame: { bottom: 124, left: 48 },
query: '',
type: 'wikilinkQuery',
}))).toEqual({
frame: { bottom: 124, left: 48 },
query: '',
type: 'wikilinkQuery',
})
})
it('ignores invalid wikilink query geometry', () => {
expect(parseEditorMessage(JSON.stringify({
frame: { bottom: '124', left: 48 },
query: 'roadmap',
type: 'wikilinkQuery',
}))).toEqual({
frame: null,
query: 'roadmap',
type: 'wikilinkQuery',
})
})
it('parses hardware Tab list indentation messages', () => {
expect(parseEditorMessage(JSON.stringify({
direction: 'in',

View File

@@ -2,7 +2,12 @@ export type MobileEditorMessage =
| { target: string; type: 'openWikilink' }
| { command: 'fileNewNote'; type: 'shortcut' }
| { direction: 'in' | 'out'; type: 'listIndent' }
| { query: string | null; type: 'wikilinkQuery' }
| { frame: MobileEditorWikilinkFrame | null; query: string | null; type: 'wikilinkQuery' }
export type MobileEditorWikilinkFrame = {
bottom: number
left: number
}
export function parseEditorMessage(data: string): MobileEditorMessage | null {
try {
@@ -17,7 +22,7 @@ function normalizeEditorMessage(value: unknown): MobileEditorMessage | null {
return null
}
if (isWikilinkQueryMessage(value)) {
return { query: value.query, type: 'wikilinkQuery' }
return { frame: wikilinkFrame(value.frame), query: value.query, type: 'wikilinkQuery' }
}
if (isListIndentMessage(value)) {
return { direction: value.direction, type: 'listIndent' }
@@ -35,6 +40,7 @@ function normalizeEditorMessage(value: unknown): MobileEditorMessage | null {
function isMessageRecord(value: unknown): value is {
command?: unknown
direction?: unknown
frame?: unknown
query?: unknown
target?: unknown
type?: unknown
@@ -43,9 +49,11 @@ function isMessageRecord(value: unknown): value is {
}
function isWikilinkQueryMessage(value: {
frame?: unknown
query?: unknown
type?: unknown
}): value is {
frame?: unknown
query: string | null
type: 'wikilinkQuery'
} {
@@ -53,6 +61,21 @@ function isWikilinkQueryMessage(value: {
&& (typeof value.query === 'string' || value.query === null)
}
function wikilinkFrame(value: unknown): MobileEditorWikilinkFrame | null {
if (!isFrameRecord(value)) {
return null
}
return { bottom: value.bottom, left: value.left }
}
function isFrameRecord(value: unknown): value is MobileEditorWikilinkFrame {
return typeof value === 'object'
&& value !== null
&& typeof (value as { bottom?: unknown }).bottom === 'number'
&& typeof (value as { left?: unknown }).left === 'number'
}
function isListIndentMessage(value: {
direction?: unknown
type?: unknown

View File

@@ -55,17 +55,47 @@ export const mobileEditorSetupScript = `
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));
var query = cleanWikilinkQuery(prefix.slice(start + 2));
if (query === null) return null;
return {
frame: wikilinkQueryFrame(selection),
query: query
};
}
function cleanWikilinkQuery(query) {
return query.indexOf("]]") >= 0 || query.indexOf("\\n") >= 0 ? null : query;
}
function emitWikilinkQuery() {
var activeQuery = activeWikilinkQuery();
postEditorMessage({
type: "wikilinkQuery",
query: activeWikilinkQuery()
frame: activeQuery ? activeQuery.frame : null,
query: activeQuery ? activeQuery.query : null
});
}
function wikilinkQueryFrame(selection) {
var range = selection.getRangeAt(0).cloneRange();
var rect = range.getBoundingClientRect();
if (hasVisibleFrame(rect)) {
return {
bottom: rect.bottom,
left: rect.left
};
}
return fallbackWikilinkQueryFrame(selection);
}
function fallbackWikilinkQueryFrame(selection) {
var container = selection.anchorNode && selection.anchorNode.parentElement;
var rect = container && container.getBoundingClientRect && container.getBoundingClientRect();
return {
bottom: rect ? rect.bottom : 0,
left: rect ? rect.left : 0
};
}
function hasVisibleFrame(rect) {
if (!rect) return false;
return Boolean(rect.bottom || rect.left);
}
function postEditorMessage(message) {
window.ReactNativeWebView && window.ReactNativeWebView.postMessage(JSON.stringify(message));
}

View File

@@ -4,6 +4,12 @@ import type { MobileNote } from './mobileNoteProjection'
describe('mobile wikilink autocomplete', () => {
it('detects an active wikilink trigger before the cursor', () => {
expect(activeMobileWikilinkQuery({ cursor: 6, markdown: 'See [[' })).toEqual({
end: 6,
query: '',
start: 4,
})
expect(activeMobileWikilinkQuery({ cursor: 12, markdown: 'See [[mobile' })).toEqual({
end: 12,
query: 'mobile',

View File

@@ -57,6 +57,8 @@ function insertWikilink({
label: string
target: string
}) {
editor.commands.focus()
const range = activeWikilinkRange(editor)
if (!range) {
return false
@@ -64,9 +66,8 @@ function insertWikilink({
return editor
.chain()
.focus()
.deleteRange(range)
.insertContent(`<a href="${wikilinkHref(target)}">${escapeHtml(label)}</a>`)
.insertContent(wikilinkTextNode({ label, target }))
.run()
}
@@ -94,11 +95,19 @@ 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;')
function wikilinkTextNode({
label,
target,
}: {
label: string
target: string
}) {
return {
marks: [{
attrs: { href: wikilinkHref(target) },
type: 'link',
}],
text: label,
type: 'text',
}
}

View File

@@ -505,3 +505,13 @@ Continue Phase 4 as a quality remediation pass before new feature work:
- `pnpm --filter @tolaria/mobile typecheck` passed.
- `pnpm --filter @tolaria/mobile test -- src/mobileEditorMessages.test.ts src/mobileShortcutCommands.test.ts src/mobileEditorDraft.test.ts` passed; the mobile test runner executed 61 files / 200 tests.
- `pnpm --filter @tolaria/mobile exec expo export --platform ios --output-dir /tmp/tolaria-mobile-export-tab-indent` passed.
## 2026-05-14 Wikilink Autocomplete Follow-Up
- Treated `[[` as an active empty wikilink query so suggestions appear immediately after the second bracket.
- Added WebView cursor geometry to wikilink query messages and anchored the suggestion menu below the active editor line instead of at the bottom of the document.
- Changed rich wikilink insertion to focus the TenTap editor, replace the active trigger range, and insert a real Tiptap link mark targeting `tolaria-note:`.
- Added message parsing coverage for empty wikilink queries with geometry and raw autocomplete coverage for the empty `[[` trigger.
- `pnpm --filter @tolaria/mobile typecheck` passed.
- `pnpm --filter @tolaria/mobile test -- src/mobileEditorMessages.test.ts src/mobileWikilinkAutocomplete.test.ts` passed; the mobile test runner executed 61 files / 202 tests.
- `pnpm --filter @tolaria/mobile exec expo export --platform ios --output-dir /tmp/tolaria-mobile-export-wikilink-autocomplete` passed.