Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions frontend/src/app/shared/helpers/ckeditor-helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Comment on lines +38 to +50
Copy link

Copilot AI Apr 22, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

document.caretPositionFromPoint and document.caretRangeFromPoint are not part of the standard DOM typings in many TypeScript setups. As written, this will typically fail to compile (Property 'caretPositionFromPoint' does not exist on type 'Document', same for caretRangeFromPoint). Use a typed cast (e.g., const doc = document as { caretPositionFromPoint?: ...; caretRangeFromPoint?: ... }) or add a local/global type augmentation so the helper remains type-safe without any errors.

Copilot uses AI. Check for mistakes.

export function retrieveCkEditorInstance(element:HTMLElement):ICKEditorInstance|undefined {
return getEditableElement(element)?.ckeditorInstance;
}
Expand Down
44 changes: 40 additions & 4 deletions frontend/src/stimulus/controllers/ckeditor-focus.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 };
Expand All @@ -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;
Comment thread
HDinger marked this conversation as resolved.
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');
Comment thread
HDinger marked this conversation as resolved.
// 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();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand All @@ -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() {
Expand All @@ -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' },
Expand Down Expand Up @@ -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 {
Comment on lines +152 to +162
Copy link

Copilot AI Apr 22, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

setCursorPosition is only called with HTMLInputElement | HTMLTextAreaElement, but the method signature accepts HTMLElement and then type-asserts to HTMLInputElement to call setSelectionRange. Tighten the parameter type to HTMLInputElement | HTMLTextAreaElement (and avoid the cast) so TypeScript can enforce correct usage and autocomplete the proper API surface.

Copilot uses AI. Check for mistakes.
// 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;
}
}
Loading