From 685b5e13c05b67ebc45fb486895bb75d087692a4 Mon Sep 17 00:00:00 2001
From: Bruno Pagno <b.pagno@openproject.com>
Date: Mon, 16 Dec 2024 16:35:40 +0100
Subject: [PATCH 1/2] create a pattern autocompleter input

This commit introduces a new type of input field which can handle autocomplete with 'tokens', so that we can build
patterns like `Example pattern {{token}} value`.
---
 app/components/_index.sass                    |   1 +
 .../types/pattern_autocompleter.html.erb      | 119 +++++++
 .../types/pattern_autocompleter.rb            |  51 +++
 .../types/pattern_autocompleter.sass          |   5 +
 .../types/subject_configuration_form.rb       |   6 +-
 .../pattern-autocompleter.controller.ts       | 308 ++++++++++++++++++
 frontend/src/stimulus/setup.ts                |   2 +
 .../open_project/forms/dsl/input_methods.rb   |   4 +
 .../forms/dsl/pattern_autocompleter_input.rb  |  61 ++++
 9 files changed, 554 insertions(+), 3 deletions(-)
 create mode 100644 app/components/work_packages/types/pattern_autocompleter.html.erb
 create mode 100644 app/components/work_packages/types/pattern_autocompleter.rb
 create mode 100644 app/components/work_packages/types/pattern_autocompleter.sass
 create mode 100644 frontend/src/stimulus/controllers/pattern-autocompleter.controller.ts
 create mode 100644 lib/primer/open_project/forms/dsl/pattern_autocompleter_input.rb

diff --git a/app/components/_index.sass b/app/components/_index.sass
index 59f6c3cdfa43..5778221f836f 100644
--- a/app/components/_index.sass
+++ b/app/components/_index.sass
@@ -21,3 +21,4 @@
 @import "work_package_relations_tab/index_component"
 @import "users/hover_card_component"
 @import "enterprise_edition/banner_component"
+@import "work_packages/types/pattern_autocompleter"
diff --git a/app/components/work_packages/types/pattern_autocompleter.html.erb b/app/components/work_packages/types/pattern_autocompleter.html.erb
new file mode 100644
index 000000000000..9fbdc941b9ce
--- /dev/null
+++ b/app/components/work_packages/types/pattern_autocompleter.html.erb
@@ -0,0 +1,119 @@
+<%#-- copyright
+OpenProject is an open source project management software.
+Copyright (C) the OpenProject GmbH
+
+This program is free software; you can redistribute it and/or
+modify it under the terms of the GNU General Public License version 3.
+
+OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows:
+Copyright (C) 2006-2013 Jean-Philippe Lang
+Copyright (C) 2010-2013 the ChiliProject Team
+
+This program is free software; you can redistribute it and/or
+modify it under the terms of the GNU General Public License
+as published by the Free Software Foundation; either version 2
+of the License, or (at your option) any later version.
+
+This program is distributed in the hope that it will be useful,
+but WITHOUT ANY WARRANTY; without even the implied warranty of
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+GNU General Public License for more details.
+
+You should have received a copy of the GNU General Public License
+along with this program; if not, write to the Free Software
+Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
+
+See COPYRIGHT and LICENSE files for more details.
+
+++#%>
+
+<%=
+  content_tag(
+    :div,
+    class: "pattern-autocompleter",
+    "data-controller": "pattern-autocompleter",
+    "data-pattern-autocompleter-pattern-initial-value": @value
+  ) do
+%>
+  <%=
+    @input.builder.hidden_field(
+      name,
+      value: @value,
+      data: {
+        "pattern-autocompleter-target": "formInput"
+      }
+    )
+  %>
+
+  <template data-pattern-autocompleter-target="tokenTemplate">
+    <%=
+      render(
+        Primer::Beta::Label.new(
+          contenteditable: false,
+          tag: :span, display: :inline, scheme: :accent,
+          "data-role": :token
+        )
+      ) do
+    %>
+      <%= content_tag(:span, style: "cursor: default;", "data-role": "token-text") { "__VALUE__" } %>
+      <%=
+        render(
+          Primer::Beta::Octicon.new(
+            icon: :x, size: :xsmall, style: "cursor: pointer;margin: 0.2em;",
+            "data-action": "click->pattern-autocompleter#remove_token"
+          )
+        )
+      %>
+    <% end %>
+  </template>
+
+  <%= content_tag(:div, style: "position: relative;") do %>
+      <%=
+        render(
+          Primer::Beta::Octicon.new(
+            icon: "triangle-down",
+            size: :small,
+            style: "cursor: pointer;position: absolute;right: 1em;top: 0.5em;",
+            "data-action": "click->pattern-autocompleter#suggestions_toggle"
+          )
+        )
+      %>
+      <%=
+        render(
+          Primer::Box.new(
+            contenteditable: true,
+            border: true, border_radius: 2, p: 1,
+            style: "white-space: pre-wrap;",
+            "data-pattern-autocompleter-target": "content",
+            data: {
+              action: "keydown->pattern-autocompleter#input_keydown
+                       input->pattern-autocompleter#input_change
+                       mouseup->pattern-autocompleter#input_mouseup
+                       blur->pattern-autocompleter#input_blur"
+            }
+          )
+        )
+      %>
+  <% end %>
+  <%= render(Primer::Box.new(box_shadow: :medium, border_radius: 2)) do %>
+    <%=
+      render(
+        Primer::Alpha::ActionList.new(
+          role: :list,
+          hidden: true,
+          show_dividers: false,
+          "data-pattern-autocompleter-target": "suggestions"
+        )
+      ) do |component|
+        @suggestions.each_key do |key|
+          component.with_divider_content(key.to_s.humanize)
+          entries = @suggestions[key]
+          entries.each do |prop, label|
+            component.with_item(label:, data: select_item_action.merge({ value: prop }))
+          end
+          component.with_divider
+        end
+      end
+    %>
+  <% end %>
+<% end %>
diff --git a/app/components/work_packages/types/pattern_autocompleter.rb b/app/components/work_packages/types/pattern_autocompleter.rb
new file mode 100644
index 000000000000..6d19cc04105d
--- /dev/null
+++ b/app/components/work_packages/types/pattern_autocompleter.rb
@@ -0,0 +1,51 @@
+# frozen_string_literal: true
+
+# -- copyright
+# OpenProject is an open source project management software.
+# Copyright (C) 2024 the OpenProject GmbH
+#
+# This program is free software; you can redistribute it and/or
+# modify it under the terms of the GNU General Public License version 3.
+#
+# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows:
+# Copyright (C) 2006-2013 Jean-Philippe Lang
+# Copyright (C) 2010-2013 the ChiliProject Team
+#
+# This program is free software; you can redistribute it and/or
+# modify it under the terms of the GNU General Public License
+# as published by the Free Software Foundation; either version 2
+# of the License, or (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program; if not, write to the Free Software
+# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
+#
+# See COPYRIGHT and LICENSE files for more details.
+# ++
+
+module WorkPackages
+  module Types
+    class PatternAutocompleter < Primer::Forms::BaseComponent
+      delegate :name, to: :@input
+
+      def initialize(input:, value:, suggestions:)
+        super()
+        @input = input
+        @value = value
+        @suggestions = suggestions
+      end
+
+      def select_item_action
+        {
+          action: "click->pattern-autocompleter#suggestions_select",
+          role: "suggestion-item"
+        }
+      end
+    end
+  end
+end
diff --git a/app/components/work_packages/types/pattern_autocompleter.sass b/app/components/work_packages/types/pattern_autocompleter.sass
new file mode 100644
index 000000000000..e376442da3a0
--- /dev/null
+++ b/app/components/work_packages/types/pattern_autocompleter.sass
@@ -0,0 +1,5 @@
+.pattern-autocompleter
+  .selected
+    color: var(--list-item-hover--color)
+    background-color: var(--control-transparent-bgColor-hover)
+
diff --git a/app/forms/work_packages/types/subject_configuration_form.rb b/app/forms/work_packages/types/subject_configuration_form.rb
index 9faf6d55fae0..86dc58cdbdfc 100644
--- a/app/forms/work_packages/types/subject_configuration_form.rb
+++ b/app/forms/work_packages/types/subject_configuration_form.rb
@@ -57,13 +57,13 @@ def has_pattern?(type)
         end
 
         subject_form.group(data: { "admin--subject-configuration-target": "patternInput" }) do |toggleable_group|
-          toggleable_group.text_field(
+          toggleable_group.pattern_autocompleter(
             name: :subject_pattern,
             value: subject_pattern,
+            suggestions: ::Types::Patterns::TokenPropertyMapper.new.tokens_for_type(model),
             label: I18n.t("types.edit.subject_configuration.pattern.label"),
             caption: I18n.t("types.edit.subject_configuration.pattern.caption"),
-            required: true,
-            input_width: :large
+            required: true
           )
         end
 
diff --git a/frontend/src/stimulus/controllers/pattern-autocompleter.controller.ts b/frontend/src/stimulus/controllers/pattern-autocompleter.controller.ts
new file mode 100644
index 000000000000..d637da296f95
--- /dev/null
+++ b/frontend/src/stimulus/controllers/pattern-autocompleter.controller.ts
@@ -0,0 +1,308 @@
+/*
+ * -- copyright
+ * OpenProject is an open source project management software.
+ * Copyright (C) the OpenProject GmbH
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU General Public License version 3.
+ *
+ * OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows:
+ * Copyright (C) 2006-2013 Jean-Philippe Lang
+ * Copyright (C) 2010-2013 the ChiliProject Team
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU General Public License
+ * as published by the Free Software Foundation; either version 2
+ * of the License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
+ *
+ * See COPYRIGHT and LICENSE files for more details.
+ * ++
+ */
+
+import { Controller } from '@hotwired/stimulus';
+
+export default class PatternAutocompleterController extends Controller {
+  static targets = [
+    'tokenTemplate',
+    'content',
+    'formInput',
+    'suggestions',
+  ];
+
+  declare readonly tokenTemplateTarget:HTMLTemplateElement;
+  declare readonly contentTarget:HTMLElement;
+  declare readonly formInputTarget:HTMLInputElement;
+  declare readonly suggestionsTarget:HTMLElement;
+
+  static values = { patternInitial: String };
+  declare patternInitialValue:string;
+
+  // internal state
+  currentRange:Range|undefined = undefined;
+  selectedSuggestion:{ element:HTMLElement|null, index:number } = { element: null, index: 0 };
+
+  connect() {
+    this.contentTarget.innerHTML = this.toHtml(this.patternInitialValue) || ' ';
+  }
+
+  // Input field events
+  input_keydown(event:KeyboardEvent) {
+    // insert the selected suggestion
+    if (event.key === 'Enter') {
+      // prevent entering new line characters
+      event.preventDefault();
+
+      const selectedItem = this.suggestionsTarget.querySelector('.selected') as HTMLElement;
+      if (selectedItem) {
+        this.insertToken(this.createToken(selectedItem.dataset.value!));
+        this.clearSuggestionsFilter();
+      }
+    }
+
+    // move up and down the suggestions selection
+    if (event.key === 'ArrowUp') {
+      event.preventDefault();
+      this.selectSuggestionAt(this.selectedSuggestion.index - 1);
+    }
+    if (event.key === 'ArrowDown') {
+      event.preventDefault();
+      this.selectSuggestionAt(this.selectedSuggestion.index + 1);
+    }
+
+    // close the suggestions
+    if (event.key === 'Escape' || event.key === 'ArrowLeft' || event.key === 'ArrowRight') {
+      this.clearSuggestionsFilter();
+      this.hide(this.suggestionsTarget);
+    }
+
+    // update cursor
+    this.setRange();
+  }
+
+  input_change() {
+    // browsers insert a `<br>` tag on empty contenteditable elements so we need to cleanup
+    if (this.contentTarget.innerHTML === '<br>') {
+      this.contentTarget.innerHTML = ' ';
+    }
+
+    this.ensureSpacesAround();
+
+    // show suggestions for the current word
+    const word = this.currentWord();
+    if (word && word.length > 0) {
+      this.filterSuggestions(word);
+      this.selectSuggestionAt(0);
+      this.show(this.suggestionsTarget);
+    } else {
+      this.clearSuggestionsFilter();
+      this.hide(this.suggestionsTarget);
+    }
+
+    // update cursor
+    this.setRange();
+  }
+
+  input_mouseup() {
+    this.setRange();
+  }
+
+  input_focus() {
+    this.setRange();
+  }
+
+  input_blur() {
+    this.updateFormInputValue();
+    this.hide(this.suggestionsTarget);
+  }
+
+  // Autocomplete events
+  suggestions_select(event:PointerEvent) {
+    const target = event.currentTarget as HTMLElement;
+
+    if (target) {
+      this.insertToken(this.createToken(target.dataset.value!));
+      this.clearSuggestionsFilter();
+    }
+  }
+
+  suggestions_toggle() {
+    this.clearSuggestionsFilter();
+    if (this.suggestionsTarget.getAttribute('hidden')) {
+      this.show(this.suggestionsTarget);
+    } else {
+      this.hide(this.suggestionsTarget);
+    }
+  }
+
+  // Token events
+  remove_token(event:PointerEvent) {
+    const target = event.currentTarget as HTMLElement;
+
+    if (target) {
+      const tokenElement = target.closest('[data-role="token"]');
+      if (tokenElement) {
+        tokenElement.remove();
+      }
+
+      this.updateFormInputValue();
+    }
+  }
+
+  // internal methods
+  private updateFormInputValue():void {
+    this.formInputTarget.value = this.toBlueprint();
+  }
+
+  private ensureSpacesAround():void {
+    if (this.contentTarget.innerHTML.startsWith('<')) {
+      this.contentTarget.insertBefore(document.createTextNode(' '), this.contentTarget.children[0]);
+    }
+    if (this.contentTarget.innerHTML.endsWith('>')) {
+      this.contentTarget.appendChild(document.createTextNode(' '));
+    }
+  }
+
+  private setRange():void {
+    const selection = document.getSelection();
+    if (selection?.rangeCount) {
+      const range = selection.getRangeAt(0);
+      if (range.startContainer.parentNode === this.contentTarget) {
+        this.currentRange = range;
+      }
+    }
+  }
+
+  private insertToken(tokenElement:HTMLElement) {
+    if (this.currentRange) {
+      const targetNode = this.currentRange.startContainer;
+      const targetOffset = this.currentRange.startOffset;
+
+      let pos = targetOffset - 1;
+      while (pos > -1 && targetNode.textContent?.charAt(pos) !== ' ') { pos-=1; }
+
+      const wordRange = document.createRange();
+      wordRange.setStart(targetNode, pos + 1);
+      wordRange.setEnd(targetNode, targetOffset);
+
+      wordRange.deleteContents();
+      wordRange.insertNode(tokenElement);
+
+      const postRange = document.createRange();
+      postRange.setStartAfter(tokenElement);
+
+      const selection = document.getSelection();
+      selection?.removeAllRanges();
+      selection?.addRange(postRange);
+
+      this.updateFormInputValue();
+      this.setRange();
+
+      // clear suggestions
+      this.clearSuggestionsFilter();
+      this.hide(this.suggestionsTarget);
+    } else {
+      this.contentTarget.appendChild(tokenElement);
+    }
+  }
+
+  private currentWord():string|null {
+    const selection = document.getSelection();
+    if (selection) {
+      return (selection.anchorNode?.textContent?.slice(0, selection.anchorOffset)
+        .split(' ')
+        .pop() as string)
+        .toLowerCase();
+    }
+
+    return null;
+  }
+
+  private clearSuggestionsFilter():void {
+    const suggestionElements = this.suggestionsTarget.children;
+    for (let i = 0; i < suggestionElements.length; i+=1) {
+      this.show(suggestionElements[i] as HTMLElement);
+    }
+  }
+
+  private filterSuggestions(word:string):void {
+    const suggestionElements = this.suggestionsTarget.children;
+    for (let i = 0; i < suggestionElements.length; i+=1) {
+      const suggestionElement = suggestionElements[i] as HTMLElement;
+      if (!suggestionElement.dataset.value) { continue; }
+
+      if (suggestionElement.textContent?.trim().toLowerCase().includes(word) || suggestionElement.dataset.value.includes(word)) {
+        this.show(suggestionElement);
+      } else {
+        this.hide(suggestionElement);
+      }
+    }
+
+    // show autocomplete
+    this.show(this.suggestionsTarget);
+  }
+
+  private selectSuggestionAt(index:number):void {
+    if (this.selectedSuggestion.element) {
+      this.selectedSuggestion.element.classList.remove('selected');
+      this.selectedSuggestion.element = null;
+    }
+
+    const possibleTargets = this.suggestionsTarget.querySelectorAll('[data-role="suggestion-item"]:not([hidden])');
+    if (possibleTargets.length > 0) {
+      if (index < 0) { index += possibleTargets.length; }
+      index %= possibleTargets.length;
+      const element = possibleTargets[index];
+      element.classList.add('selected');
+      this.selectedSuggestion.element = element as HTMLElement;
+      this.selectedSuggestion.index = index;
+    }
+  }
+
+  private hide(el:HTMLElement):void {
+    el.setAttribute('hidden', 'hidden');
+  }
+
+  private show(el:HTMLElement):void {
+    el.removeAttribute('hidden');
+  }
+
+  private createToken(value:string):HTMLElement {
+    const target = this.tokenTemplateTarget.content?.cloneNode(true) as HTMLElement;
+    const contentElement = target.firstElementChild as HTMLElement;
+    (contentElement.querySelector('[data-role="token-text"]') as HTMLElement).innerText = value;
+    return contentElement;
+  }
+
+  private toHtml(blueprint:string):string {
+    let htmlValue = blueprint.replace(/{{([0-9A-Za-z_]+)}}/g, (_, token:string) => this.createToken(token).outerHTML);
+    if (htmlValue.startsWith('<')) { htmlValue = ` ${htmlValue}`; }
+    if (htmlValue.endsWith('>')) { htmlValue = `${htmlValue} `; }
+    return htmlValue;
+  }
+
+  private toBlueprint():string {
+    let result = '';
+    this.contentTarget.childNodes.forEach((node:Element) => {
+      if (node.nodeType === Node.TEXT_NODE) {
+        // Plain text node
+        result += node.textContent;
+      } else if (node.nodeType === Node.ELEMENT_NODE && (node as HTMLElement).dataset.role === 'token') {
+        // Token element
+        const tokenText = node.querySelector('[data-role="token-text"]');
+        if (tokenText) {
+            result += `{{${tokenText.textContent?.trim()}}}`;
+        }
+      }
+    });
+    return result.trim();
+  }
+}
diff --git a/frontend/src/stimulus/setup.ts b/frontend/src/stimulus/setup.ts
index b1b052b5e8c8..3a8ed1ad7a62 100644
--- a/frontend/src/stimulus/setup.ts
+++ b/frontend/src/stimulus/setup.ts
@@ -13,6 +13,7 @@ import OpShowWhenValueSelectedController from './controllers/show-when-value-sel
 import FlashController from './controllers/flash.controller';
 import OpProjectsZenModeController from './controllers/dynamic/projects/zen-mode.controller';
 import PasswordConfirmationDialogController from './controllers/password-confirmation-dialog.controller';
+import PatternAutocompleterController from './controllers/pattern-autocompleter.controller';
 
 declare global {
   interface Window {
@@ -41,3 +42,4 @@ instance.register('show-when-checked', OpShowWhenCheckedController);
 instance.register('show-when-value-selected', OpShowWhenValueSelectedController);
 instance.register('table-highlighting', TableHighlightingController);
 instance.register('projects-zen-mode', OpProjectsZenModeController);
+instance.register('pattern-autocompleter', PatternAutocompleterController);
diff --git a/lib/primer/open_project/forms/dsl/input_methods.rb b/lib/primer/open_project/forms/dsl/input_methods.rb
index eca21f89ef75..ed7422071659 100644
--- a/lib/primer/open_project/forms/dsl/input_methods.rb
+++ b/lib/primer/open_project/forms/dsl/input_methods.rb
@@ -9,6 +9,10 @@ def autocompleter(**, &)
             add_input AutocompleterInput.new(builder:, form:, **, &)
           end
 
+          def pattern_autocompleter(**, &)
+            add_input PatternAutocompleterInput.new(builder:, form:, **, &)
+          end
+
           def color_select_list(**, &)
             add_input ColorSelectInput.new(builder:, form:, **, &)
           end
diff --git a/lib/primer/open_project/forms/dsl/pattern_autocompleter_input.rb b/lib/primer/open_project/forms/dsl/pattern_autocompleter_input.rb
new file mode 100644
index 000000000000..caf935dd56e1
--- /dev/null
+++ b/lib/primer/open_project/forms/dsl/pattern_autocompleter_input.rb
@@ -0,0 +1,61 @@
+# frozen_string_literal: true
+
+#-- copyright
+# OpenProject is an open source project management software.
+# Copyright (C) the OpenProject GmbH
+#
+# This program is free software; you can redistribute it and/or
+# modify it under the terms of the GNU General Public License version 3.
+#
+# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows:
+# Copyright (C) 2006-2013 Jean-Philippe Lang
+# Copyright (C) 2010-2013 the ChiliProject Team
+#
+# This program is free software; you can redistribute it and/or
+# modify it under the terms of the GNU General Public License
+# as published by the Free Software Foundation; either version 2
+# of the License, or (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program; if not, write to the Free Software
+# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
+#
+# See COPYRIGHT and LICENSE files for more details.
+#++
+
+module Primer
+  module OpenProject
+    module Forms
+      module Dsl
+        class PatternAutocompleterInput < Primer::Forms::Dsl::Input
+          attr_reader :name, :value, :suggestions
+
+          def initialize(name:, value:, suggestions:, **system_arguments)
+            @name = name
+            @value = value
+            @suggestions = suggestions
+
+            super(**system_arguments)
+          end
+
+          def to_component
+            WorkPackages::Types::PatternAutocompleter.new(input: self, value:, suggestions:)
+          end
+
+          def type
+            :pattern_autocompleter
+          end
+
+          def focusable?
+            true
+          end
+        end
+      end
+    end
+  end
+end

From 6c2bfef228d179fa7d3b38ea768bf263222d81c8 Mon Sep 17 00:00:00 2001
From: Bruno Pagno <b.pagno@openproject.com>
Date: Tue, 14 Jan 2025 16:00:53 +0100
Subject: [PATCH 2/2] adjust javascript implementation details

---
 .../pattern-autocompleter.controller.ts       | 19 ++++++++++++++-----
 1 file changed, 14 insertions(+), 5 deletions(-)

diff --git a/frontend/src/stimulus/controllers/pattern-autocompleter.controller.ts b/frontend/src/stimulus/controllers/pattern-autocompleter.controller.ts
index d637da296f95..5a722cd9ac24 100644
--- a/frontend/src/stimulus/controllers/pattern-autocompleter.controller.ts
+++ b/frontend/src/stimulus/controllers/pattern-autocompleter.controller.ts
@@ -52,6 +52,7 @@ export default class PatternAutocompleterController extends Controller {
 
   connect() {
     this.contentTarget.innerHTML = this.toHtml(this.patternInitialValue) || ' ';
+    this.ensureSpacesAround();
   }
 
   // Input field events
@@ -162,6 +163,11 @@ export default class PatternAutocompleterController extends Controller {
     this.formInputTarget.value = this.toBlueprint();
   }
 
+  /**
+    * If there is no editable text before or after an uneditable node (e.g. a node that has `contenteditable="false"`),
+    * it's impossible to move the cursor there. As a workaround we ensure that there is at least
+    * a whitespace character at the beginning and end of our input field.
+    */
   private ensureSpacesAround():void {
     if (this.contentTarget.innerHTML.startsWith('<')) {
       this.contentTarget.insertBefore(document.createTextNode(' '), this.contentTarget.children[0]);
@@ -186,8 +192,10 @@ export default class PatternAutocompleterController extends Controller {
       const targetNode = this.currentRange.startContainer;
       const targetOffset = this.currentRange.startOffset;
 
+      if (!targetNode.textContent) { return; }
+
       let pos = targetOffset - 1;
-      while (pos > -1 && targetNode.textContent?.charAt(pos) !== ' ') { pos-=1; }
+      while (pos > -1 && !this.isWhitespace(targetNode.textContent.charAt(pos))) { pos-=1; }
 
       const wordRange = document.createRange();
       wordRange.setStart(targetNode, pos + 1);
@@ -283,10 +291,7 @@ export default class PatternAutocompleterController extends Controller {
   }
 
   private toHtml(blueprint:string):string {
-    let htmlValue = blueprint.replace(/{{([0-9A-Za-z_]+)}}/g, (_, token:string) => this.createToken(token).outerHTML);
-    if (htmlValue.startsWith('<')) { htmlValue = ` ${htmlValue}`; }
-    if (htmlValue.endsWith('>')) { htmlValue = `${htmlValue} `; }
-    return htmlValue;
+    return blueprint.replace(/{{([0-9A-Za-z_]+)}}/g, (_, token:string) => this.createToken(token).outerHTML);
   }
 
   private toBlueprint():string {
@@ -305,4 +310,8 @@ export default class PatternAutocompleterController extends Controller {
     });
     return result.trim();
   }
+
+  private isWhitespace(value:string):boolean {
+    return /\s/.test(value);
+  }
 }