From b5ef64485fd464af373c380b4a0e04af5064d9e2 Mon Sep 17 00:00:00 2001
From: Max <solidscribe@pm.me>
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 @@
 					<span class="status-menu" v-on:click=" hash=0; save()">
 
 						<span v-if="diffsApplied > 0">
-							+{{ diffsApplied }} Unsaved Changes
+							<i class="blue wave square icon"></i>
+							+{{ diffsApplied }}
 						</span>
 
 						<span v-if="usersOnNote > 1" :data-tooltip="`Viewers`" data-position="left center">
@@ -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 @@
 						/>
 
 						<paste-button />
-
-						<span class="ui grey text text-fix">
-							Active Sessions {{ $store.getters.getActiveSessions }}
-						</span>
 						
 					</div>
 
@@ -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){