From 0a455607e5f8b37b78b6ab2e3c64450b450aaf15 Mon Sep 17 00:00:00 2001 From: Agentriel Date: Mon, 11 May 2026 13:37:50 +0200 Subject: [PATCH] Add Android cursor drag support --- src/mobile/components/pages/DocEditPage.tsx | 18 +++ src/mobile/lib/codeMirrorTouchCursor.ts | 125 ++++++++++++++++++++ 2 files changed, 143 insertions(+) create mode 100644 src/mobile/lib/codeMirrorTouchCursor.ts diff --git a/src/mobile/components/pages/DocEditPage.tsx b/src/mobile/components/pages/DocEditPage.tsx index 98d65fda4c..e4a809491b 100644 --- a/src/mobile/components/pages/DocEditPage.tsx +++ b/src/mobile/components/pages/DocEditPage.tsx @@ -55,6 +55,7 @@ import { } from '../../../cloud/lib/subscription' import CustomizedMarkdownPreviewer from '../../../cloud/components/MarkdownView/CustomizedMarkdownPreviewer' import { scrollEditorToLine } from '../../../cloud/lib/hooks/editor/docEditor' +import { attachTouchCursorDrag } from '../../lib/codeMirrorTouchCursor' interface EditorProps { doc: SerializedDocWithSupplemental @@ -94,6 +95,7 @@ const Editor = ({ const [initialLoadDone, setInitialLoadDone] = useState(false) const [editorContent, setEditorContent] = useState('') const editorRef = useRef(null) + const touchCursorDragDisposerRef = useRef<() => void>() const fileUploadHandlerRef = useRef() const docRef = useRef('') const [shortcodeConvertMenu, setShortcodeConvertMenu] = useState<{ @@ -269,6 +271,9 @@ const Editor = ({ const bindCallback = useCallback((editor: CodeMirror.Editor) => { setEditorContent(editor.getValue()) editorRef.current = editor + if (osName === 'android' && touchCursorDragDisposerRef.current == null) { + touchCursorDragDisposerRef.current = attachTouchCursorDrag(editor) + } attachFileHandlerToCodeMirrorEditor(editor, { onFile: async (file) => { return fileUploadHandlerRef.current != null @@ -366,6 +371,15 @@ const Editor = ({ }) }, []) + useEffect(() => { + return () => { + if (touchCursorDragDisposerRef.current != null) { + touchCursorDragDisposerRef.current() + touchCursorDragDisposerRef.current = undefined + } + } + }, []) + useEffect(() => { if (editorRef.current != null) { editorRef.current.refresh() @@ -755,6 +769,10 @@ const StyledEditor = styled.div` height: 100%; position: relative; z-index: 0 !important; + &.CodeMirror-touch-cursor-dragging { + cursor: text; + user-select: none; + } .CodeMirror-hints { position: absolute; z-index: 10; diff --git a/src/mobile/lib/codeMirrorTouchCursor.ts b/src/mobile/lib/codeMirrorTouchCursor.ts new file mode 100644 index 0000000000..a554841013 --- /dev/null +++ b/src/mobile/lib/codeMirrorTouchCursor.ts @@ -0,0 +1,125 @@ +const longPressDelay = 350 +const movementTolerance = 8 + +interface TouchCursorState { + active: boolean + startX: number + startY: number + timer: number +} + +const getTouchDistance = (touch: Touch, state: TouchCursorState) => { + const x = touch.clientX - state.startX + const y = touch.clientY - state.startY + + return Math.sqrt(x * x + y * y) +} + +export function attachTouchCursorDrag(editor: CodeMirror.Editor) { + const wrapperElement = editor.getWrapperElement() + let state: TouchCursorState | null = null + + const clearState = () => { + if (state != null) { + window.clearTimeout(state.timer) + state = null + } + + wrapperElement.classList.remove('CodeMirror-touch-cursor-dragging') + } + + const moveCursorToTouch = (touch: Touch) => { + const pos = editor.coordsChar({ + left: touch.pageX, + top: touch.pageY, + }) + + editor.focus() + editor.setCursor(pos) + editor.scrollIntoView(pos, 30) + } + + const onTouchStart = (event: TouchEvent) => { + clearState() + + if (event.touches.length !== 1) { + return + } + + const touch = event.touches[0] + + state = { + active: false, + startX: touch.clientX, + startY: touch.clientY, + timer: window.setTimeout(() => { + if (state == null) { + return + } + + state.active = true + wrapperElement.classList.add('CodeMirror-touch-cursor-dragging') + moveCursorToTouch(touch) + }, longPressDelay), + } + } + + const onTouchMove = (event: TouchEvent) => { + if (state == null || event.touches.length !== 1) { + clearState() + return + } + + const touch = event.touches[0] + + if (!state.active) { + if (getTouchDistance(touch, state) > movementTolerance) { + clearState() + } + return + } + + event.preventDefault() + event.stopPropagation() + moveCursorToTouch(touch) + } + + const onTouchEnd = (event: TouchEvent) => { + if (state?.active) { + event.preventDefault() + event.stopPropagation() + } + + clearState() + } + + const onContextMenu = (event: MouseEvent) => { + if (state?.active) { + event.preventDefault() + event.stopPropagation() + } + } + + wrapperElement.addEventListener('touchstart', onTouchStart, { + passive: true, + }) + wrapperElement.addEventListener('touchmove', onTouchMove, { + passive: false, + }) + wrapperElement.addEventListener('touchend', onTouchEnd, { + passive: false, + }) + wrapperElement.addEventListener('touchcancel', onTouchEnd, { + passive: false, + }) + wrapperElement.addEventListener('contextmenu', onContextMenu) + + return () => { + clearState() + wrapperElement.removeEventListener('touchstart', onTouchStart) + wrapperElement.removeEventListener('touchmove', onTouchMove) + wrapperElement.removeEventListener('touchend', onTouchEnd) + wrapperElement.removeEventListener('touchcancel', onTouchEnd) + wrapperElement.removeEventListener('contextmenu', onContextMenu) + } +}