feat: add rich mobile wikilink autocomplete
This commit is contained in:
@@ -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;
|
||||
}
|
||||
`
|
||||
|
||||
42
apps/mobile/src/MobileEditorWikilinkSuggestions.tsx
Normal file
42
apps/mobile/src/MobileEditorWikilinkSuggestions.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
49
apps/mobile/src/mobileEditorMessages.ts
Normal file
49
apps/mobile/src/mobileEditorMessages.ts
Normal 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)
|
||||
}
|
||||
117
apps/mobile/src/mobileEditorWebViewSetup.ts
Normal file
117
apps/mobile/src/mobileEditorWebViewSetup.ts
Normal 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;
|
||||
}
|
||||
`
|
||||
104
apps/mobile/src/mobileWikilinkEditorBridge.ts
Normal file
104
apps/mobile/src/mobileWikilinkEditorBridge.ts
Normal 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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''')
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user