User:SBisson (WMF)/facts.js

/**
 * Fact Cards - The sun never sets on facts
 * Inukathon March 13-17 2023
 * Eduardo Medina
 * Huei Tan
 * Stephane Bisson

(function{	function isArticleView {		return ( mw.config.get( 'wgAction' ) === 'view' ) &&			( mw.config.get( 'wgNamespaceNumber' ) === 0 ) &&			( mw.config.get('wgArticleId') > 0 );	}	function splitWithDom( root ) {	 var output = ;	  var buffer = ;	  for ( var i = 0; i < root.childNodes.length; ++i ) {	    var node = root.childNodes[i];	    if (node.nodeType === Node.TEXT_NODE) {	      if ( node.textContent.includes( '.' ) ) {	      	var text = node.textContent; // .replace( /(\d)(\.)(\d)/g, '$1&#46;$3');	        var parts = text.split( '.' );	        buffer += parts[0] + '.';	        output += ' ' + buffer.trim + ' ';	        buffer = parts[ parts.length - 1 ];	        if ( parts.length > 2 ) {	          for (var j = 1; j < parts.length - 1; j++ ) {	            output += parts[ j ];	          }	        }	      } else {	        buffer += node.textContent;	      }	    } else if ( node.nodeType === Node.COMMENT_NODE ) { // ignore comments } else { // other node if ( node.tagName === 'SUP' ) { output += node.outerHTML; } else { buffer += node.outerHTML; }	   }	  }	  return output; }	function setUpArticleParagraphs { $( 'p' ).each( ( i, p ) => {			if ( p.textContent.trim ) {				p.innerHTML = splitWithDom( p );			}		} ); }	function findFactOnView( factText, classname, navigate ) { const elements = document.querySelectorAll( 'span.fact-sentence' ); // loop through each element and check if it contains the text for (const element of elements) { if (factText.includes(element.innerText)) { if ( classname ) { $( element ).addClass( classname ); }	       if ( navigate ) { element.scrollIntoView({ behavior: "smooth" }); break; }	     }	    }	}	function imageAsDataUrl( image ) { var canvas = document.createElement( 'canvas' ); canvas.width = image.scrollWidth; canvas.height = image.scrollHeight; canvas.getContext( '2d' ).drawImage( image, 0, 0 ); return canvas.toDataURL; }	function svgToImage( image, width, height ) { var canvas = document.createElement( 'canvas' ); canvas.width = width; canvas.height = height; canvas.getContext( '2d' ).drawImage( image, 0, 0 ); return canvas.toDataURL( 'image/png', 1 ); }	function domImageDataUrl( root ) { return new Promise( function( resolve ) {	   // Get the element size	    var width = root.scrollWidth;	    var height = root.scrollHeight - 2;	    // Make a clone of the DOM and tweak	    var clone = $( root.outerHTML )[ 0 ];	    clone.style.backgroundColor = 'white';	    clone.setAttribute('xmlns', 'http://www.w3.org/1999/xhtml');	    // convert first image url to data url	    var imageUrl = $( clone ).find( 'img' )[0].src = imageAsDataUrl( $( root ).find( 'img' )[0] );	    var serialized = new XMLSerializer	      .serializeToString( clone )	      .replace( /#/g, '%23' ).replace( /\n/g, '%0A' );	    // Build svg data url manually	    var svgDataUrl = 'data:image/svg+xml;charset=utf-8,' +	      '' +	      '' + serialized +	      '' + ' ';	   var svgImage = new Image; svgImage.src = svgDataUrl; // delay the conversion of the svg into image, I don't know why setTimeout( function {	      // convert svg data url into jpeg data url	      jpegUrl = svgToImage( svgImage, width, height );	      resolve( jpegUrl );	    }, 200 ); } );	}

function isMinervaSkin { return mw.config.get( 'skin' ) === 'minerva' }

function initUI( Vue ) { // TODOs // [options panel] // no facts, nothing selected -> intro/help msg

Vue.component('fact-icon', {			template: `				 $emit('expand')">									 `,			emits: [ 'expand' ],			computed: {				style: function {					return {						position: 'fixed',						top: '25%',						right: '0',						backgroundColor: 'white',   					border: '1px solid blue',    					borderTopLeftRadius: '10px',    					borderBottomLeftRadius: '10px',    					padding: '6px',    					cursor: 'pointer'					};				},				styleImg: function {					return {						height: '50px',						width: '50px'					};				}			}		}); Vue.component('facts-list', {			template: `																			Share							Remove							Navigate

`,			props: [ 'resetForm', 'collapse' ], data: function { return { facts: [], username: mw.config.get('wgUserName'), lang: mw.config.get('wgContentLanguage'), articleTitle: mw.config.get('wgPageName') };			},			methods: { getFacts: function { const titles = `User:${this.username}/${this.lang}/${this.articleTitle}/facts`; const params = { action: 'parse', prop: 'text', page: titles, formatversion: 2 };			       return ( new mw.Api ).get( params ) .then( response => {			           	const facts = [];			            	$( response.parse.text ).find('.thumbinner').each( function { facts.push( {			           			src: $(this).find('.thumbimage')[0].src,			            			text: $(this).find('.thumbcaption').text			            		} ); } );			           	return facts;			            } ); },				share: function( event, index ) { event.preventDefault; var root = $( '.facts-sidebar-list > .facts-fact:nth(' + index + ') > .fact-card' )[ 0 ]; domImageDataUrl( root ).then( function( url ) {						if ( navigator.share ) {							var byteString = atob( url.split( ',' )[ 1 ]);							var mimeString = url.split( ',' )[ 0 ].split( ':' )[ 1 ].split( ';' )[0];							var ab = new ArrayBuffer( byteString.length );						   var ia = new Uint8Array( ab );						    for ( var i = 0; i < byteString.length; i++ ) {						        ia[ i ] = byteString.charCodeAt( i );						    }							var file = new File( [ ab ], 'fact.png', { type: mimeString } );							navigator.share( { files: [ file ] } );						} else {							var a = document.createElement( 'a' );							a.href = url;							a.download = 'fact.png';							document.body.appendChild( a );							a.click;							document.body.removeChild( a );						}					}.bind( this ) ); },				navigate: function( event, index ) { event.preventDefault; const factText = this.facts[ index ].text; findFactOnView( factText, null, true ); if ( isMinervaSkin ) { this.collapse; }				},				remove: function( event, index ) { event.preventDefault; console.log( 'remove fact #', index ); let textContent = ''; this.facts.forEach( (fact,i) => {						if ( i !== index ) {							textContent += ``;						}					} ); // edit file const title = `User:${this.username}/${this.lang}/${this.articleTitle}/facts`; const deletedFact = this.facts[ index ].text; const resetForm = this.resetForm; new mw.Api.edit( title, function {					   return { 					    	text: textContent, 					    	summary: `remove fact "${deletedFact}"`,					    	contentmodel: 'wikitext'					    };					}).then( function {						mw.notify( `Fact "${deletedFact}" has been deleted` );						resetForm( true );					}); },			},			computed: { style: function { return { height: '100%', width: '320px', padding: '10px', margin: 'auto', boxSizing: 'border-box' };				}			},			mounted: function { this.getFacts.then( f => {					this.facts = f;					let allFactsText = '';									$( 'span.fact-sentence.existed' ).removeClass( 'existed' );					f.forEach( fact => { allFactsText += fact.text; } );					findFactOnView( allFactsText, 'existed' );				}); }		});		Vue.component('fact-form', { template: `  	   			   <button class="facts-sidebar-form-footer-discard" @click="discard" :style="styleDiscard">Cancel <button class="facts-sidebar-form-footer-confirm" @click="saveFacts" :style="styleConfirm">Publish `,			props: [ 'selectedText', 'resetForm' ], data: function { const defaultImg = 'File:Noun_Fact_3331495.svg'; return { items: [], defaultImg: defaultImg, selectedImg: defaultImg, username: mw.config.get('wgUserName'), lang: mw.config.get('wgContentLanguage'), articleTitle: mw.config.get('wgPageName') };			},			computed: { styleForm: function { return { height: '100%', padding: '10px', display: 'flex', flexDirection: 'column', boxSizing: 'border-box' };				},				styleListview: function { return { display: 'flex', flexDirection: 'column', flexWrap: 'nowrap', overflow: 'auto' };				},				styleListContainer: function { return { display: 'flex', flexWrap: 'wrap', alignContent: 'flex-start', justifyContent: 'flex-start', width: '100%' };				},				styleListItem: function { return { position: 'relative', justifyContent: 'space-between', width: 'auto', flex: '1 1 auto', order: '1', display: 'flex', alignItems: 'center', backgroundColor: '#eaecf0', boxSizing: 'border-box', height: '180px', margin: '8px', transition: 'box-shadow .1s ease,outline .1s ease', cursor: 'pointer', };				},				styleImg: function { return { height: '100%', '-o-object-fit': 'cover', 'object-fit': 'cover', '-o-object-position': 'center center', 'object-position': 'center center', 'pointer-events': 'none', width: '100%' };				},				styleFooter: function { return { display: 'flex', flexDirection: 'row', justifyContent: 'space-around' };				},				styleDiscard: function { return { backgroundColor: 'white', color: 'blue', fontWeight: 'bold', padding: '8px', borderRadius: '4px', borderColor: 'blue', cursor: 'pointer' };				},				styleConfirm: function { return { backgroundColor: 'blue', color: 'white', fontWeight: 'bold', padding: '8px', borderRadius: '4px', borderColor: 'blue', cursor: 'pointer' };				}			},			methods: { discard: function { this.resetForm; },				selectImg: function( title ) { if ( this.selectedImg === title ) { this.selectedImg = this.defaultImg; } else { this.selectedImg = title; }				},				saveFacts: function { const titles = `User:${this.username}/${this.lang}/${this.articleTitle}/facts`; const newFact = this.selectedText; (new mw.Api).post(			           {			                action: 'edit',			                title: titles,			                appendtext: `center|thumb|auto|${newFact}\n\n`,			                summary: `Add fact ${newFact}`,			                token: mw.user.tokens.get( 'csrfToken' )			            }			        ).then(  => {			            mw.notify( `New Fact "${newFact}" has been added` );			            this.resetForm( true );			        } ); }			},			mounted: function { // image selection from commons api fetch(`https://commons.wikimedia.org/w/api.php?action=query&format=json&origin=*&uselang=${this.lang}&generator=search&gsrsearch=filetype%3Abitmap%7Cdrawing%20${this.articleTitle}&gsrlimit=20&gsroffset=0&gsrinfo=totalhits%7Csuggestion&gsrprop=snippet&prop=imageinfo&gsrnamespace=6&iiprop=url%7Cextmetadata&iiurlheight=180&iiextmetadatafilter=License%7CLicenseShortName%7CImageDescription%7CArtist&iiextmetadatalanguage=${this.lang}`) .then( response => response.json ) .then( data => {					   if ( data.query && data.query.pages ) {							const pages = Object.values( data.query.pages ).sort( ( a, b ) => a.index - b.index );							return pages.map(p => { const imageinfo = p.imageinfo[0]; const responsiveUrls = imageinfo.responsiveUrls && Object.values( imageinfo.responsiveUrls )[0]; return { title: p.title, thumb: imageinfo.thumburl, // responsiveUrls || imageinfo.url, width: imageinfo.thumbwidth, };							} );						} else {							return null;						}					} ) .then( data => {						this.items = data;					} ); }		});		Vue.component('sidebar', { template: ` <img :style="styleIcon" src="https://upload.wikimedia.org/wikipedia/commons/thumb/e/e2/Noun_Fact_3331495.svg/300px-Noun_Fact_3331495.svg.png" /> <b v-if="selectedText">New fact</b> <b v-else>All facts</b> <div :style="styleCollapseBtn" @click="$event => $emit('collapse')"> <img src="https://upload.wikimedia.org/wikipedia/commons/d/d3/VisualEditor_-_Icon_-_Move-ltr.svg" /> <fact-form v-if="selectedText" :selectedText="selectedText" :resetForm="resetForm"/> <facts-list v-else :key="updateTimestamp" :resetForm="resetForm" :collapse="$event => $emit('collapse')"/> `,			data: function { return { updateTimestamp: Date.now };			},			props: [ 'selectedText', 'clearSelectedText' ], emits: [ 'collapse' ], methods: { resetForm: function( hasNewFact ) { $( '.fact-sentence.selected' ).removeClass( 'selected' ); this.clearSelectedText; if ( hasNewFact ) { this.updateTimestamp = Date.now; }				}			},			computed: { style: function { var common = { position: 'fixed', backgroundColor: 'white', overflow: 'hidden', display: 'flex', flexDirection: 'column' };					if (isMinervaSkin) { // mobile view return $.extend( common, {							top: 0,							right: 0,							left: 0,							bottom: 0						} ); } else { // desktop view return $.extend( common, {							top: '50px',							right: '0',							width: '320px',							height: '92vh',							border: '1px solid blue',							borderTopLeftRadius: '10px',							borderBottomLeftRadius: '10px',							boxShadow: 'grey -1px 8px 10px 4px',							boxSizing: 'border-box'						} ); }				},				styleHeader: function { return { height: '40px', display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '10px' };				},				styleIcon: function { return { height: '30px', width: '30px' };				},				styleCollapseBtn: function { return { border: 'solid black', borderRadius: '15px', writingMode: 'vertical-rl', cursor: 'pointer' };				},				styleBody: function { return { overflow: 'scroll' };				},			}		});	} 	function mountFactsApp( Vue ) {		$( 'body' ).append ( $( ' ' ).addClass( 'facts-container' ) );		const app = Vue.createMwApp( { template: ` <fact-icon v-if="collapsed" @expand="onExpand" /> <sidebar v-else @collapse="onCollapse" :selectedText="selectedText" :clearSelectedText="clearSelectedText" /> `,			data: function { return { selectedText: '', collapsed: true };			},			methods: { getSelectedText: function { const selected = document.querySelectorAll( '.fact-sentence.selected' ); return selected.length ? Object.values( document.querySelectorAll( '.fact-sentence.selected' ) ).map( s => $( s ).text ).join( ' ' ) : '';				},				clearSelectedText: function { this.selectedText = ''; },				onExpand: function { this.collapsed = false; if ( isMinervaSkin ) { document.querySelector( 'body' ).style.overflow = 'hidden'; }				},				onCollapse: function { this.collapsed = true; if ( isMinervaSkin ) { document.querySelector( 'body' ).style.overflow = 'unset'; }				}			},			watch: { collapsed: function( value ) { var className = 'facts-sidebar-open'; if ( value ) { $( 'body' ).removeClass( className ); } else { $( 'body' ).addClass( className ); }				}			},			mounted: function { document.querySelector( '#bodyContent' ).addEventListener( 'mouseup', => {					this.selectedText = this.getSelectedText;					if ( this.selectedText ) {						this.collapsed = false;					}				}); $( 'p > span.fact-sentence' ).on(					'mousedown',					e => {						e.preventDefault;						$target = $( e.target );						if ( $target.hasClass( 'existed') ) {							// open sidebar and scroll to this fact							this.collapsed = false;						} else {							$target.toggleClass( 'selected' );						}					}				); }		} );

app.mount( '.facts-container' ); }	// --- Main program --- if ( isArticleView ) { console.log( 'Fact Cards - init' ); mw.loader.using( 'vue' ).done( function ( require ) {			const Vue = require( 'vue' );			initUI( Vue );			setUpArticleParagraphs;			mountFactsApp( Vue );		} ); } });