Changeset 35723 for main


Ignore:
Timestamp:
2021-11-08T15:51:51+13:00 (2 years ago)
Author:
cstephen
Message:

Add support for file macronisation

Location:
main/trunk/model-interfaces-dev/atea/macron-restoration/src
Files:
2 added
1 deleted
6 edited

Legend:

Unmodified
Added
Removed
  • main/trunk/model-interfaces-dev/atea/macron-restoration/src/App.vue

    r35715 r35723  
    1212
    1313    <div class="paper content">
    14         <DirectInput />
     14        <tab-bar :tabs="tabs" v-model="selectedTab" />
     15        <hr class="divider" />
     16
     17        <div class="inner-content" v-if="selectedTab === 'direct'">
     18            <direct-input />
     19        </div>
     20        <div class="inner-content" v-if="selectedTab === 'file'">
     21            <file-input />
     22        </div>
    1523    </div>
    16 
    17     <!-- <ul id="transcription-list" class="list-view content">
    18         <transition-group name="transcription-list">
    19             <li class="list-item transcription-list-item" v-for="[id, transcription] in transcriptions" :key="id">
    20                 <TranscriptionItem :transcription="transcription" />
    21             </li>
    22         </transition-group>
    23     </ul> -->
    2424</template>
    2525
     
    5555
    5656.content {
     57    padding: 0;
    5758    margin: 1em;
     59}
     60
     61.inner-content {
     62    padding: 1em;
    5863}
    5964
     
    7984import { mapState } from "vuex";
    8085import DirectInput from "./components/DirectInput.vue"
     86import FileInput from "./components/FileInput.vue"
     87import TabBar from "./components/TabBar.vue"
    8188
    8289export default {
    8390    name: "App",
    8491    components: {
    85         DirectInput
     92        DirectInput,
     93        FileInput,
     94        TabBar
    8695    },
    87     computed: mapState({
    88         translations: state => state.translations
    89     })
     96    data() {
     97        return {
     98            selectedTab: "direct"
     99        }
     100    },
     101    computed: {
     102        tabs() {
     103            return [
     104                { label: this.translations.get("DirectInput"), value: "direct" },
     105                { label: this.translations.get("FileInput"), value: "file" }
     106            ]
     107        },
     108        ...mapState({
     109            translations: state => state.translations
     110        })
     111    }
    90112}
    91113</script>
  • main/trunk/model-interfaces-dev/atea/macron-restoration/src/components/DirectInput.vue

    r35718 r35723  
    33    <textarea class="text-input input-area" @input="onTextInput"
    44        v-model="input" :placeholder="translations.get('DirectInput_InputPlaceholder')" />
     5
    56    <div class="text-container">
    6         {{ output }}
     7        <span v-if="errorState" class="material-icons mdi-m error-text">error</span>
     8        <span v-if="errorState" class="error-text">Something went wrong! Please try modifying your input text.</span>
     9
     10        <!-- eslint-disable-next-line vue/require-v-for-key -->
     11        <span v-for="word in validRestored" :class="{ 'highlight': word.macronised && this.showMacronisedWords }">{{ word.w }}&nbsp;</span>
    712    </div>
    813
    9     <button class="btn-primary right-column" :disabled="!canDownload" @click="downloadAsText">Download</button>
     14    <div class="flex">
     15        <input type="checkbox" id="i-preserve-existing-macrons" v-model="preserveExistingMacrons" />
     16        <label for="i-preserve-existing-macrons">{{ translations.get('DirectInput_PreserveExistingMacrons') }}</label>
     17    </div>
     18
     19    <div class="flex">
     20        <input type="checkbox" id="i-show-macronised-words" v-model="showMacronisedWords" />
     21        <label for="i-show-macronised-words">{{ translations.get('DirectInput_ShowMacronisedWords') }}</label>
     22    </div>
     23
     24    <button class="btn-primary right-column" :disabled="!canDownload" @click="downloadAsText">{{ translations.get('DirectInput_Download') }}</button>
    1025</div>
    1126</template>
     
    1530    display: grid;
    1631    grid-template-columns: 1fr 1fr;
    17     grid-template-rows: auto auto;
     32    grid-template-rows: 1fr auto auto;
    1833    gap: 1em;
    1934}
     
    2540}
    2641
     42.flex {
     43    display: flex;
     44    align-items: center;
     45    gap: 0.5em;
     46}
     47
    2748.right-column {
    2849    grid-column: 2;
     50}
     51
     52.highlight {
     53    background-color: var(--highlighted-word-bg);
     54}
     55
     56.error-text {
     57    color: rgb(185, 3, 3);
    2958}
    3059</style>
     
    3362import { mapState } from "vuex";
    3463import { saveAs } from "file-saver"
     64import { log } from "../js/Util"
    3565import MacronRestorationModule from "../js/MacronRestorationModule"
    3666
     
    4171    data() {
    4272        return {
    43             input: "",
    44             output: "",
     73            preserveExistingMacrons: true,
     74            showMacronisedWords: false,
    4575            transcribeTimeout: null,
    46             waitingOnTranscribe: false
     76            waitingOnTranscribe: false,
     77            errorState: false
    4778        }
    4879    },
    4980    computed: {
    5081        canDownload() {
    51             return this.output.length > 0 && !this.waitingOnTranscribe;
     82            return this.restored !== null && !this.waitingOnTranscribe;
     83        },
     84        input: {
     85            get() {
     86                return this.$store.state.directInput;
     87            },
     88            set(newValue) {
     89                this.$store.commit("setDirectInput", newValue);
     90            }
     91        },
     92        restored: {
     93            get() {
     94                return this.$store.state.directOutput;
     95            },
     96            set(newValue) {
     97                this.$store.commit("setDirectOutput", newValue);
     98            }
     99        },
     100        validRestored() {
     101            return this.restored.filter(w => w.w !== "");
    52102        },
    53103        ...mapState({
     
    75125        },
    76126        async updateMacronisedText() {
    77             // TODO: Handle error
    78             const response = await macroniser.directMacronisation(this.input);
    79             this.output = response.fragment2;
    80             this.waitingOnTranscribe = false;
     127            this.errorState = false;
     128
     129            try {
     130                this.restored = await macroniser.directMacronisation(this.input, this.preserveExistingMacrons);
     131                this.waitingOnTranscribe = false;
     132            }
     133            catch (ex) {
     134                this.errorState = true;
     135                log(ex, "warn");
     136            }
    81137        },
    82138        downloadAsText() {
    83             const blob = new Blob([ this.output ], { type: "text/plain;charset=utf-8" });
    84             saveAs(blob, "output.txt");
     139            const blob = new Blob([ this.restored ], { type: "text/plain;charset=utf-8" });
     140            saveAs(blob, "restored.txt");
    85141        }
    86142    }
  • main/trunk/model-interfaces-dev/atea/macron-restoration/src/components/FileInput.vue

    r35716 r35723  
    11<template>
    2     <div class="card">
    3         <!-- Header containing info and actions for the transcription -->
    4         <div class="transcription__header">
    5             <button class="btn-fab" v-on:click="toggleAudio" type="button" :title="translations.get('TranscriptionItem_PlayButtonTooltip')">
    6                 <span class="material-icons mdi-l play-button" v-if="!isPlaying">play_arrow</span>
    7                 <span class="material-icons mdi-l play-button" v-if="isPlaying">pause</span>
    8             </button>
     2<file-upload />
    93
    10             <span>{{ translations.get("TranscriptionItem_FileName") }}: {{ transcription.fileName }}</span>
     4<div class="file-info-list">
     5    <div v-for="(fileInfo, index) in fileInfos" :key="fileInfo.id">
     6        <div class="info-container">
     7            <span class="material-icons mdi-override" v-if="fileInfo.fileType === '.txt'">description</span>
     8            <img class="icon-l" src="resources/word_icon.ico" v-if="fileInfo.fileType === '.docx'" />
    119
    12             <div style="position: relative;">
    13                 <button class="btn-primary" @mouseover="showDownloadOptions = true" @mouseout="showDownloadOptions = false" type="button">
    14                     <span class="material-icons">download</span>
    15                     <span>{{ translations.get("TranscriptionItem_Download") }}</span>
    16                 </button>
     10            {{ fileInfo.fileName }}
    1711
    18                 <div class="download-popup card" :class="{ 'download-popup-show': showDownloadOptions }"
    19                      @mouseover="showDownloadOptions = true" @mouseout="showDownloadOptions = false">
    20                     <button @click="downloadAsText" type="button" class="btn-primary theme-flat">
    21                         <span class="material-icons">text_snippet</span>
    22                         <span>{{ translations.get("TranscriptionItem_DownloadAsText") }}</span>
    23                     </button>
    24 
    25                     <button @click="downloadAsJson" type="button" class="btn-primary theme-flat">
    26                         <span class="material-icons">integration_instructions</span>
    27                         <span>{{ translations.get("TranscriptionItem_DownloadAsJson") }}</span>
    28                     </button>
    29 
    30                     <button @click="downloadAsWebvtt" type="button" class="btn-primary theme-flat">
    31                         <span class="material-icons">subtitles</span>
    32                         <span>{{ translations.get("TranscriptionItem_DownloadAsWebvtt") }}</span>
    33                     </button>
    34                 </div>
    35             </div>
    36 
    37             <button class="btn-primary theme-error" @click="remove" type="button">
    38                 <span class="material-icons">delete</span>
    39                 <span>{{ translations.get("TranscriptionItem_Remove") }}</span>
     12            <button @click="download(fileInfo)" type="button" class="btn-primary theme-flat">
     13                <span class="material-icons">download</span>
     14                <span>{{ translations.get("FileInput_Download") }}</span>
    4015            </button>
    4116        </div>
    4217
    43         <div class="editor-controls">
    44             <audio-time-bar v-model.number="currentPlaybackTime" :audio-length="audioLength" :isDisabled="playbackState.id != transcription.id" />
    45         </div>
     18        <hr class="divider-s" v-if="index !== fileInfos.length - 1" />
     19    </div>
     20</div>
    4621
    47         <hr />
     22<form ref="formDownload" method="post" target="_blank">
     23    <input ref="inputFilePath" type="hidden" name="filepath" />
     24    <input ref="inputFileName" type="hidden" name="filename" />
     25</form>
    4826
    49         <div class="editor-controls">
    50             <TranscriptionItemEditor ref="editor" :transcription="transcription" style="margin-bottom: 1em" :enableEditing="enableEditing" />
    51             <toggle-button v-model="enableEditing" :title="translations.get('TranscriptionItemEditor_ToggleEditTooltip')">
    52                 <span class="material-icons">edit</span>
    53             </toggle-button>
    54         </div>
    55     </div>
    5627</template>
    5728
    5829<style scoped lang="scss">
    59 .transcription__header {
     30.info-container {
    6031    display: grid;
    61     gap: 0.5em 0.5em;
    62     grid-template-columns: auto 1fr auto auto;
     32    grid-template-columns: auto 1fr auto;
    6333    align-items: center;
     34
     35    gap: 0.5em;
     36    padding: 0.5em;
     37    transition-duration: var(--transition-duration);
     38
     39    &:hover {
     40        background-color: #DDD;
     41    }
    6442}
    6543
    66 .download-popup {
    67     display: flex;
    68     flex-direction: column;
    69     align-items: stretch;
    70 
    71     position: absolute;
    72     top: 97%;
    73     z-index: 2;
    74 
    75     padding: 2px;
    76     margin: 0;
    77     width: 14em;
    78 
    79     transition-duration: var(--transition-duration);
    80     visibility: hidden;
    81     opacity: 0;
     44.file-info-list {
     45    border: 1px solid #BBB;
     46    margin-top: 1em;
    8247}
    8348
    84 .download-popup-show {
    85     visibility: visible;
    86     opacity: 1;
     49.mdi-override {
     50    @extend .mdi-l;
     51
     52    color: var(--bg-color);
    8753}
    8854
    89 .editor-controls {
    90     display: grid;
    91     align-items: flex-start;
    92     grid-template-columns: 1fr auto;
    93     margin: 0.5em 0 0.3em 0;
    94     gap: 1em;
    95 }
    96 
    97 .rotate-180 {
    98     transform: rotate(180deg);
     55.icon-l {
     56    height: 36px;
    9957}
    10058</style>
     
    10260<script>
    10361import { mapState } from "vuex";
    104 import { saveAs } from "file-saver"
    105 import { TranscriptionViewModel } from "../main";
    106 import AudioPlayback from "../js/AudioPlaybackModule"
    107 import Util from "../js/Util"
    108 import AudioTimeBar from "./AudioTimeBar.vue"
    109 import TranscriptionItemEditor from "./TranscriptionItemEditor.vue"
     62import MacronRestorationService from "../js/MacronRestorationModule"
     63import FileUpload from "./FileUpload.vue"
     64
     65const macronRestorer = new MacronRestorationService();
    11066
    11167export default {
    112     name: "TranscriptionItem",
     68    name: "FileInput",
    11369    components: {
    114         AudioTimeBar,
    115         TranscriptionItemEditor
    116     },
    117     props: {
    118         transcription: TranscriptionViewModel
    119     },
    120     data() {
    121         return {
    122             enableEditing: false,
    123             showDownloadOptions: false
    124         }
     70        FileUpload
    12571    },
    12672    computed: {
    127         currentPlaybackTime: {
    128             get() {
    129                 return this.$store.getters.transcriptionPlaybackTime(this.transcription.id);
    130             },
    131             set(value) {
    132                 this.$store.commit("playbackStateSetTime", { id: this.transcription.id, time: value });
    133             }
    134         },
    135         audioLength() {
    136             return this.$store.getters.transcriptionPlaybackLength(this.transcription.id);
    137         },
    138         isPlaying() {
    139             return this.playbackState.isPlaying && this.playbackState.id === this.transcription.id;
    140         },
    14173        ...mapState({
    14274            translations: state => state.translations,
    143             playbackState: state => state.playbackState
     75            fileInfos: state => state.macronisedFileInfos
    14476        })
    14577    },
    14678    methods: {
    147         async toggleAudio() {
    148             this.isPlaying ? AudioPlayback.pause() : await AudioPlayback.play(this.transcription.id, -1);
    149         },
    150         remove() {
    151             this.$store.commit("rawTranscriptionRemove", this.transcription.id);
    152         },
    153         downloadAsText() {
    154             const fileName = buildDownloadableFileName(this.transcription.fileName, "txt");
    155 
    156             const blob = new Blob([ this.$refs.editor.words.map(w => w.word).join(" ") ], { type: "text/plain;charset=utf-8" });
    157             saveAs(blob, fileName);
    158         },
    159         downloadAsJson() {
    160             const fileName = buildDownloadableFileName(this.transcription.fileName, "json");
    161             const toDownload = (({ fileName, transcription }) => ({ fileName, transcription }))(this.transcription);
    162             toDownload.words = this.$refs.editor.words.map(w => (({ word, startTime, endTime }) => ({ word, startTime, endTime }))(w));
    163 
    164             const blob = new Blob([ JSON.stringify(toDownload, null, 4) ], { type: "application/json;charset=utf-8" });
    165             saveAs(blob, fileName);
    166         },
    167         downloadAsWebvtt() {
    168             const fileName = buildDownloadableFileName(this.transcription.fileName, "vtt");
    169             const toDownload = buildWebvttFileContents(this.transcription, this.$refs.editor);
    170 
    171             const blob = new Blob([ toDownload ], { type: "text/vtt;charset=utf-8" });
    172             saveAs(blob, fileName);
     79        download(fileInfo) {
     80            this.$refs.inputFilePath.value = fileInfo.filePath;
     81            this.$refs.inputFileName.value = fileInfo.fileName;
     82            this.$refs.formDownload.submit();
    17383        }
     84    },
     85    mounted() {
     86        this.$refs.formDownload.setAttribute("action", macronRestorer.queryUrl + "Download");
    17487    }
    17588}
    176 
    177 /**
    178  * Builds a file name for a download.
    179  * @param {String} transcriptionFileName The name of the transcription that will be downloaded.
    180  * @param {String} extension The file extension of the download. Do not include a period.
    181  * @returns {String} The file name.
    182  */
    183 function buildDownloadableFileName(transcriptionFileName, extension) {
    184     const extensionIndex = transcriptionFileName.lastIndexOf(".");
    185     let fileName = transcriptionFileName.slice(0, extensionIndex);
    186     fileName += "_transcription." + extension;
    187 
    188     return fileName;
    189 }
    190 
    191 /**
    192  * Builds a WebVTT file of the given transcription
    193  * @param {TranscriptionViewModel} transcription The transcription.
    194  * @returns {String} The WebVTT content.
    195  */
    196 function buildWebvttFileContents(transcription, editor) {
    197     let contents = "WEBVTT Transcription of " + transcription.fileName + "\n\n";
    198 
    199     for (const word of editor.words) {
    200         const startTime = Util.formatSecondsTimeString(word.startTime, true);
    201         const endTime = Util.formatSecondsTimeString(word.endTime, true);
    202 
    203         contents += startTime + " --> " + endTime + "\n";
    204         contents += "- " + word.word + "\n\n";
    205     }
    206 
    207     return contents;
    208 }
    20989</script>
  • main/trunk/model-interfaces-dev/atea/macron-restoration/src/js/MacronRestorationModule.js

    r35716 r35723  
    3333        /** @type {String} The URL to which query POST requests should be made. */
    3434        if (process.env.NODE_ENV !== "production") {
    35             this.queryUrl = "//localhost:8383/macron-restoration/jsp/servlet/DirectInput";
     35            this.queryUrl = "//localhost:8383/macron-restoration/jsp/servlet/";
    3636        }
    3737        else {
    38             this.queryUrl = "/macron-restoration/jsp/servlet/DirectInput";
     38            this.queryUrl = "/macron-restoration/jsp/servlet/";
    3939        }
    4040    }
     
    4343     * Performs a request to the macron restoration API to restore the given input text.
    4444     * @param {String} input The input text to macronise
    45      * @returns The JSON response from the macron restoration API.
     45     * @returns {Promise<{ w: String, macronised?: Boolean }>} The JSON response from the macron restoration API.
    4646     */
    47     async directMacronisation(input) {
    48         const that = this;
     47    async directMacronisation(input, preserveExistingMacrons) {
     48        const queryUrl = this.queryUrl + "DirectInput";
    4949
    5050        try {
    5151            const response = await fetch(
    52                 that.queryUrl,
     52                queryUrl,
    5353                {
    5454                    method: "POST",
     
    5656                        "Content-Type": "application/x-www-form-urlencoded"
    5757                    },
    58                     body: `o=json&fragment=${input}`
     58                    body: `o=json&fragment=${input}&preserveExistingMacrons=${preserveExistingMacrons}`
    5959                }
    6060            );
     
    7474
    7575    /**
    76      * Performs chunked queries to transcribe the given audio files, returning the data in iterations.
    77      * Data is chunked according to which ever occurs first:
    78      * A maximum of three files per request, or;
    79      * A maximum of 5 MiB before chunking.
     76     * Performs a query to macronise the given file.
    8077     *
    81      * @param {FileList | File[]} files The files to upload
    82      * @returns {AsyncGenerator<TranscriptionModel[]>} The transcribed audio files.
    83      * @throws {TranscriptionError} When the transcription request fails to complete.
     78     * @param {File} file The file to macronise.
     79     * @param {Boolean} preserveExistingMacrons Indicates if existing macrons on the file should be preserved.
     80     * @returns {Promise<{ fileName: String, filePath: String, fileType: String }>} Information about the macronised file.
     81     * @throws {MacronRestorationError} When the macronisation request fails to complete.
    8482     */
    85     async* batchFileMacronisation(files) {
    86         let filesToSubmit = [];
    87         let fileCounter = 0;
    88         let byteCounter = 0;
    89 
    90         for (const file of files) {
    91             if (fileCounter === this.MAX_BATCH_COUNT || byteCounter > this.BATCH_BYTE_LIMIT) { // 5 MiB
    92                 yield await this.fileMacronisation(filesToSubmit);
    93                 filesToSubmit = [];
    94                 byteCounter = 0;
    95                 fileCounter = 0;
    96             }
    97 
    98             filesToSubmit[fileCounter++] = file;
    99             byteCounter += file.size;
    100         }
    101 
    102         if (filesToSubmit.length > 0) {
    103             yield await this.fileMacronisation(filesToSubmit);
    104         }
    105     }
    106 
    107     /**
    108      * Performs a query to transcribe the given audio files.
    109      *
    110      * @param {File[]} files The files to upload.
    111      * @returns {Promise<TranscriptionModel[]>} The transcribed audio file.
    112      * @throws {TranscriptionError} When the transcription request fails to complete.
    113      */
    114     async fileMacronisation(files) {
    115         if (process.env.NODE_ENV !== "production" || files.some(file => file.name === "akl_mi_pk_0002_offline.wav")) {
    116             return await this.fileMacronisationTest(files);
    117         }
    118         else {
    119             return await this.fileMacronisationActual(files);
    120         }
    121     }
    122 
    123     async fileMacronisationTest(files) {
    124         const objects = [];
    125 
    126         for (const file of files) {
    127             const object = JSON.parse(this.TEST_JSON);
    128             object.file_name = file.name;
    129             objects.push(object);
    130         }
    131 
    132         return objects;
    133     }
    134 
    135     /**
    136      * Performs a query to transcribe the given audio files.
    137      *
    138      * @param {FileList | File[]} files The files to upload.
    139      * @returns {Promise<TranscriptionModel[]>} The transcribed audio file.
    140      * @throws {TranscriptionError} When the transcription request fails to complete.
    141      */
    142     async fileMacronisationActual(files) {
    143         const that = this;
     83    async fileMacronisation(file, preserveExistingMacrons) {
     84        const queryUrl = this.queryUrl + "FileUpload";
    14485        const formData = new FormData();
    14586
    146         let audioFileKeys = "";
    147         for (let i = 0; i < files.length; i++) {
    148             const f = files[i];
    149             const key = "audioFile" + i;
    150 
    151             formData.append(key, f, f.name);
    152             audioFileKeys += key + "|";
    153         }
    154         formData.append("audioFileKeys", audioFileKeys);
     87        formData.append("o", "json");
     88        formData.append("preserveExistingMacrons", preserveExistingMacrons);
     89        formData.append(file.name, file, file.name);
    15590
    15691        try {
    15792            const response = await fetch(
    158                 that.queryUrl,
     93                queryUrl,
    15994                {
    16095                    method: "POST",
     
    16499
    165100            if (!response.ok) {
    166                 log(`Transcription API failed with status ${response.status} and message ${response.statusText}`, "error")
    167                 throw new MacronRestorationError(response.statusText, undefined, response.status);
     101                log(`Macronisation API failed with status ${response.status} and message ${response.statusText}`, "error")
     102                throw new MacronRestorationError(response.statusText, file.Name, response.status);
    168103            }
    169104
     
    171106        }
    172107        catch (e) {
    173             log(`Transcription failed with reason ${e}`, "error");
    174             throw new MacronRestorationError(undefined, "Unknown");
     108            log(`Macronisation failed with reason ${e}`, "error");
     109            throw new MacronRestorationError("Unknown", file.Name, "Unknown");
    175110        }
    176111    }
  • main/trunk/model-interfaces-dev/atea/macron-restoration/src/main.js

    r35714 r35723  
    11import { createApp } from "vue";
    22import { createStore } from "vuex"
    3 // import AudioPlayback from "./js/AudioPlaybackModule";
     3import Util from "./js/Util"
    44import App from "./App.vue";
    55import ToggleButton from "./components/ToggleButton.vue"
    6 
    7 // export class TranscriptionExistsError extends Error {
    8 //     constructor(message = "", ...args) {
    9 //         super(message, ...args);
    10 //     }
    11 // }
    126
    137const store = createStore({
     
    159        return {
    1610            /** @type {Map<String, String>} */
    17             translations: new Map()
     11            translations: new Map(),
     12            macronisedFileInfos: [],
     13            directInput: null,
     14            directOutput: []
    1815        }
    1916    },
     
    2118        setTranslations(state, translations) {
    2219            state.translations = translations;
     20        },
     21        pushFileInfo(state, fileInfo) {
     22            state.macronisedFileInfos.push({
     23                id: Util.generateUuid(),
     24                ...fileInfo
     25            });
     26        },
     27        setDirectInput(state, input) {
     28            state.directInput = input;
     29        },
     30        setDirectOutput(state, output) {
     31            state.directOutput = output;
    2332        }
    2433    }
    25     // getters: {
    26     //     hasTranscriptionOfFile: (state) => (file) => {
    27     //         const id = TranscriptionViewModel.getId(file);
    28     //         return state.rawTranscriptions.has(id);
    29     //     },
    30     // }
    3134});
    3235
  • main/trunk/model-interfaces-dev/atea/macron-restoration/src/styles/_material.scss

    r35718 r35723  
    6464}
    6565
     66.divider-s {
     67    @extend .divider;
     68    margin: 0 2rem;
     69}
     70
    6671/* Buttons */
    6772
     
    108113.text-container {
    109114    display: flex;
    110     align-items: flex-start;
    111115    flex-wrap: wrap;
    112116
     
    129133    border-radius: var(--border-radius) var(--border-radius) 0 0;
    130134    border: none;
    131     border-bottom: 1px solid #AAA;
     135    border-bottom: 2px solid #999;
    132136    outline: none;
    133137    cursor: text;
     
    163167    @extend .text-input;
    164168
     169    align-items: center;
    165170    overflow-x: auto;
    166171    overflow-y: hidden;
Note: See TracChangeset for help on using the changeset viewer.