+ 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
This commit is contained in:
Max 2023-07-30 04:18:17 +00:00
parent c61f0c0198
commit b5ef64485f
4 changed files with 231 additions and 145 deletions

View File

@ -592,6 +592,15 @@ padding-right: 10px;
color: var(--main-accent); color: var(--main-accent);
opacity: 1; 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;
}
} }

View File

@ -173,7 +173,8 @@
<span class="status-menu" v-on:click=" hash=0; save()"> <span class="status-menu" v-on:click=" hash=0; save()">
<span v-if="diffsApplied > 0"> <span v-if="diffsApplied > 0">
+{{ diffsApplied }} Unsaved Changes <i class="blue wave square icon"></i>
+{{ diffsApplied }}
</span> </span>
<span v-if="usersOnNote > 1" :data-tooltip="`Viewers`" data-position="left center"> <span v-if="usersOnNote > 1" :data-tooltip="`Viewers`" data-position="left center">
@ -359,6 +360,8 @@
const dmp = new DiffMatchPatch.diff_match_patch() const dmp = new DiffMatchPatch.diff_match_patch()
import SquireButtonFunctions from '@/mixins/SquireButtonFunctions.js' import SquireButtonFunctions from '@/mixins/SquireButtonFunctions.js'
let rawNoteText = '' // Used for comparing and generating diffs
export default { export default {
name: 'NoteInputPanel', name: 'NoteInputPanel',
@ -390,7 +393,6 @@
created: '', created: '',
updated: '', updated: '',
shareUsername: null, shareUsername: null,
// diffNoteText: '',
statusText: 'saved', statusText: 'saved',
lastNoteHash: null, lastNoteHash: null,
saveDebounce: null, //Prevent save from being called numerous times quickly saveDebounce: null, //Prevent save from being called numerous times quickly
@ -428,6 +430,7 @@
//Used to restore caret position //Used to restore caret position
lastRange: null, lastRange: null,
startOffset: 0, startOffset: 0,
childIndex: null,
//Tag Display //Tag Display
allTags: [], allTags: [],
@ -570,33 +573,31 @@
}) })
}, },
initSquire(){ initSquireEvents(){
//Set up squire and load note text //Set up squire and load note text
this.setText(this.noteText) 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 //focus on open, not on mobile, it causes the keyboard to pop up, thats annoying
if(!this.$store.getters.getIsUserOnMobile){ if(!this.$store.getters.getIsUserOnMobile){
this.editor.focus() this.editor.focus()
this.editor.moveCursorToEnd() this.editor.moveCursorToEnd()
} }
//Set up websockets after squire is set up
setTimeout(() => {
this.setupWebSockets()
}, 500)
this.editor.addEventListener('cursor', e => { this.editor.addEventListener('cursor', e => {
//Save range to replace cursor if someone else makes an update this.saveCaretPosition(e)
this.lastRange = e.range
this.startOffset = parseInt(e.range.startOffset)
return
}) })
//Change button states on editor when element is active //Change button states on editor when element is active
//eg; Bold button turns green when on bold text //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 //Click Event - Open links when clicked in editor or toggle checks
this.editor.addEventListener('click', e => { this.editor.addEventListener('click', e => {
@ -681,10 +682,17 @@
}) })
}) })
//Bind event handlers
this.editor.addEventListener('keyup', event => { this.editor.addEventListener('keyup', event => {
this.onKeyup(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 => { // this.editor.addEventListener("dragstart", e => {
@ -692,12 +700,6 @@
// console.log(e) // console.log(e)
// if(){} // if(){}
// }); // });
//Show and hide additional toolbars
// this.editor.addEventListener('focus', e => {
// })
// this.editor.addEventListener('blur', e => {
// })
}, },
openEditAttachment(){ openEditAttachment(){
@ -760,13 +762,18 @@
//Setup all responsive vue data //Setup all responsive vue data
this.setupLoadedNoteData(response) this.setupLoadedNoteData(response)
this.loading = false
this.$nextTick(() => { this.$nextTick(() => {
//Adjust note title size after load //Adjust note title size after load
this.titleResize() 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.created = response.data.created
this.updated = response.data.updated this.updated = response.data.updated
this.lastInteractionTimestamp = +new Date this.lastInteractionTimestamp = +new Date
this.noteTitle = '' this.noteTitle = response.data.title || ''
if(response.data.title){
this.noteTitle = response.data.title
}
this.noteText = response.data.text this.noteText = response.data.text
this.lastNoteHash = this.hashString( response.data.text ) this.lastNoteHash = this.hashString( response.data.text )
// this.diffNoteText = response.data.text
//Setup note tags //Setup note tags
this.allTags = response.data.tags ? response.data.tags.split(','):[] this.allTags = response.data.tags ? response.data.tags.split(','):[]
@ -811,77 +815,167 @@
return true 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 //Called on squire event for keyup
diffText(event){ diffText(event){
// console.log(event.type)
//Diff the changed lines only const diffEvents = ['keyup','pathChange', 'click']
let oldText = this.noteText // only process changes on certain events
// let newText = this.getText() if( !diffEvents.includes(event?.type) ){
let newText = document.getElementById('squire-id').innerHTML return
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,
} }
this.$io.emit('note_diff', newPatch) clearTimeout(this.diffTextTimeout)
}, this.diffTextTimeout = setTimeout(() => {
patchText(incomingPatchs){
return new Promise((resolve, reject) => {
if(incomingPatchs == null){ return resolve(true) } // Current Editor Text
if(incomingPatchs.length == 0){ return resolve(true) } const liveEditorElm = document.getElementById('squire-id')
// let currentText = this.getText() // virtual element for selecting div
let currentText = document.getElementById('squire-id').innerHTML let virtualEditorElm = document.createElement('div')
virtualEditorElm.innerHTML = rawNoteText
//Convert text of all new patches into patches array // element at cursor
let patches = [] const elmAtCaret = window.getSelection().getRangeAt(0).startContainer.parentNode
incomingPatchs.forEach(patch => {
if(patch.time <= this.updated){ // Remove beginngin selector from path, make it more generic
return 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)) workingPath = workingPathParts.join(' > ')
})
if(workingPath){
if(patches.length == 0){ // Select text from virtual editor text
return resolve(true) selectedDivText = selectedDivText.querySelector(workingPath)
// select text from current editor text
newSelectedDivText = newSelectedDivText.querySelector(workingPath)
}
} }
var results = dmp.patch_apply(patches, currentText); const oldDivText = selectedDivText.innerHTML
let newText = results[0] const newDivText = newSelectedDivText.innerHTML
this.noteText = newText if(oldDivText == newDivText){ return }
// this.editor.setHTML(newText)
document.getElementById('squire-id').innerHTML = newText
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){ onKeyup(event){
this.statusText = 'modified' this.statusText = 'modified'
// Small debounce on diff generation
clearTimeout(this.diffTextTimeout)
this.diffTextTimeout = setTimeout(() => {
this.diffText()
}, 25)
//Save after x seconds //Save after x seconds
clearTimeout(this.editDebounce) clearTimeout(this.editDebounce)
this.editDebounce = setTimeout(() => { this.editDebounce = setTimeout(() => {
@ -1030,11 +1124,11 @@
}) })
}, },
destroyWebSockets(){ destroyWebSockets(){
// this.$io.removeListener('past_diffs') this.$io.removeListener('past_diffs')
// this.$io.removeListener('update_user_count') this.$io.removeListener('update_user_count')
// this.$io.removeListener('incoming_diff') this.$io.removeListener('incoming_diff')
}, },
setupWebSockets(){ initWebsocketEvents(){
//Tell server to push this note into a room //Tell server to push this note into a room
this.$io.emit('join_room', this.rawTextId ) this.$io.emit('join_room', this.rawTextId )
@ -1050,37 +1144,55 @@
this.diffsApplied = diffSinceLastUpdate.length this.diffsApplied = diffSinceLastUpdate.length
// console.log('Got Diffs Total -> ', diffSinceLastUpdate) // console.log('Got Diffs Total -> ', diffSinceLastUpdate)
} }
// console.log(diffSinceLastUpdate)
this.patchText(diffSinceLastUpdate) this.patchText(diffSinceLastUpdate)
.then(() => {
this.restoreCaretPosition()
})
}) })
this.$io.on('incoming_diff', incomingDiff => { 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]) this.patchText([incomingDiff])
.then(() => { .then(() => {
this.restoreCaretPosition()
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)
}) })
}) })
}, },
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(){ titleResize(){
//Resize the title field //Resize the title field
let element = this.$refs.titleTextarea let element = this.$refs.titleTextarea
@ -1102,6 +1214,11 @@
z-index: 1019; z-index: 1019;
text-align: right; text-align: right;
} }
.status-menu span + span {
border-left: 1px solid #ccc;
margin-left: 4px;
padding-left: 4px;
}
.font-color-bar { .font-color-bar {
/*width: calc(100% - 8px);*/ /*width: calc(100% - 8px);*/

View File

@ -36,10 +36,6 @@
/> />
<paste-button /> <paste-button />
<span class="ui grey text text-fix">
Active Sessions {{ $store.getters.getActiveSessions }}
</span>
</div> </div>
@ -571,7 +567,7 @@
// @TODO Don't even trigger this if the note wasn't changed // @TODO Don't even trigger this if the note wasn't changed
updateSingleNote(noteId, focuseAndAnimate = true){ updateSingleNote(noteId, focuseAndAnimate = true){
console.log('updating single note', noteId) // console.log('updating single note', noteId)
noteId = parseInt(noteId) noteId = parseInt(noteId)

View File

@ -114,29 +114,12 @@ io.on('connection', function(socket){
//Emit all sorted diffs to user //Emit all sorted diffs to user
socket.emit('past_diffs', noteDiffs[rawTextId]) socket.emit('past_diffs', noteDiffs[rawTextId])
} else {
socket.emit('past_diffs', null)
} }
const usersInRoom = io.sockets.adapter.rooms[rawTextId] const usersInRoom = io.sockets.adapter.rooms[rawTextId]
if(usersInRoom){ if(usersInRoom){
//Update users in room count //Update users in room count
io.to(rawTextId).emit('update_user_count', usersInRoom.length) 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) noteDiffs[noteId].push(data)
//Remove duplicate diffs if they exist // Go over each user in this note-room
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.
io.in(noteId).clients((error, clients) => { io.in(noteId).clients((error, clients) => {
if (error) throw error; 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 => { clients.forEach(socketId => {
// only send off diff if user
if(socketId != socket.id){ if(socketId != socket.id){
io.to(socketId).emit('incoming_diff', data) io.to(socketId).emit('incoming_diff', data)
} }
@ -213,7 +178,6 @@ io.on('connection', function(socket){
} }
} }
noteDiffs[checkpoint.rawTextId] = diffSet.slice(0, sliceTo) noteDiffs[checkpoint.rawTextId] = diffSet.slice(0, sliceTo)
if(noteDiffs[checkpoint.rawTextId].length == 0){ if(noteDiffs[checkpoint.rawTextId].length == 0){