Coder Social home page Coder Social logo

community-plugins's People

Contributors

chaoskid42 avatar deleolajide avatar dependabot-preview[bot] avatar dependabot[bot] avatar ibygsd avatar jarobase avatar jcbrand avatar silveryocha avatar supun19 avatar

Stargazers

 avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar

Watchers

 avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar

community-plugins's Issues

Media Content Summary

This is a desirable feature of Telegram that is needed in Converse.

Converse keeps a record of the different types of user shared content and it shows them in a list. Making much easier to locate what was posted later on.

If you ever looked for a link you posted on a chat room you'll find this will help you alot.

This could be either a plugin or a feature in converse

captura de tela 2018-12-20 10 58 50
captura de tela 2018-12-20 11 09 01

In overlayed view_mode, have main "Chat Contacts" box fixed on the left side, and chat toggles upwards.

I have an admin inteface, and would really like to

  • put the overlayed main box fixed on the bottom left side of the window instead of the bottom right side. Accordingly, the flyout control box, and then chat boxes, to popup from left to right instead of right to left.
  • Have the various chat toggles go upwards (also the group toggles like ""1 Minimized"") filling up the area of a left column in the browser window and going upwards.

Is this possible?

At some point on version 7.x I had the css code below, however it had various problems and is completely broken in 8.0.

image

My basic objective as you can see was to only used the area in the fixed left column of the window for the small chat toggles, going in an upwards direction.

.chat-head-chatroom > .close-chatbox-button {
    display: none !important;
}

#conversejs.converse-overlayed > .row {
    flex-direction: column-reverse !important;
}

#conversejs .converse-chatboxes {
    position: fixed !important;
    bottom: 40px !important;
    width: 140px !important;
    left: 0px !important;
    right: unset !important;
}

#conversejs .converse-chatboxes {
    left: 0 !important;
    right: unset !important;
}

#conversejs .chatbox-btn.close-chatbox-button.fa-sign-out-alt {
    display: none;
}

#conversejs.converse-overlayed .toggle-controlbox {
    margin: 4px 10px 0 4px !important;
}

#conversejs.converse-overlayed #minimized-chats {
    margin-left: 4px !important;
}

    #conversejs.converse-overlayed #minimized-chats .minimized-chats-flyout {
        flex-direction: column-reverse !important;
        bottom: 100px !important;
    }

settings app: read current settings and hide server admin ones?

In my tests with the settings plugin I noticed that it seems to show a blank state, so it is not possible to see what current settings (for example as defined in the index.html) are already set. This seems to me like a vital function, no?

Furthermore there are a lot of settings that I would not expose to the end user. Is there some plan to hide them?

Thanks!

Idea: Inverse community portal page with non-logged-in reading

Might be good to have something similar to Riot.im, i.e. a somewhat customizable portal page (to add some links or a small explanation) that also shows the locally available public MUCs and allows non-logged in reading of (some of) the available MUCs on the linked primary server.

search plugin some times not working

This was really a good plugin . but some time it was not working any workaround to overcome this issue even given full matched word not results displaying

settings plugin, no word wrap and support for small screens.

Right now even on a 1280 wide screen some of the settings descriptions are cut short due to the lack of word-wrap.

Also the left side-bar of the settings should probably auto-collapse into a hamburger menu or something like that on a small screen like a mobile phone.

Thanks for looking into this :)

SEARCH with 4.2.0 not visible anywhere

HI,

we integrated search plugin in to our converse application. and can see in console search plugin was ready . but missing search icon . can you guide how to acheive this

vmsg index.html out of src tree

Hello,

vmsg is looking for index.html in this path: https://woodpeckersnest.space:5281/packages/vmsg/index.html

while my plugins are under https://woodpeckersnest.space:5281/conversejs/dist/plugins/vmsg/vmsg.js

So it returns a 404. I'm using mod_conversejs from prosody.

I manually edited "vmsg.js" at line 34, changed the index.html path and now I have a different problem:

Error: TypeError: Failed to execute 'compile' on 'WebAssembly': Incorrect response MIME type. Expected 'application/wasm'.
    at worker.onmessage (VM578 vmsg-lib.js:238:18)

vmsg plugin iframe in modal not loading

Inside the overlay modal it just shows a standard 404 error message and the following in the browser console:

TypeError: this.el.querySelector(...).contentWindow.getMp3File is not a function vmsg.js:42:93

make audio conference configurable through conversejs settings

Would be convenient to be able to configure the audioconference server settings easily in the main html document instead of having to modify the javascript file.

An automatic fallback attempt to the main specified BOSH (ws?) server would be also nice in case no server is specified.

Jitsimeet plugin not working with conversejs v9.0.0

I already have conversejs v9.0.0 working with core features. But I needed to add jitsimeet for audio and video call purpose so I added jitsimeet.js file just to check if its working.
I am getting video call icon in toolbar buttons but getting error in console.
Please check below screenshot for more details. And let me know if any more information required.

jitsimeet model error

Can anyone help me with any solution to this issue?
@jcbrand @deleolajide @SilverYoCha

Seach plugin requires smilely button, otherwise breaks converseJS

I have visible_toolbar_buttons: {emoji: false}, set on my mobile view-mode instance as it is broken on small screens and the OSK comes with it's own smilies, but when I try to enable the search plugin it gives the following error:
TypeError: smiley is null search.js:175:13

With that it still loads into the control box but I can join any chats or MUCs.

How to install a plugin

Hi everyone,

May be the answer of my question is simple. But do no know how to install a new plugin.

I used to compile the convers.js (make dist) form source code. So How can I add some community-plugins in the source before compilation. I such a way that after compile (make dist) I will be able to whitelist of blacklist extra plugins based on my needs.

Regards,

Voicechat issue: $ is not defined

Hi there,

using mod_conversejs with prosody, I got the following issue with voicechat plugin:

startVoiceChat ReferenceError: $ is not defined
    at onLocalTracks (voicechat.js:193:20)
(anonymous) @ voicechat.js:153
Promise.catch (async)
startVoiceChat @ voicechat.js:152
performAudio @ voicechat.js:106
handleEvent @ converse.min.js:2

that's in chrome's developer console.

This is my config in /etc/prosody/prosody.cfg.lua:

        voicechat = {
                        ["serviceUrl"] = "wss://beta.meet.jit.si/xmpp-websocket",
                        ["prefix"] = "voicechat-",
                        ["transcribe"] = false,
                        ["transcribeLanguage"] = "en-GB",
                        ["start"] =  "Start Voice Chat",
                        ["stop"] = "Stop Voice Chat",
                        ["started"] = "has started speaking",
                        ["stopped"] = "has stopped speaking",
                        ["hosts"] =
                        {
                                ["domain"] = "beta.meet.jit.si",
                                ["muc"] = "conference.beta.meet.jit.si"
                        }
                };

following Zash's advice on the prosody's MUC, I changed the start/stop/started/stopped lines like that.

vmsg: Avoid 'Upload' button

Right now, users must press two buttons to send a recorded voice message: The green tick and then the Upload button. I think it would improve the UX if the latter could be avoided, for two reasons:

  1. Pressing two buttons to achieve a single effect seems way too much work ๐Ÿ˜„
  2. The naming of the Upload button might be confusing (or even worrying) to end users, who may not be aware that uploading the message is our way of sharing it with (only) the contact/group.

As I understand it, the reason for the separate Upload button is allowing for playing back the recorded message after pressing the green tick. I think it would be nice if this was possible before pressing the green tick, so the green tick would upload the message directly. If this is not an option (or too much work), I'd suggest at least renaming the Upload knob to Send in order to address the second issue I mentioned.

Plugin install on mod_conversejs (prosody)

Hello,

I'm trying to install the "actions" plugin in conversejs under prosody.
I've added the following option to /etc/prosody/prosody.cfg.lua

conversejs_options = {
        ....
        whitelisted_plugins = {"actions"};
}

but I don't know where to place the plugins files..

Thanks

XEPs and RFCs support page with VERSION

Search - also search local DB for decrypted messages

As seen in #21 after using type chat the plugin works in 1:1, but as the description implies, only for messages from MAM.

MAM messages means unencrypted messages, while I have on screen plenty of text, the plugin doesn't find anything....that was OMEMO encrypted.

Hope the plugin can be extended to include local DB saved messages too.

search: move to Skeletor

..as Backbone has been removed on HEAD

log error says:

TypeError: converse.env.Backbone is undefinedsearch.js:23:55
    initialize https://convorb.im/plugins/search/search.js:23

toolbar-utilities icons missing in queries

Hi,

just installed a few plugins and found out that the toolbar-utilities icons are missing when in direct (1:1) chat.

image

I can still click them and the caption will show if hovering over them.

Jitsi meet plugin

(function (root, factory) {
    if (typeof define === 'function' && define.amd) {
        // AMD. Register as a module called "myplugin"
        define(["converse"], factory);
    } else {
        // Browser globals. If you're not using a module loader such as require.js,
        // then this line below executes. Make sure that your plugin's <script> tag
        // appears after the one from converse.js.
        factory(converse);
    }
}(this, function (converse) {

    // Commonly used utilities and variables can be found under the "env"
    // namespace of the "converse" global.
    var Strophe = converse.env.Strophe,
        $iq = converse.env.$iq,
        $msg = converse.env.$msg,
        $pres = converse.env.$pres,
        $build = converse.env.$build,
        b64_sha1 = converse.env.b64_sha1,
        _ = converse.env._,
        moment = converse.env.moment;
        var _converse = null,  baseUrl = null, default_domain = null, bosh = null, muc = null;
        var conferenceView;
    var options ;
    var confOptions = {
        openBridgeChannel: true,
        p2p: {
            // Enables peer to peer mode. When enabled the system will try to
            // establish a direct connection when there are exactly 2 participants
            // in the room. If that succeeds the conference will stop sending data
            // through the JVB and use the peer to peer connection instead. When a
            // 3rd participant joins the conference will be moved back to the JVB
            // connection.
            enabled: false,
      
            // Use XEP-0215 to fetch STUN and TURN servers.
            // useStunTurn: true,
      
            // The STUN servers that will be used in the peer to peer connections
            stunServers: [
              { urls: 'stun:stun.l.google.com:19302' },
              { urls: 'stun:stun1.l.google.com:19302' },
              { urls: 'stun:stun2.l.google.com:19302' }
            ],
      
            // If set to true, it will prefer to use H.264 for P2P calls (if H.264
            // is supported).
            preferH264: true
      
            // If set to true, disable H.264 video codec by stripping it out of the
            // SDP.
            // disableH264: false,
      
            // How long we're going to wait, before going back to P2P after the 3rd
            // participant has left the conference (to filter out page reload).
            // backToP2PDelay: 5
          }
    };
    
    var connection = null;
    var isJoined = false;
    var room = null;
    
    var localTracks = [];
    var remoteTracks = {};
    var initOptions = {
        disableAudioLevels: true,
    
        // The ID of the jidesha extension for Chrome.
        desktopSharingChromeExtId: 'mbocklcggfhnbahlnepmldehdhpjfcjp',
    
        // Whether desktop sharing should be disabled on Chrome.
        desktopSharingChromeDisabled: false,
    
        // The media sources to use when using screen sharing with the Chrome
        // extension.
        desktopSharingChromeSources: [ 'screen', 'window' ],
    
        // Required version of Chrome extension
        desktopSharingChromeMinExtVersion: '0.1',
    
        // Whether desktop sharing should be disabled on Firefox.
        desktopSharingFirefoxDisabled: true
    };
    var jid;
    var passeord;
    // The following line registers your plugin.
    converse.plugins.add("jitsimeet", {

        /* Dependencies are other plugins which might be
         * overridden or relied upon, and therefore need to be loaded before
         * this plugin. They are "optional" because they might not be
         * available, in which case any overrides applicable to them will be
         * ignored.
         *
         * NB: These plugins need to have already been loaded via require.js.
         *
         * It's possible to make these dependencies "non-optional".
         * If the setting "strict_plugin_dependencies" is set to true,
         * an error will be raised if the plugin is not found.
         */
        'dependencies': ['converse-roomslist','converse-muc-views'],

        /* Converse.js's plugin mechanism will call the initialize
         * method on any plugin (if it exists) as soon as the plugin has
         * been loaded.
         */
        'initialize': function () {
            /* Inside this method, you have access to the private
             * `_converse` object.
             */
            _converse = this._converse;
            // _converse.log("The \"myplugin\" plugin is being initialized");

            /* From the `_converse` object you can get any configuration
             * options that the user might have passed in via
             * `converse.initialize`.
             *
             * You can also specify new configuration settings for this
             * plugin, or override the default values of existing
             * configuration settings. This is done like so:
            */
            _converse.api.settings.update({
                'initialize_message': 'Initializing myplugin!'
            });

            /* The user can then pass in values for the configuration
             * settings when `converse.initialize` gets called.
             * For example:
             *
             *      converse.initialize({
             *           "initialize_message": "My plugin has been initialized"
             *      });
             */
            // alert(this._converse.initialize_message);

            /* Besides `_converse.api.settings.update`, there is also a
             * `_converse.api.promises.add` method, which allows you to
             * add new promises that your plugin is obligated to fulfill.
             *
             * This method takes a string or a list of strings which
             * represent the promise names:
             *
             *      _converse.api.promises.add('myPromise');
             *
             * Your plugin should then, when appropriate, resolve the
             * promise by calling `_converse.api.emit`, which will also
             * emit an event with the same name as the promise.
             * For example:
             *
             *      _converse.api.emit('operationCompleted');
             *
             * Other plugins can then either listen for the event
             * `operationCompleted` like so:
             *
             *      _converse.api.listen.on('operationCompleted', function { ... });
             *
             * or they can wait for the promise to be fulfilled like so:
             *
             *      _converse.api.waitUntil('operationCompleted', function { ... });
             */
            baseUrl = location.protocol+"//" + _converse.api.settings.get("bosh_service_url").split("/")[2];
            bosh = '//'+_converse.api.settings.get("bosh_service_url").split("/")[2]+"/http-bind";
            default_domain = _converse.api.settings.get("default_domain");
            muc = _converse.api.settings.get("muc_domain")
          console.log('connection',_converse.connection);
            options = {
                hosts: {
                    domain: default_domain,
                    muc: muc // FIXME: use XEP-0030
                },
                bosh: bosh, // FIXME: use xep-0156 for that
            
                // The name of client node advertised in XEP-0115 'c' stanza
                clientNode: 'http://jitsi.org/jitsimeet'
            };
            videoDialog = _converse.BootstrapModal.extend({
                initialize() {
                    _converse.BootstrapModal.prototype.initialize.apply(this, arguments);
                    // this.model.on('change', this.render, this);
                    JitsiMeetJS.init(initOptions);
                    JitsiMeetJS.setLogLevel(JitsiMeetJS.logLevels.DEBUG);
                    jitisInitalize()
                },
                toHTML() {
                  return '<div class="modal" id="myModal"> <div class="modal-dialog modal-lg"> <div class="modal-content">' +
                         '<div class="modal-body">' +
                         '</div>' +
                         '</div> </div>' +
                         '<div style="width:100px;height:100px" id="localVideo"> </div>' +
                         '</div>';
                },

            });
            function jitisInitalize(){
                connection = new JitsiMeetJS.JitsiConnection(null, null, options);
                console.log('connecttion jitsi meet',connection)
                connection.addEventListener(
                    JitsiMeetJS.events.connection.CONNECTION_ESTABLISHED,
                    onConnectionSuccess);
                connection.addEventListener(
                    JitsiMeetJS.events.connection.CONNECTION_FAILED,
                    onConnectionFailed);
                connection.addEventListener(
                    JitsiMeetJS.events.connection.CONNECTION_DISCONNECTED,
                    disconnect);
                
                JitsiMeetJS.mediaDevices.addEventListener(
                    JitsiMeetJS.events.mediaDevices.DEVICE_LIST_CHANGED,
                    onDeviceListChanged);
                    console.log('passwrd',password)
                    //>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>
                connection.connect({id:_converse.connection.jid.split('/')[0],password:_converse.connection.pass});
                
                JitsiMeetJS.createLocalTracks({ devices: [ 'audio', 'video' ] })
                    .then(onLocalTracks)
                    .catch(error => {
                        throw error;
                    });
                
                if (JitsiMeetJS.mediaDevices.isDeviceChangeAvailable('output')) {
                    JitsiMeetJS.mediaDevices.enumerateDevices(devices => {
                        const audioOutputDevices
                            = devices.filter(d => d.kind === 'audiooutput');
                
                        if (audioOutputDevices.length > 1) {
                            $('#audioOutputSelect').html(
                                audioOutputDevices
                                    .map(
                                        d =>
                                            `<option value="${d.deviceId}">${d.label}</option>`)
                                    .join('\n'));
                
                            $('#audioOutputSelectWrapper').show();
                        }
                    });
                }
            }
            function onConnectionSuccess() {
                room = connection.initJitsiConference('test', confOptions);
                console.log('room',room)
                room.on(JitsiMeetJS.events.conference.TRACK_ADDED, onRemoteTrack);
                room.on(JitsiMeetJS.events.conference.TRACK_REMOVED, track => {
                    console.log(`track removed!!!${track}`);
                });
                room.on(
                    JitsiMeetJS.events.conference.CONFERENCE_JOINED,
                    onConferenceJoined);
                room.on(JitsiMeetJS.events.conference.USER_JOINED, id => {
                    console.log('user join');
                    remoteTracks[id] = [];
                });
                room.on(JitsiMeetJS.events.conference.USER_LEFT, onUserLeft);
                room.on(JitsiMeetJS.events.conference.TRACK_MUTE_CHANGED, track => {
                    console.log(`${track.getType()} - ${track.isMuted()}`);
                });
                room.on(
                    JitsiMeetJS.events.conference.DISPLAY_NAME_CHANGED,
                    (userID, displayName) => console.log(`${userID} - ${displayName}`));
                room.on(
                    JitsiMeetJS.events.conference.TRACK_AUDIO_LEVEL_CHANGED,
                    (userID, audioLevel) => console.log(`${userID} - ${audioLevel}`));
                room.on(
                    JitsiMeetJS.events.conference.PHONE_NUMBER_CHANGED,
                    () => console.log(`${room.getPhoneNumber()} - ${room.getPhonePin()}`));
                    
                room.join();
            }
            function onRemoteTrack(track) {
                if (track.isLocal()) {
                    return;
                }
                const participant = track.getParticipantId();
            
                if (!remoteTracks[participant]) {
                    remoteTracks[participant] = [];
                }
                const idx = remoteTracks[participant].push(track);
            
                track.addEventListener(
                    JitsiMeetJS.events.track.TRACK_AUDIO_LEVEL_CHANGED,
                    audioLevel => console.log(`Audio Level remote: ${audioLevel}`));
                track.addEventListener(
                    JitsiMeetJS.events.track.TRACK_MUTE_CHANGED,
                    () => console.log('remote track muted'));
                track.addEventListener(
                    JitsiMeetJS.events.track.LOCAL_TRACK_STOPPED,
                    () => console.log('remote track stoped'));
                track.addEventListener(JitsiMeetJS.events.track.TRACK_AUDIO_OUTPUT_CHANGED,
                    deviceId =>
                        console.log(
                            `track audio output device was changed to ${deviceId}`));
                const id = participant + track.getType() + idx;
            
                if (track.getType() === 'video') {
                    $('#localVideo').append(
                        `<video style="width:100px;height:100px" autoplay='1' id='${participant}video${idx}' />`);
                } else {
                    $('#localVideo').append(
                        `<audio autoplay='1' id='${participant}audio${idx}' />`);
                }
                track.attach($(`#${id}`)[0]);
            }
            function onLocalTracks(tracks) {
                localTracks = tracks;
                for (let i = 0; i < localTracks.length; i++) {
                    localTracks[i].addEventListener(
                        JitsiMeetJS.events.track.TRACK_AUDIO_LEVEL_CHANGED,
                        audioLevel => console.log(`Audio Level local: ${audioLevel}`));
                    localTracks[i].addEventListener(
                        JitsiMeetJS.events.track.TRACK_MUTE_CHANGED,
                        () => console.log('local track muted'));
                    localTracks[i].addEventListener(
                        JitsiMeetJS.events.track.LOCAL_TRACK_STOPPED,
                        () => console.log('local track stoped'));
                    localTracks[i].addEventListener(
                        JitsiMeetJS.events.track.TRACK_AUDIO_OUTPUT_CHANGED,
                        deviceId =>
                            console.log(
                                `track audio output device was changed to ${deviceId}`));
                    if (localTracks[i].getType() === 'video') {
                        $('#localVideo').append(`<video autoplay='1' style="width:100px;height:100px;margin-left:500px" id='localVideo${i}' />`);
                        console.log('localVideo',$('localVideo'),i);
                        localTracks[i].attach($(`#localVideo${i}`)[0]);
                        
                    } else {
                        $('#localVideo').append(
                            `<audio autoplay='1' muted='true' id='localAudio${i}' />`);
                        localTracks[i].attach($(`#localAudio${i}`)[0]);
                    }
                    if (isJoined) {
                        room.addTrack(localTracks[i]);
                    }
                }
            }
            function disconnect() {
                console.log('disconnect!');
                connection.removeEventListener(
                    JitsiMeetJS.events.connection.CONNECTION_ESTABLISHED,
                    onConnectionSuccess);
                connection.removeEventListener(
                    JitsiMeetJS.events.connection.CONNECTION_FAILED,
                    onConnectionFailed);
                connection.removeEventListener(
                    JitsiMeetJS.events.connection.CONNECTION_DISCONNECTED,
                    disconnect);
            }
            function onDeviceListChanged(devices) {
                console.info('current devices', devices);
            }
            function onConnectionFailed() {
                console.error('Connection Failed!');
            }
            function onConferenceJoined() {
                console.log('conference joined!');
                isJoined = true;
                for (let i = 0; i < localTracks.length; i++) {
                    room.addTrack(localTracks[i]);
                }
            }
            function onUserLeft(id) {
                console.log('user left');
                if (!remoteTracks[id]) {
                    return;
                }
                const tracks = remoteTracks[id];
            
                for (let i = 0; i < tracks.length; i++) {
                    tracks[i].detach($(`#${id}${tracks[i].getType()}`));
                }
            }
            
        },

        /* If you want to override some function or a Backbone model or
         * view defined elsewhere in converse.js, then you do that under
         * the "overrides" namespace.
         */
        'overrides': {
            /* For example, the private *_converse* object has a
             * method "onConnected". You can override that method as follows:
             */
            'onConnected': function () {
                // Overrides the onConnected method in converse.js

                // Top-level functions in "overrides" are bound to the
                // inner "_converse" object.
                var _converse = this;
                jid = _converse.connection.jid
                password = _converse.connection.password
                // Your custom code can come here ...

                // You can access the original function being overridden
                // via the __super__ attribute.
                // Make sure to pass on the arguments supplied to this
                // function and also to apply the proper "this" object.
                _converse.__super__.onConnected.apply(this, arguments);

                // Your custom code can come here ...
            },
            ChatRoomView:{
                initialize(){
                     var _converse = this;
                    this.createConferenceView();
                    _converse.__super__.initialize.apply(this, arguments);
                },
                createConferenceView(){
                    console.log('create conference view',_converse.connection.pass);
                },
                videoCall(){
                    videoDialog = new videoDialog();
                    videoDialog.show();
                }

            },

            /* Override converse.js's XMPPStatus Backbone model so that we can override the
             * function that sends out the presence stanza.
             */
            'XMPPStatus': {
                'sendPresence': function (type, status_message, jid) {
                    // The "_converse" object is available via the __super__
                    // attribute.
                    var _converse = this.__super__._converse;

                    // Custom code can come here ...

                    // You can call the original overridden method, by
                    // accessing it via the __super__ attribute.
                    // When calling it, you need to apply the proper
                    // context as reference by the "this" variable.
                    this.__super__.sendPresence.apply(this, arguments);

                    // Custom code can come here ...
                }
            }
        }
    });
    function newElement(el, id, html)
    {
        var ele = document.createElement(el);
        if (id) ele.id = id;
        if (html) ele.innerHTML = html;
        document.body.appendChild(ele);
        return ele;
    }
    
}));

I am trying to get password of user in the jitisInitalize function. but its undefine. could you help me ?

screencast issue (doesn't load anything)

Hi,

I'm trying the screencast plugin in my LAN with 2 desktop PCs. Problem is, after I start casting from one PC, nothing loads on the second and vice versa.. So it looks like it's casting from one side (I have the popup window and can select what to cast), but nothing really happens in destination.

Option to disable PDF button in Search plugin?

In some browsers or platforms the PDF functionality will not work, thus the button is a bit confusing. Would be nice to optionally disable/hide it.

An alternative might be a Print button as that is more widely supported and can also be used to make pdfs.

Recommend Projects

  • React photo React

    A declarative, efficient, and flexible JavaScript library for building user interfaces.

  • Vue.js photo Vue.js

    ๐Ÿ–– Vue.js is a progressive, incrementally-adoptable JavaScript framework for building UI on the web.

  • Typescript photo Typescript

    TypeScript is a superset of JavaScript that compiles to clean JavaScript output.

  • TensorFlow photo TensorFlow

    An Open Source Machine Learning Framework for Everyone

  • Django photo Django

    The Web framework for perfectionists with deadlines.

  • D3 photo D3

    Bring data to life with SVG, Canvas and HTML. ๐Ÿ“Š๐Ÿ“ˆ๐ŸŽ‰

Recommend Topics

  • javascript

    JavaScript (JS) is a lightweight interpreted programming language with first-class functions.

  • web

    Some thing interesting about web. New door for the world.

  • server

    A server is a program made to process requests and deliver data to clients.

  • Machine learning

    Machine learning is a way of modeling and interpreting data that allows a piece of software to respond intelligently.

  • Game

    Some thing interesting about game, make everyone happy.

Recommend Org

  • Facebook photo Facebook

    We are working to build community through open source technology. NB: members must have two-factor auth.

  • Microsoft photo Microsoft

    Open source projects and samples from Microsoft.

  • Google photo Google

    Google โค๏ธ Open Source for everyone.

  • D3 photo D3

    Data-Driven Documents codes.