funkwhale/front/src/components/ui/Textarea.vue

228 lines
8.4 KiB
Vue

<script setup lang="ts">
import { nextTick, computed, ref, type ComputedRef } from 'vue'
import { useTextareaAutosize, computedWithControl, useManualRefHistory, watchDebounced } from '@vueuse/core'
import Button from './Button.vue'
import Markdown from './Markdown.vue'
import Layout from '~/components/ui/Layout.vue'
const { max=Infinity, placeholder='', ...restProps } = defineProps<{ max?:number,placeholder?:string, label?:string }>()
const model = defineModel<string>({ default: '' })
const { undo, redo, commit: commitHistory, last } = useManualRefHistory(model)
const { textarea, triggerResize } = useTextareaAutosize({ input: model })
const commit = () => {
triggerResize()
commitHistory()
}
const preview = ref(false)
watchDebounced(model, (value) => {
if (value !== last.value.snapshot) {
commit()
}
}, { debounce: 300 })
const lineNumber = computedWithControl(
() => [textarea.value, model],
() => {
const { selectionStart } = textarea.value ?? {}
return model.value.slice(0, selectionStart).split('\n').length - 1
}
)
const updateLineNumber = () => setTimeout(lineNumber.trigger, 0)
const currentLine = computed({
get: () => model.value.split('\n')[lineNumber.value],
set: (line) => {
const content = model.value.split('\n')
content[lineNumber.value] = line
model.value = content.join('\n')
}
})
// Textarea manipulation
const splice = async (start: number, deleteCount: number, items?: string) => {
let { selectionStart, selectionEnd } = textarea.value
const lineBeginning = model.value.slice(0, selectionStart).lastIndexOf('\n') + 1
let lineStart = selectionStart - lineBeginning
let lineEnd = selectionEnd - lineBeginning
const text = currentLine.value.split('')
text.splice(start, deleteCount, items ?? '')
currentLine.value = text.join('')
if (start <= lineStart) {
lineStart += items?.length ?? 0
lineStart -= deleteCount
}
if (start <= lineEnd) {
lineEnd += items?.length ?? 0
lineEnd -= deleteCount
}
selectionStart = lineBeginning + Math.max(0, lineStart)
selectionEnd = lineBeginning + Math.max(0, lineEnd)
textarea.value.focus()
await nextTick()
textarea.value.setSelectionRange(selectionStart, selectionEnd)
}
const newLineOperations = new Map<RegExp, (event: KeyboardEvent, line: string, groups: string[]) => void>()
const newline = async (event: KeyboardEvent) => {
const line = currentLine.value
for (const regexp of newLineOperations.keys()) {
const matches = line.match(regexp) ?? []
if (matches.length > 0) {
newLineOperations.get(regexp)?.(event, line, matches.slice(1))
}
}
}
// Conditions
const isHeading1 = computed(() => currentLine.value.startsWith('# '))
const isHeading2 = computed(() => currentLine.value.startsWith('## '))
const isQuote = computed(() => currentLine.value.startsWith('> '))
const isUnorderedList = computed(() => currentLine.value.startsWith('- ') || currentLine.value.startsWith('* '))
const isOrderedList = computed(() => /^\d+\. /.test(currentLine.value))
const isParagraph = computed(() => !isHeading1.value && !isHeading2.value && !isQuote.value && !isUnorderedList.value && !isOrderedList.value)
// Prefix operations
const paragraph = async (shouldCommit = true) => {
if (isHeading1.value || isQuote.value || isUnorderedList.value) {
await splice(0, 2)
if (shouldCommit) commit()
return
}
if (isHeading2.value || isOrderedList.value) {
await splice(0, 3)
if (shouldCommit) commit()
return
}
}
const prefixOperation = (prefix: string, condition?: ComputedRef<boolean>) => async () => {
if (condition?.value) {
return paragraph()
}
await paragraph(false)
await splice(0, 0, prefix)
return commit()
}
const heading1 = prefixOperation('# ', isHeading1)
const heading2 = prefixOperation('## ', isHeading2)
const quote = prefixOperation('> ', isQuote)
const orderedList = prefixOperation('1. ', isOrderedList)
const unorderedList = prefixOperation('- ', isUnorderedList)
// Newline operations
const newlineOperation = (regexp: RegExp, newLineHandler: (line: string, groups: string[]) => Promise<void> | void) => {
newLineOperations.set(regexp, async (event, line, groups) => {
event.preventDefault()
if (new RegExp(regexp.toString().slice(1, -1) + '$').test(line)) {
return paragraph()
}
await newLineHandler(line, groups)
lineNumber.trigger()
return commit()
})
}
newlineOperation(/^(\d+)\. /, (line, [lastNumber]) => splice(line.length, 0, `\n${+lastNumber + 1}. `))
newlineOperation(/^- /, (line) => splice(line.length, 0, `\n- `))
newlineOperation(/^> /, (line) => splice(line.length, 0, `\n> `))
newlineOperation(/^\* /, (line) => splice(line.length, 0, `\n* `))
// Inline operations
const inlineOperation = (chars: string) => async () => {
const { selectionStart, selectionEnd } = textarea.value
const lineBeginning = model.value.slice(0, selectionStart).lastIndexOf('\n') + 1
await splice(selectionStart - lineBeginning, 0, chars)
await splice(selectionEnd - lineBeginning + chars.length, 0, chars)
const start = selectionStart === selectionEnd
? selectionStart + chars.length
: selectionEnd + chars.length * 2
textarea.value.setSelectionRange(start, start)
return commit()
}
const bold = inlineOperation('**')
const italics = inlineOperation('_')
const strikethrough = inlineOperation('~~')
const link = async () => {
const { selectionStart, selectionEnd } = textarea.value
const lineBeginning = model.value.slice(0, selectionStart).lastIndexOf('\n') + 1
await splice(selectionStart - lineBeginning, 0, '[')
await splice(selectionEnd - lineBeginning + 1, 0, '](url)')
textarea.value.setSelectionRange(selectionEnd + 3, selectionEnd + 6)
return commit()
}
// Fix focus
const focus = () => textarea.value.focus()
</script>
<template>
<Layout stack no-gap label
class="funkwhale textarea-label"
>
<span v-if="$slots['label']" class="label">
<slot name="label" />
</span>
<span v-if="restProps.label" class="label">
{{ restProps.label }}
</span>
<div :class="{ 'has-preview': preview }" class="funkwhale textarea" @mousedown.prevent="focus" @mouseup.prevent="focus">
<Markdown :md="model" class="preview" />
<textarea ref="textarea" @click="updateLineNumber" @mousedown.stop @mouseup.stop @keydown.left="updateLineNumber"
@keydown.right="updateLineNumber" @keydown.up="updateLineNumber" @keydown.down="updateLineNumber"
@keydown.enter="newline" @keydown.ctrl.shift.z.exact.prevent="redo" @keydown.ctrl.z.exact.prevent="undo"
@keydown.ctrl.b.exact.prevent="bold" @keydown.ctrl.i.exact.prevent="italics"
@keydown.ctrl.shift.x.exact.prevent="strikethrough" @keydown.ctrl.k.exact.prevent="link" :maxlength="max"
:placeholder="placeholder" v-model="model" id="textarea_id" />
<div class="textarea-buttons">
<Button ghost @click="preview = !preview" icon="bi-eye" color="secondary" :aria-pressed="preview || undefined" />
<div class="separator" />
<Button ghost @click="heading1" icon="bi-type-h1" color="secondary" :aria-pressed="isHeading1 || undefined" :disabled="preview" />
<Button ghost @click="heading2" icon="bi-type-h2" color="secondary" :aria-pressed="isHeading2 || undefined" :disabled="preview" />
<Button ghost @click="paragraph" icon="bi-paragraph" color="secondary" :aria-pressed="isParagraph || undefined" :disabled="preview" />
<Button ghost @click="quote" icon="bi-quote" color="secondary" :aria-pressed="isQuote || undefined" :disabled="preview" />
<Button ghost @click="orderedList" icon="bi-list-ol" color="secondary" :aria-pressed="isOrderedList || undefined"
:disabled="preview" />
<Button ghost @click="unorderedList" icon="bi-list-ul" color="secondary" :aria-pressed="isUnorderedList || undefined"
:disabled="preview" />
<div class="separator" />
<Button ghost @click="bold" icon="bi-type-bold" color="secondary" :disabled="preview" />
<Button ghost @click="italics" icon="bi-type-italic" color="secondary" :disabled="preview" />
<Button ghost @click="strikethrough" icon="bi-type-strikethrough" color="secondary" :disabled="preview" />
<Button ghost @click="link" icon="bi-link-45deg" color="secondary" :disabled="preview" />
<span v-if="max !== Infinity && typeof max === 'number'" class="letter-count">{{ max - model.length }}</span>
</div>
</div>
</Layout>
</template>
<style lang="scss">
@import './textarea.scss';
</style>