mirror of
https://github.com/chenasraf/nextcloud-forum.git
synced 2026-05-17 17:28:02 +00:00
feat: bbcode toolbar
This commit is contained in:
271
src/components/BBCodeToolbar.vue
Normal file
271
src/components/BBCodeToolbar.vue
Normal 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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user