| | |
| | | <script setup lang="ts"> |
| | | import type { AiEditor as AiEditorInstance, AiEditorOptions, Uploader } from 'aieditor' |
| | | import { nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue' |
| | | import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue' |
| | | import { AiEditor } from 'aieditor' |
| | | import 'aieditor/dist/style.css' |
| | | import { uploadSingleFile } from '@/utils/upload' |
| | |
| | | name: 'AiRichTextEditor', |
| | | }) |
| | | |
| | | interface Props { |
| | | disabled?: boolean |
| | | readOnly?: boolean |
| | | value?: string |
| | | placeholder?: string |
| | | height?: string |
| | | toolbarKeys?: AiEditorOptions['toolbarKeys'] |
| | | } |
| | | |
| | | const props = withDefaults(defineProps<Props>(), { |
| | | disabled: false, |
| | | readOnly: false, |
| | | value: '', |
| | | maxLength: 20000, |
| | | placeholder: '请输入内容...', |
| | | height: '360px', |
| | | toolbarKeys: () => [ |
| | |
| | | 'heading', |
| | | 'font-family', |
| | | 'font-size', |
| | | 'font-color', |
| | | 'divider', |
| | | 'bold', |
| | | 'italic', |
| | |
| | | 'align', |
| | | 'bullet-list', |
| | | 'ordered-list', |
| | | 'indent-decrease', |
| | | 'indent-increase', |
| | | 'divider', |
| | | 'image', |
| | | 'video', |
| | | 'attachment', |
| | | 'quote', |
| | | 'table', |
| | | 'fullscreen', |
| | | ], |
| | |
| | | ready: [editor: AiEditorInstance] |
| | | }>() |
| | | |
| | | interface Props { |
| | | disabled?: boolean |
| | | readOnly?: boolean |
| | | value?: string |
| | | placeholder?: string |
| | | height?: string |
| | | maxLength?: number |
| | | toolbarKeys?: AiEditorOptions['toolbarKeys'] |
| | | } |
| | | |
| | | const model = defineModel<string>() |
| | | |
| | | const editorRef = ref<HTMLElement | null>(null) |
| | | const editableRoot = ref<HTMLElement | null>(null) |
| | | const guardObserver = ref<MutationObserver | null>(null) |
| | | const editor = ref<AiEditorInstance | null>(null) |
| | | const lastHtml = ref('') |
| | | const currentTextCount = ref(0) |
| | | const hasMaxLengthOverflow = ref(false) |
| | | const isUnmounted = ref(false) |
| | | const isApplyingExternalContent = ref(false) |
| | | const isTruncating = ref(false) |
| | | let applyContentVersion = 0 |
| | | |
| | | const showCount = computed(() => typeof props.maxLength === 'number' && props.maxLength > 0) |
| | | const isCountOverflow = computed(() => showCount.value && hasMaxLengthOverflow.value) |
| | | |
| | | const commonUploader: Uploader = async (file) => { |
| | | const res = await uploadSingleFile(file) |
| | |
| | | return container.innerHTML |
| | | } |
| | | |
| | | function syncModelValue(html: string) { |
| | | const formattedHtml = formatTableForMiniProgram(html) |
| | | if (isApplyingExternalContent.value) { |
| | | lastHtml.value = formattedHtml |
| | | function formatRichTextList(html: string) { |
| | | if (!html) |
| | | return '' |
| | | |
| | | if (typeof document === 'undefined') |
| | | return html |
| | | |
| | | const container = document.createElement('div') |
| | | container.innerHTML = html |
| | | |
| | | const applyListStyle = (list: Element, type: 'ol' | 'ul', depth: number) => { |
| | | const baseStyle = type === 'ol' |
| | | ? `list-style-type:decimal;margin:8px 0;` |
| | | : `list-style-type:disc;margin:8px 0;` |
| | | |
| | | list.setAttribute( |
| | | 'style', |
| | | appendStyle( |
| | | removeStyleProperties(list.getAttribute('style'), ['list-style', 'list-style-type', 'padding-left', 'margin', 'margin-top', 'margin-bottom']), |
| | | baseStyle, |
| | | ), |
| | | ) |
| | | } |
| | | |
| | | const walkLists = (root: ParentNode, depth = 1) => { |
| | | Array.from(root.children).forEach((child) => { |
| | | const element = child as HTMLElement |
| | | if (element.tagName === 'OL') { |
| | | applyListStyle(element, 'ol', depth) |
| | | element.querySelectorAll(':scope > li').forEach((item) => { |
| | | const li = item as HTMLElement |
| | | li.setAttribute( |
| | | 'style', |
| | | appendStyle( |
| | | removeStyleProperties(li.getAttribute('style'), ['margin']), |
| | | 'margin:4px 0;', |
| | | ), |
| | | ) |
| | | Array.from(li.children).forEach((nested) => { |
| | | if (nested instanceof HTMLOListElement || nested instanceof HTMLUListElement) |
| | | walkLists({ children: [nested] } as unknown as ParentNode, depth + 1) |
| | | }) |
| | | }) |
| | | } |
| | | else if (element.tagName === 'UL' && element.getAttribute('data-type') !== 'taskList') { |
| | | applyListStyle(element, 'ul', depth) |
| | | element.querySelectorAll(':scope > li').forEach((item) => { |
| | | const li = item as HTMLElement |
| | | li.setAttribute( |
| | | 'style', |
| | | appendStyle( |
| | | removeStyleProperties(li.getAttribute('style'), ['margin']), |
| | | 'margin:4px 0;', |
| | | ), |
| | | ) |
| | | Array.from(li.children).forEach((nested) => { |
| | | if (nested instanceof HTMLOListElement || nested instanceof HTMLUListElement) |
| | | walkLists({ children: [nested] } as unknown as ParentNode, depth + 1) |
| | | }) |
| | | }) |
| | | } |
| | | }) |
| | | } |
| | | |
| | | walkLists(container) |
| | | |
| | | return container.innerHTML |
| | | } |
| | | |
| | | function preserveConsecutiveSpaces(html: string) { |
| | | if (!html || typeof document === 'undefined') |
| | | return html || '' |
| | | |
| | | const container = document.createElement('div') |
| | | container.innerHTML = html |
| | | |
| | | const walker = document.createTreeWalker(container, NodeFilter.SHOW_TEXT) |
| | | const textNodes: Text[] = [] |
| | | |
| | | let currentNode = walker.nextNode() |
| | | while (currentNode) { |
| | | textNodes.push(currentNode as Text) |
| | | currentNode = walker.nextNode() |
| | | } |
| | | |
| | | textNodes.forEach((node) => { |
| | | const text = node.nodeValue || '' |
| | | if (!text.includes(' ')) |
| | | return |
| | | |
| | | // Convert consecutive spaces to non-breaking spaces so backend echoes |
| | | // and mini-program rich text both preserve the original spacing. |
| | | node.nodeValue = text.replace(/ {2,}/g, spaces => '\u00A0'.repeat(spaces.length)) |
| | | }) |
| | | |
| | | return container.innerHTML |
| | | } |
| | | |
| | | function formatRichTextContent(html: string) { |
| | | return preserveConsecutiveSpaces(formatRichTextList(formatTableForMiniProgram(html))) |
| | | } |
| | | |
| | | function getTextLengthByHtml(html: string) { |
| | | if (!html) |
| | | return 0 |
| | | |
| | | if (typeof document === 'undefined') |
| | | return html.replace(/<[^>]+>/g, '').replace(/ /g, ' ').length |
| | | |
| | | const container = document.createElement('div') |
| | | container.innerHTML = html |
| | | // 必须严格使用 textContent,绝对不能用 innerText。 |
| | | // 因为 innerText 会根据 CSS 样式额外计算出换行和空格,导致总长度和底层 TreeWalker 遍历的节点长度不一致! |
| | | // 这种不一致会导致长文本截断算法失效。 |
| | | const text = container.textContent || '' |
| | | return text.replace(/[\r\n\u200B-\u200D\uFEFF]/g, '').length |
| | | } |
| | | |
| | | function truncateHtmlByTextLength(html: string, maxLength: number) { |
| | | if (!html || typeof document === 'undefined') |
| | | return html || '' |
| | | |
| | | const container = document.createElement('div') |
| | | container.innerHTML = html |
| | | |
| | | const walker = document.createTreeWalker(container, NodeFilter.SHOW_TEXT) |
| | | let currentLength = 0 |
| | | const nodesToRemove: Node[] = [] |
| | | |
| | | let currentNode = walker.nextNode() |
| | | while (currentNode) { |
| | | const nodeText = currentNode.nodeValue || '' |
| | | // 计算长度时,剔除不可见字符,避免长度计算偏大导致错误截断 |
| | | const actualText = nodeText.replace(/[\r\n\u200B-\u200D\uFEFF]/g, '') |
| | | const nodeLength = actualText.length |
| | | |
| | | if (currentLength >= maxLength) { |
| | | nodesToRemove.push(currentNode) |
| | | } |
| | | else if (currentLength + nodeLength > maxLength) { |
| | | const allowedLength = maxLength - currentLength |
| | | // 为了确保保留原有的换行格式,我们在原字符串上进行子串截取 |
| | | // 这里采用更精确的方式:逐字符遍历原字符串,直到有效字符达到 allowedLength |
| | | let sliceIndex = 0 |
| | | let validCharCount = 0 |
| | | while (sliceIndex < nodeText.length && validCharCount < allowedLength) { |
| | | const char = nodeText[sliceIndex] |
| | | if (!/[\r\n\u200B-\u200D\uFEFF]/.test(char)) { |
| | | validCharCount++ |
| | | } |
| | | sliceIndex++ |
| | | } |
| | | currentNode.nodeValue = nodeText.substring(0, sliceIndex) |
| | | currentLength += allowedLength |
| | | } |
| | | else { |
| | | currentLength += nodeLength |
| | | } |
| | | |
| | | currentNode = walker.nextNode() |
| | | } |
| | | |
| | | nodesToRemove.forEach((node) => { |
| | | const parent = node.parentNode |
| | | if (!parent) |
| | | return |
| | | |
| | | parent.removeChild(node) |
| | | |
| | | let target: Node | null = parent |
| | | while (target && target !== container && !target.textContent?.trim()) { |
| | | const ancestor = target.parentNode |
| | | if (ancestor) |
| | | ancestor.removeChild(target) |
| | | target = ancestor |
| | | } |
| | | }) |
| | | |
| | | // 如果容器内没有文本内容了,直接返回空字符串,避免残留空标签 |
| | | if (!container.textContent?.replace(/[\r\n\u200B-\u200D\uFEFF]/g, '').trim()) { |
| | | return '' |
| | | } |
| | | |
| | | return container.innerHTML |
| | | } |
| | | |
| | | function syncTextCount(html: string) { |
| | | currentTextCount.value = getTextLengthByHtml(html) |
| | | } |
| | | |
| | | function getEffectiveMaxLength() { |
| | | return typeof props.maxLength === 'number' && props.maxLength > 0 |
| | | ? props.maxLength |
| | | : undefined |
| | | } |
| | | |
| | | function syncOverflowStateByLength(length: number) { |
| | | const maxLength = getEffectiveMaxLength() |
| | | if (maxLength === undefined) { |
| | | hasMaxLengthOverflow.value = false |
| | | return |
| | | } |
| | | |
| | | if (length > maxLength) { |
| | | hasMaxLengthOverflow.value = true |
| | | return |
| | | } |
| | | |
| | | if (length < maxLength) |
| | | hasMaxLengthOverflow.value = false |
| | | } |
| | | |
| | | function getPreparedContentState(html: string) { |
| | | const formattedHtml = formatRichTextContent(html) |
| | | const maxLength = getEffectiveMaxLength() |
| | | if (maxLength === undefined) |
| | | return { html: formattedHtml, overflow: false } |
| | | |
| | | return { |
| | | html: formattedHtml, |
| | | overflow: getTextLengthByHtml(formattedHtml) > maxLength, |
| | | } |
| | | } |
| | | |
| | | function getSelectedTextLength() { |
| | | if (typeof window === 'undefined' || !editableRoot.value) |
| | | return 0 |
| | | |
| | | const selection = window.getSelection() |
| | | if (!selection || selection.rangeCount === 0) |
| | | return 0 |
| | | |
| | | // 当从外部复制富文本时,不应该算作编辑器内被选中的文本 |
| | | if (editorRef.value && !editorRef.value.contains(selection.anchorNode)) |
| | | return 0 |
| | | |
| | | const range = selection.getRangeAt(0) |
| | | const commonAncestor = range.commonAncestorContainer |
| | | if (!editableRoot.value.contains(commonAncestor)) |
| | | return 0 |
| | | |
| | | return range.toString().length |
| | | } |
| | | |
| | | function shouldBlockInsert(incomingLength: number) { |
| | | if (incomingLength <= 0) |
| | | return false |
| | | |
| | | const maxLength = getEffectiveMaxLength() |
| | | if (maxLength === undefined) |
| | | return false |
| | | |
| | | const selectedTextLength = getSelectedTextLength() |
| | | const currentLength = getTextLengthByHtml(getEditor()?.getHtml() || '') |
| | | return currentLength - selectedTextLength + incomingLength > maxLength |
| | | } |
| | | |
| | | function handleBeforeInput(event: Event) { |
| | | if (props.disabled || props.readOnly) |
| | | return |
| | | |
| | | const inputEvent = event as InputEvent |
| | | if (!inputEvent.inputType?.startsWith('insert')) |
| | | return |
| | | |
| | | // 如果是粘贴操作,交由 onChange 进行柔性截断 |
| | | if (inputEvent.inputType === 'insertFromPaste' || inputEvent.inputType === 'insertFromDrop') |
| | | return |
| | | |
| | | const incomingLength = inputEvent.data?.length ?? 0 |
| | | if (!shouldBlockInsert(incomingLength)) |
| | | return |
| | | |
| | | inputEvent.preventDefault() |
| | | hasMaxLengthOverflow.value = true |
| | | } |
| | | |
| | | function handleKeydown(event: Event) { |
| | | if (props.disabled || props.readOnly) |
| | | return |
| | | |
| | | const keyboardEvent = event as KeyboardEvent |
| | | if (keyboardEvent.isComposing || keyboardEvent.ctrlKey || keyboardEvent.metaKey || keyboardEvent.altKey) |
| | | return |
| | | |
| | | if (keyboardEvent.key.length !== 1) |
| | | return |
| | | |
| | | if (!shouldBlockInsert(keyboardEvent.key.length)) |
| | | return |
| | | |
| | | keyboardEvent.preventDefault() |
| | | hasMaxLengthOverflow.value = true |
| | | } |
| | | |
| | | function disconnectGuardObserver() { |
| | | guardObserver.value?.disconnect() |
| | | guardObserver.value = null |
| | | } |
| | | |
| | | function bindMaxLengthGuards() { |
| | | if (typeof document === 'undefined' || !editorRef.value) |
| | | return |
| | | |
| | | const root = editorRef.value.querySelector('.aie-content, [contenteditable="true"]') as HTMLElement | null |
| | | if (!root || editableRoot.value === root) |
| | | return |
| | | |
| | | unbindMaxLengthGuards() |
| | | editableRoot.value = root |
| | | editableRoot.value.addEventListener('beforeinput', handleBeforeInput, true) |
| | | editableRoot.value.addEventListener('keydown', handleKeydown, true) |
| | | disconnectGuardObserver() |
| | | } |
| | | |
| | | function unbindMaxLengthGuards() { |
| | | if (!editableRoot.value) |
| | | return |
| | | |
| | | editableRoot.value.removeEventListener('beforeinput', handleBeforeInput, true) |
| | | editableRoot.value.removeEventListener('keydown', handleKeydown, true) |
| | | editableRoot.value = null |
| | | } |
| | | |
| | | function ensureMaxLengthGuards() { |
| | | if (typeof document === 'undefined' || !editorRef.value) |
| | | return |
| | | |
| | | bindMaxLengthGuards() |
| | | if (editableRoot.value || guardObserver.value) |
| | | return |
| | | |
| | | guardObserver.value = new MutationObserver(() => { |
| | | bindMaxLengthGuards() |
| | | }) |
| | | guardObserver.value.observe(editorRef.value, { |
| | | childList: true, |
| | | subtree: true, |
| | | }) |
| | | } |
| | | |
| | | function applyFormattedValue(formattedHtml: string) { |
| | | syncTextCount(formattedHtml) |
| | | if (formattedHtml === lastHtml.value) |
| | | return |
| | | |
| | |
| | | emit('change', formattedHtml) |
| | | } |
| | | |
| | | function syncModelValue(html: string) { |
| | | const { html: formattedHtml, overflow } = getPreparedContentState(html) |
| | | syncOverflowStateByLength(getTextLengthByHtml(formattedHtml)) |
| | | if (overflow) |
| | | hasMaxLengthOverflow.value = true |
| | | if (isApplyingExternalContent.value) { |
| | | lastHtml.value = formattedHtml |
| | | syncTextCount(formattedHtml) |
| | | return |
| | | } |
| | | applyFormattedValue(formattedHtml) |
| | | } |
| | | |
| | | function destroyEditor() { |
| | | unbindMaxLengthGuards() |
| | | disconnectGuardObserver() |
| | | editor.value?.destroy() |
| | | editor.value = null |
| | | |
| | |
| | | if (!editorRef.value || editor.value || isUnmounted.value) |
| | | return |
| | | |
| | | const initialContent = formatTableForMiniProgram(content || '') |
| | | const { html: initialContent, overflow } = getPreparedContentState(content || '') |
| | | hasMaxLengthOverflow.value = overflow |
| | | |
| | | const instance = new AiEditor({ |
| | | element: editorRef.value, |
| | |
| | | video: createUploadConfig(), |
| | | attachment: createUploadConfig(), |
| | | onChange: (currentEditor) => { |
| | | syncModelValue(currentEditor.getHtml()) |
| | | if (isTruncating.value) return |
| | | |
| | | const currentHtml = currentEditor.getHtml() |
| | | const maxLength = getEffectiveMaxLength() |
| | | // 这里必须用纯文本长度判断,否则带格式的文本长度会被低估导致无法截断 |
| | | const textLength = getTextLengthByHtml(currentHtml) |
| | | |
| | | if (maxLength !== undefined && textLength > maxLength) { |
| | | // 计算出截断后的合法 HTML |
| | | const truncatedHtml = truncateHtmlByTextLength(currentHtml, maxLength) |
| | | |
| | | // 【核心修复】必须使用 setTimeout 异步更新内容。 |
| | | // 因为 ProseMirror 在处理粘贴事务时,如果同步调用 setContent 替换整个文档,会被底层引擎直接忽略! |
| | | setTimeout(() => { |
| | | isTruncating.value = true |
| | | currentEditor.setContent(truncatedHtml) |
| | | currentEditor.focusEnd() |
| | | |
| | | setTimeout(() => { |
| | | isTruncating.value = false |
| | | }, 50) |
| | | }, 0) |
| | | |
| | | hasMaxLengthOverflow.value = true |
| | | syncModelValue(truncatedHtml) |
| | | return |
| | | } |
| | | |
| | | syncOverflowStateByLength(textLength) |
| | | syncModelValue(currentHtml) |
| | | }, |
| | | onFocus: () => { |
| | | emit('focus') |
| | |
| | | |
| | | editor.value = instance |
| | | lastHtml.value = initialContent |
| | | syncTextCount(initialContent) |
| | | void nextTick(() => { |
| | | ensureMaxLengthGuards() |
| | | }) |
| | | } |
| | | |
| | | async function rebuildEditor(content: string, focus = false) { |
| | | if (isUnmounted.value) |
| | | return |
| | | |
| | | const formattedContent = formatTableForMiniProgram(content || '') |
| | | const { html: formattedContent, overflow } = getPreparedContentState(content || '') |
| | | if (formattedContent === lastHtml.value && editor.value) { |
| | | lastHtml.value = formattedContent |
| | | hasMaxLengthOverflow.value = overflow |
| | | syncTextCount(formattedContent) |
| | | return |
| | | } |
| | | |
| | |
| | | if (!isUnmounted.value && editorRef.value) |
| | | initEditor('') |
| | | lastHtml.value = '' |
| | | hasMaxLengthOverflow.value = false |
| | | syncTextCount('') |
| | | syncModelValue('') |
| | | } |
| | | |
| | |
| | | }, |
| | | ) |
| | | |
| | | watch( |
| | | () => props.maxLength, |
| | | () => { |
| | | const html = editor.value?.getHtml() || getIncomingContent() || lastHtml.value |
| | | syncTextCount(html) |
| | | syncOverflowStateByLength(getTextLengthByHtml(html)) |
| | | }, |
| | | ) |
| | | |
| | | onMounted(() => { |
| | | isUnmounted.value = false |
| | | void nextTick(() => { |
| | |
| | | class="ai-editor-host" |
| | | :style="{ minHeight: height }" |
| | | /> |
| | | <div |
| | | v-if="showCount" |
| | | class="ai-editor-count" |
| | | :class="{ 'ai-editor-count--overflow': isCountOverflow }" |
| | | > |
| | | {{ currentTextCount }} / {{ props.maxLength }} |
| | | </div> |
| | | </div> |
| | | </template> |
| | | |
| | | <style scoped> |
| | | .ai-editor-container { |
| | | position: relative; |
| | | width: 100%; |
| | | border-radius: 4px; |
| | | background: #fff; |
| | | --aie-border-color: rgba(23, 31, 38, 0.15); |
| | | --aie-text-color: #171f26; |
| | | --aie-text-placeholder-color: rgba(23, 31, 38, 0.3); |
| | | --aie-button-bg-color: #f4f6f8; |
| | | --aie-button-border-color: #f4f6f8; |
| | | --aie-button-hover-bg-color: #e9edf2; |
| | | --aie-button-hover-border-color: #e9edf2; |
| | | --aie-menus-bg-color: #f4f6f8; |
| | | --aie-menus-text-color: #74797d; |
| | | --aie-menus-svg-color: #74797d; |
| | | --aie-menus-divider-color: rgba(23, 31, 38, 0.1); |
| | | } |
| | | |
| | | .ai-editor-host { |
| | | width: 100%; |
| | | min-height: v-bind(height); |
| | | } |
| | | |
| | | .ai-editor-count { |
| | | position: absolute; |
| | | right: 12px; |
| | | bottom: 10px; |
| | | z-index: 2; |
| | | padding: 0 4px; |
| | | font-size: 12px; |
| | | line-height: 16px; |
| | | color: rgba(23, 31, 38, 0.45); |
| | | pointer-events: none; |
| | | background: rgba(255, 255, 255, 0.92); |
| | | border-radius: 2px; |
| | | } |
| | | |
| | | .ai-editor-count--overflow { |
| | | color: #d03050; |
| | | } |
| | | |
| | | :deep(aie-footer) { |
| | |
| | | } |
| | | |
| | | :deep(.aie-container) { |
| | | display: flex; |
| | | flex-direction: column; |
| | | border-radius: 4px; |
| | | overflow: hidden; |
| | | border: 1px solid rgba(23, 31, 38, 0.15); |
| | | box-sizing: border-box; |
| | | min-height: 100%; |
| | | } |
| | | |
| | | :deep(.aie-container aie-header) { |
| | | background: #f4f6f8; |
| | | border-bottom: 1px solid rgba(23, 31, 38, 0.15); |
| | | } |
| | | |
| | | :deep(.aie-container aie-header > div) { |
| | | border-bottom: none !important; |
| | | } |
| | | |
| | | :deep(.aie-container aie-header .aie-menu-item) { |
| | | color: #74797d; |
| | | } |
| | | |
| | | :deep(.aie-container aie-header .aie-menu-item svg) { |
| | | fill: #74797d; |
| | | } |
| | | |
| | | :deep(.aie-main) { |
| | | min-height: v-bind(height); |
| | | flex: 1; |
| | | min-height: v-bind(height); |
| | | overflow: auto; |
| | | } |
| | | |
| | | :deep(.aie-content) { |
| | | min-height: 100%; |
| | | padding-bottom: 28px; |
| | | box-sizing: border-box; |
| | | } |
| | | |
| | | :deep(.aie-content ul:not([data-type='taskList'])) { |
| | | list-style-type: disc; |
| | | padding-left: 20px; |
| | | margin: 8px 0; |
| | | } |
| | | |
| | | :deep(.aie-content ol) { |
| | | list-style-type: decimal; |
| | | padding-left: 20px; |
| | | margin: 8px 0; |
| | | } |
| | | |
| | | :deep(.aie-content ul:not([data-type='taskList']) > li), |
| | | :deep(.aie-content ol > li) { |
| | | list-style: inherit; |
| | | margin: 4px 0; |
| | | } |
| | | |
| | | :deep(.aie-content li > ul:not([data-type='taskList'])), |
| | | :deep(.aie-content li > ol) { |
| | | margin-top: 8px; |
| | | margin-bottom: 0; |
| | | } |
| | | </style> |