Created
January 5, 2026 20:38
-
-
Save rankill/cb833b30f8d797156414a862f47ab896 to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| <template> | |
| <div | |
| ref="richEditorEl" | |
| :class="{ | |
| 'RichEditor--editing': isEditing, | |
| 'RichEditor--preview-enabled': previewEnabled, | |
| }" | |
| class="RichEditor" | |
| role="button" | |
| tabindex="0" | |
| @click.stop="updateEditState($event, true)" | |
| @mousedown.stop="updateEditState($event, true)" | |
| @keydown.enter.exact="!isEditing ? updateEditState($event, true) : null" | |
| @keydown.esc.exact.prevent="updateEditState($event, false)" | |
| > | |
| <slot name="header"></slot> | |
| <div :class="classContent" class="RichEditor__content"> | |
| <textarea | |
| v-show="!isEditing && !computedValue?.length" | |
| :readonly="!isEditing" | |
| :value="persistText && input.length ? input : defaultValue" | |
| class="RichEditor__content__placeholder" | |
| ></textarea> | |
| <textarea | |
| v-show="isEditing && !previewEnabled" | |
| ref="textareaElRef" | |
| v-model="input" | |
| :placeholder="defaultValue" | |
| :readonly="!isEditing" | |
| class="RichEditor__content__text" | |
| rows="1" | |
| ></textarea> | |
| <Markdown | |
| v-show="(input?.length && !isEditing) || previewEnabled" | |
| :source=" | |
| input?.length ? input : t('ui.components.rich_editor.nothing_preview') | |
| " | |
| class="RichEditor__content__preview" | |
| > | |
| </Markdown> | |
| <div | |
| v-if="isEditing" | |
| :class="'RichEditor__content__toggle--' + previewPosition" | |
| class="RichEditor__content__toggle" | |
| > | |
| <p class="text-tiny text-medium capitalize"> | |
| {{ t('ui.components.rich_editor.preview') }} | |
| </p> | |
| <Toggle v-model="previewEnabled" /> | |
| </div> | |
| </div> | |
| <slot name="footer"></slot> | |
| </div> | |
| </template> | |
| <script lang="ts" setup> | |
| import { | |
| computed, | |
| defineEmits, | |
| defineExpose, | |
| defineProps, | |
| nextTick, | |
| onMounted, | |
| ref, | |
| } from 'vue'; | |
| import { useI18n } from 'vue-i18n'; | |
| import { | |
| onClickOutside, | |
| onStartTyping, | |
| useFocus, | |
| useTextareaAutosize, | |
| } from '@vueuse/core'; | |
| import Markdown from '@/ui/components/Markdown.vue'; | |
| import Toggle from '@/ui/components/base/Toggle.vue'; | |
| const props = defineProps({ | |
| modelValue: { | |
| type: String, | |
| default: () => '', | |
| }, | |
| previewPosition: { | |
| type: String, | |
| default: () => 'bottom-left', | |
| validator: (value: string) => | |
| ['top-left', 'top-right', 'bottom-left', 'bottom-right'].includes(value), | |
| }, | |
| defaultValue: { | |
| type: String, | |
| default: () => 'Add text', | |
| }, | |
| persistText: { | |
| type: Boolean, | |
| default: () => false, | |
| }, | |
| classContent: { | |
| type: String, | |
| default: () => 'text-small text-regular', | |
| }, | |
| }); | |
| const emit = defineEmits(['update:modelValue', 'editing', 'closed']); | |
| const { t } = useI18n(); | |
| const richEditorEl = ref<HTMLElement>(); | |
| const textareaElRef = ref<HTMLTextAreaElement>(); | |
| const isEditing = ref<boolean>(false); | |
| const previewEnabled = ref<boolean>(false); | |
| const computedValue = computed({ | |
| get(): string | undefined { | |
| return props.modelValue; | |
| }, | |
| set(value: string | undefined) { | |
| emit('update:modelValue', value); | |
| }, | |
| }); | |
| const { textarea, input, triggerResize } = useTextareaAutosize({ | |
| input: computedValue, | |
| element: textareaElRef, | |
| }); | |
| const { focused } = useFocus(textareaElRef); | |
| const updateEditState = async (event: Event, state: boolean) => { | |
| if (!isEditing.value && event.target instanceof HTMLAnchorElement) { | |
| return; | |
| } | |
| isEditing.value = state; | |
| setTimeout(() => triggerResize()); | |
| if (state) { | |
| emit('editing', state); | |
| await nextTick(); | |
| focused.value = true; | |
| } else { | |
| previewEnabled.value = false; | |
| emit('closed'); | |
| await nextTick(); | |
| focused.value = false; | |
| } | |
| }; | |
| const close = (event: Event) => { | |
| updateEditState(event, false); | |
| }; | |
| onStartTyping(() => { | |
| if (isEditing.value && textarea.value) { | |
| focused.value = true; | |
| } | |
| }); | |
| onClickOutside(richEditorEl, (event: Event) => { | |
| close(event); | |
| }); | |
| onMounted(() => { | |
| setTimeout(() => { | |
| triggerResize(); | |
| }); | |
| }); | |
| defineExpose({ close }); | |
| </script> | |
| <style lang="scss" scoped> | |
| .RichEditor { | |
| display: flex; | |
| flex-direction: column; | |
| word-break: break-word; | |
| &--editing { | |
| &:not(.RichEditor--preview-enabled) textarea { | |
| cursor: text; | |
| } | |
| .RichEditor__content { | |
| background-color: var(--grey-800); | |
| border: 1px solid var(--grey-500); | |
| &__text { | |
| resize: none; | |
| } | |
| } | |
| } | |
| &__content { | |
| display: flex; | |
| padding: 4px; | |
| position: relative; | |
| border-radius: 2px; | |
| border: 1px solid var(--grey-600); | |
| flex: auto; | |
| min-height: 3.6rem; | |
| &__text, | |
| &__preview, | |
| &__placeholder { | |
| width: 100%; | |
| min-height: 100%; | |
| outline: none; | |
| border: none; | |
| } | |
| &__placeholder { | |
| color: var(--grey-400); | |
| } | |
| &__text, | |
| &__placeholder { | |
| resize: none; | |
| white-space: pre-wrap; | |
| word-wrap: break-word; | |
| &::-webkit-input-placeholder { | |
| color: var(--grey-400); | |
| } | |
| } | |
| &__toggle { | |
| display: flex; | |
| align-items: center; | |
| gap: 0.4rem; | |
| position: absolute; | |
| &--top { | |
| &-left { | |
| margin-bottom: 0.8rem; | |
| bottom: 100%; | |
| left: 0; | |
| } | |
| &-right { | |
| margin-bottom: 0.8rem; | |
| bottom: 100%; | |
| right: 0; | |
| } | |
| } | |
| &--bottom { | |
| &-left { | |
| margin-top: 0.8rem; | |
| top: 100%; | |
| left: 0; | |
| } | |
| &-right { | |
| margin-top: 0.8rem; | |
| top: 100%; | |
| right: 0; | |
| } | |
| } | |
| } | |
| } | |
| } | |
| </style> |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment