<!-- SPDX-FileCopyrightText: syuilo and misskey-project SPDX-License-Identifier: AGPL-3.0-only --> <template> <div> <div :class="$style.label" @click="focus"><slot name="label"></slot></div> <div :class="{ [$style.disabled]: disabled, [$style.focused]: focused, [$style.tall]: tall, [$style.pre]: pre }" style="position: relative;"> <textarea ref="inputEl" v-model="v" v-adaptive-border :class="[$style.textarea, { _monospace: code }]" :disabled="disabled" :required="required" :readonly="readonly" :placeholder="placeholder" :pattern="pattern" :autocomplete="autocomplete" :spellcheck="spellcheck" @focus="focused = true" @blur="focused = false" @keydown="onKeydown($event)" @input="onInput" ></textarea> </div> <div :class="$style.caption"><slot name="caption"></slot></div> <button v-if="mfmPreview" style="font-size: 0.85em;" class="_textButton" type="button" @click="preview = !preview">{{ i18n.ts.preview }}</button> <div v-if="mfmPreview" v-show="preview" v-panel :class="$style.mfmPreview"> <Mfm :text="v"/> </div> <MkButton v-if="manualSave && changed" primary :class="$style.save" @click="updated"><i class="ti ti-device-floppy"></i> {{ i18n.ts.save }}</MkButton> </div> </template> <script lang="ts" setup> import { onMounted, onUnmounted, nextTick, ref, watch, computed, toRefs, shallowRef } from 'vue'; import { debounce } from 'throttle-debounce'; import MkButton from '@/components/MkButton.vue'; import { i18n } from '@/i18n.js'; import { Autocomplete, SuggestionType } from '@/scripts/autocomplete.js'; const props = defineProps<{ modelValue: string | null; required?: boolean; readonly?: boolean; disabled?: boolean; pattern?: string; placeholder?: string; autofocus?: boolean; autocomplete?: string; mfmAutocomplete?: boolean | SuggestionType[], mfmPreview?: boolean; spellcheck?: boolean; debounce?: boolean; manualSave?: boolean; code?: boolean; tall?: boolean; pre?: boolean; }>(); const emit = defineEmits<{ (ev: 'change', _ev: KeyboardEvent): void; (ev: 'keydown', _ev: KeyboardEvent): void; (ev: 'enter'): void; (ev: 'update:modelValue', value: string): void; }>(); const { modelValue, autofocus } = toRefs(props); const v = ref<string>(modelValue.value ?? ''); const focused = ref(false); const changed = ref(false); const invalid = ref(false); const filled = computed(() => v.value !== '' && v.value != null); const inputEl = shallowRef<HTMLTextAreaElement>(); const preview = ref(false); let autocompleteWorker: Autocomplete | null = null; const focus = () => inputEl.value?.focus(); const onInput = (ev) => { changed.value = true; emit('change', ev); }; const onKeydown = (ev: KeyboardEvent) => { if (ev.isComposing || ev.key === 'Process' || ev.keyCode === 229) return; emit('keydown', ev); if (ev.code === 'Enter') { emit('enter'); } if (props.code && ev.key === 'Tab') { const pos = inputEl.value?.selectionStart ?? 0; const posEnd = inputEl.value?.selectionEnd ?? v.value.length; v.value = v.value.slice(0, pos) + '\t' + v.value.slice(posEnd); nextTick(() => { inputEl.value?.setSelectionRange(pos + 1, pos + 1); }); ev.preventDefault(); } }; const updated = () => { changed.value = false; emit('update:modelValue', v.value ?? ''); }; const debouncedUpdated = debounce(1000, updated); watch(modelValue, newValue => { v.value = newValue ?? ''; }); watch(v, () => { if (!props.manualSave) { if (props.debounce) { debouncedUpdated(); } else { updated(); } } invalid.value = inputEl.value?.validity.badInput ?? true; }); onMounted(() => { nextTick(() => { if (autofocus.value) { focus(); } }); if (props.mfmAutocomplete && inputEl.value) { autocompleteWorker = new Autocomplete(inputEl.value, v, props.mfmAutocomplete === true ? undefined : props.mfmAutocomplete); } }); onUnmounted(() => { if (autocompleteWorker) { autocompleteWorker.detach(); } }); </script> <style lang="scss" module> .label { font-size: 0.85em; padding: 0 0 8px 0; user-select: none; &:empty { display: none; } } .caption { font-size: 0.85em; padding: 8px 0 0 0; color: var(--MI_THEME-fgTransparentWeak); &:empty { display: none; } } .textarea { appearance: none; -webkit-appearance: none; display: block; width: 100%; min-width: 100%; max-width: 100%; min-height: 130px; margin: 0; padding: 12px; font: inherit; font-weight: normal; font-size: 1em; color: var(--MI_THEME-fg); background: var(--MI_THEME-panel); border: solid 1px var(--MI_THEME-panel); border-radius: 6px; outline: none; box-shadow: none; box-sizing: border-box; transition: border-color 0.1s ease-out; &:hover { border-color: var(--MI_THEME-inputBorderHover) !important; } } .focused { > .textarea { border-color: var(--MI_THEME-accent) !important; } } .disabled { opacity: 0.7; cursor: not-allowed !important; > .textarea { cursor: not-allowed !important; } } .tall { > .textarea { min-height: 200px; } } .pre { > .textarea { white-space: pre; } } .save { margin: 8px 0 0 0; } .mfmPreview { padding: 12px; border-radius: var(--MI-radius); box-sizing: border-box; min-height: 130px; pointer-events: none; } </style>