typescriptvuejs3contenteditableselection-api

focus child element within contenteditable div


I have a Vue 3 component that contains a contenteditable div, and I'm dynamically adding child elements to it using a v-for loop. I need to determine which child element is currently focused, so that I can add a new child element after it. After adding the new child element, I need to set the focus to it. How can I accomplish this in Vue 3?

I'm attempting to do this:

<template>
  <div
    id="editableContainer"
    contenteditable="true"
    class="editable-container"
    @keydown.enter="validateEnterKeyEvent($event)"
  >
    <div
      v-for="(item, index) in textEditorItems"
      :id="item.id"
      :key="item"
    >
      <ContentEditableChild
        :item="item"
        @update:model-value="(newValue) => updateContent(index, newValue)"
      />
    </div>
  </div>
</template>

<script lang="ts">
import { defineComponent, nextTick, reactive, ref, watch } from "vue";
import ContentEditableChild from "@/components/contentEditable/ContentEditableChild.vue";

export default defineComponent({
  name: "DefaultContentEditable",
  components: { ContentEditableChild },
  setup() {
    const textEditorItems = reactive([
      {
        id: "slug-item0",
        type: "h1",
        content: ref("heading test"),
      },
    ]);

    // it will be the article slug later
    const idPrefix = ref("slug-item");

    const getItemId = (index: number) => {
      return `${idPrefix.value}${index}`;
    };

    watch(
      () => textEditorItems,
      (newValue, oldValue) => {
        if (newValue.length !== oldValue.length) {
          // update the IDs of the remaining elements
          for (let i = 0; i < newValue.length; i++) {
            const element = document.getElementById(getItemId(i));
            if (element) {
              element.id = getItemId(i);
            }
          }
        }
      }
    );

    function getFocusedElement() {
      const selection = window.getSelection();
      if (selection && selection.rangeCount > 0) {
        const range = selection.getRangeAt(0);
        const focused = range.commonAncestorContainer;

        let ancestor = focused;
        while (
          ancestor &&
          ancestor.parentElement &&
          ancestor.parentElement.isSameNode(focused.parentElement)
        ) {
          ancestor = ancestor.parentElement;
        }
        if (
          ancestor &&
          ancestor.parentElement &&
          ancestor.parentElement.tagName === "DIV"
        ) {
          return ancestor.parentElement;
        }
      }
      return null;
    }

    function validateEnterKeyEvent(event) {
      const currentItem = getFocusedElement();
      if (!event.shiftKey) {
        event.preventDefault();
        const currenItemContent = currentItem?.innerHTML.trim();
        if (currentItem && currenItemContent) {
          addNew(currentItem.id);
        }
      }
    }

    function addNew(currentItemId: string) {
      const currentItemIndex = textEditorItems.findIndex(
        (item) => item.id === currentItemId
      );
      creatNewParagraph(currentItemIndex, "");

      // make the newly added item focused and the previous not focused
      const newId = getItemId(currentItemIndex + 1);
      nextTick(() => {
        focusElement(newId);
      });
    }

    function focusElement(elementId: string) {
      const element = document.getElementById(elementId);
      const selection = window.getSelection();
      const range = document.createRange();
      if (element && selection) {
        range.setStart(element, 0);
        range.collapse(true);
        selection.removeAllRanges();
        selection.addRange(range);
      }
    }

    function creatNewParagraph(index: number, content: string) {
      // add a new item to the array after the current item
      textEditorItems.splice(index + 1, 0, {
        id: getItemId(index + 1),
        type: "p",
        content: content,
      });
    }

    function updateContent(index: number, newValue: string) {
      textEditorItems[index].content = newValue;
    }

    return {
      focusElement,
      getItemId,
      validateEnterKeyEvent,
      textEditorItems,
      updateContent,
    };
  },
});
</script>

<style scoped lang="scss">
.editable-container {
  outline: none;

  p {
    margin-top: 16px;
    background-color: #eee;
  }
}
* > {
}
</style>

<template>
  <component
    :is="item.type"
    ref="elementRef"
    class="fs16"
    :value="modelValue"
    tabindex="1"
    @input="$emit('update:modelValue', $event.target.innerHTML)"
  >
  </component>
</template>

<script lang="ts">
import { defineComponent, ref, onMounted } from "vue";

export default defineComponent({
  name: "ContentEditableChild",
  props: {
    item: {
      type: Object,
      required: true,
    },
  },
  emits: ["update:modelValue"],
  setup(props) {
    const modelValue = ref(props.item.content);
    const elementRef = ref(null);

    onMounted(() => {
      if (elementRef.value) {
        elementRef.value.innerHTML = modelValue.value;
      }
    });

    return { modelValue, elementRef };
  },
});
</script>

<style scoped lang="scss">
* {
  outline: none;
  margin-top: 16px;
  background-color: #eee;
}
</style>

In my previous code, I was able to use the selection API to get the currently focused element. However, I'm not sure if there's a better solution to achieve this. Although I'm able to add a new element to the contenteditable div, I'm having trouble moving the focus to the newly added element using the element.focus() function. It seems that this function doesn't work properly inside a contenteditable div.


Solution

  • I was able to resolve my issue by updating the focusElement function to

     function focusElement(elementId: string) {
      const element = document.getElementById(elementId);
      const selection = window.getSelection();
      const range = document.createRange();
      if (element && selection) {
        const focusNode = element.lastChild?.lastChild;
    
        if (focusNode) {
          range.setStart(focusNode, focusNode.length);
          range.collapse(true);
          selection.removeAllRanges();
          selection.addRange(range);
        }
      }
    }