diff options
| author | Terry Truong <terry06890@gmail.com> | 2022-09-14 19:17:41 +1000 |
|---|---|---|
| committer | Terry Truong <terry06890@gmail.com> | 2022-09-14 20:29:01 +1000 |
| commit | 8b5538e0a55a83b1ff190cd5ad689827777e73a7 (patch) | |
| tree | 33ccb2eadbe9a53dc2a870d57ba5efe758592390 /src/components | |
| parent | 556d6c953e74996e0ae6a8328e352ab43735f993 (diff) | |
Use Pinia to store user settings, palette colors, etc
Move uiOpts and lytOpts to store.ts
Add 'const's to *.ts
Diffstat (limited to 'src/components')
| -rw-r--r-- | src/components/AncestryBar.vue | 34 | ||||
| -rw-r--r-- | src/components/HelpModal.vue | 29 | ||||
| -rw-r--r-- | src/components/LoadingModal.vue | 17 | ||||
| -rw-r--r-- | src/components/SButton.vue | 2 | ||||
| -rw-r--r-- | src/components/SearchModal.vue | 40 | ||||
| -rw-r--r-- | src/components/SettingsModal.vue | 192 | ||||
| -rw-r--r-- | src/components/TileInfoModal.vue | 43 | ||||
| -rw-r--r-- | src/components/TolTile.vue | 89 | ||||
| -rw-r--r-- | src/components/TutorialPane.vue | 21 |
9 files changed, 222 insertions, 245 deletions
diff --git a/src/components/AncestryBar.vue b/src/components/AncestryBar.vue index 1b4ee81..8eabf22 100644 --- a/src/components/AncestryBar.vue +++ b/src/components/AncestryBar.vue @@ -1,7 +1,7 @@ <template> <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" + :layoutNode="node" :tolMap="tolMap" :nonAbsPos="true" @leaf-click="onTileClick(nodes[idx])" @info-click="onInfoIconClick"/> </div> </template> @@ -10,27 +10,27 @@ 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'; +import {LayoutNode} from '../layout'; +import {useStore} from '../store'; // Refs const rootRef = ref(null as HTMLDivElement | null); +// Global store +const store = useStore(); + // 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 + props.breadth - store.lytOpts.tileSpacing - store.scrollGap // Intentionally omitting extra tileSpacing, to allow for scrollGap with less image shrinkage ); const dummyNodes = computed(() => props.nodes.map(n => { @@ -54,11 +54,13 @@ function onWheelEvt(evt: WheelEvent){ // For converting vertical scrolling to ho } } function scrollToEnd(){ - let el = rootRef.value!; - if (props.vert){ - el.scrollTop = el.scrollHeight; - } else { - el.scrollLeft = el.scrollWidth; + let el = rootRef.value; + if (el != null){ + if (props.vert){ + el.scrollTop = el.scrollHeight; + } else { + el.scrollLeft = el.scrollWidth; + } } } watch(props.nodes, () => { @@ -75,12 +77,12 @@ const styles = computed(() => ({ display: 'flex', flexDirection: props.vert ? 'column' : 'row', alignItems: 'center', - gap: props.lytOpts.tileSpacing + 'px', - padding: props.lytOpts.tileSpacing + 'px', + gap: store.lytOpts.tileSpacing + 'px', + padding: store.lytOpts.tileSpacing + 'px', overflowX: props.vert ? 'hidden' : 'auto', overflowY: props.vert ? 'auto' : 'hidden', // Other - backgroundColor: props.uiOpts.ancestryBarBgColor, - boxShadow: props.uiOpts.shadowNormal, + backgroundColor: store.ancestryBarBgColor, + boxShadow: store.shadowNormal, })); </script> diff --git a/src/components/HelpModal.vue b/src/components/HelpModal.vue index c403e53..d2576d5 100644 --- a/src/components/HelpModal.vue +++ b/src/components/HelpModal.vue @@ -385,13 +385,6 @@ two species of grass being described like a third closely-related species. </p> <br/> - <h1 class="text-lg font-bold">Why did searching for 'goat' send me to the moths?</h1> - <p> - When you search for a name, then press enter, the first result is used. - Currently, search suggestions are not ordered by well-known the taxons are, - so the first result might mean 'Goat Moth' instead of 'Domestic Goat'. - </p> - <br/> <h1 class="text-lg font-bold">Why do a lot of fish have their heads clipped out?</h1> <p> Cropping images into squares was done semi-automatically, and sometimes this @@ -418,7 +411,7 @@ </template> </s-collapsible> </div> - <s-button class="mx-auto mb-2" :style="{color: uiOpts.textColor, backgroundColor: uiOpts.bgColor}" + <s-button class="mx-auto mb-2" :style="{color: store.color.text, backgroundColor: store.color.bg}" :disabled="tutOpen" @click.stop="onStartTutorial"> Start Tutorial </s-button> @@ -427,23 +420,25 @@ </template> <script setup lang="ts"> -import {ref, computed, PropType} from 'vue'; +import {ref, computed} 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 {UiOptions} from '../lib'; +import {useStore} from '../store'; // Refs const rootRef = ref(null as HTMLDivElement | null) const closeRef = ref(null as typeof CloseIcon | null); +// Global store +const store = useStore(); + // Props + events -const props = defineProps({ +defineProps({ tutOpen: {type: Boolean, default: false}, - uiOpts: {type: Object as PropType<UiOptions>, required: true}, }); -const touchDevice = computed(() => props.uiOpts.touchDevice) +const touchDevice = computed(() => store.touchDevice) const emit = defineEmits(['close', 'start-tutorial']); // Event handlers @@ -459,12 +454,12 @@ function onStartTutorial(){ // Styles const styles = computed(() => ({ - backgroundColor: props.uiOpts.bgColorAlt, - borderRadius: props.uiOpts.borderRadius + 'px', - boxShadow: props.uiOpts.shadowNormal, + backgroundColor: store.color.bgAlt, + borderRadius: store.borderRadius + 'px', + boxShadow: store.shadowNormal, })); const aStyles = computed(() => ({ - color: props.uiOpts.altColorDark, + color: store.color.altDark, })); // Classes diff --git a/src/components/LoadingModal.vue b/src/components/LoadingModal.vue index abd405c..3f941f2 100644 --- a/src/components/LoadingModal.vue +++ b/src/components/LoadingModal.vue @@ -9,19 +9,20 @@ </template> <script setup lang="ts"> -import {computed, PropType} from 'vue'; +import {computed} from 'vue'; import LoaderIcon from './icon/LoaderIcon.vue'; -import {UiOptions} from '../lib'; +import {useStore} from '../store'; -const props = defineProps({ +const store = useStore(); + +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, + color: store.color.text, + backgroundColor: store.color.bgDark2, + borderRadius: store.borderRadius + 'px', + boxShadow: store.shadowNormal, })); </script> diff --git a/src/components/SButton.vue b/src/components/SButton.vue index 884fa30..487d6bd 100644 --- a/src/components/SButton.vue +++ b/src/components/SButton.vue @@ -6,7 +6,7 @@ </template> <script setup lang="ts"> -const props = defineProps({ +defineProps({ disabled: {type: Boolean, default: false}, }); </script> diff --git a/src/components/SearchModal.vue b/src/components/SearchModal.vue index a035cac..1818529 100644 --- a/src/components/SearchModal.vue +++ b/src/components/SearchModal.vue @@ -10,7 +10,7 @@ </div> <div class="absolute top-[100%] w-full overflow-hidden" :style="suggContainerStyles"> <div v-for="(sugg, idx) of searchSuggs" :key="sugg.name + '|' + sugg.canonicalName" - :style="{backgroundColor: idx == focusedSuggIdx ? uiOpts.bgColorAltDark : uiOpts.bgColorAlt}" + :style="{backgroundColor: idx == focusedSuggIdx ? store.color.bgAltDark : store.color.bgAlt}" class="border-b p-1 px-2 hover:underline hover:cursor-pointer flex" @click="resolveSearch(sugg.canonicalName || sugg.name)"> <div class="grow overflow-hidden whitespace-nowrap text-ellipsis"> @@ -25,7 +25,7 @@ <div v-if="searchHadMoreSuggs" class="text-center">• • •</div> </div> <label :style="animateLabelStyles" class="flex gap-1"> - <input type="checkbox" v-model="uiOpts.searchJumpMode" @change="$emit('setting-chg', 'searchJumpMode')"/> + <input type="checkbox" v-model="store.searchJumpMode" @change="emit('setting-chg', 'searchJumpMode')"/> <div class="text-sm">Jump to result</div> </label> </div> @@ -37,20 +37,22 @@ import {ref, computed, onMounted, onUnmounted, PropType} from 'vue'; import SearchIcon from './icon/SearchIcon.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'; +import {LayoutNode, LayoutMap} from '../layout'; +import {queryServer, SearchSugg, SearchSuggResponse} from '../lib'; +import {useStore} from '../store'; // Refs const rootRef = ref(null as HTMLDivElement | null); const inputRef = ref(null as HTMLInputElement | null); +// Global store +const store = useStore(); + // 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']); @@ -101,8 +103,8 @@ async function onInput(){ let urlParams = new URLSearchParams({ type: 'sugg', name: input.value, - limit: String(props.uiOpts.searchSuggLimit), - tree: props.uiOpts.tree, + limit: String(store.searchSuggLimit), + tree: store.tree, }); // Query server, delaying/skipping if a request was recently sent pendingSuggReqParams.value = urlParams; @@ -168,7 +170,7 @@ async function resolveSearch(tolNodeName: string){ name: tolNodeName, toroot: '1', excl: props.activeRoot.name, - tree: props.uiOpts.tree, + tree: store.tree, }); emit('net-wait'); // Allows the parent component to show a loading-indicator let responseObj: {[x: string]: TolNode} = await queryServer(urlParams); @@ -216,7 +218,7 @@ function onInfoIconClick(nodeName: string){ // For keyboard shortcuts function onKeyDown(evt: KeyboardEvent){ - if (props.uiOpts.disableShortcuts){ + if (store.disableShortcuts){ return; } if (evt.key == 'f' && evt.ctrlKey){ @@ -232,26 +234,26 @@ onMounted(() => inputRef.value!.focus()) // Styles const styles = computed((): Record<string,string> => { - let br = props.uiOpts.borderRadius; + let br = store.borderRadius; return { - backgroundColor: props.uiOpts.bgColorAlt, + backgroundColor: store.color.bgAlt, borderRadius: (searchSuggs.value.length == 0) ? `${br}px` : `${br}px ${br}px 0 0`, - boxShadow: props.uiOpts.shadowNormal, + boxShadow: store.shadowNormal, }; }); const suggContainerStyles = computed((): Record<string,string> => { - let br = props.uiOpts.borderRadius; + let br = store.borderRadius; return { - backgroundColor: props.uiOpts.bgColorAlt, - color: props.uiOpts.textColorAlt, + backgroundColor: store.color.bgAlt, + color: store.color.textAlt, borderRadius: `0 0 ${br}px ${br}px`, }; }); const animateLabelStyles = computed(() => ({ position: 'absolute', - top: -props.lytOpts.headerSz - 2 + 'px', + top: -store.lytOpts.headerSz - 2 + 'px', right: '0', - height: props.lytOpts.headerSz + 'px', - color: props.uiOpts.textColor, + height: store.lytOpts.headerSz + 'px', + color: store.color.text, })); </script> diff --git a/src/components/SettingsModal.vue b/src/components/SettingsModal.vue index df8444f..a55dc41 100644 --- a/src/components/SettingsModal.vue +++ b/src/components/SettingsModal.vue @@ -9,19 +9,19 @@ <h2 class="font-bold md:text-xl text-center pt-1 md:pt-2 md:pb-1">Timing</h2> <div class="grid grid-cols-[130px_minmax(0,1fr)_65px] gap-1 px-2 md:px-3"> <!-- Row 1 --> - <label for="animTimeInput" @click="onReset('UI', 'transitionDuration')" :class="rLabelClasses"> + <label for="animTimeInput" @click="onResetOne('transitionDuration')" :class="rLabelClasses"> Animation Time </label> - <input type="range" min="0" max="1000" v-model.number="uiOpts.transitionDuration" - @change="onSettingChg('UI', 'transitionDuration')" class="my-auto" name="animTimeInput"/> - <div class="my-auto text-right">{{uiOpts.transitionDuration}} ms</div> + <input type="range" min="0" max="1000" v-model.number="store.transitionDuration" + @change="onSettingChg('transitionDuration')" class="my-auto" name="animTimeInput"/> + <div class="my-auto text-right">{{store.transitionDuration}} ms</div> <!-- Row 2 --> - <label for="autoDelayInput" @click="onReset('UI', 'autoActionDelay')" :class="rLabelClasses"> + <label for="autoDelayInput" @click="onResetOne('autoActionDelay')" :class="rLabelClasses"> Auto-mode Delay </label> - <input type="range" min="100" max="1000" v-model.number="uiOpts.autoActionDelay" - @change="onSettingChg('UI', 'autoActionDelay')" class="my-auto" name="autoDelayInput"/> - <div class="my-auto text-right">{{uiOpts.autoActionDelay}} ms</div> + <input type="range" min="300" max="2000" v-model.number="store.autoActionDelay" + @change="onSettingChg('autoActionDelay')" class="my-auto" name="autoDelayInput"/> + <div class="my-auto text-right">{{store.autoActionDelay}} ms</div> </div> </div> <div class="pb-2" :class="borderBClasses"> @@ -31,49 +31,49 @@ <div>Sweep leaves left</div> <ul> <li> <label> <input type="radio" v-model="sweepLeaves" :value="true" - @change="onSettingChg('LYT', 'layoutType')"/> Yes </label> </li> + @change="onSettingChg('lytOpts.layoutType')"/> Yes </label> </li> <li> <label> <input type="radio" v-model="sweepLeaves" :value="false" - @change="onSettingChg('LYT', 'layoutType')"/> No </label> </li> + @change="onSettingChg('lytOpts.layoutType')"/> No </label> </li> </ul> </div> <div> <div>Sweep into parent</div> <ul> - <li> <label> <input type="radio" :disabled="!sweepLeaves" v-model="lytOpts.sweepToParent" - value="none" @change="onSettingChg('LYT', 'sweepToParent')"/> Never </label> </li> - <li> <label> <input type="radio" :disabled="!sweepLeaves" v-model="lytOpts.sweepToParent" - value="prefer" @change="onSettingChg('LYT', 'sweepToParent')"/> Always </label> </li> - <li> <label> <input type="radio" :disabled="!sweepLeaves" v-model="lytOpts.sweepToParent" - value="fallback" @change="onSettingChg('LYT', 'sweepToParent')"/> If needed </label> </li> + <li> <label> <input type="radio" :disabled="!sweepLeaves" v-model="store.lytOpts.sweepToParent" + value="none" @change="onSettingChg('lytOpts.sweepToParent')"/> Never </label> </li> + <li> <label> <input type="radio" :disabled="!sweepLeaves" v-model="store.lytOpts.sweepToParent" + value="prefer" @change="onSettingChg('lytOpts.sweepToParent')"/> Always </label> </li> + <li> <label> <input type="radio" :disabled="!sweepLeaves" v-model="store.lytOpts.sweepToParent" + value="fallback" @change="onSettingChg('lytOpts.sweepToParent')"/> If needed </label> </li> </ul> </div> </div> <div class="grid grid-cols-[100px_minmax(0,1fr)_65px] gap-1 w-fit mx-auto px-2 md:px-3"> <!-- Row 1 --> - <label for="minTileSizeInput" @click="onReset('LYT', 'minTileSz')" :class="rLabelClasses"> + <label for="minTileSizeInput" @click="onResetOne('lytOpts.minTileSz')" :class="rLabelClasses"> Min Tile Size </label> <input type="range" - min="15" :max="uiOpts.breakpoint == 'sm' ? 150 : 200" v-model.number="lytOpts.minTileSz" - @input="onSettingChgThrottled('LYT', 'minTileSz')" @change="onSettingChg('LYT', 'minTileSz')" + min="15" :max="store.breakpoint == 'sm' ? 150 : 200" v-model.number="store.lytOpts.minTileSz" + @input="onSettingChgThrottled('lytOpts.minTileSz')" @change="onSettingChg('lytOpts.minTileSz')" name="minTileSizeInput" ref="minTileSzRef"/> - <div class="my-auto text-right">{{lytOpts.minTileSz}} px</div> + <div class="my-auto text-right">{{store.lytOpts.minTileSz}} px</div> <!-- Row 2 --> - <label for="maxTileSizeInput" @click="onReset('LYT', 'maxTileSz')" :class="rLabelClasses"> + <label for="maxTileSizeInput" @click="onResetOne('lytOpts.maxTileSz')" :class="rLabelClasses"> Max Tile Size </label> - <input type="range" min="15" max="400" v-model.number="lytOpts.maxTileSz" - @input="onSettingChgThrottled('LYT', 'maxTileSz')" @change="onSettingChg('LYT', 'maxTileSz')" + <input type="range" min="15" max="400" v-model.number="store.lytOpts.maxTileSz" + @input="onSettingChgThrottled('lytOpts.maxTileSz')" @change="onSettingChg('lytOpts.maxTileSz')" name="maxTileSizeInput" ref="maxTileSzRef"/> - <div class="my-auto text-right">{{lytOpts.maxTileSz}} px</div> + <div class="my-auto text-right">{{store.lytOpts.maxTileSz}} px</div> <!-- Row 3 --> - <label for="tileSpacingInput" @click="onReset('LYT', 'tileSpacing')" :class="rLabelClasses"> + <label for="tileSpacingInput" @click="onResetOne('lytOpts.tileSpacing')" :class="rLabelClasses"> Tile Spacing </label> - <input type="range" min="0" max="20" v-model.number="lytOpts.tileSpacing" - @input="onSettingChgThrottled('LYT', 'tileSpacing')" @change="onSettingChg('LYT', 'tileSpacing')" + <input type="range" min="0" max="20" v-model.number="store.lytOpts.tileSpacing" + @input="onSettingChgThrottled('lytOpts.tileSpacing')" @change="onSettingChg('lytOpts.tileSpacing')" name="tileSpacingInput"/> - <div class="my-auto text-right">{{lytOpts.tileSpacing}} px</div> + <div class="my-auto text-right">{{store.lytOpts.tileSpacing}} px</div> </div> </div> <div class="pb-2 px-2 md:px-3" :class="borderBClasses"> @@ -81,29 +81,29 @@ <div> Tree to use <ul class="flex justify-evenly"> - <li> <label> <input type="radio" v-model="uiOpts.tree" value="trimmed" - @change="onSettingChg('UI', 'tree')"/> Complex </label> </li> - <li> <label> <input type="radio" v-model="uiOpts.tree" value="images" - @change="onSettingChg('UI', 'tree')"/> Visual </label> </li> - <li> <label> <input type="radio" v-model="uiOpts.tree" value="picked" - @change="onSettingChg('UI', 'tree')"/> Minimal </label> </li> + <li> <label> <input type="radio" v-model="store.tree" value="trimmed" + @change="onSettingChg('tree')"/> Complex </label> </li> + <li> <label> <input type="radio" v-model="store.tree" value="images" + @change="onSettingChg('tree')"/> Visual </label> </li> + <li> <label> <input type="radio" v-model="store.tree" value="picked" + @change="onSettingChg('tree')"/> Minimal </label> </li> </ul> </div> <div> - <label> <input type="checkbox" v-model="uiOpts.searchJumpMode" - @change="onSettingChg('UI', 'searchJumpMode')"/> Skip search animation </label> + <label> <input type="checkbox" v-model="store.searchJumpMode" + @change="onSettingChg('searchJumpMode')"/> Skip search animation </label> </div> <div> - <label> <input type="checkbox" v-model="uiOpts.autoHide" - @change="onSettingChg('UI', 'autoHide')"/> Auto-hide ancestors </label> + <label> <input type="checkbox" v-model="store.autoHide" + @change="onSettingChg('autoHide')"/> Auto-hide ancestors </label> </div> - <div v-if="uiOpts.touchDevice == false"> - <label> <input type="checkbox" v-model="uiOpts.disableShortcuts" - @change="onSettingChg('UI', 'disableShortcuts')"/> Disable keyboard shortcuts </label> + <div v-if="store.touchDevice == false"> + <label> <input type="checkbox" v-model="store.disableShortcuts" + @change="onSettingChg('disableShortcuts')"/> Disable keyboard shortcuts </label> </div> </div> - <s-button class="mx-auto my-2" :style="{color: uiOpts.textColor, backgroundColor: uiOpts.bgColor}" - @click="onResetAll"> + <s-button class="mx-auto my-2" :style="{color: store.color.text, backgroundColor: store.color.bg}" + @click="onReset"> Reset </s-button> <transition name="fade"> @@ -114,11 +114,10 @@ </template> <script setup lang="ts"> -import {ref, computed, watch, PropType} from 'vue'; +import {ref, computed, watch} from 'vue'; import SButton from './SButton.vue'; import CloseIcon from './icon/CloseIcon.vue'; -import {UiOptions, OptionType, getDefaultLytOpts, getDefaultUiOpts} from '../lib'; -import {LayoutOptions} from '../layout'; +import {useStore, StoreState} from '../store'; // Refs const rootRef = ref(null as HTMLDivElement | null); @@ -127,37 +126,36 @@ 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', ]); +// Global store +const store = useStore(); -// 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'}) +// Events +const emit = defineEmits(['close', 'setting-chg', 'reset']); + +// For making only two of 'layoutType's values available for user selection) +const sweepLeaves = ref(store.lytOpts.layoutType == 'sweep'); +watch(sweepLeaves, (newVal) => {store.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){ +let settingChgTimeout = 0; // Used to throttle some setting-change handling +function onSettingChg(option: string){ // Maintain min/max-tile-size consistency - if (optionType == 'LYT' && (option == 'minTileSz' || option == 'maxTileSz')){ + if (option == 'lytOpts.minTileSz' || option == 'lytOpts.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'); + if (Number(minInput.value) > Number(maxInput.value)){ + if (option == 'lytOpts.minTileSz'){ + store.lytOpts.maxTileSz = store.lytOpts.minTileSz; + emit('setting-chg', 'lytOpts.maxTileSz'); + } else { + store.lytOpts.minTileSz = store.lytOpts.maxTileSz; + emit('setting-chg', 'lytOpts.minTileSz'); + } } } - // Notify parent component - emit('setting-chg', optionType, option, - {relayout: optionType == 'LYT', reinit: optionType == 'UI' && option == 'tree'}); + // Notify parent (might need to relayout) + emit('setting-chg', option); // Possibly make saved-indicator appear/animate if (!saved.value){ saved.value = true; @@ -168,48 +166,24 @@ function onSettingChg(optionType: OptionType, option: string){ 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 onSettingChgThrottled(option: string){ + if (settingChgTimeout == 0){ + settingChgTimeout = setTimeout(() => { + settingChgTimeout = 0; + onSettingChg(option); + }, store.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]; +function onResetOne(option: string){ + store.resetOne(option); + if (option == 'lytOpts.layoutType'){ + sweepLeaves.value = (store.lytOpts.layoutType == 'sweep'); } - // Notify parent component - onSettingChg(optionType, option); + onSettingChg(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; +function onReset(){ + emit('reset'); // Notify parent (might need to relayout) + saved.value = false; // Clear saved-indicator } // Close handling @@ -221,9 +195,9 @@ function onClose(evt: Event){ // Styles and classes const styles = computed(() => ({ - backgroundColor: props.uiOpts.bgColorAlt, - borderRadius: props.uiOpts.borderRadius + 'px', - boxShadow: props.uiOpts.shadowNormal, + backgroundColor: store.color.bgAlt, + borderRadius: store.borderRadius + 'px', + boxShadow: store.shadowNormal, })); const borderBClasses = 'border-b border-stone-400'; const rLabelClasses = "w-fit hover:cursor-pointer hover:text-lime-600"; // For reset-upon-click labels diff --git a/src/components/TileInfoModal.vue b/src/components/TileInfoModal.vue index fc09d86..2d0e354 100644 --- a/src/components/TileInfoModal.vue +++ b/src/components/TileInfoModal.vue @@ -58,35 +58,35 @@ </template> <template v-slot:content> <ul class="rounded shadow overflow-x-auto p-1" - :style="{backgroundColor: uiOpts.bgColor, color: uiOpts.textColor}"> + :style="{backgroundColor: store.color.bg, color: store.color.text}"> <li v-if="imgInfos[idx]!.url != ''"> - <span :style="{color: uiOpts.altColor}">Source: </span> + <span :style="{color: store.color.alt}">Source: </span> <a :href="imgInfos[idx]!.url" target="_blank">Link</a> <external-link-icon class="inline-block w-3 h-3 ml-1"/> </li> <li v-if="imgInfos[idx]!.artist != ''" class="whitespace-nowrap"> - <span :style="{color: uiOpts.altColor}">Artist: </span> + <span :style="{color: store.color.alt}">Artist: </span> {{imgInfos[idx]!.artist}} </li> <li v-if="imgInfos[idx]!.credit != ''" class="whitespace-nowrap"> - <span :style="{color: uiOpts.altColor}">Credits: </span> + <span :style="{color: store.color.alt}">Credits: </span> {{imgInfos[idx]!.credit}} </li> <li> - <span :style="{color: uiOpts.altColor}">License: </span> + <span :style="{color: store.color.alt}">License: </span> <a :href="licenseToUrl(imgInfos[idx]!.license)" target="_blank"> {{imgInfos[idx]!.license}} </a> <external-link-icon class="inline-block w-3 h-3 ml-1"/> </li> <li v-if="imgInfos[idx]!.src != 'picked'"> - <span :style="{color: uiOpts.altColor}">Obtained via: </span> + <span :style="{color: store.color.alt}">Obtained via: </span> <a v-if="imgInfos[idx]!.src == 'eol'" href="https://eol.org/">EoL</a> <a v-else href="https://www.wikipedia.org/">Wikipedia</a> <external-link-icon class="inline-block w-3 h-3 ml-1"/> </li> <li> - <span :style="{color: uiOpts.altColor}">Changes: </span> + <span :style="{color: store.color.alt}">Changes: </span> Cropped and resized </li> </ul> @@ -119,22 +119,21 @@ import ExternalLinkIcon from './icon/ExternalLinkIcon.vue'; import DownIcon from './icon/DownIcon.vue'; import LinkIcon from './icon/LinkIcon.vue'; import {TolNode} from '../tol'; -import {LayoutOptions} from '../layout'; -import {getImagePath, DescInfo, ImgInfo, InfoResponse, UiOptions} from '../lib'; +import {getImagePath, DescInfo, ImgInfo, InfoResponse} from '../lib'; import {capitalizeWords} from '../util'; +import {useStore} from '../store'; // Refs const rootRef = ref(null as HTMLDivElement | null); const closeRef = ref(null as typeof CloseIcon | null); +// Global store +const store = useStore(); + // 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']); @@ -239,9 +238,9 @@ function onLinkIconClick(){ // Styles const styles = computed(() => ({ - backgroundColor: props.uiOpts.bgColorAlt, - borderRadius: props.uiOpts.borderRadius + 'px', - boxShadow: props.uiOpts.shadowNormal, + backgroundColor: store.color.bgAlt, + borderRadius: store.borderRadius + 'px', + boxShadow: store.shadowNormal, overflow: 'visible auto', })); function getImgStyles(tolNode: TolNode | null): Record<string,string> { @@ -255,10 +254,10 @@ function getImgStyles(tolNode: TolNode | null): Record<string,string> { backgroundImage: imgName != null ? `url('${getImagePath(imgName as string)}')` : 'none', - backgroundColor: props.uiOpts.bgColorDark, + backgroundColor: store.color.bgDark, backgroundSize: 'cover', - borderRadius: props.uiOpts.borderRadius + 'px', - boxShadow: props.uiOpts.shadowNormal, + borderRadius: store.borderRadius + 'px', + boxShadow: store.shadowNormal, }; } function iucnStyles(iucn: string): Record<string,string>{ @@ -277,8 +276,8 @@ function iucnStyles(iucn: string): Record<string,string>{ }; } const linkCopyLabelStyles = computed(() => ({ - color: props.uiOpts.textColor, - backgroundColor: props.uiOpts.bgColor, - borderRadius: props.uiOpts.borderRadius + 'px', + color: store.color.text, + backgroundColor: store.color.bg, + borderRadius: store.borderRadius + 'px', })); </script> diff --git a/src/components/TolTile.vue b/src/components/TolTile.vue index 810c5b7..a30c6d9 100644 --- a/src/components/TolTile.vue +++ b/src/components/TolTile.vue @@ -37,7 +37,7 @@ </transition> </div> <tol-tile v-for="child in visibleChildren" :key="child.name" - :layoutNode="child" :tolMap="tolMap" :lytOpts="lytOpts" :uiOpts="uiOpts" :overflownDim="overflownDim" + :layoutNode="child" :tolMap="tolMap" :overflownDim="overflownDim" @leaf-click="onInnerLeafClick" @nonleaf-click="onInnerNonleafClick" @leaf-click-held="onInnerLeafClickHeld" @nonleaf-click-held="onInnerNonleafClickHeld" @info-click="onInnerInfoIconClick"/> @@ -52,22 +52,23 @@ import {ref, computed, watch, PropType} from 'vue'; import InfoIcon from './icon/InfoIcon.vue'; import {TolMap} from '../tol'; -import {LayoutNode, LayoutOptions} from '../layout'; -import {getImagePath, UiOptions} from '../lib'; +import {LayoutNode} from '../layout'; +import {getImagePath} from '../lib'; import {capitalizeWords} from '../util'; +import {useStore} from '../store'; const SCRIM_GRADIENT = 'linear-gradient(to bottom, rgba(0,0,0,0.4), #0000 40%, #0000 60%, rgba(0,0,0,0.2) 100%)'; // Refs const rootRef = ref(null as HTMLDivElement | null); +// Global store +const store = useStore(); + // 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}, @@ -120,26 +121,26 @@ 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')); +const infoIconDisabled = computed(() => !store.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){ + if (!store.touchDevice){ // Wait for a mouseup or click-hold clearTimeout(clickHoldTimer.value); clickHoldTimer.value = setTimeout(() => { clickHoldTimer.value = 0; onClickHold(); - }, props.uiOpts.clickHoldDuration); + }, store.clickHoldDuration); } else { // Wait for or recognise a double-click if (clickHoldTimer.value == 0){ clickHoldTimer.value = setTimeout(() => { clickHoldTimer.value = 0; onClick(); - }, props.uiOpts.clickHoldDuration); + }, store.clickHoldDuration); } else { clearTimeout(clickHoldTimer.value) clickHoldTimer.value = 0; @@ -148,7 +149,7 @@ function onMouseDown(): void { } } function onMouseUp(): void { - if (!props.uiOpts.touchDevice){ + if (!store.touchDevice){ if (clickHoldTimer.value > 0){ clearTimeout(clickHoldTimer.value); clickHoldTimer.value = 0; @@ -226,14 +227,14 @@ function onScroll(): void { pendingScrollHdlr.value = setTimeout(() => { scrollOffset.value = rootRef.value!.scrollTop; pendingScrollHdlr.value = 0; - }, props.uiOpts.animationDelay); + }, store.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 bottomY = focusedChild.pos[1] + focusedChild.dims[1] + store.lytOpts.tileSpacing; let scrollTop = Math.max(0, bottomY - (props.overflownDim / 2)); // No need to manually cap at max rootRef.value!.scrollTop = scrollTop; } @@ -253,16 +254,16 @@ function onTransitionEnd(){ // 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){ + if (valChanged && store.transitionDuration > 100 && !inTransition.value){ inTransition.value = true; - setTimeout(onTransitionEnd, props.uiOpts.transitionDuration); + setTimeout(onTransitionEnd, store.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){ + if (valChanged && store.transitionDuration > 100 && !inTransition.value){ inTransition.value = true; - setTimeout(onTransitionEnd, props.uiOpts.transitionDuration); + setTimeout(onTransitionEnd, store.transitionDuration); } }); @@ -285,7 +286,7 @@ 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); + setTimeout(() => {inFlash.value = false;}, 300); } }); @@ -294,32 +295,32 @@ const justUnhidden = ref(false); // Used to allow overflow temporarily after bei watch(() => props.layoutNode.hidden, (newVal: boolean, oldVal: boolean) => { if (oldVal && !newVal){ justUnhidden.value = true; - setTimeout(() => {justUnhidden.value = false}, props.uiOpts.transitionDuration + 100); + setTimeout(() => {justUnhidden.value = false}, store.transitionDuration + 100); } }); // Styles + classes const nonleafBgColor = computed(() => { - let colorArray = props.uiOpts.nonleafBgColors; + let colorArray = store.nonleafBgColors; return colorArray[props.layoutNode.depth % colorArray.length]; }); const boxShadow = computed((): string => { if (highlight.value){ - return props.uiOpts.shadowHovered; + return store.shadowHovered; } else if (props.layoutNode.hasFocus && !inTransition.value){ - return props.uiOpts.shadowFocused; + return store.shadowFocused; } else { - return props.uiOpts.shadowNormal; + return store.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; + return store.lytOpts.headerSz * 0.8; } else if (props.layoutNode.dims[0] >= 80){ - return props.lytOpts.headerSz * 0.7; + return store.lytOpts.headerSz * 0.7; } else { - return props.lytOpts.headerSz * 0.6; + return store.lytOpts.headerSz * 0.6; } }); // @@ -330,11 +331,11 @@ const styles = computed((): Record<string,string> => { top: props.layoutNode.pos[1] + 'px', width: props.layoutNode.dims[0] + 'px', height: props.layoutNode.dims[1] + 'px', - borderRadius: props.uiOpts.borderRadius + 'px', + borderRadius: store.borderRadius + 'px', boxShadow: boxShadow.value, visibility: 'visible', // Transition related - transitionDuration: (props.skipTransition ? 0 : props.uiOpts.transitionDuration) + 'ms', + transitionDuration: (props.skipTransition ? 0 : store.transitionDuration) + 'ms', transitionProperty: 'left, top, width, height, visibility', transitionTimingFunction: 'ease-out', zIndex: inTransition.value && wasClicked.value ? '1' : '0', @@ -342,10 +343,10 @@ const styles = computed((): Record<string,string> => { 'hidden' : 'visible', // CSS variables '--nonleafBgColor': nonleafBgColor.value, - '--tileSpacing': props.lytOpts.tileSpacing + 'px', + '--tileSpacing': store.lytOpts.tileSpacing + 'px', }; if (!isLeaf.value){ - let borderR = props.uiOpts.borderRadius + 'px'; + let borderR = store.borderRadius + 'px'; if (props.layoutNode.sepSweptArea != null){ borderR = props.layoutNode.sepSweptArea.sweptLeft ? `${borderR} ${borderR} ${borderR} 0` : @@ -354,7 +355,7 @@ const styles = computed((): Record<string,string> => { layoutStyles.borderRadius = borderR; } if (isOverflownRoot.value){ - layoutStyles.width = (props.layoutNode.dims[0] + props.uiOpts.scrollGap) + 'px'; + layoutStyles.width = (props.layoutNode.dims[0] + store.scrollGap) + 'px'; layoutStyles.height = props.overflownDim + 'px'; layoutStyles.overflow = 'hidden scroll'; } @@ -377,7 +378,7 @@ const leafStyles = computed((): Record<string,string> => { backgroundImage: tolNode.value.imgName != null ? `${SCRIM_GRADIENT},url('${getImagePath(tolNode.value.imgName as string)}')` : 'none', - backgroundColor: props.uiOpts.bgColorDark, + backgroundColor: store.color.bgDark, backgroundSize: 'cover', }; } @@ -385,8 +386,8 @@ const leafStyles = computed((): Record<string,string> => { }); 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){ + let textColor = store.color.text; + for (let [threshold, color] of store.childQtyColors){ if (numChildren >= threshold){ textColor = color; } else { @@ -413,7 +414,7 @@ function leafSubImgStyles(idx: number): Record<string,string> { backgroundImage: (tolNode.value.imgName![idx]! != null) ? `${SCRIM_GRADIENT},url('${getImagePath(tolNode.value.imgName![idx]! as string)}')` : 'none', - backgroundColor: props.uiOpts.bgColorDark, + backgroundColor: store.color.bgDark, backgroundSize: '125%', borderRadius: 'inherit', clipPath: (idx == 0) ? 'polygon(0 0, 100% 0, 0 100%)' : 'polygon(100% 0, 0 100%, 100% 100%)', @@ -438,10 +439,10 @@ const nonleafStyles = computed((): Record<string,string> => { const nonleafHeaderStyles = computed((): Record<string,string> => { let styles: Record<string,string> = { position: 'static', - height: props.lytOpts.headerSz + 'px', + height: store.lytOpts.headerSz + 'px', borderTopLeftRadius: 'inherit', borderTopRightRadius: 'inherit', - backgroundColor: props.uiOpts.nonleafHeaderColor, + backgroundColor: store.nonleafHeaderColor, }; if (isOverflownRoot.value){ styles = { @@ -451,7 +452,7 @@ const nonleafHeaderStyles = computed((): Record<string,string> => { left: '0', borderTopRightRadius: '0', zIndex: '1', - boxShadow: props.uiOpts.shadowNormal, + boxShadow: store.shadowNormal, }; } return styles; @@ -461,19 +462,19 @@ const nonleafHeaderTextStyles = computed(() => ({ fontSize: fontSz.value + 'px', paddingLeft: (fontSz.value * 0.2) + 'px', textAlign: 'center', - color: props.uiOpts.textColor, + color: store.color.text, // For ellipsis overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', })); const sepSweptAreaStyles = computed((): Record<string,string> => { - let borderR = props.uiOpts.borderRadius + 'px'; + let borderR = store.borderRadius + 'px'; let styles = { position: 'absolute', backgroundColor: nonleafBgColor.value, boxShadow: boxShadow.value, - transitionDuration: props.uiOpts.transitionDuration + 'ms', + transitionDuration: store.transitionDuration + 'ms', transitionProperty: 'left, top, width, height, visibility', transitionTimingFunction: 'ease-out', }; @@ -495,7 +496,7 @@ const sepSweptAreaStyles = computed((): Record<string,string> => { ...styles, visibility: 'hidden', left: '0', - top: props.lytOpts.headerSz + 'px', + top: store.lytOpts.headerSz + 'px', width: '0', height: '0', borderRadius: borderR, @@ -512,8 +513,8 @@ const sepSweptAreaHideEdgeClass = computed((): string => { } }); const infoIconStyles = computed((): Record<string,string> => { - let size = (props.lytOpts.headerSz * 0.85); - let marginSz = (props.lytOpts.headerSz - size); + let size = (store.lytOpts.headerSz * 0.85); + let marginSz = (store.lytOpts.headerSz - size); return { width: size + 'px', height: size + 'px', diff --git a/src/components/TutorialPane.vue b/src/components/TutorialPane.vue index 4c24bae..3ccbc46 100644 --- a/src/components/TutorialPane.vue +++ b/src/components/TutorialPane.vue @@ -70,7 +70,11 @@ import {ref, computed, watch, onMounted, PropType} from 'vue'; import SButton from './SButton.vue'; import CloseIcon from './icon/CloseIcon.vue'; -import {Action, UiOptions} from '../lib'; +import {Action} from '../lib'; +import {useStore} from '../store'; + +// Global store +const store = useStore(); // Props + events const props = defineProps({ @@ -79,9 +83,8 @@ const props = defineProps({ 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 touchDevice = computed(() => store.touchDevice); const emit = defineEmits(['close', 'stage-chg', 'skip']); // For tutorial stage @@ -118,13 +121,13 @@ function onStageChange(){ if (stage.value == 1 && !disabledOnce){ for (let action of STAGE_ACTIONS){ if (action != null && !props.actionsDone.has(action)){ - props.uiOpts.disabledActions.add(action); + store.disabledActions.add(action); } } disabledOnce = true; } // Enable action for this stage - props.uiOpts.disabledActions.delete(STAGE_ACTIONS[stage.value - 1]); + store.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 @@ -148,8 +151,8 @@ watch(() => props.triggerFlag, () => { // Styles const styles = computed(() => ({ - backgroundColor: props.uiOpts.bgColorDark, - color: props.uiOpts.textColor, + backgroundColor: store.color.bgDark, + color: store.color.text, })); const contentStyles = { padding: '0 0.5cm', @@ -157,7 +160,7 @@ const contentStyles = { textAlign: 'center', }; const buttonStyles = computed(() => ({ - color: props.uiOpts.textColor, - backgroundColor: props.uiOpts.bgColor, + color: store.color.text, + backgroundColor: store.color.bg, })); </script> |
