diff options
| author | Terry Truong <terry06890@gmail.com> | 2022-09-13 19:59:06 +1000 |
|---|---|---|
| committer | Terry Truong <terry06890@gmail.com> | 2022-09-13 20:00:17 +1000 |
| commit | 23b5cc80ba02936659564dd03b173d3214ce5978 (patch) | |
| tree | cdf6a183d1a0bfcb45a924585b764c723dd67b55 /src/components | |
| parent | e382d4173c990a49a9ef3db1b3681763a3e2e908 (diff) | |
Use Vue Composition API and ESLint
Diffstat (limited to 'src/components')
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> |
