Apply/Replay MutationRecord changes (MutationObserver API) - javascript

I've attached a MutationObserver to a DOM and am monitoring for changes.
I get the notification and receive a MutationRecord object containing a description of what was changed.
Is there a supported/standard/easy way to apply the changes in MutationRecord again? In other words, use the MutationRecord object to "replay" changes to the DOM?
Any help appreciated!

I couldn't help myself, after noticing that this was asked 6 years ago in 2016, so I made a "DomRecorder Lite". This program records three types on DOM mutation events that can happen inside a target element, in this case some div. You can play around with the tool and try to:
Add elements
Change text of a selected element
Remove selected element
Replay selected action
Replay all actions
There might be a bug or two, but I think that the Proof of Concept is there either way.
I also made a small video demonstrating how the program works (when I press edit-button, a prompt asking for a text appears, but that isn't recorded): https://www.veed.io/view/7325e363-ac5c-4c9b-8138-cd990b253372
We use two custom classes called MutationEvent and DOMRecorder:
MutationEvent
MutationEvent is responsible for storing information about an observer mutation event, such as what the event was (TEXTCHANGED | INSERT | REMOVE), DOM element data related to the event and just generic data, in case we need to attach some extra data to an event.
MutationEvent is also responsible for replaying itself depending on what the event type is. I made this PoC to support three types of events as written above.
The INSERT case is somewhat interesting because of the user's ability to replay one event (doesn't clear the recorded wrapper element) or to replay all events (clears the recorded wrapper element first).
When replaying only one INSERT event, we can't directly insert the element saved to the MutationEvent, because it would have the same id in DOM as the element from which this event originates in the first place, so we create this sort of "actor element", which is a clone of the original element (and its child content), but with a different id.
If we are replaying all the events instead, the recorded wrapper element is cleared first, which means we don't have to worry about this issue.
case 'INSERT':
elem = document.querySelector(`#${$targetid}`);
if(elem) {
let actorElement = null;
if(clean) {
actorElement = this.element.cloneNode(true);
actorElement.id = 'd-' + Math.random().toString(16).slice(2, 8);
}
else {
actorElement = this.element;
}
elem.append(actorElement);
success = true;
}
break;
DOMRecorder
DOMRecorder is kind of the meat and potatoes of everything. Its main resposibility is to use MutationObserver to observe changes in the DOM of the recorded element and to create MutationEvents from those changes. Other resposibilities include replaying a single or every MutationEvent it has stored. In this PoC, the DOMRecorder is also responsible for updating some of the select inputs on the page, which is probably not 100% legitimate way to separate concerns, but that's not relevant here.
Again, this is just a proof of concept how replaying of MutationEvents could be implemented, so there are only a limited number of supported events. This PoC also doesn't take a position regarding deep DOM subtree modifications, but after coding this, I am not too scared to think of it. see edit
Is there a supported/standard/easy way
Probably not. Can't think of many uses for such standard at least. There are some libraries out there that record user's interactions on a webpage, but those don't need to record/replay actual DOM modifications, only the interactions that could result in DOM modifications.
Should also keep in mind that, in general, it's a lot easier to implement a system that watches for changes and emits some kind of change events telling you that "this just changed", than it is to implement a system that is able to re-create those changes. The former doesn't really need to care about anything other than changes happening anywhere in any form, but with the latter, there are a lot of considerations when it comes to re-creation.
To list some:
Should you re-create something that should be unique? Is it really a replay, if the replayed changes are not exactly the same as the recorded ones?
What if the "replay" doesn't start from the same state as the "recording" started?
Should you also record what the replay does?
How do you handle local relations between events?
How do you handle global relations between events and the DOM?
If I make an element, change it's text, remove the element and want to replay the text change event again, what should happen?
What should happen when a parent of deeply nested element gets removed or changed in some way that it can no longer hold the nested element?
If we decided that deletion of a parent of deeply nested element should remove the whole element branch, should that be an event of itself, or perhaps even multiple removal events for each obsolete child on the branch?
How to keep track of CSS changes, after trying this out, it is not as simple as it seems
EDIT
So I added events for subtree insertion and removal. This means that one can now add child elements inside other elements. Functions that worked for the top-level elements should work for any child element as well, or child-of-a-child, or child-of-a-child-of-a-child, ...
This was a bit tricky to implement and the biggest issue here was keeping tracking of parent element ids. See, when an element is created, some id will be generated for it. To insert an element inside of another existing element, we obviously need to know its id. Now this seems like a no problem at first, the user selected parent element has some id, so we just get it and insert the element inside.
Everything seems to work, but we run into a problem when replaying the events and the subtree insertion just won't do anything. The reason for this was that when the program replays the events, specifically the SUBINSERT event, it uses a wrong id for the parent element. This is because the replay function clears the recorded wrapper before starting the replay and therefore all the elements created during replay will get new ids, so the SUBINSERT will not know what the parent element id is during the replay.
To fix this, I added a second, "static internal id" for each event:
constructor(recorderInstance, event, element, data)
{
this.static_replay_id = getId();
...
}
This id will not change from recording to replay, so when we want to insert an element as a child element to some parent element, we do the following event-wise:
Get the id of the mutation.target, which is the id of the parent element
Assume that since this parent element exists, there must also exist an event for it already
Get the static_replay_id of the event by the mutation.target id
Add the static_replay_id as subtree_target for this SUBINSERT event
getStaticIdForMutationTarget:
getStaticIdForMutationTarget(targetid)
{
return this.events.find((evt) => evt.element.id === targetid).static_replay_id;
}
And when we replay the SUBINSERT event, we get the current DOM id of the parent element by the saved static_replay_id:
elem = document.querySelector(`#${$targetid}div#${this.recorderInstance.getEventTargetByStaticReplayId(this.data.subtree_target)}`);
getEventTargetByStaticReplayId:
getEventTargetByStaticReplayId(replay_id)
{
let targetid = null;
for(let i = 0; i < this.events.length; i++) {
let evt = this.events[i];
if(!"static_replay_id" in evt.data)
continue;
if(evt.static_replay_id === replay_id) {
targetid = evt.element.id;
break;
}
}
return targetid;
}
EDIT 2
Added support for applying and replaying inline CSS style events and changed long event name strings, such as "SUBINSERT" or "SUBREMOVE" -> "SUBINS", "SUBREM", so that the UI works better.
Also fixed a couple of bugs regarding mutation event targets and subtree elements. Had to also re-implement how changing element text node text works. The most interesting takeaway here is that for some reason, if we try to change element's textnode's text with elem.childNodes[0].value = text, MutationObserver will not catch that - I guess it doesn't count as any kind of "change in the DOM tree".
// Can't use elem.textContent / innerText / innerHTML...
// might accidentally overwrite element's subtree
// Can't do it like this, because MutationObserver can't see this (why??)
// elem.childNodes[0].value = text;
// This is the way
elem.replaceChild(document.createTextNode(text), elem.childNodes[0]);
At this point I've noticed that a "cleaning" process is required when replaying events:
// Used to clean "dirtied" elements before replaying.
// Consider the event flow:
// 1. Create element X
// 2. Change X CSS or subinsert to X
// 3. Remove X
// 4. Replay
// Without cleanup, during replay at step 1, the created element
// will already have "later" CSS changes and subinserts applied to it ahead of time
// because of ""the way we do things"".
// This fixes that issue
__clean()
{
// OLD CLEANUP METHOD
/*while (this.element.lastElementChild) {
this.element.lastElementChild.style = "";
this.element.removeChild(this.element.lastElementChild);
}
this.element.style = "";
this.element.innerText = this.element.id;*/
// NEW CLEANUP METHOD (don't have to care what changed)
// Reset event's element
this.element = this.originalElement.cloneNode(true);
// Must remember to set the original id back
// Or else it will be the clone's id
this.element.id = this.originalId;
}
As explained in the code snippet comments, there are certain "event flows", which could lead to wrong replay results, such as some events/changes being applied to elements ahead of time so to speak.
To fix this, each event performs an internal "cleaning process" before actually replaying itself. This might not be needed, were the implementation a bit different, but for now it has to be done.
When it comes to the question "What exactly needs to be cleaned?", I am honestly not sure. As far as I know, we seem to have to clean everything that any event could possibly modify on an element.
OLD WAY
Currently that means:
Clearing element DOM subtree of children
Resetting element inline styles
Resetting element's innerText to what it originally is (it's id)
NEW WAY
I figured we can get around caring what needs to be reset in the cleaning process by just creating a clone of each even't original related element and then replacing the event's element with the original unchanged clone before replaying:
class MutationEvent
{
constructor(recorderInstance, event, element, data)
{
this.originalId = this.element.id;
this.originalElement = this.element.cloneNode(true);
// Important to change the id to "hide" the clone
this.originalElement.id = getId("o-");
...
}
}
Anyways, here's the code:
const $recordable = document.querySelector('#recordable');
const $eventlist = document.querySelector('#event-list');
const $elementlist = document.querySelector('#element-list');
function getId(prefix = "d-") {
return prefix + Math.random().toString(16).slice(2, 8);
}
// MutationEvent class
class MutationEvent
{
constructor(recorderInstance, event, element, data)
{
this.recorderInstance = recorderInstance;
this.event = event;
this.element = element.cloneNode(true);
// For cleanup
this.originalId = this.element.id;
this.originalElement = this.element.cloneNode(true);
// Important to change the id to "hide" the clone
this.originalElement.id = getId("o-");
this.data = data || { };
this.static_replay_id = getId("r-");
}
getStaticId()
{
return this.static_replay_id;
}
// Used to clean "dirtied" elements before replaying.
// Consider the event flow:
// 1. Create element X
// 2. Change X CSS or subinsert to X
// 3. Remove X
// 4. Replay
// Without cleanup, during replay at step 1, the created element
// will already have "later" CSS changes and subinserts applied to it ahead of time
// because of ""the way we do things"".
// This fixes that issue
__clean()
{
// OLD CLEANUP METHOD
/*while (this.element.lastElementChild) {
this.element.lastElementChild.style = "";
this.element.removeChild(this.element.lastElementChild);
}
this.element.style = "";
this.element.innerText = this.element.id;*/
// NEW CLEANUP METHOD (don't have to care what changed)
// Reset event's element
this.element = this.originalElement.cloneNode(true);
// Must remember to set the original id back
// Or else it will be the clone's id
this.element.id = this.originalId;
}
replay($targetid, useActor = false)
{
let elem = null;
let success = false;
this.__clean();
switch(this.event)
{
case 'TEXT':
elem = document.querySelector(`#${$targetid} div#${this.element.id}`);
if(elem) {
elem.replaceChild(document.createTextNode(this.data.text), elem.childNodes[0]);
success = true;
}
break;
case 'INSERT':
elem = document.querySelector(`#${$targetid}`);
if(elem) {
let actorElement = null;
if(useActor) {
actorElement = this.element.cloneNode(true);
actorElement.id = getId();
}
else {
actorElement = this.element;
}
elem.append(actorElement);
success = true;
}
break;
case 'SUBINS':
elem = document.querySelector(
`#${$targetid} div#${
this.recorderInstance.getEventTargetByStaticReplayId(this.data.subtree_target)
}`);
if(elem) {
let actorElement = null;
if(useActor) {
actorElement = this.element.cloneNode(true);
actorElement.id = getId();
}
else {
actorElement = this.element;
}
elem.append(actorElement);
success = true;
}
break;
case 'CSS':
elem = document.querySelector(`#${$targetid} div#${this.element.id}`);
if(elem && typeof this.data.css_rules !== 'undefined') {
this.data.css_rules.forEach((r) => {
elem.style[r.rule] = r.value;
});
success = true;
}
break;
case 'REMOVE':
elem = document.querySelector(`#${$targetid} div#${this.element.id}`);
if(elem) {
elem.remove();
success = true;
}
break;
case 'SUBREM':
elem = document.querySelector(
`#${$targetid} div#${
this.recorderInstance.getEventTargetByStaticReplayId(this.data.subtree_target)
}`);
if(elem) {
elem.remove();
success = true;
}
break;
default:
break;
}
return success;
}
toString()
{
return `${this.event}: ${this.element.id}`;
}
}
// Dom recorder / MutationObserver stuff
class DOMRecorder
{
constructor(targetNode, config)
{
this.recording = false;
this.replaying = false;
this.targetNode = targetNode;
this.config = config;
this.observer = null;
this.elementIds = [];
this.events = [];
}
start()
{
if(this.observer === null)
this.observer = new MutationObserver(this.recordEvent.bind(this));
this.observer.observe(this.targetNode, this.config);
this.recording = true;
}
getStaticIdForMutationTarget(targetid)
{
return this.events.find((evt) => evt.element.id === targetid).static_replay_id;
}
getEventByTargetId(targetid)
{
return this.events.find((evt) => evt.element.id === targetid);
}
getEventTargetByStaticReplayId(replay_id)
{
let targetid = null;
for(let i = 0; i < this.events.length; i++) {
let evt = this.events[i];
if(!"static_replay_id" in evt.data)
continue;
if(evt.static_replay_id === replay_id) {
targetid = evt.element.id;
break;
}
}
return targetid;
}
stop()
{
this.observer.disconnect();
this.recording = false;
}
update()
{
this.updateElementList();
this.updateEventList();
}
updateElementList()
{
let options = this.elementIds.map((id) => {
let option = document.createElement("option");
option.value = id;
option.innerText = id;
return option;
});
$elementlist.replaceChildren(...options);
}
updateEventList()
{
let options = this.events.map((e) => {
let option = document.createElement("option");
// This line breaks syntax-highlighting, if `` is used???
option.value = e.event + ";" + e.element.id;
option.innerText = e.toString();
return option;
});
$eventlist.replaceChildren(...options);
}
recordEvent(mutationList, observer)
{
for(const mutation of mutationList) {
// Element text changed event
if(mutation.type === 'childList' &&
mutation.addedNodes.length > 0 &&
mutation.addedNodes[0].nodeName === '#text') {
this.events.push(new MutationEvent(
this,
"TEXT",
mutation.addedNodes[0].parentElement,
{ text: mutation.addedNodes[0].nodeValue }
));
}
// Element added event
else if (mutation.type === 'childList' && mutation.addedNodes.length > 0) {
// Element added directly into the recorded element
if(mutation.target.id === this.targetNode.id) {
this.events.push(new MutationEvent(this, "INSERT", mutation.addedNodes[0]));
this.elementIds.push(mutation.addedNodes[0].id);
}
// Element added to a subtree of some element in the recorded element
else {
this.events.push(new MutationEvent(
this,
"SUBINS",
mutation.addedNodes[0],
{ subtree_target: this.getStaticIdForMutationTarget(mutation.target.id) }
));
this.elementIds.push(mutation.addedNodes[0].id);
}
}
// Element removed event
else if (mutation.type === 'childList' && mutation.removedNodes.length > 0) {
// Element removed directly from the recorded element
if(mutation.target.id === this.targetNode.id) {
this.events.push(new MutationEvent(this, "REMOVE", mutation.removedNodes[0]));
}
// Element removed from a subtree of some element in the recorded element
else {
this.events.push(new MutationEvent(
this,
"SUBREM",
mutation.removedNodes[0],
{ subtree_target: this.getStaticIdForMutationTarget(mutation.removedNodes[0].id) }
));
}
this.elementIds.splice(this.elementIds.indexOf(mutation.removedNodes[0].id), 1);
// Clean up element list, if we remove a parent element which has child elements
let childNodes = mutation.removedNodes[0].getElementsByTagName('*');
Array.from(childNodes).forEach((n) => {
this.elementIds.splice(this.elementIds.indexOf(n.id), 1);
});
}
// Element inline CSS changed
else if(mutation.type === 'attributes' && mutation.attributeName === "style") {
// Perform some magic to find specifically only the changed
// inline style names of the element, as well as their values
let addedStyles = Object.keys(mutation.target.style).filter((k) => {
return Number.isInteger(parseInt(k));
}).map((styleKey) => {
// Change rule names like "background-color" -> "backgroundColor"
let rule = mutation.target.style[styleKey]
.replace(/-(.)/g, (m,p) => p.toUpperCase());
let value = mutation.target.style[rule];
return { rule, value }
});
this.events.push(new MutationEvent(
this,
"CSS",
mutation.target,
{ css_rules: addedStyles }
));
}
}
this.update();
}
replayEvent(event, id, $targetid)
{
let replayEvent = this.events
.find((evt) => evt.event === event && evt.element.id === id);
if(!replayEvent) {
console.log(`Could not find event: "${event}", ID: "${id}"`);
return;
}
replayEvent.replay($targetid, true);
}
replayEvents(speed, $targetid)
{
if(this.replaying)
return;
this.replaying = true;
if(this.recording)
this.stop();
$recordable.innerHTML = '';
let i = 0;
let replayInterval = setInterval(() => {
if(i < this.events.length) {
this.events[i].replay($targetid);
i++;
}
else {
clearInterval(replayInterval);
this.replaying = false;
this.start();
}
}, speed);
}
}
// Example controls events
function addElement() {
let id = getId();
let div = document.createElement("div");
div.id = id;
div.innerHTML = id;
$recordable.append(div);
}
function addElementSubtree() {
let selected = $elementlist.value || null;
if(selected === null) {
console.log('No element selected');
return;
}
let id = getId();
let div = document.createElement("div");
div.id = id;
div.innerHTML = id;
let elem = document.querySelector(`#recordable div#${selected}`);
if(elem)
elem.append(div);
}
function changeCSS() {
let selected = $elementlist.value || null;
if(selected === null) {
console.log('No element selected');
return;
}
let userCSS = prompt("Input a single CSS rule like background-color: red");
userCSS = userCSS.length === 0 ? "background-color: red" : userCSS;
let [cssRuleName, cssRule] = userCSS.split(':').map((s) => s.trim());
cssRuleName = cssRuleName.replace(/-(.)/g, (m,p) => p.toUpperCase());
let elem = document.querySelector(`#recordable div#${selected}`);
if(elem)
elem.style[cssRuleName] = cssRule;
}
function removeElement() {
let selected = $elementlist.value || null;
if(selected === null) {
console.log('No element selected');
return;
}
let elem = document.querySelector(`#recordable div#${selected}`);
if(elem)
elem.remove();
}
function changeText()
{
let selected = $elementlist.value || null;
if(selected === null) {
console.log('No element selected');
return;
}
let elem = document.querySelector(`#recordable div#${selected}`);
if(elem) {
let text = prompt("Insert text for element");
// Can't use elem.textContent / innerText / innerHTML...
// might accidentally overwrite element's subtree
// Can't do it like this, because MutationObserver can't see this (why??)
// elem.childNodes[0].value = text;
// This is the way
elem.replaceChild(document.createTextNode(text), elem.childNodes[0]);
}
}
function replayOne()
{
let selected = $eventlist.value || null;
if(selected === null) {
console.log('No element selected');
return;
}
let [event, id] = selected.split(';');
recorder.replayEvent(event, id, $recordable.id);
}
function replayAll()
{
let speed = prompt("Input speed in ms for the replay") || 350;
recorder.replayEvents(speed, $recordable.id);
}
const recorder = new DOMRecorder(
$recordable,
{ attributes: true, childList: true, subtree: true }
);
recorder.start();
*
{
margin: 0;
padding: 0;
box-sizing: border-box;
}
html, body
{
width: 100%;
height: 100%;
}
#recordable
{
width: 100%;
height: calc(100% - 35px);
background-color: #7f8fa6;
padding: 5px;
display: -ms-flexbox;
display: -webkit-flex;
display: flex;
-webkit-flex-direction: row;
-ms-flex-direction: row;
flex-direction: row;
-webkit-flex-wrap: wrap;
-ms-flex-wrap: wrap;
flex-wrap: wrap;
-webkit-justify-content: flex-start;
-ms-flex-pack: start;
justify-content: flex-start;
-webkit-align-content: flex-start;
-ms-flex-line-pack: start;
align-content: flex-start;
-webkit-align-items: flex-start;
-ms-flex-align: start;
align-items: flex-start;
}
#controls
{
width: 100%;
height: 80px;
background-color: #273c75;
display: -ms-flexbox;
display: -webkit-flex;
display: flex;
-webkit-flex-direction: row;
-ms-flex-direction: row;
flex-direction: row;
-webkit-flex-wrap: nowrap;
-ms-flex-wrap: nowrap;
flex-wrap: nowrap;
-webkit-justify-content: center;
-ms-flex-pack: center;
justify-content: center;
-webkit-align-content: stretch;
-ms-flex-line-pack: stretch;
align-content: stretch;
-webkit-align-items: center;
-ms-flex-align: center;
align-items: center;
}
button, select
{
padding: 2px;
margin: 0 3px 0 3px;
}
#recordable > div
{
width: 120px;
min-height: 35px;
padding: 4px;
line-height: calc(35px - 4px);
background-color: #2f3640;
color: #dcdde1;
font-family: "courier-new", Arial;
font-size: 10pt;
margin: 0px 5px 5px 5px;
text-align: center;
}
#recordable > div > *
{
padding: 0px;
line-height: calc(35px - 4px);
background-color: #192a56;
color: #dcdde1;
font-family: "courier-new", Arial;
font-size: 10pt;
margin: 5px 5px 5px 5px !important;
text-align: center;
}
<div id = "controls">
<button id = "add-control" onClick = "addElement()">Add</button>
<button id = "add-subtree-control" onClick = "addElementSubtree()">Add sub</button>
<button id = "add-control" onClick = "removeElement()">Remove</button>
<button id = "css-control" onClick = "changeCSS()">CSS</button>
<button id = "edit-control" onClick = "changeText()">Edit</button>
<select id = "element-list">
</select>
<select id = "event-list">
</select>
<button id = "replay-control" onClick = "replayOne()">Replay</button>
<button id = "replay-all-control" onClick = "replayAll()">Replay all</button>
</div>
<div id = "recordable">
</div>

Related

HTML contenteditable: Keep Caret Position When Inner HTML Changes

I have a div that acts as a WYSIWYG editor. This acts as a text box but renders markdown syntax within it, to show live changes.
Problem: When a letter is typed, the caret position is reset to the start of the div.
const editor = document.querySelector('div');
editor.innerHTML = parse('**dlob** *cilati*');
editor.addEventListener('input', () => {
editor.innerHTML = parse(editor.innerText);
});
function parse(text) {
return text
.replace(/\*\*(.*)\*\*/gm, '**<strong>$1</strong>**') // bold
.replace(/\*(.*)\*/gm, '*<em>$1</em>*'); // italic
}
div {
height: 100vh;
width: 100vw;
}
<div contenteditable />
Codepen: https://codepen.io/ADAMJR/pen/MWvPebK
Markdown editors like QuillJS seem to edit child elements without editing the parent element. This avoids the problem but I'm now sure how to recreate that logic with this setup.
Question: How would I get the caret position to not reset when typing?
Update:
I have managed to send the caret position to the end of the div, on each input. However, this still essentially resets the position. https://codepen.io/ADAMJR/pen/KKvGNbY
You need to get position of the cursor first then process and set the content. Then restore the cursor position.
Restoring cursor position is a tricky part when there are nested elements. Also you are creating new <strong> and <em> elements every time, old ones are being discarded.
const editor = document.querySelector(".editor");
editor.innerHTML = parse(
"For **bold** two stars.\nFor *italic* one star. Some more **bold**."
);
editor.addEventListener("input", () => {
//get current cursor position
const sel = window.getSelection();
const node = sel.focusNode;
const offset = sel.focusOffset;
const pos = getCursorPosition(editor, node, offset, { pos: 0, done: false });
if (offset === 0) pos.pos += 0.5;
editor.innerHTML = parse(editor.innerText);
// restore the position
sel.removeAllRanges();
const range = setCursorPosition(editor, document.createRange(), {
pos: pos.pos,
done: false,
});
range.collapse(true);
sel.addRange(range);
});
function parse(text) {
//use (.*?) lazy quantifiers to match content inside
return (
text
.replace(/\*{2}(.*?)\*{2}/gm, "**<strong>$1</strong>**") // bold
.replace(/(?<!\*)\*(?!\*)(.*?)(?<!\*)\*(?!\*)/gm, "*<em>$1</em>*") // italic
// handle special characters
.replace(/\n/gm, "<br>")
.replace(/\t/gm, " ")
);
}
// get the cursor position from .editor start
function getCursorPosition(parent, node, offset, stat) {
if (stat.done) return stat;
let currentNode = null;
if (parent.childNodes.length == 0) {
stat.pos += parent.textContent.length;
} else {
for (let i = 0; i < parent.childNodes.length && !stat.done; i++) {
currentNode = parent.childNodes[i];
if (currentNode === node) {
stat.pos += offset;
stat.done = true;
return stat;
} else getCursorPosition(currentNode, node, offset, stat);
}
}
return stat;
}
//find the child node and relative position and set it on range
function setCursorPosition(parent, range, stat) {
if (stat.done) return range;
if (parent.childNodes.length == 0) {
if (parent.textContent.length >= stat.pos) {
range.setStart(parent, stat.pos);
stat.done = true;
} else {
stat.pos = stat.pos - parent.textContent.length;
}
} else {
for (let i = 0; i < parent.childNodes.length && !stat.done; i++) {
currentNode = parent.childNodes[i];
setCursorPosition(currentNode, range, stat);
}
}
return range;
}
.editor {
height: 100px;
width: 400px;
border: 1px solid #888;
padding: 0.5rem;
white-space: pre;
}
em, strong{
font-size: 1.3rem;
}
<div class="editor" contenteditable ></div>
The API window.getSelection returns Node and position relative to it. Every time you are creating brand new elements so we can't restore position using old node objects. So to keep it simple and have more control, we are getting position relative to the .editor using getCursorPosition function. And, after we set innerHTML content we restore the cursor position using setCursorPosition.
Both functions work with nested elements.
Also, improved the regular expressions: used (.*?) lazy quantifiers and lookahead and behind for better matching. You can find better expressions.
Note:
I've tested the code on Chrome 97 on Windows 10.
Used recursive solution in getCursorPosition and setCursorPosition for the demo and to keep it simple.
Special characters like newline require conversion to their equivalent HTML form, e.g. <br>. Tab characters require white-space: pre set on the editable element. I've tried to handled \n, \t in the demo.
The way most rich text editors does it is by keeping their own internal state, updating it on key down events and rendering a custom visual layer. For example like this:
const $editor = document.querySelector('.editor');
const state = {
cursorPosition: 0,
contents: 'hello world'.split(''),
isFocused: false,
};
const $cursor = document.createElement('span');
$cursor.classList.add('cursor');
$cursor.innerText = '᠎'; // Mongolian vowel separator
const renderEditor = () => {
const $contents = state.contents
.map(char => {
const $span = document.createElement('span');
$span.innerText = char;
return $span;
});
$contents.splice(state.cursorPosition, 0, $cursor);
$editor.innerHTML = '';
$contents.forEach(el => $editor.append(el));
}
document.addEventListener('click', (ev) => {
if (ev.target === $editor) {
$editor.classList.add('focus');
state.isFocused = true;
} else {
$editor.classList.remove('focus');
state.isFocused = false;
}
});
document.addEventListener('keydown', (ev) => {
if (!state.isFocused) return;
switch(ev.key) {
case 'ArrowRight':
state.cursorPosition = Math.min(
state.contents.length,
state.cursorPosition + 1
);
renderEditor();
return;
case 'ArrowLeft':
state.cursorPosition = Math.max(
0,
state.cursorPosition - 1
);
renderEditor();
return;
case 'Backspace':
if (state.cursorPosition === 0) return;
delete state.contents[state.cursorPosition-1];
state.contents = state.contents.filter(Boolean);
state.cursorPosition = Math.max(
0,
state.cursorPosition - 1
);
renderEditor();
return;
default:
// This is very naive
if (ev.key.length > 1) return;
state.contents.splice(state.cursorPosition, 0, ev.key);
state.cursorPosition += 1;
renderEditor();
return;
}
});
renderEditor();
.editor {
position: relative;
min-height: 100px;
max-height: max-content;
width: 100%;
border: black 1px solid;
}
.editor.focus {
border-color: blue;
}
.editor.focus .cursor {
position: absolute;
border: black solid 1px;
border-top: 0;
border-bottom: 0;
animation-name: blink;
animation-duration: 1s;
animation-iteration-count: infinite;
}
#keyframes blink {
from {opacity: 0;}
50% {opacity: 1;}
to {opacity: 0;}
}
<div class="editor"></div>
You need to keep the state of the position and restore it on each input. There is no other way. You can look at how content editable is handled in my project jQuery Terminal (the links point to specific lines in source code and use commit hash, current master when I've written this, so they will always point to those lines).
insert method that is used when user type something (or on copy-paste).
fix_textarea - the function didn't changed after I've added content editable. The function makes sure that textarea or contenteditable (that are hidden) have the same state as the visible cursor.
clip object (that is textarea or content editable - another not refactored name that in beginning was only for clipboard).
For position I use jQuery Caret that is the core of moving the cursor. You can easily modify this code and make it work as you want. jQuery plugin can be easily refactored into a function move_cursor.
This should give you an idea how to implement this on your own in your project.
You can use window.getSelection to get the current position and, after parsing, move the cursor to again this position with sel.modify.
const editor = document.querySelector('div')
editor.innerHTML = parse('**dlob** *cilati*')
sel = window.getSelection()
editor.addEventListener('input', () => {
sel.extend(editor, 0)
pos = sel.toString().length
editor.innerHTML = parse(editor.innerText)
while (pos-->0)
sel.modify('move', 'forward', "character")
})
function parse(text) {
return text
.replace(/\*\*(.*)\*\*/gm, '**<strong>$1</strong>**') // bold
.replace(/\*(.*)\*/gm, '*<em>$1</em>*'); // italic
}
div {
height: 100vh;
width: 100vw;
}
<div contenteditable />
That said, note the edit history is gone (i.e. no undo), when using editor.innerHTML = ....
As other indicated, it seems better to separate editing and rendering.
I call this pseudo-contenteditable. I asked a question related to this
Pseudo contenteditable: how does codemirror works?. Still waiting for an answer.
But the basic idea might look this https://jsfiddle.net/Lfbt4c7p.

iterating across multiple buttons using key value pairs array to add event listeners with loops

I am working on a simple calculator project
I am attempting to automate adding event listeners to the various numeric buttons (1-9).
The event listeners will listen to click events on the buttons leading to a change in the display section of the HTML (class = .display)
key value pairs being b1-b9 containing the various corresponding values.
I have come up with the below FOR EACH loop. For some reason it causes all numerical buttons to apply the number 9; which i believe is the cause of the for each loop.
I am unsure how to quite fix it. I have also come up with an alternative FOR loop that leads to another problem. pairs[Properties[i]].toString() returns undefined.
interestingly if i swap pairs[Properties[i]].toString() out to just i then the SAME issue occurs
Help really appreciated and thank you..
const pairs = {
b1: 1,
b2: 2,
b3: 3,
b4: 4,
b5: 5,
b6: 6,
b7: 7,
b8: 8,
b9: 9,
};
var Properties = Object.keys(pairs);
function loadButtons () {
for (var item in pairs) {
//for each key property in pairs
console.log(item);
let targetCell = document.querySelector("." + item.toString())
// querySelector only targets the FIRST element found
// in this case only 1 for that name
console.log(targetCell);
targetCell.addEventListener('click', () => {
// you want it to manipulate the display as and when clicked
var currentDisplay = document.querySelector(".display").innerHTML.toString();
newDisplay = currentDisplay + pairs[item].toString();
document.querySelector(".display").innerHTML = newDisplay;
})
// console.log(pairs[item]);
// // pairs[item] retrieves the value to that "key"
}
};
function alternative() {
var i;
var Properties = Object.keys(pairs);
for (i = 0; i < Properties.length; i++) {
let targetCell = document.querySelector("." + Properties[i].toString())
// querySelector only targets the FIRST element found
// in this case only 1 for that name
console.log(targetCell);
targetCell.addEventListener('click', () => {
// you want it to manipulate the display as and when clicked
var currentDisplay = document.querySelector(".display").innerHTML.toString();
newDisplay = currentDisplay + pairs[Properties[i]].toString();
document.querySelector(".display").innerHTML = newDisplay;
})
};
};
Expected should be clicking of 1 to add a string "1" to the current string of the calculator, so on .
function onClick(item, pairs) {
return () => {
// you want it to manipulate the display as and when clicked
var currentDisplay = document.querySelector(".display").innerHTML.toString();
var newDisplay = currentDisplay + pairs[item].toString();
document.querySelector(".display").innerHTML = newDisplay;
}
}
var Properties = Object.keys(pairs);
function loadButtons () {
for (var item in pairs) {
//for each key property in pairs
console.log(item);
let targetCell = document.querySelector("." + item.toString())
// querySelector only targets the FIRST element found
// in this case only 1 for that name
console.log(targetCell);
targetCell.addEventListener('click', onClick(item, pairs))
// console.log(pairs[item]);
// // pairs[item] retrieves the value to that "key"
}
};
You should use event delegation instead of looping over and attaching events to every button. Here's an example:
var keyboard = document.getElementById('keyboard');
var display = document.getElementById('display');
keyboard.addEventListener('click', function(ev) {
var val = ev.target.id;
if (ev.target.localName === 'button') {
display.innerText += val;
}
});
.calculator {
width: 300px;
background: whitesmoke;
}
#display {
height: 50px;
background: #d2d2d2;
border: 1px solid #9c9c9c;
margin: 10px auto 10px;
font-size: 20px;
line-height: 50px;
padding: 0 10px;
}
#keyboard {
display: flex;
flex-direction: row;
flex-wrap: wrap;
justify-content: center;
}
button {
font-size: 20px;
padding: 20px;
margin: 5px;
cursor: pointer;
}
<div class="calculator">
<div id="display" contenteditable="true" >
</div>
<div id="keyboard">
<button id="0">0</button>
<button id="1">1</button>
<button id="2">2</button>
<button id="3">3</button>
<button id="4">4</button>
<button id="5">5</button>
<button id="6">6</button>
<button id="7">7</button>
<button id="8">8</button>
<button id="9">9</button>
</div>
</div>
i will do it this way but that not the only one i guess and there could be better ways.
const BUTTONS_NAMESVALUES={
//-- sound awful when a loop can do that!
bt0:0,bt1:1,bt2:2,bt3:3,bt4:4,bt5:5,bt6:6,bt7:7,bt8:8,bt9:9
}
function checkButtonValue(bt){
if(BUTTONS_NAMESVALUES[bt.id] !=null && BUTTONS_NAMESVALUES[bt.id] !='undefined'){
return bt.innerHTML;
}return;
}
//a button may look like that
<button id="bt1">1</button>
//-- with listener:
document.getElementById('bt1').addEventListener('click', function(e){
let chk=checkButtonValue(this);
if(chk!=null && chk!='undefined' && chk!=''){
document.getElementById('calculatorScreen').innerHTML=''+document.getElementById('calculatorScreen').innerHTML+chk;
}
});
I hope that help. I just replace the class name '.display' who can easily be a source of error(because it's the name of a CSS property and anything is display in HTML+ using an id better in that case because it's a unique element and can't be mistaken, classes aren't) and is not very accurate(as i write a correct constante name who has some meaning instead of pairs who means really nothing ^^).
Neither i 've automated the code into a loop but that's the easy part who is ever in your script.

Scope issues inside an Event Listener?

The following code basically shows/hides paragraph tags, I'm having to re-declare the paras variable. Is this because I'm dynamically injecting the button into the DOM, or is it to do with scope? How could I better construct this markup?
// vars
var revealContainer = document.querySelector('.reveal-more');
var paras = revealContainer.querySelectorAll('p');
var status = true;
// return
if (paras && paras.length <= 3) return;
// generate show more link
revealContainer.innerHTML += '<button class="button--text reveal-more__btn">Read more</button>';
var revealBtn = revealContainer.querySelector('.reveal-more__btn');
// click event
revealBtn.addEventListener('click', function () {
var paras = revealContainer.querySelectorAll('p');
// toggle show/hide class
for (var i = 0; i < paras.length; i++) {
var p = paras[i];
p.classList.toggle('is-shown');
}
// check status
if (status) {
this.textContent = 'Read less';
status = false;
} else {
this.textContent = 'Read more';
status = true;
}
});
You can use the live HTMLCollection returned by .getElementsByTagName() instead of the static NodeList returned by .querySelectorAll()
The getElementsByTagName method of Document interface returns an HTMLCollection of elements with the given tag name. The complete document is searched, including the root node. The returned HTMLCollection is live, meaning that it updates itself automatically to stay in sync with the DOM tree without having to call document.getElementsByTagName() again.
var paragraphs = document.getElementById("container").getElementsByTagName("p");
console.log(paragraphs.length);
setInterval(function() {
document.getElementById("container").insertAdjacentHTML("beforeend", "<p>p</p>");
}, 1000);
setInterval(function() {
console.log(paragraphs.length);
}, 2000);
<div id="container"></div>
Below is a really simple Snippet that demonstrates delegated events in pure Javascript, instead of using jQuery.
Here you can see I've attached the eventListener to the div with id elements, this will then listen for click events under this, a simple matches is used just in case you have other elements your not interested in..
document.querySelector("#elements").addEventListener("click", (e) => {
if (!e.target.matches('.element')) return
console.log(`Clicked ${e.target.innerText}`);
});
.element {
border: 1px solid black;
margin: 5px;
}
<div id="elements">
<div class="element">1</div>
<div class="element">2</div>
<div class="element">3</div>
<div>Clicking this does nothing.</div>
</div>

if div element is present on page, hide other element

I have a dynamic web form, I'd like to detect if an element is visible; and if it is hide another element of mine. I have the below attempt, but this is not working stabilily; i.e. the element isn't always hiding. A better technique out there?
setInterval( myValidateFunction2, 1000);
function myValidateFunction2 () {
var inElgbl = document.getElementById('field_52_116');
if (typeof(inElgbl) != 'undefined' && inElgbl != null)
{
document.getElementById('field_52_24').style.display = "none";
}
};
It is by default display: none; but may become display: block; if it becomes display: block; I would like to display: none; my other div elem.
Consider an element to be visible if it consumes space in the document. For most purposes, this is exactly what you want.
Try this:
setInterval( myValidateFunction2, 1000);
function myValidateFunction2 () {
var inElgbl = document.getElementById('field_52_116');
if (inElgbl.offsetWidth <= 0 && inElgbl.offsetHeight <= 0)
{
document.getElementById('field_52_24').style.display = "none";
}
};
Probably the most stable way to do this would be using a DOM Mutation Observer and setting it up to watch the document or section of the document that could get the element in question.
In the example below, I'll set up an observer to watch an initially empty div and after I've set it up, I'll dynamically add the element we're supposed to be on the lookout for. You'll see that the element does not wind up getting displayed.
// Select the node that will be observed for mutations
var targetNode = document.getElementById('parent');
// Options for the observer (which mutations to observe)
var config = { attributes: true, childList: true, subtree: true };
// Callback function to execute when mutations are observed
function callback(mutationsList, observer) {
// We only need to test to see if node is truthy, which it will be if it exists
if (document.getElementById('field_52_116')){
document.getElementById('field_52_24').style.display = "none";
console.log("Node detected! Removing....");
}
};
// Create an observer instance linked to the callback function
var observer = new MutationObserver(callback);
// Start observing the target node for configured mutations
observer.observe(targetNode, config);
// So, we'll add the node to test
let newNode = document.createElement("div");
newNode.textContent = "I'm here so the other node should be hidden!";
newNode.id = "field_52_116";
targetNode.appendChild(newNode);
// Later, you can stop observing if needed
// observer.disconnect();
<div id="parent"></div>
<div id='field_52_24'>ELement to hide</div>

Toggle a specific Text with JavaScript not working as expected

I have a toggle button that changes a bit of text. The problem I run into is if I have 2 words and I want to change the text of one it changes one but when I toggle it off style is removed from both spans instead of the span of the selected text.
How can I remove the span from the specific text selected and leave the span on the other text?
function headuppercase(e) {
tags('span', 'sC');
}
function tags(tag, clas) {
var ele = document.createElement(tag);
ele.classList.add(clas);
wrap(ele);
}
function wrap(tags) {
var el = document.querySelector('span.sC');
sel = window.getSelection();
if (!el) {
if (sel.rangeCount && sel.getRangeAt) {
range = sel.getRangeAt(0);
}
document.designMode = "on";
if (range) {
sel.removeAllRanges();
sel.addRange(range);
}
range.surroundContents(tags);
} else {
var parent = el.parentNode;
while (el.firstChild) parent.insertBefore(el.firstChild, el);
parent.removeChild(el);
}
document.designMode = "off";
}
.ourbutton {
padding: 5px;
float: left;
font-variant: small-caps;
}
.container {
width: 200px;
height: 300px;
float: left;
}
.spanA {
width: 100px;
height: 80px;
max-width: 200px;
max-height: 300px;
float: left;
border: thin blue solid;
}
.sC {
font-variant: small-caps;
}
<button class="ourbutton" type="button" onclick="headuppercase();">Tt</button>
<div class="container">
<span class="spanA" contenteditable="true"></span>
</div>
No jQuery please. Thanks You!
The issue is with this document.querySelector('span.sC'). In all the case it will select the first span with sC which is not good as you have to deal with the current one.
Here is an idea of fix:
function headuppercase(e) {
tags('span', 'sC');
}
function tags(tag, clas) {
var ele = document.createElement(tag);
ele.classList.add(clas);
wrap(ele);
}
function wrap(tags) {
sel = window.getSelection();
if (sel.rangeCount && sel.getRangeAt) {
range = sel.getRangeAt(0);
}
document.designMode = "on";
if (range) {
sel.removeAllRanges();
sel.addRange(range);
}
range.surroundContents(tags);
if(tags.querySelector('.sC')) {
tags.classList.remove('sC');
tags.innerHTML=tags.querySelector('.sC').innerHTML;
}
document.designMode = "off";
}
.ourbutton {
padding: 5px;
float: left;
font-variant: small-caps;
}
.container {
width: 200px;
height: 300px;
float: left;
}
.spanA {
width: 100px;
height: 80px;
max-width: 200px;
max-height: 300px;
float: left;
border: thin blue solid;
}
.sC {
font-variant: small-caps;
}
<button class="ourbutton" type="button" onclick="headuppercase();">Tt</button>
<div class="container">
<span class="spanA" contenteditable="true"></span>
</div>
This is not so easily achievable without a library because of all the edge cases you need to handle. I will first touch on some edge cases and then give you an example on how to implement them.
Simple case
Say we have the following string inside a text Node
"Nobody wants too complex code because it becomes unmanageable."
Consider a user who selected the words "Nobody wants" and pressed the toggle small caps button. You should end up with something looking like this (where the bold segments represent text in small-caps):
"Nobody wants too complex code because it becomes unmanageable."
This is an easy case. Just wrap the segment "Nobody wants" inside a <span> and give it the sC class. The same goes for all other segments that are not yet in small caps. Nothing too hard here.
Edge case 1
But say you are in the following state (again, bold segments represent text in small-caps) :
"Nobody wants too complex code because it becomes unmanageable."
When the user selects and toggles the word "becomes" things become complicated. You have to:
Remove the last two words from their containing <span class="sC"> element
Add the word "becomes" after the span (from step 1.) and make sure it is not contained in a <span> that has the className sC.
Add the word "unmanageable" inside a new <span class="sC"> element after the text Node "becomes" (that was inserted in step 2.)
Edge case 2
Or say you are in the following state (again, bold segments represent text in small-caps) :
"Nobody wants too complex code because it becomes unmanageable."
What should happen when somebody selects and toggles the segment "wants too complex code because" ? One could say: make every character in this segment
small-caps : when it is not
back to normal : when it is currently small-caps
It's easy to see that you will again need a lot of splitting existing span elements, creating new text nodes, and so forth
Edge case N
Say you start with a nested list
A
B
C
D
and a user selects the two last items at once. Then you need to wrap each item separately into a span.
First step towards a solution
While it is not clear in your question how all the edge cases should be handled, here is a first step towards a solution.
const allCapsClass = 'sC'
function toggleCase() {
const selection = window.getSelection()
if (selection && selection.rangeCount>0) {
const spans = wrapInNonNestedSpanners(selection.getRangeAt(0))
for (const span of spans) {
const classes = span.classList
const action = classes.contains(allCapsClass) ? 'remove' : 'add'
classes[action](allCapsClass)
}
}
}
const spannerClassName = "non-nested-spanner"
const spannerQuerySelector = `.${spannerClassName}`
function wrapInNonNestedSpanners(range) {
const containingSpanner = getContainingSpanner(range)
const result = []
if (containingSpanner != null) { // Edge case 1
const endRange = document.createRange() // contents of the span after range
endRange.selectNode(containingSpanner)
endRange.setStart(range.endContainer, range.endOffset)
const endContents = endRange.cloneContents()
const wrappedSelectionContents = containingSpanner.cloneNode(false)
wrappedSelectionContents.appendChild(range.cloneContents())
endRange.deleteContents()
range.deleteContents()
const parent = containingSpanner.parentNode
const next = containingSpanner.nextSibling
parent.insertBefore(wrappedSelectionContents, next)
result.push(wrappedSelectionContents)
if (!isEmptySpanner(endContents.childNodes[0])) parent.insertBefore(endContents, next)
if (isEmptySpanner(containingSpanner)) parent.removeChild(containingSpanner)
const newSelection = document.createRange()
newSelection.selectNode(wrappedSelectionContents)
window.getSelection().removeAllRanges()
window.getSelection().addRange(newSelection)
} else { // Edge case 2
const contents = range.extractContents()
const spanners = contents.querySelectorAll(spannerQuerySelector)
let endRange = document.createRange() // range before the span
for (let index = spanners.length-1; index>=0; index--) {
const spanner = spanners[index]
endRange.selectNodeContents(contents)
endRange.setStartAfter(spanner)
if (!endRange.collapsed) {
const wrappedEndContents = createSpannerWrapping(endRange.extractContents())
range.insertNode(wrappedEndContents)
result.unshift(wrappedEndContents)
}
range.insertNode(spanner)
result.unshift(spanner)
}
const rest = createSpannerWrapping(contents)
if (!isEmptySpanner(rest)) {
range.insertNode(rest)
result.unshift(rest)
}
}
return result
}
function getContainingSpanner(range) {
let cursor = range.commonAncestorContainer
if (cursor.classList == undefined) cursor = cursor.parentElement
while (cursor.parentElement != null) {
if (cursor.classList.contains(spannerClassName)) return cursor
cursor = cursor.parentElement
}
return null
}
function createSpannerWrapping(childNode) {
const spanner = document.createElement('span')
spanner.classList.add(spannerClassName)
spanner.appendChild(childNode)
return spanner
}
function isEmptySpanner(spanner) {
if (spanner.childNodes.length == 0) return true
else if (spanner.childNodes.length == 1) {
const node = spanner.childNodes[0]
return node instanceof Text && node.length == 0
}
return false
}
.sC {
font-variant: small-caps;
}
<section contenteditable>
Hello world this is some text
</section>
<button onclick="toggleCase()">Toggle case</button>

Categories