]> git.ipfire.org Git - thirdparty/google/fonts.git/commitdiff
Create tags2.html
authorMarc Foley <m.foley.88@gmail.com>
Tue, 4 Mar 2025 13:30:23 +0000 (13:30 +0000)
committerGitHub <noreply@github.com>
Tue, 4 Mar 2025 13:30:23 +0000 (13:30 +0000)
tags2.html [new file with mode: 0644]

diff --git a/tags2.html b/tags2.html
new file mode 100644 (file)
index 0000000..91dba21
--- /dev/null
@@ -0,0 +1,686 @@
+<meta charset="utf-8">
+<script src="https://cdn.jsdelivr.net/npm/vue@2/dist/vue.js"></script>
+<link rel="preconnect" href="https://fonts.googleapis.com">
+<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
+<link href="https://cdn.jsdelivr.net/npm/daisyui@4.12.14/dist/full.min.css" rel="stylesheet" type="text/css" />
+<link href="https://fonts.google.com/metadata/fonts" type="application/json" type="text/css" />
+<script src="https://cdn.tailwindcss.com"></script>
+
+<body>
+  <div id="app">
+    <div id="fonts">
+      <link v-for="url in fontUrls" :href="url" rel="stylesheet">
+    </div>
+
+    <!-- Navbar -->
+    <div style="max-height: 3rem" class="navbar bg-base-100 fixed left-0 top-0 shadow">
+        <a class="btn btn-ghost btn-sm text-xl">GF Tagger</a>
+        <ul class="menu menu-horizontal px-1">
+          <li>
+            <details>
+              <summary>File</summary>
+              <ul class="shadow w-36">
+                <li><a @click="saveCSV">Export CSV</a></li>
+                <li><a @click="prCSV">Open PR</a></li>
+              </ul>
+            </details>
+          </li>
+          <li>
+            <details>
+              <summary>Edit</summary>
+              <ul class="shadow w-56">
+                <li><a @click="AddPlaceHolderTags">Insert placeholder tags</a></li>
+                <li><a @click="RemovePlaceHolderTags">Remove placeholder tags</a></li>
+              </ul>
+            </details>
+          </li>
+          <li>
+            <details>
+              <summary>{{ currentCategory }}</summary>
+              <ul class="shadow">
+                <div class="cont" style="max-height: 16rem; overflow: scroll">
+                  <li  v-for="category in sortedCategories()">
+                    <a @click="currentCategory = category">{{ category }}</a>
+                  </li>
+                </div>
+              </ul>
+            </details>
+          </li>
+          <li>
+            <form @submit.prevent="loadCSV">
+              <div class="join" style="padding-top: 12px;">
+                  <input class="join-item input input-sm input-bordered w-full max-w-xs" v-model="commit" required placeholder="refs/heads/main">
+                  <button class="join-item btn btn-sm">checkout</button>
+              </div>
+            </form>
+          </li>
+          <li>
+              <div class="join" style="padding-top: 10px;">
+                  <input class="join-item input input-sm input-bordered w-full max-w-xs" v-model="tagFilter" placeholder="filter">
+              </div>
+          </li>
+        </ul>
+    </div>    
+
+    <!-- Add font panel -->
+    <div id="panel" class="fixed top-20 right-0 bg-base-100 p-5 shadow w-80">
+      <div class="panel-tile">
+        <form @submit.prevent="AddTag">
+          <div class="label label-xs">
+            <span class="label-text label-xs">Add Tag</span>
+          </div>
+          <div class="join">
+            <input class="join-item input input-xs input-bordered w-full max-w-xs" v-model="newTag" required placeholder="/Expressive/Funky">
+            <button class="join-item btn btn-xs">Add</button>
+          </div>
+        </form> 
+        <div class="divider"></div>
+          <div class="label lavel-xs">
+            <span class="label-text label-xs">Add Family</span>
+          </div>
+          <div class="join">
+            <input class="join-item input input-xs input-bordered w-full max-w-xs" list="items" v-model="newFamily" required placeholder="Family Name">
+            <datalist id="items">
+              <option v-for="family in uniqueFamilies" :value="family.name">
+            </datalist>
+            <input type="number" max="100" min="0" class="join-item input input-xs input-bordered btn-square" v-model="newWeight" required placeholder="Score">
+            <button @click="addAxis" class="join-item btn btn-xs">Add Axis</button>
+            <button @click="AddFamily" class="join-item btn btn-xs">Add</button>
+          </div>
+          <div style="max-height: 200px; overflow: scroll">
+          <div style="margin-bottom: 12pt;" v-for="(axisSet, idx) in newAxes">
+            Axis: {{ axisSet.tag }}
+            <label class="input input-bordered input-xs flex items-center gap-2">
+              Tag
+              <input type="text" class="grow" placeholder="wght" v-model="axisSet.tag" />
+            </label>
+            <div v-for="position in axisSet.positions">
+              <label class="input input-bordered input-xs flex items-center gap-2">
+                Coordinate
+                <input type="text" class="grow" placeholder="400" v-model="position.coordinate" />
+              </label>
+              <label class="input input-bordered input-xs flex items-center gap-2">
+                Score
+                <input type="number" class="grow" placeholder="400" v-model="position.score" />
+              </label>
+            </div>  
+              <button class="btn btn-xs join-item pr-2" @click="removeAxis(idx)">X</button>
+          </div>
+        </div>
+
+        <div class="divider"></div>
+        <form @submit.prevent="copyFamily">
+          <div class="label label-xs">
+            <span class="label-text label-xs">Copy tags from a family</span>
+          </div>
+          <div class="join">
+            <input class="join-item input input-xs input-bordered" list="items" v-model="fromFamily" required placeholder="From Family">
+            <input class="join-item input input-xs input-bordered" list="items" v-model="toFamily" required placeholder="To Family">
+            <datalist id="items">
+              <option v-for="family in uniqueFamilies" :value="family">
+            </datalist>
+            <button class="join-item btn btn-xs">Copy</button>
+          </div>
+        </form>
+      </div>
+
+      <div class="divider"></div>
+
+      <div class="panel-tile" style="max-height: 100px; overflow: scroll;">
+        <label>History</label>
+          <div v-if="isEdited">
+            <p style="font-size: 10pt;" v-for="item in history">{{ item }}</p>
+          </div>
+          <div v-else>
+            <p style="font-size: 10pt;">No changes</p>
+          </div>
+      </div>
+    </div>
+
+    <div v-if="sortedTags.length === 0">
+      <p>No families found for this tag. Please add some</p>
+    </div>
+    <div class="mt-20">
+      <div class="item" v-for="(family, i) in sortedTags" :key="family.name">
+        <family-item :family="family" :ready="ready" :index="i" @edited="edited" @remove="removeFamily"></family-item>
+      </div>
+    </div>
+
+  </div>
+</body>
+
+<script>
+  class FontTag {
+    constructor(name, category, score = 0) {
+        this.name = name;
+        this.category = category;
+        this.score = score;
+    }
+    static fromCsv(line) {
+      //'Maven Pro:"wght,wdth@100,200",/Mono,40' --> ["Maven Pro:"wght,wdth@100,200", "/Mono", "40"]
+      //'Maven Pro,/Mono,40' --> ["Maven Pro", "/Mono", "40"]
+      const regex = /("[^"]+"|[^,]+)(?=\s*,|\s*$)/g;
+      const parsed = line.match(regex).map(item => item.trim())
+      const [name, category, score] = parsed;
+      return new FontTag(name, category, score);
+    }
+    isVF() {
+      return this.name.includes("@");
+    }
+
+    toCsv() {
+        return `${this.name},${this.category},${this.score}`;
+    }
+
+    toTag() {
+      return `${this.name},${this.category},${this.score}`;
+    }
+
+    toUrl() {
+      return "https://fonts.googleapis.com/css2?family=" + this.name.replace(" ", "+").replace('"', "");
+    }
+    toStyle() {
+      if (!this.isVF()) {
+        return `font-family: ${this.name}; font-size: 32pt;`;
+      }
+      let cleaned = this.name.replaceAll('"', '')
+      let [name, axes] = cleaned.split(":");
+      let [axisTag, axisCoords] = axes.split("@");
+      let axisTags = axisTag.split(",");
+      let axisCoordinates = axisCoords.split(",");
+      let style = `font-family: "${name}", "Adobe NotDef"; font-size: 32pt; font-variation-settings:`;
+      for (let i = 0; i < axisTags.length; i++) {
+        style += ` '${axisTags[i]}' ${axisCoordinates[i]},`;
+      }
+      return style.slice(0, -1) + ';';
+    }
+
+    get displayName() {
+      return this.name
+    }
+}
+function _axesCombos(axes, current = [], res = []) {
+    if (current.length === axes.length) {
+        const axisSet = {score: 0, axes: []};
+        for (let i = 0; i < current.length; i++) {
+            axisSet.score += Number(current[i].score);
+            axisSet.axes.push({tag: axes[i].tag, coords: current[i].coordinate});
+        }
+        res.push(axisSet);
+        return res;
+    }
+    for (let i = 0; i < axes[current.length].positions.length; i++) {
+        _axesCombos(axes, [...current, axes[current.length].positions[i]], res);
+    }
+    return res;
+}
+
+function axesCombos(axes) {
+    let axisSets = _axesCombos(axes);
+    axisSets.forEach((axisSet) => {
+        axisSet.axes.forEach((axis) => {
+            delete axis.score;
+        });
+    });
+    return axisSets;
+}
+
+  Vue.component('family-item', {
+    props: ['family', 'ready', 'index'],
+    template: `
+      <div class="item p-1">
+        <div class="join">
+          <b class="pr-2">{{ familyDisplayName }}</b>
+          <input style="width: 3rem;" class="join-item input input-xs input-bordered btn-square" :data-index="index" v-model.lazy="family.score" @change="edited" placeholder="family.score">
+          <button class="btn btn-xs join-item pr-2" @click="removeFamily">X</button>
+          </div>
+          <div v-if="ready" :style="familyStyle" contenteditable="true">
+            {{ familyPangram }}
+          </div>
+        <div v-else>
+          Loading...
+        </div>
+        <div class="divider"></div>
+      </div>
+    `,
+    methods: {
+      edited() {
+        this.$emit('edited', this.family);
+      },
+      removeFamily() {
+        this.$emit('remove', this.family);
+      }
+    },
+    computed: {
+      familyPangram() {
+        return this.$root.familyPangram(this.family);
+      },
+      familyStyle() {
+        return this.family.toStyle();
+      },
+      familyDisplayName() {
+        return this.family.displayName;
+      }
+    }
+  });
+
+  var app = new Vue({
+    el: '#app',
+    data() {
+      return {
+        ready: false,
+        isEdited: false,
+        tagFilter: "",
+        commit: "refs/heads/main",
+        fontUrls: [],
+        newTag: "",
+        newFamily: '',
+        newWeight: '',
+        newAxes: [],
+        fromFamily: "",
+        toFamily: "",
+        currentCategory: "/Expressive/Calm",
+        categories: new Set(),
+        tags: [],
+        seen: new Set(),
+        pangrams: new Map([
+          ["English", "The quick brown fox jumps over the lazy dog."],
+          ["Greek", "Ζαφείρι δέξου πάγκαλο, βαθῶν ψυχῆς τὸ σῆμα"],
+          ["Cyrillic", "В чащах юга жил бы цитрус? Да, но фальшивый экземпляр!"],
+          ["Japanese", "いろはにほへと ちりぬるを わかよたれそ つねならむ うゐのおくやま けふこえて あさきゆめみし ゑひもせす(ん"],
+          ["Chinese", "視野無限廣,窗外有藍天"],
+          ["Arabic", "نص حكيم له سر قاطع وذو شأن عظيم مكتوب على ثوب أخضر ومغلف بجلد أزرق"],
+          ["Hebrew", "שפן אכל קצת גזר בטעם חסה, ודי."],
+          ["Devanagari", "ऋषियों को सताने वाले दुष्ट राक्षसों के राजा रावण का सर्वनाश करने वाले विष्णुवतार भगवान श्रीराम, अयोध्या के महाराज दशरथ के बड़े सपुत्र थे।"],
+          ["Bengali", "যেহেতু মানব পরিবারের সকল সদস্যের সমান ও অবিচ্ছেদ্য অধিকারসমূহ"],
+          ["Gujarati", "કેમ કે માનવકુટુંબના દરેક સભ્યની પરંપરાપ્રાપ્ત પ્રતિષ્ઠાને અને"],
+          ["Telugu", "మానవకుటంబమునందలి వ్యక్తులందరికిని గల ఆజన్మసిద్ధమైన ప్రతిపత్తిని"],
+          ["Kannada", "ಎಲ್ಲಾ ಮಾನವರೂ ಸ್ವತಂತ್ರರಾಗಿಯೇ ಜನಿಸಿದ್ದಾರೆ. ಹಾಗೂ ಘನತೆ ಮತ್ತು ಹಕ್ಕುಗಳಲ್ಲಿ"],
+          ["Khmer", "ដោយយល់ឃើញថា ការទទួលស្គាល់សេចក្ដីថ្លៃថ្នូរជាប់ពីកំណើត និងសិទ្ធិស្មើភាពគ្នា"],
+          ["Phags Pa", "ꡗ ꡈꡱ ᠂ ꡒ ꡂ ꡈꡞ ᠂ ꡚꡖꡋ ꡈꡞꡋꡨꡖ ꡗꡛꡧꡖ ꡈꡋ ꡈꡱꡨꡖ ꡳꡬꡖ"],
+          ["Tamil", "மனிதக் குடும்பத்தினைச் சேர்ந்த யாவரதும் உள்ளார்ந்த"],
+        ]),
+        familyScripts: new Map(),
+        history: [],
+      };
+    },
+    watch: {
+      commit(newCommit) {
+        this.updateURL();
+      },
+      currentCategory(newCategory) {
+        this.updateURL();
+      },
+    },
+    created() {
+      this.loadFonts();
+      this.loadCSV();
+      this.loadFamilyPangrams();
+
+    },
+    mounted() {
+      const urlParams = new URLSearchParams(window.location.search);
+      const category = urlParams.get('category');
+      if (category) {
+        this.currentCategory = category;
+      }
+      const commit = urlParams.get('commit');
+      if (commit) {
+        this.commit = commit;
+        this.loadCSV();
+      }
+    },
+    computed: {
+      sortedTags() {
+        let ll = this.tags;
+        let filtered = ll.filter(family => family.category === this.currentCategory);
+        filtered = filtered.filter(family => family.name.toLowerCase().includes(this.tagFilter.toLowerCase()));
+        filtered.sort(function(a, b) {return b.score - a.score;});
+        return filtered;
+      },
+      uniqueFamilies() {
+        const seen = new Set();
+        let res = [];
+        for (let family of this.tags) {
+          if (seen.has(family.displayName)) {
+            continue;
+          }
+          seen.add(family.displayName);
+          res.push(family);
+        }
+        return res;
+      }
+    },
+    methods: {
+
+      async loadFonts() {
+        let dat = await fetch("family_data.json").then(response => response.json()).then(data => {
+          let results = [];
+          let familyMeta = data["familyMetadataList"]
+          familyMeta.forEach(family => {
+            // to do bullshit aplhabetical sorting
+            let path = `https://fonts.googleapis.com/css2?family=${family.family.replaceAll(" ", "+")}`
+            if (family.axes.length > 0) {
+              path += ":" + family.axes.map(a => {return a.tag}).join(",")
+              path += "@";
+              path += family.axes.map(a => {return `${Number(a.min)}..${Number(a.max)}`}).join(",")
+            }
+            results.push(path);
+          })
+          return results
+        });
+        this.fontUrls = dat
+      },
+      addAxis() {
+        this.newAxes.push(
+          {
+            tag: "",
+            positions: [
+              {coordinate: "", score: 0},
+              {coordinate: "", score: 0},
+            ]
+          }
+        )
+      },
+      removeAxis(idx) {
+        this.newAxes.splice(idx, 1);
+      },
+      sortedCategories() {
+        return Array.from(this.categories).sort();
+      },
+      updateURL() {
+        const url = new URL(window.location);
+        if (this.commit && this.commit !== "refs/heads/main") {
+          url.searchParams.set('commit', this.commit);
+        } else {
+          url.searchParams.delete('commit');
+        }
+        if (this.currentCategory) {
+          url.searchParams.set('category', this.currentCategory);
+        } else {
+          url.searchParams.delete('category');
+        }
+        history.pushState(null, '', url);
+      },
+      familyPangram(family) {
+        let pangram = this.pangrams.get(this.familyScripts.get(family.name));
+        if (!pangram) {
+          return this.pangrams.get("English");
+        }
+        return pangram;
+      },
+      edited(family) {
+        this.isEdited = true;
+        this.history.push(`* ${family.name},${family.category},${family.Weight}`);
+      },
+      parseUnicode(str) {
+        let ranges = str.split(",");
+        let script = "English";
+        let scripts = {
+          "U+600-6FF": "Arabic",
+          "U+900-97F": "Devanagari",
+          "U+590-5FF": "Hebrew",
+          "U+A80-AFF": "Gujarati",
+          "U+C00-C7F": "Telugu",
+          "U+C80-CFF": "Kannada",
+          "U+980-9FE": "Bengali",
+          "U+1780-17FF": "Khmer",
+          "U+A840-A877": "Phags Pa",
+          "U+0B82-0BFA": "Tamil",
+        }
+        for (let i = 0; i < ranges.length; i++) {
+          for (let key in scripts) {
+            if (ranges[i].includes(key)) {
+              script = scripts[key];
+              break;
+            }
+          }
+        }
+        return script;
+      },
+      async loadFamilyPangrams(delay = 1000) {
+        await document.fonts.ready;
+        let result = new Map();
+        let fonts = document.fonts;
+        fonts.forEach((font) => {
+          if (!result.has(font.family)) {
+            result.set(font.family, this.parseUnicode(font.unicodeRange));
+          }
+        });
+        if (result.size < 1000) {
+          setTimeout(() => this.loadFamilyPangrams(), delay);
+        }
+        this.familyScripts = result;
+        this.ready = true;
+      },
+      AddTag() {
+        this.isEdited = true;
+        this.categories.add(this.newTag);
+        this.history.push(`+ Tag added "${this.newTag}"`);
+        this.currentCategory = this.newTag;
+      },
+      AddFamily() {
+        this.isEdited = true;
+        const fonts = document.getElementById("fonts")
+        if (this.newAxes.length > 0) {
+          const solved = axesCombos(this.newAxes);
+          for(let i=0; i<solved.length; i++) {
+            let vfTag = solved[i]
+            if (vfTag.score === 0) {
+              alert("Please fill in all axis scores");
+              return;
+            }
+            if (vfTag.score > 100) {
+              alert("Summed score of axes must be less than 100")
+              return;
+            }
+            for (let j=0; j<vfTag.axes.length; j++) {
+              if (vfTag.axes[j].tag === "" || vfTag.axes[j].coords === "") {
+                alert("Please fill in all axis tags and coordinates");
+                return;
+              }
+            }
+          }
+          solved.forEach((ax) => {
+            let name = `${this.newFamily}:"${ax.axes.map((a) => a.tag).join(",")}@${ax.axes.map((a) => a.coords).join(",")}"`;
+            let newFamily = new FontTag(name, this.currentCategory, ax.score)
+            let tagKey = `${newFamily.name},${newFamily.category}`;
+            if (this.seen.has(tagKey)) {
+              alert(`Tag "${newFamily.name}" already exists in "${this.currentCategory}"`);
+              return; 
+            }
+            this.seen.add(tagKey)
+            this.tags.push(newFamily);
+            this.history.push(`+ ${newFamily.displayName},${newFamily.category},${newFamily.score}`);
+          })
+          this.newAxes = [];
+        } else {
+          let newFamily = new FontTag(this.newFamily, this.currentCategory, axes=this.newAxes, score=this.newWeight);
+            let tagKey = `${newFamily.name},${newFamily.category}`;
+          if (this.seen.has(tagKey)) {
+            alert(`Tag "${newFamily.name}" already exists in "${this.currentCategory}"`);
+            return;
+          }
+          this.seen.add(tagKey);
+          this.tags.push(newFamily);
+          this.history.push(`+ ${newFamily.displayName},${newFamily.category},${newFamily.score}`);
+        }
+      },
+      copyFamily() {
+        this.isEdited = true;
+        let fromTags = this.tags.filter(family => family.name === this.fromFamily.name);
+        if (fromTags.length === 0) {
+          alert(`No tags found for Family "${this.toFamily}"`);
+          return;
+        }
+        fromTags.forEach((tag) => {
+          let newTag = {Family: this.toFamily, "Group/Tag": tag["Group/Tag"], Weight: tag.Weight};
+          this.tags.push(newTag);
+          this.history.push(`+ ${newTag.Family},${newTag["Group/Tag"]},${newTag.Weight}`);
+        })
+      },
+      AddPlaceHolderTags() {
+        this.isEdited = true;
+        const existingTags = this.sortedTags
+        let seen = new Set();
+        existingTags.forEach((family) => seen.add(family.name));
+        const familiesToAdd = this.uniqueFamilies
+        familiesToAdd.forEach((family) => {
+          if (!seen.has(family.name)) {
+            this.tags.push(new FontTag(family.name, this.currentCategory, score=""));
+          }
+        });
+        this.history.push(`+ Placeholder tags added for ${this.currentCategory}`);
+      },
+      RemovePlaceHolderTags() {
+        this.isEdited = true;
+        this.tags = this.tags.filter((family) => family.score !== "");
+        this.history.push(`- Placeholder tags removed for all categories`);
+      },
+      removeFamily(Family) {
+        this.isEdited = true;
+        this.tags = this.tags.filter((t) => t !== Family);
+        let tagKey = `${Family.name},${Family.category}`;
+        this.seen.delete(tagKey);
+        this.history.push(`- ${Family.displayName},${Family.category},${Family.score}`);
+      },
+      tagsToCSV() {
+        this.RemovePlaceHolderTags();
+        this.tags = this.tags.filter((t) => t.name !== "");
+        // The sorting function used is case sensitive.
+        // This means that "A" will come before "a".
+        this.tags = Array.from(this.tags).sort((a, b) => {
+          if (`${a.displayName},${a.category}` < `${b.displayName},${b.category}`) {
+            return -1;
+          }
+          if (`${a.displayName},${a.category}` > `${b.displayName},${b.category}`) {
+            return 1;
+          }
+          return 0;
+        });
+        let res = "Family,Group/Tag,Weight\r\n"
+        this.tags.forEach((family) => {
+          res += family.toCsv() + "\r\n";
+        });
+        return res
+      },
+      saveCSV() {
+        let csv = this.tagsToCSV();
+        const blob = new Blob([csv], { type: 'text/csv' });
+        const url = URL.createObjectURL(blob);
+        const a = document.createElement('a');
+        a.href = url;
+        a.download = "families.csv";
+        document.body.appendChild(a);
+        a.click();
+        document.body.removeChild(a);
+        URL.revokeObjectURL(url);
+      },
+      prCSV() {
+        let csv = this.tagsToCSV();
+        alert("Tag data copied to clipboard. A github pull request page will open in a new tab. Please remove the old data and paste in the new.");
+        navigator.clipboard.writeText(csv);
+        window.open("https://github.com/google/fonts/edit/main/tags/all/families.csv")
+      },
+      loadCSV() {
+        if (this.history.length > 0) {
+          let proceed = confirm("Checking out a new commit will delete any changes you've made. Would you like to continue?")
+          if (proceed === false) {
+            return;
+          }
+        }
+        this.history = [];
+        const csvFilePath = `https://raw.githubusercontent.com/google/fonts/${this.commit}/tags/all/families.csv`; // Update this path to your CSV file
+        fetch(csvFilePath)
+          .then(response => {
+            if (response.status === 404) {
+              alert(`No data found for commit "${this.commit}". Please input a git commit hash e.g 538d9635c160306b40af31c9a3821c59b285bbff`);
+            }
+            return response.text()
+          })
+          .then(csvText => {
+            const lines = csvText.split("\r\n")
+            lines.forEach((line) => {
+              if (line === "") {
+                return;
+              }
+              let family = FontTag.fromCsv(line);
+              this.categories.add(family.category);
+              this.tags.push(family);
+            });
+            csvText = "Family,Group/Tag,Weight\r\n" + csvText;
+        })
+      }
+    }
+  } // methods
+  )
+  // close open navbar dropdowns when user clicks elsewhere
+  var details = [...document.querySelectorAll('details')];
+  document.addEventListener('click', function(e) {
+  if (!details.some(f => f.contains(e.target))) {
+    details.forEach(f => f.removeAttribute('open'));
+  } else {
+    details.forEach(f => !f.contains(e.target) ? f.removeAttribute('open') : '');
+  }
+})
+
+document.addEventListener("keydown", function(e) {
+  const scoreInputs = document.querySelectorAll("[data-index]")
+  let active = document.activeElement;
+  let idx = Number(active.dataset.index)  
+  switch (e.key) {
+    case "ArrowUp":
+      if (idx > 0) {
+        idx--
+      }
+      break;
+    case "ArrowDown":
+      if (idx < scoreInputs.length - 1) {
+        idx++
+      }
+      break;
+    case "!":
+      e.preventDefault();
+      app.sortedTags[idx].score = 100;
+      idx++
+      scoreInputs[idx].scrollIntoView({"block": "center"});
+      scoreInputs.forEach((input, i) => {
+        if (i !== idx) {
+          input.parentElement.parentElement.parentElement.style.webkitFilter = "blur(5px)";
+        } else {
+          input.parentElement.parentElement.parentElement.style.webkitFilter = "none";
+        }
+      })
+      break;
+    case "@":
+      e.preventDefault();
+      app.removeFamily(app.sortedTags[idx]);
+      scoreInputs[idx].scrollIntoView({"block": "center"});
+      idx++
+      scoreInputs.forEach((input, i) => {
+        if (i !== idx) {
+          input.parentElement.parentElement.parentElement.style.webkitFilter = "blur(5px)";
+        } else {
+          input.parentElement.parentElement.parentElement.style.webkitFilter = "none";
+        }
+      })
+      break;
+    case "Escape":
+      scoreInputs.forEach((input, i) => {
+        input.parentElement.parentElement.parentElement.style.webkitFilter = "none";
+      })
+      break;
+    default:
+      return;
+  }
+  scoreInputs[idx].focus();
+})
+</script>
+
+
+<style>
+  @font-face {
+      font-family: "Adobe NotDef";
+      src: url(https://cdn.jsdelivr.net/gh/adobe-fonts/adobe-notdef/AND-Regular.ttf);
+  }
+</style>