Skip to content
Snippets Groups Projects
Commit cd0e3642 authored by Kasper Seweryn's avatar Kasper Seweryn 🥞
Browse files

Add textarea

parent e251e660
No related branches found
No related tags found
1 merge request!1Implement all components
Pipeline #23545 failed with stages
in 10 minutes and 56 seconds
tasks:
- name: Frontend
init: yarn install
command: yarn dev
- name: Docs
init: yarn install
command: yarn docs:dev
vscode:
......
......@@ -29,8 +29,11 @@ export default defineConfig({
] },
{ text: "Activity", link: "/components/activity/" },
{ text: "Popover", link: "/components/popover/" },
{ text: "Input", link: "/components/input/" },
{ text: "Pagination", link: "/components/pagination/" },
{ text: "Form", items: [
{ text: "Input", link: "/components/input/" },
{ text: "Pagination", link: "/components/pagination/" },
{ text: "Textarea", link: "/components/textarea/" },
] },
]
}
]
......
<script setup lang="ts">
import { ref } from 'vue'
const text1 = ref('# Funk\nwhale')
const text2 = ref('# Funk\nwhale')
</script>
# Textarea
## Textarea model
```html
<fw-textarea v-model="text" />
```
<fw-textarea v-model="text1" />
## Textarea max length
```html
<fw-textarea v-model="text" :max="20" />
```
<fw-textarea v-model="text2" :max="20" />
......@@ -20,8 +20,9 @@ export { default as FwPopover } from './popover/Popover.vue'
// Loader
export { default as FwLoader } from './loader/Loader.vue'
// Inputs
// Form
export { default as FwPagination } from './pagination/Pagination.vue'
export { default as FwTextarea } from './textarea/Textarea.vue'
export { default as FwInput } from './input/Input.vue'
// Pills
......
......@@ -24,6 +24,10 @@
border-radius: var(--fw-border-radius);
box-shadow: inset 0 0 0 4px var(--fw-border-color);
&:hover {
--fw-border-color: var(--fw-grey-300);
}
&:active,
&:focus {
--fw-border-color: var(--fw-primary);
......
<script setup lang="ts">
import { useVModel, useTextareaAutosize, computedWithControl, useManualRefHistory, watchDebounced } from '@vueuse/core'
import { FwButton } from '~/components'
import { nextTick, computed, type ComputedRef } from 'vue'
interface Events {
(e: 'update:modelValue', value: string): void
}
interface Props {
modelValue: string
max?: number
}
const emit = defineEmits<Events>()
const props = withDefaults(defineProps<Props>(), { max: Infinity })
const value = useVModel(props, 'modelValue', emit)
const { undo, redo, commit, last } = useManualRefHistory(value)
const { textarea } = useTextareaAutosize({ input: value })
watchDebounced(value, (value) => {
if (value !== last.value.snapshot) {
commit()
}
}, { debounce: 300 })
const lineNumber = computedWithControl(
() => [textarea.value, value.value],
() => {
const { selectionStart } = textarea.value ?? {}
return value.value.slice(0, selectionStart).split('\n').length - 1
}
)
const updateLineNumber = () => setTimeout(lineNumber.trigger, 0)
const currentLine = computed({
get: () => value.value.split('\n')[lineNumber.value],
set: (line) => {
const content = value.value.split('\n')
content[lineNumber.value] = line
value.value = content.join('\n')
}
})
// Textarea manipulation
const splice = async (start: number, deleteCount: number, items?: string) => {
let { selectionStart, selectionEnd } = textarea.value
const lineBeginning = value.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('- '))
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> `))
// Inline operations
const inlineOperation = (chars: string) => async () => {
const { selectionStart, selectionEnd } = textarea.value
const lineBeginning = value.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 = value.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()
}
</script>
<template>
<div
class="funkwhale textarea"
>
<textarea
ref="textarea"
@click="updateLineNumber"
@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"
v-model="value"
id="textarea_id"
/>
<div class="textarea-buttons">
<fw-button icon="bi-eye" secondary />
<div class="separator" />
<fw-button @click="heading1" icon="bi-type-h1" secondary :is-active="isHeading1" />
<fw-button @click="heading2" icon="bi-type-h2" secondary :is-active="isHeading2" />
<fw-button @click="paragraph" icon="bi-paragraph" secondary :is-active="isParagraph" />
<fw-button @click="quote" icon="bi-quote" secondary :is-active="isQuote" />
<fw-button @click="orderedList" icon="bi-list-ol" secondary :is-active="isOrderedList" />
<fw-button @click="unorderedList" icon="bi-list-ul" secondary :is-active="isUnorderedList" />
<div class="separator" />
<fw-button @click="bold" icon="bi-type-bold" secondary />
<fw-button @click="italics" icon="bi-type-italic" secondary />
<fw-button @click="strikethrough" icon="bi-type-strikethrough" secondary />
<fw-button @click="link" icon="bi-link-45deg" secondary />
<span v-if="max !== Infinity" class="letter-count">{{ max - value.length }}</span>
</div>
</div>
</template>
<style lang="scss">
@import './style.scss'
</style>
.funkwhale {
&.textarea {
--fw-border-color: var(--fw-bg-color);
--fw-buttons-border-color: var(--fw-grey-400);
position: relative;
padding: 8px;
background-color: var(--fw-grey-100);
border-radius: var(--fw-border-radius);
box-shadow: inset 0 0 0 4px var(--fw-border-color);
html.dark & {
background-color: var(--fw-grey-900);
--fw-border-color: var(--fw-grey-900);
--fw-buttons-border-color: var(--fw-grey-800);
}
&:hover {
--fw-border-color: var(--fw-grey-300);
html.dark & {
--fw-border-color: var(--fw-grey-700);
}
}
&:focus-within {
--fw-border-color: var(--fw-primary) !important;
background-color: var(--fw-bg-color) !important;
> .textarea-buttons {
opacity: 1;
transform: translateY(0rem);
pointer-events: auto;
}
}
> textarea {
display: block;
width: 100%;
min-height: 240px;
padding: 8px 12px 40px;
font-family: $font-main;
background: transparent;
}
> .textarea-buttons {
display: flex;
align-items: center;
position: absolute;
bottom: 8px;
left: 8px;
right: 8px;
opacity: 0;
transform: translateY(.5rem);
pointer-events: none;
transition: all .2s ease;
padding-top: 4px;
border-top: 1px solid var(--fw-buttons-border-color);
> .funkwhale.button {
font-size: 1.2rem;
padding: 0.2em;
&:not(:hover):not(:active):not(.is-active) {
--fw-bg-color: transparent;
}
}
> .separator {
width: 1px;
height: 28px;
background-color: var(--fw-buttons-border-color);
margin: 0 8px;
}
> .letter-count {
margin-left: auto;
padding-right: 16px;
color: var(--fw-buttons-border-color);
}
}
}
}
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment