aboutsummaryrefslogtreecommitdiff
path: root/src/components
diff options
context:
space:
mode:
authorTerry Truong <terry06890@gmail.com>2022-09-13 19:59:06 +1000
committerTerry Truong <terry06890@gmail.com>2022-09-13 20:00:17 +1000
commit23b5cc80ba02936659564dd03b173d3214ce5978 (patch)
treecdf6a183d1a0bfcb45a924585b764c723dd67b55 /src/components
parente382d4173c990a49a9ef3db1b3681763a3e2e908 (diff)
Use Vue Composition API and ESLint
Diffstat (limited to 'src/components')
-rw-r--r--src/components/AncestryBar.vue150
-rw-r--r--src/components/HelpModal.vue108
-rw-r--r--src/components/IconButton.vue31
-rw-r--r--src/components/LoadingModal.vue30
-rw-r--r--src/components/SButton.vue10
-rw-r--r--src/components/SCollapsible.vue101
-rw-r--r--src/components/SearchModal.vue429
-rw-r--r--src/components/SettingsModal.vue231
-rw-r--r--src/components/TileInfoModal.vue324
-rw-r--r--src/components/TolTile.vue954
-rw-r--r--src/components/TutorialPane.vue193
-rw-r--r--src/components/icon/CloseIcon.vue5
-rw-r--r--src/components/icon/DownIcon.vue5
-rw-r--r--src/components/icon/EduIcon.vue5
-rw-r--r--src/components/icon/ExternalLinkIcon.vue5
-rw-r--r--src/components/icon/HelpIcon.vue5
-rw-r--r--src/components/icon/InfoIcon.vue5
-rw-r--r--src/components/icon/LinkIcon.vue5
-rw-r--r--src/components/icon/LoaderIcon.vue5
-rw-r--r--src/components/icon/LogInIcon.vue5
-rw-r--r--src/components/icon/PauseIcon.vue5
-rw-r--r--src/components/icon/PlayIcon.vue5
-rw-r--r--src/components/icon/SearchIcon.vue5
-rw-r--r--src/components/icon/SettingsIcon.vue5
24 files changed, 1226 insertions, 1400 deletions
diff --git a/src/components/AncestryBar.vue b/src/components/AncestryBar.vue
index 3cfd116..1b4ee81 100644
--- a/src/components/AncestryBar.vue
+++ b/src/components/AncestryBar.vue
@@ -1,92 +1,86 @@
<template>
-<div :style="styles" @wheel.stop="onWheelEvt">
+<div :style="styles" @wheel.stop="onWheelEvt" ref="rootRef">
<tol-tile v-for="(node, idx) in dummyNodes" :key="node.name" class="shrink-0"
:layoutNode="node" :tolMap="tolMap" :nonAbsPos="true" :lytOpts="lytOpts" :uiOpts="uiOpts"
@leaf-click="onTileClick(nodes[idx])" @info-click="onInfoIconClick"/>
</div>
</template>
-<script lang="ts">
-import {defineComponent, PropType} from 'vue';
+<script setup lang="ts">
+import {ref, computed, watch, onMounted, nextTick, PropType} from 'vue';
import TolTile from './TolTile.vue';
import {TolMap} from '../tol';
import {LayoutNode, LayoutOptions} from '../layout';
import {UiOptions} from '../lib';
-export default defineComponent({
- props: {
- nodes: {type: Array as PropType<LayoutNode[]>, required: true},
- vert: {type: Boolean, default: false},
- breadth: {type: Number, required: true},
- // Other
- lytOpts: {type: Object as PropType<LayoutOptions>, required: true},
- uiOpts: {type: Object as PropType<UiOptions>, required: true},
- tolMap: {type: Object as PropType<TolMap>, required: true},
- },
- computed: {
- imgSz(){
- return this.breadth - this.lytOpts.tileSpacing - this.uiOpts.scrollGap;
- // Intentionally omitting extra tileSpacing, to allow for scrollGap with less image shrinkage
- },
- dummyNodes(){ // Childless versions of 'nodes' used to parameterise <tol-tile>s
- return this.nodes.map(n => {
- let newNode = new LayoutNode(n.name, []);
- newNode.dims = [this.imgSz, this.imgSz];
- return newNode;
- });
- },
- styles(): Record<string,string> {
- return {
- // For child layout
- display: 'flex',
- flexDirection: this.vert ? 'column' : 'row',
- alignItems: 'center',
- gap: this.lytOpts.tileSpacing + 'px',
- padding: this.lytOpts.tileSpacing + 'px',
- overflowX: this.vert ? 'hidden' : 'auto',
- overflowY: this.vert ? 'auto' : 'hidden',
- // Other
- backgroundColor: this.uiOpts.ancestryBarBgColor,
- boxShadow: this.uiOpts.shadowNormal,
- };
- },
- },
- methods: {
- // Click events
- onTileClick(node: LayoutNode){
- this.$emit('ancestor-click', node);
- },
- onInfoIconClick(data: string){
- this.$emit('info-click', data);
- },
- // For converting vertical scrolling to horizontal
- onWheelEvt(evt: WheelEvent){
- if (!this.vert && Math.abs(evt.deltaX) < Math.abs(evt.deltaY)){
- this.$el.scrollLeft -= (evt.deltaY > 0 ? -30 : 30);
- }
- },
- // Other
- scrollToEnd(){
- if (this.vert){
- this.$el.scrollTop = this.$el.scrollHeight;
- } else {
- this.$el.scrollLeft = this.$el.scrollWidth;
- }
- },
- },
- watch: {
- // For scrolling-to-end upon node/screen changes
- nodes(){
- this.$nextTick(() => this.scrollToEnd());
- },
- vert(){
- this.$nextTick(() => this.scrollToEnd());
- },
- },
- mounted(){
- this.scrollToEnd();
- },
- components: {TolTile, },
- emits: ['ancestor-click', 'info-click', ],
+// Refs
+const rootRef = ref(null as HTMLDivElement | null);
+
+// Props + events
+const props = defineProps({
+ nodes: {type: Array as PropType<LayoutNode[]>, required: true},
+ vert: {type: Boolean, default: false},
+ breadth: {type: Number, required: true},
+ //
+ lytOpts: {type: Object as PropType<LayoutOptions>, required: true},
+ uiOpts: {type: Object as PropType<UiOptions>, required: true},
+ tolMap: {type: Object as PropType<TolMap>, required: true},
});
+const emit = defineEmits(['ancestor-click', 'info-click']);
+
+// Computed prop data for display
+const imgSz = computed(() =>
+ props.breadth - props.lytOpts.tileSpacing - props.uiOpts.scrollGap
+ // Intentionally omitting extra tileSpacing, to allow for scrollGap with less image shrinkage
+);
+const dummyNodes = computed(() => props.nodes.map(n => {
+ let newNode = new LayoutNode(n.name, []);
+ newNode.dims = [imgSz.value, imgSz.value];
+ return newNode;
+}));
+
+// Click handling
+function onTileClick(node: LayoutNode){
+ emit('ancestor-click', node);
+}
+function onInfoIconClick(data: string){
+ emit('info-click', data);
+}
+
+// Scroll handling
+function onWheelEvt(evt: WheelEvent){ // For converting vertical scrolling to horizontal
+ if (!props.vert && Math.abs(evt.deltaX) < Math.abs(evt.deltaY)){
+ rootRef.value!.scrollLeft -= (evt.deltaY > 0 ? -30 : 30);
+ }
+}
+function scrollToEnd(){
+ let el = rootRef.value!;
+ if (props.vert){
+ el.scrollTop = el.scrollHeight;
+ } else {
+ el.scrollLeft = el.scrollWidth;
+ }
+}
+watch(props.nodes, () => {
+ nextTick(() => scrollToEnd());
+});
+watch(() => props.vert, () => {
+ nextTick(() => scrollToEnd());
+});
+onMounted(() => scrollToEnd());
+
+// Styles
+const styles = computed(() => ({
+ // For child layout
+ display: 'flex',
+ flexDirection: props.vert ? 'column' : 'row',
+ alignItems: 'center',
+ gap: props.lytOpts.tileSpacing + 'px',
+ padding: props.lytOpts.tileSpacing + 'px',
+ overflowX: props.vert ? 'hidden' : 'auto',
+ overflowY: props.vert ? 'auto' : 'hidden',
+ // Other
+ backgroundColor: props.uiOpts.ancestryBarBgColor,
+ boxShadow: props.uiOpts.shadowNormal,
+}));
</script>
diff --git a/src/components/HelpModal.vue b/src/components/HelpModal.vue
index 733810b..c403e53 100644
--- a/src/components/HelpModal.vue
+++ b/src/components/HelpModal.vue
@@ -1,9 +1,9 @@
<template>
-<div class="fixed left-0 top-0 w-full h-full bg-black/20" @click="onClose">
+<div class="fixed left-0 top-0 w-full h-full bg-black/20" @click="onClose" ref="rootRef">
<!-- Outer div is slightly less dark to make scrollbar more distinguishable -->
<div class="absolute left-1/2 -translate-x-1/2 top-1/2 -translate-y-1/2
w-[90%] max-w-[16cm] max-h-[80%] overflow-auto" :style="styles">
- <close-icon @click.stop="onClose" ref="closeIcon"
+ <close-icon @click.stop="onClose" ref="closeRef"
class="absolute top-1 right-1 md:top-2 md:right-2 w-8 h-8 hover:cursor-pointer"/>
<h1 class="text-center text-xl font-bold pt-2 pb-1">Help</h1>
<div class="flex flex-col gap-2 p-2">
@@ -84,7 +84,7 @@
</ul>
</p>
<br/>
- <p>
+ <div>
There are many other methods of visualisation.
Examples include <a href="https://itol.embl.de/" :style="aStyles">iTOL</a>
and <a href="https://www.onezoom.org/" :style="aStyles">OneZoom</a>
@@ -104,7 +104,7 @@
</div>
</div>
</div>
- </p>
+ </div>
<br/>
<h1 class="text-lg font-bold">Settings</h1>
<ul class="list-disc pl-4">
@@ -231,7 +231,7 @@
<a href="https://tree.opentreeoflife.org" :style="aStyles">Open Tree of Life</a>,
in <a href="https://tree.opentreeoflife.org/about/synthesis-release"
:style="aStyles">synthesis release</a>
- version 13.4, accessed 23/04/2022. The data is licensed under
+ version 13.4, accessed 23/04/2022. The data is licensed under
<a href="https://creativecommons.org/publicdomain/zero/1.0/" :style="aStyles">CC0</a>.
</li>
<li>
@@ -296,7 +296,7 @@
Thanks to <a href="https://www.onezoom.org/" :style="aStyles">OneZoom</a> for having
<a href="https://github.com/OneZoom/OZtree/tree/main/OZprivate/ServerScripts/TaxonMappingAndPopularity"
:style="aStyles">code</a>
- that automates node mapping.
+ that automates taxon ID mapping
</li>
<li>
Thanks to
@@ -426,67 +426,51 @@
</div>
</template>
-<script lang="ts">
-import {defineComponent, PropType} from 'vue';
+<script setup lang="ts">
+import {ref, computed, PropType} from 'vue';
import SButton from './SButton.vue';
import SCollapsible from './SCollapsible.vue';
import CloseIcon from './icon/CloseIcon.vue';
import DownIcon from './icon/DownIcon.vue';
-import ExternalLinkIcon from './icon/ExternalLinkIcon.vue';
import {UiOptions} from '../lib';
-export default defineComponent({
- props: {
- tutOpen: {type: Boolean, default: false},
- uiOpts: {type: Object as PropType<UiOptions>, required: true},
- },
- computed: {
- styles(): Record<string,string> {
- return {
- backgroundColor: this.uiOpts.bgColorAlt,
- borderRadius: this.uiOpts.borderRadius + 'px',
- boxShadow: this.uiOpts.shadowNormal,
- };
- },
- scClasses(): string {
- return 'border border-stone-400 rounded';
- },
- scSummaryClasses(): string {
- return "relative text-center p-1 bg-stone-300 hover:brightness-90 hover:bg-lime-200 md:p-2";
- },
- downIconClasses(): string {
- return 'absolute w-6 h-6 my-auto mx-1 transition-transform duration-300';
- },
- downIconExpandedClasses(): string {
- return this.downIconClasses + ' -rotate-90';
- },
- contentClasses(): string {
- return 'py-2 px-2 text-sm md:text-base';
- },
- aStyles(): Record<string,string> {
- return {
- color: this.uiOpts.altColorDark,
- };
- },
- linkIconClasses(): string {
- return 'inline-block w-3 h-3 ml-1';
- },
- touchDevice(): boolean {
- return this.uiOpts.touchDevice;
- },
- },
- methods: {
- onClose(evt: Event){
- if (evt.target == this.$el || (this.$refs.closeIcon as typeof CloseIcon).$el.contains(evt.target)){
- this.$emit('close');
- }
- },
- onStartTutorial(){
- this.$emit('start-tutorial');
- this.$emit('close');
- },
- },
- components: {SButton, SCollapsible, CloseIcon, DownIcon, ExternalLinkIcon, },
- emits: ['close', 'start-tutorial', ],
+// Refs
+const rootRef = ref(null as HTMLDivElement | null)
+const closeRef = ref(null as typeof CloseIcon | null);
+
+// Props + events
+const props = defineProps({
+ tutOpen: {type: Boolean, default: false},
+ uiOpts: {type: Object as PropType<UiOptions>, required: true},
});
+const touchDevice = computed(() => props.uiOpts.touchDevice)
+const emit = defineEmits(['close', 'start-tutorial']);
+
+// Event handlers
+function onClose(evt: Event){
+ if (evt.target == rootRef.value || closeRef.value!.$el.contains(evt.target)){
+ emit('close');
+ }
+}
+function onStartTutorial(){
+ emit('start-tutorial');
+ emit('close');
+}
+
+// Styles
+const styles = computed(() => ({
+ backgroundColor: props.uiOpts.bgColorAlt,
+ borderRadius: props.uiOpts.borderRadius + 'px',
+ boxShadow: props.uiOpts.shadowNormal,
+}));
+const aStyles = computed(() => ({
+ color: props.uiOpts.altColorDark,
+}));
+
+// Classes
+const scClasses = 'border border-stone-400 rounded';
+const scSummaryClasses = 'relative text-center p-1 bg-stone-300 hover:brightness-90 hover:bg-lime-200 md:p-2';
+const downIconClasses = 'absolute w-6 h-6 my-auto mx-1 transition-transform duration-300';
+const downIconExpandedClasses = computed(() => downIconClasses + ' -rotate-90');
+const contentClasses = 'py-2 px-2 text-sm md:text-base';
</script>
diff --git a/src/components/IconButton.vue b/src/components/IconButton.vue
index 5684fb0..9357e97 100644
--- a/src/components/IconButton.vue
+++ b/src/components/IconButton.vue
@@ -5,24 +5,19 @@
</div>
</template>
-<script lang="ts">
-import {defineComponent, PropType} from 'vue';
+<script setup lang="ts">
+import {computed} from 'vue';
-export default defineComponent({
- props: {
- size: {type: Number, default: 36},
- disabled: {type: Boolean, default: false},
- },
- computed: {
- styles(): Record<string,string> {
- return {
- minWidth: this.size + 'px',
- maxWidth: this.size + 'px',
- minHeight: this.size + 'px',
- maxHeight: this.size + 'px',
- padding: (this.size / 5) + 'px',
- };
- },
- },
+const props = defineProps({
+ size: {type: Number, default: 36},
+ disabled: {type: Boolean, default: false},
});
+
+const styles = computed(() => ({
+ minWidth: props.size + 'px',
+ maxWidth: props.size + 'px',
+ minHeight: props.size + 'px',
+ maxHeight: props.size + 'px',
+ padding: (props.size / 5) + 'px',
+}));
</script>
diff --git a/src/components/LoadingModal.vue b/src/components/LoadingModal.vue
index ee8d699..abd405c 100644
--- a/src/components/LoadingModal.vue
+++ b/src/components/LoadingModal.vue
@@ -8,26 +8,20 @@
</div>
</template>
-<script lang="ts">
-import {defineComponent, PropType} from 'vue';
+<script setup lang="ts">
+import {computed, PropType} from 'vue';
import LoaderIcon from './icon/LoaderIcon.vue';
import {UiOptions} from '../lib';
-export default defineComponent({
- props: {
- msg: {type: String, required: true},
- uiOpts: {type: Object as PropType<UiOptions>, required: true},
- },
- computed: {
- styles(): Record<string,string> {
- return {
- color: this.uiOpts.textColor,
- backgroundColor: this.uiOpts.bgColorDark2,
- borderRadius: this.uiOpts.borderRadius + 'px',
- boxShadow: this.uiOpts.shadowNormal,
- };
- },
- },
- components: {LoaderIcon, },
+const props = defineProps({
+ msg: {type: String, required: true},
+ uiOpts: {type: Object as PropType<UiOptions>, required: true},
});
+
+const styles = computed(() => ({
+ color: props.uiOpts.textColor,
+ backgroundColor: props.uiOpts.bgColorDark2,
+ borderRadius: props.uiOpts.borderRadius + 'px',
+ boxShadow: props.uiOpts.shadowNormal,
+}));
</script>
diff --git a/src/components/SButton.vue b/src/components/SButton.vue
index 508f8e6..884fa30 100644
--- a/src/components/SButton.vue
+++ b/src/components/SButton.vue
@@ -5,12 +5,8 @@
</button>
</template>
-<script lang="ts">
-import {defineComponent, PropType} from 'vue';
-
-export default defineComponent({
- props: {
- disabled: {type: Boolean, default: false},
- },
+<script setup lang="ts">
+const props = defineProps({
+ disabled: {type: Boolean, default: false},
});
</script>
diff --git a/src/components/SCollapsible.vue b/src/components/SCollapsible.vue
index 5b49c8c..39b4283 100644
--- a/src/components/SCollapsible.vue
+++ b/src/components/SCollapsible.vue
@@ -11,62 +11,51 @@
</div>
</template>
-<script lang="ts">
-import {defineComponent, PropType} from 'vue';
+<script setup lang="ts">
+import {ref, computed, watch} from 'vue';
-export default defineComponent({
- props: {
- modelValue: {type: Boolean, default: false}, // For using v-model on the component
- },
- data(){
- return {
- open: false,
- };
- },
- computed: {
- styles(): Record<string,string> {
- return {
- overflow: this.open ? 'visible' : 'hidden',
- };
- },
- contentStyles(): Record<string,string> {
- return {
- overflow: 'hidden',
- opacity: this.open ? '1' : '0',
- transitionProperty: 'max-height, opacity',
- transitionDuration: '300ms',
- transitionTimingFunction: 'ease-in-out',
- };
- },
- },
- methods: {
- onClick(evt: Event){
- this.open = !this.open;
- this.$emit('update:modelValue', this.open);
- if (this.open){
- this.$emit('open');
- }
- },
- onEnter(el: HTMLDivElement){
- el.style.maxHeight = el.scrollHeight + 'px';
- },
- onAfterEnter(el: HTMLDivElement){
- el.style.maxHeight = 'none';
- // Allows the content to grow after the transition ends, as the scrollHeight sometimes is too short
- },
- onBeforeLeave(el: HTMLDivElement){
- el.style.maxHeight = el.scrollHeight + 'px';
- el.offsetWidth; // Triggers reflow
- },
- onLeave(el: HTMLDivElement){
- el.style.maxHeight = '0';
- },
- },
- watch: {
- modelValue(newVal, oldVal){
- this.open = newVal;
- },
- },
- emits: ['update:modelValue', 'open', ],
+// Props + events
+const props = defineProps({
+ modelValue: {type: Boolean, default: false}, // For using v-model on the component
});
+const emit = defineEmits(['update:modelValue', 'open']);
+
+// For open status
+const open = ref(false);
+watch(() => props.modelValue, (newVal) => {open.value = newVal})
+function onClick(){
+ open.value = !open.value;
+ emit('update:modelValue', open.value);
+ if (open.value){
+ emit('open');
+ }
+}
+
+// Styles
+const styles = computed(() => ({
+ overflow: open.value ? 'visible' : 'hidden',
+}));
+const contentStyles = computed(() => ({
+ overflow: 'hidden',
+ opacity: open.value ? '1' : '0',
+ transitionProperty: 'max-height, opacity',
+ transitionDuration: '300ms',
+ transitionTimingFunction: 'ease-in-out',
+}));
+
+// Open/close transitions
+function onEnter(el: HTMLDivElement){
+ el.style.maxHeight = el.scrollHeight + 'px';
+}
+function onAfterEnter(el: HTMLDivElement){
+ el.style.maxHeight = 'none';
+ // Allows the content to grow after the transition ends, as the scrollHeight sometimes is too short
+}
+function onBeforeLeave(el: HTMLDivElement){
+ el.style.maxHeight = el.scrollHeight + 'px';
+ el.offsetWidth; // Triggers reflow
+}
+function onLeave(el: HTMLDivElement){
+ el.style.maxHeight = '0';
+}
</script>
diff --git a/src/components/SearchModal.vue b/src/components/SearchModal.vue
index 7406634..a035cac 100644
--- a/src/components/SearchModal.vue
+++ b/src/components/SearchModal.vue
@@ -1,8 +1,8 @@
<template>
-<div class="fixed left-0 top-0 w-full h-full bg-black/40" @click="onClose">
+<div class="fixed left-0 top-0 w-full h-full bg-black/40" @click="onClose" ref="rootRef">
<div class="absolute left-1/2 -translate-x-1/2 top-1/4 -translate-y-1/2 min-w-3/4 md:min-w-[12cm] flex"
:style="styles">
- <input type="text" class="block border p-1 px-2 rounded-l-[inherit] grow" ref="searchInput"
+ <input type="text" class="block border p-1 px-2 rounded-l-[inherit] grow" ref="inputRef"
@keyup.enter="onSearch" @keyup.esc="onClose"
@input="onInput" @keydown.down.prevent="onDownKey" @keydown.up.prevent="onUpKey"/>
<div class="p-1 hover:cursor-pointer">
@@ -32,225 +32,226 @@
</div>
</template>
-<script lang="ts">
-import {defineComponent, PropType} from 'vue';
+<script setup lang="ts">
+import {ref, computed, onMounted, onUnmounted, PropType} from 'vue';
import SearchIcon from './icon/SearchIcon.vue';
-import LogInIcon from './icon/LogInIcon.vue';
import InfoIcon from './icon/InfoIcon.vue';
import {TolNode, TolMap} from '../tol';
import {LayoutNode, LayoutMap, LayoutOptions} from '../layout';
import {queryServer, SearchSugg, SearchSuggResponse, UiOptions} from '../lib';
-export default defineComponent({
- props: {
- lytMap: {type: Object as PropType<LayoutMap>, required: true}, // Used to check if a searched-for node exists
- activeRoot: {type: Object as PropType<LayoutNode>, required: true}, // Sent to server to reduce response size
- tolMap: {type: Object as PropType<TolMap>, required: true}, // Upon a search response, gets new nodes added
- lytOpts: {type: Object as PropType<LayoutOptions>, required: true},
- uiOpts: {type: Object as PropType<UiOptions>, required: true},
- },
- data(){
- return {
- // Search-suggestion data
- searchSuggs: [] as SearchSugg[],
- searchHadMoreSuggs: false,
- suggsInput: '', // The input that resulted in the current suggestions (used to highlight matching text)
- // For search-suggestion requests
- lastSuggReqTime: 0, // Set when a search-suggestions request is initiated
- pendingSuggReqParams: null as null | URLSearchParams,
- // Used by a search-suggestion requester to request with the latest user input
- pendingDelayedSuggReq: 0, // Set via setTimeout() for a non-initial search-suggestions request
- pendingSuggInput: '', // Used to remember what input triggered a suggestions request
- // Other
- focusedSuggIdx: null as null | number, // Index of a search-suggestion selected using the arrow keys
- };
- },
- computed: {
- styles(): Record<string,string> {
- let br = this.uiOpts.borderRadius;
- return {
- backgroundColor: this.uiOpts.bgColorAlt,
- borderRadius: (this.searchSuggs.length == 0) ? `${br}px` : `${br}px ${br}px 0 0`,
- boxShadow: this.uiOpts.shadowNormal,
- };
- },
- suggContainerStyles(): Record<string,string> {
- let br = this.uiOpts.borderRadius;
- return {
- backgroundColor: this.uiOpts.bgColorAlt,
- color: this.uiOpts.textColorAlt,
- borderRadius: `0 0 ${br}px ${br}px`,
- };
- },
- animateLabelStyles(): Record<string,string> {
- return {
- position: 'absolute',
- top: -this.lytOpts.headerSz - 2 + 'px',
- right: '0',
- height: this.lytOpts.headerSz + 'px',
- color: this.uiOpts.textColor,
- };
- },
- suggDisplayStrings(): [string, string, string, string][] {
- let result: [string, string, string, string][] = [];
- let input = this.suggsInput.toLowerCase();
- // For each SearchSugg
- for (let sugg of this.searchSuggs){
- let idx = sugg.name.indexOf(input);
- // Split suggestion text into parts before/within/after an input match
- let strings: [string, string, string, string];
- if (idx != -1){
- strings = [sugg.name.substring(0, idx), input, sugg.name.substring(idx + input.length), ''];
- } else {
- strings = [input, '', '', ''];
- }
- // Indicate any distinct canonical-name
- if (sugg.canonicalName != null){
- strings[3] = ` (aka ${sugg.canonicalName})`;
- }
- //
- result.push(strings);
- }
- return result;
- },
- },
- methods: {
- // Search-suggestion events
- async onInput(){
- let input = this.$refs.searchInput as HTMLInputElement;
- // Check for empty input
- if (input.value.length == 0){
- this.searchSuggs = [];
- this.searchHadMoreSuggs = false;
- this.focusedSuggIdx = null;
- return;
- }
- // Get URL params to use for querying search-suggestions
- let urlParams = new URLSearchParams({
- type: 'sugg',
- name: input.value,
- limit: String(this.uiOpts.searchSuggLimit),
- tree: this.uiOpts.tree,
- });
- // Query server, delaying/skipping if a request was recently sent
- this.pendingSuggReqParams = urlParams;
- this.pendingSuggInput = input.value;
- let doReq = async () => {
- let suggInput = this.pendingSuggInput;
- let responseObj: SearchSuggResponse =
- await queryServer(this.pendingSuggReqParams!);
- if (responseObj == null){
- return;
- }
- this.searchSuggs = responseObj.suggs;
- this.searchHadMoreSuggs = responseObj.hasMore;
- this.suggsInput = suggInput;
- // Auto-select first result if present
- if (this.searchSuggs.length > 0){
- this.focusedSuggIdx = 0;
- } else {
- this.focusedSuggIdx = null;
- }
- };
- let currentTime = new Date().getTime();
- if (this.lastSuggReqTime == 0){
- this.lastSuggReqTime = currentTime;
- await doReq();
- if (this.lastSuggReqTime == currentTime){
- this.lastSuggReqTime = 0;
- }
- } else if (this.pendingDelayedSuggReq == 0){
- this.lastSuggReqTime = currentTime;
- this.pendingDelayedSuggReq = setTimeout(async () => {
- this.pendingDelayedSuggReq = 0;
- await doReq();
- if (this.lastSuggReqTime == currentTime){
- this.lastSuggReqTime = 0;
- }
- }, 300);
- }
- },
- onInfoIconClick(nodeName: string){
- this.$emit('info-click', nodeName);
- },
- onDownKey(){
- if (this.focusedSuggIdx != null){
- this.focusedSuggIdx = (this.focusedSuggIdx + 1) % this.searchSuggs.length;
- }
- },
- onUpKey(){
- if (this.focusedSuggIdx != null){
- this.focusedSuggIdx = (this.focusedSuggIdx - 1 + this.searchSuggs.length) % this.searchSuggs.length;
- // The addition after '-1' is to avoid becoming negative
- }
- },
- // Search events
- onSearch(){
- if (this.focusedSuggIdx == null){
- let input = (this.$refs.searchInput as HTMLInputElement).value.toLowerCase();
- this.resolveSearch(input)
- } else {
- let sugg = this.searchSuggs[this.focusedSuggIdx]
- this.resolveSearch(sugg.canonicalName || sugg.name);
- }
- },
- async resolveSearch(tolNodeName: string){
- if (tolNodeName == ''){
- return;
- }
- // Check if the node data is already here
- if (this.lytMap.has(tolNodeName)){
- this.$emit('search', tolNodeName);
- return;
- }
- // Ask server for nodes in parent-chain, updates tolMap, then emits search event
- let urlParams = new URLSearchParams({
- type: 'node',
- name: tolNodeName,
- toroot: '1',
- excl: this.activeRoot.name,
- tree: this.uiOpts.tree,
- });
- this.$emit('net-wait'); // Allows the parent component to show a loading-indicator
- let responseObj: {[x: string]: TolNode} = await queryServer(urlParams);
- this.$emit('net-get');
- if (responseObj == null){
- return;
- }
- let keys = Object.getOwnPropertyNames(responseObj);
- if (keys.length > 0){
- keys.forEach(key => {
- if (!this.tolMap.has(key)){
- this.tolMap.set(key, responseObj[key])
- }
- });
- this.$emit('search', tolNodeName);
- } else {
- // Trigger failure animation
- let input = this.$refs.searchInput as HTMLInputElement;
- input.classList.remove('animate-red-then-fade');
- input.offsetWidth; // Triggers reflow
- input.classList.add('animate-red-then-fade');
+// Refs
+const rootRef = ref(null as HTMLDivElement | null);
+const inputRef = ref(null as HTMLInputElement | null);
+
+// Props + events
+const props = defineProps({
+ lytMap: {type: Object as PropType<LayoutMap>, required: true}, // Used to check if a searched-for node exists
+ activeRoot: {type: Object as PropType<LayoutNode>, required: true}, // Sent to server to reduce response size
+ tolMap: {type: Object as PropType<TolMap>, required: true}, // Upon a search response, gets new nodes added
+ lytOpts: {type: Object as PropType<LayoutOptions>, required: true},
+ uiOpts: {type: Object as PropType<UiOptions>, required: true},
+});
+const emit = defineEmits(['search', 'close', 'info-click', 'setting-chg', 'net-wait', 'net-get']);
+
+// Search-suggestion data
+const searchSuggs = ref([] as SearchSugg[]);
+const searchHadMoreSuggs = ref(false);
+const suggDisplayStrings = computed((): [string, string, string, string][] => {
+ let result: [string, string, string, string][] = [];
+ let input = suggsInput.value.toLowerCase();
+ // For each SearchSugg
+ for (let sugg of searchSuggs.value){
+ let idx = sugg.name.indexOf(input);
+ // Split suggestion text into parts before/within/after an input match
+ let strings: [string, string, string, string];
+ if (idx != -1){
+ strings = [sugg.name.substring(0, idx), input, sugg.name.substring(idx + input.length), ''];
+ } else {
+ strings = [input, '', '', ''];
+ }
+ // Indicate any distinct canonical-name
+ if (sugg.canonicalName != null){
+ strings[3] = ` (aka ${sugg.canonicalName})`;
+ }
+ //
+ result.push(strings);
+ }
+ return result;
+});
+const suggsInput = ref(''); // The input that resulted in the current suggestions (used to highlight matching text)
+const focusedSuggIdx = ref(null as null | number); // Index of a search-suggestion selected using the arrow keys
+
+// For search-suggestion requests
+const lastSuggReqTime = ref(0); // Set when a search-suggestions request is initiated
+const pendingSuggReqParams = ref(null as null | URLSearchParams);
+ // Used by a search-suggestion requester to request with the latest user input
+const pendingDelayedSuggReq = ref(0); // Set via setTimeout() for a non-initial search-suggestions request
+const pendingSuggInput = ref(''); // Used to remember what input triggered a suggestions request
+async function onInput(){
+ let input = inputRef.value!;
+ // Check for empty input
+ if (input.value.length == 0){
+ searchSuggs.value = [];
+ searchHadMoreSuggs.value = false;
+ focusedSuggIdx.value = null;
+ return;
+ }
+ // Get URL params to use for querying search-suggestions
+ let urlParams = new URLSearchParams({
+ type: 'sugg',
+ name: input.value,
+ limit: String(props.uiOpts.searchSuggLimit),
+ tree: props.uiOpts.tree,
+ });
+ // Query server, delaying/skipping if a request was recently sent
+ pendingSuggReqParams.value = urlParams;
+ pendingSuggInput.value = input.value;
+ let doReq = async () => {
+ let suggInput = pendingSuggInput.value;
+ let responseObj: SearchSuggResponse =
+ await queryServer(pendingSuggReqParams.value!);
+ if (responseObj == null){
+ return;
+ }
+ searchSuggs.value = responseObj.suggs;
+ searchHadMoreSuggs.value = responseObj.hasMore;
+ suggsInput.value = suggInput;
+ // Auto-select first result if present
+ if (searchSuggs.value.length > 0){
+ focusedSuggIdx.value = 0;
+ } else {
+ focusedSuggIdx.value = null;
+ }
+ };
+ let currentTime = new Date().getTime();
+ if (lastSuggReqTime.value == 0){
+ lastSuggReqTime.value = currentTime;
+ await doReq();
+ if (lastSuggReqTime.value == currentTime){
+ lastSuggReqTime.value = 0;
+ }
+ } else if (pendingDelayedSuggReq.value == 0){
+ lastSuggReqTime.value = currentTime;
+ pendingDelayedSuggReq.value = setTimeout(async () => {
+ pendingDelayedSuggReq.value = 0;
+ await doReq();
+ if (lastSuggReqTime.value == currentTime){
+ lastSuggReqTime.value = 0;
}
- },
- // Other
- onSearchModeChg(){
- this.uiOpts.searchJumpMode = !this.uiOpts.searchJumpMode;
- this.$emit('setting-chg', 'searchJumpMode');
- },
- onClose(evt: Event){
- if (evt.target == this.$el){
- this.$emit('close');
+ }, 300);
+ }
+}
+
+// For search events
+function onSearch(){
+ if (focusedSuggIdx.value == null){
+ let input = inputRef.value!.value.toLowerCase();
+ resolveSearch(input)
+ } else {
+ let sugg = searchSuggs.value[focusedSuggIdx.value]
+ resolveSearch(sugg.canonicalName || sugg.name);
+ }
+}
+async function resolveSearch(tolNodeName: string){
+ if (tolNodeName == ''){
+ return;
+ }
+ // Check if the node data is already here
+ if (props.lytMap.has(tolNodeName)){
+ emit('search', tolNodeName);
+ return;
+ }
+ // Ask server for nodes in parent-chain, updates tolMap, then emits search event
+ let urlParams = new URLSearchParams({
+ type: 'node',
+ name: tolNodeName,
+ toroot: '1',
+ excl: props.activeRoot.name,
+ tree: props.uiOpts.tree,
+ });
+ emit('net-wait'); // Allows the parent component to show a loading-indicator
+ let responseObj: {[x: string]: TolNode} = await queryServer(urlParams);
+ emit('net-get');
+ if (responseObj == null){
+ return;
+ }
+ let keys = Object.getOwnPropertyNames(responseObj);
+ if (keys.length > 0){
+ keys.forEach(key => {
+ if (!props.tolMap.has(key)){
+ props.tolMap.set(key, responseObj[key])
}
- },
- focusInput(){ // Used from external component
- (this.$refs.searchInput as HTMLInputElement).focus();
- },
- },
- mounted(){
- (this.$refs.searchInput as HTMLInputElement).focus();
- },
- components: {SearchIcon, InfoIcon, LogInIcon, },
- emits: ['search', 'close', 'info-click', 'setting-chg', 'net-wait', 'net-get', ],
+ });
+ emit('search', tolNodeName);
+ } else {
+ // Trigger failure animation
+ let input = inputRef.value!;
+ input.classList.remove('animate-red-then-fade');
+ input.offsetWidth; // Triggers reflow
+ input.classList.add('animate-red-then-fade');
+ }
+}
+
+// More event handling
+function onClose(evt: Event){
+ if (evt.target == rootRef.value){
+ emit('close');
+ }
+}
+function onDownKey(){
+ if (focusedSuggIdx.value != null){
+ focusedSuggIdx.value = (focusedSuggIdx.value + 1) % searchSuggs.value.length;
+ }
+}
+function onUpKey(){
+ if (focusedSuggIdx.value != null){
+ focusedSuggIdx.value = (focusedSuggIdx.value - 1 + searchSuggs.value.length) % searchSuggs.value.length;
+ // The addition after '-1' is to avoid becoming negative
+ }
+}
+function onInfoIconClick(nodeName: string){
+ emit('info-click', nodeName);
+}
+
+// For keyboard shortcuts
+function onKeyDown(evt: KeyboardEvent){
+ if (props.uiOpts.disableShortcuts){
+ return;
+ }
+ if (evt.key == 'f' && evt.ctrlKey){
+ evt.preventDefault();
+ inputRef.value!.focus();
+ }
+}
+onMounted(() => window.addEventListener('keydown', onKeyDown))
+onUnmounted(() => window.removeEventListener('keydown', onKeyDown))
+
+// Focus input on mount
+onMounted(() => inputRef.value!.focus())
+
+// Styles
+const styles = computed((): Record<string,string> => {
+ let br = props.uiOpts.borderRadius;
+ return {
+ backgroundColor: props.uiOpts.bgColorAlt,
+ borderRadius: (searchSuggs.value.length == 0) ? `${br}px` : `${br}px ${br}px 0 0`,
+ boxShadow: props.uiOpts.shadowNormal,
+ };
+});
+const suggContainerStyles = computed((): Record<string,string> => {
+ let br = props.uiOpts.borderRadius;
+ return {
+ backgroundColor: props.uiOpts.bgColorAlt,
+ color: props.uiOpts.textColorAlt,
+ borderRadius: `0 0 ${br}px ${br}px`,
+ };
});
+const animateLabelStyles = computed(() => ({
+ position: 'absolute',
+ top: -props.lytOpts.headerSz - 2 + 'px',
+ right: '0',
+ height: props.lytOpts.headerSz + 'px',
+ color: props.uiOpts.textColor,
+}));
</script>
diff --git a/src/components/SettingsModal.vue b/src/components/SettingsModal.vue
index 4f5f05e..df8444f 100644
--- a/src/components/SettingsModal.vue
+++ b/src/components/SettingsModal.vue
@@ -1,8 +1,8 @@
<template>
-<div class="fixed left-0 top-0 w-full h-full bg-black/40" @click="onClose">
+<div class="fixed left-0 top-0 w-full h-full bg-black/40" @click="onClose" ref="rootRef">
<div class="absolute left-1/2 -translate-x-1/2 top-1/2 -translate-y-1/2
min-w-[8cm] max-w-[80%] max-h-[80%] overflow-auto" :style="styles">
- <close-icon @click.stop="onClose" ref="closeIcon"
+ <close-icon @click.stop="onClose" ref="closeRef"
class="absolute top-1 right-1 md:top-2 md:right-2 w-8 h-8 hover:cursor-pointer" />
<h1 class="text-xl md:text-2xl font-bold text-center py-2" :class="borderBClasses">Settings</h1>
<div class="pb-2" :class="borderBClasses">
@@ -56,7 +56,7 @@
<input type="range"
min="15" :max="uiOpts.breakpoint == 'sm' ? 150 : 200" v-model.number="lytOpts.minTileSz"
@input="onSettingChgThrottled('LYT', 'minTileSz')" @change="onSettingChg('LYT', 'minTileSz')"
- name="minTileSizeInput" ref="minTileSzInput"/>
+ name="minTileSizeInput" ref="minTileSzRef"/>
<div class="my-auto text-right">{{lytOpts.minTileSz}} px</div>
<!-- Row 2 -->
<label for="maxTileSizeInput" @click="onReset('LYT', 'maxTileSz')" :class="rLabelClasses">
@@ -64,7 +64,7 @@
</label>
<input type="range" min="15" max="400" v-model.number="lytOpts.maxTileSz"
@input="onSettingChgThrottled('LYT', 'maxTileSz')" @change="onSettingChg('LYT', 'maxTileSz')"
- name="maxTileSizeInput" ref="maxTileSzInput"/>
+ name="maxTileSizeInput" ref="maxTileSzRef"/>
<div class="my-auto text-right">{{lytOpts.maxTileSz}} px</div>
<!-- Row 3 -->
<label for="tileSpacingInput" @click="onReset('LYT', 'tileSpacing')" :class="rLabelClasses">
@@ -107,129 +107,124 @@
Reset
</s-button>
<transition name="fade">
- <div v-if="saved" class="absolute right-1 bottom-1" ref="saveIndicator"> Saved </div>
+ <div v-if="saved" class="absolute right-1 bottom-1" ref="saveIndRef"> Saved </div>
</transition>
</div>
</div>
</template>
-<script lang="ts">
-import {defineComponent, PropType} from 'vue';
+<script setup lang="ts">
+import {ref, computed, watch, PropType} from 'vue';
import SButton from './SButton.vue';
import CloseIcon from './icon/CloseIcon.vue';
import {UiOptions, OptionType, getDefaultLytOpts, getDefaultUiOpts} from '../lib';
import {LayoutOptions} from '../layout';
-export default defineComponent({
- props: {
- lytOpts: {type: Object as PropType<LayoutOptions>, required: true},
- uiOpts: {type: Object as PropType<UiOptions>, required: true},
- },
- data(){
- return {
- sweepLeaves: this.lytOpts.layoutType == 'sweep',
- // For making only two of 'layoutType's values available for user selection
- saved: false, // Set to true after a setting is saved
- settingChgTimeout: 0, // Use to throttle some setting-change handling
- };
- },
- computed: {
- styles(): Record<string,string> {
- return {
- backgroundColor: this.uiOpts.bgColorAlt,
- borderRadius: this.uiOpts.borderRadius + 'px',
- boxShadow: this.uiOpts.shadowNormal,
- };
- },
- borderBClasses(): string {
- return 'border-b border-stone-400';
- },
- rLabelClasses(): string { // For reset-upon-click labels
- return "w-fit hover:cursor-pointer hover:text-lime-600";
- },
- },
- methods: {
- onClose(evt: Event){
- if (evt.target == this.$el || (this.$refs.closeIcon as typeof CloseIcon).$el.contains(evt.target)){
- this.$emit('close');
- }
- },
- onSettingChg(optionType: OptionType, option: string){
- // Maintain min/max-tile-size consistency
- if (optionType == 'LYT' && (option == 'minTileSz' || option == 'maxTileSz')){
- let minInput = this.$refs.minTileSzInput as HTMLInputElement;
- let maxInput = this.$refs.maxTileSzInput as HTMLInputElement;
- if (option == 'minTileSz' && Number(minInput.value) > Number(maxInput.value)){
- this.lytOpts.maxTileSz = this.lytOpts.minTileSz;
- this.$emit('setting-chg', 'LYT', 'maxTileSz');
- } else if (option == 'maxTileSz' && Number(maxInput.value) < Number(minInput.value)){
- this.lytOpts.minTileSz = this.lytOpts.maxTileSz;
- this.$emit('setting-chg', 'LYT', 'minTileSz');
- }
- }
- // Notify parent component
- this.$emit('setting-chg', optionType, option,
- {relayout: optionType == 'LYT', reinit: optionType == 'UI' && option == 'tree'});
- // Possibly make saved-indicator appear/animate
- if (!this.saved){
- this.saved = true;
- } else {
- let el = this.$refs.saveIndicator as HTMLDivElement;
- el.classList.remove('animate-flash-green');
- el.offsetWidth; // Triggers reflow
- el.classList.add('animate-flash-green');
- }
- },
- onSettingChgThrottled(optionType: OptionType, option: string){
- if (this.settingChgTimeout == 0){
- this.settingChgTimeout = setTimeout(() => {
- this.settingChgTimeout = 0;
- this.onSettingChg(optionType, option);
- }, this.uiOpts.animationDelay);
- }
- },
- onReset(optionType: OptionType, option: string){
- // Restore the setting's default
- let defaultLytOpts = getDefaultLytOpts();
- let defaultUiOpts = getDefaultUiOpts(defaultLytOpts);
- if (optionType == 'LYT'){
- let lytOpt = option as keyof LayoutOptions;
- if (this.lytOpts[lytOpt] == defaultLytOpts[lytOpt]){
- return;
- }
- (this.lytOpts[lytOpt] as any) = defaultLytOpts[lytOpt];
- if (option == 'layoutType'){
- this.sweepLeaves = this.lytOpts.layoutType == 'sweep';
- }
- } else {
- let uiOpt = option as keyof UiOptions;
- if (this.uiOpts[uiOpt] == defaultUiOpts[uiOpt]){
- return;
- }
- (this.uiOpts[uiOpt] as any) = defaultUiOpts[uiOpt];
- }
- // Notify parent component
- this.onSettingChg(optionType, option);
- },
- onResetAll(){
- // Restore default options
- let defaultLytOpts = getDefaultLytOpts();
- let defaultUiOpts = getDefaultUiOpts(defaultLytOpts);
- let needReinit = this.uiOpts.tree != defaultUiOpts.tree;
- Object.assign(this.lytOpts, defaultLytOpts);
- Object.assign(this.uiOpts, defaultUiOpts);
- // Notify parent component
- this.$emit('reset', needReinit);
- // Clear saved-indicator
- this.saved = false;
- },
- },
- watch: {
- sweepLeaves(newVal: boolean, oldVal: boolean){
- this.lytOpts.layoutType = newVal ? 'sweep' : 'rect';
- },
- },
- components: {SButton, CloseIcon, },
- emits: ['close', 'setting-chg', 'reset', ],
+// Refs
+const rootRef = ref(null as HTMLDivElement | null);
+const closeRef = ref(null as typeof CloseIcon | null);
+const minTileSzRef = ref(null as HTMLInputElement | null);
+const maxTileSzRef = ref(null as HTMLInputElement | null);
+const saveIndRef = ref(null as HTMLDivElement | null);
+
+// Props + events
+const props = defineProps({
+ lytOpts: {type: Object as PropType<LayoutOptions>, required: true},
+ uiOpts: {type: Object as PropType<UiOptions>, required: true},
});
+const emit = defineEmits(['close', 'setting-chg', 'reset', ]);
+
+// For settings
+const sweepLeaves = ref(props.lytOpts.layoutType == 'sweep');
+ // For making only two of 'layoutType's values available for user selection)
+watch(sweepLeaves, (newVal) => {props.lytOpts.layoutType = newVal ? 'sweep' : 'rect'})
+
+// Settings change handling
+const saved = ref(false); // Set to true after a setting is saved
+const settingChgTimeout = ref(0); // Used to throttle some setting-change handling
+function onSettingChg(optionType: OptionType, option: string){
+ // Maintain min/max-tile-size consistency
+ if (optionType == 'LYT' && (option == 'minTileSz' || option == 'maxTileSz')){
+ let minInput = minTileSzRef.value!;
+ let maxInput = maxTileSzRef.value!;
+ if (option == 'minTileSz' && Number(minInput.value) > Number(maxInput.value)){
+ props.lytOpts.maxTileSz = props.lytOpts.minTileSz;
+ emit('setting-chg', 'LYT', 'maxTileSz');
+ } else if (option == 'maxTileSz' && Number(maxInput.value) < Number(minInput.value)){
+ props.lytOpts.minTileSz = props.lytOpts.maxTileSz;
+ emit('setting-chg', 'LYT', 'minTileSz');
+ }
+ }
+ // Notify parent component
+ emit('setting-chg', optionType, option,
+ {relayout: optionType == 'LYT', reinit: optionType == 'UI' && option == 'tree'});
+ // Possibly make saved-indicator appear/animate
+ if (!saved.value){
+ saved.value = true;
+ } else {
+ let el = saveIndRef.value!;
+ el.classList.remove('animate-flash-green');
+ el.offsetWidth; // Triggers reflow
+ el.classList.add('animate-flash-green');
+ }
+}
+function onSettingChgThrottled(optionType: OptionType, option: string){
+ if (settingChgTimeout.value == 0){
+ settingChgTimeout.value = setTimeout(() => {
+ settingChgTimeout.value = 0;
+ onSettingChg(optionType, option);
+ }, props.uiOpts.animationDelay);
+ }
+}
+function onReset(optionType: OptionType, option: string){
+ // Restore the setting's default
+ let defaultLytOpts = getDefaultLytOpts();
+ let defaultUiOpts = getDefaultUiOpts(defaultLytOpts);
+ if (optionType == 'LYT'){
+ let lytOpt = option as keyof LayoutOptions;
+ if (props.lytOpts[lytOpt] == defaultLytOpts[lytOpt]){
+ return;
+ }
+ (props.lytOpts[lytOpt] as any) = defaultLytOpts[lytOpt];
+ if (option == 'layoutType'){
+ sweepLeaves.value = props.lytOpts.layoutType == 'sweep';
+ }
+ } else {
+ let uiOpt = option as keyof UiOptions;
+ if (props.uiOpts[uiOpt] == defaultUiOpts[uiOpt]){
+ return;
+ }
+ (props.uiOpts[uiOpt] as any) = defaultUiOpts[uiOpt];
+ }
+ // Notify parent component
+ onSettingChg(optionType, option);
+}
+function onResetAll(){
+ // Restore default options
+ let defaultLytOpts = getDefaultLytOpts();
+ let defaultUiOpts = getDefaultUiOpts(defaultLytOpts);
+ let needReinit = props.uiOpts.tree != defaultUiOpts.tree;
+ Object.assign(props.lytOpts, defaultLytOpts);
+ Object.assign(props.uiOpts, defaultUiOpts);
+ // Notify parent component
+ emit('reset', needReinit);
+ // Clear saved-indicator
+ saved.value = false;
+}
+
+// Close handling
+function onClose(evt: Event){
+ if (evt.target == rootRef.value || closeRef.value!.$el.contains(evt.target)){
+ emit('close');
+ }
+}
+
+// Styles and classes
+const styles = computed(() => ({
+ backgroundColor: props.uiOpts.bgColorAlt,
+ borderRadius: props.uiOpts.borderRadius + 'px',
+ boxShadow: props.uiOpts.shadowNormal,
+}));
+const borderBClasses = 'border-b border-stone-400';
+const rLabelClasses = "w-fit hover:cursor-pointer hover:text-lime-600"; // For reset-upon-click labels
</script>
diff --git a/src/components/TileInfoModal.vue b/src/components/TileInfoModal.vue
index 39e2b5b..fc09d86 100644
--- a/src/components/TileInfoModal.vue
+++ b/src/components/TileInfoModal.vue
@@ -1,9 +1,9 @@
<template>
-<div class="fixed left-0 top-0 w-full h-full bg-black/40" @click="onClose">
+<div class="fixed left-0 top-0 w-full h-full bg-black/40" @click="onClose" ref="rootRef">
<div class="absolute left-1/2 -translate-x-1/2 top-1/2 -translate-y-1/2
max-w-[80%] w-2/3 min-w-[8cm] md:w-[14cm] lg:w-[16cm] max-h-[80%]" :style="styles">
<div class="pb-1 md:pb-2">
- <close-icon @click.stop="onClose" ref="closeIcon"
+ <close-icon @click.stop="onClose" ref="closeRef"
class="absolute top-1 right-1 md:top-2 md:right-2 w-8 h-8 hover:cursor-pointer"/>
<div class="absolute top-1 left-1 md:top-2 md:left-2 flex items-center">
<a :href="'/?node=' + encodeURIComponent(nodeName)" class="block w-8 h-8 p-[2px] hover:cursor-pointer"
@@ -23,7 +23,7 @@
<div v-if="tolNode.iucn != null">
<a href="https://en.wikipedia.org/wiki/Endangered_species_(IUCN_status)"
target="_blank" title="IUCN Conservation Status">IUCN</a>:
- <span :style="iucnStyles">{{getDisplayIucn(tolNode.iucn)}}</span>
+ <span :style="iucnStyles(tolNode.iucn)">{{getDisplayIucn(tolNode.iucn)}}</span>
</div>
<div>
<a :href="'https://tree.opentreeoflife.org/opentree/argus/opentree13.4@' + tolNode.otolId"
@@ -111,176 +111,174 @@
</div>
</template>
-<script lang="ts">
-import {defineComponent, PropType} from 'vue';
+<script setup lang="ts">
+import {ref, computed, PropType} from 'vue';
import SCollapsible from './SCollapsible.vue';
import CloseIcon from './icon/CloseIcon.vue';
import ExternalLinkIcon from './icon/ExternalLinkIcon.vue';
import DownIcon from './icon/DownIcon.vue';
import LinkIcon from './icon/LinkIcon.vue';
-import {TolNode, TolMap} from '../tol';
-import {LayoutNode, LayoutOptions} from '../layout';
-import {getImagePath, DescInfo, ImgInfo, NodeInfo, InfoResponse, UiOptions} from '../lib';
+import {TolNode} from '../tol';
+import {LayoutOptions} from '../layout';
+import {getImagePath, DescInfo, ImgInfo, InfoResponse, UiOptions} from '../lib';
import {capitalizeWords} from '../util';
-export default defineComponent({
- props: {
- // Node data to display
- nodeName: {type: String, required: true},
- infoResponse: {type: Object as PropType<InfoResponse>, required: true},
- // Options
- lytOpts: {type: Object as PropType<LayoutOptions>, required: true},
- uiOpts: {type: Object as PropType<UiOptions>, required: true},
- },
- data(){
- return {
- linkCopied: false, // Used to temporarily show a 'link copied' label
- };
- },
- computed: {
- tolNode(): TolNode {
- return this.infoResponse.nodeInfo.tolNode;
- },
- nodes(): (TolNode | null)[] {
- if (this.infoResponse.subNodesInfo.length == 0){
- return [this.tolNode];
- } else {
- return this.infoResponse.subNodesInfo.map(nodeInfo => nodeInfo != null ? nodeInfo.tolNode : null);
- }
- },
- imgInfos(): (ImgInfo | null)[] {
- if (this.infoResponse.subNodesInfo.length == 0){
- return [this.infoResponse.nodeInfo.imgInfo];
- } else {
- return this.infoResponse.subNodesInfo.map(nodeInfo => nodeInfo != null ? nodeInfo.imgInfo : null);
- }
- },
- descInfos(): (DescInfo | null)[] {
- if (this.infoResponse.subNodesInfo.length == 0){
- return [this.infoResponse.nodeInfo.descInfo];
- } else {
- return this.infoResponse.subNodesInfo.map(nodeInfo => nodeInfo != null ? nodeInfo.descInfo : null);
- }
- },
- subNames(): [string, string] | null {
- const regex = /\[(.+) \+ (.+)\]/;
- let results = regex.exec(this.nodeName);
- return results == null ? null : [results[1], results[2]];
- },
- styles(): Record<string,string> {
- return {
- backgroundColor: this.uiOpts.bgColorAlt,
- borderRadius: this.uiOpts.borderRadius + 'px',
- boxShadow: this.uiOpts.shadowNormal,
- overflow: 'visible auto',
- };
- },
- iucnStyles(): Record<string,string> {
- let col = 'currentcolor';
- switch (this.tolNode.iucn){
- case 'least concern': col = 'green'; break;
- case 'near threatened': col = 'limegreen'; break;
- case 'vulnerable': col = 'goldenrod'; break;
- case 'endangered': col = 'darkorange'; break;
- case 'critically endangered': col = 'red'; break;
- case 'extinct in the wild':
- case 'extinct species': col = 'gray'; break;
- }
- return {
- color: col,
- };
- },
- linkCopyLabelStyles(): Record<string,string> {
- return {
- color: this.uiOpts.textColor,
- backgroundColor: this.uiOpts.bgColor,
- borderRadius: this.uiOpts.borderRadius + 'px',
- };
- },
- },
- methods: {
- getDisplayName(name: string, tolNode: TolNode | null): string {
- if (tolNode == null || tolNode.commonName == null){
- return capitalizeWords(name);
- } else {
- return `${capitalizeWords(tolNode.commonName)} (aka ${capitalizeWords(name)})`;
- }
- },
- getDisplayIucn(iucn: string){
- switch (this.tolNode.iucn){
- case 'least concern': return 'LC';
- case 'near threatened': return 'NT';
- case 'vulnerable': return 'VN';
- case 'endangered': return 'EN';
- case 'critically endangered': return 'CR';
- case 'extinct in the wild': return 'EX';
- case 'extinct species': return 'ES';
- case 'data deficient': return 'DD';
+// Refs
+const rootRef = ref(null as HTMLDivElement | null);
+const closeRef = ref(null as typeof CloseIcon | null);
+
+// Props + events
+const props = defineProps({
+ // Node data to display
+ nodeName: {type: String, required: true},
+ infoResponse: {type: Object as PropType<InfoResponse>, required: true},
+ // Options
+ lytOpts: {type: Object as PropType<LayoutOptions>, required: true},
+ uiOpts: {type: Object as PropType<UiOptions>, required: true},
+});
+const emit = defineEmits(['close']);
+
+// InfoResponse computed data
+const tolNode = computed(() => props.infoResponse.nodeInfo.tolNode);
+const nodes = computed((): (TolNode | null)[] => {
+ if (props.infoResponse.subNodesInfo.length == 0){
+ return [tolNode.value];
+ } else {
+ return props.infoResponse.subNodesInfo.map(nodeInfo => nodeInfo != null ? nodeInfo.tolNode : null);
+ }
+});
+const imgInfos = computed((): (ImgInfo | null)[] => {
+ if (props.infoResponse.subNodesInfo.length == 0){
+ return [props.infoResponse.nodeInfo.imgInfo];
+ } else {
+ return props.infoResponse.subNodesInfo.map(nodeInfo => nodeInfo != null ? nodeInfo.imgInfo : null);
+ }
+});
+const descInfos = computed((): (DescInfo | null)[] => {
+ if (props.infoResponse.subNodesInfo.length == 0){
+ return [props.infoResponse.nodeInfo.descInfo];
+ } else {
+ return props.infoResponse.subNodesInfo.map(nodeInfo => nodeInfo != null ? nodeInfo.descInfo : null);
+ }
+});
+const subNames = computed((): [string, string] | null => {
+ const regex = /\[(.+) \+ (.+)\]/;
+ let results = regex.exec(props.nodeName);
+ return results == null ? null : [results[1], results[2]];
+});
+
+// InfoResponse data converters
+function getDisplayName(name: string, tolNode: TolNode | null): string {
+ if (tolNode == null || tolNode.commonName == null){
+ return capitalizeWords(name);
+ } else {
+ return `${capitalizeWords(tolNode.commonName)} (aka ${capitalizeWords(name)})`;
+ }
+}
+function getDisplayIucn(iucn: string){
+ switch (iucn){
+ case 'least concern': return 'LC';
+ case 'near threatened': return 'NT';
+ case 'vulnerable': return 'VN';
+ case 'endangered': return 'EN';
+ case 'critically endangered': return 'CR';
+ case 'extinct in the wild': return 'EX';
+ case 'extinct species': return 'ES';
+ case 'data deficient': return 'DD';
+ }
+}
+function licenseToUrl(license: string){
+ license = license.toLowerCase().replaceAll('-', ' ');
+ if (license == 'cc0'){
+ return 'https://creativecommons.org/publicdomain/zero/1.0/';
+ } else if (license == 'cc publicdomain'){
+ return 'https://creativecommons.org/licenses/publicdomain/';
+ } else {
+ const regex = /cc by( nc)?( sa)?( ([0-9.]+)( [a-z]+)?)?/;
+ let results = regex.exec(license);
+ if (results != null){
+ let url = 'https://creativecommons.org/licenses/by';
+ if (results[1] != null){
+ url += '-nc';
}
- },
- getImgStyles(tolNode: TolNode | null): Record<string,string> {
- let imgName = null;
- if (tolNode != null && typeof(tolNode.imgName) === 'string'){ // Exclude string-array case
- imgName = tolNode.imgName;
+ if (results[2] != null){
+ url += '-sa';
}
- return {
- width: '200px',
- height: '200px',
- backgroundImage: imgName != null ?
- `url('${getImagePath(imgName as string)}')` :
- 'none',
- backgroundColor: this.uiOpts.bgColorDark,
- backgroundSize: 'cover',
- borderRadius: this.uiOpts.borderRadius + 'px',
- boxShadow: this.uiOpts.shadowNormal,
- };
- },
- licenseToUrl(license: string){
- license = license.toLowerCase().replaceAll('-', ' ');
- if (license == 'cc0'){
- return 'https://creativecommons.org/publicdomain/zero/1.0/';
- } else if (license == 'cc publicdomain'){
- return 'https://creativecommons.org/licenses/publicdomain/';
+ if (results[4] != null){
+ url += '/' + results[4];
} else {
- const regex = /cc by( nc)?( sa)?( ([0-9.]+)( [a-z]+)?)?/;
- let results = regex.exec(license);
- if (results != null){
- let url = 'https://creativecommons.org/licenses/by';
- if (results[1] != null){
- url += '-nc';
- }
- if (results[2] != null){
- url += '-sa';
- }
- if (results[4] != null){
- url += '/' + results[4];
- } else {
- url += '/4.0';
- }
- if (results[5] != null){
- url += '/' + results[5].substring(1);
- }
- return url;
- }
- return "[INVALID LICENSE]";
+ url += '/4.0';
}
- },
- onClose(evt: Event){
- if (evt.target == this.$el || (this.$refs.closeIcon as typeof CloseIcon).$el.contains(evt.target)){
- this.$emit('close');
+ if (results[5] != null){
+ url += '/' + results[5].substring(1);
}
- },
- onLinkIconClick(evt: Event){
- // Copy link to clipboard
- let url = new URL(window.location.href);
- url.search = (new URLSearchParams({node: this.nodeName})).toString();
- navigator.clipboard.writeText(url.toString());
- // Show visual indicator
- this.linkCopied = true;
- setTimeout(() => {this.linkCopied = false}, 1500);
- },
- },
- components: {SCollapsible, CloseIcon, ExternalLinkIcon, DownIcon, LinkIcon, },
- emits: ['close', ],
-});
+ return url;
+ }
+ return "[INVALID LICENSE]";
+ }
+}
+
+// Close handling
+function onClose(evt: Event){
+ if (evt.target == rootRef.value || closeRef.value!.$el.contains(evt.target)){
+ emit('close');
+ }
+}
+
+// Copy-link handling
+const linkCopied = ref(false); // Used to temporarily show a 'link copied' label
+function onLinkIconClick(){
+ // Copy link to clipboard
+ let url = new URL(window.location.href);
+ url.search = (new URLSearchParams({node: props.nodeName})).toString();
+ navigator.clipboard.writeText(url.toString());
+ // Show visual indicator
+ linkCopied.value = true;
+ setTimeout(() => {linkCopied.value = false}, 1500);
+}
+
+// Styles
+const styles = computed(() => ({
+ backgroundColor: props.uiOpts.bgColorAlt,
+ borderRadius: props.uiOpts.borderRadius + 'px',
+ boxShadow: props.uiOpts.shadowNormal,
+ overflow: 'visible auto',
+}));
+function getImgStyles(tolNode: TolNode | null): Record<string,string> {
+ let imgName = null;
+ if (tolNode != null && typeof(tolNode.imgName) === 'string'){ // Exclude string-array case
+ imgName = tolNode.imgName;
+ }
+ return {
+ width: '200px',
+ height: '200px',
+ backgroundImage: imgName != null ?
+ `url('${getImagePath(imgName as string)}')` :
+ 'none',
+ backgroundColor: props.uiOpts.bgColorDark,
+ backgroundSize: 'cover',
+ borderRadius: props.uiOpts.borderRadius + 'px',
+ boxShadow: props.uiOpts.shadowNormal,
+ };
+}
+function iucnStyles(iucn: string): Record<string,string>{
+ let col = 'currentcolor';
+ switch (iucn){
+ case 'least concern': col = 'green'; break;
+ case 'near threatened': col = 'limegreen'; break;
+ case 'vulnerable': col = 'goldenrod'; break;
+ case 'endangered': col = 'darkorange'; break;
+ case 'critically endangered': col = 'red'; break;
+ case 'extinct in the wild':
+ case 'extinct species': col = 'gray'; break;
+ }
+ return {
+ color: col,
+ };
+}
+const linkCopyLabelStyles = computed(() => ({
+ color: props.uiOpts.textColor,
+ backgroundColor: props.uiOpts.bgColor,
+ borderRadius: props.uiOpts.borderRadius + 'px',
+}));
</script>
diff --git a/src/components/TolTile.vue b/src/components/TolTile.vue
index afb6616..d3b3539 100644
--- a/src/components/TolTile.vue
+++ b/src/components/TolTile.vue
@@ -1,5 +1,5 @@
<template>
-<div :style="styles" @scroll="onScroll">
+<div :style="styles" @scroll="onScroll" ref="rootRef">
<div v-if="isLeaf" :class="[hasOneImage ? 'flex' : 'grid', {'hover:cursor-pointer': isExpandableLeaf}]"
class="w-full h-full flex-col grid-cols-1" :style="leafStyles"
@mouseenter="onMouseEnter" @mouseleave="onMouseLeave" @mousedown="onMouseDown" @mouseup="onMouseUp">
@@ -48,512 +48,470 @@
</div>
</template>
-<script lang="ts">
-import {defineComponent, PropType} from 'vue';
+<script setup lang="ts">
+import {ref, computed, watch, PropType} from 'vue';
import InfoIcon from './icon/InfoIcon.vue';
-import {TolNode, TolMap} from '../tol';
+import {TolMap} from '../tol';
import {LayoutNode, LayoutOptions} from '../layout';
import {getImagePath, UiOptions} from '../lib';
import {capitalizeWords} from '../util';
-const scrimGradient = 'linear-gradient(to bottom, rgba(0,0,0,0.4), #0000 40%, #0000 60%, rgba(0,0,0,0.2) 100%)';
+const SCRIM_GRADIENT = 'linear-gradient(to bottom, rgba(0,0,0,0.4), #0000 40%, #0000 60%, rgba(0,0,0,0.2) 100%)';
-export default defineComponent({
- props: {
- layoutNode: {type: Object as PropType<LayoutNode>, required: true},
- tolMap: {type: Object as PropType<TolMap>, required: true},
- // Options
- lytOpts: {type: Object as PropType<LayoutOptions>, required: true},
- uiOpts: {type: Object as PropType<UiOptions>, required: true},
- // Other
- skipTransition: {type: Boolean, default: false},
- nonAbsPos: {type: Boolean, default: false},
- // For a leaf node, prevents usage of absolute positioning (used by AncestryBar)
- overflownDim: {type: Number, default: 0},
- // For a non-leaf node, display with overflow within area of this height
- },
- data(){
+// Refs
+const rootRef = ref(null as HTMLDivElement | null);
+
+// Props + events
+const props = defineProps({
+ layoutNode: {type: Object as PropType<LayoutNode>, required: true},
+ tolMap: {type: Object as PropType<TolMap>, required: true},
+ // Options
+ lytOpts: {type: Object as PropType<LayoutOptions>, required: true},
+ uiOpts: {type: Object as PropType<UiOptions>, required: true},
+ // Other
+ skipTransition: {type: Boolean, default: false},
+ nonAbsPos: {type: Boolean, default: false},
+ // For a leaf node, prevents usage of absolute positioning (used by AncestryBar)
+ overflownDim: {type: Number, default: 0},
+ // For a non-leaf node, display with overflow within area of this height
+});
+const emit = defineEmits(['leaf-click', 'nonleaf-click', 'leaf-click-held', 'nonleaf-click-held', 'info-click']);
+
+// Data computed from props
+const tolNode = computed(() => props.tolMap.get(props.layoutNode.name)!);
+const visibleChildren = computed((): LayoutNode[] => { // Used to reduce slowdown from rendering many nodes
+ let children = props.layoutNode.children;
+ // If not displaying with overflow, return 'visible' layoutNode children
+ if (!isOverflownRoot.value){
+ return children.filter(n => !n.hidden || n.hiddenWithVisibleTip);
+ }
+ // Otherwise, return children within/near non-overflowing region
+ let firstIdx = children.length - 1;
+ for (let i = 0; i < children.length; i++){
+ if (children[i].pos[1] + children[i].dims[1] >= scrollOffset.value){
+ firstIdx = i;
+ break;
+ }
+ }
+ let lastIdx = children.length;
+ for (let i = firstIdx + 1; i < children.length; i++){
+ if (children[i].pos[1] > scrollOffset.value + props.overflownDim){
+ lastIdx = i;
+ break;
+ }
+ }
+ return children.slice(firstIdx, lastIdx);
+});
+const isLeaf = computed(() => props.layoutNode.children.length == 0);
+const isExpandableLeaf = computed(() => isLeaf.value && tolNode.value.children.length > 0);
+const showNonleafHeader = computed(() =>
+ (props.layoutNode.showHeader && props.layoutNode.sepSweptArea == null) ||
+ (props.layoutNode.sepSweptArea != null && props.layoutNode.sepSweptArea.sweptLeft)
+);
+const displayName = computed((): string => {
+ let newName = capitalizeWords(tolNode.value.commonName || props.layoutNode.name);
+ if (!tolNode.value.pSupport && tolNode.value.parent != null){
+ newName += '*';
+ }
+ return newName;
+});
+const hasOneImage = computed(() => !Array.isArray(tolNode.value.imgName));
+const isOverflownRoot = computed(() =>
+ props.overflownDim > 0 && !props.layoutNode.hidden && props.layoutNode.children.length > 0
+);
+const hasFocusedChild = computed(() => props.layoutNode.children.some(n => n.hasFocus));
+const infoIconDisabled = computed(() => !props.uiOpts.disabledActions.has('tileInfo'));
+
+// Click/hold handling
+const clickHoldTimer = ref(0); // Used to recognise click-and-hold events
+function onMouseDown(): void {
+ highlight.value = false;
+ if (!props.uiOpts.touchDevice){
+ // Wait for a mouseup or click-hold
+ clearTimeout(clickHoldTimer.value);
+ clickHoldTimer.value = setTimeout(() => {
+ clickHoldTimer.value = 0;
+ onClickHold();
+ }, props.uiOpts.clickHoldDuration);
+ } else {
+ // Wait for or recognise a double-click
+ if (clickHoldTimer.value == 0){
+ clickHoldTimer.value = setTimeout(() => {
+ clickHoldTimer.value = 0;
+ onClick();
+ }, props.uiOpts.clickHoldDuration);
+ } else {
+ clearTimeout(clickHoldTimer.value)
+ clickHoldTimer.value = 0;
+ onDblClick();
+ }
+ }
+}
+function onMouseUp(): void {
+ if (!props.uiOpts.touchDevice){
+ if (clickHoldTimer.value > 0){
+ clearTimeout(clickHoldTimer.value);
+ clickHoldTimer.value = 0;
+ onClick();
+ }
+ }
+}
+
+// Click-action handling
+const wasClicked = ref(false); // Used to increase z-index during transition after this tile (or a child) is clicked
+function onClick(): void {
+ if (isLeaf.value && !isExpandableLeaf.value){
+ console.log('Ignored click on non-expandable node');
+ return;
+ }
+ wasClicked.value = true;
+ emit(isLeaf.value ? 'leaf-click' : 'nonleaf-click', props.layoutNode);
+}
+function onClickHold(): void {
+ if (isLeaf.value && !isExpandableLeaf.value){
+ console.log('Ignored click-hold on non-expandable node');
+ return;
+ }
+ emit(isLeaf.value ? 'leaf-click-held' : 'nonleaf-click-held', props.layoutNode);
+}
+function onDblClick(): void {
+ onClickHold();
+}
+function onInfoIconClick(): void {
+ emit('info-click', props.layoutNode.name);
+}
+// Child click-action propagation
+function onInnerLeafClick(node: LayoutNode): void {
+ wasClicked.value = true;
+ emit('leaf-click', node);
+}
+function onInnerNonleafClick(node: LayoutNode): void {
+ wasClicked.value = true;
+ emit('nonleaf-click', node);
+}
+function onInnerLeafClickHeld(node: LayoutNode): void {
+ emit('leaf-click-held', node);
+}
+function onInnerNonleafClickHeld(node: LayoutNode): void {
+ emit('nonleaf-click-held', node);
+}
+function onInnerInfoIconClick(nodeName: string): void {
+ emit('info-click', nodeName);
+}
+
+// Mouse-hover handling
+const highlight = ref(false); // Used to draw a colored outline on mouse hover
+function onMouseEnter(): void {
+ if ((!isLeaf.value || isExpandableLeaf.value) && !inTransition.value){
+ highlight.value = true;
+ }
+}
+function onMouseLeave(): void {
+ highlight.value = false;
+}
+
+// Scrolling if overflownRoot
+const scrollOffset = ref(0); // Used to track scroll offset when displaying with overflow
+const pendingScrollHdlr = ref(0); // Used for throttling updating of scrollOffset
+function onScroll(): void {
+ if (pendingScrollHdlr.value == 0){
+ pendingScrollHdlr.value = setTimeout(() => {
+ scrollOffset.value = rootRef.value!.scrollTop;
+ pendingScrollHdlr.value = 0;
+ }, props.uiOpts.animationDelay);
+ }
+}
+// Scroll to focused child if overflownRoot
+watch(hasFocusedChild, (newVal: boolean) => {
+ if (newVal && isOverflownRoot.value){
+ let focusedChild = props.layoutNode.children.find(n => n.hasFocus)!
+ let bottomY = focusedChild.pos[1] + focusedChild.dims[1] + props.lytOpts.tileSpacing;
+ let scrollTop = Math.max(0, bottomY - (props.overflownDim / 2)); // No need to manually cap at max
+ rootRef.value!.scrollTop = scrollTop;
+ }
+});
+
+// Transition related
+const inTransition = ref(false); // Used to avoid content overlap and overflow during 'user-perceivable' transitions
+const hasExpanded = ref(false); // Set to true after an expansion transition ends, and false upon collapse
+ // Used to hide overflow on tile expansion, but not hide a sepSweptArea on subsequent transitions
+function onTransitionEnd(){
+ if (inTransition.value){
+ inTransition.value = false;
+ wasClicked.value = false;
+ hasExpanded.value = props.layoutNode.children.length > 0;
+ }
+}
+// For setting transition state (allows external triggering, like via search and auto-mode)
+watch(() => props.layoutNode.pos, (newVal: [number, number], oldVal: [number, number]) => {
+ let valChanged = newVal[0] != oldVal[0] || newVal[1] != oldVal[1];
+ if (valChanged && props.uiOpts.transitionDuration > 100 && !inTransition.value){
+ inTransition.value = true;
+ setTimeout(onTransitionEnd, props.uiOpts.transitionDuration);
+ }
+});
+watch(() => props.layoutNode.dims, (newVal: [number, number], oldVal: [number, number]) => {
+ let valChanged = newVal[0] != oldVal[0] || newVal[1] != oldVal[1];
+ if (valChanged && props.uiOpts.transitionDuration > 100 && !inTransition.value){
+ inTransition.value = true;
+ setTimeout(onTransitionEnd, props.uiOpts.transitionDuration);
+ }
+});
+
+// For externally triggering fail animations (used by search and auto-mode)
+function triggerAnimation(animation: string){
+ let el = rootRef.value!;
+ el.classList.remove(animation);
+ el.offsetWidth; // Triggers reflow
+ el.classList.add(animation);
+}
+watch(() => props.layoutNode.failFlag, () =>
+ triggerAnimation(isLeaf.value ? 'animate-expand-shrink' : 'animate-shrink-expand')
+);
+
+// For 'flashing' the tile when focused
+const inFlash = ref(false); // Used to 'flash' the tile when focused
+watch(() => props.layoutNode.hasFocus, (newVal: boolean, oldVal: boolean) => {
+ if (newVal != oldVal && newVal){
+ inFlash.value = true;
+ setTimeout(() => {inFlash.value = false;}, props.uiOpts.transitionDuration);
+ }
+});
+
+// For temporarily enabling overflow after being unhidden
+const justUnhidden = ref(false); // Used to allow overflow temporarily after being unhidden
+watch(() => props.layoutNode.hidden, (newVal: boolean, oldVal: boolean) => {
+ if (oldVal && !newVal){
+ justUnhidden.value = true;
+ setTimeout(() => {justUnhidden.value = false}, props.uiOpts.transitionDuration + 100);
+ }
+});
+
+// Styles + classes
+const nonleafBgColor = computed(() => {
+ let colorArray = props.uiOpts.nonleafBgColors;
+ return colorArray[props.layoutNode.depth % colorArray.length];
+});
+const boxShadow = computed((): string => {
+ if (highlight.value){
+ return props.uiOpts.shadowHovered;
+ } else if (props.layoutNode.hasFocus && !inTransition.value){
+ return props.uiOpts.shadowFocused;
+ } else {
+ return props.uiOpts.shadowNormal;
+ }
+});
+const fontSz = computed((): number => {
+ // These values are a compromise between dynamic font size and code simplicity
+ if (props.layoutNode.dims[0] >= 150){
+ return props.lytOpts.headerSz * 0.8;
+ } else if (props.layoutNode.dims[0] >= 80){
+ return props.lytOpts.headerSz * 0.7;
+ } else {
+ return props.lytOpts.headerSz * 0.6;
+ }
+});
+//
+const styles = computed((): Record<string,string> => {
+ let layoutStyles = {
+ position: 'absolute',
+ left: props.layoutNode.pos[0] + 'px',
+ top: props.layoutNode.pos[1] + 'px',
+ width: props.layoutNode.dims[0] + 'px',
+ height: props.layoutNode.dims[1] + 'px',
+ borderRadius: props.uiOpts.borderRadius + 'px',
+ boxShadow: boxShadow.value,
+ visibility: 'visible',
+ // Transition related
+ transitionDuration: (props.skipTransition ? 0 : props.uiOpts.transitionDuration) + 'ms',
+ transitionProperty: 'left, top, width, height, visibility',
+ transitionTimingFunction: 'ease-out',
+ zIndex: inTransition.value && wasClicked.value ? '1' : '0',
+ overflow: (inTransition.value && !isLeaf.value && !hasExpanded.value && !justUnhidden.value) ?
+ 'hidden' : 'visible',
+ // CSS variables
+ '--nonleafBgColor': nonleafBgColor.value,
+ '--tileSpacing': props.lytOpts.tileSpacing + 'px',
+ };
+ if (!isLeaf.value){
+ let borderR = props.uiOpts.borderRadius + 'px';
+ if (props.layoutNode.sepSweptArea != null){
+ borderR = props.layoutNode.sepSweptArea.sweptLeft ?
+ `${borderR} ${borderR} ${borderR} 0` :
+ `${borderR} 0 ${borderR} ${borderR}`;
+ }
+ layoutStyles.borderRadius = borderR;
+ }
+ if (isOverflownRoot.value){
+ layoutStyles.width = (props.layoutNode.dims[0] + props.uiOpts.scrollGap) + 'px';
+ layoutStyles.height = props.overflownDim + 'px';
+ layoutStyles.overflow = 'hidden scroll';
+ }
+ if (props.layoutNode.hidden){
+ layoutStyles.left = layoutStyles.top = layoutStyles.width = layoutStyles.height = '0';
+ layoutStyles.visibility = 'hidden';
+ }
+ if (props.nonAbsPos){
+ layoutStyles.position = 'static';
+ }
+ return layoutStyles;
+});
+const leafStyles = computed((): Record<string,string> => {
+ let styles: Record<string,string> = {
+ borderRadius: 'inherit',
+ };
+ if (hasOneImage.value){
+ styles = {
+ ...styles,
+ backgroundImage: tolNode.value.imgName != null ?
+ `${SCRIM_GRADIENT},url('${getImagePath(tolNode.value.imgName as string)}')` :
+ 'none',
+ backgroundColor: props.uiOpts.bgColorDark,
+ backgroundSize: 'cover',
+ };
+ }
+ return styles;
+});
+const leafHeaderStyles = computed((): Record<string,string> => {
+ let numChildren = tolNode.value.children.length;
+ let textColor = props.uiOpts.textColor;
+ for (let [threshold, color] of props.uiOpts.childQtyColors){
+ if (numChildren >= threshold){
+ textColor = color;
+ } else {
+ break;
+ }
+ }
+ return {
+ lineHeight: (fontSz.value * 1.3) + 'px',
+ fontSize: fontSz.value + 'px',
+ paddingLeft: (fontSz.value * 0.2) + 'px',
+ color: textColor,
+ // For ellipsis
+ overflow: 'hidden',
+ textOverflow: 'ellipsis',
+ whiteSpace: 'nowrap',
+ };
+});
+function leafSubImgStyles(idx: number): Record<string,string> {
+ let [w, h] = props.layoutNode.dims;
+ return {
+ width: '100%',
+ height: '100%',
+ // Image (and scrims)
+ backgroundImage: (tolNode.value.imgName![idx]! != null) ?
+ `${SCRIM_GRADIENT},url('${getImagePath(tolNode.value.imgName![idx]! as string)}')` :
+ 'none',
+ backgroundColor: props.uiOpts.bgColorDark,
+ backgroundSize: '125%',
+ borderRadius: 'inherit',
+ clipPath: (idx == 0) ? 'polygon(0 0, 100% 0, 0 100%)' : 'polygon(100% 0, 0 100%, 100% 100%)',
+ backgroundPosition: (idx == 0) ? `${-w/4}px ${-h/4}px` : '0px 0px',
+ };
+}
+const leafFirstImgStyles = computed(() => leafSubImgStyles(0));
+const leafSecondImgStyles = computed(() => leafSubImgStyles(1));
+const nonleafStyles = computed((): Record<string,string> => {
+ let styles = {
+ width: '100%',
+ height: '100%',
+ backgroundColor: nonleafBgColor.value,
+ borderRadius: 'inherit',
+ };
+ if (isOverflownRoot.value){
+ styles.width = props.layoutNode.dims[0] + 'px';
+ styles.height = props.layoutNode.dims[1] + 'px';
+ }
+ return styles;
+});
+const nonleafHeaderStyles = computed((): Record<string,string> => {
+ let styles: Record<string,string> = {
+ position: 'static',
+ height: props.lytOpts.headerSz + 'px',
+ borderTopLeftRadius: 'inherit',
+ borderTopRightRadius: 'inherit',
+ backgroundColor: props.uiOpts.nonleafHeaderColor,
+ };
+ if (isOverflownRoot.value){
+ styles = {
+ ...styles,
+ position: 'sticky',
+ top: '0',
+ left: '0',
+ borderTopRightRadius: '0',
+ zIndex: '1',
+ boxShadow: props.uiOpts.shadowNormal,
+ };
+ }
+ return styles;
+});
+const nonleafHeaderTextStyles = computed(() => ({
+ lineHeight: (fontSz.value * 1.3) + 'px',
+ fontSize: fontSz.value + 'px',
+ paddingLeft: (fontSz.value * 0.2) + 'px',
+ textAlign: 'center',
+ color: props.uiOpts.textColor,
+ // For ellipsis
+ overflow: 'hidden',
+ textOverflow: 'ellipsis',
+ whiteSpace: 'nowrap',
+}));
+const sepSweptAreaStyles = computed((): Record<string,string> => {
+ let borderR = props.uiOpts.borderRadius + 'px';
+ let styles = {
+ position: 'absolute',
+ backgroundColor: nonleafBgColor.value,
+ boxShadow: boxShadow.value,
+ transitionDuration: props.uiOpts.transitionDuration + 'ms',
+ transitionProperty: 'left, top, width, height, visibility',
+ transitionTimingFunction: 'ease-out',
+ };
+ let area = props.layoutNode.sepSweptArea;
+ if (!props.layoutNode.hidden && area != null){
+ return {
+ ...styles,
+ visibility: 'visible',
+ left: area.pos[0] + 'px',
+ top: area.pos[1] + 'px',
+ width: area.dims[0] + 'px',
+ height: area.dims[1] + 'px',
+ borderRadius: area.sweptLeft ?
+ `${borderR} 0 0 ${borderR}` :
+ `${borderR} ${borderR} 0 0`,
+ };
+ } else {
return {
- // Mouse-event related
- clickHoldTimer: 0, // Used to recognise click-and-hold events
- highlight: false, // Used to draw a colored outline on mouse hover
- // Scroll-during-overflow related
- scrollOffset: 0, // Used to track scroll offset when displaying with overflow
- pendingScrollHdlr: 0, // Used for throttling updating of scrollOffset
- // Transition related
- inTransition: false, // Used to avoid content overlap and overflow during 'user-perceivable' transitions
- wasClicked: false, // Used to increase z-index during transition after this tile (or a child) is clicked
- hasExpanded: false, // Set to true after an expansion transition ends, and false upon collapse
- // Used to hide overflow on tile expansion, but not hide a sepSweptArea on subsequent transitions
- justUnhidden: false, // Used to allow overflow temporarily after being unhidden
- // Other
- inFlash: false, // Used to 'flash' the tile when focused
+ ...styles,
+ visibility: 'hidden',
+ left: '0',
+ top: props.lytOpts.headerSz + 'px',
+ width: '0',
+ height: '0',
+ borderRadius: borderR,
};
- },
- computed: {
- tolNode(): TolNode {
- return this.tolMap.get(this.layoutNode.name)!;
- },
- visibleChildren(): LayoutNode[] { // Used to reduce slowdown from rendering many nodes
- let children = this.layoutNode.children;
- // If not displaying with overflow, return 'visible' layoutNode children
- if (!this.isOverflownRoot){
- return children.filter(n => !n.hidden || n.hiddenWithVisibleTip);
- }
- // Otherwise, return children within/near non-overflowing region
- let firstIdx = children.length - 1;
- for (let i = 0; i < children.length; i++){
- if (children[i].pos[1] + children[i].dims[1] >= this.scrollOffset){
- firstIdx = i;
- break;
- }
- }
- let lastIdx = children.length;
- for (let i = firstIdx + 1; i < children.length; i++){
- if (children[i].pos[1] > this.scrollOffset + this.overflownDim){
- lastIdx = i;
- break;
- }
- }
- return children.slice(firstIdx, lastIdx);
- },
- // Convenience abbreviations
- isLeaf(): boolean {
- return this.layoutNode.children.length == 0;
- },
- isExpandableLeaf(): boolean {
- return this.isLeaf && this.tolNode.children.length > 0;
- },
- showNonleafHeader(): boolean {
- return (this.layoutNode.showHeader && this.layoutNode.sepSweptArea == null) ||
- (this.layoutNode.sepSweptArea != null && this.layoutNode.sepSweptArea.sweptLeft);
- },
- displayName(): string {
- let newName = capitalizeWords(this.tolNode.commonName || this.layoutNode.name);
- if (!this.tolNode.pSupport && this.tolNode.parent != null){
- newName += '*';
- }
- return newName;
- },
- hasOneImage(): boolean {
- return !Array.isArray(this.tolNode.imgName);
- },
- isOverflownRoot(): boolean {
- return this.overflownDim > 0 && !this.layoutNode.hidden && this.layoutNode.children.length > 0;
- },
- hasFocusedChild(): boolean {
- return this.layoutNode.children.some(n => n.hasFocus);
- },
- infoIconDisabled(): boolean {
- return !this.uiOpts.disabledActions.has('tileInfo');
- },
- // For styling
- nonleafBgColor(): string {
- let colorArray = this.uiOpts.nonleafBgColors;
- return colorArray[this.layoutNode.depth % colorArray.length];
- },
- boxShadow(): string {
- if (this.highlight){
- return this.uiOpts.shadowHovered;
- } else if (this.layoutNode.hasFocus && !this.inTransition){
- return this.uiOpts.shadowFocused;
- } else {
- return this.uiOpts.shadowNormal;
- }
- },
- fontSz(): number {
- // These values are a compromise between dynamic font size and code simplicity
- if (this.layoutNode.dims[0] >= 150){
- return this.lytOpts.headerSz * 0.8;
- } else if (this.layoutNode.dims[0] >= 80){
- return this.lytOpts.headerSz * 0.7;
- } else {
- return this.lytOpts.headerSz * 0.6;
- }
- },
- styles(): Record<string,string> {
- let layoutStyles = {
- position: 'absolute',
- left: this.layoutNode.pos[0] + 'px',
- top: this.layoutNode.pos[1] + 'px',
- width: this.layoutNode.dims[0] + 'px',
- height: this.layoutNode.dims[1] + 'px',
- borderRadius: this.uiOpts.borderRadius + 'px',
- boxShadow: this.boxShadow,
- visibility: 'visible',
- // Transition related
- transitionDuration: (this.skipTransition ? 0 : this.uiOpts.transitionDuration) + 'ms',
- transitionProperty: 'left, top, width, height, visibility',
- transitionTimingFunction: 'ease-out',
- zIndex: this.inTransition && this.wasClicked ? '1' : '0',
- overflow: (this.inTransition && !this.isLeaf && !this.hasExpanded && !this.justUnhidden) ?
- 'hidden' : 'visible',
- // CSS variables
- '--nonleafBgColor': this.nonleafBgColor,
- '--tileSpacing': this.lytOpts.tileSpacing + 'px',
- };
- if (!this.isLeaf){
- let borderR = this.uiOpts.borderRadius + 'px';
- if (this.layoutNode.sepSweptArea != null){
- borderR = this.layoutNode.sepSweptArea.sweptLeft ?
- `${borderR} ${borderR} ${borderR} 0` :
- `${borderR} 0 ${borderR} ${borderR}`;
- }
- layoutStyles.borderRadius = borderR;
- }
- if (this.isOverflownRoot){
- layoutStyles.width = (this.layoutNode.dims[0] + this.uiOpts.scrollGap) + 'px';
- layoutStyles.height = this.overflownDim + 'px';
- layoutStyles.overflow = 'hidden scroll';
- }
- if (this.layoutNode.hidden){
- layoutStyles.left = layoutStyles.top = layoutStyles.width = layoutStyles.height = '0';
- layoutStyles.visibility = 'hidden';
- }
- if (this.nonAbsPos){
- layoutStyles.position = 'static';
- }
- return layoutStyles;
- },
- leafStyles(): Record<string,string> {
- let styles: Record<string,string> = {
- borderRadius: 'inherit',
- };
- if (this.hasOneImage){
- styles = {
- ...styles,
- backgroundImage: this.tolNode.imgName != null ?
- `${scrimGradient},url('${getImagePath(this.tolNode.imgName as string)}')` :
- 'none',
- backgroundColor: this.uiOpts.bgColorDark,
- backgroundSize: 'cover',
- };
- }
- return styles;
- },
- leafHeaderStyles(): Record<string,string> {
- let numChildren = this.tolNode.children.length;
- let textColor = this.uiOpts.textColor;
- for (let [threshold, color] of this.uiOpts.childQtyColors){
- if (numChildren >= threshold){
- textColor = color;
- } else {
- break;
- }
- }
- return {
- lineHeight: (this.fontSz * 1.3) + 'px',
- fontSize: this.fontSz + 'px',
- paddingLeft: (this.fontSz * 0.2) + 'px',
- color: textColor,
- // For ellipsis
- overflow: 'hidden',
- textOverflow: 'ellipsis',
- whiteSpace: 'nowrap',
- };
- },
- leafFirstImgStyles(): Record<string,string> {
- return this.leafSubImgStyles(0);
- },
- leafSecondImgStyles(): Record<string,string> {
- return this.leafSubImgStyles(1);
- },
- nonleafStyles(): Record<string,string> {
- let styles = {
- width: '100%',
- height: '100%',
- backgroundColor: this.nonleafBgColor,
- borderRadius: 'inherit',
- };
- if (this.isOverflownRoot){
- styles.width = this.layoutNode.dims[0] + 'px';
- styles.height = this.layoutNode.dims[1] + 'px';
- }
- return styles;
- },
- nonleafHeaderStyles(): Record<string,string> {
- let styles: Record<string,string> = {
- position: 'static',
- height: this.lytOpts.headerSz + 'px',
- borderTopLeftRadius: 'inherit',
- borderTopRightRadius: 'inherit',
- backgroundColor: this.uiOpts.nonleafHeaderColor,
- };
- if (this.isOverflownRoot){
- styles = {
- ...styles,
- position: 'sticky',
- top: '0',
- left: '0',
- borderTopRightRadius: '0',
- zIndex: '1',
- boxShadow: this.uiOpts.shadowNormal,
- };
- }
- return styles;
- },
- nonleafHeaderTextStyles(): Record<string,string> {
- return {
- lineHeight: (this.fontSz * 1.3) + 'px',
- fontSize: this.fontSz + 'px',
- paddingLeft: (this.fontSz * 0.2) + 'px',
- textAlign: 'center',
- color: this.uiOpts.textColor,
- // For ellipsis
- overflow: 'hidden',
- textOverflow: 'ellipsis',
- whiteSpace: 'nowrap',
- };
- },
- sepSweptAreaStyles(): Record<string,string> {
- let borderR = this.uiOpts.borderRadius + 'px';
- let styles = {
- position: 'absolute',
- backgroundColor: this.nonleafBgColor,
- boxShadow: this.boxShadow,
- transitionDuration: this.uiOpts.transitionDuration + 'ms',
- transitionProperty: 'left, top, width, height, visibility',
- transitionTimingFunction: 'ease-out',
- };
- let area = this.layoutNode.sepSweptArea;
- if (!this.layoutNode.hidden && area != null){
- return {
- ...styles,
- visibility: 'visible',
- left: area.pos[0] + 'px',
- top: area.pos[1] + 'px',
- width: area.dims[0] + 'px',
- height: area.dims[1] + 'px',
- borderRadius: area.sweptLeft ?
- `${borderR} 0 0 ${borderR}` :
- `${borderR} ${borderR} 0 0`,
- };
- } else {
- return {
- ...styles,
- visibility: 'hidden',
- left: '0',
- top: this.lytOpts.headerSz + 'px',
- width: '0',
- height: '0',
- borderRadius: borderR,
- };
- }
- },
- sepSweptAreaHideEdgeClass(): string {
- if (this.layoutNode.sepSweptArea == null){
- return '';
- } else if (this.layoutNode.sepSweptArea.sweptLeft){
- return 'hide-right-edge';
- } else {
- return 'hide-top-edge';
- }
- },
- infoIconStyles(): Record<string,string> {
- let size = (this.lytOpts.headerSz * 0.85);
- let marginSz = (this.lytOpts.headerSz - size);
- return {
- width: size + 'px',
- height: size + 'px',
- minWidth: size + 'px',
- minHeight: size + 'px',
- margin: this.isLeaf ? `auto ${marginSz}px ${marginSz}px auto` : `auto ${marginSz}px auto 0`,
- };
- },
- infoIconClasses(): string {
- return 'text-white/30 hover:text-white hover:cursor-pointer';
- },
- // For watching layoutNode data
- pos(){
- return this.layoutNode.pos;
- },
- dims(){
- return this.layoutNode.dims;
- },
- hidden(){
- return this.layoutNode.hidden;
- },
- hasFocus(){
- return this.layoutNode.hasFocus;
- },
- failFlag(){
- return this.layoutNode.failFlag;
- },
- },
- methods: {
- // Click handling
- onMouseDown(): void {
- this.highlight = false;
- if (!this.uiOpts.touchDevice){
- // Wait for a mouseup or click-hold
- clearTimeout(this.clickHoldTimer);
- this.clickHoldTimer = setTimeout(() => {
- this.clickHoldTimer = 0;
- this.onClickHold();
- }, this.uiOpts.clickHoldDuration);
- } else {
- // Wait for or recognise a double-click
- if (this.clickHoldTimer == 0){
- this.clickHoldTimer = setTimeout(() => {
- this.clickHoldTimer = 0;
- this.onClick();
- }, this.uiOpts.clickHoldDuration);
- } else {
- clearTimeout(this.clickHoldTimer)
- this.clickHoldTimer = 0;
- this.onDblClick();
- }
- }
- },
- onMouseUp(): void {
- if (!this.uiOpts.touchDevice){
- if (this.clickHoldTimer > 0){
- clearTimeout(this.clickHoldTimer);
- this.clickHoldTimer = 0;
- this.onClick();
- }
- }
- },
- onClick(): void {
- if (this.isLeaf && !this.isExpandableLeaf){
- console.log('Ignored click on non-expandable node');
- return;
- }
- this.wasClicked = true;
- this.$emit(this.isLeaf ? 'leaf-click' : 'nonleaf-click', this.layoutNode);
- },
- onClickHold(): void {
- if (this.isLeaf && !this.isExpandableLeaf){
- console.log('Ignored click-hold on non-expandable node');
- return;
- }
- this.$emit(this.isLeaf ? 'leaf-click-held' : 'nonleaf-click-held', this.layoutNode);
- },
- onDblClick(): void {
- this.onClickHold();
- },
- onInfoIconClick(evt: Event): void {
- this.$emit('info-click', this.layoutNode.name);
- },
- // Mouse-hover handling
- onMouseEnter(evt: Event): void {
- if ((!this.isLeaf || this.isExpandableLeaf) && !this.inTransition){
- this.highlight = true;
- }
- },
- onMouseLeave(evt: Event): void {
- this.highlight = false;
- },
- // Child event propagation
- onInnerLeafClick(node: LayoutNode): void {
- this.wasClicked = true;
- this.$emit('leaf-click', node);
- },
- onInnerNonleafClick(node: LayoutNode): void {
- this.wasClicked = true;
- this.$emit('nonleaf-click', node);
- },
- onInnerLeafClickHeld(node: LayoutNode): void {
- this.$emit('leaf-click-held', node);
- },
- onInnerNonleafClickHeld(node: LayoutNode): void {
- this.$emit('nonleaf-click-held', node);
- },
- onInnerInfoIconClick(nodeName: string): void {
- this.$emit('info-click', nodeName);
- },
- onScroll(evt: Event): void {
- if (this.pendingScrollHdlr == 0){
- this.pendingScrollHdlr = setTimeout(() => {
- this.scrollOffset = this.$el.scrollTop;
- this.pendingScrollHdlr = 0;
- }, this.uiOpts.animationDelay);
- }
- },
- // Other
- leafSubImgStyles(idx: number): Record<string,string> {
- let [w, h] = this.layoutNode.dims;
- return {
- width: '100%',
- height: '100%',
- // Image (and scrims)
- backgroundImage: (this.tolNode.imgName![idx]! != null) ?
- `${scrimGradient},url('${getImagePath(this.tolNode.imgName![idx]! as string)}')` :
- 'none',
- backgroundColor: this.uiOpts.bgColorDark,
- backgroundSize: '125%',
- borderRadius: 'inherit',
- clipPath: idx == 0 ? 'polygon(0 0, 100% 0, 0 100%)' : 'polygon(100% 0, 0 100%, 100% 100%)',
- backgroundPosition: idx == 0 ? `${-w/4}px ${-h/4}px` : '0px 0px',
- };
- },
- onTransitionEnd(evt: Event){
- if (this.inTransition){
- this.inTransition = false;
- this.wasClicked = false;
- this.hasExpanded = this.layoutNode.children.length > 0;
- }
- },
- triggerAnimation(animation: string){
- this.$el.classList.remove(animation);
- this.$el.offsetWidth; // Triggers reflow
- this.$el.classList.add(animation);
- },
- },
- watch: {
- // For setting transition state (allows external triggering, like via search and auto-mode)
- pos: {
- handler(newVal: [number, number], oldVal: [number, number]){
- let valChanged = newVal[0] != oldVal[0] || newVal[1] != oldVal[1];
- if (valChanged && this.uiOpts.transitionDuration > 100 && !this.inTransition){
- this.inTransition = true;
- setTimeout(this.onTransitionEnd, this.uiOpts.transitionDuration);
- }
- },
- deep: true,
- },
- dims: {
- handler(newVal: [number, number], oldVal: [number, number]){
- let valChanged = newVal[0] != oldVal[0] || newVal[1] != oldVal[1];
- if (valChanged && this.uiOpts.transitionDuration > 100 && !this.inTransition){
- this.inTransition = true;
- setTimeout(this.onTransitionEnd, this.uiOpts.transitionDuration);
- }
- },
- deep: true,
- },
- // For externally triggering fail animations (used by search and auto-mode)
- failFlag(){
- this.triggerAnimation(this.isLeaf ? 'animate-expand-shrink' : 'animate-shrink-expand');
- },
- // Scroll to focused child if overflownRoot
- hasFocusedChild(newVal: boolean, oldVal: boolean){
- if (newVal && this.isOverflownRoot){
- let focusedChild = this.layoutNode.children.find(n => n.hasFocus)!
- let bottomY = focusedChild.pos[1] + focusedChild.dims[1] + this.lytOpts.tileSpacing;
- let scrollTop = Math.max(0, bottomY - (this.overflownDim / 2)); // No need to manually cap at max
- this.$el.scrollTop = scrollTop;
- }
- },
- // Allow overflow temporarily after being unhidden
- hidden(newVal: boolean, oldVal: boolean){
- if (oldVal && !newVal){
- this.justUnhidden = true;
- setTimeout(() => {this.justUnhidden = false;}, this.uiOpts.transitionDuration + 100);
- }
- },
- // Used to 'flash' the tile when focused
- hasFocus(newVal: boolean, oldVal: boolean){
- if (newVal != oldVal && newVal){
- this.inFlash = true;
- setTimeout(() => {this.inFlash = false;}, this.uiOpts.transitionDuration);
- }
- },
- },
- name: 'tol-tile', // Note: Need this to use self in template
- components: {InfoIcon, },
- emits: ['leaf-click', 'nonleaf-click', 'leaf-click-held', 'nonleaf-click-held', 'info-click', ],
+ }
+});
+const sepSweptAreaHideEdgeClass = computed((): string => {
+ if (props.layoutNode.sepSweptArea == null){
+ return '';
+ } else if (props.layoutNode.sepSweptArea.sweptLeft){
+ return 'hide-right-edge';
+ } else {
+ return 'hide-top-edge';
+ }
+});
+const infoIconStyles = computed((): Record<string,string> => {
+ let size = (props.lytOpts.headerSz * 0.85);
+ let marginSz = (props.lytOpts.headerSz - size);
+ return {
+ width: size + 'px',
+ height: size + 'px',
+ minWidth: size + 'px',
+ minHeight: size + 'px',
+ margin: isLeaf.value ? `auto ${marginSz}px ${marginSz}px auto` : `auto ${marginSz}px auto 0`,
+ };
});
+const infoIconClasses = 'text-white/30 hover:text-white hover:cursor-pointer';
</script>
<style scoped>
diff --git a/src/components/TutorialPane.vue b/src/components/TutorialPane.vue
index 2803498..4c24bae 100644
--- a/src/components/TutorialPane.vue
+++ b/src/components/TutorialPane.vue
@@ -2,7 +2,7 @@
<div :style="styles" class="relative flex flex-col justify-between">
<close-icon @click.stop="onClose" class="absolute top-2 right-2 w-8 h-8 hover:cursor-pointer"/>
<h1 class="text-center text-lg font-bold pt-3 pb-2">
- {{stage == 0 ? 'Welcome' : `Tutorial (Step ${stage} of ${lastStage})`}}
+ {{stage == 0 ? 'Welcome' : `Tutorial (Step ${stage} of ${LAST_STAGE})`}}
</h1>
<transition name="fade" mode="out-in">
<div v-if="stage == 0" :style="contentStyles">
@@ -58,119 +58,106 @@
Prev
</s-button>
<s-button :class="{invisible: !hidNextPrevOnce && stage == 1}"
- @click="stage != lastStage ? onNextClick() : onClose()" :style="buttonStyles">
- {{stage != lastStage ? 'Next' : 'Finish'}}
+ @click="stage != LAST_STAGE ? onNextClick() : onClose()" :style="buttonStyles">
+ {{stage != LAST_STAGE ? 'Next' : 'Finish'}}
</s-button>
</template>
</div>
</div>
</template>
-<script lang="ts">
-import {defineComponent, PropType} from 'vue';
+<script setup lang="ts">
+import {ref, computed, watch, onMounted, PropType} from 'vue';
import SButton from './SButton.vue';
import CloseIcon from './icon/CloseIcon.vue';
import {Action, UiOptions} from '../lib';
-export default defineComponent({
- props: {
- actionsDone: {type: Object as PropType<Set<Action>>, required: true},
- // Used to avoid disabling actions already done
- triggerFlag: {type: Boolean, required: true},
- // Used to indicate that a tutorial-requested 'trigger' action has been done
- skipWelcome: {type: Boolean, default: false},
- uiOpts: {type: Object as PropType<UiOptions>, required: true},
- },
- data(){
- return {
- stage: 0, // Indicates the current step of the tutorial (stage 0 is the welcome message)
- lastStage: 9,
- disabledOnce: false, // Set to true after disabling features at stage 1
- stageActions: [
- // Specifies, for stages 1+, what action to enable (can repeat an action to enable nothing new)
- 'expand', 'collapse', 'expandToView', 'unhideAncestor',
- 'tileInfo', 'search', 'autoMode', 'settings', 'help',
- ] as Action[],
- hidNextPrevOnce: false, // Used to hide prev/next buttons when initially at stage 1
- };
- },
- computed: {
- styles(): Record<string,string> {
- return {
- backgroundColor: this.uiOpts.bgColorDark,
- color: this.uiOpts.textColor,
- };
- },
- contentStyles(): Record<string,string> {
- return {
- padding: '0 0.5cm',
- overflow: 'auto',
- textAlign: 'center',
- };
- },
- buttonStyles(): Record<string,string> {
- return {
- color: this.uiOpts.textColor,
- backgroundColor: this.uiOpts.bgColor,
- };
- },
- touchDevice(): boolean {
- return this.uiOpts.touchDevice;
- },
- },
- methods: {
- onStartTutorial(){
- this.stage = 1;
- },
- onSkipTutorial(){
- this.$emit('skip');
- this.$emit('close');
- },
- onPrevClick(){
- this.stage = Math.max(1, this.stage - 1);
- },
- onNextClick(){
- this.stage = Math.min(this.stage + 1, this.lastStage);
- },
- onClose(){
- this.$emit('close');
- },
- },
- watch: {
- stage(newVal, oldVal){
- // If starting tutorial, disable 'all' actions
- if (newVal == 1 && !this.disabledOnce){
- for (let action of this.stageActions){
- if (action != null && !this.actionsDone.has(action)){
- this.uiOpts.disabledActions.add(action);
- }
- }
- this.disabledOnce = true;
- }
- // Enable action for this stage
- this.uiOpts.disabledActions.delete(this.stageActions[this.stage - 1]);
- // Notify of new trigger-action
- this.$emit('stage-chg', this.stageActions[this.stage - 1]);
- // After stage 1, show prev/next buttons
- if (newVal == 2){
- this.hidNextPrevOnce = true;
- }
- },
- // Called when a trigger-action is done, and advances to the next stage
- triggerFlag(){
- if (this.stage < this.lastStage){
- this.onNextClick();
- } else {
- this.onClose();
+// Props + events
+const props = defineProps({
+ actionsDone: {type: Object as PropType<Set<Action>>, required: true},
+ // Used to avoid disabling actions already done
+ triggerFlag: {type: Boolean, required: true},
+ // Used to indicate that a tutorial-requested 'trigger' action has been done
+ skipWelcome: {type: Boolean, default: false},
+ uiOpts: {type: Object as PropType<UiOptions>, required: true},
+});
+const touchDevice = computed(() => props.uiOpts.touchDevice);
+const emit = defineEmits(['close', 'stage-chg', 'skip']);
+
+// For tutorial stage
+const stage = ref(props.skipWelcome ? 1 : 0);
+ // Indicates the current step of the tutorial (stage 0 is the welcome message)
+const LAST_STAGE = 9;
+const STAGE_ACTIONS = [
+ // Specifies, for stages 1+, what action to enable (can repeat an action to enable nothing new)
+ 'expand', 'collapse', 'expandToView', 'unhideAncestor',
+ 'tileInfo', 'search', 'autoMode', 'settings', 'help',
+] as Action[];
+let disabledOnce = false; // Set to true after disabling features at stage 1
+const hidNextPrevOnce = ref(false); // Used to hide prev/next buttons when initially at stage 1
+
+// For stage changes
+function onStartTutorial(){
+ stage.value = 1;
+}
+function onSkipTutorial(){
+ emit('skip');
+ emit('close');
+}
+function onPrevClick(){
+ stage.value = Math.max(1, stage.value - 1);
+}
+function onNextClick(){
+ stage.value = Math.min(stage.value + 1, LAST_STAGE);
+}
+function onClose(){
+ emit('close');
+}
+function onStageChange(){
+ // If starting tutorial, disable 'all' actions
+ if (stage.value == 1 && !disabledOnce){
+ for (let action of STAGE_ACTIONS){
+ if (action != null && !props.actionsDone.has(action)){
+ props.uiOpts.disabledActions.add(action);
}
- },
- },
- created(){
- if (this.skipWelcome){
- this.stage += 1;
}
- },
- components: {CloseIcon, SButton, },
- emits: ['close', 'stage-chg', 'skip', ],
+ disabledOnce = true;
+ }
+ // Enable action for this stage
+ props.uiOpts.disabledActions.delete(STAGE_ACTIONS[stage.value - 1]);
+ // Notify of new trigger-action
+ emit('stage-chg', STAGE_ACTIONS[stage.value - 1]);
+ // After stage 1, show prev/next buttons
+ if (stage.value == 2){
+ hidNextPrevOnce.value = true;
+ }
+}
+onMounted(() => {
+ if (props.skipWelcome){
+ onStageChange();
+ }
+})
+watch(stage, onStageChange);
+watch(() => props.triggerFlag, () => {
+ if (stage.value < LAST_STAGE){
+ onNextClick();
+ } else {
+ onClose();
+ }
});
+
+// Styles
+const styles = computed(() => ({
+ backgroundColor: props.uiOpts.bgColorDark,
+ color: props.uiOpts.textColor,
+}));
+const contentStyles = {
+ padding: '0 0.5cm',
+ overflow: 'auto',
+ textAlign: 'center',
+};
+const buttonStyles = computed(() => ({
+ color: props.uiOpts.textColor,
+ backgroundColor: props.uiOpts.bgColor,
+}));
</script>
diff --git a/src/components/icon/CloseIcon.vue b/src/components/icon/CloseIcon.vue
index 7dceef9..a62b08a 100644
--- a/src/components/icon/CloseIcon.vue
+++ b/src/components/icon/CloseIcon.vue
@@ -5,8 +5,3 @@
<line x1="6" y1="6" x2="18" y2="18"/>
</svg>
</template>
-
-<script lang="ts">
-import {defineComponent, PropType} from 'vue';
-export default defineComponent({});
-</script>
diff --git a/src/components/icon/DownIcon.vue b/src/components/icon/DownIcon.vue
index dc954d1..f7a5835 100644
--- a/src/components/icon/DownIcon.vue
+++ b/src/components/icon/DownIcon.vue
@@ -4,8 +4,3 @@
<polyline points="6 9 12 15 18 9"></polyline>
</svg>
</template>
-
-<script lang="ts">
-import {defineComponent, PropType} from 'vue';
-export default defineComponent({});
-</script>
diff --git a/src/components/icon/EduIcon.vue b/src/components/icon/EduIcon.vue
index e46f2a6..a7d0405 100644
--- a/src/components/icon/EduIcon.vue
+++ b/src/components/icon/EduIcon.vue
@@ -6,8 +6,3 @@
d="M112 240v128l144 80 144-80V240M480 368V192M256 320v128"/>
</svg>
</template>
-
-<script lang="ts">
-import {defineComponent, PropType} from 'vue';
-export default defineComponent({});
-</script>
diff --git a/src/components/icon/ExternalLinkIcon.vue b/src/components/icon/ExternalLinkIcon.vue
index b7cb286..f672f3a 100644
--- a/src/components/icon/ExternalLinkIcon.vue
+++ b/src/components/icon/ExternalLinkIcon.vue
@@ -6,8 +6,3 @@
<line x1="10" y1="14" x2="21" y2="3"></line>
</svg>
</template>
-
-<script lang="ts">
-import {defineComponent, PropType} from 'vue';
-export default defineComponent({});
-</script>
diff --git a/src/components/icon/HelpIcon.vue b/src/components/icon/HelpIcon.vue
index 8486686..a61553a 100644
--- a/src/components/icon/HelpIcon.vue
+++ b/src/components/icon/HelpIcon.vue
@@ -5,8 +5,3 @@
<circle cx="248" cy="430" r="32" fill="currentColor"/>
</svg>
</template>
-
-<script lang="ts">
-import {defineComponent, PropType} from 'vue';
-export default defineComponent({});
-</script>
diff --git a/src/components/icon/InfoIcon.vue b/src/components/icon/InfoIcon.vue
index 0f390cf..47a14cd 100644
--- a/src/components/icon/InfoIcon.vue
+++ b/src/components/icon/InfoIcon.vue
@@ -6,8 +6,3 @@
<line x1="12" y1="8" x2="12.01" y2="8"/>
</svg>
</template>
-
-<script lang="ts">
-import {defineComponent, PropType} from 'vue';
-export default defineComponent({});
-</script>
diff --git a/src/components/icon/LinkIcon.vue b/src/components/icon/LinkIcon.vue
index 49996b7..2e43324 100644
--- a/src/components/icon/LinkIcon.vue
+++ b/src/components/icon/LinkIcon.vue
@@ -5,8 +5,3 @@
<path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"></path>
</svg>
</template>
-
-<script lang="ts">
-import {defineComponent, PropType} from 'vue';
-export default defineComponent({});
-</script>
diff --git a/src/components/icon/LoaderIcon.vue b/src/components/icon/LoaderIcon.vue
index cd5093b..c2a0369 100644
--- a/src/components/icon/LoaderIcon.vue
+++ b/src/components/icon/LoaderIcon.vue
@@ -11,8 +11,3 @@
<line x1="16.24" y1="7.76" x2="19.07" y2="4.93"></line>
</svg>
</template>
-
-<script lang="ts">
-import {defineComponent, PropType} from 'vue';
-export default defineComponent({});
-</script>
diff --git a/src/components/icon/LogInIcon.vue b/src/components/icon/LogInIcon.vue
index b91550b..97ab871 100644
--- a/src/components/icon/LogInIcon.vue
+++ b/src/components/icon/LogInIcon.vue
@@ -6,8 +6,3 @@
<line x1="15" y1="12" x2="3" y2="12"></line>
</svg>
</template>
-
-<script lang="ts">
-import {defineComponent, PropType} from 'vue';
-export default defineComponent({});
-</script>
diff --git a/src/components/icon/PauseIcon.vue b/src/components/icon/PauseIcon.vue
index dc768a2..be13f5f 100644
--- a/src/components/icon/PauseIcon.vue
+++ b/src/components/icon/PauseIcon.vue
@@ -5,8 +5,3 @@
<rect x="14" y="4" width="4" height="16"></rect>
</svg>
</template>
-
-<script lang="ts">
-import {defineComponent, PropType} from 'vue';
-export default defineComponent({});
-</script>
diff --git a/src/components/icon/PlayIcon.vue b/src/components/icon/PlayIcon.vue
index 7e5d823..4295328 100644
--- a/src/components/icon/PlayIcon.vue
+++ b/src/components/icon/PlayIcon.vue
@@ -4,8 +4,3 @@
<polygon points="5 3 19 12 5 21 5 3"></polygon>
</svg>
</template>
-
-<script lang="ts">
-import {defineComponent, PropType} from 'vue';
-export default defineComponent({});
-</script>
diff --git a/src/components/icon/SearchIcon.vue b/src/components/icon/SearchIcon.vue
index 40b8226..4a25aea 100644
--- a/src/components/icon/SearchIcon.vue
+++ b/src/components/icon/SearchIcon.vue
@@ -5,8 +5,3 @@
<line x1="21" y1="21" x2="16.65" y2="16.65"/>
</svg>
</template>
-
-<script lang="ts">
-import {defineComponent, PropType} from 'vue';
-export default defineComponent({});
-</script>
diff --git a/src/components/icon/SettingsIcon.vue b/src/components/icon/SettingsIcon.vue
index bea2a3f..e8738a9 100644
--- a/src/components/icon/SettingsIcon.vue
+++ b/src/components/icon/SettingsIcon.vue
@@ -15,8 +15,3 @@
2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"/>
</svg>
</template>
-
-<script lang="ts">
-import {defineComponent, PropType} from 'vue';
-export default defineComponent({});
-</script>