Fossil SCM
/chat: do not show the Toggle Text Mode feature for messages with no text, e.g. image-only posts (resolves an unhandled exception). When text is toggled to the unparsed state, show a copy-to-clipboard button which copies the raw message text to the clipboard. That is a workaround for mouse-copying of that text collecting extraneous newlines for reasons only the browsers understand.
Commit
f98a4f5c94a844dd270526f380d8f6aaa641ebcf6b18feed5681b80b249aee23
Parent
cd7f2ddc9884884…
2 files changed
+5
-3
+33
-6
+5
-3
| --- src/fossil.copybutton.js | ||
| +++ src/fossil.copybutton.js | ||
| @@ -17,13 +17,10 @@ | ||
| 17 | 17 | |
| 18 | 18 | .copyFromElement: DOM element |
| 19 | 19 | |
| 20 | 20 | .copyFromId: DOM element ID |
| 21 | 21 | |
| 22 | - One of copyFromElement or copyFromId must be provided, but copyFromId | |
| 23 | - may optionally be provided via e.dataset.copyFromId. | |
| 24 | - | |
| 25 | 22 | .extractText: optional callback which is triggered when the copy |
| 26 | 23 | button is clicked. It must return the text to copy to the |
| 27 | 24 | clipboard. The default is to extract it from the copy-from |
| 28 | 25 | element, using its [value] member, if it has one, else its |
| 29 | 26 | [innerText]. A client-provided callback may use any data source |
| @@ -30,10 +27,15 @@ | ||
| 30 | 27 | it likes, so long as it's synchronous. If this function returns a |
| 31 | 28 | falsy value then the clipboard is not modified. This function is |
| 32 | 29 | called with the fully expanded/resolved options object as its |
| 33 | 30 | "this" (that's a different instance than the one passed to this |
| 34 | 31 | function!). |
| 32 | + | |
| 33 | + At least one of copyFromElement, copyFromId, or extractText must | |
| 34 | + be provided, but if copyFromId is not set and e.dataset.copyFromId | |
| 35 | + is then that value is used in its place. extractText() trumps the | |
| 36 | + other two options. | |
| 35 | 37 | |
| 36 | 38 | .cssClass: optional CSS class, or list of classes, to apply to e. |
| 37 | 39 | |
| 38 | 40 | .style: optional object of properties to copy directly into |
| 39 | 41 | e.style. |
| 40 | 42 |
| --- src/fossil.copybutton.js | |
| +++ src/fossil.copybutton.js | |
| @@ -17,13 +17,10 @@ | |
| 17 | |
| 18 | .copyFromElement: DOM element |
| 19 | |
| 20 | .copyFromId: DOM element ID |
| 21 | |
| 22 | One of copyFromElement or copyFromId must be provided, but copyFromId |
| 23 | may optionally be provided via e.dataset.copyFromId. |
| 24 | |
| 25 | .extractText: optional callback which is triggered when the copy |
| 26 | button is clicked. It must return the text to copy to the |
| 27 | clipboard. The default is to extract it from the copy-from |
| 28 | element, using its [value] member, if it has one, else its |
| 29 | [innerText]. A client-provided callback may use any data source |
| @@ -30,10 +27,15 @@ | |
| 30 | it likes, so long as it's synchronous. If this function returns a |
| 31 | falsy value then the clipboard is not modified. This function is |
| 32 | called with the fully expanded/resolved options object as its |
| 33 | "this" (that's a different instance than the one passed to this |
| 34 | function!). |
| 35 | |
| 36 | .cssClass: optional CSS class, or list of classes, to apply to e. |
| 37 | |
| 38 | .style: optional object of properties to copy directly into |
| 39 | e.style. |
| 40 |
| --- src/fossil.copybutton.js | |
| +++ src/fossil.copybutton.js | |
| @@ -17,13 +17,10 @@ | |
| 17 | |
| 18 | .copyFromElement: DOM element |
| 19 | |
| 20 | .copyFromId: DOM element ID |
| 21 | |
| 22 | .extractText: optional callback which is triggered when the copy |
| 23 | button is clicked. It must return the text to copy to the |
| 24 | clipboard. The default is to extract it from the copy-from |
| 25 | element, using its [value] member, if it has one, else its |
| 26 | [innerText]. A client-provided callback may use any data source |
| @@ -30,10 +27,15 @@ | |
| 27 | it likes, so long as it's synchronous. If this function returns a |
| 28 | falsy value then the clipboard is not modified. This function is |
| 29 | called with the fully expanded/resolved options object as its |
| 30 | "this" (that's a different instance than the one passed to this |
| 31 | function!). |
| 32 | |
| 33 | At least one of copyFromElement, copyFromId, or extractText must |
| 34 | be provided, but if copyFromId is not set and e.dataset.copyFromId |
| 35 | is then that value is used in its place. extractText() trumps the |
| 36 | other two options. |
| 37 | |
| 38 | .cssClass: optional CSS class, or list of classes, to apply to e. |
| 39 | |
| 40 | .style: optional object of properties to copy directly into |
| 41 | e.style. |
| 42 |
+33
-6
| --- src/fossil.page.chat.js | ||
| +++ src/fossil.page.chat.js | ||
| @@ -702,10 +702,15 @@ | ||
| 702 | 702 | } |
| 703 | 703 | if(!e || !id) return false; |
| 704 | 704 | else if(e.$isToggling) return; |
| 705 | 705 | e.$isToggling = true; |
| 706 | 706 | const content = e.querySelector('.content-target'); |
| 707 | + if(!content){ | |
| 708 | + console.warn("Should not be possible: trying to toggle text", | |
| 709 | + "mode of a message with no .content-target.", e); | |
| 710 | + return; | |
| 711 | + } | |
| 707 | 712 | if(!content.$elems){ |
| 708 | 713 | content.$elems = [ |
| 709 | 714 | content.firstElementChild, // parsed elem |
| 710 | 715 | undefined // plaintext elem |
| 711 | 716 | ]; |
| @@ -714,21 +719,39 @@ | ||
| 714 | 719 | const child = ( |
| 715 | 720 | content.firstElementChild===content.$elems[0] |
| 716 | 721 | ? content.$elems[1] |
| 717 | 722 | : content.$elems[0] |
| 718 | 723 | ); |
| 724 | + D.clearElement(content); | |
| 725 | + if(child===content.$elems[1]){ | |
| 726 | + /* When showing the unformatted version, inject a | |
| 727 | + copy-to-clipboard button. This is a workaround for | |
| 728 | + mouse-copying from that field collecting twice as many | |
| 729 | + newlines as it should (for unknown reasons). */ | |
| 730 | + const cpId = 'copy-to-clipboard-'+id; | |
| 731 | + /* ^^^ copy button element ID, needed for LABEL element | |
| 732 | + pairing. Recall that we destroy all child elements of | |
| 733 | + `content` each time we hit this block, so we can reuse | |
| 734 | + that element ID on subsequent toggles. */ | |
| 735 | + const btnCp = D.attr(D.addClass(D.span(),'copy-button'), 'id', cpId); | |
| 736 | + F.copyButton(btnCp, {extractText: ()=>child._xmsgRaw}); | |
| 737 | + const lblCp = D.label(cpId, "Copy unformatted text"); | |
| 738 | + lblCp.addEventListener('click',()=>btnCp.click(), false); | |
| 739 | + D.append(content, D.append(D.addClass(D.span(), 'nobr'), btnCp, lblCp)); | |
| 740 | + } | |
| 719 | 741 | delete e.$isToggling; |
| 720 | - D.append(D.clearElement(content), child); | |
| 742 | + D.append(content, child); | |
| 721 | 743 | return; |
| 722 | 744 | } |
| 723 | 745 | // We need to fetch the plain-text version... |
| 724 | 746 | const self = this; |
| 725 | 747 | F.fetch('chat-fetch-one',{ |
| 726 | 748 | urlParams:{ name: id, raw: true}, |
| 727 | 749 | responseType: 'json', |
| 728 | 750 | onload: function(msg){ |
| 729 | 751 | content.$elems[1] = D.append(D.pre(),msg.xmsg); |
| 752 | + content.$elems[1]._xmsgRaw = msg.xmsg/*used for copy-to-clipboard feature*/; | |
| 730 | 753 | self.toggleTextMode(e); |
| 731 | 754 | }, |
| 732 | 755 | aftersend:function(){ |
| 733 | 756 | delete e.$isToggling; |
| 734 | 757 | Chat.ajaxEnd(); |
| @@ -1135,15 +1158,19 @@ | ||
| 1135 | 1158 | eMsg.scrollIntoView(); |
| 1136 | 1159 | } |
| 1137 | 1160 | )); |
| 1138 | 1161 | const toolbar2 = D.addClass(D.div(), 'toolbar'); |
| 1139 | 1162 | D.append(this.e, toolbar2); |
| 1140 | - D.append(toolbar2, D.button( | |
| 1141 | - "Toggle text mode", function(){ | |
| 1142 | - self.hide(); | |
| 1143 | - Chat.toggleTextMode(eMsg); | |
| 1144 | - })); | |
| 1163 | + if(eMsg.querySelector('.content-target')){ | |
| 1164 | + /* ^^^ messages with only an embedded image have no | |
| 1165 | + .content-target area. */ | |
| 1166 | + D.append(toolbar2, D.button( | |
| 1167 | + "Toggle text mode", function(){ | |
| 1168 | + self.hide(); | |
| 1169 | + Chat.toggleTextMode(eMsg); | |
| 1170 | + })); | |
| 1171 | + } | |
| 1145 | 1172 | if(eMsg.dataset.xfrom){ |
| 1146 | 1173 | /* Add a link to the /timeline filtered on this user. */ |
| 1147 | 1174 | const timelineLink = D.attr( |
| 1148 | 1175 | D.a(F.repoUrl('timeline',{ |
| 1149 | 1176 | u: eMsg.dataset.xfrom, |
| 1150 | 1177 |
| --- src/fossil.page.chat.js | |
| +++ src/fossil.page.chat.js | |
| @@ -702,10 +702,15 @@ | |
| 702 | } |
| 703 | if(!e || !id) return false; |
| 704 | else if(e.$isToggling) return; |
| 705 | e.$isToggling = true; |
| 706 | const content = e.querySelector('.content-target'); |
| 707 | if(!content.$elems){ |
| 708 | content.$elems = [ |
| 709 | content.firstElementChild, // parsed elem |
| 710 | undefined // plaintext elem |
| 711 | ]; |
| @@ -714,21 +719,39 @@ | |
| 714 | const child = ( |
| 715 | content.firstElementChild===content.$elems[0] |
| 716 | ? content.$elems[1] |
| 717 | : content.$elems[0] |
| 718 | ); |
| 719 | delete e.$isToggling; |
| 720 | D.append(D.clearElement(content), child); |
| 721 | return; |
| 722 | } |
| 723 | // We need to fetch the plain-text version... |
| 724 | const self = this; |
| 725 | F.fetch('chat-fetch-one',{ |
| 726 | urlParams:{ name: id, raw: true}, |
| 727 | responseType: 'json', |
| 728 | onload: function(msg){ |
| 729 | content.$elems[1] = D.append(D.pre(),msg.xmsg); |
| 730 | self.toggleTextMode(e); |
| 731 | }, |
| 732 | aftersend:function(){ |
| 733 | delete e.$isToggling; |
| 734 | Chat.ajaxEnd(); |
| @@ -1135,15 +1158,19 @@ | |
| 1135 | eMsg.scrollIntoView(); |
| 1136 | } |
| 1137 | )); |
| 1138 | const toolbar2 = D.addClass(D.div(), 'toolbar'); |
| 1139 | D.append(this.e, toolbar2); |
| 1140 | D.append(toolbar2, D.button( |
| 1141 | "Toggle text mode", function(){ |
| 1142 | self.hide(); |
| 1143 | Chat.toggleTextMode(eMsg); |
| 1144 | })); |
| 1145 | if(eMsg.dataset.xfrom){ |
| 1146 | /* Add a link to the /timeline filtered on this user. */ |
| 1147 | const timelineLink = D.attr( |
| 1148 | D.a(F.repoUrl('timeline',{ |
| 1149 | u: eMsg.dataset.xfrom, |
| 1150 |
| --- src/fossil.page.chat.js | |
| +++ src/fossil.page.chat.js | |
| @@ -702,10 +702,15 @@ | |
| 702 | } |
| 703 | if(!e || !id) return false; |
| 704 | else if(e.$isToggling) return; |
| 705 | e.$isToggling = true; |
| 706 | const content = e.querySelector('.content-target'); |
| 707 | if(!content){ |
| 708 | console.warn("Should not be possible: trying to toggle text", |
| 709 | "mode of a message with no .content-target.", e); |
| 710 | return; |
| 711 | } |
| 712 | if(!content.$elems){ |
| 713 | content.$elems = [ |
| 714 | content.firstElementChild, // parsed elem |
| 715 | undefined // plaintext elem |
| 716 | ]; |
| @@ -714,21 +719,39 @@ | |
| 719 | const child = ( |
| 720 | content.firstElementChild===content.$elems[0] |
| 721 | ? content.$elems[1] |
| 722 | : content.$elems[0] |
| 723 | ); |
| 724 | D.clearElement(content); |
| 725 | if(child===content.$elems[1]){ |
| 726 | /* When showing the unformatted version, inject a |
| 727 | copy-to-clipboard button. This is a workaround for |
| 728 | mouse-copying from that field collecting twice as many |
| 729 | newlines as it should (for unknown reasons). */ |
| 730 | const cpId = 'copy-to-clipboard-'+id; |
| 731 | /* ^^^ copy button element ID, needed for LABEL element |
| 732 | pairing. Recall that we destroy all child elements of |
| 733 | `content` each time we hit this block, so we can reuse |
| 734 | that element ID on subsequent toggles. */ |
| 735 | const btnCp = D.attr(D.addClass(D.span(),'copy-button'), 'id', cpId); |
| 736 | F.copyButton(btnCp, {extractText: ()=>child._xmsgRaw}); |
| 737 | const lblCp = D.label(cpId, "Copy unformatted text"); |
| 738 | lblCp.addEventListener('click',()=>btnCp.click(), false); |
| 739 | D.append(content, D.append(D.addClass(D.span(), 'nobr'), btnCp, lblCp)); |
| 740 | } |
| 741 | delete e.$isToggling; |
| 742 | D.append(content, child); |
| 743 | return; |
| 744 | } |
| 745 | // We need to fetch the plain-text version... |
| 746 | const self = this; |
| 747 | F.fetch('chat-fetch-one',{ |
| 748 | urlParams:{ name: id, raw: true}, |
| 749 | responseType: 'json', |
| 750 | onload: function(msg){ |
| 751 | content.$elems[1] = D.append(D.pre(),msg.xmsg); |
| 752 | content.$elems[1]._xmsgRaw = msg.xmsg/*used for copy-to-clipboard feature*/; |
| 753 | self.toggleTextMode(e); |
| 754 | }, |
| 755 | aftersend:function(){ |
| 756 | delete e.$isToggling; |
| 757 | Chat.ajaxEnd(); |
| @@ -1135,15 +1158,19 @@ | |
| 1158 | eMsg.scrollIntoView(); |
| 1159 | } |
| 1160 | )); |
| 1161 | const toolbar2 = D.addClass(D.div(), 'toolbar'); |
| 1162 | D.append(this.e, toolbar2); |
| 1163 | if(eMsg.querySelector('.content-target')){ |
| 1164 | /* ^^^ messages with only an embedded image have no |
| 1165 | .content-target area. */ |
| 1166 | D.append(toolbar2, D.button( |
| 1167 | "Toggle text mode", function(){ |
| 1168 | self.hide(); |
| 1169 | Chat.toggleTextMode(eMsg); |
| 1170 | })); |
| 1171 | } |
| 1172 | if(eMsg.dataset.xfrom){ |
| 1173 | /* Add a link to the /timeline filtered on this user. */ |
| 1174 | const timelineLink = D.attr( |
| 1175 | D.a(F.repoUrl('timeline',{ |
| 1176 | u: eMsg.dataset.xfrom, |
| 1177 |