43c0ffe7f8
We use MfM in all sorts of places, and only some of them are actual blocks. We can now tell the `Mfm` component to make the top-level `<bdi>` a block when we need to (mostly note bodies, user descriptions, announcements) and leave it inline in all other places. This should still rendener inline rtl content embedded in ltr text in a sensible way, while providing right-alignment for fully rtl blocks.
234 lines
5.1 KiB
Vue
234 lines
5.1 KiB
Vue
<!--
|
|
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" :isBlock="true" />
|
|
</div>
|
|
|
|
<MkButton v-if="manualSave && changed" primary :class="$style.save" @click="updated"><i class="ph-floppy-disk ph-bold ph-lg"></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(--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(--fg);
|
|
background: var(--panel);
|
|
border: solid 1px var(--panel);
|
|
border-radius: var(--radius-sm);
|
|
outline: none;
|
|
box-shadow: none;
|
|
box-sizing: border-box;
|
|
transition: border-color 0.1s ease-out;
|
|
|
|
&:hover {
|
|
border-color: var(--inputBorderHover) !important;
|
|
}
|
|
}
|
|
|
|
.focused {
|
|
> .textarea {
|
|
border-color: var(--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(--radius);
|
|
box-sizing: border-box;
|
|
min-height: 130px;
|
|
pointer-events: none;
|
|
}
|
|
</style>
|