Rewrite the code for the editor for better browser compat

Does not work well on mobile. That's on my to-do list, don't you worry!
This commit is contained in:
Gnome Ann 2021-09-27 13:11:15 -04:00
parent fd8968d14f
commit c3781e0e2f
4 changed files with 231 additions and 101 deletions

View File

@ -69,8 +69,10 @@ var gamestarted = false;
var editmode = false;
var connected = false;
var newly_loaded = true;
var current_editing_chunk = null;
var chunk_conflict = false;
var modified_chunks = new Set();
var empty_chunks = new Set();
var mutation_observer = null;
var gametext_bound = false;
var sman_allow_delete = false;
var sman_allow_rename = false;
@ -720,9 +722,6 @@ function setadventure(state) {
function autofocus(event) {
if(connected) {
if(event.target.tagName == "CHUNK") {
current_editing_chunk = event.target;
}
event.target.focus();
} else {
event.preventDefault();
@ -740,65 +739,6 @@ function chunkOnKeyDown(event) {
return;
}
// Allow left and right arrow keys (and backspace) to move between chunks
switch(event.keyCode) {
case 37: // left
case 39: // right
var old_range = getSelection().getRangeAt(0);
var old_range_start = old_range.startOffset;
var old_range_end = old_range.endOffset;
var old_range_ancestor = old_range.commonAncestorContainer;
var old_range_start_container = old_range.startContainer;
var old_range_end_container = old_range.endContainer;
setTimeout(function () {
// Wait a few milliseconds and check if the caret has moved
var new_selection = getSelection();
var new_range = new_selection.getRangeAt(0);
if(old_range_start != new_range.startOffset || old_range_end != new_range.endOffset || old_range_ancestor != new_range.commonAncestorContainer || old_range_start_container != new_range.startContainer || old_range_end_container != new_range.endContainer) {
return;
}
// If it hasn't moved, we're at the beginning or end of a chunk
// and the caret must be moved to a different chunk
var chunk = document.activeElement;
switch(event.keyCode) {
case 37: // left
if((chunk = chunk.previousSibling) && chunk.tagName == "CHUNK") {
var range = document.createRange();
range.selectNodeContents(chunk);
range.collapse(false);
new_selection.removeAllRanges();
new_selection.addRange(range);
}
break;
case 39: // right
if((chunk = chunk.nextSibling) && chunk.tagName == "CHUNK") {
chunk.focus();
}
}
}, 2);
return;
case 8: // backspace
var old_length = document.activeElement.innerText.length;
setTimeout(function () {
// Wait a few milliseconds and compare the chunk's length
if(old_length != document.activeElement.innerText.length) {
return;
}
// If it's the same, we're at the beginning of a chunk
if((chunk = document.activeElement.previousSibling) && chunk.tagName == "CHUNK") {
var range = document.createRange();
var selection = getSelection();
range.selectNodeContents(chunk);
range.collapse(false);
selection.removeAllRanges();
selection.addRange(range);
}
}, 2);
return
}
// Don't allow any edits if not connected to server
if(!connected) {
event.preventDefault();
@ -820,25 +760,6 @@ function chunkOnKeyDown(event) {
}
}
function submitEditedChunk(event) {
// Don't do anything if the current chunk hasn't been edited or if someone
// else overwrote it while you were busy lollygagging
if(current_editing_chunk === null || chunk_conflict) {
chunk_conflict = false;
return;
}
var chunk = current_editing_chunk;
current_editing_chunk = null;
// Submit the edited chunk if it's not empty, otherwise delete it
if(chunk.innerText.length) {
socket.send({'cmd': 'inlineedit', 'chunk': chunk.getAttribute("n"), 'data': chunk.innerText.replace(/\u00a0/g, " ")});
} else {
socket.send({'cmd': 'inlinedelete', 'data': chunk.getAttribute("n")});
}
}
function downloadStory(format) {
var filename_without_extension = storyname !== null ? storyname : "untitled";
@ -890,6 +811,179 @@ function downloadStory(format) {
URL.revokeObjectURL(objectURL);
}
function buildChunkSetFromNodeArray(nodes) {
var set = new Set();
for(var i = 0; i < nodes.length; i++) {
node = nodes[i];
while(node !== null && node.tagName !== "CHUNK") {
node = node.parentNode;
}
if(node === null) {
continue;
}
set.add(node.getAttribute("n"));
}
return set;
}
function getSelectedNodes() {
var range = rangy.getSelection().getRangeAt(0); // rangy is not a typo
var nodes = range.getNodes([1,3]);
nodes.push(range.startContainer);
nodes.push(range.endContainer);
return nodes
}
function applyChunkDeltas(nodes) {
var chunks = Array.from(buildChunkSetFromNodeArray(nodes));
for(var i = 0; i < chunks.length; i++) {
modified_chunks.add(chunks[i]);
}
setTimeout(function() {
var chunks = Array.from(modified_chunks);
var selected_chunks = buildChunkSetFromNodeArray(getSelectedNodes());
for(var i = 0; i < chunks.length; i++) {
var chunk = document.getElementById("n" + chunks[i]);
if(chunk && chunk.innerText.length != 0) {
if(!selected_chunks.has(chunks[i])) {
modified_chunks.delete(chunks[i]);
socket.send({'cmd': 'inlineedit', 'chunk': chunks[i], 'data': chunk.innerText.replace(/\u00a0/g, " ")});
}
empty_chunks.delete(chunks[i]);
} else {
if(!selected_chunks.has(chunks[i])) {
modified_chunks.delete(chunks[i]);
socket.send({'cmd': 'inlineedit', 'chunk': chunks[i], 'data': ''});
}
empty_chunks.add(chunks[i]);
}
}
}, 2);
}
function syncAllModifiedChunks(including_selected_chunks=false) {
var chunks = Array.from(modified_chunks);
var selected_chunks = buildChunkSetFromNodeArray(getSelectedNodes());
for(var i = 0; i < chunks.length; i++) {
if(including_selected_chunks || !selected_chunks.has(chunks[i])) {
modified_chunks.delete(chunks[i]);
var chunk = document.getElementById("n" + chunks[i]);
var data = chunk ? document.getElementById("n" + chunks[i]).innerText.replace(/\u00a0/g, " ") : "";
if(data.length == 0) {
empty_chunks.add(chunks[i]);
} else {
empty_chunks.delete(chunks[i]);
}
socket.send({'cmd': 'inlineedit', 'chunk': chunks[i], 'data': data});
}
}
}
function deleteEmptyChunks() {
var chunks = Array.from(empty_chunks);
for(var i = 0; i < chunks.length; i++) {
empty_chunks.delete(chunks[i]);
socket.send({'cmd': 'inlinedelete', 'data': chunks[i]});
}
}
function highlightEditingChunks() {
var chunks = $('chunk.editing').toArray();
var selected_chunks = buildChunkSetFromNodeArray(getSelectedNodes());
for(var i = 0; i < chunks.length; i++) {
var chunk = chunks[i];
if(!selected_chunks.has(chunks[i].getAttribute("n"))) {
unbindGametext();
$(chunk).removeClass('editing');
bindGametext();
}
}
chunks = Array.from(selected_chunks);
for(var i = 0; i < chunks.length; i++) {
var chunk = $("#n"+chunks[i]);
unbindGametext();
chunk.addClass('editing');
bindGametext();
}
}
// (Desktop client only) This gets run every time the text in a chunk is edited
// or a chunk is deleted
function chunkOnDOMMutate(mutations, observer) {
if(!gametext_bound) {
return;
}
var nodes = [];
for(var i = 0; i < mutations.length; i++) {
var mutation = mutations[i];
if(mutation.type === "childList") {
nodes = nodes.concat(Array.from(mutation.addedNodes), Array.from(mutation.removedNodes));
} else {
nodes.push(mutation.target);
}
}
applyChunkDeltas(nodes);
}
// This gets run every time you try to paste text into the editor
function chunkOnPaste(event) {
if(!gametext_bound) {
return;
}
// If possible, intercept paste events into the editor in order to always
// paste as plaintext
if(event.originalEvent.clipboardData && document.queryCommandSupported && document.execCommand && document.queryCommandSupported('insertText')) {
event.preventDefault();
document.execCommand('insertText', false, event.originalEvent.clipboardData.getData('text/plain'));
}
}
// (Desktop client only) This gets run every time the caret moves in the editor
function chunkOnSelectionChange(event) {
if(!gametext_bound) {
return;
}
setTimeout(function() {
syncAllModifiedChunks();
setTimeout(function() {
highlightEditingChunks();
}, 2);
}, 2);
}
// (Desktop client only) This gets run when you defocus the editor by clicking
// outside of the editor or by pressing escape or tab
function chunkOnFocusOut(event) {
if(!gametext_bound || event.target !== gametext) {
return;
}
setTimeout(function() {
if(document.activeElement === gametext || gametext.contains(document.activeElement)) {
return;
}
syncAllModifiedChunks(true);
setTimeout(function() {
var blurred = $("#gametext")[0] !== document.activeElement;
if(blurred) {
deleteEmptyChunks();
}
setTimeout(function() {
$("chunk").removeClass('editing');
}, 2);
}, 2);
}, 2);
}
function bindGametext() {
mutation_observer.observe($("#gametext")[0], {characterData: true, childList: true, subtree: true});
gametext_bound = true;
}
function unbindGametext() {
mutation_observer.disconnect();
gametext_bound = false;
}
//=================================================================//
// READY/RUNTIME
//=================================================================//
@ -973,11 +1067,11 @@ $(document).ready(function(){
format_menu.html("");
wi_menu.html("");
// Set up "Allow Editing"
$('body').on('input', autofocus).on('keydown', 'chunk', chunkOnKeyDown).on('focusout', 'chunk', submitEditedChunk);
$('#allowediting').prop('checked', allowedit).prop('disabled', false).change().on('change', function () {
$('body').on('input', autofocus);
$('#allowediting').prop('checked', allowedit).prop('disabled', false).change().off('change').on('change', function () {
if(allowtoggle) {
allowedit = $(this).prop('checked')
$("chunk").attr('contenteditable', allowedit)
allowedit = $(this).prop('checked');
$(gametext).attr('contenteditable', allowedit);
}
});
} else if(msg.cmd == "updatescreen") {
@ -987,13 +1081,11 @@ $(document).ready(function(){
action_mode = 0;
changemode();
}
// Send game content to Game Screen
if(allowedit && document.activeElement.tagName == "CHUNK") {
chunk_conflict = true;
}
unbindGametext();
modified_chunks = new Set();
empty_chunks = new Set();
game_text.html(msg.data);
// Make content editable if need be
$('chunk').attr('contenteditable', allowedit);
bindGametext();
// Scroll to bottom of text
if(newly_loaded) {
scrollToBottom();
@ -1007,15 +1099,16 @@ $(document).ready(function(){
const {index, html, last} = msg.data;
const existingChunk = game_text.children(`#n${index}`)
const newChunk = $(html);
unbindGametext();
if (existingChunk.length > 0) {
// Update existing chunk
existingChunk.before(newChunk);
existingChunk.remove();
} else {
} else if (!empty_chunks.has(index.toString())) {
// Append at the end
game_text.append(newChunk);
}
newChunk.attr('contenteditable', allowedit);
bindGametext();
hide([$('#curtain')]);
if(last) {
// Scroll to bottom of text if it's the last element
@ -1024,8 +1117,9 @@ $(document).ready(function(){
} else if(msg.cmd == "removechunk") {
hideMessage();
let index = msg.data;
// Remove the chunk
game_text.children(`#n${index}`).remove()
unbindGametext();
game_text.children(`#n${index}`).remove() // Remove the chunk
bindGametext();
hide([$('#curtain')]);
} else if(msg.cmd == "setgamestate") {
// Enable or Disable buttons
@ -1252,7 +1346,31 @@ $(document).ready(function(){
connect_status.removeClass("color_green");
connect_status.addClass("color_orange");
});
// Register editing events (desktop)
$(gametext).on('keydown',
chunkOnKeyDown
).on('paste',
chunkOnPaste
).on('focus',
chunkOnSelectionChange
).on('keydown',
chunkOnSelectionChange
).on('focusout',
chunkOnFocusOut
);
mutation_observer = new MutationObserver(chunkOnDOMMutate);
// This is required for the editor to work correctly in Firefox on desktop
// because the gods of HTML and JavaScript say so
$(document.body).on('focusin', function(event) {
setTimeout(function() {
if(document.activeElement !== gametext && gametext.contains(document.activeElement)) {
gametext.focus();
}
}, 2);
});
// Bind actions to UI buttons
button_send.on("click", function(ev) {
dosubmit();

View File

@ -11,12 +11,12 @@ chunk {
font-weight: bold;
}
chunk[contenteditable="true"]:focus, chunk[contenteditable="true"]:focus * {
chunk.editing, chunk.editing * {
color: #cdf !important;
font-weight: normal !important;
}
chunk, chunk * {
#gametext, chunk, chunk * {
outline: 0px solid transparent;
}

11
static/rangy-core.min.js vendored Normal file

File diff suppressed because one or more lines are too long

View File

@ -9,6 +9,7 @@
<script src="static/application.js?ver=0.15.0g"></script>
<script src="static/bootstrap.min.js"></script>
<script src="static/bootstrap-toggle.min.js"></script>
<script src="static/rangy-core.min.js"></script>
<link rel="stylesheet" href="static/bootstrap.min.css">
<link rel="stylesheet" href="static/bootstrap-toggle.min.css">
@ -81,7 +82,7 @@
</div>
<div class="layer-container">
<div class="layer-bottom row" id="gamescreen">
<span id="gametext"><p>...</p></span>
<span id="gametext" contenteditable="true"><p>...</p></span>
<div class="hidden" id="wimenu">
</div>
</div>