From b5ef64485fd464af373c380b4a0e04af5064d9e2 Mon Sep 17 00:00:00 2001 From: Max Date: Sun, 30 Jul 2023 04:18:17 +0000 Subject: [PATCH] + Giant update to multiple users editing notes. - Edits are done per DOM element, making diffs smaller and faster - Multiple users can now edit the same note witheout it turning into a gaint mess - Caret position is saved better and wont jump around as much + Removed Active sessions text --- client/src/assets/semantic-helper.css | 9 + client/src/components/NoteInputPanel.vue | 319 ++++++++++++++++------- client/src/pages/NotesPage.vue | 6 +- server/index.js | 42 +-- 4 files changed, 231 insertions(+), 145 deletions(-) diff --git a/client/src/assets/semantic-helper.css b/client/src/assets/semantic-helper.css index 22c4b5b..4307ef9 100644 --- a/client/src/assets/semantic-helper.css +++ b/client/src/assets/semantic-helper.css @@ -592,6 +592,15 @@ padding-right: 10px; color: var(--main-accent); opacity: 1; } + + /* Remove indent line on mobile */ + .note-card-text > ol > ol, + .squire-box > ol > ol, + .note-card-text > ul > ul, + .squire-box > ul > ul + { + border-left: none; + } } diff --git a/client/src/components/NoteInputPanel.vue b/client/src/components/NoteInputPanel.vue index 037a8cb..c7109cd 100644 --- a/client/src/components/NoteInputPanel.vue +++ b/client/src/components/NoteInputPanel.vue @@ -173,7 +173,8 @@ - +{{ diffsApplied }} Unsaved Changes + + +{{ diffsApplied }} @@ -359,6 +360,8 @@ const dmp = new DiffMatchPatch.diff_match_patch() import SquireButtonFunctions from '@/mixins/SquireButtonFunctions.js' + + let rawNoteText = '' // Used for comparing and generating diffs export default { name: 'NoteInputPanel', @@ -390,7 +393,6 @@ created: '', updated: '', shareUsername: null, - // diffNoteText: '', statusText: 'saved', lastNoteHash: null, saveDebounce: null, //Prevent save from being called numerous times quickly @@ -428,6 +430,7 @@ //Used to restore caret position lastRange: null, startOffset: 0, + childIndex: null, //Tag Display allTags: [], @@ -570,33 +573,31 @@ }) }, - initSquire(){ + initSquireEvents(){ //Set up squire and load note text this.setText(this.noteText) + // Use squire box HTML for diff/patch changes + rawNoteText = document.getElementById('squire-id').innerHTML + //focus on open, not on mobile, it causes the keyboard to pop up, thats annoying if(!this.$store.getters.getIsUserOnMobile){ this.editor.focus() this.editor.moveCursorToEnd() } - //Set up websockets after squire is set up - setTimeout(() => { - this.setupWebSockets() - }, 500) - this.editor.addEventListener('cursor', e => { - //Save range to replace cursor if someone else makes an update - this.lastRange = e.range - this.startOffset = parseInt(e.range.startOffset) - return + this.saveCaretPosition(e) }) //Change button states on editor when element is active //eg; Bold button turns green when on bold text - this.editor.addEventListener('pathChange', e => this.pathChangeEvent(e)) + this.editor.addEventListener('pathChange', e => { + this.pathChangeEvent(e) + this.diffText(e) + }) //Click Event - Open links when clicked in editor or toggle checks this.editor.addEventListener('click', e => { @@ -681,10 +682,17 @@ }) }) - //Bind event handlers this.editor.addEventListener('keyup', event => { - this.onKeyup(event) + this.diffText(event) + }) + + this.editor.addEventListener('focus', e => { + // this.diffText(e) + }) + + this.editor.addEventListener('blur', e => { + this.diffText(e) }) // this.editor.addEventListener("dragstart", e => { @@ -692,12 +700,6 @@ // console.log(e) // if(){} // }); - - //Show and hide additional toolbars - // this.editor.addEventListener('focus', e => { - // }) - // this.editor.addEventListener('blur', e => { - // }) }, openEditAttachment(){ @@ -760,13 +762,18 @@ //Setup all responsive vue data this.setupLoadedNoteData(response) - this.loading = false - this.$nextTick(() => { //Adjust note title size after load this.titleResize() - this.initSquire() + this.initSquireEvents() + + //Set up websockets after squire is set up + setTimeout(() => { + this.initWebsocketEvents() + + this.loading = false + }, 500) }) }) @@ -786,14 +793,11 @@ this.created = response.data.created this.updated = response.data.updated this.lastInteractionTimestamp = +new Date - this.noteTitle = '' - if(response.data.title){ - this.noteTitle = response.data.title - } + this.noteTitle = response.data.title || '' this.noteText = response.data.text this.lastNoteHash = this.hashString( response.data.text ) - // this.diffNoteText = response.data.text + //Setup note tags this.allTags = response.data.tags ? response.data.tags.split(','):[] @@ -811,77 +815,167 @@ return true + }, + generateSelector(el){ + + if (!(el instanceof Element)) + return; + + var path = []; + while (el.nodeType === Node.ELEMENT_NODE) { + var selector = el.nodeName.toLowerCase(); + if (el.id) { + selector += '#' + el.id; + path.unshift(selector); + break; + } else { + var sib = el, nth = 1; + while (sib = sib.previousElementSibling) { + if (sib.nodeName.toLowerCase() == selector) + nth++; + } + if (nth != 1) + selector += ":nth-of-type("+nth+")"; + } + path.unshift(selector); + el = el.parentNode; + } + return path.join(" > "); }, //Called on squire event for keyup diffText(event){ + // console.log(event.type) - //Diff the changed lines only + const diffEvents = ['keyup','pathChange', 'click'] - let oldText = this.noteText - // let newText = this.getText() - let newText = document.getElementById('squire-id').innerHTML - - const diff = dmp.diff_main(oldText, newText) - // dmp.diff_cleanupSemantic(diff) - const patch_list = dmp.patch_make(oldText, newText, diff); - const patch_text = dmp.patch_toText(patch_list); - - if(patch_text == ''){ return } - - //Save computed diff text - this.noteText = newText - - let newPatch = { - id: this.rawTextId, - diff: patch_text, + // only process changes on certain events + if( !diffEvents.includes(event?.type) ){ + return } - this.$io.emit('note_diff', newPatch) - }, - patchText(incomingPatchs){ - return new Promise((resolve, reject) => { + clearTimeout(this.diffTextTimeout) + this.diffTextTimeout = setTimeout(() => { - if(incomingPatchs == null){ return resolve(true) } - if(incomingPatchs.length == 0){ return resolve(true) } + // Current Editor Text + const liveEditorElm = document.getElementById('squire-id') - // let currentText = this.getText() - let currentText = document.getElementById('squire-id').innerHTML + // virtual element for selecting div + let virtualEditorElm = document.createElement('div') + virtualEditorElm.innerHTML = rawNoteText - //Convert text of all new patches into patches array - let patches = [] - incomingPatchs.forEach(patch => { + // element at cursor + const elmAtCaret = window.getSelection().getRangeAt(0).startContainer.parentNode - if(patch.time <= this.updated){ - return + // Remove beginngin selector from path, make it more generic + const path = this.generateSelector(elmAtCaret).replace('div#squire-id > ','') + let workingPath = '' + + // default to entire note text, select down if path + let selectedDivText = virtualEditorElm + let newSelectedDivText = liveEditorElm + + if( path != ''){ + + const pathParts = path.split(' > ') + let testedPathParts = [] + let workingPathParts = [] + + for (var i = 0; i < pathParts.length; i++) { + + testedPathParts.push(pathParts[i]) + let currentTestPath = testedPathParts.join(' > ') + // console.log('elm test ',i,currentTestPath) + let elmTest = virtualEditorElm.querySelector(currentTestPath) + + if(!elmTest){ + break + } + + workingPathParts.push(pathParts[i]) } - patches.push(...dmp.patch_fromText(patch.diff)) - }) - - if(patches.length == 0){ - return resolve(true) + workingPath = workingPathParts.join(' > ') + + if(workingPath){ + // Select text from virtual editor text + selectedDivText = selectedDivText.querySelector(workingPath) + // select text from current editor text + newSelectedDivText = newSelectedDivText.querySelector(workingPath) + } + } - var results = dmp.patch_apply(patches, currentText); - let newText = results[0] + const oldDivText = selectedDivText.innerHTML + const newDivText = newSelectedDivText.innerHTML - this.noteText = newText - // this.editor.setHTML(newText) - document.getElementById('squire-id').innerHTML = newText + if(oldDivText == newDivText){ return } - return resolve(true) + const diff = dmp.diff_main(oldDivText, newDivText) + const patch_list = dmp.patch_make(oldDivText, newDivText, diff) + const patch_text = dmp.patch_toText(patch_list) + + // save raw text for future diffs + rawNoteText = liveEditorElm.innerHTML + + let newPatch = { + id: this.rawTextId, + diff: patch_text, + path: path, + // testing metrics + 'old text':oldDivText, + 'new text':newDivText, + 'starting path':path, + 'working path':workingPath, + } + + // console.log('Sending out patch', newPatch) + + this.$io.emit('note_diff', newPatch) + }, 100) + + }, + patchText(incomingPatchs){ + // console.log('incoming patches ', incomingPatchs) + return new Promise((resolve, reject) => { + + const editorElement = document.getElementById('squire-id') + + // iterate over incoming patches because they apply to specific divs + incomingPatchs.forEach(patch => { + + // default to parent element, change to child if set + let editedElement = editorElement + if(patch.path){ + editedElement = editorElement.querySelector(patch.path) + } + + if( !editedElement ){ + editedElement = editorElement + } + + // convert patch from text and then apply to selected element + const patches = dmp.patch_fromText(patch.diff) + const patchResults = dmp.patch_apply(patches, editedElement.innerHTML) + + // console.log('Patch results') + // console.log([patch.path, editedElement.innerHTML, patchResults[0]]) + + // patch changed directly into editor + editedElement.innerHTML = patchResults[0] + }) + + // save editor HTML after change for future comparisons + rawNoteText = editorElement.innerHTML + + this.$nextTick(() => { + return resolve(true) + }) }) }, onKeyup(event){ this.statusText = 'modified' - // Small debounce on diff generation - clearTimeout(this.diffTextTimeout) - this.diffTextTimeout = setTimeout(() => { - this.diffText() - }, 25) - //Save after x seconds clearTimeout(this.editDebounce) this.editDebounce = setTimeout(() => { @@ -1030,11 +1124,11 @@ }) }, destroyWebSockets(){ - // this.$io.removeListener('past_diffs') - // this.$io.removeListener('update_user_count') - // this.$io.removeListener('incoming_diff') + this.$io.removeListener('past_diffs') + this.$io.removeListener('update_user_count') + this.$io.removeListener('incoming_diff') }, - setupWebSockets(){ + initWebsocketEvents(){ //Tell server to push this note into a room this.$io.emit('join_room', this.rawTextId ) @@ -1050,37 +1144,55 @@ this.diffsApplied = diffSinceLastUpdate.length // console.log('Got Diffs Total -> ', diffSinceLastUpdate) } + // console.log(diffSinceLastUpdate) this.patchText(diffSinceLastUpdate) + .then(() => { + this.restoreCaretPosition() + }) }) this.$io.on('incoming_diff', incomingDiff => { - //Save current caret position - //Find index of child element based on past range - const element = window.getSelection().getRangeAt(0).startContainer.parentNode - const textLines = document.getElementById('squire-id').children - const childIndex = [...textLines].indexOf(element) - this.patchText([incomingDiff]) .then(() => { - - if(childIndex == -1){ - console.log('Cursor position lost. Div being updated was lost.') - return - } - - //Reset caret position - //Find child index of old range and create a new one - let allChildren = document.getElementById('squire-id').children - const newLine = allChildren[childIndex].firstChild - let range = document.createRange() - range.setStart(newLine, this.startOffset) - range.setEnd(newLine, this.startOffset) - this.editor.setSelection(range) + this.restoreCaretPosition() }) }) }, + saveCaretPosition(event){ + + //Find index of child element based on past range + const element = window.getSelection().getRangeAt(0).startContainer.parentNode + + //Save range to replace cursor if someone else makes an update + this.lastRange = this.generateSelector(element) + this.startOffset = parseInt(event.range.startOffset) || 0 + + return + }, + restoreCaretPosition(){ + return new Promise((resolve, reject) => { + // This code is intended to restore caret position to previous location + // when a third party updates the note. + + if(!this.lastRange){ return resolve(true) } + + const editorElement = document.getElementById('squire-id') + const lastElement = editorElement.querySelector(this.lastRange) + + if( !lastElement ){ return resolve(true) } + + let range = document.createRange() + range.setStart(lastElement.firstChild, this.startOffset) + range.setEnd(lastElement.firstChild, this.startOffset) + + // Set range in editor element + this.editor.setSelection(range) + + return resolve(true) + }) + }, titleResize(){ //Resize the title field let element = this.$refs.titleTextarea @@ -1102,6 +1214,11 @@ z-index: 1019; text-align: right; } + .status-menu span + span { + border-left: 1px solid #ccc; + margin-left: 4px; + padding-left: 4px; + } .font-color-bar { /*width: calc(100% - 8px);*/ diff --git a/client/src/pages/NotesPage.vue b/client/src/pages/NotesPage.vue index 8d79d09..1f3a03c 100644 --- a/client/src/pages/NotesPage.vue +++ b/client/src/pages/NotesPage.vue @@ -36,10 +36,6 @@ /> - - - Active Sessions {{ $store.getters.getActiveSessions }} - @@ -571,7 +567,7 @@ // @TODO Don't even trigger this if the note wasn't changed updateSingleNote(noteId, focuseAndAnimate = true){ - console.log('updating single note', noteId) + // console.log('updating single note', noteId) noteId = parseInt(noteId) diff --git a/server/index.js b/server/index.js index 43b1c6c..0c40996 100644 --- a/server/index.js +++ b/server/index.js @@ -114,29 +114,12 @@ io.on('connection', function(socket){ //Emit all sorted diffs to user socket.emit('past_diffs', noteDiffs[rawTextId]) - } else { - socket.emit('past_diffs', null) } const usersInRoom = io.sockets.adapter.rooms[rawTextId] if(usersInRoom){ //Update users in room count io.to(rawTextId).emit('update_user_count', usersInRoom.length) - - //Debugging text - prints out notes in limbo - let noteDiffKeys = Object.keys(noteDiffs) - let totalDiffs = 0 - noteDiffKeys.forEach(diffSetKey => { - if(noteDiffs[diffSetKey]){ - totalDiffs += noteDiffs[diffSetKey].length - } - }) - //Debugging Text - if(noteDiffKeys.length > 0){ - console.log('Total notes in limbo -> ', noteDiffKeys.length) - console.log('Total Diffs for all notes -> ', totalDiffs) - } - } }) @@ -162,31 +145,13 @@ io.on('connection', function(socket){ noteDiffs[noteId].push(data) - //Remove duplicate diffs if they exist - for (var i = noteDiffs[noteId].length - 1; i >= 0; i--) { - - let pastDiff = noteDiffs[noteId][i] - - for (var j = noteDiffs[noteId].length - 1; j >= 0; j--) { - let currentDiff = noteDiffs[noteId][j] - - if(i == j){ - continue - } - - if(currentDiff.diff == pastDiff.diff || currentDiff.time == pastDiff.time){ - console.log('Removing Duplicate') - noteDiffs[noteId].splice(i,1) - } - } - } - - //Each user joins a room when they open the app. + // Go over each user in this note-room io.in(noteId).clients((error, clients) => { if (error) throw error; - //Go through each client in note room and send them the diff + //Go through each client in note-room and send them the diff clients.forEach(socketId => { + // only send off diff if user if(socketId != socket.id){ io.to(socketId).emit('incoming_diff', data) } @@ -213,7 +178,6 @@ io.on('connection', function(socket){ } } - noteDiffs[checkpoint.rawTextId] = diffSet.slice(0, sliceTo) if(noteDiffs[checkpoint.rawTextId].length == 0){