Implement jump to channel feature.

This commit is contained in:
Richard Lewis 2019-11-26 20:50:40 +00:00
parent fa57814678
commit 0b5cbceffd
5 changed files with 333 additions and 68 deletions

View File

@ -1,5 +1,5 @@
<template> <template>
<ChannelWrapper ref="wrapper" :network="network" :channel="channel"> <ChannelWrapper ref="wrapper" v-bind="$props">
<span class="name">{{ channel.name }}</span> <span class="name">{{ channel.name }}</span>
<span v-if="channel.unread" :class="{highlight: channel.highlight}" class="badge">{{ <span v-if="channel.unread" :class="{highlight: channel.highlight}" class="badge">{{
unreadCount unreadCount
@ -36,6 +36,8 @@ export default {
props: { props: {
network: Object, network: Object,
channel: Object, channel: Object,
active: Boolean,
isFiltering: Boolean,
}, },
computed: { computed: {
unreadCount() { unreadCount() {

View File

@ -5,7 +5,7 @@
ref="element" ref="element"
:class="[ :class="[
'channel-list-item', 'channel-list-item',
{active: activeChannel && channel === activeChannel.channel}, {active: active},
{'parted-channel': channel.type === 'channel' && channel.state === 0}, {'parted-channel': channel.type === 'channel' && channel.state === 0},
{'has-draft': channel.pendingMessage}, {'has-draft': channel.pendingMessage},
{ {
@ -19,7 +19,7 @@
:data-name="channel.name" :data-name="channel.name"
:data-type="channel.type" :data-type="channel.type"
:aria-controls="'#chan-' + channel.id" :aria-controls="'#chan-' + channel.id"
:aria-selected="activeChannel && channel === activeChannel.channel" :aria-selected="active"
:style="channel.closed ? {transition: 'none', opacity: 0.4} : null" :style="channel.closed ? {transition: 'none', opacity: 0.4} : null"
role="tab" role="tab"
@click="click" @click="click"
@ -37,13 +37,15 @@ export default {
props: { props: {
network: Object, network: Object,
channel: Object, channel: Object,
active: Boolean,
isFiltering: Boolean,
}, },
computed: { computed: {
activeChannel() { activeChannel() {
return this.$store.state.activeChannel; return this.$store.state.activeChannel;
}, },
isChannelVisible() { isChannelVisible() {
return !isChannelCollapsed(this.network, this.channel); return this.isFiltering || !isChannelCollapsed(this.network, this.channel);
}, },
}, },
methods: { methods: {

View File

@ -2,6 +2,54 @@
<div v-if="networks.length === 0" class="empty"> <div v-if="networks.length === 0" class="empty">
You are not connected to any networks yet. You are not connected to any networks yet.
</div> </div>
<div v-else>
<div class="jump-to-input">
<input
ref="searchInput"
:value="searchText"
placeholder="Jump to..."
type="search"
class="search input mousetrap"
aria-label="Search among the channel list"
tabindex="-1"
@input="setSearchText"
@keydown.up="navigateResults($event, -1)"
@keydown.down="navigateResults($event, 1)"
@keydown.page-up="navigateResults($event, -10)"
@keydown.page-down="navigateResults($event, 10)"
@keydown.enter="selectResult"
@keydown.escape="deactivateSearch"
@focus="activateSearch"
/>
</div>
<div v-if="searchText" class="jump-to-results">
<div v-if="results.length" ref="results">
<div
v-for="item in results"
:key="item.channel.id"
v-on="{mouseover: () => setActiveSearchItem(item.channel)}"
@click.prevent="selectResult"
>
<Channel
v-if="item.channel.type !== 'lobby'"
:channel="item.channel"
:network="item.network"
:active="item.channel === activeSearchItem"
:is-filtering="true"
/>
<NetworkLobby
v-else
:channel="item.channel"
:network="item.network"
:active="item.channel === activeSearchItem"
:is-filtering="true"
/>
</div>
</div>
<div v-else class="no-results">
No results found.
</div>
</div>
<Draggable <Draggable
v-else v-else
:list="networks" :list="networks"
@ -23,6 +71,8 @@
:key="network.uuid" :key="network.uuid"
:class="{ :class="{
collapsed: network.isCollapsed, collapsed: network.isCollapsed,
'not-connected': !network.status.connected,
'not-secure': !network.status.secure,
}" }"
class="network" class="network"
role="region" role="region"
@ -30,6 +80,7 @@
<NetworkLobby <NetworkLobby
:network="network" :network="network"
:is-join-channel-shown="network.isJoinChannelShown" :is-join-channel-shown="network.isJoinChannelShown"
:active="activeChannel && network.channels[0] === activeChannel.channel"
@toggleJoinChannel="network.isJoinChannelShown = !network.isJoinChannelShown" @toggleJoinChannel="network.isJoinChannelShown = !network.isJoinChannelShown"
/> />
<JoinChannel <JoinChannel
@ -58,15 +109,18 @@
:key="channel.id" :key="channel.id"
:channel="channel" :channel="channel"
:network="network" :network="network"
:active="activeChannel && channel === activeChannel.channel"
/> />
</Draggable> </Draggable>
</div> </div>
</Draggable> </Draggable>
</div>
</template> </template>
<script> <script>
import Mousetrap from "mousetrap"; import Mousetrap from "mousetrap";
import Draggable from "vuedraggable"; import Draggable from "vuedraggable";
import {filter as fuzzyFilter} from "fuzzy";
import NetworkLobby from "./NetworkLobby.vue"; import NetworkLobby from "./NetworkLobby.vue";
import Channel from "./Channel.vue"; import Channel from "./Channel.vue";
import JoinChannel from "./JoinChannel.vue"; import JoinChannel from "./JoinChannel.vue";
@ -82,10 +136,55 @@ export default {
Channel, Channel,
Draggable, Draggable,
}, },
data() {
return {
searchText: "",
activeSearchItem: null,
};
},
computed: { computed: {
activeChannel() {
return this.$store.state.activeChannel;
},
networks() { networks() {
return this.$store.state.networks; return this.$store.state.networks;
}, },
items() {
const items = [];
for (const network of this.$store.state.networks) {
for (const channel of network.channels) {
if (
this.$store.state.activeChannel &&
channel === this.$store.state.activeChannel.channel
) {
continue;
}
items.push({network, channel});
}
}
return items;
},
results() {
const results = fuzzyFilter(this.searchText, this.items, {
extract: (item) => item.channel.name,
}).map((item) => item.original);
return results;
},
},
watch: {
searchText() {
this.setActiveSearchItem();
},
},
mounted() {
Mousetrap.bind("alt+j", this.toggleSearch);
},
destroyed() {
Mousetrap.unbind("alt+j", this.toggleSearch);
}, },
mounted() { mounted() {
Mousetrap.bind("alt+shift+right", this.expandNetwork); Mousetrap.bind("alt+shift+right", this.expandNetwork);
@ -143,6 +242,110 @@ export default {
order: channel.network.channels.map((c) => c.id), order: channel.network.channels.map((c) => c.id),
}); });
}, },
toggleSearch(event) {
event.preventDefault();
if (this.$refs.searchInput === document.activeElement) {
this.closeSearch();
return;
}
this.activateSearch();
},
activateSearch() {
if (this.$refs.searchInput === document.activeElement) {
return;
}
this.sidebarWasOpen = this.$store.state.sidebarOpen;
this.$store.commit("sidebarOpen", true);
this.$nextTick(() => {
this.$refs.searchInput.focus();
});
},
deactivateSearch() {
this.activeSearchItem = null;
this.searchText = "";
this.$refs.searchInput.blur();
if (!this.sidebarWasOpen) {
this.$store.commit("sidebarOpen", false);
}
},
setSearchText(e) {
this.searchText = e.target.value;
},
setActiveSearchItem(channel) {
if (!this.results.length) {
return;
}
if (!channel) {
channel = this.results[0].channel;
}
this.activeSearchItem = channel;
},
selectResult() {
if (!this.searchText || !this.results.length) {
return;
}
this.$root.switchToChannel(this.activeSearchItem);
this.deactivateSearch();
},
navigateResults(event, direction) {
// Prevent propagation to stop global keybind handler from capturing pagedown/pageup
// and redirecting it to the message list container for scrolling
event.stopImmediatePropagation();
event.preventDefault();
if (!this.searchText) {
return;
}
const channels = this.results.map((r) => r.channel);
// Bail out if there's no channels to select
if (!channels.length) {
this.activeSearchItem = null;
return;
}
let currentIndex = channels.indexOf(this.activeSearchItem);
// If there's no active channel select the first or last one depending on direction
if (!this.activeSearchItem || currentIndex === -1) {
this.activeSearchItem = direction ? channels[0] : channels[channels.length - 1];
this.scrollToActive();
return;
}
currentIndex += direction;
// Wrap around the list if necessary. Normaly each loop iterates once at most,
// but might iterate more often if pgup or pgdown are used in a very short list
while (currentIndex < 0) {
currentIndex += channels.length;
}
while (currentIndex > channels.length - 1) {
currentIndex -= channels.length;
}
this.activeSearchItem = channels[currentIndex];
this.scrollToActive();
},
scrollToActive() {
// Scroll the list if needed after the active class is applied
this.$nextTick(() => {
const el = this.$refs.results.querySelector(".active-result");
if (el) {
el.scrollIntoView({block: "nearest", inline: "nearest"});
}
});
},
}, },
}; };
</script> </script>

View File

@ -1,5 +1,5 @@
<template> <template>
<ChannelWrapper :network="network" :channel="channel"> <ChannelWrapper v-bind="$props" :channel="channel">
<button <button
v-if="network.channels.length > 1" v-if="network.channels.length > 1"
:aria-controls="'network-' + network.uuid" :aria-controls="'network-' + network.uuid"
@ -58,6 +58,8 @@ export default {
props: { props: {
network: Object, network: Object,
isJoinChannelShown: Boolean, isJoinChannelShown: Boolean,
active: Boolean,
isFiltering: Boolean,
}, },
computed: { computed: {
channel() { channel() {

View File

@ -325,6 +325,7 @@ p {
.channel-list-item .not-secure-icon::before, .channel-list-item .not-secure-icon::before,
.channel-list-item .not-connected-icon::before, .channel-list-item .not-connected-icon::before,
.channel-list-item .parted-channel-icon::before, .channel-list-item .parted-channel-icon::before,
.jump-to-input::before,
#sidebar .collapse-network-icon::before { #sidebar .collapse-network-icon::before {
font: normal normal normal 14px/1 FontAwesome; font: normal normal normal 14px/1 FontAwesome;
font-size: inherit; /* Can't have font-size inherit on line above, so need to override */ font-size: inherit; /* Can't have font-size inherit on line above, so need to override */
@ -510,6 +511,7 @@ p {
content: "\f0da"; /* http://fontawesome.io/icon/caret-right/ */ content: "\f0da"; /* http://fontawesome.io/icon/caret-right/ */
} }
.jump-to-input::before,
#chat .count::before { #chat .count::before {
color: #cfcfcf; color: #cfcfcf;
content: "\f002"; /* http://fontawesome.io/icon/search/ */ content: "\f002"; /* http://fontawesome.io/icon/search/ */
@ -618,6 +620,7 @@ p {
width: 220px; width: 220px;
max-height: 100%; max-height: 100%;
will-change: transform; will-change: transform;
color: #b7c5d1; /* same as .channel-list-item color */
} }
#viewport.menu-open #sidebar { #viewport.menu-open #sidebar {
@ -648,7 +651,6 @@ p {
.channel-list-item, .channel-list-item,
#sidebar .empty { #sidebar .empty {
color: #b7c5d1;
font-size: 14px; font-size: 14px;
} }
@ -703,10 +705,6 @@ background on hover (unless active) */
right: 10px; right: 10px;
} }
#sidebar .networks {
padding-top: 5px;
}
#sidebar .network { #sidebar .network {
position: relative; position: relative;
margin-bottom: 20px; margin-bottom: 20px;
@ -2807,3 +2805,61 @@ part/quit messages where we don't load previews (adds a blank line otherwise) */
#chat table.channel-list .topic { #chat table.channel-list .topic {
white-space: pre-wrap; white-space: pre-wrap;
} }
/* Jump to feature WIP styles */
.jump-to-input {
margin: 8px;
position: relative;
}
.jump-to-input .input {
margin: 0;
width: 100%;
border: 0;
color: inherit;
background-color: rgba(255, 255, 255, 0.1);
padding-right: 35px;
}
.jump-to-input .input::placeholder {
color: rgba(255, 255, 255, 0.35);
}
.jump-to-input::before {
color: rgba(255, 255, 255, 0.35);
position: absolute;
right: 8px;
top: 0;
bottom: 0;
pointer-events: none;
line-height: 35px;
}
.jump-to-results {
margin: 0;
padding: 0;
list-style: none;
overflow: auto;
}
.jump-to-results .no-results {
margin: 14px 8px;
text-align: center;
}
.jump-to-results .channel-list-item.active {
cursor: pointer;
}
.jump-to-results .channel-list-item .add-channel,
.jump-to-results .channel-list-item .close-tooltip {
display: none;
}
.jump-to-results .channel-list-item[data-type="lobby"] {
padding: 8px 14px;
}
.jump-to-results .channel-list-item[data-type="lobby"]::before {
content: "\f233";
}