From a175e93484c03b02a4ae50747e624743092551ba Mon Sep 17 00:00:00 2001 From: Henriette Darge Date: Mon, 2 Mar 2026 10:55:13 +0100 Subject: [PATCH] Remember the click position when activating an inplaceEditField --- .../app/shared/helpers/ckeditor-helpers.ts | 19 ++++++ .../controllers/ckeditor-focus.controller.ts | 44 +++++++++++-- .../dynamic/inplace-edit.controller.ts | 63 +++++++++++++++++++ 3 files changed, 122 insertions(+), 4 deletions(-) diff --git a/frontend/src/app/shared/helpers/ckeditor-helpers.ts b/frontend/src/app/shared/helpers/ckeditor-helpers.ts index b731d1f7ccb4..e13259f0f9f4 100644 --- a/frontend/src/app/shared/helpers/ckeditor-helpers.ts +++ b/frontend/src/app/shared/helpers/ckeditor-helpers.ts @@ -30,6 +30,25 @@ import { ICKEditorInstance } from 'core-app/shared/components/editor/components/ckeditor/ckeditor.types'; +/** + * Returns a collapsed DOM Range at the given viewport coordinates. + * Prefers the modern `caretPositionFromPoint` API, falls back to the + * deprecated `caretRangeFromPoint` for browsers that do not support it yet. + */ +export function caretRangeFromPoint(x:number, y:number):Range|null { + if ('caretPositionFromPoint' in document) { + const pos = document.caretPositionFromPoint(x, y); + if (pos) { + const range = document.createRange(); + range.setStart(pos.offsetNode, pos.offset); + range.collapse(true); + return range; + } + return null; + } + return (document as Document).caretRangeFromPoint?.(x, y) ?? null; +} + export function retrieveCkEditorInstance(element:HTMLElement):ICKEditorInstance|undefined { return getEditableElement(element)?.ckeditorInstance; } diff --git a/frontend/src/stimulus/controllers/ckeditor-focus.controller.ts b/frontend/src/stimulus/controllers/ckeditor-focus.controller.ts index 5b77e20a7981..ae8399fb1630 100644 --- a/frontend/src/stimulus/controllers/ckeditor-focus.controller.ts +++ b/frontend/src/stimulus/controllers/ckeditor-focus.controller.ts @@ -29,7 +29,7 @@ */ import { Controller } from '@hotwired/stimulus'; -import { retrieveCkEditorInstance } from 'core-app/shared/helpers/ckeditor-helpers'; +import { caretRangeFromPoint, retrieveCkEditorInstance } from 'core-app/shared/helpers/ckeditor-helpers'; export default class extends Controller { static values = { autofocus: Boolean }; @@ -40,12 +40,48 @@ export default class extends Controller { connect():void { if (this.autofocusValue) { - setTimeout(() => { this.focusInput(); }, 100); + const x = parseInt(document.body.dataset.inplaceEditClickX ?? '', 10); + const y = parseInt(document.body.dataset.inplaceEditClickY ?? '', 10); + delete document.body.dataset.inplaceEditClickX; + delete document.body.dataset.inplaceEditClickY; + + const coords = !isNaN(x) && !isNaN(y) ? { x, y } : null; + setTimeout(() => { this.focusInput(coords); }, 100); } } - focusInput():void { + focusInput(coords?:{ x:number; y:number }|null):void { this.element.scrollIntoView({ block: 'center' }); - retrieveCkEditorInstance(this.editorTarget)?.editing.view.focus(); + const editor = retrieveCkEditorInstance(this.editorTarget); + if (!editor) return; + + if (coords) { + try { + const editableEl = this.editorTarget.querySelector('.ck-editor__editable_inline'); + // Convert coordinates stored relative to the inplace-edit container back to + // absolute viewport coordinates using the post-scroll position of the editor. + const rect = editableEl?.getBoundingClientRect(); + const domRange = rect + ? caretRangeFromPoint(rect.left + coords.x, rect.top + coords.y) + : null; + + if (domRange && editableEl?.contains(domRange.startContainer)) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any,@typescript-eslint/no-unsafe-assignment + const ck = editor as any; + const viewRange = ck.editing.view.domConverter.domRangeToView(domRange); + if (viewRange) { + const modelRange = ck.editing.mapper.toModelRange(viewRange); + ck.model.change((writer:any) => { writer.setSelection(modelRange); }); + editor.editing.view.focus(); + return; + } + } + // eslint-disable-next-line @typescript-eslint/no-unused-vars + } catch (e) { + // Fall through to default focus + } + } + + editor.editing.view.focus(); } } diff --git a/frontend/src/stimulus/controllers/dynamic/inplace-edit.controller.ts b/frontend/src/stimulus/controllers/dynamic/inplace-edit.controller.ts index 52a4ef7ec2e4..5e400855be8b 100644 --- a/frontend/src/stimulus/controllers/dynamic/inplace-edit.controller.ts +++ b/frontend/src/stimulus/controllers/dynamic/inplace-edit.controller.ts @@ -31,6 +31,7 @@ import { Controller } from '@hotwired/stimulus'; import { renderStreamMessage } from '@hotwired/turbo'; +import { caretRangeFromPoint } from 'core-app/shared/helpers/ckeditor-helpers'; export default class extends Controller { static values = { @@ -50,6 +51,10 @@ export default class extends Controller { this.boundFormDataHandler = (e:FormDataEvent) => this.appendStableKeySystemArguments(e); form.addEventListener('formdata', this.boundFormDataHandler); } + + if (this.element instanceof HTMLInputElement || this.element instanceof HTMLTextAreaElement) { + this.setCursorPosition(this.element); + } } disconnect() { @@ -72,6 +77,8 @@ export default class extends Controller { return; } + this.storeCursorPositionData(e); + const response = await fetch(this.urlValue, { method: 'GET', headers: { Accept: 'text/vnd.turbo-stream.html' }, @@ -138,4 +145,60 @@ export default class extends Controller { } return false; } + + // When the controller is connected to a text input (i.e. the edit field has + // just been rendered), apply the stored char offset so the cursor lands where + // the user clicked in the display field. + private setCursorPosition(element:HTMLElement):void { + const offset = parseInt(document.body.dataset.inplaceEditCharOffset ?? '', 10); + delete document.body.dataset.inplaceEditCharOffset; + if (!isNaN(offset)) { + // requestAnimationFrame ensures autofocus has run and the element is focused. + // setSelectionRange is not supported on all input types (e.g. number, date) — + // those will silently keep the browser's default cursor placement. + requestAnimationFrame(() => { + try { + (element as HTMLInputElement).setSelectionRange(offset, offset); + } catch { + // ignore + } + }); + } + } + + private storeCursorPositionData(e:Event):void { + if (e instanceof MouseEvent) { + const container = e.currentTarget as HTMLElement; + const rect = container.getBoundingClientRect(); + document.body.dataset.inplaceEditClickX = String(e.clientX - rect.left); + document.body.dataset.inplaceEditClickY = String(e.clientY - rect.top); + + // For plain-text inputs: store the char offset at the click position so + // the rendered text input can place the cursor accurately via setSelectionRange. + const range = caretRangeFromPoint(e.clientX, e.clientY); + if (range && container.contains(range.startContainer)) { + document.body.dataset.inplaceEditCharOffset = String( + this.getCharOffset(container, range.startContainer, range.startOffset), + ); + } else { + delete document.body.dataset.inplaceEditCharOffset; + } + } else { + delete document.body.dataset.inplaceEditClickX; + delete document.body.dataset.inplaceEditClickY; + delete document.body.dataset.inplaceEditCharOffset; + } + } + + private getCharOffset(root:Element, targetNode:Node, targetOffset:number):number { + let count = 0; + let node:Node|null; + const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT); + + while ((node = walker.nextNode())) { + if (node === targetNode) return count + targetOffset; + count += (node as Text).length; + } + return count; + } }