Files
native/src/components/RichTextEditor.tsx
T
Linus Rath bfa02f1177 feat: let the app rotate and work in landscape
The app was locked to portrait in app.config.js and the hand-kept
AndroidManifest.xml. Android 16 ignores that lock on screens 600 dp and
wider, so tablets and foldables rotated into layouts that were never
tested. This removes the lock in both places, keeps React state across
rotation and split-screen resizes (`smallestScreenSize` joins
`configChanges`), and fixes what broke in landscape:

- the thread pager sizes its pages from its own width and snaps back to
  the open message after a rotation instead of showing half a neighbour
- compose and the thread view share one keyboard tracker; a rotation
  with the keyboard open closes it, since Android sends no new keyboard
  frame and the old portrait one blanked the whole landscape composer
- in a short window with the keyboard up the composer hides the format
  bar and tightens its header so a few lines of text stay visible
- inputs with suggestions or context around them opt out of Android's
  full-screen landscape keyboard
- month views fit whole rows to the real height, keep the month across a
  remount, and the month dropdown re-measures and caps its height
- the week grid's header follows the remounted list after a rotation
- the event sheet's actions stay on screen in landscape
- every RN Modal allows all orientations, or iOS forces portrait
2026-10-15 01:45:23 +02:00

286 lines
9.7 KiB
TypeScript

import React from 'react';
import { StyleSheet, View, Platform } from 'react-native';
import { WebView, type WebViewMessageEvent } from 'react-native-webview';
import { useColors } from '../theme/colors';
import type { ThemePalette } from '../theme/tokens';
import { buildEditorHtml, MIN_EDITOR_HEIGHT } from '../lib/editor-html';
export type RichTextCommand =
| 'bold'
| 'italic'
| 'underline'
| 'strikeThrough'
| 'insertUnorderedList'
| 'insertOrderedList'
| 'formatBlock:H1'
| 'formatBlock:H2'
| 'formatBlock:BLOCKQUOTE'
| 'formatBlock:P'
| 'justifyLeft'
| 'justifyCenter'
| 'justifyRight'
| 'removeFormat'
| 'undo'
| 'redo'
// Text colour: a CSS colour after the colon, e.g. 'foreColor:#d97706'.
| `foreColor:${string}`
// Background colour, the same way; 'hiliteColor:transparent' removes it.
| `hiliteColor:${string}`;
export interface RichTextSelectionState {
bold: boolean;
italic: boolean;
underline: boolean;
strikeThrough: boolean;
ul: boolean;
ol: boolean;
blockquote: boolean;
h1: boolean;
h2: boolean;
alignLeft: boolean;
alignCenter: boolean;
alignRight: boolean;
link: boolean;
}
const EMPTY_STATE: RichTextSelectionState = {
bold: false, italic: false, underline: false, strikeThrough: false,
ul: false, ol: false, blockquote: false, h1: false, h2: false,
alignLeft: false, alignCenter: false, alignRight: false, link: false,
};
export interface RichTextEditorHandle {
exec(command: RichTextCommand): void;
insertLink(url: string, label?: string): void;
unsetLink(): void;
insertImage(src: string, cid?: string, alt?: string): void;
/** Insert an HTML fragment at the caret (template bodies, tables). */
insertHtml(html: string): void;
setHtml(html: string): void;
/**
* Read the live editor DOM content. Resolves with the current innerHTML or
* rejects after `timeoutMs` when the page script is dead or the bridge is
* broken — callers must treat that as "content unknown", never fall back to
* possibly-stale state (issue #9: doing so silently sent blank replies).
*/
getHtml(timeoutMs?: number): Promise<string>;
focus(): void;
/** Drop the caret, which closes the keyboard. */
blur(): void;
}
interface Props {
initialHtml?: string;
placeholder?: string;
/** Keep remote images and fonts from loading (read once, on mount). */
blockRemoteImages?: boolean;
onChange?: (html: string) => void;
onSelectionChange?: (state: RichTextSelectionState) => void;
onFocus?: () => void;
onBlur?: () => void;
}
const RichTextEditor = React.forwardRef<RichTextEditorHandle, Props>(function RichTextEditor(
{ initialHtml = '', placeholder = '', blockRemoteImages = false, onChange, onSelectionChange, onFocus, onBlur },
ref,
) {
const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]);
const webViewRef = React.useRef<WebView>(null);
const [height, setHeight] = React.useState(MIN_EDITOR_HEIGHT);
const onChangeRef = React.useRef(onChange);
const onSelectionRef = React.useRef(onSelectionChange);
const onFocusRef = React.useRef(onFocus);
const onBlurRef = React.useRef(onBlur);
onChangeRef.current = onChange;
onSelectionRef.current = onSelectionChange;
onFocusRef.current = onFocus;
onBlurRef.current = onBlur;
// Build the source HTML once. Theming changes won't auto-rebuild the page,
// which is fine: themes change rarely and the user can recompose if needed.
const html = React.useMemo(
() => buildEditorHtml({ initialHtml, placeholder, c, blockRemoteImages }),
// c reference changes per theme switch but we don't want to lose user
// edits on a theme change. Bake in the initial colors only.
// eslint-disable-next-line react-hooks/exhaustive-deps
[],
);
// Scripts injected before the page has run its inline script are lost, and
// the composer talks to the editor as soon as async data arrives (signature
// after the identities load, hydrated inline images). Queue until the page
// posts its first message, then replay in order.
const readyRef = React.useRef(false);
const queuedRef = React.useRef<string[]>([]);
const call = React.useCallback((script: string) => {
if (!readyRef.current) {
queuedRef.current.push(script);
return;
}
webViewRef.current?.injectJavaScript(`${script}; true;`);
}, []);
// Pending getHtml() readbacks, keyed by request id. Timers reject requests
// the page never answers (dead script / broken bridge).
const snapshotSeq = React.useRef(0);
const pendingSnapshots = React.useRef(
new Map<number, {
resolve: (html: string) => void;
reject: (err: Error) => void;
timer: ReturnType<typeof setTimeout>;
}>(),
);
React.useEffect(() => {
const pending = pendingSnapshots.current;
return () => {
for (const { timer, reject } of pending.values()) {
clearTimeout(timer);
reject(new Error('Editor unmounted'));
}
pending.clear();
};
}, []);
React.useImperativeHandle(ref, () => ({
exec: (command) => {
const sep = command.indexOf(':');
const name = sep === -1 ? command : command.slice(0, sep);
const value = sep === -1 ? undefined : command.slice(sep + 1);
if (value) call(`window.__rne && window.__rne.exec(${JSON.stringify(name)}, ${JSON.stringify(value)})`);
else call(`window.__rne && window.__rne.exec(${JSON.stringify(name)})`);
},
insertLink: (url, label) => {
call(`window.__rne && window.__rne.insertLink(${JSON.stringify(url)}, ${JSON.stringify(label ?? '')})`);
},
unsetLink: () => {
call(`window.__rne && window.__rne.unsetLink()`);
},
insertImage: (src, cid, alt) => {
call(`window.__rne && window.__rne.insertImage(${JSON.stringify(src)}, ${JSON.stringify(cid ?? '')}, ${JSON.stringify(alt ?? '')})`);
},
insertHtml: (html) => {
call(`window.__rne && window.__rne.insertHtml(${JSON.stringify(html)})`);
},
setHtml: (next) => {
call(`window.__rne && window.__rne.setHtml(${JSON.stringify(next)})`);
},
getHtml: (timeoutMs = 2000) =>
new Promise<string>((resolve, reject) => {
const id = ++snapshotSeq.current;
const timer = setTimeout(() => {
pendingSnapshots.current.delete(id);
reject(new Error('Editor did not answer'));
}, timeoutMs);
pendingSnapshots.current.set(id, { resolve, reject, timer });
call(`window.__rne && window.__rne.getHtml(${id})`);
}),
focus: () => {
call(`window.__rne && window.__rne.focus()`);
},
blur: () => {
call(`document.activeElement && document.activeElement.blur()`);
},
}), [call]);
const onMessage = (event: WebViewMessageEvent) => {
let data: { type: string; payload: unknown };
try {
data = JSON.parse(event.nativeEvent.data);
} catch {
return;
}
if (!readyRef.current) {
readyRef.current = true;
const queued = queuedRef.current;
queuedRef.current = [];
for (const script of queued) webViewRef.current?.injectJavaScript(`${script}; true;`);
}
switch (data.type) {
case 'change':
onChangeRef.current?.(typeof data.payload === 'string' ? data.payload : '');
break;
case 'height': {
const h = typeof data.payload === 'number' ? data.payload : Number(data.payload);
if (!Number.isNaN(h) && h > 0) setHeight(Math.max(MIN_EDITOR_HEIGHT, h));
break;
}
case 'htmlSnapshot': {
const p = data.payload as { id?: number; html?: string } | null;
const entry = typeof p?.id === 'number' ? pendingSnapshots.current.get(p.id) : undefined;
if (entry) {
pendingSnapshots.current.delete(p!.id!);
clearTimeout(entry.timer);
entry.resolve(typeof p?.html === 'string' ? p.html : '');
}
break;
}
case 'selection':
onSelectionRef.current?.((data.payload as RichTextSelectionState) ?? EMPTY_STATE);
break;
case 'focus':
onFocusRef.current?.();
break;
case 'blur':
onBlurRef.current?.();
break;
}
};
return (
<View style={[styles.container, { height }]}>
<WebView
ref={webViewRef}
originWhitelist={['about:blank']}
source={{ html }}
onMessage={onMessage}
javaScriptEnabled
domStorageEnabled={false}
scrollEnabled={false}
showsVerticalScrollIndicator={false}
showsHorizontalScrollIndicator={false}
keyboardDisplayRequiresUserAction={false}
hideKeyboardAccessoryView
setSupportMultipleWindows={false}
thirdPartyCookiesEnabled={false}
sharedCookiesEnabled={false}
allowFileAccess={false}
allowFileAccessFromFileURLs={false}
allowUniversalAccessFromFileURLs={false}
allowsBackForwardNavigationGestures={false}
incognito
cacheEnabled={false}
mixedContentMode="never"
androidLayerType={Platform.OS === 'android' ? 'hardware' : undefined}
style={styles.webview}
containerStyle={styles.webviewContainer}
onShouldStartLoadWithRequest={(request) => {
// Allow only the initial about:blank doc + data URIs we emit
// ourselves. Reject `about:*` (e.g. about:srcdoc) and every other
// scheme so a paste-injected anchor can't navigate the editor.
const url = request.url;
return !url || url === 'about:blank' || url.startsWith('data:');
}}
/>
</View>
);
});
export default RichTextEditor;
function makeStyles(c: ThemePalette) {
return StyleSheet.create({
container: {
width: '100%',
backgroundColor: c.background,
},
webview: {
backgroundColor: 'transparent',
flex: 1,
},
webviewContainer: {
backgroundColor: 'transparent',
},
});
}