From c76ef01ebac0dd1378899c192a83e8ccdcbce464 Mon Sep 17 00:00:00 2001 From: lucaronin Date: Wed, 13 May 2026 17:15:06 +0200 Subject: [PATCH] feat: add rich mobile wikilink autocomplete --- apps/mobile/src/MobileEditorAdapter.tsx | 115 +++-------------- .../src/MobileEditorWikilinkSuggestions.tsx | 42 +++++++ apps/mobile/src/mobileEditorMessages.ts | 49 ++++++++ apps/mobile/src/mobileEditorWebViewSetup.ts | 117 ++++++++++++++++++ apps/mobile/src/mobileWikilinkEditorBridge.ts | 104 ++++++++++++++++ docs/MOBILE_PROGRESS.md | 11 +- docs/MOBILE_QUALITY_AUDIT.md | 4 +- docs/MOBILE_STRATEGY.md | 4 +- 8 files changed, 346 insertions(+), 100 deletions(-) create mode 100644 apps/mobile/src/MobileEditorWikilinkSuggestions.tsx create mode 100644 apps/mobile/src/mobileEditorMessages.ts create mode 100644 apps/mobile/src/mobileEditorWebViewSetup.ts create mode 100644 apps/mobile/src/mobileWikilinkEditorBridge.ts diff --git a/apps/mobile/src/MobileEditorAdapter.tsx b/apps/mobile/src/MobileEditorAdapter.tsx index f2a54bc4..2f292766 100644 --- a/apps/mobile/src/MobileEditorAdapter.tsx +++ b/apps/mobile/src/MobileEditorAdapter.tsx @@ -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(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({ applyMobileEditorWebViewSetup(editor)} onMessage={handleMessage} /> + { + editor.insertWikilink({ label: targetNote.title, target: targetNote.id }) + setWikilinkQuery(null) + }} + query={wikilinkQuery} + /> 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 ( + + {suggestions.map((suggestion) => ( + onSelectNote(suggestion)} + style={({ pressed }) => [styles.rawEditorSuggestion, pressed ? styles.pressed : null]} + > + {suggestion.title} + {suggestion.id} + + ))} + + ) +} diff --git a/apps/mobile/src/mobileEditorMessages.ts b/apps/mobile/src/mobileEditorMessages.ts new file mode 100644 index 00000000..ee37762f --- /dev/null +++ b/apps/mobile/src/mobileEditorMessages.ts @@ -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) +} diff --git a/apps/mobile/src/mobileEditorWebViewSetup.ts b/apps/mobile/src/mobileEditorWebViewSetup.ts new file mode 100644 index 00000000..0bf804e3 --- /dev/null +++ b/apps/mobile/src/mobileEditorWebViewSetup.ts @@ -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; + } +` diff --git a/apps/mobile/src/mobileWikilinkEditorBridge.ts b/apps/mobile/src/mobileWikilinkEditorBridge.ts new file mode 100644 index 00000000..90f8dbed --- /dev/null +++ b/apps/mobile/src/mobileWikilinkEditorBridge.ts @@ -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({ + 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(`${escapeHtml(label)}`) + .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, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, ''') +} diff --git a/docs/MOBILE_PROGRESS.md b/docs/MOBILE_PROGRESS.md index 6b62515f..3aebfec7 100644 --- a/docs/MOBILE_PROGRESS.md +++ b/docs/MOBILE_PROGRESS.md @@ -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. diff --git a/docs/MOBILE_QUALITY_AUDIT.md b/docs/MOBILE_QUALITY_AUDIT.md index d6e4176e..97588549 100644 --- a/docs/MOBILE_QUALITY_AUDIT.md +++ b/docs/MOBILE_QUALITY_AUDIT.md @@ -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. diff --git a/docs/MOBILE_STRATEGY.md b/docs/MOBILE_STRATEGY.md index a0be0f42..21a7e464 100644 --- a/docs/MOBILE_STRATEGY.md +++ b/docs/MOBILE_STRATEGY.md @@ -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