Files
purewhiter--mobilegym/os/TextSelectionService.ts
wehub-resource-sync 2114b14ee0
Sync main into demo / sync (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:35:26 +08:00

207 lines
5.7 KiB
TypeScript

import { createVolatileOsStore } from './createOsStore';
import { realNow } from './TimeService';
type EditableEl = HTMLInputElement | HTMLTextAreaElement | HTMLElement;
export interface SelectionMenuPosition {
x: number;
y: number;
}
export interface TextSelectionState {
selectionMenuVisible: boolean;
selectionMenuPosition: SelectionMenuPosition | null;
selectedText: string;
targetElement: HTMLElement | null;
}
interface ClipboardAdapter {
copyText: (text: string) => void;
getText: () => string | null;
}
const clipboardAdapter: ClipboardAdapter = {
copyText: () => {},
getText: () => null,
};
const base = createVolatileOsStore<TextSelectionState>('textSelection', {
selectionMenuVisible: false,
selectionMenuPosition: null,
selectedText: '',
targetElement: null,
}, {
useImmer: false,
registerToServiceRegistry: false,
});
let suppressAutoMenuUntil = 0;
function nowMs(): number {
return realNow();
}
function isSuppressed(): boolean {
return nowMs() < suppressAutoMenuUntil;
}
function isEditableElement(el: any): el is EditableEl {
if (!el || !(el instanceof HTMLElement)) return false;
if (el instanceof HTMLInputElement) return !el.disabled && !el.readOnly && el.type !== 'hidden';
if (el instanceof HTMLTextAreaElement) return !el.disabled && !el.readOnly;
return el.isContentEditable === true;
}
function setNativeValue(el: HTMLInputElement | HTMLTextAreaElement, value: string) {
let proto: any = Object.getPrototypeOf(el);
while (proto) {
const desc = Object.getOwnPropertyDescriptor(proto, 'value');
if (desc && typeof desc.set === 'function') {
desc.set.call(el, value);
return;
}
proto = Object.getPrototypeOf(proto);
}
(el as any).value = value;
}
function dispatchInput(el: HTMLElement) {
try {
el.dispatchEvent(new Event('input', { bubbles: true }));
} catch {
// ignore
}
}
function insertTextAtCursor(el: EditableEl, text: string) {
if (el instanceof HTMLInputElement || el instanceof HTMLTextAreaElement) {
const value = String(el.value ?? '');
const start = typeof el.selectionStart === 'number' ? el.selectionStart : value.length;
const end = typeof el.selectionEnd === 'number' ? el.selectionEnd : start;
const next = value.slice(0, start) + text + value.slice(end);
setNativeValue(el, next);
try {
const caret = start + text.length;
el.setSelectionRange(caret, caret);
} catch {
// ignore
}
dispatchInput(el);
return;
}
try {
document.execCommand('insertText', false, text);
} catch {
el.textContent = (el.textContent || '') + text;
dispatchInput(el);
}
}
function selectAllText(el: EditableEl) {
if (el instanceof HTMLInputElement || el instanceof HTMLTextAreaElement) {
el.select();
return;
}
const range = document.createRange();
range.selectNodeContents(el);
const selection = window.getSelection();
if (selection) {
selection.removeAllRanges();
selection.addRange(range);
}
}
export function configureTextSelectionClipboardAdapter(adapter: Partial<ClipboardAdapter>) {
if (typeof adapter.copyText === 'function') clipboardAdapter.copyText = adapter.copyText;
if (typeof adapter.getText === 'function') clipboardAdapter.getText = adapter.getText;
}
export const TextSelectionService = {
getState: base.getState,
subscribe(listener: (state: TextSelectionState) => void): () => void {
listener(base.getState());
return base.subscribe(listener);
},
showSelectionMenu(x: number, y: number, selectedText: string, targetElement?: HTMLElement): void {
base.setState({
selectionMenuVisible: true,
selectionMenuPosition: { x, y },
selectedText: String(selectedText ?? ''),
targetElement: targetElement || null,
}, true);
},
updateSelectedText(selectedText: string): void {
const state = base.getState();
if (!state.selectionMenuVisible) return;
const next = String(selectedText ?? '');
if (next === state.selectedText) return;
base.setState({
...state,
selectedText: next,
}, true);
},
hideSelectionMenu(): void {
const state = base.getState();
if (!state.selectionMenuVisible) return;
base.setState({
...state,
selectionMenuVisible: false,
selectionMenuPosition: null,
selectedText: '',
targetElement: null,
}, true);
},
suppressAutoMenu(ms: number = 350): void {
const dur = Math.max(0, Math.min(2000, Math.floor(ms)));
suppressAutoMenuUntil = nowMs() + dur;
},
canAutoShowMenu(): boolean {
return !isSuppressed();
},
isMenuVisible(): boolean {
return base.getState().selectionMenuVisible;
},
getTargetElement(): HTMLElement | null {
return base.getState().targetElement;
},
performCopy(): void {
const state = base.getState();
if (!state.selectedText) return;
TextSelectionService.suppressAutoMenu();
clipboardAdapter.copyText(state.selectedText);
TextSelectionService.hideSelectionMenu();
},
performPaste(): void {
const state = base.getState();
const text = clipboardAdapter.getText();
if (text && state.targetElement && isEditableElement(state.targetElement)) {
state.targetElement.focus();
insertTextAtCursor(state.targetElement, text);
}
TextSelectionService.suppressAutoMenu();
TextSelectionService.hideSelectionMenu();
},
performSelectAll(): void {
const state = base.getState();
if (state.targetElement && isEditableElement(state.targetElement)) {
state.targetElement.focus();
selectAllText(state.targetElement);
}
TextSelectionService.suppressAutoMenu();
TextSelectionService.hideSelectionMenu();
},
};
export default TextSelectionService;