Fossil SCM
chat: added drag/drop support for files. Images get previewed like those pasted from the clipboard.
Commit
4c0146f180ef15b10ddd5914d26c32240c353d4f5aa76bc054c70df8b34e599a
Parent
cf789fa7e1ab0da…
2 files changed
+24
-4
+74
-17
+24
-4
| --- src/chat.c | ||
| +++ src/chat.c | ||
| @@ -84,10 +84,14 @@ | ||
| 84 | 84 | @ #chat-input-file { |
| 85 | 85 | @ display: flex; |
| 86 | 86 | @ flex-direction: row; |
| 87 | 87 | @ align-items: center; |
| 88 | 88 | @ } |
| 89 | + @ #chat-input-file > span, | |
| 90 | + @ #chat-input-file > input[type=file] { | |
| 91 | + @ align-self: flex-start; | |
| 92 | + @ } | |
| 89 | 93 | @ #chat-input-file > input { |
| 90 | 94 | @ flex: 1 0 auto; |
| 91 | 95 | @ } |
| 92 | 96 | @ .chat-timestamp { |
| 93 | 97 | @ font-family: monospace; |
| @@ -94,24 +98,40 @@ | ||
| 94 | 98 | @ font-size: 0.8em; |
| 95 | 99 | @ white-space: pre; |
| 96 | 100 | @ text-align: left; |
| 97 | 101 | @ opacity: 0.8; |
| 98 | 102 | @ } |
| 103 | + @ #chat-drop-zone { | |
| 104 | + @ box-sizing: content-box; | |
| 105 | + @ background-color: #e0e0e0; | |
| 106 | + @ flex: 3 1 auto; | |
| 107 | + @ padding: 0.5em 1em; | |
| 108 | + @ border: 1px solid #808080; | |
| 109 | + @ border-radius: 0.25em; | |
| 110 | + @ } | |
| 111 | + @ #chat-drop-zone.dragover { | |
| 112 | + @ border: 1px dashed green; | |
| 113 | + @ } | |
| 114 | + @ #chat-drop-details { | |
| 115 | + @ white-space: pre; | |
| 116 | + @ font-family: monospace; | |
| 117 | + @ } | |
| 99 | 118 | @ </style> |
| 100 | 119 | @ <form accept-encoding="utf-8" id="chat-form"> |
| 101 | 120 | @ <div id='chat-input-area'> |
| 102 | 121 | @ <div id='chat-input-line'> |
| 103 | - @ <input type="text" name="msg" id="sbox"\ | |
| 122 | + @ <input type="text" name="msg" id="sbox" \ | |
| 104 | 123 | @ placeholder="Type message here."> |
| 105 | 124 | @ <input type="submit" value="Send"> |
| 106 | 125 | @ </div> |
| 107 | 126 | @ <div id='chat-input-file'> |
| 108 | 127 | @ <span>File:</span> |
| 109 | 128 | @ <input type="file" name="file"> |
| 110 | - @ <div id='chat-pasted-image'> | |
| 111 | - @ Or paste an image from the clipboard, if supported by your | |
| 112 | - @ environment.<br><img> | |
| 129 | + @ <div id="chat-drop-zone"> | |
| 130 | + @ Or drag/drop a file in this spot, or paste an image from | |
| 131 | + @ the clipboard if supported by your environment. | |
| 132 | + @ <div id="chat-drop-details"></div> | |
| 113 | 133 | @ </div> |
| 114 | 134 | @ </div> |
| 115 | 135 | @ </div> |
| 116 | 136 | @ </form> |
| 117 | 137 | @ <hr> |
| 118 | 138 |
| --- src/chat.c | |
| +++ src/chat.c | |
| @@ -84,10 +84,14 @@ | |
| 84 | @ #chat-input-file { |
| 85 | @ display: flex; |
| 86 | @ flex-direction: row; |
| 87 | @ align-items: center; |
| 88 | @ } |
| 89 | @ #chat-input-file > input { |
| 90 | @ flex: 1 0 auto; |
| 91 | @ } |
| 92 | @ .chat-timestamp { |
| 93 | @ font-family: monospace; |
| @@ -94,24 +98,40 @@ | |
| 94 | @ font-size: 0.8em; |
| 95 | @ white-space: pre; |
| 96 | @ text-align: left; |
| 97 | @ opacity: 0.8; |
| 98 | @ } |
| 99 | @ </style> |
| 100 | @ <form accept-encoding="utf-8" id="chat-form"> |
| 101 | @ <div id='chat-input-area'> |
| 102 | @ <div id='chat-input-line'> |
| 103 | @ <input type="text" name="msg" id="sbox"\ |
| 104 | @ placeholder="Type message here."> |
| 105 | @ <input type="submit" value="Send"> |
| 106 | @ </div> |
| 107 | @ <div id='chat-input-file'> |
| 108 | @ <span>File:</span> |
| 109 | @ <input type="file" name="file"> |
| 110 | @ <div id='chat-pasted-image'> |
| 111 | @ Or paste an image from the clipboard, if supported by your |
| 112 | @ environment.<br><img> |
| 113 | @ </div> |
| 114 | @ </div> |
| 115 | @ </div> |
| 116 | @ </form> |
| 117 | @ <hr> |
| 118 |
| --- src/chat.c | |
| +++ src/chat.c | |
| @@ -84,10 +84,14 @@ | |
| 84 | @ #chat-input-file { |
| 85 | @ display: flex; |
| 86 | @ flex-direction: row; |
| 87 | @ align-items: center; |
| 88 | @ } |
| 89 | @ #chat-input-file > span, |
| 90 | @ #chat-input-file > input[type=file] { |
| 91 | @ align-self: flex-start; |
| 92 | @ } |
| 93 | @ #chat-input-file > input { |
| 94 | @ flex: 1 0 auto; |
| 95 | @ } |
| 96 | @ .chat-timestamp { |
| 97 | @ font-family: monospace; |
| @@ -94,24 +98,40 @@ | |
| 98 | @ font-size: 0.8em; |
| 99 | @ white-space: pre; |
| 100 | @ text-align: left; |
| 101 | @ opacity: 0.8; |
| 102 | @ } |
| 103 | @ #chat-drop-zone { |
| 104 | @ box-sizing: content-box; |
| 105 | @ background-color: #e0e0e0; |
| 106 | @ flex: 3 1 auto; |
| 107 | @ padding: 0.5em 1em; |
| 108 | @ border: 1px solid #808080; |
| 109 | @ border-radius: 0.25em; |
| 110 | @ } |
| 111 | @ #chat-drop-zone.dragover { |
| 112 | @ border: 1px dashed green; |
| 113 | @ } |
| 114 | @ #chat-drop-details { |
| 115 | @ white-space: pre; |
| 116 | @ font-family: monospace; |
| 117 | @ } |
| 118 | @ </style> |
| 119 | @ <form accept-encoding="utf-8" id="chat-form"> |
| 120 | @ <div id='chat-input-area'> |
| 121 | @ <div id='chat-input-line'> |
| 122 | @ <input type="text" name="msg" id="sbox" \ |
| 123 | @ placeholder="Type message here."> |
| 124 | @ <input type="submit" value="Send"> |
| 125 | @ </div> |
| 126 | @ <div id='chat-input-file'> |
| 127 | @ <span>File:</span> |
| 128 | @ <input type="file" name="file"> |
| 129 | @ <div id="chat-drop-zone"> |
| 130 | @ Or drag/drop a file in this spot, or paste an image from |
| 131 | @ the clipboard if supported by your environment. |
| 132 | @ <div id="chat-drop-details"></div> |
| 133 | @ </div> |
| 134 | @ </div> |
| 135 | @ </div> |
| 136 | @ </form> |
| 137 | @ <hr> |
| 138 |
+74
-17
| --- src/chat.js | ||
| +++ src/chat.js | ||
| @@ -1,29 +1,92 @@ | ||
| 1 | 1 | (function(){ |
| 2 | 2 | const form = document.querySelector('#chat-form'); |
| 3 | 3 | let mxMsg = 0; |
| 4 | - const F = window.fossil; | |
| 4 | + const F = window.fossil, D = F.dom; | |
| 5 | 5 | const _me = F.user.name; |
| 6 | - /* State for pasting an image from the clipboard */ | |
| 7 | - const ImagePasteState = { | |
| 8 | - imgTag: document.querySelector('#chat-pasted-image img'), | |
| 6 | + /* State for paste and drag/drop */ | |
| 7 | + const BlobXferState = { | |
| 8 | + dropZone: document.querySelector('#chat-drop-zone'), | |
| 9 | + dropDetails: document.querySelector('#chat-drop-details'), | |
| 10 | + imgTag: document.querySelector('#chat-drop-details img'), | |
| 9 | 11 | blob: undefined |
| 10 | 12 | }; |
| 13 | + /** Updates the paste/drop zone with details of the pasted/dropped | |
| 14 | + data. */ | |
| 15 | + const updateDropZoneContent = function(blob){ | |
| 16 | + const bx = BlobXferState, dd = bx.dropDetails; | |
| 17 | + bx.blob = blob; | |
| 18 | + D.clearElement(dd); | |
| 19 | + D.append(dd, D.br(), "Name: ", blob.name, | |
| 20 | + D.br(), "Size: ",blob.size); | |
| 21 | + if(blob.type && blob.type.startsWith("image/")){ | |
| 22 | + const img = D.img(); | |
| 23 | + D.append(dd, D.br(), img); | |
| 24 | + const reader = new FileReader(); | |
| 25 | + reader.onload = (e)=>img.setAttribute('src', e.target.result); | |
| 26 | + reader.readAsDataURL(blob); | |
| 27 | + } | |
| 28 | + }; | |
| 29 | + //////////////////////////////////////////////////////////// | |
| 30 | + // File drag/drop. | |
| 31 | + // Adapted from: https://stackoverflow.com/a/58677161 | |
| 32 | + const dropHighlight = BlobXferState.dropZone /* target zone */; | |
| 33 | + const dropEvents = { | |
| 34 | + drop: function(ev){ | |
| 35 | + ev.preventDefault(); | |
| 36 | + D.removeClass(dropHighlight, 'dragover'); | |
| 37 | + const file = ev.dataTransfer.files[0]; | |
| 38 | + if(file) { | |
| 39 | + updateDropZoneContent(file); | |
| 40 | + } | |
| 41 | + }, | |
| 42 | + dragenter: function(ev){ | |
| 43 | + ev.preventDefault(); | |
| 44 | + ev.dataTransfer.dropEffect = "copy"; | |
| 45 | + D.addClass(dropHighlight, 'dragover'); | |
| 46 | + }, | |
| 47 | + dragover: function(ev){ | |
| 48 | + ev.preventDefault(); | |
| 49 | + }, | |
| 50 | + dragend: function(ev){ | |
| 51 | + ev.preventDefault(); | |
| 52 | + }, | |
| 53 | + dragleave: function(ev){ | |
| 54 | + ev.preventDefault(); | |
| 55 | + D.removeClass(dropHighlight, 'dragover'); | |
| 56 | + } | |
| 57 | + }; | |
| 58 | + /* | |
| 59 | + The idea here is to accept drops at multiple points or, ideally, | |
| 60 | + document.body, and apply them to P.e.taContent, but the precise | |
| 61 | + combination of event handling needed to pull this off is eluding | |
| 62 | + me. | |
| 63 | + */ | |
| 64 | + [BlobXferState.dropZone | |
| 65 | + /* ideally we'd link only to document.body, but the events seem to | |
| 66 | + get out of whack, with dropleave being triggered at unexpected | |
| 67 | + points. */ | |
| 68 | + ].forEach(function(e){ | |
| 69 | + Object.keys(dropEvents).forEach( | |
| 70 | + (k)=>e.addEventListener(k, dropEvents[k], true) | |
| 71 | + ); | |
| 72 | + }); | |
| 73 | + | |
| 11 | 74 | form.addEventListener('submit',(e)=>{ |
| 12 | 75 | e.preventDefault(); |
| 13 | 76 | const fd = new FormData(form); |
| 14 | - if(ImagePasteState.blob/*replace file content with this*/){ | |
| 15 | - fd.set("file", ImagePasteState.blob); | |
| 77 | + if(BlobXferState.blob/*replace file content with this*/){ | |
| 78 | + fd.set("file", BlobXferState.blob); | |
| 16 | 79 | } |
| 17 | - if( form.msg.value.length>0 || form.file.value.length>0 || ImagePasteState.blob ){ | |
| 80 | + if( form.msg.value.length>0 || form.file.value.length>0 || BlobXferState.blob ){ | |
| 18 | 81 | fetch("chat-send",{ |
| 19 | 82 | method: 'POST', |
| 20 | 83 | body: fd |
| 21 | 84 | }); |
| 22 | 85 | } |
| 23 | - ImagePasteState.blob = undefined; | |
| 24 | - ImagePasteState.imgTag.removeAttribute('src'); | |
| 86 | + BlobXferState.blob = undefined; | |
| 87 | + D.clearElement(BlobXferState.dropDetails); | |
| 25 | 88 | form.msg.value = ""; |
| 26 | 89 | form.file.value = ""; |
| 27 | 90 | form.msg.focus(); |
| 28 | 91 | }); |
| 29 | 92 | /* Handle image paste from clipboard. TODO: figure out how we can |
| @@ -32,18 +95,12 @@ | ||
| 32 | 95 | document.onpaste = function(event){ |
| 33 | 96 | const items = event.clipboardData.items, |
| 34 | 97 | item = items[0]; |
| 35 | 98 | if(!item || !item.type) return; |
| 36 | 99 | //console.debug("pasted item =",item); |
| 37 | - if('file'===item.kind && item.type.startsWith("image/")){ | |
| 38 | - ImagePasteState.blob = items[0].getAsFile(); | |
| 39 | - //console.debug("pasted blob =",ImagePasteState.blob); | |
| 40 | - const reader = new FileReader(); | |
| 41 | - reader.onload = function(event){ | |
| 42 | - ImagePasteState.imgTag.setAttribute('src', event.target.result); | |
| 43 | - }; | |
| 44 | - reader.readAsDataURL(ImagePasteState.blob); | |
| 100 | + if('file'===item.kind){ | |
| 101 | + updateDropZoneContent(items[0].getAsFile()); | |
| 45 | 102 | }else if('string'===item.kind){ |
| 46 | 103 | item.getAsString((v)=>form.msg.value = v); |
| 47 | 104 | } |
| 48 | 105 | }; |
| 49 | 106 | /* Injects element e as a new row in the chat, at the top of the list */ |
| 50 | 107 |
| --- src/chat.js | |
| +++ src/chat.js | |
| @@ -1,29 +1,92 @@ | |
| 1 | (function(){ |
| 2 | const form = document.querySelector('#chat-form'); |
| 3 | let mxMsg = 0; |
| 4 | const F = window.fossil; |
| 5 | const _me = F.user.name; |
| 6 | /* State for pasting an image from the clipboard */ |
| 7 | const ImagePasteState = { |
| 8 | imgTag: document.querySelector('#chat-pasted-image img'), |
| 9 | blob: undefined |
| 10 | }; |
| 11 | form.addEventListener('submit',(e)=>{ |
| 12 | e.preventDefault(); |
| 13 | const fd = new FormData(form); |
| 14 | if(ImagePasteState.blob/*replace file content with this*/){ |
| 15 | fd.set("file", ImagePasteState.blob); |
| 16 | } |
| 17 | if( form.msg.value.length>0 || form.file.value.length>0 || ImagePasteState.blob ){ |
| 18 | fetch("chat-send",{ |
| 19 | method: 'POST', |
| 20 | body: fd |
| 21 | }); |
| 22 | } |
| 23 | ImagePasteState.blob = undefined; |
| 24 | ImagePasteState.imgTag.removeAttribute('src'); |
| 25 | form.msg.value = ""; |
| 26 | form.file.value = ""; |
| 27 | form.msg.focus(); |
| 28 | }); |
| 29 | /* Handle image paste from clipboard. TODO: figure out how we can |
| @@ -32,18 +95,12 @@ | |
| 32 | document.onpaste = function(event){ |
| 33 | const items = event.clipboardData.items, |
| 34 | item = items[0]; |
| 35 | if(!item || !item.type) return; |
| 36 | //console.debug("pasted item =",item); |
| 37 | if('file'===item.kind && item.type.startsWith("image/")){ |
| 38 | ImagePasteState.blob = items[0].getAsFile(); |
| 39 | //console.debug("pasted blob =",ImagePasteState.blob); |
| 40 | const reader = new FileReader(); |
| 41 | reader.onload = function(event){ |
| 42 | ImagePasteState.imgTag.setAttribute('src', event.target.result); |
| 43 | }; |
| 44 | reader.readAsDataURL(ImagePasteState.blob); |
| 45 | }else if('string'===item.kind){ |
| 46 | item.getAsString((v)=>form.msg.value = v); |
| 47 | } |
| 48 | }; |
| 49 | /* Injects element e as a new row in the chat, at the top of the list */ |
| 50 |
| --- src/chat.js | |
| +++ src/chat.js | |
| @@ -1,29 +1,92 @@ | |
| 1 | (function(){ |
| 2 | const form = document.querySelector('#chat-form'); |
| 3 | let mxMsg = 0; |
| 4 | const F = window.fossil, D = F.dom; |
| 5 | const _me = F.user.name; |
| 6 | /* State for paste and drag/drop */ |
| 7 | const BlobXferState = { |
| 8 | dropZone: document.querySelector('#chat-drop-zone'), |
| 9 | dropDetails: document.querySelector('#chat-drop-details'), |
| 10 | imgTag: document.querySelector('#chat-drop-details img'), |
| 11 | blob: undefined |
| 12 | }; |
| 13 | /** Updates the paste/drop zone with details of the pasted/dropped |
| 14 | data. */ |
| 15 | const updateDropZoneContent = function(blob){ |
| 16 | const bx = BlobXferState, dd = bx.dropDetails; |
| 17 | bx.blob = blob; |
| 18 | D.clearElement(dd); |
| 19 | D.append(dd, D.br(), "Name: ", blob.name, |
| 20 | D.br(), "Size: ",blob.size); |
| 21 | if(blob.type && blob.type.startsWith("image/")){ |
| 22 | const img = D.img(); |
| 23 | D.append(dd, D.br(), img); |
| 24 | const reader = new FileReader(); |
| 25 | reader.onload = (e)=>img.setAttribute('src', e.target.result); |
| 26 | reader.readAsDataURL(blob); |
| 27 | } |
| 28 | }; |
| 29 | //////////////////////////////////////////////////////////// |
| 30 | // File drag/drop. |
| 31 | // Adapted from: https://stackoverflow.com/a/58677161 |
| 32 | const dropHighlight = BlobXferState.dropZone /* target zone */; |
| 33 | const dropEvents = { |
| 34 | drop: function(ev){ |
| 35 | ev.preventDefault(); |
| 36 | D.removeClass(dropHighlight, 'dragover'); |
| 37 | const file = ev.dataTransfer.files[0]; |
| 38 | if(file) { |
| 39 | updateDropZoneContent(file); |
| 40 | } |
| 41 | }, |
| 42 | dragenter: function(ev){ |
| 43 | ev.preventDefault(); |
| 44 | ev.dataTransfer.dropEffect = "copy"; |
| 45 | D.addClass(dropHighlight, 'dragover'); |
| 46 | }, |
| 47 | dragover: function(ev){ |
| 48 | ev.preventDefault(); |
| 49 | }, |
| 50 | dragend: function(ev){ |
| 51 | ev.preventDefault(); |
| 52 | }, |
| 53 | dragleave: function(ev){ |
| 54 | ev.preventDefault(); |
| 55 | D.removeClass(dropHighlight, 'dragover'); |
| 56 | } |
| 57 | }; |
| 58 | /* |
| 59 | The idea here is to accept drops at multiple points or, ideally, |
| 60 | document.body, and apply them to P.e.taContent, but the precise |
| 61 | combination of event handling needed to pull this off is eluding |
| 62 | me. |
| 63 | */ |
| 64 | [BlobXferState.dropZone |
| 65 | /* ideally we'd link only to document.body, but the events seem to |
| 66 | get out of whack, with dropleave being triggered at unexpected |
| 67 | points. */ |
| 68 | ].forEach(function(e){ |
| 69 | Object.keys(dropEvents).forEach( |
| 70 | (k)=>e.addEventListener(k, dropEvents[k], true) |
| 71 | ); |
| 72 | }); |
| 73 | |
| 74 | form.addEventListener('submit',(e)=>{ |
| 75 | e.preventDefault(); |
| 76 | const fd = new FormData(form); |
| 77 | if(BlobXferState.blob/*replace file content with this*/){ |
| 78 | fd.set("file", BlobXferState.blob); |
| 79 | } |
| 80 | if( form.msg.value.length>0 || form.file.value.length>0 || BlobXferState.blob ){ |
| 81 | fetch("chat-send",{ |
| 82 | method: 'POST', |
| 83 | body: fd |
| 84 | }); |
| 85 | } |
| 86 | BlobXferState.blob = undefined; |
| 87 | D.clearElement(BlobXferState.dropDetails); |
| 88 | form.msg.value = ""; |
| 89 | form.file.value = ""; |
| 90 | form.msg.focus(); |
| 91 | }); |
| 92 | /* Handle image paste from clipboard. TODO: figure out how we can |
| @@ -32,18 +95,12 @@ | |
| 95 | document.onpaste = function(event){ |
| 96 | const items = event.clipboardData.items, |
| 97 | item = items[0]; |
| 98 | if(!item || !item.type) return; |
| 99 | //console.debug("pasted item =",item); |
| 100 | if('file'===item.kind){ |
| 101 | updateDropZoneContent(items[0].getAsFile()); |
| 102 | }else if('string'===item.kind){ |
| 103 | item.getAsString((v)=>form.msg.value = v); |
| 104 | } |
| 105 | }; |
| 106 | /* Injects element e as a new row in the chat, at the top of the list */ |
| 107 |