feat: bbcode toolbar

This commit is contained in:
2025-11-10 23:31:31 +02:00
parent 1ea03d5f65
commit 72dbf9b349
4 changed files with 388 additions and 20 deletions

View File

@@ -0,0 +1,271 @@
<template>
<div class="bbcode-toolbar">
<NcButton
v-for="button in bbcodeButtons"
:key="button.tag"
type="tertiary"
:aria-label="button.label"
:title="button.label"
@click="insertBBCode(button)"
class="bbcode-button"
>
<template #icon>
<component :is="button.icon" :size="20" />
</template>
</NcButton>
</div>
</template>
<script lang="ts">
import { defineComponent, type PropType } from 'vue'
import NcButton from '@nextcloud/vue/components/NcButton'
import FormatBoldIcon from '@icons/FormatBold.vue'
import FormatItalicIcon from '@icons/FormatItalic.vue'
import FormatStrikethroughIcon from '@icons/FormatStrikethrough.vue'
import FormatUnderlineIcon from '@icons/FormatUnderline.vue'
import CodeTagsIcon from '@icons/CodeTags.vue'
import EmailIcon from '@icons/Email.vue'
import LinkIcon from '@icons/Link.vue'
import ImageIcon from '@icons/Image.vue'
import FormatQuoteCloseIcon from '@icons/FormatQuoteClose.vue'
import YoutubeIcon from '@icons/Youtube.vue'
import FormatFontIcon from '@icons/FormatFont.vue'
import FormatSizeIcon from '@icons/FormatSize.vue'
import FormatColorFillIcon from '@icons/FormatColorFill.vue'
import FormatAlignLeftIcon from '@icons/FormatAlignLeft.vue'
import FormatAlignCenterIcon from '@icons/FormatAlignCenter.vue'
import FormatAlignRightIcon from '@icons/FormatAlignRight.vue'
import EyeOffIcon from '@icons/EyeOff.vue'
import FormatListBulletedIcon from '@icons/FormatListBulleted.vue'
interface BBCodeButton {
tag: string
label: string
icon: any
template: string
hasValue?: boolean
placeholder?: string
promptForContent?: boolean
contentPlaceholder?: string
}
export default defineComponent({
name: 'BBCodeToolbar',
components: {
NcButton,
},
props: {
textareaRef: {
type: Object as PropType<HTMLTextAreaElement | null>,
default: null,
},
},
emits: ['insert'],
data() {
return {
bbcodeButtons: [
{
tag: 'b',
label: 'Bold',
icon: FormatBoldIcon,
template: '[b]{text}[/b]',
},
{
tag: 'i',
label: 'Italic',
icon: FormatItalicIcon,
template: '[i]{text}[/i]',
},
{
tag: 'u',
label: 'Underline',
icon: FormatUnderlineIcon,
template: '[u]{text}[/u]',
},
{
tag: 's',
label: 'Strikethrough',
icon: FormatStrikethroughIcon,
template: '[s]{text}[/s]',
},
{
tag: 'code',
label: 'Code',
icon: CodeTagsIcon,
template: '[code]{text}[/code]',
},
{
tag: 'quote',
label: 'Quote',
icon: FormatQuoteCloseIcon,
template: '[quote]{text}[/quote]',
},
{
tag: 'url',
label: 'Link',
icon: LinkIcon,
template: '[url={value}]{text}[/url]',
hasValue: true,
placeholder: 'http://example.com',
promptForContent: true,
contentPlaceholder: 'Link text',
},
{
tag: 'email',
label: 'Email',
icon: EmailIcon,
template: '[email]{text}[/email]',
promptForContent: true,
contentPlaceholder: 'test@example.com',
},
{
tag: 'img',
label: 'Image',
icon: ImageIcon,
template: '[img]{text}[/img]',
promptForContent: true,
contentPlaceholder: 'http://example.com/image.png',
},
{
tag: 'youtube',
label: 'YouTube',
icon: YoutubeIcon,
template: '[youtube]{text}[/youtube]',
promptForContent: true,
contentPlaceholder: 'video-id',
},
{
tag: 'list',
label: 'List',
icon: FormatListBulletedIcon,
template: '[list]\n[*]{text}\n[/list]',
},
{
tag: 'color',
label: 'Color',
icon: FormatColorFillIcon,
template: '[color={value}]{text}[/color]',
hasValue: true,
placeholder: 'red',
},
{
tag: 'size',
label: 'Size',
icon: FormatSizeIcon,
template: '[size={value}]{text}[/size]',
hasValue: true,
placeholder: '12',
},
{
tag: 'font',
label: 'Font',
icon: FormatFontIcon,
template: '[font={value}]{text}[/font]',
hasValue: true,
placeholder: 'Arial',
},
{
tag: 'left',
label: 'Align Left',
icon: FormatAlignLeftIcon,
template: '[left]{text}[/left]',
},
{
tag: 'center',
label: 'Align Center',
icon: FormatAlignCenterIcon,
template: '[center]{text}[/center]',
},
{
tag: 'right',
label: 'Align Right',
icon: FormatAlignRightIcon,
template: '[right]{text}[/right]',
},
{
tag: 'spoiler',
label: 'Spoiler',
icon: EyeOffIcon,
template: '[spoiler]{text}[/spoiler]',
},
] as BBCodeButton[],
}
},
methods: {
insertBBCode(button: BBCodeButton): void {
if (!this.textareaRef) {
return
}
const textarea = this.textareaRef
const start = textarea.selectionStart
const end = textarea.selectionEnd
const selectedText = textarea.value.substring(start, end)
const beforeText = textarea.value.substring(0, start)
const afterText = textarea.value.substring(end)
let insertText = ''
let value = ''
let contentText = selectedText
// If the button requires a value (like url, color, size, font), prompt the user
if (button.hasValue) {
// eslint-disable-next-line no-alert
value = prompt(`Enter ${button.label} value:`, button.placeholder || '') || ''
if (!value) {
return
}
}
// If no text is selected and button needs content prompt, ask for it
if (!selectedText && button.promptForContent) {
// eslint-disable-next-line no-alert
contentText =
prompt(`Enter ${button.label} content:`, button.contentPlaceholder || '') || ''
if (!contentText) {
return
}
}
// Generate the BBCode text
insertText = button.template
.replace('{value}', value)
.replace('{text}', contentText || button.placeholder || '')
// Calculate new cursor position
const newText = beforeText + insertText + afterText
const cursorPos = beforeText.length + insertText.length
// Emit the insert event so the parent can update the model
this.$emit('insert', {
text: newText,
cursorPos,
selectedText,
})
// Focus the textarea after insertion
this.$nextTick(() => {
textarea.focus()
textarea.setSelectionRange(cursorPos, cursorPos)
})
},
},
})
</script>
<style scoped lang="scss">
.bbcode-toolbar {
display: flex;
flex-wrap: wrap;
gap: 4px;
}
.bbcode-button {
min-width: auto !important;
padding: 4px 8px !important;
&:hover {
background: var(--color-background-dark);
}
}
</style>

View File

@@ -1,5 +1,7 @@
<template>
<div class="post-edit-form">
<BBCodeToolbar :textarea-ref="textareaElement" @insert="handleBBCodeInsert" />
<NcTextArea
v-model="content"
:placeholder="strings.placeholder"
@@ -45,6 +47,7 @@ import NcLoadingIcon from '@nextcloud/vue/components/NcLoadingIcon'
import NcTextArea from '@nextcloud/vue/components/NcTextArea'
import HelpCircleIcon from '@icons/HelpCircle.vue'
import BBCodeHelpDialog from './BBCodeHelpDialog.vue'
import BBCodeToolbar from './BBCodeToolbar.vue'
import { t } from '@nextcloud/l10n'
export default defineComponent({
@@ -55,6 +58,7 @@ export default defineComponent({
NcTextArea,
HelpCircleIcon,
BBCodeHelpDialog,
BBCodeToolbar,
},
props: {
initialContent: {
@@ -68,6 +72,7 @@ export default defineComponent({
content: this.initialContent,
submitting: false,
showHelp: false,
textareaElement: null as HTMLTextAreaElement | null,
strings: {
placeholder: t('forum', 'Edit your post...'),
cancel: t('forum', 'Cancel'),
@@ -77,6 +82,14 @@ export default defineComponent({
},
}
},
mounted() {
// Get reference to the actual textarea DOM element
this.updateTextareaRef()
},
updated() {
// Update textarea ref if it changes
this.updateTextareaRef()
},
computed: {
canSubmit(): boolean {
return this.content.trim().length > 0 && this.content !== this.initialContent
@@ -118,6 +131,19 @@ export default defineComponent({
textarea.$el.querySelector('textarea').focus()
}
},
updateTextareaRef(): void {
const textarea = this.$refs.textarea as any
if (textarea?.$el?.querySelector('textarea')) {
this.textareaElement = textarea.$el.querySelector('textarea')
}
},
handleBBCodeInsert(data: { text: string; cursorPos: number }): void {
// Update the content with the new text
this.content = data.text
// The cursor position is handled by the BBCodeToolbar component
},
},
})
</script>

View File

@@ -9,16 +9,19 @@
</div>
<div class="reply-body">
<NcTextArea
v-model="content"
:placeholder="strings.placeholder"
:rows="4"
:disabled="submitting"
@keydown.ctrl.enter="submitReply"
@keydown.meta.enter="submitReply"
class="reply-textarea"
ref="textarea"
/>
<div class="reply-textarea-container">
<BBCodeToolbar :textarea-ref="textareaElement" @insert="handleBBCodeInsert" />
<NcTextArea
v-model="content"
:placeholder="strings.placeholder"
:rows="4"
:disabled="submitting"
@keydown.ctrl.enter="submitReply"
@keydown.meta.enter="submitReply"
class="reply-textarea"
ref="textarea"
/>
</div>
<div class="reply-footer">
<div class="reply-footer-left">
@@ -58,6 +61,7 @@ import NcTextArea from '@nextcloud/vue/components/NcTextArea'
import HelpCircleIcon from '@icons/HelpCircle.vue'
import SendIcon from '@icons/Send.vue'
import BBCodeHelpDialog from './BBCodeHelpDialog.vue'
import BBCodeToolbar from './BBCodeToolbar.vue'
import { t } from '@nextcloud/l10n'
import { useCurrentUser } from '@/composables/useCurrentUser'
@@ -71,6 +75,7 @@ export default defineComponent({
HelpCircleIcon,
SendIcon,
BBCodeHelpDialog,
BBCodeToolbar,
},
emits: ['submit', 'cancel'],
setup() {
@@ -86,6 +91,7 @@ export default defineComponent({
content: '',
submitting: false,
showHelp: false,
textareaElement: null as HTMLTextAreaElement | null,
strings: {
placeholder: t('forum', 'Write your reply...'),
cancel: t('forum', 'Cancel'),
@@ -95,6 +101,14 @@ export default defineComponent({
},
}
},
mounted() {
// Get reference to the actual textarea DOM element
this.updateTextareaRef()
},
updated() {
// Update textarea ref if it changes
this.updateTextareaRef()
},
computed: {
canSubmit(): boolean {
return this.content.trim().length > 0
@@ -147,6 +161,19 @@ export default defineComponent({
// Set the textarea content with a quoted version of the provided content
this.content = `[quote]${contentRaw}[/quote]\n`
},
updateTextareaRef(): void {
const textarea = this.$refs.textarea as any
if (textarea?.$el?.querySelector('textarea')) {
this.textareaElement = textarea.$el.querySelector('textarea')
}
},
handleBBCodeInsert(data: { text: string; cursorPos: number }): void {
// Update the content with the new text
this.content = data.text
// The cursor position is handled by the BBCodeToolbar component
},
},
})
</script>
@@ -182,9 +209,17 @@ export default defineComponent({
gap: 12px;
}
.reply-textarea-container {
background: var(--color-background-hover);
border: 1px solid var(--color-border);
border-radius: 6px;
padding: 4px;
}
.reply-textarea {
min-height: 6.125rem;
resize: vertical;
margin-top: 0;
:global(.textarea__main-wrapper),
textarea {

View File

@@ -18,16 +18,20 @@
class="title-input"
/>
<NcTextArea
v-model="content"
:placeholder="strings.contentPlaceholder"
:rows="6"
:disabled="submitting"
@keydown.ctrl.enter="submitThread"
@keydown.meta.enter="submitThread"
class="content-textarea"
ref="contentTextarea"
/>
<div class="content-textarea-container">
<BBCodeToolbar :textarea-ref="textareaElement" @insert="handleBBCodeInsert" />
<NcTextArea
v-model="content"
:placeholder="strings.contentPlaceholder"
:rows="6"
:disabled="submitting"
@keydown.ctrl.enter="submitThread"
@keydown.meta.enter="submitThread"
class="content-textarea"
ref="contentTextarea"
/>
</div>
<div class="form-footer">
<div class="form-footer-left">
@@ -68,6 +72,7 @@ import NcTextField from '@nextcloud/vue/components/NcTextField'
import HelpCircleIcon from '@icons/HelpCircle.vue'
import CheckIcon from '@icons/Check.vue'
import BBCodeHelpDialog from './BBCodeHelpDialog.vue'
import BBCodeToolbar from './BBCodeToolbar.vue'
import { t } from '@nextcloud/l10n'
import { useCurrentUser } from '@/composables/useCurrentUser'
@@ -82,6 +87,7 @@ export default defineComponent({
HelpCircleIcon,
CheckIcon,
BBCodeHelpDialog,
BBCodeToolbar,
},
emits: ['submit', 'cancel'],
setup() {
@@ -98,6 +104,7 @@ export default defineComponent({
content: '',
submitting: false,
showHelp: false,
textareaElement: null as HTMLTextAreaElement | null,
strings: {
titleLabel: t('forum', 'Title'),
titlePlaceholder: t('forum', 'Enter thread title...'),
@@ -109,6 +116,14 @@ export default defineComponent({
},
}
},
mounted() {
// Get reference to the actual textarea DOM element
this.updateTextareaRef()
},
updated() {
// Update textarea ref if it changes
this.updateTextareaRef()
},
computed: {
canSubmit(): boolean {
return this.title.trim().length > 0 && this.content.trim().length > 0
@@ -161,6 +176,19 @@ export default defineComponent({
textarea.$el.querySelector('textarea').focus()
}
},
updateTextareaRef(): void {
const textarea = this.$refs.contentTextarea as any
if (textarea?.$el?.querySelector('textarea')) {
this.textareaElement = textarea.$el.querySelector('textarea')
}
},
handleBBCodeInsert(data: { text: string; cursorPos: number }): void {
// Update the content with the new text
this.content = data.text
// The cursor position is handled by the BBCodeToolbar component
},
},
})
</script>
@@ -200,9 +228,17 @@ export default defineComponent({
}
}
.content-textarea-container {
background: var(--color-background-hover);
border: 1px solid var(--color-border);
border-radius: 6px;
padding: 4px;
}
.content-textarea {
min-height: 8rem;
resize: vertical;
margin-top: 0;
:global(.textarea__main-wrapper),
textarea {