[READ-ONLY] Mirror of https://github.com/flo-bit/ui-kit. 🦊 fox ui, svelte 5 and tailwind 4 flo-bit.dev/ui-kit/
svelte tailwindcss ui-components
0

Configure Feed

Select the types of activity you want to include in your feed.

improve rich text editor

Florian (May 11, 2025, 10:56 AM +0200) 27345065 f511aa29

+428 -43
+33
pnpm-lock.yaml
··· 538 538 '@tiptap/extension-text': 539 539 specifier: ^2.12.0 540 540 version: 2.12.0(@tiptap/core@2.12.0) 541 + '@tiptap/extension-typography': 542 + specifier: ^2.12.0 543 + version: 2.12.0(@tiptap/core@2.12.0) 541 544 '@tiptap/extension-underline': 542 545 specifier: ^2.12.0 543 546 version: 2.12.0(@tiptap/core@2.12.0) 547 + '@tiptap/pm': 548 + specifier: ^2.12.0 549 + version: 2.12.0 544 550 '@tiptap/starter-kit': 545 551 specifier: ^2.12.0 546 552 version: 2.12.0 553 + '@tiptap/suggestion': 554 + specifier: ^2.12.0 555 + version: 2.12.0(@tiptap/core@2.12.0)(@tiptap/pm@2.12.0) 547 556 bits-ui: 548 557 specifier: ^1.4.3 549 558 version: 1.4.3(svelte@5.28.1) 550 559 lowlight: 551 560 specifier: ^3.3.0 552 561 version: 3.3.0 562 + state: 563 + specifier: link:@tiptap/pm/state 564 + version: link:@tiptap/pm/state 565 + tippy.js: 566 + specifier: ^6.3.7 567 + version: 6.3.7 553 568 devDependencies: 554 569 '@eslint/compat': 555 570 specifier: ^1.2.5 ··· 2122 2137 '@tiptap/core': 2.12.0(@tiptap/pm@2.12.0) 2123 2138 dev: false 2124 2139 2140 + /@tiptap/extension-typography@2.12.0(@tiptap/core@2.12.0): 2141 + resolution: {integrity: sha512-Sh95D8ON3KcGR/6wQ1B2PwVQNQXSHItQFodDAEgT9c6CCPzrz0omLWJvG7lx7NzMoZ7cvPE/BBoEujsDIT1XRA==, tarball: https://registry.npmjs.org/@tiptap/extension-typography/-/extension-typography-2.12.0.tgz} 2142 + peerDependencies: 2143 + '@tiptap/core': ^2.7.0 2144 + dependencies: 2145 + '@tiptap/core': 2.12.0(@tiptap/pm@2.12.0) 2146 + dev: false 2147 + 2125 2148 /@tiptap/extension-underline@2.12.0(@tiptap/core@2.12.0): 2126 2149 resolution: {integrity: sha512-u95lrUCesw1SN3BXY4xrgfSuxtoCYmJ9uaU7IVVOu0zVsDFtLlOa82kd63KVF+URL0kMdO+FBmvdS6d8Era70Q==, tarball: https://registry.npmjs.org/@tiptap/extension-underline/-/extension-underline-2.12.0.tgz} 2127 2150 peerDependencies: ··· 2176 2199 '@tiptap/extension-strike': 2.12.0(@tiptap/core@2.12.0) 2177 2200 '@tiptap/extension-text': 2.12.0(@tiptap/core@2.12.0) 2178 2201 '@tiptap/extension-text-style': 2.12.0(@tiptap/core@2.12.0) 2202 + '@tiptap/pm': 2.12.0 2203 + dev: false 2204 + 2205 + /@tiptap/suggestion@2.12.0(@tiptap/core@2.12.0)(@tiptap/pm@2.12.0): 2206 + resolution: {integrity: sha512-bsXLoZbjUo1oOF1Z+XSfoGzbcnrTcYtJdfylM/FerMLU9T12dhsM/Ri2SKLX4IR5D0HJ07FcsEHCrGEy8Y5y0A==, tarball: https://registry.npmjs.org/@tiptap/suggestion/-/suggestion-2.12.0.tgz} 2207 + peerDependencies: 2208 + '@tiptap/core': ^2.7.0 2209 + '@tiptap/pm': ^2.7.0 2210 + dependencies: 2211 + '@tiptap/core': 2.12.0(@tiptap/pm@2.12.0) 2179 2212 '@tiptap/pm': 2.12.0 2180 2213 dev: false 2181 2214
+5
.changeset/neat-years-sit.md
··· 1 + --- 2 + '@fuxui/text': patch 3 + --- 4 + 5 + improve rich text editor
+6 -1
packages/text/package.json
··· 81 81 "@tiptap/extension-placeholder": "^2.12.0", 82 82 "@tiptap/extension-strike": "^2.12.0", 83 83 "@tiptap/extension-text": "^2.12.0", 84 + "@tiptap/extension-typography": "^2.12.0", 84 85 "@tiptap/extension-underline": "^2.12.0", 86 + "@tiptap/pm": "^2.12.0", 85 87 "@tiptap/starter-kit": "^2.12.0", 88 + "@tiptap/suggestion": "^2.12.0", 86 89 "bits-ui": "^1.4.3", 87 - "lowlight": "^3.3.0" 90 + "lowlight": "^3.3.0", 91 + "state": "link:@tiptap/pm/state", 92 + "tippy.js": "^6.3.7" 88 93 }, 89 94 "peerDependencies": { 90 95 "svelte": ">=5",
+15 -2
packages/text/src/lib/components/rich-text-editor/Icon.svelte
··· 1 1 <script lang="ts"> 2 - import type { RichTextTypes } from "."; 2 + import type { RichTextTypes } from '.'; 3 3 4 4 let { 5 5 name 6 6 }: { 7 - name: RichTextTypes 7 + name: RichTextTypes; 8 8 } = $props(); 9 9 </script> 10 10 ··· 103 103 ><path d="M10 12h11" /><path d="M10 18h11" /><path d="M10 6h11" /><path d="M4 10h2" /><path 104 104 d="M4 6h1v4" 105 105 /><path d="M6 18H4c0-1 2-2 2-3s-1-1.5-2-1" /></svg 106 + > 107 + {:else if name === 'image'} 108 + <svg 109 + xmlns="http://www.w3.org/2000/svg" 110 + viewBox="0 0 24 24" 111 + fill="none" 112 + stroke="currentColor" 113 + stroke-width="2" 114 + stroke-linecap="round" 115 + stroke-linejoin="round" 116 + ><rect width="18" height="18" x="3" y="3" rx="2" ry="2" /><circle cx="9" cy="9" r="2" /><path 117 + d="m21 15-3.086-3.086a2 2 0 0 0-2.828 0L6 21" 118 + /></svg 106 119 > 107 120 {/if}
+64 -10
packages/text/src/lib/components/rich-text-editor/RichTextEditor.svelte
··· 12 12 import RichTextEditorMenu from './RichTextEditorMenu.svelte'; 13 13 import type { RichTextTypes } from '.'; 14 14 import RichTextEditorLinkMenu from './RichTextEditorLinkMenu.svelte'; 15 + import Slash, { suggestion } from './slash-menu'; 16 + import Typography from '@tiptap/extension-typography' 15 17 16 18 import './code.css'; 17 19 18 20 let { 19 21 content = $bindable({}), 20 - placeholder = 'Write something ...', 22 + placeholder = 'Write or press / for commands', 21 23 editor = $bindable(null), 22 24 ref = $bindable(null) 23 25 }: { ··· 57 59 levels: [1, 2, 3] 58 60 } 59 61 }), 60 - Placeholder.configure({ placeholder }), 61 - // Only use CustomImage, not the standard Image extension 62 + Placeholder.configure({ 63 + placeholder: ({ node }) => { 64 + // only show in paragraphs 65 + if (node.type.name === 'paragraph' || node.type.name === 'heading') { 66 + return placeholder; 67 + } 68 + return ''; 69 + } 70 + }), 62 71 Image.configure({ 63 72 HTMLAttributes: { 64 73 class: 'max-w-full object-contain relative rounded-2xl' ··· 94 103 openOnClick: false, 95 104 autolink: true, 96 105 defaultProtocol: 'https' 97 - }) 106 + }), 107 + Slash.configure({ 108 + suggestion: suggestion({ 109 + char: '/', 110 + pluginKey: 'slash', 111 + switchTo, 112 + processImageFile 113 + }) 114 + }), 115 + Typography.configure() 98 116 ]; 99 117 100 118 editor = new Editor({ ··· 274 292 }, 100); 275 293 } 276 294 } 295 + 296 + function switchTo(value: RichTextTypes) { 297 + editor?.chain().focus().setParagraph().run(); 298 + 299 + if (value === 'heading-1') { 300 + editor?.chain().focus().setNode('heading', { level: 1 }).run(); 301 + } else if (value === 'heading-2') { 302 + editor?.chain().focus().setNode('heading', { level: 2 }).run(); 303 + } else if (value === 'heading-3') { 304 + editor?.chain().focus().setNode('heading', { level: 3 }).run(); 305 + } else if (value === 'blockquote') { 306 + editor?.chain().focus().setBlockquote().run(); 307 + } else if (value === 'code') { 308 + editor?.chain().focus().setCodeBlock().run(); 309 + } else if (value === 'bullet-list') { 310 + editor?.chain().focus().toggleBulletList().run(); 311 + } else if (value === 'ordered-list') { 312 + editor?.chain().focus().toggleOrderedList().run(); 313 + } 314 + } 277 315 </script> 278 316 279 317 <div ··· 297 335 {isImage} 298 336 {clickedLink} 299 337 {processImageFile} 338 + {switchTo} 300 339 bind:selectedType 301 340 /> 302 341 303 - <RichTextEditorLinkMenu 304 - bind:ref={menuLink} 305 - {editor} 306 - bind:link 307 - /> 308 - 342 + <RichTextEditorLinkMenu bind:ref={menuLink} {editor} bind:link bind:linkInput /> 309 343 310 344 <style> 311 345 :global(.tiptap) { ··· 323 357 outline: 3px solid var(--color-accent-500); 324 358 } 325 359 } 360 + 361 + :global(blockquote p:first-of-type::before) { 362 + content: none; 363 + } 364 + 365 + :global(blockquote p:last-of-type::after) { 366 + content: none; 367 + } 368 + 369 + :global(blockquote p) { 370 + font-style: normal; 371 + } 372 + } 373 + 374 + :global(.tiptap .is-empty::before) { 375 + color: var(--color-base-500); 376 + content: attr(data-placeholder); 377 + float: left; 378 + height: 0; 379 + pointer-events: none; 326 380 } 327 381 </style>
+13 -9
packages/text/src/lib/components/rich-text-editor/RichTextEditorLinkMenu.svelte
··· 5 5 let { 6 6 editor, 7 7 link = $bindable(''), 8 - ref = $bindable(null) 8 + ref = $bindable(null), 9 + linkInput = $bindable(null) 9 10 }: { 10 11 editor: Editor | null; 11 12 link: string; 12 13 ref: HTMLElement | null; 14 + linkInput: HTMLInputElement | null; 13 15 } = $props(); 14 16 15 - let linkInput: HTMLInputElement | null = $state(null); 17 + function processLink(link: string) { 18 + return link.includes(':') ? link : `http://${link}`; 19 + } 16 20 </script> 17 21 18 22 <div ··· 27 31 placeholder="Enter link" 28 32 onblur={() => { 29 33 if (link === '') { 30 - editor?.chain().focus().unsetLink().run(); 34 + editor?.chain().focus().extendMarkRange('link').unsetLink().run(); 31 35 } else { 32 - editor?.chain().focus().setLink({ href: link }).run(); 36 + editor?.chain().focus().extendMarkRange('link').setLink({ href: processLink(link) }).run(); 33 37 } 34 38 }} 35 39 onkeydown={(e: KeyboardEvent) => { 36 40 if (e.key === 'Enter') { 37 41 if (link === '') { 38 - editor?.chain().focus().unsetLink().run(); 42 + editor?.chain().focus().extendMarkRange('link').unsetLink().run(); 39 43 } else { 40 - editor?.chain().focus().setLink({ href: link }).run(); 44 + editor?.chain().focus().extendMarkRange('link').setLink({ href: processLink(link) }).run(); 41 45 } 42 46 } 43 47 }} ··· 46 50 size="iconSm" 47 51 onclick={() => { 48 52 if (link === '') { 49 - editor?.chain().focus().unsetLink().run(); 53 + editor?.chain().focus().extendMarkRange('link').unsetLink().run(); 50 54 } else { 51 - editor?.chain().focus().setLink({ href: link }).run(); 55 + editor?.chain().focus().extendMarkRange('link').setLink({ href: processLink(link) }).run(); 52 56 } 53 57 }} 54 58 > ··· 68 72 <Button 69 73 size="iconSm" 70 74 onclick={() => { 71 - editor?.chain().focus().unsetLink().run(); 75 + editor?.chain().focus().extendMarkRange('link').unsetLink().run(); 72 76 }} 73 77 variant="ghost" 74 78 >
+5 -20
packages/text/src/lib/components/rich-text-editor/RichTextEditorMenu.svelte
··· 15 15 clickedLink, 16 16 selectedType = $bindable('paragraph'), 17 17 ref = $bindable(null), 18 - processImageFile 18 + processImageFile, 19 + switchTo 19 20 }: { 20 21 editor: Editor | null; 21 22 isBold: boolean; ··· 28 29 selectedType: RichTextTypes; 29 30 ref: HTMLElement | null; 30 31 processImageFile: (file: File, input: HTMLInputElement) => void; 32 + switchTo: (value: RichTextTypes) => void; 31 33 } = $props(); 32 34 33 35 $inspect(isBold); ··· 41 43 } 42 44 43 45 let fileInput = $state<HTMLInputElement | null>(null); 46 + 44 47 </script> 45 48 46 49 <div ··· 49 52 > 50 53 <Select 51 54 onValueChange={(value) => { 52 - editor?.chain().focus().unsetAllMarks().run(); 53 - 54 - if (value === 'heading-1') { 55 - editor?.chain().focus().setNode('heading', { level: 1 }).run(); 56 - } else if (value === 'heading-2') { 57 - editor?.chain().focus().setNode('heading', { level: 2 }).run(); 58 - } else if (value === 'heading-3') { 59 - editor?.chain().focus().setNode('heading', { level: 3 }).run(); 60 - } else if (value === 'blockquote') { 61 - editor?.chain().focus().setBlockquote().run(); 62 - } else if (value === 'code') { 63 - editor?.chain().focus().setCodeBlock().run(); 64 - } else if (value === 'bullet-list') { 65 - editor?.chain().focus().toggleBulletList().run(); 66 - } else if (value === 'ordered-list') { 67 - editor?.chain().focus().toggleOrderedList().run(); 68 - } else if (value === 'paragraph') { 69 - editor?.chain().focus().setParagraph().run(); 70 - } 55 + switchTo(value as RichTextTypes); 71 56 }} 72 57 type="single" 73 58 items={[
+88
packages/text/src/lib/components/rich-text-editor/slash-menu/SuggestionSelect.svelte
··· 1 + <script lang="ts"> 2 + import { cn } from '@fuxui/base'; 3 + import type { Editor, Range } from '@tiptap/core'; 4 + import Icon from '../Icon.svelte'; 5 + import type { RichTextTypes } from '..'; 6 + 7 + type Props = { 8 + items: { 9 + value: RichTextTypes; 10 + label: string; 11 + command: ({ editor, range }: { editor: Editor; range: Range }) => void; 12 + }[]; 13 + range: Range; 14 + editor: Editor; 15 + active?: number; 16 + }; 17 + 18 + let { items, range, editor, active }: Props = $props(); 19 + 20 + let activeIndex = $state(active ?? 0); 21 + 22 + export function setItems(value: any[]) { 23 + items = value; 24 + } 25 + 26 + export function setRange(value: Range) { 27 + range = value; 28 + } 29 + 30 + export function onKeyDown(event: KeyboardEvent) { 31 + if (event.repeat) { 32 + return false; 33 + } 34 + switch (event.key) { 35 + case 'ArrowUp': { 36 + if (activeIndex <= 0) { 37 + activeIndex = items.length - 1; 38 + } else { 39 + activeIndex--; 40 + } 41 + return true; 42 + } 43 + case 'ArrowDown': { 44 + if (activeIndex >= items.length - 1) { 45 + activeIndex = 0; 46 + } else { 47 + activeIndex++; 48 + } 49 + return true; 50 + } 51 + case 'Enter': { 52 + const selected = items[activeIndex]; 53 + 54 + if (selected) { 55 + selected.command({ editor, range }); 56 + return true; 57 + } 58 + } 59 + } 60 + 61 + return false; 62 + } 63 + </script> 64 + 65 + <menu 66 + class={cn( 67 + 'bg-base-50/50 border-base-500/20 overflow-hidden rounded-2xl border shadow-lg backdrop-blur-xl', 68 + 'dark:bg-base-900/50 dark:border-base-500/10', 69 + 'motion-safe:animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2', 70 + "divide-base-300/30 dark:divide-base-800 divide-y text-sm" 71 + )} 72 + > 73 + 74 + {#each items as item, index} 75 + <button 76 + onclick={() => item.command({ editor, range })} 77 + class={cn( 78 + 'text-base-900 dark:text-base-200 group relative isolate flex min-w-28 w-full cursor-pointer items-center gap-3 px-3 py-2 font-medium [&_svg]:size-3.5', 79 + activeIndex === index 80 + ? 'text-accent-700 dark:text-accent-400 bg-accent-500/10' 81 + : 'hover:bg-accent-500/10' 82 + )} 83 + > 84 + <Icon name={item.value} /> 85 + {item.label} 86 + </button> 87 + {/each} 88 + </menu>
+195
packages/text/src/lib/components/rich-text-editor/slash-menu/index.ts
··· 1 + import { Extension } from '@tiptap/core'; 2 + import Suggestion from '@tiptap/suggestion'; 3 + import type { Editor, Range } from '@tiptap/core'; 4 + import { PluginKey } from '@tiptap/pm/state'; 5 + import type { SuggestionKeyDownProps, SuggestionProps } from '@tiptap/suggestion'; 6 + import SuggestionSelect from './SuggestionSelect.svelte'; 7 + import { mount, unmount } from 'svelte'; 8 + import tippy, { type GetReferenceClientRect } from 'tippy.js'; 9 + import type { RichTextTypes } from '..'; 10 + 11 + export default Extension.create({ 12 + name: 'slash', 13 + 14 + addOptions() { 15 + return { 16 + suggestion: { 17 + char: '/', 18 + // eslint-disable-next-line @typescript-eslint/no-explicit-any 19 + command: ({ editor, range, props }: { editor: Editor; range: Range; props: any }) => { 20 + props.command({ editor, range }); 21 + } 22 + } 23 + }; 24 + }, 25 + 26 + addProseMirrorPlugins() { 27 + return [ 28 + Suggestion({ 29 + editor: this.editor, 30 + ...this.options.suggestion 31 + }) 32 + ]; 33 + } 34 + }); 35 + 36 + export function suggestion({ 37 + char, 38 + pluginKey, 39 + switchTo, 40 + processImageFile 41 + }: { 42 + char: string; 43 + pluginKey: string; 44 + switchTo: (value: RichTextTypes) => void; 45 + processImageFile: (file: File) => void; 46 + }) { 47 + return { 48 + char, 49 + pluginKey: new PluginKey(pluginKey), 50 + 51 + items: ({ query }: { query: string }) => { 52 + return [ 53 + { 54 + value: 'paragraph', 55 + label: 'Paragraph', 56 + command: ({ editor, range }: { editor: Editor; range: Range }) => { 57 + editor.chain().focus().deleteRange(range).run(); 58 + switchTo('paragraph'); 59 + } 60 + }, 61 + { 62 + value: 'heading-1', 63 + label: 'Heading 1', 64 + command: ({ editor, range }: { editor: Editor; range: Range }) => { 65 + editor.chain().focus().deleteRange(range).run(); 66 + switchTo('heading-1'); 67 + } 68 + }, 69 + { 70 + value: 'heading-2', 71 + label: 'Heading 2', 72 + command: ({ editor, range }: { editor: Editor; range: Range }) => { 73 + editor.chain().focus().deleteRange(range).run(); 74 + switchTo('heading-2'); 75 + } 76 + }, 77 + { 78 + value: 'heading-3', 79 + label: 'Heading 3', 80 + command: ({ editor, range }: { editor: Editor; range: Range }) => { 81 + editor.chain().focus().deleteRange(range).run(); 82 + switchTo('heading-3'); 83 + } 84 + }, 85 + { 86 + value: 'blockquote', 87 + label: 'Blockquote', 88 + command: ({ editor, range }: { editor: Editor; range: Range }) => { 89 + editor.chain().focus().deleteRange(range).run(); 90 + switchTo('blockquote'); 91 + } 92 + }, 93 + { 94 + value: 'code', 95 + label: 'Code Block', 96 + command: ({ editor, range }: { editor: Editor; range: Range }) => { 97 + editor.chain().focus().deleteRange(range).run(); 98 + switchTo('code'); 99 + } 100 + }, 101 + { 102 + value: 'bullet-list', 103 + label: 'Bullet List', 104 + command: ({ editor, range }: { editor: Editor; range: Range }) => { 105 + editor.chain().focus().deleteRange(range).run(); 106 + switchTo('bullet-list'); 107 + } 108 + }, 109 + { 110 + value: 'ordered-list', 111 + label: 'Ordered List', 112 + command: ({ editor, range }: { editor: Editor; range: Range }) => { 113 + editor.chain().focus().deleteRange(range).run(); 114 + switchTo('ordered-list'); 115 + } 116 + }, 117 + { 118 + value: 'image', 119 + label: 'Add Image', 120 + command: ({ editor, range }: { editor: Editor; range: Range }) => { 121 + editor.chain().focus().deleteRange(range).run(); 122 + 123 + const fileInput = document.createElement('input'); 124 + fileInput.type = 'file'; 125 + fileInput.click(); 126 + fileInput.addEventListener('change', (event) => { 127 + const input = event.target as HTMLInputElement; 128 + if (!input.files?.length) return; 129 + const file = input.files[0]; 130 + if (!file?.type.startsWith('image/')) return; 131 + processImageFile(file); 132 + 133 + input.remove(); 134 + }); 135 + } 136 + } 137 + ].filter((item) => item.label.toLowerCase().includes(query.toLowerCase())); 138 + }, 139 + 140 + render: () => { 141 + let component: ReturnType<typeof SuggestionSelect>; 142 + let popup: ReturnType<typeof tippy>; 143 + 144 + return { 145 + onStart: (props: SuggestionProps) => { 146 + const element = document.createElement('div'); 147 + 148 + component = mount(SuggestionSelect, { 149 + target: element, 150 + props 151 + }); 152 + 153 + if (!props.clientRect) { 154 + return; 155 + } 156 + 157 + popup = tippy('body', { 158 + getReferenceClientRect: props.clientRect as GetReferenceClientRect, 159 + appendTo: () => document.body, 160 + content: element, 161 + showOnCreate: true, 162 + interactive: true, 163 + trigger: 'manual', 164 + placement: 'bottom-start' 165 + }); 166 + }, 167 + onUpdate: (props: SuggestionProps) => { 168 + component.setItems(props.items); 169 + component.setRange(props.range); 170 + 171 + if (!props.clientRect) { 172 + return; 173 + } 174 + 175 + popup[0].setProps({ 176 + getReferenceClientRect: props.clientRect as GetReferenceClientRect 177 + }); 178 + }, 179 + onKeyDown: (props: SuggestionKeyDownProps) => { 180 + if (props.event.key === 'Escape') { 181 + popup[0].hide(); 182 + 183 + return true; 184 + } 185 + 186 + return component.onKeyDown(props.event); 187 + }, 188 + onExit: () => { 189 + popup[0].destroy(); 190 + unmount(component); 191 + } 192 + }; 193 + } 194 + }; 195 + }
+4 -1
apps/docs/src/routes/(main)/components/text/rich-text-editor/Example.svelte
··· 1 1 <script lang="ts"> 2 + import { Prose } from '@fuxui/base'; 2 3 import { RichTextEditor } from '@fuxui/text'; 3 4 4 5 let content = $state(''); ··· 7 8 <div 8 9 class="border-base-300/50 bg-base-300/20 dark:border-base-800/50 dark:bg-base-900/30 text-base-900 dark:text-base-100 relative isolate min-h-24 w-full rounded-2xl border p-4 backdrop-blur-2xl" 9 10 > 10 - <RichTextEditor bind:content placeholder="Write something..." /> 11 + <Prose size="md"> 12 + <RichTextEditor bind:content /> 13 + </Prose> 11 14 </div>