fix: repair mobile wikilink autocomplete
This commit is contained in:
@@ -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}
|
||||
/>
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''')
|
||||
function wikilinkTextNode({
|
||||
label,
|
||||
target,
|
||||
}: {
|
||||
label: string
|
||||
target: string
|
||||
}) {
|
||||
return {
|
||||
marks: [{
|
||||
attrs: { href: wikilinkHref(target) },
|
||||
type: 'link',
|
||||
}],
|
||||
text: label,
|
||||
type: 'text',
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user