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.
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);
}
}
}