diff --git a/apps/mobile/src/MobileEditorAdapter.tsx b/apps/mobile/src/MobileEditorAdapter.tsx index 24581f1f..aa26dd84 100644 --- a/apps/mobile/src/MobileEditorAdapter.tsx +++ b/apps/mobile/src/MobileEditorAdapter.tsx @@ -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(null) + const [wikilinkFrame, setWikilinkFrame] = useState(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({ { editor.insertWikilink({ label: targetNote.title, target: targetNote.id }) setWikilinkQuery(null) + setWikilinkFrame(null) }} query={wikilinkQuery} /> diff --git a/apps/mobile/src/MobileEditorWikilinkSuggestions.tsx b/apps/mobile/src/MobileEditorWikilinkSuggestions.tsx index 56258787..34f59423 100644 --- a/apps/mobile/src/MobileEditorWikilinkSuggestions.tsx +++ b/apps/mobile/src/MobileEditorWikilinkSuggestions.tsx @@ -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 ( - + {suggestions.map((suggestion) => ( ) } + +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), + } +} diff --git a/apps/mobile/src/mobileEditorMessages.test.ts b/apps/mobile/src/mobileEditorMessages.test.ts index 958a5a36..813447ed 100644 --- a/apps/mobile/src/mobileEditorMessages.test.ts +++ b/apps/mobile/src/mobileEditorMessages.test.ts @@ -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', diff --git a/apps/mobile/src/mobileEditorMessages.ts b/apps/mobile/src/mobileEditorMessages.ts index 645cad8e..2a82ff22 100644 --- a/apps/mobile/src/mobileEditorMessages.ts +++ b/apps/mobile/src/mobileEditorMessages.ts @@ -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 diff --git a/apps/mobile/src/mobileEditorWebViewSetup.ts b/apps/mobile/src/mobileEditorWebViewSetup.ts index eebbc361..7c5f83d5 100644 --- a/apps/mobile/src/mobileEditorWebViewSetup.ts +++ b/apps/mobile/src/mobileEditorWebViewSetup.ts @@ -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)); } diff --git a/apps/mobile/src/mobileWikilinkAutocomplete.test.ts b/apps/mobile/src/mobileWikilinkAutocomplete.test.ts index 77d1525a..edd93530 100644 --- a/apps/mobile/src/mobileWikilinkAutocomplete.test.ts +++ b/apps/mobile/src/mobileWikilinkAutocomplete.test.ts @@ -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', diff --git a/apps/mobile/src/mobileWikilinkEditorBridge.ts b/apps/mobile/src/mobileWikilinkEditorBridge.ts index 90f8dbed..3b397005 100644 --- a/apps/mobile/src/mobileWikilinkEditorBridge.ts +++ b/apps/mobile/src/mobileWikilinkEditorBridge.ts @@ -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(`${escapeHtml(label)}`) + .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, ''') +function wikilinkTextNode({ + label, + target, +}: { + label: string + target: string +}) { + return { + marks: [{ + attrs: { href: wikilinkHref(target) }, + type: 'link', + }], + text: label, + type: 'text', + } } diff --git a/docs/MOBILE_PROGRESS.md b/docs/MOBILE_PROGRESS.md index bbc1ab6e..084fcbc5 100644 --- a/docs/MOBILE_PROGRESS.md +++ b/docs/MOBILE_PROGRESS.md @@ -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.